feat(iva): add travel period margin and special travel suspensive events
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
<?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 IvaTravelEventDraftTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
public function test_travel_margin_and_special_events_recover_without_changing_accepted_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-travel-events.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:360',$recovered['context_opportunity']);
|
||||
$this->assertSame('Manica',$recovered['selection_labels']['province_id']);
|
||||
$store=json_decode($recovered['modelo_a_snapshot'],true,flags:JSON_THROW_ON_ERROR);
|
||||
$travel=$store['travelOperations']['opportunity:360'];$events=$store['travelSpecialEvents']['opportunity:360'];
|
||||
$this->assertCount(10,$travel['rows']);$this->assertCount(5,$travel['periods']);$this->assertCount(12,$events['rows']);
|
||||
$this->assertSame('0.00',$travel['result']['periods'][0]['baseAmount']);
|
||||
$this->assertSame('1160.00',$travel['result']['periods'][1]['carryForward']);
|
||||
$this->assertSame('500.00',$travel['result']['periods'][2]['baseAmount']);
|
||||
$this->assertSame('1160.00',$travel['result']['rows'][4]['generalGross']);
|
||||
$this->assertSame('general',$travel['result']['rows'][5]['regime']);
|
||||
$this->assertSame('outside',$travel['result']['rows'][9]['regime']);
|
||||
$this->assertSame('2026-12-05',$events['result']['rows'][0]['eventDate']);
|
||||
$this->assertSame('2026-12-10',$events['result']['rows'][1]['eventDate']);
|
||||
$this->assertNull($events['result']['rows'][3]['eventDate']);
|
||||
$this->assertSame('232.00',$events['result']['rows'][4]['dueEvents'][0]['amount']);
|
||||
$this->assertSame('928.00',$events['result']['rows'][4]['dueEvents'][1]['amount']);
|
||||
$this->assertSame('2026-12-08',$events['result']['rows'][4]['dueEvents'][1]['date']);
|
||||
$this->assertSame('deferred',$events['result']['rows'][11]['status']);
|
||||
foreach($events['result']['rows'] as $row){$this->assertNull($row['baseAmount']);$this->assertNull($row['vat']);$this->assertNull($row['deduction']);}
|
||||
$this->assertCount(1,$travel['history']);$this->assertCount(1,$events['history']);
|
||||
$this->assertSame($travel['rows'],$travel['result']['inputs']);$this->assertSame($events['rows'],$events['result']['inputs']);
|
||||
$frozen=json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-travel-events-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,['travelOperations'=>true,'travelSpecialEvents'=>true]));
|
||||
$this->assertSame('regulation_pending',$store['digitalOperations']['opportunity:359']['result']['rows'][0]['procedure']);
|
||||
$this->assertNull($store['digitalOperations']['opportunity:359']['result']['rows'][0]['vat']);
|
||||
$this->assertSame('potential',$store['foreignOperations']['opportunity:358']['result']['rows'][0]['relation19']);
|
||||
$this->assertNull($store['books']['opportunity:329']['result']['periods'][0]['fields']['11']);
|
||||
file_put_contents(base_path('outputs/iva-l1-travel-events-20261025/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,57 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';
|
||||
import {newTravel,newTravelCost,newTravelPeriod,travelConditions,travelCostKinds,evaluateTravel,recordTravel,saveTravelBook,travelBook,travelCurrent,travelReportRows,switchTravelResources} from '../../resources/js/lib/iva-travel.ts';
|
||||
import {newTravelEvent,evaluateTravelEvent,recordTravelEvents,saveTravelEventBook,travelEventBook,travelEventCurrent,travelEventReportRows,switchTravelEvent} from '../../resources/js/lib/iva-travel-events.ts';
|
||||
const frozen=JSON.parse(fs.readFileSync('tests/frontend/fixtures/iva-travel-events-preserved.json','utf8')),prior=JSON.parse(frozen.modelo_a_snapshot);
|
||||
const draft={...frozen,context_opportunity:'opportunity:360',project_title:'SYNTHETIC QA — Viagens e eventos / Manica'};
|
||||
function cost(id,amount,kind='direct'){return{...newTravelCost(id),kind,description:'SYNTHETIC QA '+kind,amount,ref:'SYNTHETIC QA documento/custo '+id,thirdParty:'yes',directBenefit:'yes'};}
|
||||
function trip(id,period,gross,costAmount,patch={}){return{...newTravel(id,'MZN'),description:'SYNTHETIC QA viagem '+id,period,operatorRef:'SYNTHETIC QA operador1',operationRef:'SYNTHETIC QA op '+id,documentRef:'SYNTHETIC QA contrato/registo',documentDate:'2026-10-01',scope:'SYNTHETIC QA pacote/parcela/período identificado',evidence:'SYNTHETIC QA factos/pressupostos',agencyCountry:'MZ',agencyRef:'SYNTHETIC QA sede fornecedora MZ',resources:'third_party',retailer:'no',grossPrice:gross,domesticTaxable:'yes',taxRef:'SYNTHETIC QA parcela tributável nacional/divisor17(1)',conditions:Object.fromEntries(travelConditions.map(([k])=>[k,'yes'])),costs:[cost(id+'-cost',costAmount)],...patch};}
|
||||
function period(id,month,operator='SYNTHETIC QA operador1',mode='none'){return{...newTravelPeriod(id),period:month,operatorRef:operator,scope:'SYNTHETIC QA registo completo do âmbito simulado',complete:'yes',registerRef:'SYNTHETIC QA livro/previsão completo',priorMode:mode};}
|
||||
const trips=[trip('A','2026-10','1160','580'),trip('B','2026-10','1160','1740'),trip('C','2026-11','1160','2320'),trip('D','2026-12','2320','580'),trip('mixed','2026-12','2320','580',{operatorRef:'SYNTHETIC QA operador2',resources:'mixed',thirdPartyGross:'1160',allocationRef:'SYNTHETIC QA repartição contratual sem percentagem presumida'}),trip('own','2026-12','1160','0',{resources:'own'}),trip('intermediary','2026-12','1160','0',{conditions:{operator:'yes',singlePrice:'yes',ownName:'no'}}),trip('retail','2026-12','1160','0',{retailer:'yes',newPackage:'no'}),trip('retail_new','2026-12','1160','580',{operatorRef:'SYNTHETIC QA operador4',retailer:'yes',newPackage:'yes',packageRef:'SYNTHETIC QA conjunto novo/serviços substanciais'}),trip('foreign','2026-12','1160','580',{agencyCountry:'foreign'})];
|
||||
trips[0].costs.push(...travelCostKinds.filter(([k])=>['exchange','advertising','communications','sales_commission'].includes(k)).map(([kind],i)=>cost('A-'+kind,String((i+1)*10),kind)));
|
||||
const periods=[period('oct','2026-10'),period('nov','2026-11',undefined,'previous'),period('dec','2026-12',undefined,'previous'),period('mixed','2026-12','SYNTHETIC QA operador2'),period('retail_new','2026-12','SYNTHETIC QA operador4')];
|
||||
let travel=recordTravel(draft,{rows:trips,periods});draft.modelo_a_snapshot=saveTravelBook(draft,travel);
|
||||
for(const row of trips)test('P129 '+row.id+' regime and component scope',()=>{const v=evaluateTravel(row,'MZN');assert.equal(v.regime,row.id==='mixed'?'mixed':['own','intermediary','retail'].includes(row.id)?'general':row.id==='foreign'?'outside':'special');assert.equal(v.status,['own','intermediary','retail','foreign'].includes(row.id)?'not_applicable':'determined');if(row.id==='mixed'){assert.equal(v.specialGross,'1160.00');assert.equal(v.generalGross,'1160.00');}});
|
||||
test('P129 period-global margin offsets package gains/losses; excludes four legal costs; no individual-margin aggregation',()=>{
|
||||
const p=travel.result.periods;assert.equal(p[0].grossReceipts,'2320.00');assert.equal(p[0].directCosts,'2320.00');assert.equal(p[0].excludedCosts,'100.00');assert.equal(p[0].baseAmount,'0.00');assert.equal(p[0].carryForward,'0.00');assert.equal(p[1].grossDifference,'-1160.00');assert.equal(p[1].carryForward,'1160.00');assert.equal(p[2].priorExcess,'1160.00');assert.equal(p[2].baseAmount,'500.00');assert.equal(p[2].carryForward,'0.00');assert.equal(p[3].baseAmount,'500.00');for(const x of p){assert.equal(x.vat,null);assert.equal(x.deduction,null);assert.equal(x.baseDivisorRate,'16%');}
|
||||
});
|
||||
test('P129 unknown amounts, scope, cost benefit, currency and previous excess do not become zero',()=>{
|
||||
for(const patch of [{grossPrice:''},{currency:'USD'},{taxRef:''},{domesticTaxable:''},{agencyCountry:''},{documentDate:'2026-02-30'},{operatorRef:''},{resources:''},{conditions:{operator:'yes',singlePrice:'',ownName:'yes'}},{costs:[{...trips[0].costs[0],directBenefit:''}]}])assert.equal(evaluateTravel({...trips[0],...patch},'MZN').status,'indeterminate');
|
||||
for(const patch of [{complete:'no'},{priorMode:''},{priorMode:'previous'},{registerRef:''}])assert.equal(recordTravel(draft,{rows:[trips[0]],periods:[{...periods[0],...patch}]}).result.periods[0].status,'indeterminate');
|
||||
assert.equal(evaluateTravel({...trips[4],thirdPartyGross:'9999'},'MZN').status,'indeterminate');assert.equal(evaluateTravel({...trips[8],packageRef:''},'MZN').status,'indeterminate');
|
||||
const unknown={...trips[1],grossPrice:''};assert.equal(recordTravel(draft,{rows:[trips[0],unknown],periods:[periods[0]]}).result.periods[0].status,'indeterminate');
|
||||
const both={...trips[1],origin:'effective'};assert.equal(recordTravel(draft,{rows:[trips[0],both],periods:[periods[0]]}).result.periods[0].status,'indeterminate');
|
||||
});
|
||||
test('P129 explicit prior excess and nearest-unit rounding; cost exclusions never become deduction',()=>{
|
||||
const t={...trips[0],grossPrice:'1.75',costs:[]},p={...periods[0],priorMode:'manual',priorAmount:'0',priorRef:'SYNTHETIC QA fecho anterior sem excesso'};assert.equal(recordTravel(draft,{rows:[t],periods:[p]}).result.periods[0].baseAmount,'2.00');
|
||||
const indirect={...trips[0],costs:[cost('indirect','500','other')]};indirect.costs[0].directBenefit='no';assert.equal(evaluateTravel(indirect,'MZN').eligibleCosts,'0.00');assert.equal(evaluateTravel(indirect,'MZN').excludedCosts,'500.00');
|
||||
const correction={...periods[2],priorMode:'manual',priorAmount:'500',priorRef:'SYNTHETIC QA correcção referenciada'};const pending=recordTravel(draft,{rows:trips,periods:[periods[0],periods[1],correction]});assert.equal(pending.result.periods[2].status,'indeterminate');assert.match(pending.result.periods[2].reason,/Nada foi sobrescrito/);const confirmed=recordTravel(draft,{rows:trips,periods:[periods[0],periods[1],{...correction,priorOverride:'yes'}]});assert.equal(confirmed.result.periods[2].priorExcess,'500.00');assert.equal(confirmed.result.periods[2].baseAmount,'1069.00');
|
||||
});
|
||||
function event(id,patch={}){return{...newTravelEvent(id,'MZN'),treatment:'travel_special',travelRowId:'A',description:'SYNTHETIC QA evento '+id,period:'2026-12',operationRef:trips[0].operationRef,documentRef:'SYNTHETIC QA documento evento',documentDate:'2026-12-01',reviewDate:'2026-12-31',scope:'SYNTHETIC QA parcela viagem/bens identificados',evidence:'SYNTHETIC QA factos/pressupostos',factsConfirmed:'yes',fullPaymentState:'full',fullPaymentDate:'2026-12-05',fullPaymentRef:'SYNTHETIC QA liquidação integral',startState:'started',startDate:'2026-12-10',startRef:'SYNTHETIC QA primeiro serviço',invoiceRequired:'no',paymentState:'received',paymentDate:'2026-12-05',paymentAmount:'1160',paymentRef:'SYNTHETIC QA recebimento',amount:'1160',...patch};}
|
||||
const events=[event('full_first'),event('start_first',{fullPaymentDate:'2026-12-20',paymentDate:'2026-12-20'}),event('same_day',{fullPaymentDate:'2026-12-10',paymentDate:'2026-12-10'}),event('partial_8c',{fullPaymentState:'partial',startState:'not_started',paymentAmount:'232',invoiceRequired:'yes',invoiceState:'not_issued',legalDeadline:'2026-12-20',deadlineRef:'SYNTHETIC QA prazo identificado,sem cinco dias por defeito',reviewDate:'2026-12-10'}),event('partial_then_full',{fullPaymentDate:'2026-12-08',paymentAmount:'232',invoiceRequired:'yes',invoiceState:'issued',invoiceDate:'2026-12-15',invoiceRef:'SYNTHETIC QA factura',legalDeadline:'2026-12-20',deadlineRef:'SYNTHETIC QA prazo identificado'}),event('invoice_before_start',{fullPaymentState:'none',startState:'not_started',paymentState:'none',invoiceRequired:'yes',invoiceState:'issued',invoiceDate:'2026-12-02',invoiceRef:'SYNTHETIC QA factura antecipada',legalDeadline:'2026-12-20',deadlineRef:'SYNTHETIC QA prazo identificado'}),event('travel_deferred',{fullPaymentState:'none',startState:'not_started',paymentState:'none'})];
|
||||
for(const clause of ['a','b','c','d'])events.push(event('exit_'+clause,{treatment:'suspensive_exit',travelRowId:'',regimeClause:clause,entryDate:'2026-11-01',entryRef:'SYNTHETIC QA colocação regime/bens',regimeStatus:'ceased',exitDate:'2026-12-10',exitRef:'SYNTHETIC QA cessação efectiva',cessationConfirmed:'yes'}));
|
||||
events.push({...events[7],id:'still_held',regimeStatus:'still'});
|
||||
for(const row of events)test('P133 '+row.id+' special event separate from tax amounts',()=>{const v=evaluateTravelEvent(draft,row);assert.equal(v.status,['travel_deferred','still_held'].includes(row.id)?'deferred':'determined');assert.equal(v.baseAmount,null);assert.equal(v.vat,null);assert.equal(v.deduction,null);if(row.id.startsWith('exit_')){assert.equal(v.eventDate,'2026-12-10');assert.equal(v.dueEvents[0].amount,null);}});
|
||||
test('P133 full/start timing and partial/full/early invoice never duplicate amounts',()=>{
|
||||
assert.equal(evaluateTravelEvent(draft,events[0]).eventDate,'2026-12-05');assert.equal(evaluateTravelEvent(draft,events[1]).eventDate,'2026-12-10');assert.match(evaluateTravelEvent(draft,events[2]).eventTiming,/ordem intradiária não apurada/);
|
||||
const early=evaluateTravelEvent(draft,events[3]);assert.equal(early.eventDate,null);assert.deepEqual(early.dueEvents.map(x=>[x.date,x.amount]),[['2026-12-05','232.00']]);assert.equal(early.remainingPending,true);
|
||||
const full=evaluateTravelEvent(draft,events[4]);assert.equal(full.eventDate,'2026-12-08');assert.deepEqual(full.dueEvents.map(x=>[x.date,x.amount]),[['2026-12-05','232.00'],['2026-12-08','928.00']]);assert.equal(full.remainingPending,false);
|
||||
const invoice=evaluateTravelEvent(draft,events[5]);assert.equal(invoice.eventDate,null);assert.equal(invoice.dueEvents[0].date,'2026-12-02');
|
||||
const invoiceFirst={...events[4],invoiceDate:'2026-12-03'};assert.deepEqual(evaluateTravelEvent(draft,invoiceFirst).dueEvents.map(x=>[x.date,x.amount]),[['2026-12-03','1160.00']]);
|
||||
});
|
||||
test('P133 absence, conflicting facts, future/invalid dates and regime transfer do not invent events',()=>{
|
||||
for(const patch of [{travelRowId:''},{operationRef:'another'},{fullPaymentState:''},{startState:''},{fullPaymentState:'partial',paymentAmount:'1160'},{fullPaymentDate:'2026-12-99'},{startDate:'2027-01-01'},{fullPaymentState:'none',paymentState:'received'},{factsConfirmed:''}])assert.equal(evaluateTravelEvent(draft,{...events[0],...patch}).status,'indeterminate');
|
||||
for(const patch of [{regimeClause:''},{entryRef:''},{entryDate:'2026-12-20'},{regimeStatus:''},{cessationConfirmed:'no'},{exitRef:''},{exitDate:'2027-01-01'}])assert.equal(evaluateTravelEvent(draft,{...events[7],...patch}).status,'indeterminate');
|
||||
assert.equal(evaluateTravelEvent({...draft,context_opportunity:'opportunity:361'},events[0]).status,'indeterminate');assert.equal(evaluateTravelEvent(draft,{...events[0],travelRowId:'own'}).status,'not_applicable');
|
||||
});
|
||||
test('P133 classification dependency is guarded; unknown margin amounts do not block an independently documented event',()=>{
|
||||
const changed={...travel,rows:travel.rows.map(r=>r.id==='A'?{...r,grossPrice:''}:r)},d={...draft,modelo_a_snapshot:saveTravelBook(draft,changed)};assert.equal(evaluateTravelEvent(d,events[0]).status,'indeterminate');d.modelo_a_snapshot=saveTravelBook(d,recordTravel(d,changed));assert.equal(evaluateTravelEvent(d,events[0]).status,'determined');
|
||||
const b=recordTravelEvents(draft,{rows:events}),fresh={...draft,modelo_a_snapshot:saveTravelEventBook(draft,b)};assert.equal(travelEventCurrent(fresh,b),true);fresh.modelo_a_snapshot=saveTravelBook(fresh,recordTravel(fresh,travel));assert.equal(travelEventCurrent(fresh,b),false);assert.equal(b.result.rows[0].source.revision,1);
|
||||
});
|
||||
test('Private save/recovery/history/report preserve123 delivered books, digital limitation and partial drafts',()=>{
|
||||
const t=recordTravel(draft,travel),d={...draft,modelo_a_snapshot:saveTravelBook(draft,t)},a=recordTravelEvents(d,{rows:events});d.modelo_a_snapshot=saveTravelEventBook(d,a);const b=recordTravelEvents(d,a);d.modelo_a_snapshot=saveTravelEventBook(d,b);
|
||||
assert.equal(t.history.length,1);assert.equal(b.history.length,1);assert.equal(travelReportRows(d).length,15);assert.equal(travelEventReportRows(d).length,12);assert.equal(travelCurrent(d,t),true);assert.equal(travelEventCurrent(d,b),true);
|
||||
const store=JSON.parse(d.modelo_a_snapshot);delete store.travelOperations;delete store.travelSpecialEvents;assert.deepEqual(store,prior);assert.deepEqual(travelBook({...d,context_opportunity:'opportunity:361'}),{rows:[],periods:[]});assert.deepEqual(travelEventBook({...d,context_opportunity:'opportunity:361'}),{rows:[]});
|
||||
assert.throws(()=>recordTravel(d,{rows:[trips[0],trips[0]],periods}));assert.throws(()=>recordTravelEvents(d,{rows:[events[0],events[0]]}));assert.throws(()=>travelBook({...d,modelo_a_snapshot:JSON.stringify({...store,travelOperations:[]})}));assert.throws(()=>travelEventBook({...d,modelo_a_snapshot:JSON.stringify({...store,travelSpecialEvents:[]})}));
|
||||
const switched=switchTravelResources(trips[4],'own');assert.equal(switched.allocationRef,trips[4].allocationRef);assert.deepEqual(switched.costs,trips[4].costs);assert.equal(switched.conditions.singlePrice,'');assert.equal(switchTravelEvent(events[0],'suspensive_exit').fullPaymentRef,events[0].fullPaymentRef);
|
||||
if(process.env.IVA_TRAVEL_EVENTS_FIXTURE)fs.writeFileSync(process.env.IVA_TRAVEL_EVENTS_FIXTURE,JSON.stringify(d,null,2)+'\n');
|
||||
});
|
||||
Reference in New Issue
Block a user