QA on real prod estimate 701795d8 surfaced two mapper defects:
- STREET_RE used JS \b which does NOT word-boundary Cyrillic → street token never
matched → object.address fell back to the bare house number ('14/3') and city
mis-parsed ('14/3, Россия'). Rewrote parseAddress to handle both OSM (house-first)
and DaData (city-first) orders; street keyword delimited by space, not \b. Now
'улица Яскина, 14/3' + city 'Екатеринбург'.
- days_on_market is null across prod lots → scatter-mini rendered empty. Active
analogs now fall back to 'days listed so far' = today-listing_date (deals still
require a real days_on_market — today-deal-date is not exposure). TODO BE-1.
Verified via tsx harness against the real estimate (25/25); next build green.
FE-1 foundation for #2036. app/v2/page.tsx now owns data: ParamsPanel form ->
useEstimateMutation, restore-by-id via window.location.search (mirrors v1, avoids
useSearchParams Suspense build break), sub-hooks useStreetDeals/useEstimateHouseAnalytics;
loading/empty/insufficient/error states (honest placeholders, no fixtures-as-real).
New components/trade-in/v2/mappers.ts: pure AggregatedEstimate(+street-deals/analytics)
-> fixture-shape transforms — 3 price tiers (asking/expected_sold/DKP), ranges, scatter,
7 source slots, summary. CV + per-source counts are FE-approx pending BE-1 (#2043).
ParamsPanel -> controlled form (onSubmit/isPending/error/initialValues). ResultPanel/
ObjectSummary/HeroBar/Footer accept a composite data prop defaulting to the existing
fixture (pixel-perfect markup unchanged). next build green (/v2 34 kB).
avito_detail_backfill (mode=browser) aborted prod run 458 with
attempted=5 enriched=0 blocked=5: the lat-null pending queue is dominated
by DEAD listings (404 — that's why they are stale coord-holes). In browser
mode a dead listing's 404 page ("Ошибка 404. Страница не найдена") has no
item-view, so _is_detail_soft_block returned True → AvitoBlockedError →
backfill counted it as `blocked` and the consecutive-block breaker aborted
the run before reaching live listings.
- New AvitoListingGoneError (subclass of common AvitoError, NOT of
AvitoBlockedError/AvitoRateLimitedError) so the backfill block-trap
does not catch it.
- New _is_detail_not_found() detector + browser-mode branch in fetch_detail,
checked BEFORE firewall/soft-block (keyed on the 404 title so it does not
swallow the generic soft-block deflect). Curl path unchanged (404 → 302/
non-200 → ValueError → failed, as before).
- Backfill: new `gone` counter; dedicated except AvitoListingGoneError branch
that is neutral to the consecutive-block breaker and marks the listing
is_active=FALSE (SAVEPOINT) so it leaves the snapshot scope.
Tests: unit for _is_detail_not_found (404 True; soft-block/item-view/empty
False), browser-mode fetch_detail raising AvitoListingGoneError (not Blocked),
and backfill gone-row marks inactive without aborting the breaker.
Прод-/trade-in/* обслуживает tradein-mvp/frontend (basePath=/trade-in,
standalone), а не главный frontend — v2 по ошибке попал в главный, где
прод его не отдаёт. Markup-only порт (fixtures, без API):
- компоненты v2 → tradein-mvp/frontend/src/components/trade-in/v2/
- роут src/app/v2/ (basePath даёт прод-URL /trade-in/v2)
- public/trade-in-v2/building.png
- HeroBar: <img> → next/image (плоский img не префиксит basePath → 404)
- удалён мёртвый v2 из главного frontend
next build (NEXT_PUBLIC_BASE_PATH=/trade-in) зелёный, роут /v2 prerendered.
Soft-block (HTTP 200 generic витрина без item-view) теперь детектится как блок → существующая 403/firewall reconnect-машинерия → ротация exit-IP вместо silent failed (blocked=0, enriched→0 каскад). + snapshot ORDER BY (lat IS NULL) DESC для приоритизации coord-holes (#1967, in-scope 321). Tests: test_avito_detail_soft_block.py. Full tradein suite 2591 passed.
deep-review #1964: v_objective_lots_latest has NO premise/district filter inside,
so a consumer's outer WHERE cannot push below DISTINCT ON → the view materializes
the WHOLE table (Parallel Seq Scan + external Sort 1.76M rows, ~55MB spill) on
every query. For REQUEST-PATH consumers inside analyze_parcel this is a ~19x latency
regression vs the pre-#1964 raw-table plan.
DISPROVEN remedy (NOT applied): a full index on the physflat-key does NOT help —
DISTINCT ON selects ol.* (51 cols, width≈945) so index-only-unique is impossible;
the planner ignores the index (seq-scan+sort still cheaper) and even forced it is
~3.9 s. A 142MB index for zero request-path benefit + slower bulk-INSERT during
objective-scrape is wrong. Honors original #1964 decision "no new index".
Prod EXPLAIN (Академический / 3km radius, 2026-06-28):
consumer via view inline (this commit)
concepts median 5854 ms 1640 ms (bitmap district + sort)
parcels district 5854 ms 1640 ms
parcels geo-median 6443 ms 122 ms (NestedLoop geo->complex bitmap)
parcels obj_pricing 5721 ms 441 ms (project bitmap per nearby ЖК)
FIX: keep v_objective_lots_latest ONLY for batch/background/cached consumers
(supply_layers L1, competitors._SOLD_COUNT_SQL, special_indices [/forecast bg task
30-180s], admin, landing). Revert the 4 request-path consumers inside analyze_parcel
to inline DISTINCT ON (physflat-key, latest snapshot) with the filter pushed INTO the
CTE so the district/spatial/project index applies:
- concepts._OBJECTIVE_MEDIAN_SQL
- parcels.py district price block
- parcels.py geo-radius median (complex_id-scoped)
- parcels.py obj_pricing CTE (project_name-scoped; aggregates over deduped set)
Migration 175 header CORRECTED: accurately states the partial mig-173 index does NOT
serve the view (qual can't push below DISTINCT ON), the full index is disproven/not
added, and which consumers use the view vs inline. No DDL change (still view-only).
Tests: +guards (concepts/obj_pricing dedup inline, not view; obj_pricing physflat
DISTINCT ON; perf-pushdown scope preserved). 965 passed.
Three #1953 audit follow-ups:
- perf: add migration 174 — composite index idx_kn_flats_obj_snap on
domrf_kn_flats (obj_id, snapshot_date DESC). Serves the #1956 flats_latest
CTE in best_layouts.py (DISTINCT ON (obj_id) ORDER BY obj_id, snapshot_date
DESC + self-join on (obj_id, snapshot_date)); previously only (obj_id)
existed so Postgres sorted per object. Prod: 789 569 rows, idx ~5.7 MB,
dry-run instant. Idempotent, self-wrapped BEGIN/COMMIT.
- frontend: route every max_height_m read through coerceFloat (same string-bug
class as max_far #1962). max_height_m is NUMERIC → arrives as a string on the
wire; ptica-adapt.ts read it raw at 4 sites and relied on formatInt/Math.round
coercion. Widen the type in nspd.ts and fix the stale "real number" comment in
nspd-regulation.ts.
- frontend: hide the «Дата регистрации» EGRN row entirely when
registration_date is null (~97% of parcels) instead of rendering a bare «—».
Финальная часть эпика #1953: пользователь выбирает типовые дома
(тип × этажность × число секций) вместо авто max-FAR раскладки, формируя
building_program из Stage 3a.
Бэкенд:
- GET /api/v1/concepts/house-types — read-only каталог HOUSE_TYPES
(section_type, label_ru, footprint w×d + sqm, default_floors, housing_class)
как single source of truth; фронт ничего не хардкодит.
- Схема HouseTypeCatalog / HouseTypeCatalogItem в schemas/concept.py.
- Тесты эндпоинта: полнота каталога + совпадение ключей с available_section_types.
Кодген: api-types.ts перегенерён (dump OpenAPI → openapi-typescript →
project-local prettier 3.9.0); 2-й прогон без диффа.
Фронтенд:
- useHouseTypes() (TanStack useQuery, staleTime Infinity) в concept-api.ts;
building_program в ConceptInput, placed_count/requested_count в ConceptVariant.
- HouseProgramPicker: toggle «Авто (max-FAR)» (default, program omit → greedy)
vs «Выбрать дома» (список каталожных типов, count 1-50 / floors 1-40, дефолт
из каталога; габариты/этажность/класс как подсказка). Смонтирован в
Section7Concept и на странице /concept.
- Partial-fit заметка в ConceptVariantsResult: при placed<requested честное
«Разместилось N из M секций — участок вмещает меньше» (нейтрально, не ошибка).
The old test_no_program_reproduces_greedy_output_unchanged only compared
building_program=None (default) vs explicit None — both through the new
_Placer code — so it proved the two None branches agree but did NOT pin
the greedy geometry; it would still pass if the _Placer extraction had
drifted the output. test_placement.py only checks invariants, never
concrete counts/TEAP, so there was no anti-regression guard that the
greedy path is byte-identical after the refactor.
Replace it with two tests:
- test_greedy_output_matches_golden_pin: hard-coded literals per strategy
on the fixed _BIG_PARCEL — (features, built_area_sqm, total_floor_area_sqm,
apartments_count) — frozen from the current (== pre-refactor) output, so
any future deterministic drift in greedy placement FAILS.
- test_explicit_none_program_equals_default_greedy: keeps the None-branch
equivalence check (default vs explicit None go one greedy path).
Extend the concept generator so ConceptInput can carry an optional
building_program (list of typed houses from a catalog). When present,
placement lays out EXACTLY that program — for each item, place `count`
sections of the catalog footprint at the item's floors — instead of the
greedy max-FAR coverage-cap sweep. When absent, the existing greedy
behavior is unchanged (byte-for-byte backward-compatible).
- catalog.py: hardcoded HOUSE_TYPES (panel_econom, monolith_comfort,
tower_business, lowrise_comfort, townhouse) — sane-default catalog,
promote to DB later; get_house_type / available_section_types lookups.
- schema: additive BuildingProgramItem {section_type, floors, count} and
ConceptInput.building_program (default None -> greedy). ConceptVariant
gains optional placed_count / requested_count (partial-fit signal).
- placement: shared _Placer (collision/STRtree/setback machine extracted
from greedy sweep, reused — no duplication); place_program +
place_program_variant; branch in place_all_strategies on
building_program. Mixed-floor TEAP via exact per-floor-group aggregation
(GFA = sum(area_i * floors_i), no rounding drift).
- partial fit: when the parcel can't fit all sections, place as many as
fit and report placed_count < requested_count (no hard-422); zero-fit
still raises ParcelGeometryError (-> 422).
- API: validate program section_type keys against the catalog (unknown ->
422) before placement.
- tests: catalog integrity, greedy backward-compat, exact 2-item program +
TEAP reflection, over-packed partial placement, API program path.
- regenerate frontend api-types.ts (OpenAPI codegen gate stays green).
The new POST /api/v1/concepts/recompute endpoint + MassingProgram /
MassingRecomputeOutput schemas changed the backend OpenAPI; the generated
frontend/src/lib/api-types.ts was out of sync, failing the CI
openapi-codegen-check gate. Regenerated via the exact CI sequence
(openapi-typescript + prettier 3.9.0). Byte-stable on 2nd regen; tsc --noEmit clean.
Code-review follow-up: /recompute hardcoded price_source="objective_district_median"
for any body-supplied market_price_per_sqm, mislabeling the honesty-flag once
Stage 2b forwards a price whose genuine source differs (objective_geo_radius /
district_reference / class_norm from financial_estimate).
- schemas/concept.py: add optional price_source: str | None to MassingProgram.
- api/v1/concepts.py: on FAST path use payload.price_source if provided, else the
default label (now a module-level constant _DEFAULT_PRERESOLVED_SOURCE). DB-fallback
and class-norm paths keep their own resolved source unchanged.
- tests: assert body-provided price_source echoes through to financial.price_source
(not overwritten), and the default label applies when the front omits it.
Stage 2a of epic #1953: backend service + endpoint for live economic recompute
driven by the interactive 3D massing (Stage 2b debounced sliders).
- teap.py: add pure synthesize_teap_from_program(total_footprint_sqm, floors,
site_area_sqm, housing_class, sections) — builds a TEAP from the SCALAR
aggregate footprint × floors, mirroring synthesize_teap_from_buildability and
reusing the same shared norm constants (_OFFICE_SHARE_OF_GFA / _EFFICIENCY_BY_CLASS
/ _AVG_APARTMENT_SQM / _PARKING_PER_APARTMENT) — single source of truth.
- schemas/concept.py: add MassingProgram (program contract, optional pre-resolved
market_price_per_sqm + parcel_centroid_wkt) and MassingRecomputeOutput (teap + financial).
- api/v1/concepts.py: add POST /api/v1/concepts/recompute — synthesize TEAP → run
the existing pure compute_financial. FAST path uses body market_price_per_sqm
(no DB); else _lookup_market_price by centroid via run_in_threadpool; else class norm.
- tests: synthesize_teap_from_program (gfa math, parity with compute_teap, class
efficiency, sections no-op) + endpoint (200, coherent output, price passthrough
skips DB, DB fallback, class-norm default, floors validation).
Fold the generative concept generator (3 strategy variants: placement map +
ТЭП + finance) into the light analysis report (/site-finder/analysis/{cad}) as
a new section after «6. Прогноз», so Концепция lives in one place alongside the
report instead of a separate flow (epic #1953, #1965 Stage 1).
Frontend-only: reuses ConceptParamsForm / ConceptVariantsResult and the
/concepts mutation (useCreateConcept) as-is — no duplication, no backend change.
Inputs are seeded from the analysis the report already has (no re-entry of the
cadastre/polygon): polygon ← extractPolygon(geom_geojson); housing_class /
development_type ← financial_estimate inferred values; land_cost ←
egrn.cadastral_value_rub; falls back to comfort/mid_rise.
Runs on demand (heavy generative call — never auto on mount); result block
(Leaflet map) is lazy-mounted via dynamic({ ssr: false }) and the section is
collapsed by default. When financial_estimate is null (ЗОУИТ/СЗЗ/нет зоны) it
shows an honest banner but still allows a manual run, since /concepts is
independent of the financial_estimate gate. Graceful empty state when geometry
is missing/unsupported. Stage 2 (interactive 3D + /concepts/recompute) left as
a seam, not built.
_SUPPLY_BATCH_SQL джойнил domrf_kn_flats по ОДНОЙ глобальной дате
(f.snapshot_date = MAX(snapshot_date) по всей таблице). Но domrf_kn_flats —
ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в свой день. На единственной
глобал-max дате присутствует обычно 1 объект → у остальных 0 квартир →
supply_units_in_radius=0 для всех строк 4.2 Планировки → frontend показывал
«Срок продажи 0 мес» и «% продано —». Регрессия от #1944 (objects-first
дедуп snapshot'ов объектов, который сам по себе корректен).
Фикс: flats_latest CTE (DISTINCT ON (obj_id) ... ORDER BY obj_id,
snapshot_date DESC, id DESC) берёт для КАЖДОГО obj_id его собственный
последний снимок и джойнится к nearby. objects-first MATERIALIZED дедуп
(#1944) сохранён → fan-out по снимкам не возвращается. Глобальный
db.scalar(MAX(snapshot_date)) + :latest_snap bind удалены.
Прод (66:41:0205010:287, r=1км, 9 объектов): supply 0 (global-max) → 2675
(per-object, 4 объекта имеют flats на разных датах 2026-05-17/05-05; ни один
не на глобал-max 2026-06-22). Данные flats частично сломаны (#1945, отдельно),
но фикс корректно двигает supply с 0 к реальным per-object числам.
Тесты: новый guard test_supply_joins_flats_per_object_latest_snapshot;
обновлены mock-фабрики (db.scalar больше не вызывается).
Корень «−1.00 везде» (эпик #1953): compute_demand_supply_forecast брал
district-wide unit_velocity (847.5/мес, ВСЕ классы/комнаты) как спрос и
весь district-сток (~63k доступных) как предложение для КАЖДОЙ ячейки
what_to_build → один и тот же ratio во всех ячейках → все deficit_index
прижаты к −1.0. Плюс objective_lots — append-per-snapshot (~2.9× инфляция
строк), что симметрично раздувало обе базы → даже сегментация без дедупа
осталась бы вырожденной.
Фикс (blast radius — ТОЛЬКО forecast/deficit calc; platform-wide dedup = #1964):
- market_metrics.compute_market_metrics: +obj_class/+room_bucket (+cache key).
_STOCK_SQL и _SALES_WINDOW_SQL дедуплят до ПОСЛЕДНЕГО снапшота на физлот
(DISTINCT ON project_name,corpus_name,section,floor,lot_number ORDER BY …
snapshot_date DESC,id DESC), затем агрегируют. Class-фильтр (LOWER=LOWER,
class lowercase) + room-bucket (Source-B room_area-вокабуляр, зеркало
sales_series.room_area_bucket_of → what_to_build фильтрует без перевода).
ROLLUP/GROUPING сохранён; confidence считается на дедуплицированных counts.
- demand_supply_forecast: base_pace и open-сток теперь ПОСЕГМЕНТНЫЕ
(market_metrics(obj_class,room_bucket)). При заданном сегменте L2/L3
(hidden/future) ИСКЛЮЧЕНЫ из баланса — они класс/формат-агностичны, иначе
двоились бы по всем ячейкам. +_market_room_bucket VOCAB-мост (валидирующий
pass-through Source-B меток; неизвестное → None = без фильтра, не тихий 0-rows).
- what_to_build/_DEFAULT_CLASSES и recommendation Economy-маппинг: «эконом»→
«стандарт» (в objective_lots эконома НЕТ, стандарт=483k → раньше ячейка
матчила 0 строк и молча выпадала).
- report_assembler honesty-guard: если ВСЯ сетка прижата к ±1.0
(degenerate-fallback) — не эмитим «строить»/«избегать», показываем
«недостаточно гранулярных данных для посегментного вывода».
- data/sql/173_objective_lots_physflat_idx.sql: partial index под DISTINCT ON
(Index Only Scan + Unique, без Sort на 1.75M строк; idempotent, BEGIN/COMMIT).
Prod-verify (parcel 66:41:0205010:287, Железнодорожный, h=24): ячейки
ДИФФЕРЕНЦИРУЮТ (12 measured, 7 distinct) вместо all −1.0; MOI комфорт/студия
38.5 vs стандарт/студия 244.3 (точное совпадение с ожидаемым).
Тесты: регрессия «ячейки различаются (не all −1.0)» + vocab-translation +
honesty-guard + посегментное предложение. ruff clean; no :name::type.
house_type (normalized) + house_catalog_url→house_url persisted in save_detail_enrichment; COALESCE existing-first for house_type, new-first for house_url. No migration (columns exist). Refs #2009