feat(iva): prepare P103 price comparison and referenced correction in QA
This commit is contained in:
@@ -13,6 +13,7 @@ import { SimulationFuelBase } from './simulation-fuel-base';
|
||||
import { SimulationNoncashBase } from './simulation-noncash-base';
|
||||
import { SimulationCompositionBase } from './simulation-composition-base';
|
||||
import { SimulationExclusionBase } from './simulation-exclusion-base';
|
||||
import { SimulationValueReview } from './simulation-value-review';
|
||||
import { SimulationOfferPreparations } from './simulation-offer-preparations';
|
||||
import { SimulationOperations } from './simulation-operations';
|
||||
|
||||
@@ -104,6 +105,7 @@ export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
|
||||
<SimulationNoncashBase draft={draft} onChange={onChange} />
|
||||
<SimulationCompositionBase draft={draft} onChange={onChange} />
|
||||
<SimulationExclusionBase draft={draft} onChange={onChange} />
|
||||
<SimulationValueReview draft={draft} onChange={onChange} />
|
||||
<SimulationOfferPreparations draft={draft} onChange={onChange} />
|
||||
<SimulationModeloA draft={draft} />
|
||||
</section>;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {useState} from 'react';
|
||||
import {newValueReview,switchValueReview,switchPriceContext,switchValueOrigin,valueReviewBook,valueReviewConditions,valueReviewCurrent,valueReviewReportRows,valueReviewSource,valueReviewTreatments,priceContexts,recordValueReviews,saveValueReviewBook,type ValueReviewBook,type ValueReviewRow} from '@/lib/iva-value-review';
|
||||
|
||||
export function SimulationValueReview({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
|
||||
const[error,setError]=useState('');let book:ValueReviewBook;
|
||||
try{book=valueReviewBook(draft);}catch(cause){return<p role="alert">{String(cause)}</p>;}
|
||||
const save=(next:ValueReviewBook)=>{try{onChange('modelo_a_snapshot',saveValueReviewBook(draft,next));setError('');}catch(cause){setError(String(cause));}};
|
||||
const change=(id:string,patch:Partial<ValueReviewRow>)=>save({...book,rows:book.rows.map(row=>row.id===id?{...row,...patch}:row)});
|
||||
return<details className="space-y-3 rounded border p-3">
|
||||
<summary>Base tributável — comparação de preços e correcção referenciada</summary>
|
||||
<p>Oportunidade: {String(draft.context_opportunity||'entrada geral')}. Compare a contraprestação com o preço corrente ou normal correspondente à natureza da operação. Um preço inferior permite à AT proceder à correcção;esta comparação não exerce esse poder nem substitui automaticamente a contraprestação.</p>
|
||||
<p>Para registar uma correcção,identifique o montante,referência,data e âmbito aplicáveis à operação e período. Na projecção,um cenário condicionado permanece identificado como pressuposto,não decisão AT concedida. Base e IVA são resultados distintos.</p>
|
||||
<p>{valueReviewSource}</p>{error&&<p role="alert">{error}</p>}
|
||||
<button type="button" className="underline" onClick={()=>save({...book,rows:[...book.rows,newValueReview(crypto.randomUUID(),String(draft.currency??''))]})}>Acrescentar comparação de contraprestaçã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 da comparação<select className="block rounded border p-2" value={row.treatment} onChange={e=>change(row.id,switchValueReview(row,e.target.value))}><option value="">Por indicar</option>{valueReviewTreatments.map(t=><option key={t.id} value={t.id}>{t.label}</option>)}</select></label>
|
||||
<label>Contexto do preço de referência<select className="block rounded border p-2" value={row.priceContext} onChange={e=>change(row.id,switchPriceContext(row,e.target.value))}><option value="">Por indicar</option>{priceContexts.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,switchValueOrigin(row,e.target.value as ValueReviewRow['origin']))}><option value="projected">Projectados / pressupostos</option><option value="effective">Efectivos declarados</option></select></label>
|
||||
{([['description','Operação identificada'],['operationRef','Referência da operação / pressuposto'],['documentRef','Documento da operação / referência da previsão'],['documentDate','Data do documento / prevista'],['period','Mês da operação'],['currency','Moeda de origem'],['declaredAmount','Contraprestação declarada sem IVA (moeda indicada)'],['referenceAmount','Preço corrente ou normal comparável sem IVA (moeda indicada)'],['referenceRef','Referência do preço corrente ou normal / pressuposto'],['referenceScope','Âmbito do preço: bens/serviços, estádio, período, local e condições'],['compositionRef','Referência da composição equivalente sem próprio IVA'],['evidence','Fundamento factual / pressupostos']] as const).map(([field,label])=><label key={field}>{label}<input className="block rounded border p-2" type={field==='documentDate'?'date':field==='period'?'month':'text'} value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
|
||||
{row.treatment==='referenced_correction'&&<>
|
||||
{([['correctedAmount','Montante da base corrigida / pressuposto condicionado'],['correctionRef','Referência da correcção AT / pressuposto condicionado'],['correctionScope','Âmbito da correcção: bens/serviços, operação, período e condições'],['correctionDate','Data da correcção / prevista']] as const).map(([field,label])=><label key={field}>{label}<input className="block rounded border p-2" type={field==='correctionDate'?'date':'text'} value={row[field]} onChange={e=>change(row.id,{[field]:e.target.value})}/></label>)}
|
||||
<label>{row.origin==='projected'?'O cenário condicionado assume expressamente esta correcção e âmbito,sem afirmar decisão AT concedida?':'A correcção AT referenciada aplica-se a estes bens/serviços,operação,período e condições?'}<select className="block rounded border p-2" value={row.correctionApplies} onChange={e=>change(row.id,{correctionApplies:e.target.value as ValueReviewRow['correctionApplies']})}><option value="">Por indicar</option><option value="yes">Sim</option><option value="no">Não</option></select></label>
|
||||
</>}
|
||||
</div>
|
||||
<p>Mudar percurso/contexto conserva montantes e documentos,mas exige rever as correspondências afectadas. Campos sem aplicação ficam ocultos e conservados;uma comparação não concede nem determina uma correcção.</p>
|
||||
<fieldset><legend>Condições próprias da comparação</legend>{valueReviewConditions.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}})}><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(recordValueReviews(draft,book));}catch(cause){setError(String(cause));}}}>Comparar e guardar versão</button>}
|
||||
{book.result&&<><p role="status">{valueReviewCurrent(draft,book)?'Comparações correspondentes às entradas guardadas.':'Entradas alteradas;comparações históricas conservadas. Reveja e determine novamente.'} {book.history?.length??0} versões anteriores. IVA e campo11 não são preenchidos por estas bases.</p><dl>{valueReviewReportRows(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>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {acquisitionScope,readAcquisitionStore} from './iva-acquisitions.ts';
|
||||
|
||||
export const valueReviewVersion='value-review-base/1';
|
||||
export const valueReviewSource='CIVA republicado Lei13/2016,15(7),PDF14/p1426(272):para efeitos15(1),quando a contraprestação é inferior à resultante dos preços correntes ou normais de venda à porta da fábrica,por grosso ou a retalho,ou aos preços correntes/normais do serviço,tendo em conta a natureza das transmissões,a Administração Tributária pode proceder à correcção. Lei22/2022PDF3mantém15(7);Lei10/2025não altera15. Recorte2026/MZN;comparação não exerce o poder da AT,nem substitui automaticamente a contraprestação pelo valor de referência. Sem incidência,taxa,IVA,dedução/campo11,regularizações ou totaisModeloA.';
|
||||
export const valueReviewTreatments=[{id:'price_comparison',label:'Comparar sem correcção automática'},{id:'referenced_correction',label:'Registar correcção referenciada / cenário condicionado'}] as const;
|
||||
export const priceContexts=[{id:'factory',label:'Venda à porta da fábrica'},{id:'wholesale',label:'Venda por grosso'},{id:'retail',label:'Venda a retalho'},{id:'service',label:'Preço corrente ou normal do serviço'}] as const;
|
||||
export const valueReviewConditions=[
|
||||
['taxableOperation','A operação e a contraprestação para efeitos15(1)estão identificadas'],
|
||||
['referenceCorresponds','O preço corrente ou normal e o contexto escolhido correspondem à natureza desta transmissão ou serviço,no período e âmbito identificados'],
|
||||
['netComposition','Os montantes comparados têm composição equivalente,sem próprioIVA,com inclusões/exclusões aplicáveis15(5)/(6)consideradas'],
|
||||
] as const;
|
||||
type Choice=''|'yes'|'no';type Condition=typeof valueReviewConditions[number][0];
|
||||
export type ValueReviewRow={id:string;treatment:string;priceContext:string;description:string;period:string;origin:'projected'|'effective';currency:string;operationRef:string;documentRef:string;documentDate:string;declaredAmount:string;referenceAmount:string;referenceRef:string;referenceScope:string;compositionRef:string;evidence:string;correctedAmount:string;correctionRef:string;correctionScope:string;correctionDate:string;correctionApplies:Choice;conditions:Partial<Record<Condition,Choice>>};
|
||||
type LineResult={id:string;status:'determined'|'not_applicable'|'indeterminate';baseAmount:string|null;vat:null;rate:null;comparison:'below'|'not_below'|null;reason:string};
|
||||
export type ValueReviewCalculation={version:string;revision:number;calculatedAt:string;opportunity:string;location:string;sourceKey:string;source:string;inputs:ValueReviewRow[];rows:LineResult[]};
|
||||
export type ValueReviewBook={rows:ValueReviewRow[];result?:ValueReviewCalculation;history?:ValueReviewCalculation[]};
|
||||
type Store=ReturnType<typeof readAcquisitionStore>&{valueReviews?:Record<string,ValueReviewBook>};
|
||||
export function newValueReview(id:string,currency:string):ValueReviewRow{return{id,treatment:'',priceContext:'',description:'',period:'',origin:'projected',currency,operationRef:'',documentRef:'',documentDate:'',declaredAmount:'',referenceAmount:'',referenceRef:'',referenceScope:'',compositionRef:'',evidence:'',correctedAmount:'',correctionRef:'',correctionScope:'',correctionDate:'',correctionApplies:'',conditions:{}};}
|
||||
export function switchValueReview(row:ValueReviewRow,treatment:string):ValueReviewRow{return{...row,treatment,correctionApplies:''};}
|
||||
export function switchPriceContext(row:ValueReviewRow,priceContext:string):ValueReviewRow{return{...row,priceContext,conditions:{...row.conditions,referenceCorresponds:''},correctionApplies:''};}
|
||||
export function switchValueOrigin(row:ValueReviewRow,origin:ValueReviewRow['origin']):ValueReviewRow{return{...row,origin,correctionApplies:''};}
|
||||
|
||||
function readStore(draft:Record<string,unknown>):Store{
|
||||
const store=readAcquisitionStore(draft.modelo_a_snapshot) as Store;
|
||||
if(store.valueReviews!==undefined&&(!store.valueReviews||typeof store.valueReviews!=='object'||Array.isArray(store.valueReviews)))throw new Error('Bases de contraprestação não recuperáveis;originais preservados.');
|
||||
const fields=Object.keys(newValueReview('','')).filter(key=>key!=='conditions');
|
||||
for(const book of Object.values(store.valueReviews??{}))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 ValueReviewRow]!=='string')||!row.conditions||typeof row.conditions!=='object'||Array.isArray(row.conditions)))throw new Error('Linhas de contraprestação não recuperáveis;originais preservados,sem substituição por lista vazia.');
|
||||
return store;
|
||||
}
|
||||
export function valueReviewBook(draft:Record<string,unknown>):ValueReviewBook{return readStore(draft).valueReviews?.[acquisitionScope(draft)]??{rows:[]};}
|
||||
export function saveValueReviewBook(draft:Record<string,unknown>,book:ValueReviewBook):string{const current=readStore(draft),encoded=JSON.stringify({...current,valueReviews:{...current.valueReviews,[acquisitionScope(draft)]:book}});readStore({...draft,modelo_a_snapshot:encoded});return encoded;}
|
||||
function decimal(raw:unknown):bigint|null{if(typeof raw!=='string'||!/^\d{1,15}(\.\d{1,6})?$/.test(raw.trim()))return null;const[whole,fraction='']=raw.trim().split('.');return BigInt(whole)*1000000n+BigInt(fraction.padEnd(6,'0'));}
|
||||
function money(n:bigint):string{return`${n/1000000n}.${(n%1000000n).toString().padStart(6,'0').replace(/0+$/,'').padEnd(2,'0')}`;}
|
||||
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 evaluateValueReview(row:ValueReviewRow,currency:unknown):LineResult{
|
||||
const result:LineResult={id:row.id,status:'indeterminate',baseAmount:null,vat:null,rate:null,comparison:null,reason:''};const stop=(reason:string,status:LineResult['status']='indeterminate')=>({...result,status,reason});
|
||||
if(!valueReviewTreatments.some(item=>item.id===row.treatment)||!priceContexts.some(item=>item.id===row.priceContext))return stop('Identifique o percurso e o contexto do preço de referência;desconhecido não permite presumir correcção.');
|
||||
if(currency!=='MZN'||row.currency!==currency)return stop('Moeda fora do âmbito MZN ou divergente do projecto;sem conversão silenciosa.');
|
||||
if(!/^2026-(0[1-9]|1[0-2])$/.test(row.period)||!['projected','effective'].includes(row.origin))return stop('Indique mês em2026 e dados efectivos declarados ou pressupostos expressos de projecção.');
|
||||
const failed=valueReviewConditions.filter(([key])=>row.conditions[key]==='no');if(failed.length)return stop('Não se aplica esta comparação: '+failed.map(([,label])=>label).join('; ')+'. Não é isenção nem IVA zero.','not_applicable');
|
||||
if(valueReviewConditions.some(([key])=>row.conditions[key]!=='yes'))return stop('Confirme as condições próprias da comparação;desconhecido não é zero,não aplicável ou correcção.');
|
||||
if(!row.description.trim()||!row.operationRef.trim()||!row.documentRef.trim()||!validDate(row.documentDate)||!row.referenceRef.trim()||!row.referenceScope.trim()||!row.compositionRef.trim()||!row.evidence.trim())return stop('Identifique operação,documento/data,preço corrente/normal,referência/âmbito e composição,ou pressupostos expressos;sem avaliação automática/certificado/aprovador novo.');
|
||||
const declared=decimal(row.declaredAmount),reference=decimal(row.referenceAmount);if(declared===null||reference===null)return stop('Indique contraprestação e preço de referência comparáveis;valor desconhecido ou inválido não é zero.');
|
||||
result.comparison=declared<reference?'below':'not_below';
|
||||
const comparison=declared<reference?`Contraprestação ${money(declared)} inferior à referência ${money(reference)} por ${money(reference-declared)}MZN;AT pode proceder à correcção,sem automatismo.`:`Contraprestação ${money(declared)} não inferior à referência ${money(reference)}MZN;não se demonstra este pressuposto15(7).`;
|
||||
if(row.treatment==='price_comparison')return{...result,status:'determined',baseAmount:money(declared),reason:comparison+' Contraprestação declarada conservada como base neste recorte,sem correcção pelo portal;IVA/taxa,dedução/campo11,regularizações e totaisModeloA não determinados.'};
|
||||
if(declared>=reference)return stop(comparison+' A correcção deste percurso15(7)não é aplicada. Registo e referências conservados.','not_applicable');
|
||||
if(row.correctionApplies==='no')return stop(comparison+' A correcção referenciada não abrange esta operação/período/condições;sem aplicação automática.','not_applicable');
|
||||
if(row.correctionApplies!=='yes'||!row.correctionRef.trim()||!row.correctionScope.trim()||!validDate(row.correctionDate))return stop(comparison+' Identifique referência,data e âmbito da correcção AT aplicável ou pressuposto expresso do cenário condicionado;comparar não substitui uma correcção.');
|
||||
const corrected=decimal(row.correctedAmount);if(corrected===null)return stop(comparison+' Indique o montante da correcção referenciada ou pressuposto condicionado;sem usar automaticamente o maior valor ou a referência.');
|
||||
return{...result,status:'determined',baseAmount:money(corrected),reason:comparison+` ${row.origin==='projected'?'Cenário condicionado à correcção expressamente assumida,não decisão AT concedida':'Correcção AT nos dados efectivos declarados,com referência/data/âmbito identificados'}:base ${money(corrected)}MZN. Montante da referência não substituído automaticamente. Sem IVA/taxa,dedução/campo11,regularizações ou totaisModeloA.`};
|
||||
}
|
||||
|
||||
function key(draft:Record<string,unknown>,book:ValueReviewBook):string{return JSON.stringify({version:valueReviewVersion,scope:acquisitionScope(draft),location:draft.selection_labels??{},currency:draft.currency,rows:book.rows});}
|
||||
export function valueReviewCurrent(draft:Record<string,unknown>,book:ValueReviewBook):boolean{return!!book.result&&book.result.sourceKey===key(draft,book);}
|
||||
export function recordValueReviews(draft:Record<string,unknown>,book:ValueReviewBook):ValueReviewBook{
|
||||
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:ValueReviewCalculation={version:valueReviewVersion,revision:(book.result?.revision??0)+1,calculatedAt:new Date().toISOString(),opportunity:acquisitionScope(draft),location:JSON.stringify(draft.selection_labels??{}),sourceKey:key(draft,book),source:valueReviewSource,inputs:structuredClone(book.rows),rows:book.rows.map(row=>evaluateValueReview(row,draft.currency))};
|
||||
const next={...book,result,history:[...(book.history??[]),...(book.result?[book.result]:[])]};saveValueReviewBook(draft,next);return next;
|
||||
}
|
||||
export function valueReviewReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{
|
||||
try{const book=valueReviewBook(draft),result=book.result;if(!result)return[];return result.inputs.map((row,index)=>['IVA mensal',`Base P103 — ${row.period} — ${row.id}`,`${row.description};base tributável: ${result.rows[index].baseAmount??'Indeterminada'} ${row.currency};IVA: Indeterminado;${result.rows[index].reason}`,`${result.opportunity};${result.location};${valueReviewCurrent(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'};${valueReviewTreatments.find(t=>t.id===row.treatment)?.label??'Percurso por indicar'};contexto ${priceContexts.find(t=>t.id===row.priceContext)?.label??'Por indicar'};operação ${row.operationRef};contraprestação ${row.declaredAmount};preço referência ${row.referenceAmount},${row.referenceRef};âmbito ${row.referenceScope};${row.treatment==='referenced_correction'?`correcção ${row.correctedAmount},referência ${row.correctionRef},data ${row.correctionDate},âmbito ${row.correctionScope},aplica:${row.correctionApplies==='yes'?'Sim':row.correctionApplies==='no'?'Não':'Por indicar'}`:'Sem correcção automática'};composição ${row.compositionRef};documento ${row.documentRef},${row.documentDate};condições ${valueReviewConditions.map(([key,label])=>label+': '+(row.conditions[key]==='yes'?'Sim':row.conditions[key]==='no'?'Não':'Por indicar')).join('; ')};fundamento ${row.evidence};não preenche totaisModeloA ou campo11.`]);}
|
||||
catch{return[['IVA mensal','Comparação/correcção da contraprestação','Dados não recuperáveis','Originais preservados;sem substituir por zero.']];}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { fuelBaseReportRows } from './iva-fuel-base.ts';
|
||||
import { noncashBaseReportRows } from './iva-noncash-base.ts';
|
||||
import { compositionReportRows } from './iva-composition-base.ts';
|
||||
import { exclusionReportRows } from './iva-exclusion-base.ts';
|
||||
import { valueReviewReportRows } from './iva-value-review.ts';
|
||||
import { offerPreparationReportRows } from './iva-offer-preparations.ts';
|
||||
import { salesReportRows } from './iva-operations.ts';
|
||||
import { fiscalQaRows } from './fiscal-qa-preview.ts';
|
||||
@@ -634,6 +635,7 @@ export function buildProjectDossierRows(
|
||||
rows.push(...noncashBaseReportRows(draft));
|
||||
rows.push(...compositionReportRows(draft));
|
||||
rows.push(...exclusionReportRows(draft));
|
||||
rows.push(...valueReviewReportRows(draft));
|
||||
rows.push(...offerPreparationReportRows(draft));
|
||||
const operationRows = salesReportRows(draft);
|
||||
rows.push(...operationRows);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?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 IvaValueReviewDraftTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
public function test_value_reviews_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-g7h-bases.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:346', $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['valueReviews']['opportunity:346'];
|
||||
$this->assertCount(2, $book['rows']);
|
||||
$this->assertSame(['price_comparison','referenced_correction'], array_column($book['rows'], 'treatment'));
|
||||
$this->assertSame(['1000.25','1400.75'], array_column($book['result']['rows'], 'baseAmount'));
|
||||
$this->assertSame(['determined','determined'], array_column($book['result']['rows'], 'status'));
|
||||
$this->assertSame([null,null], array_column($book['result']['rows'], 'vat'));
|
||||
$this->assertSame([null,null], array_column($book['result']['rows'], 'rate'));
|
||||
$this->assertSame(['below','below'], array_column($book['result']['rows'], 'comparison'));
|
||||
$this->assertSame('1000.25', $book['rows'][0]['declaredAmount']);
|
||||
$this->assertSame('1500.50', $book['rows'][0]['referenceAmount']);
|
||||
$this->assertSame('1400.75', $book['rows'][1]['correctedAmount']);
|
||||
$this->assertSame('projected', $book['rows'][1]['origin']);
|
||||
$this->assertSame('yes', $book['rows'][1]['correctionApplies']);
|
||||
$this->assertSame('retail', $book['rows'][1]['priceContext']);
|
||||
$this->assertSame('2026-12-20', $book['rows'][1]['correctionDate']);
|
||||
$this->assertStringContainsString('condicionado correcção A', $book['rows'][1]['correctionRef']);
|
||||
$this->assertStringContainsString('dezembro2026', $book['rows'][1]['correctionScope']);
|
||||
$this->assertStringContainsString('Cenário condicionado', $book['result']['rows'][1]['reason']);
|
||||
$this->assertStringContainsString('15(7)', $book['result']['source']);
|
||||
$this->assertSame($book['rows'], $book['result']['inputs']);
|
||||
$this->assertCount(2, $book['history']);
|
||||
$this->assertNull($book['history'][0]['rows'][0]['baseAmount']);
|
||||
$frozen = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-g7h-preserved-inputs.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, ['valueReviews' => 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-g7h-value-review-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,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {newValueReview,switchValueReview,switchPriceContext,switchValueOrigin,valueReviewTreatments,priceContexts,valueReviewConditions,evaluateValueReview,recordValueReviews,saveValueReviewBook,valueReviewBook,valueReviewCurrent,valueReviewReportRows} from '../../resources/js/lib/iva-value-review.ts';
|
||||
import {exclusionCurrent} from '../../resources/js/lib/iva-exclusion-base.ts';
|
||||
import {compositionCurrent} from '../../resources/js/lib/iva-composition-base.ts';
|
||||
import {createProjectDossierReport} from '../../resources/js/lib/project-dossier-report.ts';
|
||||
const frozen=JSON.parse(fs.readFileSync(new URL('./fixtures/iva-g7h-preserved-inputs.json',import.meta.url),'utf8'));
|
||||
const row=(treatment='price_comparison')=>({...newValueReview('g7h-'+treatment,'MZN'),treatment,priceContext:'retail',description:'SYNTHETIC QA operação A',period:'2026-12',operationRef:'SYNTHETIC QA operação A',documentRef:'SYNTHETIC QA G7H '+treatment,documentDate:'2026-12-12',declaredAmount:'1000.25',referenceAmount:'1500.50',referenceRef:'SYNTHETIC QA preço corrente A',referenceScope:'SYNTHETIC QA bens A,retalho,dezembro2026,Manica e condições comparáveis',compositionRef:'SYNTHETIC QA composição equivalente15(5)/(6),sem próprioIVA',evidence:'SYNTHETIC QA pressupostos expressos,não documento real',correctedAmount:'1400.75',correctionRef:'SYNTHETIC QA pressuposto condicionado correcção A',correctionScope:'SYNTHETIC QA bens A/operação A/dezembro2026/condições A',correctionDate:'2026-12-20',correctionApplies:'yes',conditions:Object.fromEntries(valueReviewConditions.map(([key])=>[key,'yes']))});
|
||||
const draft=()=>({...structuredClone(frozen),context_opportunity:'opportunity:346',project_title:'SYNTHETIC QA — G7H / Comparação e correcção'});
|
||||
|
||||
test('P103 contexts and ownconditions/references;below comparison never replaces declaredbase automatically',()=>{
|
||||
for(const context of priceContexts){const input={...row(),priceContext:context.id};assert.equal(evaluateValueReview(input,'MZN').baseAmount,'1000.25');assert.equal(evaluateValueReview(input,'MZN').comparison,'below');
|
||||
for(const[key]of valueReviewConditions)for(const value of ['','unknown','no']){const r=evaluateValueReview({...input,conditions:{...input.conditions,[key]:value}},'MZN');assert.equal(r.status,value==='no'?'not_applicable':'indeterminate');assert.equal(r.baseAmount,null);assert.equal(r.vat,null);}
|
||||
for(const patch of [{description:''},{operationRef:''},{documentRef:''},{documentDate:'2026-02-30'},{referenceRef:''},{referenceScope:''},{compositionRef:''},{evidence:''},{period:'2025-12'},{currency:'USD'},{priceContext:'unknown'},{treatment:''},{declaredAmount:''},{referenceAmount:''}])assert.equal(evaluateValueReview({...input,...patch},'MZN').baseAmount,null);
|
||||
}
|
||||
for(const referenceAmount of ['1000.25','500.00']){const r=evaluateValueReview({...row(),referenceAmount},'MZN');assert.equal(r.comparison,'not_below');assert.equal(r.baseAmount,'1000.25');}
|
||||
});
|
||||
|
||||
test('correction amount/reference/scope required;scenario distinct fromeffective;no maximum/default reference;exactdecimal and switches',()=>{
|
||||
const input=row('referenced_correction'),ok=evaluateValueReview(input,'MZN');assert.equal(ok.baseAmount,'1400.75');assert.equal(ok.comparison,'below');assert.match(ok.reason,/Cenário condicionado/);assert.equal(ok.vat,null);assert.equal(ok.rate,null);assert.match(evaluateValueReview({...input,origin:'effective'},'MZN').reason,/dados efectivos declarados/);
|
||||
for(const patch of [{correctedAmount:''},{correctionRef:''},{correctionScope:''},{correctionDate:'2026-02-30'},{correctionApplies:''}]){const r=evaluateValueReview({...input,...patch},'MZN');assert.equal(r.baseAmount,null);assert.equal(r.comparison,'below');}
|
||||
assert.equal(evaluateValueReview({...input,correctionApplies:'no'},'MZN').status,'not_applicable');assert.equal(evaluateValueReview({...input,referenceAmount:'500'},'MZN').status,'not_applicable');
|
||||
for(const correctedAmount of ['0','900.15','999999999999999.999999'])assert.equal(evaluateValueReview({...input,correctedAmount},'MZN').baseAmount,correctedAmount==='0'?'0.00':correctedAmount);
|
||||
for(const correctedAmount of ['-1','NaN','0.0000001'])assert.equal(evaluateValueReview({...input,correctedAmount},'MZN').baseAmount,null);
|
||||
assert.equal(evaluateValueReview({...row(),declaredAmount:'0',referenceAmount:'0'},'MZN').baseAmount,'0.00');assert.equal(evaluateValueReview({...row(),declaredAmount:'1.000001',referenceAmount:'1.000002'},'MZN').comparison,'below');
|
||||
for(const changed of [switchValueReview(input,'price_comparison'),switchPriceContext(input,'wholesale'),switchValueOrigin(input,'effective')]){assert.equal(changed.correctionApplies,'');for(const field of ['declaredAmount','referenceAmount','correctedAmount','correctionRef','correctionScope'])assert.equal(changed[field],input[field]);}
|
||||
assert.equal(evaluateValueReview(switchValueOrigin(input,'effective'),'MZN').baseAmount,null);assert.equal(evaluateValueReview(switchPriceContext(input,'wholesale'),'MZN').baseAmount,null);
|
||||
});
|
||||
|
||||
test('save/recovery/history/report preserve all85 delivered books/currentkeys including P102/P101/P095',()=>{
|
||||
const d=draft(),prior=JSON.parse(d.modelo_a_snapshot);assert.deepEqual(valueReviewBook(d),{rows:[]});assert.deepEqual(valueReviewReportRows(d),[]);
|
||||
let book=recordValueReviews(d,{rows:valueReviewTreatments.map(t=>({...row(t.id),conditions:{}}))});book=recordValueReviews(d,{...book,rows:valueReviewTreatments.map(t=>({...row(t.id),conditions:{...row(t.id).conditions,referenceCorresponds:'no'}}))});book=recordValueReviews(d,{...book,rows:valueReviewTreatments.map(t=>row(t.id))});assert.equal(book.history.length,2);assert.ok(book.history.every(h=>h.rows.every(r=>r.baseAmount===null&&r.vat===null)));assert.ok(valueReviewCurrent(d,book));
|
||||
for(const field of ['priceContext','declaredAmount','referenceAmount','referenceScope','correctedAmount','correctionRef','correctionScope','correctionApplies','origin'])assert.ok(!valueReviewCurrent(d,{...book,rows:book.rows.map(r=>({...r,[field]:'changed'}))}));for(const patch of [{context_opportunity:'opportunity:347'},{currency:'USD'},{selection_labels:{province_id:'Maputo'}}])assert.ok(!valueReviewCurrent({...d,...patch},book));
|
||||
d.modelo_a_snapshot=saveValueReviewBook(d,book);assert.deepEqual(valueReviewBook(JSON.parse(JSON.stringify(d))),book);const next=JSON.parse(d.modelo_a_snapshot);const{valueReviews,...untouched}=next;assert.deepEqual(untouched,prior);for(const[scope,previous]of Object.entries(prior.considerationExclusions))assert.ok(exclusionCurrent({...d,context_opportunity:scope},previous));for(const[scope,previous]of Object.entries(prior.considerationCompositions))assert.ok(compositionCurrent({...d,context_opportunity:scope},previous));
|
||||
assert.equal(next.considerationExclusions['opportunity:345'].result.rows[0].baseAmount,'799.70');assert.equal(next.considerationCompositions['opportunity:344'].result.rows[0].baseAmount,'1150.60');assert.equal(next.regulatedFuelBases['opportunity:342'].result.rows[0].baseAmount,'1400.15');assert.equal(next.books['opportunity:329'].result.periods[0].fields['11'],null);
|
||||
const report=JSON.stringify(createProjectDossierReport(d,{selectedSections:new Set(['finance']),version:'QA G7H'}));for(const token of ['Base P103','1000.25','1400.75','1500.50','IVA: Indeterminado','opportunity:346','Manica','2026-12','15(7)','condicionado','correcção A','campo11'])assert.ok(report.includes(token),token);assert.deepEqual(valueReviewReportRows({...d,context_opportunity:'opportunity:347'}),[]);
|
||||
if(process.env.IVA_G7H_CALCULATED)fs.writeFileSync(process.env.IVA_G7H_CALCULATED,JSON.stringify(d));
|
||||
});
|
||||
|
||||
test('corrupt review rows/store cannot overwrite originals;duplicateoperationids cannotbe recorded',()=>{
|
||||
const d=draft(),original=d.modelo_a_snapshot;for(const patch of [{declaredAmount:4},{conditions:[]},{correctionScope:null}])assert.throws(()=>saveValueReviewBook(d,{rows:[{...row(),...patch}]}),/originais preservados/);assert.equal(d.modelo_a_snapshot,original);assert.throws(()=>recordValueReviews(d,{rows:[row(),row()]}),/distintos/);assert.throws(()=>recordValueReviews(d,{rows:[]}),/distintos/);assert.throws(()=>valueReviewBook({...d,modelo_a_snapshot:JSON.stringify({...JSON.parse(original),valueReviews:[]})}),/originais preservados/);
|
||||
});
|
||||
Reference in New Issue
Block a user