diff --git a/AGENTS.md b/AGENTS.md
index 492cda3..21575c9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,5 +1,7 @@
# SGF Tempo — instruções locais
+- Em 26/10/2026, o utilizador aceitou o cálculo condicionado e autorizou o registo e checkpoint Git exclusivamente local, sem credenciais/dados locais. Ver `docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md`. Perfil, concessões e activação local foram objecto de confirmações específicas; os detalhes nominativos permanecem na ficha local não versionada. Exigibilidade e período de apuramento permanecem pendentes; aprovação temporal não os determina. Preservar versões/âmbito exactos, separação de proponente/validador, B1 e aprovações contabilísticas. Calcular não altera decisão temporal validada. Sem avançar para emissão documental, recebimentos, lançamentos ou publicação.
+
- Em 26/10/2026, o utilizador aceitou expressamente o cálculo de ensaio demonstrado (base 1000,03; IVA exacto 160,0048; IVA proposto 160,00; diferença preservada 0,0048; total proposto 1160,03) e autorizou registo e checkpoint Git apenas local. Ver `docs/SERVICE_CALCULATION_SEPARATE_REVIEW.md`. Esta confirmação substitui apenas a pendência de aceitação do ensaio: a aplicabilidade fiscal real permanece separada e pendente, sem autorização para activar perfil fiscal real, emitir, contabilizar ou publicar.
- 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.
diff --git a/README.md b/README.md
index 07b9240..d8cecc0 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,8 @@ Não existe criação de tenants ou empresas pela interface, auto-registo, opç
## Testes e ferramentas
+O incremento de cálculo condicionado foi aceite; ver [entrega, verificações e limites](docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md). Na empresa/unidade autorizadas, consultar **Perfil fiscal do serviço** e **Rascunhos de facturação → Pré-visualizar rascunho**. A activação é uma operação local especificamente autorizada, sem via no navegador. O cálculo exige decisão temporal aprovada e compatível; quando há pendência, não determina exigibilidade ou período de apuramento. Sem emissão, recebimentos ou lançamentos.
+
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 test
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 npm run build
diff --git a/app/Modules/Services/DocumentPreview.php b/app/Modules/Services/DocumentPreview.php
index 791e826..edc65ac 100644
--- a/app/Modules/Services/DocumentPreview.php
+++ b/app/Modules/Services/DocumentPreview.php
@@ -105,6 +105,7 @@ final class DocumentPreview
'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),
+ 'fiscal_history' => $this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'service_fiscal.view') ? app(ServiceFiscal::class)->history($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']]];
diff --git a/app/Modules/Services/Http/ServiceFiscalController.php b/app/Modules/Services/Http/ServiceFiscalController.php
new file mode 100644
index 0000000..aa95949
--- /dev/null
+++ b/app/Modules/Services/Http/ServiceFiscalController.php
@@ -0,0 +1,16 @@
+json($data)->header('Cache-Control','no-store, private'); }
+ public function index(Request $r,ServiceFiscal $f) { return $this->response($f->listing($r->user()->id)); }
+ public function propose(Request $r,ServiceFiscal $f) { return $this->response($f->propose($r->user()->id,$r->all())); }
+ public function decide(Request $r,int $id,ServiceFiscal $f) { return $this->response($f->decide($id,$r->user()->id,$r->all())); }
+ public function calculate(Request $r,int $id,ServiceFiscal $f) { return $this->response($f->calculate($id,$r->user()->id,$r->all())); }
+ public function history(Request $r,int $id,ServiceFiscal $f) { return $this->response(['history'=>$f->history($id,$r->user()->id)]); }
+}
diff --git a/app/Modules/Services/LocalFiscalActivation.php b/app/Modules/Services/LocalFiscalActivation.php
new file mode 100644
index 0000000..7fd7313
--- /dev/null
+++ b/app/Modules/Services/LocalFiscalActivation.php
@@ -0,0 +1,52 @@
+runningInConsole(),403,'Operação exclusivamente no terminal local.');
+ if (ServiceCalculation::isolated()) TestingDatabaseGuard::assertSafe();
+ else abort_unless(LocalDatabaseGuard::allows(),403,'Base própria de desenvolvimento local obrigatória.');
+ $rules=['proposal_id'=>'required|integer|min:1','version'=>'required|integer|min:1','identifier'=>'required|string','tenant_id'=>'required|integer|min:1','company_id'=>'required|integer|min:1','unit_id'=>'required|integer|min:1','service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','operator_id'=>'required|integer|min:1','definition_hash'=>($apply?'required':'nullable').'|string|regex:/^[a-f0-9]{64}$/D'];
+ abort_if(array_diff(array_keys($input),array_keys($rules))!==[],422);
+ $in=Validator::make($input,$rules)->validate();
+ return DB::transaction(function() use($in,$apply) {
+ $scope=array_intersect_key($in,array_flip(['tenant_id','company_id','unit_id']));
+ DB::table('companies')->where('id',$scope['company_id'])->where('tenant_id',$scope['tenant_id'])->lockForUpdate()->firstOrFail();
+ app(CompanyContext::class)->selectFor((int)$in['operator_id'],(int)$scope['tenant_id'],(int)$scope['company_id'],(int)$scope['unit_id']);
+ app(\App\Modules\Identity\Services\Permissions::class)->require((int)$in['operator_id'],(int)$scope['tenant_id'],(int)$scope['company_id'],'service_fiscal.view');
+ $p=DB::table('sf_proposals')->where($scope)->where('id',$in['proposal_id'])->where('kind','profile')->where('identifier',$in['identifier'])->where('version',$in['version'])->first();
+ abort_unless($p,409,'Perfil, versão ou âmbito diferente da operação autorizada.');
+ $d=json_decode($p->data,true,32,JSON_THROW_ON_ERROR);
+ foreach(['service_id','service_version'] as $key) abort_unless((int)$d[$key]===(int)$in[$key],409,'Serviço/versão diferente.');
+ abort_unless($d['valid_from']===$in['valid_from'] && ($d['valid_until'] ?? null)===($in['valid_until'] ?? null),409,'Vigência diferente.');
+ abort_unless($d['currency']==='MZN' && $d['price_mode']==='added' && $d['rate_percent']==='16' && $d['treatment']==='domestic_normal',409,'Tratamento diferente do recorte aprovado.');
+ abort_unless(DB::table('a_services')->where('id',$in['service_id'])->where('tenant_id',$scope['tenant_id'])->where('company_id',$scope['company_id'])->where('version',$in['service_version'])->exists(),409,'Serviço desactualizado.');
+ abort_unless(DB::table('sf_decisions')->where('proposal_id',$p->id)->where('decision','approved')->exists(),409,'Aprovação exacta em falta.');
+ abort_if(DB::table('sf_proposals')->where($scope)->where('kind','profile')->where('identifier',$p->identifier)->where('version','>',$p->version)->whereExists(fn($q)=>$q->selectRaw('1')->from('sf_decisions')->whereColumn('sf_decisions.proposal_id','sf_proposals.id')->where('decision','approved'))->exists(),409,'Outra versão já foi aprovada; reveja a operação.');
+ $profile=DB::table('service_calculation_profiles')->where($scope)->where('id',$p->profile_id)->where('identifier',$p->identifier)->where('version',$p->version)->first();
+ abort_unless($profile,409,'Perfil de cálculo incompatível.');
+ $definition=json_decode($profile->definition,true,32,JSON_THROW_ON_ERROR);
+ abort_unless(($definition['kind'] ?? null)==='real' && ($definition['policy'] ?? null)===ServiceCalculation::POLICY,409,'Política/perfil incompatível.');
+ foreach($d as $key=>$value) abort_unless(($definition[$key] ?? null)===$value,409,'Definição diverge da proposta aprovada.');
+ $hash=hash('sha256',json_encode([$p->id,(int)$p->version,$scope,$d,$definition],JSON_THROW_ON_ERROR));
+ if($apply) abort_unless(hash_equals($hash,$in['definition_hash']),409,'A definição não corresponde à operação revista.');
+ $last=DB::table('sf_activation_events')->where('proposal_id',$p->id)->orderByDesc('id')->first();
+ $active=(bool)($last?->active ?? false);
+ $result=['proposal_id'=>$p->id,'identifier'=>$p->identifier,'version'=>(int)$p->version,'scope'=>$scope,'service_id'=>(int)$d['service_id'],'service_version'=>(int)$d['service_version'],'valid_from'=>$d['valid_from'],'valid_until'=>$d['valid_until'] ?? null,'definition_hash'=>$hash,'active'=>$active,'applied'=>false];
+ if(!$apply || $active) return $result+['replayed'=>$apply && $active];
+ $event=DB::table('sf_activation_events')->insertGetId(['proposal_id'=>$p->id,'actor_id'=>$in['operator_id'],'active'=>true,'reason'=>'Operação local especificamente autorizada pelo utilizador para este perfil/versão/âmbito; apenas cálculo proposto, sem emissão, recebimentos ou lançamentos. Definição SHA256: '.$hash,'created_at'=>now()]);
+ Audit::record('service_fiscal.local_activated',(int)$in['operator_id'],(int)$scope['tenant_id'],(int)$scope['company_id'],(int)$scope['unit_id'],details:['record_id'=>$p->id,'version'=>(int)$p->version,'event_id'=>$event,'reason'=>'Activação local especificamente autorizada; sem poderes de emissão ou lançamentos.']);
+ return array_replace($result,['active'=>true,'applied'=>true,'event_id'=>$event,'replayed'=>false]);
+ });
+ }
+}
diff --git a/app/Modules/Services/ServiceCalculation.php b/app/Modules/Services/ServiceCalculation.php
index 2691e4c..6981202 100644
--- a/app/Modules/Services/ServiceCalculation.php
+++ b/app/Modules/Services/ServiceCalculation.php
@@ -18,6 +18,15 @@ final class ServiceCalculation
public static function isolated(): bool { return app()->environment(['testing','browser-testing']); }
+ public static function evaluateReal(array $data, array $prepared, array $profile): array
+ {
+ abort_unless(($profile['kind'] ?? null)==='real',422,'Perfil real identificado obrigatório.');
+ // Shared decimal arithmetic; authority/applicability is checked by ServiceFiscal before calling.
+ $result=self::evaluate($data,$prepared,array_replace($profile,['kind'=>'synthetic']));
+ if ($result['state']!=='pending') $result['state']='real_proposed_conditioned';
+ return $result;
+ }
+
public static function evaluate(array $data, array $prepared, ?array $profile): array
{
$pending = [];
@@ -97,14 +106,15 @@ final class ServiceCalculation
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.']]; }
+ if (!self::isolated()) { return ['environment'=>'normal','history'=>[], 'pending'=>['O cálculo proposto requer perfil aprovado, activo e compatível e decisão temporal aprovada. Consulte o resultado e as pendências abaixo, quando disponíveis.']]; }
TestingDatabaseGuard::assertSafe();
[$b,$p] = $this->sources($draft,$actor);
- $profiles = DB::table('service_calculation_profiles')->where($scope)->orderByDesc('version')->get();
+ $profiles = DB::table('service_calculation_profiles')->where($scope)->whereRaw("definition->>'kind' = ?",['synthetic'])->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);
+ if (isset($snapshot['input']['profile_proposal_id'])) continue;
$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];
diff --git a/app/Modules/Services/ServiceFiscal.php b/app/Modules/Services/ServiceFiscal.php
new file mode 100644
index 0000000..0cc2a77
--- /dev/null
+++ b/app/Modules/Services/ServiceFiscal.php
@@ -0,0 +1,181 @@
+context->identifiers(); $unit=$this->context->unitId();
+ abort_unless($actor===$this->context->userId() && $unit!==null,403,'Seleccione uma unidade autorizada.');
+ $this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'unit.view',$unit);
+ foreach (array_unique(['view',$action]) as $a) $this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'service_fiscal.'.$a,$a==='calculate' ? $unit : null);
+ return $s+['unit_id'=>$unit];
+ }
+ private function command(int $actor,string $action,callable $fn): array
+ {
+ $s=$this->scope($actor,$action);
+ return DB::transaction(function() use($actor,$action,$fn,$s) {
+ DB::table('companies')->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
+ $this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']); $this->scope($actor,$action);
+ return $fn($s);
+ });
+ }
+ private function row(int $id,array $s): object
+ {
+ $r=DB::table('sf_proposals')->where($s)->where('id',$id)->first(); abort_unless($r,403,'Proposta fora do âmbito.'); return $r;
+ }
+ private function data(object $r): array { return json_decode($r->data,true,32,JSON_THROW_ON_ERROR); }
+ private function decision(int $id): ?object { return DB::table('sf_decisions')->where('proposal_id',$id)->first(); }
+ private function approved(object $r): bool { return $this->decision($r->id)?->decision==='approved'; }
+ private function activated(object $r): bool { return (bool)(DB::table('sf_activation_events')->where('proposal_id',$r->id)->orderByDesc('id')->first()?->active ?? false); }
+ private function latest(object $r,array $s,?string $date=null): bool
+ {
+ // Pending/refused proposals do not supersede a currently approved version.
+ $q=DB::table('sf_proposals')->where($s)->where('kind',$r->kind)->where('identifier',$r->identifier)->where('version','>',$r->version)
+ ->whereExists(fn($q)=>$q->selectRaw('1')->from('sf_decisions')->whereColumn('sf_decisions.proposal_id','sf_proposals.id')->where('decision','approved'));
+ if($r->kind==='profile' && $date!==null) $q->whereRaw("data->>'valid_from' <= ?",[$date]);
+ return !$q->exists();
+ }
+ private function exact(array $data,array $rules): array
+ {
+ abort_if(array_diff(array_keys($data),array_keys($rules))!==[],422,'Campo protegido ou fora do âmbito fiscal.');
+ return Validator::make($data,$rules)->validate();
+ }
+ public function listing(int $actor): array
+ {
+ $s=$this->scope($actor);
+ if (!\Illuminate\Support\Facades\Schema::hasTable('sf_proposals')) return ['migration_pending'=>true,'proposals'=>[]];
+ $rows=DB::table('sf_proposals')->where($s)->orderByDesc('id')->get()->map(function($r) { return (array)$r+['definition'=>$this->data($r),'decision'=>$this->decision($r->id),'active'=>$r->kind==='profile' && $this->activated($r)]; })->all();
+ return ['proposals'=>$rows,'migration_pending'=>false,'notice'=>'RASCUNHO — SEM VALIDADE FISCAL; aprovação não activa o perfil.'];
+ }
+ public function propose(int $actor,array $input): array
+ {
+ return $this->command($actor,'propose',function($s) use($actor,$input) {
+ $in=$this->exact($input,['kind'=>'required|in:profile,temporal','identifier'=>'required|string|max:100|regex:/^[A-Za-z0-9_-]+$/D','previous_id'=>'nullable|integer|min:1','data'=>'required|array']);
+ $previous=isset($in['previous_id']) ? $this->row((int)$in['previous_id'],$s) : null;
+ $last=DB::table('sf_proposals')->where($s)->where('kind',$in['kind'])->where('identifier',$in['identifier'])->orderByDesc('version')->first();
+ abort_unless(($last?->id ?? null)===($previous?->id ?? null),409,'Indique a versão anterior exacta.');
+ $version=(int)($last?->version ?? 0)+1;
+ if ($in['kind']==='profile') {
+ $d=$this->exact($in['data'],['service_id'=>'required|integer|min:1','service_version'=>'required|integer|min:1','currency'=>'required|in:MZN','price_mode'=>'required|in:added','rate_percent'=>'required|in:16','valid_from'=>'required|date_format:Y-m-d','valid_until'=>'nullable|date_format:Y-m-d|after_or_equal:valid_from','treatment'=>'required|in:domestic_normal','conditions'=>'required|string|max:4000','evidence'=>'required|string|max:4000']);
+ $service=DB::table('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
+ abort_unless($service && (int)$service->version===(int)$d['service_version'],409,'Serviço/versão incompatível.');
+ if ($previous) abort_unless((int)$this->data($previous)['service_id']===(int)$d['service_id'],422,'Nova versão não pode trocar a identidade do serviço.');
+ abort_if(DB::table('sf_proposals')->where($s)->where('kind','profile')->whereRaw("data->>'service_id' = ?",[(string)$d['service_id']])->where('identifier','<>',$in['identifier'])->exists(),409,'Use a identidade existente deste serviço e uma nova versão.');
+ $profile=DB::table('service_calculation_profiles')->insertGetId($s+['identifier'=>$in['identifier'],'version'=>$version,'definition'=>json_encode($d+['kind'=>'real','policy'=>ServiceCalculation::POLICY,'sources'=>ServiceCalculation::SOURCES+['fiscal_2026'=>'Lei10/2025:6,32;vigencia2026-01-01']],JSON_THROW_ON_ERROR),'created_at'=>now()]);
+ $draft=null;
+ } else {
+ $d=$this->exact($in['data'],['profile_proposal_id'=>'required|integer|min:1','draft_id'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:0','reference_date'=>'required|date_format:Y-m-d','reference_basis'=>'required|string|max:2000','period_start'=>'required|date_format:Y-m-d','period_end'=>'nullable|date_format:Y-m-d|after_or_equal:period_start','next_billing'=>'nullable|date_format:Y-m-d','event'=>'required|in:pending,invoice_in_time,invoice_deadline','event_date'=>'nullable|required_unless:event,pending|date_format:Y-m-d','evidence'=>'required|string|max:4000']);
+ abort_if($d['event']==='pending' && !empty($d['event_date']),422,'Pendência temporal não tem data de exigibilidade.');
+ abort_if($d['event']!=='pending' && $d['reference_date']!==$d['event_date'],422,'A referência de vigência deve corresponder à exigibilidade fundamentada quando determinada.');
+ $p=$this->row((int)$d['profile_proposal_id'],$s); abort_unless($p->kind==='profile' && $this->approved($p),422,'Perfil exacto aprovado necessário.');
+ [$b,$prepared]=$this->draft((int)$d['draft_id'],$actor);
+ $this->versions($b,$prepared,$d);
+ abort_unless(($prepared['data']['period_start'] ?? null)===$d['period_start'] && ($prepared['data']['period_end'] ?? null)===($d['period_end'] ?? null),409,'Período deve corresponder à preparação revista.');
+ if ($previous) abort_unless((int)$previous->draft_id===(int)$d['draft_id'],422,'Decisão temporal pertence ao mesmo rascunho.');
+ abort_if(DB::table('sf_proposals')->where($s)->where('kind','temporal')->where('draft_id',$d['draft_id'])->where('identifier','<>',$in['identifier'])->exists(),409,'Use a identidade temporal existente e uma nova versão.');
+ $profile=$p->profile_id; $draft=$d['draft_id'];
+ }
+ $id=DB::table('sf_proposals')->insertGetId($s+['kind'=>$in['kind'],'identifier'=>$in['identifier'],'version'=>$version,'previous_id'=>$previous?->id,'profile_id'=>$profile,'draft_id'=>$draft,'data'=>json_encode($d,JSON_THROW_ON_ERROR),'actor_id'=>$actor,'created_at'=>now()]);
+ Audit::record('service_fiscal.proposed',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'version'=>$version,'kind'=>$in['kind']]);
+ return ['id'=>$id,'version'=>$version,'active'=>false];
+ });
+ }
+ public function decide(int $id,int $actor,array $input): array
+ {
+ return $this->command($actor,'approve',function($s) use($id,$actor,$input) {
+ $d=$this->exact($input,['version'=>'required|integer|min:1','decision'=>'required|in:approved,refused','reason'=>'required|string|max:4000']);
+ $r=$this->row($id,$s); abort_unless((int)$r->version===(int)$d['version'] && (int)$r->actor_id!==$actor,403,'Versão exacta e validador distinto obrigatórios.');
+ abort_if($this->decision($id),409,'Decisão imutável; correcção exige nova versão.');
+ if ($r->kind==='temporal' && $d['decision']==='approved') {
+ $data=$this->data($r); [$b,$p]=$this->draft((int)$r->draft_id,$actor); $this->versions($b,$p,$data);
+ $profile=$this->row((int)$data['profile_proposal_id'],$s); $this->compatible($profile,$s,$data['reference_date'],$b,$p,false);
+ }
+ DB::table('sf_decisions')->insert(['proposal_id'=>$id,'actor_id'=>$actor,'decision'=>$d['decision'],'reason'=>$d['reason'],'created_at'=>now()]);
+ Audit::record('service_fiscal.decided',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'version'=>$r->version,'decision'=>$d['decision']]);
+ return ['id'=>$id,'decision'=>$d['decision'],'active'=>false];
+ });
+ }
+ /** No HTTP activation endpoint. Only isolated proofs may exercise active/revoked states in this increment. */
+ public function proofActivation(int $id,int $actor,int $version,bool $active): void
+ {
+ abort_unless(ServiceCalculation::isolated(),403,'Activação real não autorizada neste incremento.'); TestingDatabaseGuard::assertSafe();
+ $this->command($actor,'approve',function($s) use($id,$actor,$version,$active) {
+ $p=$this->row($id,$s); abort_unless($p->kind==='profile' && (int)$p->version===$version && $this->approved($p) && $this->latest($p,$s),409);
+ DB::table('sf_activation_events')->insert(['proposal_id'=>$id,'actor_id'=>$actor,'active'=>$active,'reason'=>'Prova sintética isolada; não é activação real.','created_at'=>now()]);
+ Audit::record('service_fiscal.proof_activation',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['proposal_id'=>$id,'version'=>$version,'active'=>$active]);
+ return [];
+ });
+ }
+ private function draft(int $id,int $actor): array
+ {
+ $b=$this->workflow->show('billing_drafts',$id,$actor); abort_if($b['internal_confirmation'],409,'B1 preservada.');
+ return [$b,$this->preparation->show('billing_drafts',$id,$actor)];
+ }
+ private function versions(array $b,array $p,array $d): void
+ {
+ abort_unless($b['version']===(int)$d['draft_version'] && $p['version']===(int)$d['preparation_version'] && ($p['data']['draft_version'] ?? null)===$b['version'],409,'Rascunho/preparação desactualizados.');
+ $current=DB::table('a_service_drafts')->where('id',$b['source_id'])->value('version');
+ abort_unless((int)$current===(int)$b['data']['source_version'],409,'Origem desactualizada.');
+ }
+ private function compatible(object $p,array $s,string $date,array $b,array $prepared,bool $active=true): array
+ {
+ $d=$this->data($p); $line=$b['data']['lines'][0] ?? [];
+ abort_unless($p->kind==='profile' && $this->approved($p) && (!$active || $this->activated($p)) && $this->latest($p,$s,$date),409,'Perfil sem aprovação/activação exacta ou substituído.');
+ abort_unless($date!=='' && $d['valid_from']<=$date && (empty($d['valid_until']) || $date<=$d['valid_until']),422,'Vigência não demonstrada.');
+ $service=DB::table('a_services')->where('tenant_id',$s['tenant_id'])->where('company_id',$s['company_id'])->where('id',$d['service_id'])->first();
+ abort_unless($service && (int)$service->version===(int)$d['service_version'] && count($b['data']['lines'])===1 && (int)($line['service_id'] ?? 0)===(int)$d['service_id'] && $b['data']['currency']===$d['currency'] && ($prepared['data']['price_mode'] ?? null)===$d['price_mode'],422,'Serviço, moeda ou modo incompatível.');
+ return $d;
+ }
+ public function calculate(int $draft,int $actor,array $input): array
+ {
+ return $this->command($actor,'calculate',function($s) use($draft,$actor,$input) {
+ $d=$this->exact($input,['profile_proposal_id'=>'required|integer|min:1','profile_version'=>'required|integer|min:1','temporal_proposal_id'=>'required|integer|min:1','temporal_version'=>'required|integer|min:1','draft_version'=>'required|integer|min:1','preparation_version'=>'required|integer|min:0']);
+ [$b,$prepared]=$this->draft($draft,$actor); $this->versions($b,$prepared,$d);
+ $p=$this->row((int)$d['profile_proposal_id'],$s); $t=$this->row((int)$d['temporal_proposal_id'],$s);
+ abort_unless((int)$p->version===(int)$d['profile_version'] && $t->kind==='temporal' && (int)$t->version===(int)$d['temporal_version'] && (int)$t->draft_id===$draft && (int)$t->profile_id===(int)$p->profile_id && $this->approved($t) && $this->latest($t,$s),409,'Decisão temporal exacta e aprovada necessária.');
+ $td=$this->data($t); $this->versions($b,$prepared,$td);
+ $definition=$this->compatible($p,$s,$td['reference_date'],$b,$prepared);
+ $profile=json_decode(DB::table('service_calculation_profiles')->where('id',$p->profile_id)->value('definition'),true,32,JSON_THROW_ON_ERROR);
+ $result=ServiceCalculation::evaluateReal($b['data'],$prepared['data'],$profile);
+ abort_if($result['state']==='pending',422,implode(' ',$result['pending']));
+ $pending=$td['event']==='pending';
+ $result['temporal_state']=$pending ? 'temporal_pending' : 'temporal_decision_validated';
+ $result['exigibility_date']=$pending ? null : $td['event_date'];
+ $result['assessment_period']=$pending ? null : substr($td['event_date'],0,7);
+ if ($pending) $result['pending'][]='Exigibilidade e período de apuramento não determinados; resultado condicionado.';
+ $input=['draft_id'=>$draft,'draft_version'=>$b['version'],'preparation_version'=>$prepared['version'],'profile_proposal_id'=>$p->id,'profile_id'=>$p->profile_id,'profile_version'=>(int)$p->version,'profile_identifier'=>$p->identifier,'profile'=>$profile,'temporal_proposal_id'=>$t->id,'temporal_version'=>(int)$t->version,'temporal'=>$td,'data'=>$b['data'],'prepared'=>$prepared['data'],'policy'=>ServiceCalculation::POLICY];
+ $hash=hash('sha256',json_encode($input,JSON_THROW_ON_ERROR));
+ $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($s+['billing_draft_id'=>$draft,'profile_id'=>$p->profile_id,'actor_id'=>$actor,'fingerprint'=>$hash,'snapshot'=>json_encode(['input'=>$input,'result'=>$result],JSON_THROW_ON_ERROR),'created_at'=>now()]);
+ Audit::record('service_fiscal.calculated',$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['result_id'=>$id,'profile_proposal_id'=>$p->id,'temporal_proposal_id'=>$t->id]);
+ return ['id'=>$id,'replayed'=>false];
+ });
+ }
+ public function history(int $draft,int $actor): array
+ {
+ $s=$this->scope($actor); [$b,$prepared]=$this->draft($draft,$actor); $history=[];
+ if (!\Illuminate\Support\Facades\Schema::hasTable('sf_proposals')) return [];
+ foreach(DB::table('service_calculation_results')->where($s)->where('billing_draft_id',$draft)->orderByDesc('id')->get() as $r) {
+ $snapshot=json_decode($r->snapshot,true,32,JSON_THROW_ON_ERROR); $in=$snapshot['input'];
+ if (!isset($in['profile_proposal_id'])) continue;
+ $current=true;
+ try { $this->versions($b,$prepared,$in); $p=$this->row($in['profile_proposal_id'],$s); $t=$this->row($in['temporal_proposal_id'],$s); abort_unless($this->approved($t) && $this->latest($t,$s),409); $this->compatible($p,$s,$in['temporal']['reference_date'],$b,$prepared); }
+ catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $current=false; }
+ $history[]=['id'=>$r->id,'current'=>$current,'snapshot'=>$snapshot];
+ }
+ return $history;
+ }
+}
diff --git a/database/migrations/2026_10_26_050000_create_service_fiscal_lifecycle.php b/database/migrations/2026_10_26_050000_create_service_fiscal_lifecycle.php
new file mode 100644
index 0000000..f017f4e
--- /dev/null
+++ b/database/migrations/2026_10_26_050000_create_service_fiscal_lifecycle.php
@@ -0,0 +1,68 @@
+definition;
+ $expression=substr($existing,6); // retain every existing permission/scope branch
+ 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 (($expression) OR (permission IN ('service_fiscal.view','service_fiscal.propose','service_fiscal.approve') AND unit_id IS NULL) OR (permission='service_fiscal.calculate' AND unit_id IS NOT NULL))");
+ Schema::create('sf_proposals', function (Blueprint $t) {
+ $t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id');
+ $t->string('kind'); $t->string('identifier'); $t->unsignedInteger('version');
+ $t->foreignId('profile_id')->constrained('service_calculation_profiles')->restrictOnDelete();
+ $t->foreignId('draft_id')->nullable()->constrained('a_billing_drafts')->restrictOnDelete();
+ $t->foreignId('previous_id')->nullable()->constrained('sf_proposals')->restrictOnDelete();
+ $t->foreignId('actor_id')->constrained('users'); $t->jsonb('data'); $t->timestampTz('created_at');
+ $t->unique(['unit_id','kind','identifier','version']);
+ $t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units')->restrictOnDelete();
+ });
+ Schema::create('sf_decisions', function (Blueprint $t) {
+ $t->id(); $t->foreignId('proposal_id')->unique()->constrained('sf_proposals')->restrictOnDelete();
+ $t->foreignId('actor_id')->constrained('users'); $t->string('decision'); $t->text('reason'); $t->timestampTz('created_at');
+ });
+ Schema::create('sf_activation_events', function (Blueprint $t) {
+ $t->id(); $t->foreignId('proposal_id')->constrained('sf_proposals')->restrictOnDelete();
+ $t->foreignId('actor_id')->constrained('users'); $t->boolean('active'); $t->text('reason'); $t->timestampTz('created_at');
+ });
+ DB::unprepared(<<<'SQL'
+ CREATE OR REPLACE FUNCTION sgf_fiscal_proposal_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN
+ IF NEW.kind NOT IN ('profile','temporal') 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
+ ) OR (NEW.kind='profile' AND NEW.draft_id IS NOT NULL) OR (NEW.kind='temporal' AND NEW.draft_id IS NULL)
+ OR (NEW.draft_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM a_billing_drafts b WHERE b.id=NEW.draft_id AND b.tenant_id=NEW.tenant_id AND b.company_id=NEW.company_id AND b.unit_id=NEW.unit_id))
+ OR (NEW.previous_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM sf_proposals p WHERE p.id=NEW.previous_id AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.unit_id=NEW.unit_id AND p.kind=NEW.kind AND p.identifier=NEW.identifier AND p.version=NEW.version-1))
+ THEN RAISE EXCEPTION 'SGF_FISCAL_SCOPE'; END IF;
+ IF NEW.previous_id IS NULL AND NEW.version<>1 THEN RAISE EXCEPTION 'SGF_FISCAL_INITIAL_VERSION'; END IF;
+ IF NEW.kind='profile' 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_FISCAL_SERVICE_SCOPE'; END IF;
+ RETURN NEW;
+ END; $$;
+ CREATE TRIGGER fiscal_proposal_scope BEFORE INSERT ON sf_proposals FOR EACH ROW EXECUTE FUNCTION sgf_fiscal_proposal_guard();
+ CREATE OR REPLACE FUNCTION sgf_fiscal_decision_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN
+ IF NEW.decision NOT IN ('approved','refused') OR EXISTS(SELECT 1 FROM sf_proposals WHERE id=NEW.proposal_id AND actor_id=NEW.actor_id)
+ THEN RAISE EXCEPTION 'SGF_FISCAL_DECISION'; END IF;
+ RETURN NEW;
+ END; $$;
+ CREATE TRIGGER fiscal_decision_guard BEFORE INSERT ON sf_decisions FOR EACH ROW EXECUTE FUNCTION sgf_fiscal_decision_guard();
+ CREATE OR REPLACE FUNCTION sgf_fiscal_activation_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN
+ IF NOT EXISTS(SELECT 1 FROM sf_proposals p JOIN sf_decisions d ON d.proposal_id=p.id AND d.decision='approved' WHERE p.id=NEW.proposal_id AND p.kind='profile')
+ THEN RAISE EXCEPTION 'SGF_FISCAL_ACTIVATION'; END IF;
+ RETURN NEW;
+ END; $$;
+ CREATE TRIGGER fiscal_activation_guard BEFORE INSERT ON sf_activation_events FOR EACH ROW EXECUTE FUNCTION sgf_fiscal_activation_guard();
+ SQL);
+ foreach (['sf_proposals','sf_decisions','sf_activation_events'] as $table) {
+ DB::unprepared("CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()");
+ }
+ }
+ public function down(): void { throw new RuntimeException('Fiscal history requires a reviewed preservation procedure.'); }
+};
diff --git a/docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md b/docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md
new file mode 100644
index 0000000..a874a15
--- /dev/null
+++ b/docs/CONDITIONAL_CALCULATION_ACCEPTANCE.md
@@ -0,0 +1,39 @@
+# Cálculo condicionado — entrega aceite
+
+26/10/2026, Africa/Johannesburg. O utilizador declarou: **«Aceito esta entrega do cálculo condicionado. Regista a aceitação e cria o checkpoint Git local, excluindo credenciais e dados locais. Exigibilidade e período de apuramento permanecem pendentes. Não avances para emissão documental.»**
+
+## Âmbito aceite e limites
+
+Perfil fiscal versionado, proposta e validação por identidades distintas, activação por operação local especificamente autorizada e cálculo exacto sobre uma linha de serviço com preço sem imposto. A versão, empresa, unidade, serviço, vigência, fontes e condições são verificadas. O cálculo não altera decisões fiscais validadas. A classificação contabilística não determina IVA.
+
+A decisão temporal aprovada pode manter o acontecimento pendente. Nesse caso o resultado permanece condicionado, com exigibilidade e período de apuramento nulos, sem presumir datas a partir do início do serviço ou da próxima facturação. O resultado é apresentado como **RASCUNHO — SEM VALIDADE FISCAL**. Não existe emissão, recebimento, apuramento líquido ou lançamento contabilístico neste incremento.
+
+O caso local foi conferido no navegador: base 7 850,00, IVA exacto/proposto 1 256,00, diferença 0,00 e total proposto 9 106,00 MZN, com perfil/versões identificados. Os mesmos montantes constam das provas sintéticas; este registo não exporta cliente, documentos, decisões, identificadores ou movimentos da base local. A repetição do cálculo conservou um único resultado.
+
+## Implementação e verificações
+
+- `ServiceFiscal` e controlador: propostas/decisões imutáveis, autorização por âmbito e comando de cálculo condicionado; integração na pré-visualização existente.
+- Migração aditiva `2026_10_26_050000_create_service_fiscal_lifecycle.php`: três tabelas, relações e triggers, ampliação do CHECK de permissões preservando os direitos anteriores. Migração e cinco concessões iniciais foram confirmadas especificamente e aplicadas; comparação verificou preservação de 28 tabelas preexistentes. As provas JSON conservam apenas resultados agregados dessas fases, não o estado actual de activação.
+- `LocalFiscalActivation` / `scripts/service-fiscal-activate.php`: operação de console, base local própria, manifesto exacto e hash da definição revista. Sem rota HTTP ou concessão permanente de activação. Repetição não duplica eventos. A activação local posteriormente autorizada foi executada e auditada; não é executada automaticamente pelo checkout ou migração.
+- Interface com nomes em português, resumo de taxa/moeda/preço/vigência, estados distintos, condições e fontes acessíveis, fundamento da decisão e indicação permanente dos limites do rascunho.
+- Regressões dirigidas do circuito inicial: **52 testes / 1 171 asserções** em `sgf_tempo_test`. Após acrescentar a activação local: **17 testes / 423 asserções**, filtro `ServiceFiscalTest|ServiceCalculationTest|DatabaseSafetyTest`. Compilação Vite passou. Sem novas dependências ou versões.
+- Provas de concorrência: dois processos PHP sobrepostos, uma inserção e um replay, preservando o histórico; [registo sintético](proofs/service-fiscal-concurrency.json). [Imagem sintética](proofs/service-fiscal-browser.png). Os testes destrutivos e demonstrações sintéticas usam exclusivamente a instância isolada e a guarda de `sgf_tempo_test`.
+
+Não foram repetidos testes destrutivos para registar esta aceitação. As alterações posteriores às provas foram apresentação e correcção de uma mensagem obsoleta, sem mudança das fórmulas/autorizações.
+
+## Utilização e reprodução técnica
+
+Seleccionar empresa e unidade autorizadas → **Perfil fiscal do serviço** para consultar/propor/validar versões. Consultar **Rascunhos de facturação → registo autorizado → Pré-visualizar rascunho** para o resultado e pendências. Apenas o detentor da permissão unitária de cálculo pode executar o comando; a validação temporal requer direito próprio e identidade distinta de quem propôs.
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 test --stop-on-failure '--filter=ServiceFiscalTest|ServiceCalculationTest|DatabaseSafetyTest'
+powershell -NoProfile -ExecutionPolicy Bypass -File scripts/sgf.ps1 npm run build
+```
+
+Os scripts `service-fiscal-fixture.php`, `service-fiscal-worker.php` e `service-fiscal-concurrency.php` são para provas sintéticas guardadas. Parar a instância de prova antes de testes que recriam a base de testes. Nunca usar reset/fresh em desenvolvimento. Os scripts de concessão/activação não são instruções para repetir as operações reais sem autorização específica.
+
+## Preservação do checkpoint
+
+Checkpoint exclusivamente local, sem push. Contém código, migração, testes, documentação de aceitação e provas sintéticas/agregadas. Ficam fora: `.env*` privados, credenciais, manifestos, fingerprints locais, bases, logs, imagens da operação real e a ficha nominativa `REAL_SERVICE_FISCAL_ASSESSMENT.md`, que permanece no posto local. A ficha contém o histórico detalhado do caso e este registo não a substitui.
+
+**Entrega encerrada no âmbito aceite.** Exigibilidade e período de apuramento permanecem pendentes. A emissão documental exige incremento separado; esta aceitação não o autoriza e não autoriza publicação.
diff --git a/docs/proofs/service-fiscal-browser.png b/docs/proofs/service-fiscal-browser.png
new file mode 100644
index 0000000..b53a008
Binary files /dev/null and b/docs/proofs/service-fiscal-browser.png differ
diff --git a/docs/proofs/service-fiscal-concurrency.json b/docs/proofs/service-fiscal-concurrency.json
new file mode 100644
index 0000000..a788ef5
--- /dev/null
+++ b/docs/proofs/service-fiscal-concurrency.json
@@ -0,0 +1,7 @@
+{
+ "database": "sgf_tempo_test",
+ "overlapping_php_processes": 2,
+ "lock_overlap_observed": true,
+ "one_insert_one_replay": true,
+ "prior_history_preserved": true
+}
\ No newline at end of file
diff --git a/docs/proofs/service-fiscal-dev-preservation-grants.json b/docs/proofs/service-fiscal-dev-preservation-grants.json
new file mode 100644
index 0000000..c1a2e41
--- /dev/null
+++ b/docs/proofs/service-fiscal-dev-preservation-grants.json
@@ -0,0 +1,9 @@
+{
+ "database": "sgf_tempo_dev",
+ "preexisting_tables_checked": 28,
+ "preexisting_records_unchanged": true,
+ "new_fiscal_tables_empty": true,
+ "real_profile_activated": false,
+ "confirmed_grants_verified": 5,
+ "prior_grants_and_audit_unchanged": true
+}
\ No newline at end of file
diff --git a/docs/proofs/service-fiscal-dev-preservation.json b/docs/proofs/service-fiscal-dev-preservation.json
new file mode 100644
index 0000000..379d3b0
--- /dev/null
+++ b/docs/proofs/service-fiscal-dev-preservation.json
@@ -0,0 +1,9 @@
+{
+ "database": "sgf_tempo_dev",
+ "preexisting_tables_checked": 28,
+ "preexisting_records_unchanged": true,
+ "new_fiscal_tables_empty": true,
+ "real_profile_activated": false,
+ "confirmed_grants_verified": 0,
+ "prior_grants_and_audit_unchanged": true
+}
\ No newline at end of file
diff --git a/resources/css/app.css b/resources/css/app.css
index 4e39b10..f536226 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -9,3 +9,4 @@
.accounting-proposal dl { display: grid; gap: .5rem; }
.accounting-proposal dl > div { display: grid; grid-template-columns: minmax(8rem, 1fr) 2fr; gap: 1rem; }
.accounting-proposal dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
+.fiscal-page-heading h1{margin:8px 0 12px}.fiscal-page-heading>p:last-child{color:#5d716d;margin:0 0 24px}.fiscal-notice{display:flex;flex-direction:column;gap:7px;background:#fff7e7;border:1px solid #ead7ad;border-left:4px solid #c5983e;border-radius:8px;padding:16px 20px;margin-bottom:24px;color:#745723;font-size:.85rem;line-height:1.6}.fiscal-notice strong{font-size:.75rem;letter-spacing:.04em}.fiscal-list-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:26px 0 16px}.fiscal-list-heading h2{margin:0}.fiscal-list-heading>span{font-size:.8rem;color:#64796f}.fiscal-proposal{background:#fff;border:1px solid #dce6e1;border-radius:12px;padding:28px;margin-bottom:24px;box-shadow:0 3px 12px #123f3505}.fiscal-card-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:20px}.fiscal-card-heading h3{font-size:1.3rem;margin:8px 0;letter-spacing:-.02em}.fiscal-card-heading .eyebrow{margin-top:0}.fiscal-reference{color:#65786f;font-size:.78rem;margin:0;overflow-wrap:anywhere}.fiscal-statuses{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}.fiscal-status{border-radius:20px;padding:6px 10px;white-space:nowrap;font-size:.72rem;font-weight:650;background:#edf2ef;color:#53665d}.fiscal-status.pending{background:#fff2d3;color:#80601c}.fiscal-status.approved{background:#e5f3e9;color:#286444}.fiscal-status.refused{background:#fbece8;color:#963f2d}.fiscal-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:24px 0;background:#f3f7f4;border:1px solid #e0e9e3;border-radius:8px;padding:18px;gap:20px}.fiscal-summary dt,.fiscal-facts dt{color:#667b70;font-size:.75rem;margin-bottom:8px}.fiscal-summary dd{margin:0;font-weight:650;font-size:.95rem;line-height:1.5}.fiscal-facts{display:grid;grid-template-columns:1fr 1fr;gap:18px 30px;margin:0 0 24px}.fiscal-facts dd{margin:0;line-height:1.5;font-size:.9rem}.fiscal-conditions{border-top:1px solid #e5ece7;padding-top:18px}.fiscal-conditions h4,.fiscal-review h4{font-size:.95rem;margin:0 0 10px}.fiscal-conditions p,.fiscal-evidence p,.fiscal-recorded p{font-size:.88rem;line-height:1.85;white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 18px;max-width:75ch}.fiscal-evidence{border-top:1px solid #e5ece7;padding:16px 0}.fiscal-evidence summary,.fiscal-new>summary{cursor:pointer;color:#28634f;font-weight:650;font-size:.9rem}.fiscal-evidence p{margin:15px 0 0;color:#5d716d}.fiscal-review{background:#f5f8f6;border-top:1px solid #dce6e1;margin:8px -28px -28px;padding:24px 28px;border-radius:0 0 12px 12px}.fiscal-review>p{font-size:.85rem;color:#5d716d;margin:0 0 18px}.fiscal-review label{max-width:700px}.fiscal-review textarea{resize:vertical;min-height:110px}.fiscal-refuse{color:#923f2c;border-color:#dbb7ac}.fiscal-recorded{padding-top:16px;border-top:1px solid #e5ece7}.fiscal-recorded>strong{font-size:.85rem}.fiscal-new form{margin-top:24px}.fiscal-new textarea{resize:vertical}.fiscal-proposal dd{overflow-wrap:anywhere}@media(max-width:1000px){.fiscal-summary{grid-template-columns:1fr 1fr}.fiscal-card-heading{flex-direction:column}.fiscal-statuses{justify-content:flex-start}}@media(max-width:560px){.fiscal-proposal{padding:20px}.fiscal-review{margin:8px -20px -20px;padding:20px}.fiscal-list-heading{display:block}.fiscal-list-heading>span{display:block;margin-top:8px}.fiscal-facts{grid-template-columns:1fr}.fiscal-summary{gap:16px;padding:14px}.fiscal-card-heading h3{font-size:1.12rem}.fiscal-review .actions button{flex:1}}
diff --git a/resources/js/DocumentPreview.jsx b/resources/js/DocumentPreview.jsx
index e1bc2c6..0b628cd 100644
--- a/resources/js/DocumentPreview.jsx
+++ b/resources/js/DocumentPreview.jsx
@@ -52,6 +52,7 @@ export default function DocumentPreview({ id, historical, context, api, onClose
Base aritmética exacta: {entry.snapshot.result.base_exact}
;
+ const fields=kind==='profile' ? {service_id:'ID do serviço existente',service_version:'Versão do serviço',valid_from:'Vigência desde',valid_until:'Vigência até (opcional)',conditions:'Condições de aplicabilidade',evidence:'Referências de validação e fontes'} : {profile_proposal_id:'ID da proposta exacta do perfil',draft_id:'ID do rascunho',draft_version:'Versão do rascunho',preparation_version:'Versão da preparação',reference_date:'Data explícita para verificar vigência',reference_basis:'Fundamento dessa referência (não presumido)',period_start:'Início do período na preparação',period_end:'Fim do período (opcional)',next_billing:'Próxima facturação prevista (opcional)',event_date:'Data do acontecimento (apenas se determinado)',evidence:'Evidência e fundamento do acontecimento/pêndencia'};
+ const dates=['valid_from','valid_until','reference_date','period_start','period_end','next_billing','event_date'];
+ async function propose(e) { e.preventDefault(); await perform(async()=>{
+ const data={}; Object.keys(fields).forEach(key=>{ if(form[key]!=='' && !(key==='event_date' && form.event==='pending')) data[key]=key.endsWith('_id') || key.endsWith('_version') ? Number(form[key]) : form[key]; });
+ if(kind==='profile') Object.assign(data,{currency:'MZN',price_mode:'added',rate_percent:'16',treatment:'domestic_normal'}); else data.event=form.event;
+ await api('/api/service-fiscal/proposals','POST',{kind,identifier:form.identifier,...(form.previous_id ? {previous_id:Number(form.previous_id)} : {}),data}); await load();
+ },'Proposta guardada; não activa o perfil.'); }
+ return
ENQUADRAMENTO DO SERVIÇO
Perfis e validação fiscal
Consulte as condições propostas e acompanhe a validação de cada versão.
RASCUNHO — SEM VALIDADE FISCALAprovar não activa o perfil. Este ecrã não emite facturas; calcular não altera uma decisão temporal validada.
{error &&
{error}
}
+ {listing?.migration_pending ?
Preparação técnica pendente de migração autorizada; não há perfil activo.
: <>
+ {can('service_fiscal.propose') && Preparar nova proposta}
+
As propostas e respectivas decisões serão apresentadas aqui.
}>}
+ ;
+}
diff --git a/resources/js/app.jsx b/resources/js/app.jsx
index c3efdec..55fb7c9 100644
--- a/resources/js/app.jsx
+++ b/resources/js/app.jsx
@@ -5,6 +5,7 @@ import ServicesWorkspace, { serviceAreas } from './ServicesWorkspace.jsx';
import DocumentPreparationWorkspace from './DocumentPreparationWorkspace.jsx';
import { DocumentPreviewConfiguration } from './DocumentPreview.jsx';
import CatalogueAccountingWorkspace from './CatalogueAccountingWorkspace.jsx';
+import ServiceFiscalWorkspace from './ServiceFiscalWorkspace.jsx';
const labels = { 'catalogue_accounting.view':'Consultar classificação contabilística', 'catalogue_accounting.propose':'Preparar classificação contabilística', 'catalogue_accounting.approve':'Validar ou recusar classificação contabilística', 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
@@ -49,7 +50,7 @@ function App() {
if (!state) return
SGF TEMPO
{error || 'A carregar…'}
;
if (state.setup_pending) return
SGF TEMPO · PREPARAÇÃO LOCAL
Falta concluir a primeira conta
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.
Concluir no terminal do VS Code
Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta: