Files
Investidor-V2/resources/js/lib/context-inheritance.ts
T
2026-10-23 15:41:37 +02:00

119 lines
9.0 KiB
TypeScript

import { restoreBusinessPlanSnapshot } from './business-financial-plan.ts';
const empty = (value: unknown) => value == null || value === '' || (Array.isArray(value) && value.length === 0);
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).sort(([a],[b])=>a.localeCompare(b)).map(([key,item])=>[key,canonical(item)]));
return value;
}
// Change tracking only, never an authorization, integrity or legal-acceptance token. No personal values in metadata.
function signature(value: unknown): string {
const text=JSON.stringify(canonical(value)) ?? 'undefined';let hash=14695981039346656037n;
for(let index=0;index<text.length;index++) hash=BigInt.asUintN(64,(hash^BigInt(text.charCodeAt(index)))*1099511628211n);
return `${text.length}:${hash.toString(16)}`;
}
export function inheritCompatibleFields(scope:string, values:Record<string,unknown>, source:Record<string,unknown>, raw:unknown, allowed?:string[]): {patch:Record<string,unknown>; metadata:string; inherited:string[]; conflicts:string[]} {
let previous:Record<string,string>={};
try { const saved=JSON.parse(String(raw ?? ''));if(saved.version===1 && saved.scope===scope && saved.inherited && typeof saved.inherited==='object') previous=saved.inherited; } catch { /* Legacy inputs are retained, not overwritten. */ }
const next={...previous};const patch:Record<string,unknown>={};const inherited:string[]=[];const conflicts:string[]=[];
const geo=['province_id','district_id','admin_post_id','locality_id'];
const geoConflict=geo.some(key=>(!empty(values[key]) || key in previous) && signature(values[key])!==signature(source[key]) && previous[key]!==signature(values[key]));
for(const [key,value] of Object.entries(source)) {
if ((allowed && !allowed.includes(key)) || (empty(value) && !(key in previous))) continue;
if (key === 'context_applicability_snapshot') {
// Read-only assessment of the current dossier, not a deliberate form answer.
if (signature(values[key]) !== signature(value)) patch[key] = value;
next[key] = signature(value); inherited.push(key);
continue;
}
if (key.endsWith('_label')) {
const base = key.slice(0, -6);
const baseOwned = previous[base] === signature(values[base]);
const baseVacant = empty(values[base]) && !(base in previous);
if (!(base in source) || (geo.includes(base) && geoConflict) || (!baseOwned && !baseVacant && signature(values[base]) !== signature(source[base]))) continue;
// A label describes its identifier; it must never lag behind an adopted identifier.
if (signature(values[key]) !== signature(value)) patch[key] = value;
next[key] = signature(value); inherited.push(key);
continue;
}
const current=values[key];
const track = (item:unknown) => key==='parcels' ? Object.fromEntries(geo.map(field=>[field,(item as Array<Record<string,unknown>>)?.[0]?.[field] ?? ''])) : item;
const owned=previous[key]===signature(track(current));
const vacant=key==='parcels' ? geo.every(field=>empty((current as Array<Record<string,unknown>>)?.[0]?.[field])) : empty(current);
if ((geo.includes(key) && geoConflict) || (!owned && (!vacant || key in previous))) {
if(signature(current)!==signature(value)) conflicts.push(key);
continue;
}
if(key==='parcels') {
const parcels=current as Array<Record<string,unknown>>|undefined;
if(!owned && parcels?.length && geo.some(field=>!empty(parcels[0]?.[field]))) {conflicts.push(key);continue;}
}
if(signature(track(current))!==signature(track(value))) {
if(key==='parcels') {
const parcels=(current as Array<Record<string,unknown>>) ?? [];
patch[key]=[{...parcels[0],...Object.fromEntries(geo.map(field=>[field,(value as Array<Record<string,unknown>>)[0]?.[field] ?? '']))},...parcels.slice(1)];
} else patch[key]=value;
}
next[key]=signature(track(value));inherited.push(key);
}
return {patch,metadata:JSON.stringify({version:1,scope,inherited:next}),inherited,conflicts};
}
/** Presentation only: a saved, unedited inherited value can be shown without asking again. */
export function reusedInvestmentValue(scope: string, key: string, values: Record<string, unknown>): string | null {
if (!['journey_type', 'purpose', 'project_title', 'project_summary', 'sector_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id'].includes(key)) return null;
try {
const metadata = JSON.parse(String(values.context_reuse ?? ''));
if (metadata.version !== 1 || metadata.scope !== scope || empty(values[key]) || metadata.inherited?.[key] !== signature(values[key])) return null;
if (key.endsWith('_id') || ['journey_type', 'purpose'].includes(key)) {
const label = values[`${key}_label`];
return typeof label === 'string' && label.trim() && metadata.inherited?.[`${key}_label`] === signature(label) ? label : null;
}
return typeof values[key] === 'string' ? values[key] : null;
} catch { return null; }
}
/** Report the lineage without changing a stored answer or disclosing the tracking hash. */
export function investmentFieldOrigin(values: Record<string, unknown>, key: string, fallback: string): string {
try {
const reuse = JSON.parse(String(values.context_reuse ?? ''));
if (reuse.version !== 1 || !/^dossier:.+:simulator$/.test(reuse.scope) || !reuse.inherited?.[key]) return fallback;
const snapshot = JSON.parse(String(values.context_applicability_snapshot ?? ''));
const origin = snapshot.field_provenance?.[key];
const source = origin?.kind === 'opportunity' ? `oportunidade «${origin.title ?? 'referência guardada'}»`
: origin?.kind === 'process_declaration' || origin?.kind === 'dossier' ? 'dados introduzidos pelo utilizador no dossier'
: origin?.kind === 'sector' ? `catálogo sectorial «${origin.title ?? 'referência guardada'}»`
: origin?.kind === 'territory' ? `catálogo territorial «${origin.title ?? 'referência guardada'}»`
: origin?.kind === 'linked_simulation' ? 'simulador ligado ao mesmo dossier'
: 'dossier de investimento';
const version = origin?.version ? `, versão ${origin.version}` : '';
return reuse.inherited[key] === signature(values[key])
? `Herdado de ${source}${version}`
: `Alterado posteriormente pelo utilizador; origem anterior: ${source}${version}`;
} catch { return fallback; }
}
export function inheritSimulatorFields<T extends Record<string,unknown>>(draft:T): T {
const source:Record<string,unknown>={};
const plan=draft.business_plan_snapshot ? restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot)) : null;
if(plan?.currency) source.currency=plan.currency;
const currency=draft.currency || source.currency;
if(currency) {source.fiscal_currency=currency;source.opportunity_currency=currency;source.published_iva_currency=currency;}
if(draft.tax_residency_country) source.fiscal_residency_country=draft.tax_residency_country;
if(draft.fiscal_reference_date) source.published_iva_date=draft.fiscal_reference_date;
let settings:Record<string,unknown>={start:'',basis:'unknown',declared:false,auto:false};
let settingsValid=!draft.monthly_iva_settings;
try {const saved=JSON.parse(String(draft.monthly_iva_settings ?? ''));if(saved && !Array.isArray(saved) && typeof saved.start==='string' && ['net','included','unknown'].includes(saved.basis) && typeof saved.declared==='boolean' && typeof saved.auto==='boolean') {settings=saved;settingsValid=true;}} catch { /* Invalid stored inputs remain intact. */ }
const start=String(draft.expected_start_date ?? '');
if(/^\d{4}-\d{2}-\d{2}$/.test(start)) source.monthly_iva_start=start.slice(0,7);
else if(/^\d{4}$/.test(String(draft.fiscal_period)) && draft.fiscal_periodicity==='monthly' && /^(0?[1-9]|1[0-2])$/.test(String(draft.fiscal_month))) source.monthly_iva_start=`${draft.fiscal_period}-${String(draft.fiscal_month).padStart(2,'0')}`;
if(!settingsValid) delete source.monthly_iva_start;
// Legacy drafts without a compatible source need no inheritance marker or autosave churn.
if (!Object.keys(source).length && !draft.compatible_fields_snapshot) return draft;
const values={...draft,monthly_iva_start:settings.start ?? ''};
const patch=inheritCompatibleFields('simulator-compatible-fields',values,source,draft.compatible_fields_snapshot);
const next:Record<string,unknown>={...draft,...patch.patch,compatible_fields_snapshot:patch.metadata};
if('monthly_iva_start' in patch.patch) next.monthly_iva_settings=JSON.stringify({...settings,start:patch.patch.monthly_iva_start});
delete next.monthly_iva_start;
return JSON.stringify(next)===JSON.stringify(draft) ? draft : next as T;
}