grantforge-api / frontend-react /src /components /company /strategyCascadeModel.ts
GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
7.16 kB
/**
* 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;
}