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}

IVA exacto: {entry.snapshot.result.vat_exact}

IVA truncado proposto: {entry.snapshot.result.vat_document_proposed}

Diferença descartada: {entry.snapshot.result.discarded_difference}

Total documental proposto: {entry.snapshot.result.total_document_proposed ?? 'Pendente: representação da base/total'} {entry.snapshot.result.currency}

{entry.snapshot.result.pending.map((text,i)=>

{text}

)}

Sem validação fiscal. {notice}

)} } } + {!historical && view.fiscal_history?.length>0 &&

Cálculo proposto do serviço — condicionado

{view.fiscal_history.map(entry=>

Resultado #{entry.id} · {entry.current?'Actual para as versões validadas':'Desactualizado — histórico'}

Perfil {entry.snapshot.input.profile_identifier} v{entry.snapshot.input.profile_version} · decisão temporal v{entry.snapshot.input.temporal_version}

Base exacta: {entry.snapshot.result.base_exact} {entry.snapshot.result.currency}

IVA exacto: {entry.snapshot.result.vat_exact}

IVA truncado proposto: {entry.snapshot.result.vat_document_proposed}

Diferença preservada: {entry.snapshot.result.discarded_difference}

Total proposto: {entry.snapshot.result.total_document_proposed ?? 'Pendente'}

Exigibilidade: {entry.snapshot.result.exigibility_date ?? 'Não determinada'}

Período de apuramento: {entry.snapshot.result.assessment_period ?? 'Não determinado'}

{entry.snapshot.result.pending.map((p,i)=>

{p}

)}

{notice} · Não representa emissão ou apuramento líquido do imposto.

)}
} {!!view.payments.length &&

Instruções do emitente para facilitar o pagamento

Informação introduzida pelo utilizador, sem verificação bancária. Não regista recebimento nem inicia operação bancária.

{view.payments.map(payment =>

{payment.data.label} · versão {payment.version}

{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]) =>

{label}: {payment.data[key]}

)}{payment.data.instructions &&

{payment.data.instructions}

}
)}
} {view.observations &&

Observações

{view.observations}

}

Dados em falta e validações pendentes

Preenchido não significa validado. Imposto e total definitivo continuam por apurar.

{notice}

diff --git a/resources/js/ServiceFiscalWorkspace.jsx b/resources/js/ServiceFiscalWorkspace.jsx new file mode 100644 index 0000000..30eed97 --- /dev/null +++ b/resources/js/ServiceFiscalWorkspace.jsx @@ -0,0 +1,52 @@ +import React, { useEffect, useState } from 'react'; + +const fieldLabels = {currency:'Moeda',treatment:'Tratamento fiscal',price_mode:'Modo de preço',service_id:'Serviço',service_version:'Versão do serviço',valid_from:'Vigência desde',valid_until:'Vigência até',rate_percent:'Taxa de IVA',profile_proposal_id:'Proposta do perfil',draft_id:'Rascunho',draft_version:'Versão do rascunho',preparation_version:'Versão da preparação',reference_date:'Data de referência',reference_basis:'Fundamento da referência',period_start:'Início do período',period_end:'Fim do período',next_billing:'Próxima facturação prevista',event:'Acontecimento',event_date:'Data do acontecimento'}; +const valueLabels = {domestic_normal:'Operação interna · regime normal',added:'Preço sem IVA · imposto acrescido',included:'Imposto incluído',pending:'Por determinar',invoice_in_time:'Emissão dentro do prazo fundamentado',invoice_deadline:'Termo do prazo de emissão fundamentado'}; +function displayValue(key,value) { + if(value===null || value===undefined || value==='') return 'Não indicado'; + if(['valid_from','valid_until','reference_date','period_start','period_end','next_billing','event_date'].includes(key) && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value.split('-').reverse().join('/'); + if(key==='rate_percent') return `${value}%`; + if(['service_id','draft_id','profile_proposal_id'].includes(key)) return `#${value}`; + return valueLabels[value] || String(value); +} +function FiscalProposal({p,can,busy,reasons,setReasons,perform,api,load,listing,context}) { + const d=p.definition, status=p.decision?.decision; + const stateLabel=status==='approved'?'Validada':status==='refused'?'Recusada':'Aguarda validação'; + const summary=p.kind==='profile'?['rate_percent','currency','price_mode','valid_from']:['period_start','period_end','event','event_date']; + return
+

{p.kind==='profile'?'PERFIL FISCAL':'DECISÃO TEMPORAL'} · PROPOSTA #{p.id}

{p.kind==='profile'?`Enquadramento de IVA · serviço #${d.service_id}`:`Período e exigibilidade · rascunho #${d.draft_id}`}

{p.identifier} · Versão {p.version}

{stateLabel}{p.kind==='profile' && {p.active?'Activo para cálculo proposto':'Sem activação'}}
+
{summary.map(key=>
{fieldLabels[key]}
{displayValue(key,d[key])}
)}
+
{Object.entries(d).filter(([key])=>!summary.includes(key) && !['evidence','conditions','reference_basis'].includes(key)).map(([key,value])=>
{fieldLabels[key] || key}
{displayValue(key,value)}
)}
+ {d.conditions &&

Condições de aplicação

{d.conditions}

} + {d.reference_basis &&

Fundamento da data de referência

{d.reference_basis}

} + {d.evidence &&
Fontes e elementos de suporte

{d.evidence}

} + {p.decision &&
Fundamento da decisão

{p.decision.reason}

} + {!p.decision && can('service_fiscal.approve') &&

Validar esta versão

A decisão refere-se à versão {p.version}, na unidade seleccionada. Validar não activa o perfil nem emite uma factura.