326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
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<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(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<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(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<string, unknown>,
|
|
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<T extends Record<string, unknown>>(
|
|
draft: T,
|
|
): T {
|
|
const source: Record<string, unknown> = {};
|
|
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<string, unknown> = {
|
|
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<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);
|
|
}
|