import { inputText } from './input-text.ts'; 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, source: Record, raw: unknown, allowed?: string[], ): { patch: Record; metadata: string; inherited: string[]; conflicts: string[]; } { let previous: Record = {}; try { const saved = JSON.parse(inputText(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 = {}; 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>)?.[0]?.[ field ] ?? '', ]), ) : item; const owned = previous[key] === signature(track(current)); const vacant = key === 'parcels' ? geo.every((field) => empty( (current as Array>)?.[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> | 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>) ?? []; patch[key] = [ { ...parcels[0], ...Object.fromEntries( geo.map((field) => [ field, (value as Array>)[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 | 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(inputText(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, key: string, fallback: string, ): string { try { const reuse = JSON.parse(inputText(values.context_reuse ?? '')); if ( reuse.version !== 1 || !/^dossier:.+:simulator$/.test(reuse.scope) || !reuse.inherited?.[key] ) return fallback; const snapshot = JSON.parse( inputText(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>( draft: T, ): T { const source: Record = {}; const plan = draft.business_plan_snapshot ? restoreBusinessPlanSnapshot(inputText(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 = { start: '', basis: 'unknown', declared: false, auto: false, }; let settingsValid = !draft.monthly_iva_settings; try { const saved = JSON.parse(inputText(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 = inputText(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 = `${inputText(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 = { ...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); }