feat(cockpit): поверхностить financial_estimate в Development Scan / Investment Clearance
- adaptEconomyCard(financial?) — показывает выручку/маржу/ROI/IRR из финмодели когда financial_estimate присутствует; badge «предв.» → «ориент.», caption честно говорит источник цены (рынок vs норматив класса), IRR — proxy-флаг - DevelopmentScan: передаёт analysis.financial_estimate в adaptEconomyCard - adaptFinanceDrawer: добавлена строка «Горизонт продаж» (sales_duration_months + caption: дефолт-норматив vs по темпу поглощения рынка) - adaptInvestmentClearance: financing_enabled=true → «Profit · до финанс.» + новый bignum «После финанс.» с post-financing net profit и caption ставки - adaptSiteVerdict: verdict_label «С ограничениями» → word=«С ограничениями» tone=warn (вместо Приобретать/good), корректно для ЗОУИТ area-gate parcels - Тесты: +6 кейсов (economy real/null, sales horizon, clearance financing, verdict «С ограничениями» / «Можно»); 20/20 pass
This commit is contained in:
parent
4eeda46cfa
commit
c1541962dc
3 changed files with 169 additions and 18 deletions
|
|
@ -61,7 +61,7 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
|
||||||
onOpen={onOpenDrawer}
|
onOpen={onOpenDrawer}
|
||||||
/>
|
/>
|
||||||
<ScanCard
|
<ScanCard
|
||||||
card={adaptEconomyCard()}
|
card={adaptEconomyCard(analysis.financial_estimate)}
|
||||||
drawerKey="economy"
|
drawerKey="economy"
|
||||||
onOpen={onOpenDrawer}
|
onOpen={onOpenDrawer}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,13 @@ import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
adaptBuySignalGauge,
|
adaptBuySignalGauge,
|
||||||
|
adaptEconomyCard,
|
||||||
adaptFinanceDrawer,
|
adaptFinanceDrawer,
|
||||||
adaptInvestmentClearance,
|
adaptInvestmentClearance,
|
||||||
adaptLegalDrawer,
|
adaptLegalDrawer,
|
||||||
adaptPassport,
|
adaptPassport,
|
||||||
adaptPassportDrawer,
|
adaptPassportDrawer,
|
||||||
|
adaptSiteVerdict,
|
||||||
buySignalFromForecast,
|
buySignalFromForecast,
|
||||||
} from "@/components/site-finder/ptica/ptica-adapt";
|
} from "@/components/site-finder/ptica/ptica-adapt";
|
||||||
import type {
|
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
|
// The passport-card encumbrances fix must also cover its two drawer mirrors
|
||||||
// (adaptPassportDrawer + adaptLegalDrawer), else the drill-down presents the
|
// (adaptPassportDrawer + adaptLegalDrawer), else the drill-down presents the
|
||||||
// em-dash stub as a live measurement.
|
// em-dash stub as a live measurement.
|
||||||
const findEncumbrance = (rows: { k: string; field: { isReal: boolean } }[]) =>
|
const findEncumbrance = (
|
||||||
rows.find((r) => r.k === "Обременения");
|
rows: { k: string; field: { isReal: boolean } }[],
|
||||||
|
) => rows.find((r) => r.k === "Обременения");
|
||||||
|
|
||||||
const passportRow = findEncumbrance(adaptPassportDrawer(analysis).registry);
|
const passportRow = findEncumbrance(adaptPassportDrawer(analysis).registry);
|
||||||
expect(passportRow?.field.isReal).toBe(false);
|
expect(passportRow?.field.isReal).toBe(false);
|
||||||
|
|
@ -122,6 +125,7 @@ function financialFixture(): ParcelFinancialEstimate {
|
||||||
housing_class_inferred: "comfort",
|
housing_class_inferred: "comfort",
|
||||||
development_type_inferred: "high_rise",
|
development_type_inferred: "high_rise",
|
||||||
schedule_is_default: true,
|
schedule_is_default: true,
|
||||||
|
sales_duration_months: 30,
|
||||||
teap_synth: {
|
teap_synth: {
|
||||||
residential_area_sqm: 28_000,
|
residential_area_sqm: 28_000,
|
||||||
total_floor_area_sqm: 34_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("Проценты по кредиту");
|
||||||
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
return {
|
||||||
title: "Экономика",
|
title: "Экономика",
|
||||||
badge: "предв.",
|
badge: "ориент.",
|
||||||
rows: [
|
rows: [
|
||||||
{ key: "Потенц. выручки", field: placeholder("после финмодели") },
|
{
|
||||||
{ key: "Маржа", field: placeholder("после финмодели") },
|
key: "Потенц. выручки",
|
||||||
{ key: "ROI", field: placeholder("после финмодели") },
|
field: {
|
||||||
{ key: "IRR", field: placeholder("после финмодели") },
|
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,
|
summary,
|
||||||
rows: [
|
rows: [
|
||||||
...financingRows,
|
...financingRows,
|
||||||
{ k: "GDV (выручка)", field: real(`${formatBln(financial.revenue_rub)} млрд ₽`) },
|
{
|
||||||
|
k: "GDV (выручка)",
|
||||||
|
field: real(`${formatBln(financial.revenue_rub)} млрд ₽`),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
k: "Совокупные затраты",
|
k: "Совокупные затраты",
|
||||||
field: real(`${formatBln(financial.cost_rub)} млрд ₽`),
|
field: real(`${formatBln(financial.cost_rub)} млрд ₽`),
|
||||||
|
|
@ -1788,7 +1823,9 @@ export function adaptFinanceDrawer(
|
||||||
{ k: "Срок окупаемости", field: real(paybackLabel) },
|
{ k: "Срок окупаемости", field: real(paybackLabel) },
|
||||||
{
|
{
|
||||||
k: "Цена ₽/м²",
|
k: "Цена ₽/м²",
|
||||||
field: real(`${formatRubPerM2(financial.price_per_sqm_used)} · ${priceLabel}`),
|
field: real(
|
||||||
|
`${formatRubPerM2(financial.price_per_sqm_used)} · ${priceLabel}`,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
k: "Класс жилья",
|
k: "Класс жилья",
|
||||||
|
|
@ -1804,6 +1841,19 @@ export function adaptFinanceDrawer(
|
||||||
financial.development_type_inferred,
|
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: "ТЭП · жилая площадь",
|
k: "ТЭП · жилая площадь",
|
||||||
field: real(`${formatInt(teap.residential_area_sqm)} м²`),
|
field: real(`${formatInt(teap.residential_area_sqm)} м²`),
|
||||||
|
|
@ -2076,9 +2126,7 @@ export function adaptInvestmentClearance(
|
||||||
const priceCaption = financial.price_is_calibrated
|
const priceCaption = financial.price_is_calibrated
|
||||||
? "цена: рынок (Objective)"
|
? "цена: рынок (Objective)"
|
||||||
: "цена: норматив класса";
|
: "цена: норматив класса";
|
||||||
const irrCaption = financial.irr_is_proxy
|
const irrCaption = financial.irr_is_proxy ? "оценочный (proxy)" : "DCF";
|
||||||
? "оценочный (proxy)"
|
|
||||||
: "DCF";
|
|
||||||
const paybackValue =
|
const paybackValue =
|
||||||
financial.payback_months != null
|
financial.payback_months != null
|
||||||
? `${(financial.payback_months / 12).toLocaleString("ru-RU", {
|
? `${(financial.payback_months / 12).toLocaleString("ru-RU", {
|
||||||
|
|
@ -2103,11 +2151,24 @@ export function adaptInvestmentClearance(
|
||||||
isReal: true,
|
isReal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Profit · прибыль",
|
label: financial.financing_enabled
|
||||||
|
? "Profit · до финанс."
|
||||||
|
: "Profit · прибыль",
|
||||||
value: formatBln(financial.net_profit_rub),
|
value: formatBln(financial.net_profit_rub),
|
||||||
unit: "млрд ₽",
|
unit: "млрд ₽",
|
||||||
isReal: true,
|
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",
|
label: "ROI",
|
||||||
value: `${formatPctFromFraction(financial.roi)} %`,
|
value: `${formatPctFromFraction(financial.roi)} %`,
|
||||||
|
|
@ -2298,8 +2359,13 @@ export function adaptSiteVerdict(
|
||||||
let tone: PticaSiteVerdict["tone"] = "warn";
|
let tone: PticaSiteVerdict["tone"] = "warn";
|
||||||
if (gate) {
|
if (gate) {
|
||||||
if (gate.can_build_mkd === true) {
|
if (gate.can_build_mkd === true) {
|
||||||
word = "Приобретать";
|
if (gate.verdict_label === "С ограничениями") {
|
||||||
tone = "good";
|
word = "С ограничениями";
|
||||||
|
tone = "warn";
|
||||||
|
} else {
|
||||||
|
word = "Приобретать";
|
||||||
|
tone = "good";
|
||||||
|
}
|
||||||
} else if (gate.can_build_mkd === false) {
|
} else if (gate.can_build_mkd === false) {
|
||||||
word = "Нельзя";
|
word = "Нельзя";
|
||||||
tone = "bad";
|
tone = "bad";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue