feat(cockpit): поверхностить financial_estimate в Development Scan / Investment Clearance (#1881 rank-5) #1894

Merged
bot-backend merged 1 commit from feat/cockpit-financial-honesty into main 2026-06-24 16:20:20 +00:00
3 changed files with 169 additions and 18 deletions

View file

@ -61,7 +61,7 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
onOpen={onOpenDrawer}
/>
<ScanCard
card={adaptEconomyCard()}
card={adaptEconomyCard(analysis.financial_estimate)}
drawerKey="economy"
onOpen={onOpenDrawer}
/>

View file

@ -16,11 +16,13 @@ import { describe, expect, it } from "vitest";
import {
adaptBuySignalGauge,
adaptEconomyCard,
adaptFinanceDrawer,
adaptInvestmentClearance,
adaptLegalDrawer,
adaptPassport,
adaptPassportDrawer,
adaptSiteVerdict,
buySignalFromForecast,
} from "@/components/site-finder/ptica/ptica-adapt";
import type {
@ -91,8 +93,9 @@ describe("ptica-adapt honesty (#1871 P1.3)", () => {
// The passport-card encumbrances fix must also cover its two drawer mirrors
// (adaptPassportDrawer + adaptLegalDrawer), else the drill-down presents the
// em-dash stub as a live measurement.
const findEncumbrance = (rows: { k: string; field: { isReal: boolean } }[]) =>
rows.find((r) => r.k === "Обременения");
const findEncumbrance = (
rows: { k: string; field: { isReal: boolean } }[],
) => rows.find((r) => r.k === "Обременения");
const passportRow = findEncumbrance(adaptPassportDrawer(analysis).registry);
expect(passportRow?.field.isReal).toBe(false);
@ -122,6 +125,7 @@ function financialFixture(): ParcelFinancialEstimate {
housing_class_inferred: "comfort",
development_type_inferred: "high_rise",
schedule_is_default: true,
sales_duration_months: 30,
teap_synth: {
residential_area_sqm: 28_000,
total_floor_area_sqm: 34_000,
@ -224,4 +228,85 @@ describe("ptica-adapt finance (#1881 PR-4)", () => {
expect(keys).not.toContain("Проценты по кредиту");
expect(keys).not.toContain("Чистая прибыль после финанс.");
});
it("adaptFinanceDrawer: sales_duration_months shown with schedule source", () => {
const d = adaptFinanceDrawer(financialFixture()); // schedule_is_default: true
const row = d.rows.find((r) => r.k === "Горизонт продаж");
expect(row?.field.isReal).toBe(true);
expect(row?.field.value).toContain("30");
expect(row?.field.caption).toContain("дефолт");
const d2 = adaptFinanceDrawer({
...financialFixture(),
schedule_is_default: false,
});
const row2 = d2.rows.find((r) => r.k === "Горизонт продаж");
expect(row2?.field.caption).toContain("поглощения");
});
it("adaptInvestmentClearance: financing_enabled → «После финанс.» + relabeled profit", () => {
const c = adaptInvestmentClearance(financialFixture()); // financing_enabled: true
const labels = c.bignums.map((b) => b.label);
expect(labels).toContain("После финанс.");
expect(labels).toContain("Profit · до финанс.");
const after = c.bignums.find((b) => b.label === "После финанс.");
expect(after?.isReal).toBe(true);
expect(after?.caption).toContain("кредит");
});
it("adaptEconomyCard: no financial → all placeholders + badge предв.", () => {
const card = adaptEconomyCard(null);
expect(card.badge).toBe("предв.");
expect(card.rows.every((r) => r.field.isReal === false)).toBe(true);
});
it("adaptEconomyCard: financial present → real rows + badge ориент.", () => {
const card = adaptEconomyCard(financialFixture());
expect(card.badge).toBe("ориент.");
expect(card.rows.every((r) => r.field.isReal === true)).toBe(true);
const rev = card.rows.find((r) => r.key === "Потенц. выручки");
expect(rev?.field.value).toContain("млрд");
expect(rev?.field.isReal).toBe(true);
});
});
// ── #1881 PR-5 gate / site-verdict ─────────────────────────────────────────────
describe("ptica-adapt gate (#1892 ЗОУИТ area-gate)", () => {
it("adaptSiteVerdict: verdict_label «С ограничениями» → word=«С ограничениями» tone=warn", () => {
const a: ParcelAnalysis = {
...analyzeFixture,
gate_verdict: {
can_build_mkd: true,
verdict_label: "С ограничениями",
blockers: [],
warnings: [
{
code: "ZOUIT_ENGINEERING_PARTIAL",
detail: "Охранная зона ЛЭП перекрывает 12 % участка",
},
],
source: "nspd_dump",
},
} as unknown as ParcelAnalysis;
const v = adaptSiteVerdict(a);
expect(v.word).toBe("С ограничениями");
expect(v.tone).toBe("warn");
});
it("adaptSiteVerdict: can_build_mkd=true without ограничения → Приобретать good", () => {
const a: ParcelAnalysis = {
...analyzeFixture,
gate_verdict: {
can_build_mkd: true,
verdict_label: "Можно",
blockers: [],
warnings: [],
source: "nspd_dump",
},
} as unknown as ParcelAnalysis;
const v = adaptSiteVerdict(a);
expect(v.word).toBe("Приобретать");
expect(v.tone).toBe("good");
});
});

View file

@ -553,16 +553,48 @@ export function adaptMarketCard(a: ParcelAnalysis): PticaScanCard {
};
}
/** Экономика — PLACEHOLDER (предв.), surfaced after the financial model. */
export function adaptEconomyCard(): PticaScanCard {
/**
* Экономика REAL когда backend отдаёт `financial_estimate` (#1881), иначе
* честный PLACEHOLDER (предв.) до финмодели. Выручка/маржа/ROI/IRR в реальных
* числах; caption у выручки говорит источник цены (рынок vs норматив класса),
* IRR помечается «(proxy)» когда `irr_is_proxy`.
*/
export function adaptEconomyCard(
financial?: ParcelFinancialEstimate | null,
): PticaScanCard {
if (!financial) {
return {
title: "Экономика",
badge: "предв.",
rows: [
{ key: "Потенц. выручки", field: placeholder("после финмодели") },
{ key: "Маржа", field: placeholder("после финмодели") },
{ key: "ROI", field: placeholder("после финмодели") },
{ key: "IRR", field: placeholder("после финмодели") },
],
};
}
const irrLabel = financial.irr_is_proxy
? `${formatPctFromFraction(financial.irr)} % (proxy)`
: `${formatPctFromFraction(financial.irr)} %`;
return {
title: "Экономика",
badge: "предв.",
badge: "ориент.",
rows: [
{ key: "Потенц. выручки", field: placeholder("после финмодели") },
{ key: "Маржа", field: placeholder("после финмодели") },
{ key: "ROI", field: placeholder("после финмодели") },
{ key: "IRR", field: placeholder("после финмодели") },
{
key: "Потенц. выручки",
field: {
value: `${formatBln(financial.revenue_rub)} млрд ₽`,
isReal: true,
caption: financial.price_is_calibrated ? "рынок" : "норматив класса",
},
},
{
key: "Маржа",
field: real(`${formatPctFromFraction(financial.margin_pct)} %`),
},
{ key: "ROI", field: real(`${formatPctFromFraction(financial.roi)} %`) },
{ key: "IRR", field: real(irrLabel) },
],
};
}
@ -1765,7 +1797,10 @@ export function adaptFinanceDrawer(
summary,
rows: [
...financingRows,
{ k: "GDV (выручка)", field: real(`${formatBln(financial.revenue_rub)} млрд ₽`) },
{
k: "GDV (выручка)",
field: real(`${formatBln(financial.revenue_rub)} млрд ₽`),
},
{
k: "Совокупные затраты",
field: real(`${formatBln(financial.cost_rub)} млрд ₽`),
@ -1788,7 +1823,9 @@ export function adaptFinanceDrawer(
{ k: "Срок окупаемости", field: real(paybackLabel) },
{
k: "Цена ₽/м²",
field: real(`${formatRubPerM2(financial.price_per_sqm_used)} · ${priceLabel}`),
field: real(
`${formatRubPerM2(financial.price_per_sqm_used)} · ${priceLabel}`,
),
},
{
k: "Класс жилья",
@ -1804,6 +1841,19 @@ export function adaptFinanceDrawer(
financial.development_type_inferred,
),
},
{
k: "Горизонт продаж",
field:
financial.sales_duration_months != null
? {
value: `${fmtNum(financial.sales_duration_months, 0)} мес`,
isReal: true,
caption: financial.schedule_is_default
? "дефолт-норматив"
: "по темпу поглощения рынка",
}
: notReal("нет данных"),
},
{
k: "ТЭП · жилая площадь",
field: real(`${formatInt(teap.residential_area_sqm)} м²`),
@ -2076,9 +2126,7 @@ export function adaptInvestmentClearance(
const priceCaption = financial.price_is_calibrated
? "цена: рынок (Objective)"
: "цена: норматив класса";
const irrCaption = financial.irr_is_proxy
? "оценочный (proxy)"
: "DCF";
const irrCaption = financial.irr_is_proxy ? "оценочный (proxy)" : "DCF";
const paybackValue =
financial.payback_months != null
? `${(financial.payback_months / 12).toLocaleString("ru-RU", {
@ -2103,11 +2151,24 @@ export function adaptInvestmentClearance(
isReal: true,
},
{
label: "Profit · прибыль",
label: financial.financing_enabled
? "Profit · до финанс."
: "Profit · прибыль",
value: formatBln(financial.net_profit_rub),
unit: "млрд ₽",
isReal: true,
},
...(financial.financing_enabled
? [
{
label: "После финанс.",
value: formatBln(financial.net_profit_after_financing_rub),
unit: "млрд ₽",
isReal: true,
caption: `кредит ${formatPctFromFraction(financial.annual_rate_used)} %`,
},
]
: []),
{
label: "ROI",
value: `${formatPctFromFraction(financial.roi)} %`,
@ -2298,8 +2359,13 @@ export function adaptSiteVerdict(
let tone: PticaSiteVerdict["tone"] = "warn";
if (gate) {
if (gate.can_build_mkd === true) {
word = "Приобретать";
tone = "good";
if (gate.verdict_label === "С ограничениями") {
word = "С ограничениями";
tone = "warn";
} else {
word = "Приобретать";
tone = "good";
}
} else if (gate.can_build_mkd === false) {
word = "Нельзя";
tone = "bad";