diff --git a/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx b/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx index 3ffaea0f..29e815a0 100644 --- a/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx +++ b/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx @@ -61,7 +61,7 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) { onOpen={onOpenDrawer} /> diff --git a/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts b/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts index d383686c..16ee44a1 100644 --- a/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts +++ b/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts @@ -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"); + }); }); diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index 19acd17f..e692a54e 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -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";