feat(iva): distinguish offer requests emergency drafts and conditional scenarios
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { newOfferPreparation,offerPreparationBook,offerPreparationSource,offerReusableData,recordOfferPreparations,reuseOfferData,saveOfferPreparationBook,type OfferPreparation,type OfferPreparationBook } from '@/lib/iva-offer-preparations';
|
||||
import { newOfferPreparation,offerPreparationBook,offerPreparationSource,offerReusableData,recordOfferPreparations,reuseOfferData,saveOfferPreparationBook,emptyOfferRequest,recordOfferRequest,offerRequestLabels,offerScenarios,type OfferRequest,type OfferPreparation,type OfferPreparationBook } from '@/lib/iva-offer-preparations';
|
||||
|
||||
export function SimulationOfferPreparations({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}) {
|
||||
const [error,setError]=useState('');let book:OfferPreparationBook;let reusable:ReturnType<typeof offerReusableData>;
|
||||
@@ -10,7 +10,7 @@ export function SimulationOfferPreparations({draft,onChange}:{draft:Record<strin
|
||||
const question=(row:OfferPreparation,key:'nationalInstitution'|'publicSocialInstitution'|'goodsSuitable'|'publicInterestUse'|'mitigation',label:string)=><label className="block" key={key}>{label}<select className="block rounded border p-2" value={row[key]} onChange={e=>change(row.id,{[key]:e.target.value})}><option value="">Por indicar</option><option value="yes">Sim</option><option value="no">Não</option></select></label>;
|
||||
return <details className="space-y-3 rounded border p-3"><summary>Ofertas a instituições e para calamidades — preparar rascunhos</summary>
|
||||
<p>Prepare apenas as ofertas relevantes para a oportunidade {String(draft.context_opportunity||'entrada geral')}. Pode guardar dados incompletos e reutilizar referências existentes. A reutilização preenche apenas campos vazios; as suas edições mantêm-se.</p>
|
||||
<p>O procedimento de aplicação do benefício está em pesquisa. Este rascunho não concede isenção, não calcula IVA nem altera a dedução no campo11. Os restantes enquadramentos continuam disponíveis.</p>
|
||||
<p>O procedimento de aplicação do benefício está em pesquisa. Prepare o pedido e compare o cenário normal com uma hipótese condicionada à isenção. Apresentar o pedido não concede isenção. Estes cenários não alteram a dedução no campo11; os restantes enquadramentos continuam disponíveis.</p>
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
<button type="button" className="underline" onClick={()=>run(()=>save({...book,rows:[...book.rows,newOfferPreparation(crypto.randomUUID(),'P084',String(draft.currency??''))]}))}>Preparar oferta a instituição</button>{' '}
|
||||
<button type="button" className="underline" onClick={()=>run(()=>save({...book,rows:[...book.rows,newOfferPreparation(crypto.randomUUID(),'P085',String(draft.currency??''))]}))}>Preparar oferta para calamidade</button>
|
||||
@@ -21,6 +21,24 @@ export function SimulationOfferPreparations({draft,onChange}:{draft:Record<strin
|
||||
{row.reusedFrom&&<p>Referência reutilizada: {row.reusedFrom.key}. A origem foi conservada; a aquisição/importação original não foi alterada.</p>}
|
||||
<div className="grid gap-3 md:grid-cols-2">{(['description','period','currency','amount','counterparty','documentRef','documentDate','beneficiary','purpose','evidence'] as const).map((key,index)=><label key={key}>{['Bens destinados à oferta','Período previsto / efectivo','Moeda','Valor de referência, sem cálculo fiscal','Fornecedor / importador','Documento / referência da previsão','Data do documento / prevista','Instituição / destinatário da oferta','Finalidade concreta da oferta','Fundamento factual / pressupostos'][index]}<input className="block rounded border p-2" type={key==='period'?'month':key==='documentDate'?'date':'text'} value={row[key]} onChange={e=>change(row.id,{[key]:e.target.value})}/></label>)}</div>
|
||||
{row.branch==='P084'?<fieldset><legend>Condições próprias da oferta à instituição</legend>{question(row,'nationalInstitution','A instituição beneficiária é nacional')}{question(row,'publicSocialInstitution','A instituição tem interesse público e relevantes fins sociais')}{question(row,'goodsSuitable','Os bens são inteiramente adequados à natureza da instituição')}{question(row,'publicInterestUse','A instituição utilizará os bens em actividades de evidente interesse público')}</fieldset>:<fieldset><legend>Finalidade própria da oferta para calamidade</legend><label>Calamidade e contexto afectados<input className="block rounded border p-2" value={row.disaster} onChange={e=>change(row.id,{disaster:e.target.value})}/></label><label>Natureza da calamidade<input className="block rounded border p-2" value={row.disasterKind} placeholder="Cheias, tempestades, secas, ciclones, sismos ou outra de idêntica natureza" onChange={e=>change(row.id,{disasterKind:e.target.value})}/></label>{question(row,'mitigation','Os bens destinam-se a ofertas para atenuar os efeitos da calamidade identificada')}</fieldset>}
|
||||
{row.branch==='P085'&&<label>Finalidade humanitária da resposta à emergência<textarea className="block w-full rounded border p-2" value={row.humanitarianPurpose??''} onChange={e=>change(row.id,{humanitarianPurpose:e.target.value})}/></label>}
|
||||
<label>Documentos disponíveis e respectivas referências<textarea className="block w-full rounded border p-2" value={row.availableDocuments??''} onChange={e=>change(row.id,{availableDocuments:e.target.value})}/></label>
|
||||
{!!row.documentRef&&<button type="button" className="underline" onClick={()=>{const reference=[row.documentRef,row.documentDate].filter(Boolean).join(', ');if(!(row.availableDocuments??'').includes(reference))change(row.id,{availableDocuments:[row.availableDocuments,reference].filter(Boolean).join('\n')});}}>Juntar referência do documento já indicado</button>}
|
||||
{row.branch==='P084'&&(()=>{const request=row.request??emptyOfferRequest();const edit=(patch:Partial<OfferRequest>)=>change(row.id,{request:{...request,...patch}});return <fieldset className="rounded border p-3"><legend>Pedido e âmbito da decisão</legend>
|
||||
<p>Registe o estado declarado e os elementos disponíveis. Este registo não apresenta um pedido à autoridade nem concede o benefício. Conserve o âmbito exacto de cada decisão, mesmo que cubra apenas parte dos bens ou períodos.</p>
|
||||
<label>Estado declarado do pedido<select className="block rounded border p-2" value={request.state} onChange={e=>edit({state:e.target.value as OfferRequest['state']})}>{Object.entries(offerRequestLabels).map(([value,label])=><option key={value} value={value}>{label}</option>)}</select></label>
|
||||
{(['reference','presentedOn','decisionRef','decisionOn','goods','periodFrom','periodTo','conditions'] as const).map((key,index)=><label className="block" key={key}>{['Referência do pedido','Data de apresentação, se disponível','Documento / referência da decisão, se disponível','Data da decisão, se disponível','Bens abrangidos pela decisão','Início do período abrangido','Fim do período abrangido','Condições, limites ou fundamento da decisão'][index]}<input className="block rounded border p-2" type={key==='presentedOn'||key==='decisionOn'?'date':'text'} placeholder={key==='periodFrom'||key==='periodTo'?'AAAA-MM ou AAAA-MM-DD, conforme a decisão':undefined} value={request[key]} onChange={e=>edit({[key]:e.target.value})}/></label>)}
|
||||
<label>Operação abrangida pela decisão<select className="block rounded border p-2" value={request.operation} onChange={e=>edit({operation:e.target.value as OfferRequest['operation']})}><option value="">Por indicar</option><option value="acquisition">Aquisição</option><option value="import">Importação</option></select></label>
|
||||
<button type="button" className="underline" onClick={()=>edit({goods:request.goods||row.description,operation:request.operation||row.operation,periodFrom:request.periodFrom||row.period,periodTo:request.periodTo||row.period})}>Reutilizar contexto nos campos de âmbito vazios</button>{' '}
|
||||
<button type="button" className="rounded border p-2" onClick={()=>run(()=>save({...book,rows:book.rows.map(item=>item.id===row.id?recordOfferRequest(item):item)}))}>Registar estado declarado e âmbito</button>
|
||||
{(row.requestRecords??[]).map((record,index)=><p key={index}>{offerRequestLabels[record.request.state]} — {record.recordedAt}; bens: {record.request.goods||'por indicar'}; operação: {record.request.operation||'por indicar'}; período: {record.request.periodFrom||'por indicar'} a {record.request.periodTo||'por indicar'}; condições: {record.request.conditions||'por indicar'}; decisão: {record.request.decisionRef||'por indicar'}. Este âmbito não se estende automaticamente à restante operação ou oportunidade.</p>)}
|
||||
</fieldset>;})()}
|
||||
<fieldset className="rounded border p-3"><legend>Cenário normal e cenário condicionado à isenção</legend>
|
||||
<label>IVA no cenário normal, indicado em documento ou pressuposto<input className="block rounded border p-2" inputMode="decimal" value={row.normalVat??''} onChange={e=>change(row.id,{normalVat:e.target.value})}/></label>
|
||||
<label>Documento ou pressuposto do IVA normal<input className="block rounded border p-2" value={row.normalVatSource??''} onChange={e=>change(row.id,{normalVatSource:e.target.value})}/></label>
|
||||
<label>Assumir, apenas para comparar, a hipótese condicionada de isenção<select className="block rounded border p-2" value={row.conditionalAssumption??''} onChange={e=>change(row.id,{conditionalAssumption:e.target.value as ''|'yes'|'no'})}><option value="">Por indicar</option><option value="yes">Sim, como hipótese condicionada</option><option value="no">Não</option></select></label>
|
||||
{(()=>{const scenarios=offerScenarios(row);return <><table><thead><tr><th>Cenário</th><th>IVA ({row.currency})</th></tr></thead><tbody><tr><td>Normal — valor indicado, sem taxa presumida</td><td>{scenarios.normalVat??'Indeterminado'}</td></tr><tr><td>Condicionado à isenção — hipótese, não concessão</td><td>{scenarios.conditionalVat??'Indeterminado'}</td></tr></tbody></table><p>{scenarios.conditionalReason}</p><p>O cenário condicionado não altera imposto, dedução, totais ou o estado do pedido. O procedimento só será aplicado quando a correspondência regulamentar estiver demonstrada.</p></>;})()}
|
||||
</fieldset>
|
||||
</details>)}
|
||||
{!!book.rows.length&&<><button type="button" className="rounded border p-2" onClick={()=>run(()=>save(recordOfferPreparations(book)))}>Guardar versão dos rascunhos de ofertas</button><p role="status">{book.saved?'Versão de preparação conservada; alterações seguintes não substituem essa versão.':'Preparação em curso; pode guardar mesmo com informação em falta.'} Benefício não aplicado. {book.history?.length??0} versões anteriores.</p></>}
|
||||
</details>;
|
||||
|
||||
@@ -3,7 +3,9 @@ import { importExemptionBook } from './iva-import-exemptions.ts';
|
||||
|
||||
type Choice = ''|'yes'|'no';
|
||||
export const offerPreparationSource='CIVA,Lei13/2016,14(2)(a/b) e14(3),PDF13/p.1426(271). Preparação de dados; procedimento regulamentar de aplicação ainda em pesquisa. Não concede isenção nem preenche dedução/campo11.';
|
||||
export type OfferPreparation = {id:string;branch:'P084'|'P085';operation:''|'acquisition'|'import';description:string;period:string;currency:string;amount:string;counterparty:string;documentRef:string;documentDate:string;beneficiary:string;purpose:string;evidence:string;nationalInstitution:Choice;publicSocialInstitution:Choice;goodsSuitable:Choice;publicInterestUse:Choice;disaster:string;disasterKind:string;mitigation:Choice;reusedFrom?:{key:string;copiedAt:string}};
|
||||
export type OfferRequest = {state:'preparation'|'presented'|'granted'|'denied';reference:string;presentedOn:string;decisionRef:string;decisionOn:string;goods:string;operation:''|'acquisition'|'import';periodFrom:string;periodTo:string;conditions:string};
|
||||
export const offerRequestLabels = {preparation:'Em preparação',presented:'Apresentado',granted:'Deferido',denied:'Indeferido'};
|
||||
export type OfferPreparation = {id:string;branch:'P084'|'P085';operation:''|'acquisition'|'import';description:string;period:string;currency:string;amount:string;counterparty:string;documentRef:string;documentDate:string;beneficiary:string;purpose:string;evidence:string;nationalInstitution:Choice;publicSocialInstitution:Choice;goodsSuitable:Choice;publicInterestUse:Choice;disaster:string;disasterKind:string;mitigation:Choice;reusedFrom?:{key:string;copiedAt:string};humanitarianPurpose?:string;availableDocuments?:string;request?:OfferRequest;requestRecords?:Array<{recordedAt:string;request:OfferRequest}>;normalVat?:string;normalVatSource?:string;conditionalAssumption?:Choice};
|
||||
export type OfferPreparationBook={rows:OfferPreparation[];history?:Array<{savedAt:string;rows:OfferPreparation[]}>;saved?:{savedAt:string;rows:OfferPreparation[]}};
|
||||
type Store={version:1;books:Record<string,unknown>;offerPreparations?:Record<string,OfferPreparationBook>;[key:string]:unknown};
|
||||
export function newOfferPreparation(id:string,branch:OfferPreparation['branch'],currency:string):OfferPreparation{return {id,branch,operation:branch==='P085'?'acquisition':'',description:'',period:'',currency,amount:'',counterparty:'',documentRef:'',documentDate:'',beneficiary:'',purpose:'',evidence:'',nationalInstitution:'',publicSocialInstitution:'',goodsSuitable:'',publicInterestUse:'',disaster:'',disasterKind:'',mitigation:''};}
|
||||
@@ -13,7 +15,11 @@ function read(draft:Record<string,unknown>):Store {
|
||||
if(value.offerPreparations!==undefined&&(!value.offerPreparations||typeof value.offerPreparations!=='object'||Array.isArray(value.offerPreparations)))throw new Error('Rascunhos de ofertas não recuperáveis; sem substituição por lista vazia.');
|
||||
for(const book of Object.values(value.offerPreparations??{})){
|
||||
if(!book||!Array.isArray(book.rows)||(book.history!==undefined&&!Array.isArray(book.history)))throw new Error('Formato de rascunhos de ofertas não recuperável; originais preservados.');
|
||||
for(const row of book.rows)if(!row||!['P084','P085'].includes(row.branch)||Object.keys(newOfferPreparation('','P084','')).some(key=>typeof row[key as keyof OfferPreparation]!=='string')||(row.branch==='P085'&&row.operation==='import'))throw new Error('Rascunho de oferta não recuperável ou operação fora do ramo; dados conservados.');
|
||||
for(const row of book.rows){
|
||||
if(!row||!['P084','P085'].includes(row.branch)||Object.keys(newOfferPreparation('','P084','')).some(key=>typeof row[key as keyof OfferPreparation]!=='string')||(row.branch==='P085'&&row.operation==='import'))throw new Error('Rascunho de oferta não recuperável ou operação fora do ramo; dados conservados.');
|
||||
if(['humanitarianPurpose','availableDocuments','normalVat','normalVatSource','conditionalAssumption'].some(key=>row[key as keyof OfferPreparation]!==undefined&&typeof row[key as keyof OfferPreparation]!=='string')||(row.requestRecords!==undefined&&!Array.isArray(row.requestRecords)))throw new Error('Elementos do percurso de ofertas não recuperáveis; originais preservados.');
|
||||
if(row.request!==undefined&&(!row.request||!Object.hasOwn(offerRequestLabels,row.request.state)||Object.entries(emptyOfferRequest()).some(([key])=>typeof row.request?.[key as keyof OfferRequest]!=='string')))throw new Error('Pedido de oferta não recuperável; decisão e originais conservados.');
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -41,7 +47,29 @@ export function reuseOfferData(row:OfferPreparation,source:OfferReusable):OfferP
|
||||
for(const [key,value] of Object.entries(source.values))if(!next[key as keyof OfferPreparation]&&typeof value==='string')Object.assign(next,{[key]:value});
|
||||
return {...next,reusedFrom:{key:source.key,copiedAt:new Date().toISOString()}};
|
||||
}
|
||||
export function emptyOfferRequest():OfferRequest{return {state:'preparation',reference:'',presentedOn:'',decisionRef:'',decisionOn:'',goods:'',operation:'',periodFrom:'',periodTo:'',conditions:''};}
|
||||
export function recordOfferRequest(row:OfferPreparation):OfferPreparation{
|
||||
if(row.branch!=='P084')throw new Error('Os estados deste pedido pertencem ao percurso P084; emergência conservada no percurso próprio.');
|
||||
const request=row.request??emptyOfferRequest();
|
||||
const validPeriod=(value:string)=>{if(/^\d{4}-(0[1-9]|1[0-2])$/.test(value))return true;if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return false;const d=new Date(value+'T00:00:00Z');return Number.isFinite(d.getTime())&&d.toISOString().slice(0,10)===value;};
|
||||
if(['granted','denied'].includes(request.state)&&(!request.goods.trim()||!['acquisition','import'].includes(request.operation)||!validPeriod(request.periodFrom)||!validPeriod(request.periodTo)||request.periodFrom.length!==request.periodTo.length||request.periodTo<request.periodFrom||!request.conditions.trim()))throw new Error('Conserve o âmbito da decisão: bens, operação, início/fim do período e condições. Use meses ou datas exactas com a mesma precisão. Pode continuar a guardar o rascunho incompleto.');
|
||||
return {...row,requestRecords:[...(row.requestRecords??[]),{recordedAt:new Date().toISOString(),request:structuredClone(request)}]};
|
||||
}
|
||||
export function offerScenarios(row:OfferPreparation):{normalVat:string|null;conditionalVat:string|null;conditionalReason:string;applied:false}{
|
||||
const normalVat=row.normalVatSource?.trim()&&/^\d{1,15}(\.\d{1,6})?$/.test(row.normalVat??'')?row.normalVat!:null;
|
||||
const result={normalVat,conditionalVat:null as string|null,conditionalReason:'Hipótese de isenção não assumida; benefício não aplicado.',applied:false as const};
|
||||
if(row.conditionalAssumption!=='yes')return result;
|
||||
const choices=row.branch==='P084'?[row.nationalInstitution,row.publicSocialInstitution,row.goodsSuitable,row.publicInterestUse]:[row.mitigation];
|
||||
if(choices.includes('no'))return {...result,conditionalReason:'Condição material contrariada; este cenário de isenção não está demonstrado. Benefício não aplicado.'};
|
||||
if(choices.some(value=>value!=='yes')||!row.description.trim()||!row.beneficiary.trim()||!row.purpose.trim()||!row.operation||!/^2026-(0[1-9]|1[0-2])$/.test(row.period)||row.currency!=='MZN'||(row.branch==='P085'&&(!row.disaster.trim()||!row.humanitarianPurpose?.trim())))return {...result,conditionalReason:'Pressupostos, contexto ou condições materiais por indicar; sem IVA zero presumido. Benefício não aplicado.'};
|
||||
return {...result,conditionalVat:'0.00',conditionalReason:'Cenário condicionado à isenção: hipótese expressa de cumprimento das condições e aplicação do procedimento, cuja correspondência permanece pendente. Não é benefício concedido nem imposto aplicado; apresentar pedido não concede isenção.'};
|
||||
}
|
||||
export function offerPathReport(row:OfferPreparation):string {
|
||||
const request=row.request??emptyOfferRequest(),scenario=offerScenarios(row);
|
||||
const scope=(r:OfferRequest)=>`bens ${r.goods||'por indicar'}; operação ${r.operation||'por indicar'}; período ${r.periodFrom||'por indicar'} a ${r.periodTo||'por indicar'}; condições ${r.conditions||'por indicar'}; documento ${r.decisionRef||'por indicar'},${r.decisionOn||'data por indicar'}`;
|
||||
return `${row.branch==='P084'?`Pedido ${offerRequestLabels[request.state]} (estado declarado, sem concessão pelo simulador); referência ${request.reference||'por indicar'}, apresentação ${request.presentedOn||'por indicar'}; âmbito em edição ${scope(request)}; ${(row.requestRecords??[]).map(record=>`estado registado ${offerRequestLabels[record.request.state]} em ${record.recordedAt}, ${scope(record.request)}`).join(' | ')||'sem estado/decisão registado'}; não estender decisão aos restantes bens, operações, períodos ou oportunidades; apresentar pedido não concede isenção`:`Emergência ${row.disaster||'por indicar'}; finalidade humanitária ${row.humanitarianPurpose||'por indicar'}; procedimento de emergência/doação por corresponder, sem aplicação automática`}; documentos disponíveis ${row.availableDocuments||row.documentRef||'por indicar'}; Cenário normal — IVA ${scenario.normalVat??'Indeterminado'} ${row.currency}, documento/pressuposto ${row.normalVatSource||'por indicar'}; Cenário condicionado à isenção — IVA ${scenario.conditionalVat??'Indeterminado'} ${row.currency}; ${scenario.conditionalReason}`;
|
||||
}
|
||||
export function offerPreparationReportRows(draft:Record<string,unknown>):Array<[string,string,string,string]>{
|
||||
try{return offerPreparationBook(draft).rows.map(row=>['IVA mensal',`Rascunho de oferta ${row.branch} — ${row.id}`,`${row.description||'Bens por indicar'}; preparação, benefício não aplicado; ${row.amount||'Valor não informado'} ${row.currency}; ${row.period||'Período não informado'}`,`${offerPreparationSource} Oportunidade ${acquisitionScope(draft)}; operação ${row.operation||'por indicar'}; contraparte ${row.counterparty||'por indicar'}; documento ${row.documentRef||'por indicar'},${row.documentDate||'data por indicar'}; beneficiário ${row.beneficiary||'por indicar'}; finalidade ${row.purpose||'por indicar'}; ${row.branch==='P084'?`instituição nacional ${row.nationalInstitution||'por indicar'}, interesse público/relevantes fins sociais ${row.publicSocialInstitution||'por indicar'}, bens adequados ${row.goodsSuitable||'por indicar'}, utilização de interesse público ${row.publicInterestUse||'por indicar'}`:`calamidade ${row.disaster||'por indicar'}, natureza ${row.disasterKind||'por indicar'}, atenuação dos efeitos ${row.mitigation||'por indicar'}`}; fundamento ${row.evidence||'por indicar'}; origem reutilizada ${row.reusedFrom?.key||'entrada directa'}; sem cálculo de IVA,isenção automática ou dedução.`]);}
|
||||
try{return offerPreparationBook(draft).rows.map(row=>['IVA mensal',`Rascunho de oferta ${row.branch} — ${row.id}`,`${row.description||'Bens por indicar'}; preparação, benefício não aplicado; ${row.amount||'Valor não informado'} ${row.currency}; ${row.period||'Período não informado'}`,`${offerPreparationSource} Oportunidade ${acquisitionScope(draft)}; operação ${row.operation||'por indicar'}; contraparte ${row.counterparty||'por indicar'}; documento ${row.documentRef||'por indicar'},${row.documentDate||'data por indicar'}; beneficiário ${row.beneficiary||'por indicar'}; finalidade ${row.purpose||'por indicar'}; ${row.branch==='P084'?`instituição nacional ${row.nationalInstitution||'por indicar'}, interesse público/relevantes fins sociais ${row.publicSocialInstitution||'por indicar'}, bens adequados ${row.goodsSuitable||'por indicar'}, utilização de interesse público ${row.publicInterestUse||'por indicar'}`:`calamidade ${row.disaster||'por indicar'}, natureza ${row.disasterKind||'por indicar'}, atenuação dos efeitos ${row.mitigation||'por indicar'}`}; fundamento ${row.evidence||'por indicar'}; origem reutilizada ${row.reusedFrom?.key||'entrada directa'}; ${offerPathReport(row)}; sem isenção automática ou dedução.`]);}
|
||||
catch{return [['IVA mensal','Rascunhos de ofertas','Dados não recuperáveis','Originais preservados; sem substituição por zero.']];}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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 IvaOfferPathsDraftTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
public function test_import_classification_recovers_privately_without_changing_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-offer-paths.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:334', $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['offerPreparations']['opportunity:334'];
|
||||
$this->assertCount(2, $book['rows']);
|
||||
$this->assertSame(['P084','P085'], array_column($book['rows'], 'branch'));
|
||||
$this->assertSame(['import','acquisition'], array_column($book['rows'], 'operation'));
|
||||
$this->assertArrayNotHasKey('result', $book);
|
||||
$this->assertCount(3, $book['history']);
|
||||
$this->assertSame('SYNTHETIC QA finalidade editada deliberadamente', $book['saved']['rows'][0]['purpose']);
|
||||
$this->assertSame('SYNTHETIC QA cheias', $book['rows'][1]['disaster']);
|
||||
$frozen = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-g6e-inputs.json')), true, flags: JSON_THROW_ON_ERROR);
|
||||
$prior = json_decode($frozen['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR);
|
||||
$this->assertSame($prior['salesOperations'], $store['salesOperations']);
|
||||
$this->assertSame($prior['books'], $store['books']);
|
||||
$this->assertSame($prior['importExemptions']['opportunity:330'], $store['importExemptions']['opportunity:330']);
|
||||
$this->assertSame($prior['importExemptions']['opportunity:331'], $store['importExemptions']['opportunity:331']);
|
||||
$this->assertSame($prior['importExemptions']['opportunity:332'], $store['importExemptions']['opportunity:332']);
|
||||
$this->assertSame($prior['importExemptions'], $store['importExemptions']);
|
||||
$this->assertSame('presented', $book['rows'][0]['request']['state']);
|
||||
$this->assertSame('granted', $book['rows'][0]['requestRecords'][0]['request']['state']);
|
||||
$this->assertSame('SYNTHETIC QA apenas bens A', $book['rows'][0]['requestRecords'][0]['request']['goods']);
|
||||
$this->assertSame('2026-11', $book['rows'][0]['requestRecords'][0]['request']['periodTo']);
|
||||
$this->assertSame('SYNTHETIC QA assistência imediata às vítimas das cheias', $book['rows'][1]['humanitarianPurpose']);
|
||||
$this->assertSame('800', $book['rows'][0]['normalVat']);
|
||||
$this->assertSame('yes', $book['rows'][1]['conditionalAssumption']);
|
||||
$this->assertArrayNotHasKey('appliedBenefit', $book);
|
||||
$this->assertNull($store['books']['opportunity:329']['result']['periods'][0]['fields']['11']);
|
||||
$this->assertNull($store['books']['opportunity:329']['result']['periods'][0]['fields']['17']);
|
||||
$this->assertStringContainsString('BR89', $store['books']['opportunity:329']['result']['sources']);
|
||||
file_put_contents(base_path('outputs/iva-offer-paths-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,33 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { offerPreparationBook,saveOfferPreparationBook,recordOfferRequest,emptyOfferRequest,offerScenarios,offerPathReport,recordOfferPreparations } from '../../resources/js/lib/iva-offer-preparations.ts';
|
||||
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
|
||||
const frozen=JSON.parse(fs.readFileSync(new URL('./fixtures/iva-g6e-inputs.json',import.meta.url),'utf8'));
|
||||
const draft=()=>({...structuredClone(frozen),context_opportunity:'opportunity:334',project_title:'SYNTHETIC QA — Offer paths'});
|
||||
const row84=()=>({...offerPreparationBook(draft()).rows[0],normalVat:'800',normalVatSource:'SYNTHETIC QA pressuposto expresso, não taxa inferida',conditionalAssumption:'yes'});
|
||||
test('presented request never grants benefit;normal amount requires own source and conditional scenario explicit material assumptions',()=>{
|
||||
const row={...row84(),request:{...emptyOfferRequest(),state:'presented',reference:'SYNTHETIC QA pedido',presentedOn:'2026-11-20'}};
|
||||
assert.deepEqual(offerScenarios(row),{normalVat:'800',conditionalVat:'0.00',conditionalReason:offerScenarios(row).conditionalReason,applied:false});assert.match(offerScenarios(row).conditionalReason,/Não é benefício concedido/);
|
||||
for(const patch of [{conditionalAssumption:''},{goodsSuitable:''},{goodsSuitable:'no'},{period:'2027-01'},{currency:'USD'}])assert.equal(offerScenarios({...row,...patch}).conditionalVat,null);
|
||||
for(const patch of [{normalVat:''},{normalVatSource:''},{normalVat:'-10'},{normalVat:'desconhecido'}])assert.equal(offerScenarios({...row,...patch}).normalVat,null);
|
||||
assert.match(offerPathReport(row),/Pedido Apresentado/);assert.match(offerPathReport(row),/apresentar pedido não concede isenção/);
|
||||
});
|
||||
test('P084 recorded decisions preserve goods/operation/period/conditions and previous decision when current inputs change',()=>{
|
||||
let row={...row84(),request:{...emptyOfferRequest(),state:'granted',decisionRef:'SYNTHETIC QA decisão parcial',decisionOn:'2026-11-25',goods:'SYNTHETIC QA só bens A',operation:'import',periodFrom:'2026-12',periodTo:'2026-12',conditions:'SYNTHETIC QA exclusivamente entrega ao destinatário A'}};
|
||||
row=recordOfferRequest(row);assert.equal(row.requestRecords.length,1);const accepted=structuredClone(row.requestRecords[0]);
|
||||
row={...row,description:'Bens B posteriores',operation:'acquisition',period:'2027-01',request:{...row.request,state:'denied',decisionRef:'SYNTHETIC QA decisão B',goods:'Bens B',periodFrom:'2027-01',periodTo:'2027-01',operation:'acquisition',conditions:'SYNTHETIC QA apenas pedido B'}};row=recordOfferRequest(row);assert.deepEqual(row.requestRecords[0],accepted);assert.equal(row.requestRecords.length,2);assert.equal(offerScenarios(row).applied,false);assert.equal(offerScenarios(row).conditionalVat,null);assert.match(offerPathReport(row),/não estender decisão/);
|
||||
for(const key of ['goods','operation','periodFrom','periodTo','conditions'])assert.throws(()=>recordOfferRequest({...row,request:{...row.request,[key]:''}}),/âmbito da decisão/);
|
||||
const exact=recordOfferRequest({...row,request:{...row.request,periodFrom:'2027-01-10',periodTo:'2027-01-15'}});assert.equal(exact.requestRecords.at(-1).request.periodFrom,'2027-01-10');assert.equal(exact.requestRecords.at(-1).request.periodTo,'2027-01-15');assert.throws(()=>recordOfferRequest({...row,request:{...row.request,periodFrom:'2027-02-30',periodTo:'2027-03-01'}}),/âmbito da decisão/);
|
||||
assert.equal(row.requestRecords[0].request.operation,'import');assert.equal(row.operation,'acquisition');
|
||||
});
|
||||
test('P085 humanitarian purpose/emergency/documents are distinct;missing inputs stay indeterminate and no P084 state is imposed',()=>{
|
||||
const base={...offerPreparationBook(draft()).rows[1],period:'2026-12',beneficiary:'SYNTHETIC QA destinatário',purpose:'SYNTHETIC QA atenuar cheias',humanitarianPurpose:'SYNTHETIC QA água e alimentação para deslocados',availableDocuments:'SYNTHETIC QA lista de bens\nSYNTHETIC QA guia disponível',normalVat:'160',normalVatSource:'SYNTHETIC QA previsão explícita',conditionalAssumption:'yes'};
|
||||
assert.equal(offerScenarios(base).conditionalVat,'0.00');assert.equal(offerScenarios({...base,humanitarianPurpose:''}).conditionalVat,null);assert.equal(offerScenarios({...base,mitigation:'no'}).conditionalVat,null);assert.throws(()=>recordOfferRequest(base),/P084/);assert.match(offerPathReport(base),/finalidade humanitária/);assert.match(offerPathReport(base),/procedimento de emergência\/doação por corresponder/);assert.equal(base.request,undefined);
|
||||
});
|
||||
test('save/recover/report retain old drafts/histories and all27fiscalbooks;normal and conditional are labelled and field11 stays unchanged',()=>{
|
||||
const d=draft(),prior=JSON.parse(frozen.modelo_a_snapshot),old=offerPreparationBook(d);const priorDecision=recordOfferRequest({...row84(),request:{...emptyOfferRequest(),state:'granted',decisionRef:'SYNTHETIC QA decisão parcial anterior',goods:'SYNTHETIC QA apenas bens A',operation:'import',periodFrom:'2026-11',periodTo:'2026-11',conditions:'SYNTHETIC QA só a operação anterior A'}});const p84=recordOfferRequest({...priorDecision,request:{...emptyOfferRequest(),state:'presented',reference:'SYNTHETIC QA pedido em apreciação'}});const p85={...old.rows[1],period:'2026-12',beneficiary:'SYNTHETIC QA destinatário',purpose:'SYNTHETIC QA resposta humanitária',humanitarianPurpose:'SYNTHETIC QA assistência imediata às vítimas das cheias',availableDocuments:'SYNTHETIC QA bens e destinatário',normalVat:'160',normalVatSource:'SYNTHETIC QA previsão sem taxa inferida',conditionalAssumption:'yes'};
|
||||
const book=recordOfferPreparations({...old,rows:[p84,p85]});assert.deepEqual(book.history.at(-1),old.saved);d.modelo_a_snapshot=saveOfferPreparationBook(d,book);assert.deepEqual(offerPreparationBook(JSON.parse(JSON.stringify(d))),book);const next=JSON.parse(d.modelo_a_snapshot);for(const key of ['books','salesOperations','importExemptions'])assert.deepEqual(next[key],prior[key]);assert.equal(next.books['opportunity:329'].result.periods[0].fields['11'],null);
|
||||
const report=JSON.stringify(createProjectDossierReport(d,{selectedSections:new Set(['finance']),version:'QA offer paths'}));for(const token of ['Pedido Apresentado','Cenário normal','Cenário condicionado à isenção','hipótese','finalidade humanitária','benefício não aplicado','800','160','apresentar pedido não concede isenção','SYNTHETIC QA decisão parcial anterior','SYNTHETIC QA apenas bens A','2026-11'])assert.ok(report.includes(token),token);
|
||||
if(process.env.IVA_OFFER_PATHS)fs.writeFileSync(process.env.IVA_OFFER_PATHS,JSON.stringify(d));
|
||||
});
|
||||
Reference in New Issue
Block a user