fix(iva): preserve exact and literal scope of offer decisions

This commit is contained in:
2026-10-24 20:31:58 +02:00
parent 7ecf9ec79a
commit 946fc08906
3 changed files with 8 additions and 4 deletions
@@ -29,9 +29,11 @@ export function SimulationOfferPreparations({draft,onChange}:{draft:Record<strin
<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>
<label>Âmbito de operações literal, se a decisão abranger mais do que a opção indicada<input className="block rounded border p-2" value={request.operationScope??''} onChange={e=>edit({operationScope:e.target.value})}/></label>
<label>Âmbito temporal literal, se não estiver limitado pelas datas indicadas<input className="block rounded border p-2" placeholder="Conservar, por exemplo, a indicação sem termo ou até revogação do documento" value={request.periodScope??''} onChange={e=>edit({periodScope:e.target.value})}/></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>)}
{(row.requestRecords??[]).map((record,index)=><p key={index}>{offerRequestLabels[record.request.state]} — {record.recordedAt}; bens: {record.request.goods||'por indicar'}; operação: {record.request.operationScope||record.request.operation||'por indicar'}; período: {record.request.periodScope||`${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>
+4 -3
View File
@@ -3,7 +3,7 @@ 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 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 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;operationScope?:string;periodScope?: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[]}};
@@ -52,7 +52,8 @@ 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.');
const temporalScope=!!request.periodScope?.trim()||(validPeriod(request.periodFrom)&&validPeriod(request.periodTo)&&request.periodFrom.length===request.periodTo.length&&request.periodTo>=request.periodFrom);
if(['granted','denied'].includes(request.state)&&(!request.goods.trim()||(!['acquisition','import'].includes(request.operation)&&!request.operationScope?.trim())||!temporalScope||!request.conditions.trim()))throw new Error('Conserve o âmbito da decisão: bens, operação, período e condições. Pode usar datas, meses ou o âmbito temporal literal do documento, inclusive sem termo. 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}{
@@ -66,7 +67,7 @@ export function offerScenarios(row:OfferPreparation):{normalVat:string|null;cond
}
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'}`;
const scope=(r:OfferRequest)=>`bens ${r.goods||'por indicar'}; operação ${r.operationScope||r.operation||'por indicar'}; período ${r.periodScope||`${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]>{
+1
View File
@@ -19,6 +19,7 @@ test('P084 recorded decisions preserve goods/operation/period/conditions and pre
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/);
const literal=recordOfferRequest({...row,request:{...row.request,operation:'',operationScope:'SYNTHETIC QA aquisição e importação dos bens A',periodFrom:'',periodTo:'',periodScope:'SYNTHETIC QA desde a decisão até revogação, sem termo'}});assert.match(offerPathReport(literal),/até revogação, sem termo/);assert.equal(literal.requestRecords.at(-1).request.operationScope,'SYNTHETIC QA aquisição e importação dos bens A');
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',()=>{