feat(iva): prepare five G8 service territoriality branches together in QA

This commit is contained in:
2026-10-24 23:38:14 +02:00
parent aac3f46306
commit ec33fb5be2
8 changed files with 209 additions and 0 deletions
@@ -16,6 +16,7 @@ import { SimulationExclusionBase } from './simulation-exclusion-base';
import { SimulationValueReview } from './simulation-value-review';
import { SimulationServiceNormal } from './simulation-service-normal';
import { SimulationGoodsLocation } from './simulation-goods-location';
import { SimulationServiceLocation } from './simulation-service-location';
import { SimulationOfferPreparations } from './simulation-offer-preparations';
import { SimulationOperations } from './simulation-operations';
@@ -110,6 +111,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
<SimulationValueReview draft={draft} onChange={onChange} />
<SimulationServiceNormal draft={draft} onChange={onChange} />
<SimulationGoodsLocation draft={draft} onChange={onChange} />
<SimulationServiceLocation draft={draft} onChange={onChange} />
<SimulationOfferPreparations draft={draft} onChange={onChange} />
<SimulationModeloA draft={draft} />
</section>;
@@ -0,0 +1,42 @@
import {useState} from 'react';
import {newServiceLocation,switchServiceTreatment,serviceLocationBook,serviceLocationConditions,ownServiceConditions,serviceLocationCurrent,serviceLocationReportRows,serviceLocationSource,serviceLocationTreatments,recordServiceLocations,saveServiceLocationBook,type ServiceLocationBook,type ServiceLocationRow} from '@/lib/iva-service-location';
export function SimulationServiceLocation({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
const[error,setError]=useState('');let book:ServiceLocationBook;
try{book=serviceLocationBook(draft);}catch(cause){return<p role="alert">{String(cause)}</p>;}
const save=(next:ServiceLocationBook)=>{try{onChange('modelo_a_snapshot',saveServiceLocationBook(draft,next));setError('');}catch(cause){setError(String(cause));}};
const change=(id:string,patch:Partial<ServiceLocationRow>)=>save({...book,rows:book.rows.map(row=>row.id===id?{...row,...patch}:row)});
return<details className="space-y-3 rounded border p-3">
<summary>Localização — serviços, imóveis, trabalhos, actividades e transporte</summary>
<p>Oportunidade: {String(draft.context_opportunity||'entrada geral')}. Regra geral:ponto de estabelecimento do prestador a partir do qual o serviço é prestado. Imóveis,execução total/essencial em bens móveis e actividades materiais têm percursos próprios,independentemente da sede do prestador.</p>
<p>Conferir primeiro a natureza e as regras especiais. Endereço/nacionalidade do cliente não substituem os factos relevantes. Digital e outros6(7)/(8)seguem os seus percursos. Classificação territorial não concede isenção nem calcula IVA.</p>
<p>{serviceLocationSource}</p>{error&&<p role="alert">{error}</p>}
<button type="button" className="underline" onClick={()=>save({...book,rows:[...book.rows,newServiceLocation(crypto.randomUUID())]})}>Acrescentar localização de serviço</button>
{book.rows.map(row=><details key={row.id} className="rounded border p-3"><summary>{row.description||'Operação por identificar'} — {row.period||'mês por indicar'}</summary>
<div className="grid gap-3 md:grid-cols-2">
<label>Percurso de localização do serviço<select className="block rounded border p-2" value={row.treatment} onChange={e=>change(row.id,switchServiceTreatment(row,e.target.value))}><option value="">Por indicar</option>{serviceLocationTreatments.map(t=><option key={t.id} value={t.id}>{t.label}</option>)}</select></label>
<label>Natureza dos dados<select className="block rounded border p-2" value={row.origin} onChange={e=>change(row.id,{origin:e.target.value as ServiceLocationRow['origin']})}><option value="projected">Projectados / pressupostos</option><option value="effective">Efectivos declarados</option></select></label>
{([['description','Serviço identificado'],['operationRef','Referência da operação / pressuposto'],['operationDate','Data do serviço / prevista'],['period','Mês do serviço'],['documentRef','Documento da operação / referência da previsão'],['documentDate','Data do documento / prevista'],['scope','Âmbito: serviço, operação, período e condições'],['evidence','Fundamento factual / pressupostos']] as const).map(([field,label])=><label key={field}>{label}<input className="block rounded border p-2" type={['operationDate','documentDate'].includes(field)?'date':field==='period'?'month':'text'} value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
{row.treatment==='transport_service'?<>
<p>Uma linha por trajecto;ida e volta separadas. Use a mesma unidade nas duas distâncias. Partida e chegada nacionais incluem percurso fora do país6(6).</p>
{([['routeRef','Trajecto e referência / pressuposto'],['totalDistance','Distância total'],['nationalDistance','Distância fisicamente nacional']] as const).map(([field,label])=><label key={field}>{label}<input className="block rounded border p-2" value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
{([['departure','País do ponto de partida'],['arrival','País do ponto de chegada']] as const).map(([field,label])=><label key={field}>{label}<select className="block rounded border p-2" value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}><option value="">Por indicar</option><option value="MZ">Moçambique</option><option value="foreign">Estrangeiro</option></select></label>)}
</>:row.treatment==='general_service'?<>
<label>Estabelecimento do prestador a partir do qual o serviço é prestado<input className="block rounded border p-2" value={row.providerPlace} onChange={e=>change(row.id,{providerPlace:e.target.value})}/></label>
<label>País desse estabelecimento<select className="block rounded border p-2" value={row.providerCountry} onChange={e=>change(row.id,{providerCountry:e.target.value})}><option value="">Por indicar</option><option value="MZ">Moçambique</option><option value="foreign">Estrangeiro</option></select></label>
<label>Referência do estabelecimento prestador / pressuposto<input className="block rounded border p-2" value={row.providerRef} onChange={e=>change(row.id,{providerRef:e.target.value})}/></label>
</>:row.treatment&&<>
<label>{row.treatment==='immovable_service'?'Local do imóvel':row.treatment==='movable_work'?'Local de execução total ou essencial':'Local material da actividade ou serviço acessório'}<input className="block rounded border p-2" value={row.servicePlace} onChange={e=>change(row.id,{servicePlace:e.target.value})}/></label>
<label>Situação do local relevante<select className="block rounded border p-2" value={row.placeCountry} onChange={e=>change(row.id,{placeCountry:e.target.value})}><option value="">Por indicar</option><option value="MZ">Moçambique</option><option value="foreign">Estrangeiro</option></select></label>
<label>Referência da localização / pressuposto<input className="block rounded border p-2" value={row.placeRef} onChange={e=>change(row.id,{placeRef:e.target.value})}/></label>
<label>Imóvel, bens ou actividade e relação do serviço<input className="block rounded border p-2" value={row.subjectRef} onChange={e=>change(row.id,{subjectRef:e.target.value})}/></label>
</>}
</div>
<p>Mudar percurso conserva os locais e referências,mas exige rever os factos próprios. Campos sem aplicação ficam ocultos e conservados.</p>
<fieldset><legend>Factos próprios deste percurso</legend>{[...serviceLocationConditions,...ownServiceConditions(row.treatment)].map(([key,label])=><label className="block" key={key}>{label}<select className="block rounded border p-2" value={row.conditions[key]??''} onChange={e=>change(row.id,{conditions:{...row.conditions,[key]:e.target.value as ''|'yes'|'no'}})}><option value="">Por indicar</option><option value="yes">Sim</option><option value="no">Não</option></select></label>)}</fieldset>
</details>)}
{!!book.rows.length&&<button type="button" className="rounded bg-emerald-800 p-2 text-white" onClick={()=>{try{save(recordServiceLocations(draft,book));}catch(cause){setError(String(cause));}}}>Classificar localização e guardar versão</button>}
{book.result&&<><p role="status">{serviceLocationCurrent(draft,book)?'Localizações correspondentes às entradas guardadas.':'Entradas alteradas;localizações históricas conservadas. Reveja e classifique novamente.'} {book.history?.length??0} versões anteriores. Classificação não determina isenção,IVA ou campo11.</p><dl>{serviceLocationReportRows(draft).map(([,label,value,origin],index)=><div key={index}><dt className="font-semibold">{label}</dt><dd>{value}<small className="block">{origin}</small></dd></div>)}</dl></>}
</details>;
}
+66
View File
@@ -0,0 +1,66 @@
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
export const serviceLocationVersion='service-territoriality/1';
export const serviceLocationSource='CIVA6(3),6(4)/(5)(a–d),6(6),Lei13/2016PDF7–8/p1426(265–266);AvisoBR89-I,08/06/2017,PDF1 rectifica6(5)c/d:actividades materialmente nacionais e transporte pela distância nacional;Lei10/2025PDF12mantém6(3–6),altera7–9digitais. Recorte2026:regra geral sem regras especiais aplicáveis;imóveis,trabalhos/peritagens em móveis e actividades presenciais pelos factos próprios. Sem taxa/IVA/isenção/dedução/campo11/totaisModeloA;digital e outros6(7)/(8)não classificados por estes percursos.';
export const serviceLocationTreatments=[{id:'general_service',label:'P106 — Regra geral dos serviços',branch:'P106'},{id:'immovable_service',label:'P107 — Serviços relacionados com imóveis',branch:'P107'},{id:'movable_work',label:'P108 — Trabalhos/peritagens em bens móveis',branch:'P108'},{id:'activity_service',label:'P109 — Actividades presenciais e serviços acessórios',branch:'P109'},{id:'transport_service',label:'P110 — Transporte por distância e percurso',branch:'P110'}] as const;
export const serviceLocationConditions=[['serviceSupply','Está identificada uma prestação de serviços e a operação/período correspondentes'],['otherSpecialRulesExcluded','Foi conferida a natureza da operação: não está abrangida neste caso por regime digital ou regras6(7)/(8)que exijam outro percurso']] as const;
export const serviceOwnConditions={
general_service:[['noPlaceException','Não se aplica nenhuma excepção de localização6(4)/(5):imóveis,trabalhos/peritagens em móveis,actividades materiais ou transporte'],['providerEstablishment','Identificada a sede,estabelecimento estável ou domicílio do prestador a partir do qual este serviço é prestado']],
immovable_service:[['propertyRelation','O serviço relaciona-se com o imóvel identificado,incluindo preparação/coordenação de trabalhos imobiliários,peritos ou agentes imobiliários']],
movable_work:[['corporealMovable','São trabalhos em bens móveis corpóreos ou peritagens a eles referentes'],['totalEssential','O local indicado corresponde à execução total ou essencial dos trabalhos/peritagens']],
activity_service:[['activityNature','Serviço artístico,científico,desportivo,recreativo,de ensino ou similar,incluindo organizadores e serviços acessórios dessa actividade'],['materialActivity','A actividade ou serviço acessório tem materialmente lugar no local indicado']],
transport_service:[['transportNature','Prestação de transporte com percurso e distâncias identificados'],['routeCorresponds','Distâncias e pontos de partida/chegada correspondem a este trajecto;ida e volta são trajectos distintos6(6)']],
} as const;
export function ownServiceConditions(treatment:string):ReadonlyArray<readonly[string,string]>{return serviceOwnConditions[treatment as keyof typeof serviceOwnConditions]??[];}
type Choice=''|'yes'|'no';type Condition=typeof serviceLocationConditions[number][0];
export type ServiceLocationRow={id:string;treatment:string;description:string;period:string;origin:'projected'|'effective';operationDate:string;operationRef:string;documentRef:string;documentDate:string;servicePlace:string;placeCountry:string;placeRef:string;totalDistance:string;nationalDistance:string;departure:string;arrival:string;routeRef:string;providerRef:string;providerPlace:string;providerCountry:string;subjectRef:string;scope:string;evidence:string;conditions:Partial<Record<string,Choice>>};
type LineResult={id:string;status:'determined'|'not_applicable'|'indeterminate';territory:'national'|'outside'|'partial'|null;baseAmount:null;vat:null;rate:null;reason:string};
export type ServiceLocationCalculation={version:string;revision:number;calculatedAt:string;opportunity:string;location:string;sourceKey:string;source:string;inputs:ServiceLocationRow[];rows:LineResult[]};
export type ServiceLocationBook={rows:ServiceLocationRow[];result?:ServiceLocationCalculation;history?:ServiceLocationCalculation[]};
type Store=ReturnType<typeof readAcquisitionStore>&{serviceLocations?:Record<string,ServiceLocationBook>};
export function newServiceLocation(id:string):ServiceLocationRow{return{id,treatment:'',description:'',period:'',origin:'projected',operationDate:'',operationRef:'',documentRef:'',documentDate:'',servicePlace:'',placeCountry:'',placeRef:'',totalDistance:'',nationalDistance:'',departure:'',arrival:'',routeRef:'',providerRef:'',providerPlace:'',providerCountry:'',subjectRef:'',scope:'',evidence:'',conditions:{}};}
export function switchServiceTreatment(row:ServiceLocationRow,treatment:string):ServiceLocationRow{return{...row,treatment,conditions:Object.fromEntries(serviceLocationConditions.map(([key])=>[key,row.conditions[key]??'']))};}
function readStore(draft:Record<string,unknown>):Store{
const store=readAcquisitionStore(draft.modelo_a_snapshot) as Store;
if(store.serviceLocations!==undefined&&(!store.serviceLocations||typeof store.serviceLocations!=='object'||Array.isArray(store.serviceLocations)))throw new Error('Localizações de serviços não recuperáveis;originais preservados.');
const fields=Object.keys(newServiceLocation('')).filter(key=>key!=='conditions');
for(const book of Object.values(store.serviceLocations??{}))if(!book||!Array.isArray(book.rows)||(book.history!==undefined&&!Array.isArray(book.history))||book.rows.some(row=>!row||fields.some(key=>typeof row[key as keyof ServiceLocationRow]!=='string')||!row.conditions||typeof row.conditions!=='object'||Array.isArray(row.conditions)))throw new Error('Linhas de localização de serviços não recuperáveis;originais preservados,sem substituição por lista vazia.');
return store;
}
export function serviceLocationBook(draft:Record<string,unknown>):ServiceLocationBook{return readStore(draft).serviceLocations?.[acquisitionScope(draft)]??{rows:[]};}
export function saveServiceLocationBook(draft:Record<string,unknown>,book:ServiceLocationBook):string{const current=readStore(draft),encoded=JSON.stringify({...current,serviceLocations:{...current.serviceLocations,[acquisitionScope(draft)]:book}});readStore({...draft,modelo_a_snapshot:encoded});return encoded;}
function validDate(value:string):boolean{const d=new Date(value+'T00:00:00Z');return /^\d{4}-\d{2}-\d{2}$/.test(value)&&Number.isFinite(d.getTime())&&d.toISOString().slice(0,10)===value;}
export function evaluateServiceLocation(row:ServiceLocationRow):LineResult{
const result:LineResult={id:row.id,status:'indeterminate',territory:null,baseAmount:null,vat:null,rate:null,reason:''};const stop=(reason:string,status:LineResult['status']='indeterminate')=>({...result,status,reason});
const rule=serviceLocationTreatments.find(t=>t.id===row.treatment);if(!rule)return stop('Identifique o percurso do serviço;desconhecido não determina território.');
if(!/^2026-(0[1-9]|1[0-2])$/.test(row.period)||!['projected','effective'].includes(row.origin)||!validDate(row.operationDate)||row.operationDate.slice(0,7)!==row.period)return stop('Identifique mês2026,data correspondente e natureza efectiva/projectada dos dados.');
const conditions=[...serviceLocationConditions,...ownServiceConditions(row.treatment)];
if(conditions.some(([key])=>row.conditions[key]==='no'))return stop('Este percurso não se aplica aos factos declarados;rever a regra própria,sem presumir fora do território,isenção ou IVAzero.','not_applicable');
if(conditions.some(([key])=>row.conditions[key]!=='yes'))return stop('Factos ou regra própria por esclarecer;localização indeterminada,não isenção ou IVAzero.');
if(!row.description.trim()||!row.operationRef.trim()||!row.documentRef.trim()||!validDate(row.documentDate)||!row.scope.trim()||!row.evidence.trim())return stop('Identifique serviço/operação,documento/data,âmbito e fundamento factual ou pressupostos expressos;reutilize dados sem certificado/aprovador novo.');
if(row.treatment==='transport_service'){
const parse=(v:string):bigint|null=>/^\d{1,18}(\.\d{1,6})?$/.test(v)?BigInt(v.split('.')[0])*1000000n+BigInt((v.split('.')[1]??'').padEnd(6,'0')):null;
const total=parse(row.totalDistance),national=parse(row.nationalDistance);
if(total===null||total===0n||national===null||national>total||!['MZ','foreign'].includes(row.departure)||!['MZ','foreign'].includes(row.arrival)||!row.routeRef.trim())return stop('Identifique percurso,pontos de partida/chegada e distâncias na mesma unidade,total positiva e nacional entre zero e total;desconhecido não é zero.');
const deemed=row.departure==='MZ'&&row.arrival==='MZ';const covered=deemed?total:national;const territory=covered===0n?'outside':covered===total?'national':'partial';
return{...result,status:'determined',territory,reason:`P110:artigo6(4)d/6(5)d rectificado pelo AvisoBR89-I,08/06/2017,PDF1,e6(6). ${deemed?'Partida e chegada no território nacional:inclui o percurso efectuado fora dos mesmos.':`Parcela nacional:distância ${row.nationalDistance} de ${row.totalDistance},na mesma unidade,sem arredondamento ou apuramento monetário.`} Ida e volta são trajectos distintos:recolher uma linha por trajecto. ${row.origin==='projected'?'Pressupostos expressos.':'Dados efectivos declarados.'} Fora do território não equivale a isenção/IVAzero;sem base,taxa,IVA,dedução/campo11 ou totaisModeloA.`};
}
const general=row.treatment==='general_service';const country=general?row.providerCountry:row.placeCountry;
if(!['MZ','foreign'].includes(country)||!(general?row.providerPlace:row.servicePlace).trim()||!(general?row.providerRef:row.placeRef).trim()||(!general&&!row.subjectRef.trim()))return stop(general?'Identifique sede/EP/domicílio do prestador a partir do qual o serviço é prestado,país e referência;endereço do cliente não substitui esse estabelecimento.':'Identifique local,país,referência e imóvel/bens/actividade a que respeita;endereço do cliente ou sede do prestador não substituem esses factos.');
const territory=country==='MZ'?'national':'outside';const clause=general?'6(3)':`6(${territory==='national'?'5':'4'})(${row.treatment==='immovable_service'?'a':row.treatment==='movable_work'?'b':'c'})`;
const fact=general?'estabelecimento do prestador a partir do qual o serviço é prestado':row.treatment==='immovable_service'?'local do imóvel':row.treatment==='movable_work'?'local de execução total ou essencial dos trabalhos/peritagens':'local material da actividade ou serviço acessório';
return{...result,status:'determined',territory,reason:`${rule.branch}:artigo${clause},${fact} ${territory==='national'?'emMoçambique':'fora deMoçambique'},${row.origin==='projected'?'sob pressupostos expressos':'segundo dados efectivos declarados'}. ${general?'Regras especiais conferidas como não aplicáveis.':'Regra territorial própria,independente da sede nacional ou estrangeira do prestador.'} Fora deste âmbito não equivale a isenção/IVAzero;não calcula base,taxa,IVA,dedução/campo11 ou totaisModeloA.`};
}
function key(draft:Record<string,unknown>,book:ServiceLocationBook):string{return JSON.stringify({version:serviceLocationVersion,scope:acquisitionScope(draft),location:draft.selection_labels??{},rows:book.rows});}
export function serviceLocationCurrent(draft:Record<string,unknown>,book:ServiceLocationBook):boolean{return!!book.result&&book.result.sourceKey===key(draft,book);}
export function recordServiceLocations(draft:Record<string,unknown>,book:ServiceLocationBook):ServiceLocationBook{
if(!book.rows.length||new Set(book.rows.map(row=>row.id)).size!==book.rows.length)throw new Error('Indique operações com identificadores distintos;originais conservados.');
const result:ServiceLocationCalculation={version:serviceLocationVersion,revision:(book.result?.revision??0)+1,calculatedAt:new Date().toISOString(),opportunity:acquisitionScope(draft),location:JSON.stringify(draft.selection_labels??{}),sourceKey:key(draft,book),source:serviceLocationSource,inputs:structuredClone(book.rows),rows:book.rows.map(row=>evaluateServiceLocation(row))};
const next={...book,result,history:[...(book.history??[]),...(book.result?[book.result]:[])]};saveServiceLocationBook(draft,next);return next;
}
export function serviceLocationReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{
try{const book=serviceLocationBook(draft),result=book.result;if(!result)return[];return result.inputs.map((row,index)=>['IVA mensal',`Localização ${serviceLocationTreatments.find(t=>t.id===row.treatment)?.branch??'serviço'} — ${row.period} — ${row.id}`,`${row.description};classificação territorial: ${result.rows[index].territory==='national'?'Moçambique':result.rows[index].territory==='outside'?'Fora do território nacional':result.rows[index].territory==='partial'?'Parcela nacional do transporte':'Indeterminada'};IVA: Indeterminado;${result.rows[index].reason}`,`${result.opportunity};${result.location};${serviceLocationCurrent(draft,book)?'Resultado actual':'Entradas alteradas;resultado histórico conservado'};versão${result.revision};${result.calculatedAt};${result.source};${row.origin==='projected'?'Projecção':'Dados efectivos declarados'};${serviceLocationTreatments.find(t=>t.id===row.treatment)?.label??'Percurso por indicar'};operação ${row.operationRef},${row.operationDate};${row.treatment==='transport_service'?`trajecto ${row.routeRef};partida ${row.departure};chegada ${row.arrival};distâncias nacional/total ${row.nationalDistance}/${row.totalDistance}`:row.treatment==='general_service'?`estabelecimento prestador ${row.providerPlace},${row.providerCountry};referência ${row.providerRef}`:`local ${row.servicePlace},${row.placeCountry};referência ${row.placeRef};imóvel/bens/actividade ${row.subjectRef}`};âmbito ${row.scope};documento ${row.documentRef},${row.documentDate};condições ${[...serviceLocationConditions,...ownServiceConditions(row.treatment)].map(([key,label])=>label+': '+(row.conditions[key]==='yes'?'Sim':row.conditions[key]==='no'?'Não':'Por indicar')).join('; ')};fundamento ${row.evidence};não determina isenção/IVA/campo11/totaisModeloA.`]);}
catch{return[['IVA mensal','Localização de serviços','Dados não recuperáveis','Originais preservados;sem classificar por defeito.']];}
}
+2
View File
@@ -15,6 +15,7 @@ import { exclusionReportRows } from './iva-exclusion-base.ts';
import { valueReviewReportRows } from './iva-value-review.ts';
import { serviceNormalReportRows } from './iva-service-normal.ts';
import { goodsLocationReportRows } from './iva-goods-location.ts';
import { serviceLocationReportRows } from './iva-service-location.ts';
import { offerPreparationReportRows } from './iva-offer-preparations.ts';
import { salesReportRows } from './iva-operations.ts';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
@@ -640,6 +641,7 @@ export function buildProjectDossierRows(
rows.push(...valueReviewReportRows(draft));
rows.push(...serviceNormalReportRows(draft));
rows.push(...goodsLocationReportRows(draft));
rows.push(...serviceLocationReportRows(draft));
rows.push(...offerPreparationReportRows(draft));
const operationRows = salesReportRows(draft);
rows.push(...operationRows);
@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IvaServiceLocationDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_service_locations_recover_privately_preserving_all_delivered_books(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::factory()->create(['email_verified_at' => now()]));
$draft = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-g8-services.json')), true, flags: JSON_THROW_ON_ERROR);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => [], 'economic_snapshot' => $draft], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$recovered = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($draft['modelo_a_snapshot'], $recovered['modelo_a_snapshot']);
$this->assertSame('opportunity:349', $recovered['context_opportunity']);
$this->assertSame('Manica', $recovered['selection_labels']['province_id']);
$store = json_decode($recovered['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR);
$book = $store['serviceLocations']['opportunity:349'];
$this->assertCount(10, $book['rows']);
$this->assertSame(['general_service','general_service','immovable_service','immovable_service','movable_work','movable_work','activity_service','activity_service','transport_service','transport_service'],array_column($book['rows'],'treatment'));
$this->assertSame(['national','outside','national','outside','national','outside','national','outside','national','outside'],array_column($book['result']['rows'],'territory'));
foreach ($book['result']['rows'] as $row) {
$this->assertNull($row['baseAmount']);$this->assertNull($row['vat']);$this->assertNull($row['rate']);
}
$this->assertSame($book['rows'], $book['result']['inputs']);
$this->assertCount(1, $book['history']);
$this->assertNull($book['history'][0]['rows'][0]['baseAmount']);
$frozen = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-g8-services-preserved.json')), true, flags: JSON_THROW_ON_ERROR);
$prior = json_decode($frozen['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR);
$this->assertSame($prior, array_diff_key($store, ['serviceLocations' => true]));
$this->assertSame('1250.30', $store['publicAuthorityBases']['opportunity:339']['result']['rows'][1]['baseAmount']);
$this->assertNull($store['publicAuthorityBases']['opportunity:339']['result']['rows'][1]['vat']);
$this->assertSame('400.00', $store['importExemptions']['opportunity:338']['result']['rows'][1]['importVat']);
$this->assertNull($store['books']['opportunity:329']['result']['periods'][0]['fields']['11']);
file_put_contents(base_path('outputs/iva-l1-g8-services-20261024/recovered.json'), json_encode($recovered, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {newServiceLocation,switchServiceTreatment,serviceLocationConditions,ownServiceConditions,serviceLocationTreatments,evaluateServiceLocation,recordServiceLocations,saveServiceLocationBook,serviceLocationBook,serviceLocationCurrent,serviceLocationReportRows} from '../../resources/js/lib/iva-service-location.ts';
const frozen=JSON.parse(fs.readFileSync('tests/frontend/fixtures/iva-g8-services-preserved.json','utf8'));
const draft={...frozen,context_opportunity:'opportunity:349',project_title:'SYNTHETIC QA — G8 services / Manica'};
function row(t,country='MZ') {return {...newServiceLocation(t+'-'+country),treatment:t,description:'SYNTHETIC QA serviço',period:'2026-12',origin:'projected',operationDate:'2026-12-12',operationRef:'SYNTHETIC QA operação',documentRef:'SYNTHETIC QA documento/pressuposto',documentDate:'2026-12-12',scope:'SYNTHETIC QA operação/serviço/dezembro2026',evidence:'SYNTHETIC QA pressupostos expressos',servicePlace:'SYNTHETIC QA local material',placeCountry:country,placeRef:'SYNTHETIC QA referência local',providerPlace:'SYNTHETIC QA estabelecimento prestador',providerCountry:country,providerRef:'SYNTHETIC QA referência estabelecimento',subjectRef:'SYNTHETIC QA imóvel/bens/actividade',totalDistance:'100.50',nationalDistance:country==='MZ'?'80.25':'0',departure:country,arrival:country,routeRef:'SYNTHETIC QA trajecto individual',conditions:Object.fromEntries([...serviceLocationConditions,...ownServiceConditions(t)].map(([key])=>[key,'yes']))};}
for(const treatment of serviceLocationTreatments)test(treatment.branch+' facts,own guards and territorial direction',()=>{
for(const country of ['MZ','foreign']){
const r=row(treatment.id,country);const expected=country==='MZ'?'national':'outside';assert.equal(evaluateServiceLocation(r).territory,expected);
assert.deepEqual(['baseAmount','vat','rate'].map(k=>evaluateServiceLocation(r)[k]),[null,null,null]);
if(treatment.id!=='general_service'){r.providerCountry=country==='MZ'?'foreign':'MZ';assert.equal(evaluateServiceLocation(r).territory,expected);}
for(const [key] of [...serviceLocationConditions,...ownServiceConditions(treatment.id)]){const missing=structuredClone(r);delete missing.conditions[key];assert.equal(evaluateServiceLocation(missing).status,'indeterminate');missing.conditions[key]='no';assert.equal(evaluateServiceLocation(missing).status,'not_applicable');assert.equal(evaluateServiceLocation(missing).territory,null);}
for(const field of ['operationRef','documentRef','scope','evidence',...(treatment.id==='general_service'?['providerPlace','providerCountry','providerRef']:treatment.id==='transport_service'?['routeRef','totalDistance','nationalDistance','departure','arrival']:['servicePlace','placeCountry','placeRef','subjectRef'])])assert.equal(evaluateServiceLocation({...r,[field]:''}).status,'indeterminate',field);
assert.equal(evaluateServiceLocation({...r,operationDate:'2026-11-12'}).status,'indeterminate');assert.equal(evaluateServiceLocation({...r,documentDate:'2026-02-30'}).status,'indeterminate');assert.equal(evaluateServiceLocation({...r,period:'2025-12'}).status,'indeterminate');
}
});
test('Four branches persist together,history,scope,hidden retention and previous books',()=>{
const rows=serviceLocationTreatments.flatMap(t=>['MZ','foreign'].map(c=>row(t.id,c)));
let book=recordServiceLocations(draft,{rows});book=recordServiceLocations(draft,book);assert.equal(book.history.length,1);
const next={...draft,modelo_a_snapshot:saveServiceLocationBook(draft,book)};assert.deepEqual(serviceLocationBook(next),book);assert.ok(serviceLocationCurrent(next,book));assert.equal(serviceLocationCurrent({...next,context_opportunity:'other'},book),false);
assert.equal(serviceLocationCurrent({...next,currency:'USD',customer_country:'foreign'},book),true);
assert.equal(serviceLocationReportRows(next).length,10);for(const t of serviceLocationTreatments)assert.ok(serviceLocationReportRows(next).some(r=>r[1].includes(t.branch)));
const store=JSON.parse(next.modelo_a_snapshot);delete store.serviceLocations;assert.deepEqual(store,JSON.parse(frozen.modelo_a_snapshot));
const switched=switchServiceTreatment(rows[0],'immovable_service');assert.equal(switched.providerRef,rows[0].providerRef);assert.equal(switched.servicePlace,rows[0].servicePlace);assert.equal(switched.conditions.propertyRelation,undefined);assert.equal(evaluateServiceLocation(switched).status,'indeterminate');
const corrupt={...next,modelo_a_snapshot:JSON.stringify({...JSON.parse(next.modelo_a_snapshot),serviceLocations:[]})};assert.throws(()=>serviceLocationBook(corrupt));assert.throws(()=>recordServiceLocations(next,{rows:[rows[0],rows[0]]}));
if(process.env.IVA_G8_SERVICES_FIXTURE)fs.writeFileSync(process.env.IVA_G8_SERVICES_FIXTURE,JSON.stringify(next,null,2)+'\n');
});
test('P110 domestic deemed distance,mixed route,zero,invalid and exact distances',()=>{
const r=row('transport_service');assert.equal(evaluateServiceLocation(r).territory,'national');
assert.equal(evaluateServiceLocation({...r,arrival:'foreign'}).territory,'partial');
assert.equal(evaluateServiceLocation({...r,arrival:'foreign',nationalDistance:'0'}).territory,'outside');
assert.equal(evaluateServiceLocation({...r,arrival:'foreign',nationalDistance:r.totalDistance}).territory,'national');
for(const patch of [{totalDistance:'0'},{nationalDistance:'101'},{nationalDistance:'-1'},{totalDistance:'1e3'},{totalDistance:'1,5'},{departure:''},{arrival:''}])assert.equal(evaluateServiceLocation({...r,...patch}).status,'indeterminate');
assert.equal(evaluateServiceLocation({...r,arrival:'foreign',nationalDistance:'0.000001',totalDistance:'999999999999999999.123456'}).territory,'partial');
assert.match(evaluateServiceLocation(r).reason,/08\/06\/2017/);assert.match(evaluateServiceLocation(r).reason,/Ida e volta/);
});