Перейти к основному содержимому

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.

Metadata

ПолеЗначение
Сервис/контекстinvest-analytics
API areapublic
Feature groupinvestor-series
Статусdraft
Документdocs/06-services/invest-analytics/api/public/get-property-object-investor-series.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (Bearer JWT) или Anonymous реф-гость (валидная реферальная cookie referralUrl — гейт каталога); реф-гость → readOnly-режим (см. модель ответа)
Auth policy / Permissionspolicy CatalogAccess; permission invest-analytics.series.view (каталог — docs/06-services/identity/domain/permissions.md)
Scope (RBAC)платформа (расчёт не привилегирован; выдача одинакова для инвестора/агента/менеджера)
Tenant isolationне применяется — вход платформенный (экономическая модель не tenanted, ADR-0052 п.3); request не tenanted
Auditnone (public read; экономическая модель наружу не раскрывается — уходят только вычисленные серии)

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/v1/property-objects/{propertyObjectId}/investor-series
Success status200
Idempotency headerне применяется (safe GET)
Correlationсквозной trace через traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PropertyObjectIdrouteGuidДа!= Guid.Empty; существование — при загрузке (chessboard)
Entityquery entityEntityKindНетwire legal / individual; default legal; неизвестное → INVEST_ANALYTICS_INVALID_TAX_PARAMS (../../domain/tax-entity-params.md)
Taxquery taxTaxRegimeНетwire usn-6 / usn-15 / osno; default usn-6; при Entity = Individual принимается и игнорируется в ставке (не ошибка)
IsReadOnlyidentity (вычисляется)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Описание
monthsIReadOnlyList<InvestorMonthModel>ДаInvestorSeriesResult.MonthsreadOnly-режим: пустой список (детальная помесячная таблица гостю не отдаётся)HorizonMonths + 1 строк, monthIndex 0..N (INV-CALC-4).
seriesAnalyticsSeriesModelДаInvestorSeriesResult.Series— (графики доступны и гостю)Три серии графиков.
metricsByMonthIReadOnlyList<AsideMetricsModel>ДаInvestorSeriesResult.MetricsByMonthreadOnly-режим: поля profitTax, taxRatePercent, creditPaymentnullСводка на каждый месяц (фронт листает слайдером без повторных запросов).
readOnlyboolДаIsReadOnlyПризнак урезанного режима реф-гостя (additive-поле; фронт блокирует переключатели entity/tax).
isSyntheticHistoryboolДашаг загрузки истории ценыtrue — историческая часть price-per-meter синтетическая (< 2 фактов ClickHouse).

InvestorMonthModel (wire-проекция InvestorMonth, 18 полей)

Формулы каждого поля — дом ../../domain/investor-series-models.md (таблица InvestorMonth); здесь только wire-имена и типы:

Wire-полеТипИсточник (InvestorMonth.*)
timestring (yyyy-MM-01)Time
yearintYear
monthint (1–12)Month
monthIndexint (0-based)MonthIndex
marketPriceM2decimalMarketPriceM2
objectPricedecimalObjectPrice
incomeFromGrowthdecimalIncomeFromGrowth
rentMonthdecimalRentMonth
expensesdecimalExpenses
ebitdadecimalEbitda
cumulativeEbitdadecimalCumulativeEbitda (дубль Ebitda; не удалять до мажорной версии)
cumulativeRevenuedecimalCumulativeRevenue
cumulativeExpensesdecimalCumulativeExpenses
appreciationTaxdecimalAppreciationTax
ebitdaTaxdecimalEbitdaTax
grossProfitdecimalGrossProfit
roiGrossdecimal (доля)RoiGross
occupancydecimal (доля)Occupancy

AnalyticsSeriesModel

Wire-полеТипИсточникОписание
gross-profitIReadOnlyList<SeriesPointModel>AnalyticsSeries.GrossProfitВаловая прибыль на м², все месяцы.
ebitdaIReadOnlyList<SeriesPointModel>AnalyticsSeries.EbitdaEBITDA на м², только месяцы с Ebitda > 0 (INV-CALC-3).
price-per-meterIReadOnlyList<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-режиме (налоговая детализация и ипотечный платёж гостю не показываются); для аутентифицированного пользователя всегда заполнены.

Алгоритм

  1. Контекст и доступ. Actor через policy CatalogAccess: Bearer JWT → User, IsReadOnly = false; валидная реф-cookie без JWT → Anonymous, IsReadOnly = true; ни того ни другого → 401 UNAUTHORIZED.
  2. Проверка входа. 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). Допустимость состояния не проверяется — расчёт возможен для объекта в любом статусе продажи.
  3. Что читаем (строго в этом порядке):
    1. Объект недвижимости — из сервиса 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).
    2. Платформенная модель — по well-known-id; при отсутствии строки (seed не применён) — дефолты кода (таблица в ../../domain/economic-model-config.md).
    3. Тип объекта недвижимости — по GroupId (может быть не настроен).
    4. Помесячные оверрайды типа — полный список по GroupId без пагинации (единицы–десятки строк).
    5. Ипотека — из crm purchase-config (ставка/срок). Недоступность — деградация, не ошибка: fallback-константы конфигурации сервиса 21 % / 240 мес.
    6. История цены м² — из ленты цен ClickHouse (месячные точки argMax(NewPricePerMeter) по PropertyObjectId от первого факта до AnchorTime, без FINAL). Если фактов < 2 или ClickHouse недоступен — synthetic-fallback (feature flag invest-analytics.synthetic-history-fallback); IsSyntheticHistory = true. Ошибка ClickHouse расчёт не валит.
  4. Бизнес-правила и расчёт (детерминированный, чистый, без I/O — INV-CALC-6):
    1. Каскад коэффициентов: для каждого из девяти полей InvestorCoefficients независимо — первое валидное из цепочки PropertyObjectTypeConfig.поле (not null)EconomicModelConfig.поле → дефолт кода.
    2. Даты: AnchorTime = первый день текущего месяца сервера (yyyy-MM-01); DeliveryTime = parseDeliveryMonth(propertyObject.DeliveryDate) (ISO / «N кв. YYYY» / месяц по-русски / только год / null — правила дат в доме моделей).
    3. Налог режима: regimeTaxRate по (Entity, Tax) — Individual0.13; Legal: Usn60.06, Usn150.15, Osno0.43 (../../domain/tax-entity-params.md).
    4. Словарь оверрайдов: overrideMap[(Year, Month)] → MonthOverrideValues из шага 3.4.
    5. Помесячный цикл 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).
    6. Ипотека: аннуитет (S = BasePrice, r = MortgageAnnualRate/100/12, MonthlyPayment = S × r × (1+r)^Months / ((1+r)^Months − 1)) — одна величина для всех срезов.
    7. AsideMetrics для каждого i — 21 поле по таблице дома (в т.ч. RoiPerYear = toPercent(Σ RoiGross[1..12]), Growth12mPercent — вперёд, у края серии назад, CreditPayment из п.4.6).
    8. AnalyticsSeries: gross-profit (все месяцы, GrossProfit / Area), ebitda (только точки > 0), price-per-meter = историческая часть (шаг 3.6) + прогнозные round2(marketM2(i)).
    9. Финальный guard INV-CALC-1: любое не-конечное число результата → 0.
  5. Результат. Wire-модель серии (camelCase, ключи серий gross-profit/ebitda/price-per-meter); при IsReadOnly = true: months → пустой список, metricsByMonth[*].profitTax|taxRatePercent|creditPaymentnull; readOnly, isSyntheticHistory — по факту. Расчёт выполняется на каждый запрос без кэша и не персистится.

Диаграмма

Побочные эффекты

ТипДетали
Внешние вызовыchessboard (timeout 2 с, retry 1); crm purchase-config (timeout 2 с, fallback-константы); ClickHouse (fallback-синтетика)
Кэш / проекции / поискnone — расчёт на каждый запрос без кэша (ADR-0056)

Интеграционных и доменных событий метод не публикует.

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400PropertyObjectId = Guid.Empty / прочая валидацияОшибка валидации запросаno retry
INVEST_ANALYTICS_INVALID_TAX_PARAMS400Неизвестное значение entity/taxНедопустимые параметры расчётаno retry
UNAUTHORIZED401Нет JWT и нет валидной реф-cookieТребуется авторизация или реферальная ссылкаno retry
INVEST_ANALYTICS_PROPERTY_OBJECT_NOT_FOUND404chessboard не знает объект недвижимостиОбъект не найденno retry
INVEST_ANALYTICS_PROPERTY_OBJECT_SOURCE_UNAVAILABLE503chessboard недоступен (после 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).

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы