0 ? "+" : rub < 0 ? MINUS : "";
+ if (abs >= 1e9) return `${sign}${(abs / 1e9).toFixed(1)} млрд ₽`;
+ if (abs >= 1e6) return `${sign}${Math.round(abs / 1e6)} млн ₽`;
+ return `${sign}${nf.format(Math.round(abs))} ₽`;
+}
+
+/** ₽/м² с пробелами-разрядами: «142 500 ₽/м²». */
+function formatPricePerSqm(rub: number): string {
+ return `${nf.format(Math.round(rub))} ₽/м²`;
+}
+
+/** Проценты доли: 0.184 → «18.4%». Отрицательные — с Unicode-минусом. */
+function formatPct(fraction: number): string {
+ const v = (fraction * 100).toFixed(1);
+ return v.startsWith("-") ? `${MINUS}${v.slice(1)}%` : `${v}%`;
+}
+
+function formatInt(n: number): string {
+ return nf.format(Math.round(n));
+}
+
+// ── Narrowing (backend отдаёт класс/тип/источник строкой, extra=allow) ─────────
+
+const HOUSING_CLASSES: readonly HousingClass[] = [
+ "econom",
+ "comfort",
+ "business",
+];
+const DEVELOPMENT_TYPES: readonly DevelopmentType[] = [
+ "spot",
+ "mid_rise",
+ "high_rise",
+];
+function housingClassLabel(value: string): string {
+ return (HOUSING_CLASSES as readonly string[]).includes(value)
+ ? HOUSING_CLASS_LABELS[value as HousingClass]
+ : value;
+}
+
+function developmentTypeLabel(value: string): string {
+ return (DEVELOPMENT_TYPES as readonly string[]).includes(value)
+ ? DEVELOPMENT_TYPE_LABELS[value as DevelopmentType]
+ : value;
+}
+
+/**
+ * Честная подпись источника цены (тот же набор, что priceSourceCaption в
+ * concept-api + objective_geo_radius из parcel_financial). Вариант несёт
+ * price_source строкой, а не FinancialModel-объект, поэтому берём каноничные
+ * фразы по значению — норматив класса НИКОГДА не выдаётся за рыночную цену,
+ * и наоборот: рыночный источник не должен падать в норматив-фразу (инверсия
+ * честности; неизвестный источник показываем как есть, не как норматив).
+ */
+function priceSourceLabel(source: string): string {
+ switch (source) {
+ case "objective_district_median":
+ return "рынок: медиана объявлений Objective по району";
+ case "objective_geo_radius":
+ return "рынок: медиана новостроек в радиусе 3 км";
+ case "district_reference":
+ return "рынок: справочная медиана района";
+ case "class_norm":
+ return "норматив класса (нет рыночных данных по участку)";
+ default:
+ return source;
+ }
+}
+
+// ── Props ─────────────────────────────────────────────────────────────────────
+
+interface Props {
+ /** program_alternatives из ответа /analyze (null/пусто → блок не рендерится). */
+ alternatives?: ProgramAlternatives | null;
+}
+
+// ── Variant card ──────────────────────────────────────────────────────────────
+
+function VariantCard({
+ variant,
+ isBest,
+}: {
+ variant: ProgramVariant;
+ isBest: boolean;
+}) {
+ const npvPositive = variant.npv_rub > 0;
+ const npvColor = npvPositive
+ ? "var(--success)"
+ : variant.npv_rub < 0
+ ? "var(--danger)"
+ : "var(--fg-primary)";
+
+ const title = `${housingClassLabel(variant.housing_class)} · ${variant.floors} эт · ${developmentTypeLabel(variant.development_type)}`;
+
+ return (
+
+ {/* Титул + бейдж «лучший» */}
+
+
+ {title}
+
+ {isBest && (
+
+ лучший
+
+ )}
+
+
+ {/* NPV — крупно */}
+
+
+ NPV
+
+
+ {formatMoneySigned(variant.npv_rub)}
+
+
+
+ {/* ROI / IRR строкой */}
+
+ ROI {formatPct(variant.roi)} · IRR {formatPct(variant.irr)}
+
+
+ {/* Жилая S · квартиры */}
+
+ Жилая {formatInt(variant.residential_sqm)} м² ·{" "}
+ {formatInt(variant.apartments_count)} квартир
+
+
+ {/* Цена м² + источник */}
+
+ Цена {formatPricePerSqm(variant.price_per_sqm_used)} (
+ {priceSourceLabel(variant.price_source)})
+
+
+ );
+}
+
+// ── SectionAlternatives ───────────────────────────────────────────────────────
+
+export function SectionAlternatives({ alternatives }: Props) {
+ // Рендер-guard: нет поля / пустой список → блок не рендерится вовсе.
+ const variants = alternatives?.variants ?? [];
+ if (!alternatives || variants.length === 0) return null;
+
+ const topVariants = variants.slice(0, TOP_N);
+ const { any_viable, grid_size, caveat } = alternatives;
+
+ const subtitle = any_viable
+ ? "Базовая оценка отрицательна, но участок сходится при другой программе — топ вариантов по NPV:"
+ : `При текущих вводных ни один из ${grid_size} вариантов программы (класс × этажность) не выходит в плюс. Ниже — наименее убыточные:`;
+
+ // «Лучший» = вариант с максимальным NPV. Список уже отсортирован по NPV убыв.
+ // на backend, но берём индекс явно (устойчиво к любому порядку прихода).
+ let bestIndex = 0;
+ for (let i = 1; i < topVariants.length; i++) {
+ if (topVariants[i].npv_rub > topVariants[bestIndex].npv_rub) bestIndex = i;
+ }
+
+ // Honest-caveat одним предложением: caveat ответа + допущение по графику, если
+ // хоть один показанный вариант считался по типовому графику строительства.
+ const scheduleIsDefault = topVariants.some((v) => v.schedule_is_default);
+ const caveatText = scheduleIsDefault
+ ? `${caveat} График строительства — типовое допущение, не график проекта.`
+ : caveat;
+
+ return (
+
+
+
+ {/* Карточки топ-3 — auto-fit грид как KPI-ряды. */}
+
+ {topVariants.map((variant, i) => (
+
+ ))}
+
+
+ {/* Один честный caveat — полным предложением, --fg-tertiary. */}
+
+ {caveatText}
+
+
+ );
+}
diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts
index 06624738..8a12aaac 100644
--- a/frontend/src/types/site-finder.ts
+++ b/frontend/src/types/site-finder.ts
@@ -546,6 +546,53 @@ export interface ParcelAnalysis {
// Эпик #1881 PR-4 — финмодель из /analyze (extra=allow на backend, hand-typed
// здесь). null когда нельзя строить МКД / нет зонинга / нет площади.
financial_estimate?: ParcelFinancialEstimate | null;
+ // #2181 — топ-N вариантов программы (класс × этажность) по NPV. Backend кладёт
+ // это поле ТОЛЬКО при отрицательном фин-вердикте (program_optimizer.py);
+ // на позитивных участках null / отсутствует. Hand-typed (extra=allow на backend).
+ program_alternatives?: ProgramAlternatives | null;
+}
+
+/**
+ * Один вариант программы застройки (класс × этажность), посчитанный
+ * program_optimizer.optimize_program (#2181) через ту же compute_financial, что
+ * и основной financial_estimate. Класс/тип застройки приходят строкой (backend
+ * extra=allow) — на UI сужаются к HOUSING_CLASS_LABELS / DEVELOPMENT_TYPE_LABELS.
+ */
+export interface ProgramVariant {
+ // "econom" | "comfort" | "business" (Literal HousingClass на backend).
+ housing_class: string;
+ floors: number;
+ // "spot" | "mid_rise" | "high_rise" (по этажности комбо).
+ development_type: string;
+ // NPV дисконтированного cashflow, ₽ (ключ ранжирования; может быть отрицательным).
+ npv_rub: number;
+ irr: number;
+ roi: number;
+ net_profit_rub: number;
+ residential_sqm: number;
+ apartments_count: number;
+ // Цена продажи жилья, ₽/м², фактически использованная в выручке комбо.
+ price_per_sqm_used: number;
+ // "objective_district_median" | "district_reference" | "class_norm" — тот же
+ // honest-набор источников, что у financial_estimate.price_source.
+ price_source: string;
+ // true — график фаз/продаж типовой (допущение), не график проекта.
+ schedule_is_default: boolean;
+}
+
+/**
+ * Блок «Как участок сходится» (#2181) — ранжир вариантов программы. Присутствует
+ * только при отрицательном базовом фин-вердикте; иначе null / отсутствует.
+ */
+export interface ProgramAlternatives {
+ // Топ-N вариантов по NPV убыв. (тай-брейк ROI). При all-negative — «наименее убыточные».
+ variants: ProgramVariant[];
+ // true — хотя бы один вариант с NPV > 0 (участок сходится при другой программе).
+ any_viable: boolean;
+ // Сколько комбо (класс × этажность) реально посчиталось в переборе.
+ grid_size: number;
+ // HEAVY caveat — тот же, что у основной финмодели (оценка по МАКС. застройке зоны).
+ caveat: string;
}
/**