feat(cockpit): поверхностить financial_estimate в Development Scan / Investment Clearance (#1881 rank-5) (#1894)
feat(cockpit): поверхностить financial_estimate в Development Scan / Investment Clearance (#1881 rank-5)
This commit is contained in:
parent
c13ab32aa2
commit
5a73f1d0b6
3 changed files with 169 additions and 18 deletions
|
|
@ -61,7 +61,7 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
|
|||
onOpen={onOpenDrawer}
|
||||
/>
|
||||
<ScanCard
|
||||
card={adaptEconomyCard()}
|
||||
card={adaptEconomyCard(analysis.financial_estimate)}
|
||||
drawerKey="economy"
|
||||
onOpen={onOpenDrawer}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue