Spaces:
Sleeping
Sleeping
| /** | |
| * 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<string, unknown> | 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<string, unknown> => !!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<string, unknown>) | |
| : {}; | |
| 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<string, string> = { | |
| 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; | |
| } | |