GET /api/v1/property-objects/{propertyObjectId}/investor-series — Серии доходности объекта недвижимости
Назначение
Серверный расчёт помесячных серий доходности объекта недвижимости: InvestorMonth[] + AnalyticsSeries
(графики gross-profit/ebitda/price-per-meter) + AsideMetrics для каждого месяца. Питает страницу
/lots/[id]/analytics (график, калькулятор, правая панель метрик, мобильный calc-block) для инвестора,
агента и менеджера, а для реф-гостя — в урезанном readOnly-режиме. Формулы, входы и инварианты расчёта —
дом ../../domain/investor-series-models.md.
| Поле | Значение |
|---|
| Сервис/контекст | invest-analytics |
| API area | public |
| Feature group | investor-series |
| Статус | draft |
| Документ | docs/06-services/invest-analytics/api/public/get-property-object-investor-series.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Bearer JWT) или Anonymous реф-гость (валидная реферальная cookie referralUrl — гейт каталога); реф-гость → readOnly-режим (см. модель ответа) |
| Auth policy / Permissions | policy CatalogAccess; permission invest-analytics.series.view (каталог — docs/06-services/identity/domain/permissions.md) |
| Scope (RBAC) | платформа (расчёт не привилегирован; выдача одинакова для инвестора/агента/менеджера) |
| Tenant isolation | не применяется — вход платформенный (экономическая модель не tenanted, ADR-0052 п.3); request не tenanted |
| Audit | none (public read; экономическая модель наружу не раскрывается — уходят только вычисленные серии) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/property-objects/{propertyObjectId}/investor-series |
| Success status | 200 |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
PropertyObjectId | route | Guid | Да | != Guid.Empty; существование — при загрузке (chessboard) | — |
Entity | query entity | EntityKind | Нет | wire legal / individual; default legal; неизвестное → INVEST_ANALYTICS_INVALID_TAX_PARAMS (../../domain/tax-entity-params.md) | — |
Tax | query tax | TaxRegime | Нет | wire usn-6 / usn-15 / osno; default usn-6; при Entity = Individual принимается и игнорируется в ставке (не ошибка) | — |
IsReadOnly | identity (вычисляется) | bool | Да | true, если actor — реф-гость без JWT | при true параметры entity/tax принудительно = дефолты (передача не ошибка) |
Модель ответа InvestorSeriesResponse
Wire-проекция контейнера серий (../../domain/investor-series-models.md)
- additive-поля
readOnly, isSyntheticHistory. JSON — camelCase; ключи серий — дословно gross-profit,
ebitda, price-per-meter.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
months | IReadOnlyList<InvestorMonthModel> | Да | InvestorSeriesResult.Months | readOnly-режим: пустой список (детальная помесячная таблица гостю не отдаётся) | HorizonMonths + 1 строк, monthIndex 0..N (INV-CALC-4). |
series | AnalyticsSeriesModel | Да | InvestorSeriesResult.Series | — (графики доступны и гостю) | Три серии графиков. |
metricsByMonth | IReadOnlyList<AsideMetricsModel> | Да | InvestorSeriesResult.MetricsByMonth | readOnly-режим: поля profitTax, taxRatePercent, creditPayment → null | Сводка на каждый месяц (фронт листает слайдером без повторных запросов). |
readOnly | bool | Да | IsReadOnly | — | Признак урезанного режима реф-гостя (additive-поле; фронт блокирует переключатели entity/tax). |
isSyntheticHistory | bool | Да | шаг загрузки истории цены | — | true — историческая часть price-per-meter синтетическая (< 2 фактов ClickHouse). |
InvestorMonthModel (wire-проекция InvestorMonth, 18 полей)
Формулы каждого поля — дом ../../domain/investor-series-models.md
(таблица InvestorMonth); здесь только wire-имена и типы:
| Wire-поле | Тип | Источник (InvestorMonth.*) |
|---|
time | string (yyyy-MM-01) | Time |
year | int | Year |
month | int (1–12) | Month |
monthIndex | int (0-based) | MonthIndex |
marketPriceM2 | decimal | MarketPriceM2 |
objectPrice | decimal | ObjectPrice |
incomeFromGrowth | decimal | IncomeFromGrowth |
rentMonth | decimal | RentMonth |
expenses | decimal | Expenses |
ebitda | decimal | Ebitda |
cumulativeEbitda | decimal | CumulativeEbitda (дубль Ebitda; не удалять до мажорной версии) |
cumulativeRevenue | decimal | CumulativeRevenue |
cumulativeExpenses | decimal | CumulativeExpenses |
appreciationTax | decimal | AppreciationTax |
ebitdaTax | decimal | EbitdaTax |
grossProfit | decimal | GrossProfit |
roiGross | decimal (доля) | RoiGross |
occupancy | decimal (доля) | Occupancy |
AnalyticsSeriesModel
| Wire-поле | Тип | Источник | Описание |
|---|
gross-profit | IReadOnlyList<SeriesPointModel> | AnalyticsSeries.GrossProfit | Валовая прибыль на м², все месяцы. |
ebitda | IReadOnlyList<SeriesPointModel> | AnalyticsSeries.Ebitda | EBITDA на м², только месяцы с Ebitda > 0 (INV-CALC-3). |
price-per-meter | IReadOnlyList<SeriesPointModel> | AnalyticsSeries.PricePerMeter | История (ClickHouse или синтетика) + прогноз marketM2. |
SeriesPointModel: time (string, ISO date) + value (decimal).
AsideMetricsModel (wire-проекция AsideMetrics, 21 поле)
Формулы — дом ../../domain/investor-series-models.md (таблица
AsideMetrics). Wire-имена: forecastDate (string), forecastPrice, forecastPricePerMeter,
grossProfit, roi, roiPerYear, growthIncome, roiGrowth, revenueGrowth, profitTax,
taxRatePercent, ebitda, roiEbitda, rentAverage, occupancy, totalRevenue, expenses,
creditPayment, growth12mPercent, growthTotalPercent (все decimal), monthsElapsed (int).
Поля profitTax, taxRatePercent, creditPayment в wire-модели — decimal?: null только в
readOnly-режиме (налоговая детализация и ипотечный платёж гостю не показываются); для
аутентифицированного пользователя всегда заполнены.
Алгоритм
- Контекст и доступ. Actor через policy
CatalogAccess: Bearer JWT → User, IsReadOnly = false;
валидная реф-cookie без JWT → Anonymous, IsReadOnly = true; ни того ни другого → 401 UNAUTHORIZED.
- Проверка входа.
PropertyObjectId != Guid.Empty; entity ∈ {legal,individual}, tax ∈
{usn-6,usn-15,osno} — неизвестное значение → INVEST_ANALYTICS_INVALID_TAX_PARAMS (400,
молчаливый откат к дефолту запрещён — ../../domain/tax-entity-params.md);
отсутствующие параметры → дефолты legal/usn-6; при IsReadOnly = true параметры принудительно
замещаются дефолтами. Существование объекта недвижимости проверяется при загрузке (единственный владелец
параметров объекта — chessboard). Допустимость состояния не проверяется — расчёт возможен для объекта
в любом статусе продажи.
- Что читаем (строго в этом порядке):
- Объект недвижимости — из сервиса
chessboard (запрос параметров аналитики объекта, timeout 2 с,
1 retry) → BasePrice (эффективная цена с каталожными оверрайдами), AreaTotal, PricePerMeter,
GroupId (LayoutGroup), DeliveryDate (срок сдачи корпуса). 404 →
INVEST_ANALYTICS_PROPERTY_OBJECT_NOT_FOUND; сеть/5xx/timeout после retry →
INVEST_ANALYTICS_PROPERTY_OBJECT_SOURCE_UNAVAILABLE (503).
- Платформенная модель — по well-known-id; при отсутствии строки (seed не применён) — дефолты
кода (таблица в ../../domain/economic-model-config.md).
- Тип объекта недвижимости — по
GroupId (может быть не настроен).
- Помесячные оверрайды типа — полный список по
GroupId без пагинации (единицы–десятки строк).
- Ипотека — из
crm purchase-config (ставка/срок). Недоступность — деградация, не ошибка:
fallback-константы конфигурации сервиса 21 % / 240 мес.
- История цены м² — из ленты цен ClickHouse (месячные точки
argMax(NewPricePerMeter) по
PropertyObjectId от первого факта до AnchorTime, без FINAL). Если фактов < 2 или ClickHouse
недоступен — synthetic-fallback (feature flag invest-analytics.synthetic-history-fallback);
IsSyntheticHistory = true. Ошибка ClickHouse расчёт не валит.
- Бизнес-правила и расчёт (детерминированный, чистый, без I/O — INV-CALC-6):
- Каскад коэффициентов: для каждого из девяти полей
InvestorCoefficients независимо — первое
валидное из цепочки PropertyObjectTypeConfig.поле (not null) → EconomicModelConfig.поле → дефолт
кода.
- Даты:
AnchorTime = первый день текущего месяца сервера (yyyy-MM-01); DeliveryTime =
parseDeliveryMonth(propertyObject.DeliveryDate) (ISO / «N кв. YYYY» / месяц по-русски / только год /
null — правила дат в доме моделей).
- Налог режима:
regimeTaxRate по (Entity, Tax) — Individual → 0.13; Legal:
Usn6 → 0.06, Usn15 → 0.15, Osno → 0.43
(../../domain/tax-entity-params.md).
- Словарь оверрайдов:
overrideMap[(Year, Month)] → MonthOverrideValues из шага 3.4.
- Помесячный цикл
i = 0..HorizonMonths (включительно; HorizonMonths + 1 итераций), в каждой
итерации по порядку (формулы — таблица InvestorMonth дома моделей):
time = AnchorTime + i мес; ovr = overrideMap[(time.Year, time.Month)] (может отсутствовать);
- цепочка цены:
marketM2(0) = positiveOr(ovr.MarketPriceM2, PricePerMeter × (1 + MarketPremium));
marketM2(i>0) = positiveOr(ovr.MarketPriceM2, marketM2(i−1) × (1 + (ovr.PriceGrowthMonthly ?? PriceGrowthMonthly))) (INV-CALC-2: абсолюты только при > 0);
- цепочка аренды:
dailyRent(i) аналогично от DailyRentM2 / RentGrowthMonthly;
- рост и налог на прирост:
gainM2 = marketM2(i) − initialMarketM2; apprTaxRate = ovr.AppreciationTaxMonthly != null ? ovr.AppreciationTaxMonthly × 12 : regimeTaxRate
(INV-CALC-5); apprTaxM2, growthIncomeM2;
- параметры месяца:
occ = ovr.Occupancy ?? Occupancy; expShare = ovr.ExpenseShare ?? ExpenseShare; inOperation = DeliveryTime == null || time >= DeliveryTime (INV-CALC-3);
- аренда:
monthRevenueM2 = inOperation ? dailyRent(i) × daysInMonth × occ : 0;
cumRevenueM2 += monthRevenueM2;
- EBITDA и налог:
ebitdaM2 = inOperation ? positiveOr(ovr.Ebitda, cumRevenueM2 × (1 − expShare)) : 0; ebitdaTaxRate = ovr.EbitdaTaxMonthly != null ? × 12 : regimeTaxRate; ebitdaTaxM2,
netEbitdaM2;
- материализация
InvestorMonth[i] — 18 полей (round2, умножение per-м² на AreaTotal,
RoiGross с guard BasePrice == 0 → 0).
- Ипотека: аннуитет (
S = BasePrice, r = MortgageAnnualRate/100/12,
MonthlyPayment = S × r × (1+r)^Months / ((1+r)^Months − 1)) — одна величина для всех срезов.
- AsideMetrics для каждого
i — 21 поле по таблице дома (в т.ч. RoiPerYear = toPercent(Σ RoiGross[1..12]), Growth12mPercent — вперёд, у края серии назад, CreditPayment из п.4.6).
- AnalyticsSeries:
gross-profit (все месяцы, GrossProfit / Area), ebitda (только точки
> 0), price-per-meter = историческая часть (шаг 3.6) + прогнозные round2(marketM2(i)).
- Финальный guard INV-CALC-1: любое не-конечное число результата →
0.
- Результат. Wire-модель серии (camelCase, ключи серий
gross-profit/ebitda/price-per-meter);
при IsReadOnly = true: months → пустой список,
metricsByMonth[*].profitTax|taxRatePercent|creditPayment → null; readOnly, isSyntheticHistory —
по факту. Расчёт выполняется на каждый запрос без кэша и не персистится.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Внешние вызовы | chessboard (timeout 2 с, retry 1); crm purchase-config (timeout 2 с, fallback-константы); ClickHouse (fallback-синтетика) |
| Кэш / проекции / поиск | none — расчёт на каждый запрос без кэша (ADR-0056) |
Интеграционных и доменных событий метод не публикует.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
ValidationError | 400 | PropertyObjectId = Guid.Empty / прочая валидация | Ошибка валидации запроса | no retry |
INVEST_ANALYTICS_INVALID_TAX_PARAMS | 400 | Неизвестное значение entity/tax | Недопустимые параметры расчёта | no retry |
UNAUTHORIZED | 401 | Нет JWT и нет валидной реф-cookie | Требуется авторизация или реферальная ссылка | no retry |
INVEST_ANALYTICS_PROPERTY_OBJECT_NOT_FOUND | 404 | chessboard не знает объект недвижимости | Объект не найден | no retry |
INVEST_ANALYTICS_PROPERTY_OBJECT_SOURCE_UNAVAILABLE | 503 | chessboard недоступен (после retry) | Параметры объекта временно недоступны | retry с backoff |
Недоступность crm (ипотека) и ClickHouse (история) — деградация с fallback-ами, не ошибки ответа.
Совместимость и наблюдаемость
- Additive-поля (
readOnly, isSyntheticHistory, будущие поля AsideMetrics) — без breaking;
удаление/переименование — breaking (версия метода). Дубль cumulativeEbitda не удалять до мажорной
версии. Unknown enum на входе → 400.
- Logs:
investor-series-calculated (propertyObjectId, entity, tax, readOnly, horizon, isSyntheticHistory,
elapsed; PII нет). Metrics: invest_analytics_series_requests_total{entity,tax,readOnly}, latency
histogram (SLO p95 ≤ 300 мс), invest_analytics_series_fallback_total{kind=mortgage|history}.
Traces: span приёма → span-ы chessboard/PG/crm/CH → span расчёта; correlation через сквозной trace
traceparent. Dashboard — борд контекста; runbook — деградации fallback-ов.
Acceptance Criteria
- Given аутентифицированный пользователь (Bearer JWT), существующий объект,
entity = legal, tax = usn-6, When GET, Then 200 с полным months (HorizonMonths + 1 строк), тремя сериями и
metricsByMonth со всеми заполненными полями; readOnly = false.
- Given реф-гость (валидная реф-cookie, без JWT), When GET, Then
200, readOnly = true:
months = [], metricsByMonth[*].profitTax|taxRatePercent|creditPayment = null, параметры entity/tax
принудительно = дефолты; графики (series) гостю доступны.
- Given аутентифицированный пользователь и неизвестное
entity/tax, When GET, Then 400 INVEST_ANALYTICS_INVALID_TAX_PARAMS (молчаливый откат к дефолту запрещён).
- Given
entity = individual и tax = usn-15, When GET, Then tax игнорируется в ставке
(ставка 0.13), это не ошибка.
- Given
chessboard не знает объект недвижимости, When GET, Then 404 INVEST_ANALYTICS_PROPERTY_OBJECT_NOT_FOUND; chessboard недоступен после retry → 503 INVEST_ANALYTICS_PROPERTY_OBJECT_SOURCE_UNAVAILABLE.
- Given
crm purchase-config недоступен, When GET, Then 200 с ипотекой по fallback-константам
21 %/240 мес — не ошибка; инкрементируется …series_fallback_total{kind=mortgage}.
- Given по объекту < 2 фактов ClickHouse или ClickHouse недоступен, When GET, Then
200 с
isSyntheticHistory = true (историческая часть price-per-meter синтетическая) — не ошибка.
- Given ни JWT, ни валидной реф-cookie, When GET, Then
401 UNAUTHORIZED.
- Given одинаковые входы и тот же серверный месяц (
AnchorTime), When GET дважды, Then результат
детерминирован (INV-CALC-6); любое не-конечное число → 0 (INV-CALC-1).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы