/** * Pure helpers for strategy cascade UI (no React / no network). * Canonical path ids match backend/core/strategy/recommend.py. */ /** Tax sub-path ids from backend core/strategy/tax_paths.py */ export type TaxChecklistId = 'psi' | 'ulga_br' | 'ip_box' | string; export type StrategyPathView = { id: string; label: string; score: number; reasons: string[]; primary: boolean; de_minimis_blocked: boolean; /** Tax path (`tax_psi_ulgi`) only — primary checklist ref (usually psi) */ checklist_id?: string | null; /** Tax path only — ordered ids: psi, ulga_br, ip_box */ checklist_ids?: TaxChecklistId[]; /** PSI guardrail — never suggest submit to PARP */ never_say_submit_to_parp?: boolean; }; export type StrategyResultView = { paths: StrategyPathView[]; primaryPathId: string | null; recommendedPathIds: string[]; deMinimisExhausted: boolean; mspStatus: string | null; deMinimisUsedEur: number | null; notes: string[]; cascadeOrder: string[]; }; export const CANONICAL_CASCADE_ORDER = [ 'tax_psi_ulgi', 'regional_fst', 'national_feng', 'debt_loan_guarantee', 'direct_eu_prep', ] as const; export type CanonicalPathId = (typeof CANONICAL_CASCADE_ORDER)[number]; /** Normalize API strategy payload into a stable view model. Always ≥4 paths when source has them. */ export function normalizeStrategyResult( raw: Record | null | undefined, ): StrategyResultView { const empty: StrategyResultView = { paths: [], primaryPathId: null, recommendedPathIds: [], deMinimisExhausted: false, mspStatus: null, deMinimisUsedEur: null, notes: [], cascadeOrder: [...CANONICAL_CASCADE_ORDER], }; if (!raw || typeof raw !== 'object') return empty; const pathsIn = Array.isArray(raw.paths) ? raw.paths : []; const paths: StrategyPathView[] = pathsIn .filter((p): p is Record => !!p && typeof p === 'object') .map((p) => { const checklistIds = Array.isArray(p.checklist_ids) ? p.checklist_ids.map((x) => String(x)).filter(Boolean) : undefined; const view: StrategyPathView = { id: String(p.id || ''), label: String(p.label || p.id || 'Ścieżka'), score: Number(p.score) || 0, reasons: Array.isArray(p.reasons) ? p.reasons.map((r) => String(r)).filter(Boolean) : [], primary: Boolean(p.primary), de_minimis_blocked: Boolean(p.de_minimis_blocked), }; if (checklistIds && checklistIds.length > 0) { view.checklist_ids = checklistIds; } if (p.checklist_id != null && p.checklist_id !== '') { view.checklist_id = String(p.checklist_id); } else if (checklistIds && checklistIds.length > 0) { view.checklist_id = checklistIds[0]; } if (p.never_say_submit_to_parp != null) { view.never_say_submit_to_parp = Boolean(p.never_say_submit_to_parp); } return view; }) .filter((p) => p.id); // Sort by score desc; keep blocked paths visible (not filtered out) paths.sort((a, b) => b.score - a.score); const primaryFromFlag = paths.find((p) => p.primary)?.id ?? null; const primaryPathId = (typeof raw.primary_path_id === 'string' && raw.primary_path_id) || primaryFromFlag; // Ensure exactly one primary flag matches primary_path_id if (primaryPathId) { for (const p of paths) { p.primary = p.id === primaryPathId && !p.de_minimis_blocked; } // if primary was a blocked path, fall back to highest non-blocked if (!paths.some((p) => p.primary)) { const fallback = paths.find((p) => !p.de_minimis_blocked); if (fallback) fallback.primary = true; } } const rec = Array.isArray(raw.recommended_paths) ? raw.recommended_paths : []; const recommendedPathIds = rec .map((p) => (p && typeof p === 'object' ? String((p as { id?: string }).id || '') : '')) .filter(Boolean); const elig = raw.eligibility_summary && typeof raw.eligibility_summary === 'object' ? (raw.eligibility_summary as Record) : {}; const cascadeOrder = Array.isArray(raw.cascade_order) ? raw.cascade_order.map((x) => String(x)) : [...CANONICAL_CASCADE_ORDER]; const notes = Array.isArray(raw.notes) ? raw.notes.map((n) => String(n)) : []; return { paths, primaryPathId: paths.find((p) => p.primary)?.id ?? primaryPathId, recommendedPathIds: recommendedPathIds.length > 0 ? recommendedPathIds : paths.filter((p) => !p.de_minimis_blocked).slice(0, 4).map((p) => p.id), deMinimisExhausted: Boolean(elig.de_minimis_exhausted), mspStatus: elig.msp != null ? String(elig.msp) : null, deMinimisUsedEur: typeof elig.de_minimis_used_eur === 'number' ? elig.de_minimis_used_eur : null, notes, cascadeOrder, }; } /** Paths the UI should show as cards — all scored paths (including blocked). */ export function pathsForCards(view: StrategyResultView): StrategyPathView[] { return view.paths.slice(); } export function isBlockedPath(path: StrategyPathView): boolean { return Boolean(path.de_minimis_blocked); } export function formatScore(score: number): string { if (!Number.isFinite(score)) return '—'; return Math.round(score).toString(); } /** Human-readable short labels for tax checklist chips (UI only). */ export const TAX_CHECKLIST_LABELS: Record = { psi: 'PSI', ulga_br: 'Ulga B+R', ip_box: 'IP Box', }; export function formatTaxChecklistId(id: string): string { return TAX_CHECKLIST_LABELS[id] || id; } /** True when path carries tax checklist_ids from attach_tax_checklists. */ export function hasTaxChecklists(path: StrategyPathView): boolean { return Array.isArray(path.checklist_ids) && path.checklist_ids.length > 0; } /** * Decide which path should become the active selection after a recommend load. * - If the user already picked a still-valid non-blocked path, keep it (no auto change). * - Otherwise prefer primary (non-blocked), else highest non-blocked score. * Returns the path to select, or null when nothing to change/notify. */ export function resolveAutoSelectedPath( view: StrategyResultView, currentSelected: string | null | undefined, userHasPicked: boolean, ): StrategyPathView | null { if (userHasPicked && currentSelected) { const still = view.paths.find( (p) => p.id === currentSelected && !isBlockedPath(p), ); if (still) return null; } if (currentSelected) { const still = view.paths.find( (p) => p.id === currentSelected && !isBlockedPath(p), ); if (still && !userHasPicked) { // Keep current (e.g. parent already holds primary) — no re-notify needed // only if it is already the primary/chosen; still return null to avoid loops return null; } } const primary = view.paths.find((p) => p.primary && !isBlockedPath(p)) || view.paths.find((p) => p.id === view.primaryPathId && !isBlockedPath(p)) || view.paths.find((p) => !isBlockedPath(p)); if (!primary) return null; if (primary.id === currentSelected) return null; return primary; }