EPIC: exhaustive line-by-line аудит backend ПТИЦЫ (2026-07-07) — 89 подтверждённых находок #2464

Open
opened 2026-07-07 11:46:44 +00:00 by bot-backend · 16 comments
Collaborator

Обновление 2026-08-19. Кластер C закрыт целиком (3 пункта): admin_scrape.py:194 и report_maps.py:149 — PR #2927 (executor не глушится shutdown(wait=True)), photos.py:110 — PR #2928 (соединение отпускается до внешнего фетча) + покрытие рискованной ветки PR #2930. По objective_backfill.py:469 (кластер B) в PR #2929 закрыта половина — гео-проход; core-pass оставлен открытым сознательно, обоснование замером в комментарии ниже. domrf_catalog.py:80 проверен и правки НЕ требует: ноль строк на проде, заблокирован WAF-баном — тоже в комментарии.

СВЕРЕНО С КОДОМ 2026-08-13 (origin/main @ 9e83eb4a; отметки обновлены 13.08 17:50). Из 89 находок закрыто 26, осталось 63; пунктов, оказавшихся неверными, не найдено (у 5 уточнена формулировка, вывод устоял). Первые 19 были закрыты семью правками, а отметки не проставлял никто — учёт отставал от кода на все 19; ещё три закрыты волной 13.08 (PR #2863, #2866 + проверка без правки по geo_radius_price). Отметки ниже расставлены по результатам сверки; разбор с файл:строка и рабочий список остатка — в комментарии под задачей.

Остаток по серьёзности: высокая 17, средняя 28, низкая 18.

Мульти-агентный исчерпывающий line-by-line ревью всего backend/app/** (263 файла, 90 639 строк, 45 чанков). Каждый файл прочитан целиком; каждая находка проверена 2 независимыми скептиками (adversarial verify).

89 подтверждённых (из 140 кандидатов, 51 отсеян). Сгруппировано по паттернам — фиксить волнами.

A. Session-poisoning (нет rollback/SAVEPOINT) (16)

  • [C] backend/app/services/site_finder/developer_attribution.py:153 — get_developer_attribution swallows OperationalError/ProgrammingError/DataError (and generic Exception) from db.execute() without calling db.rollback(), leaving the shared request-scoped Session's transaction in Postgres's aborted state.
  • [H] backend/app/services/forecasting/orchestrator.py:127 — _safe_call swallows any exception from a §9.x layer without db.rollback(), so a real DB-level error in one layer poisons the shared SQLAlchemy Session for every later layer that reuses the same db object.
  • [H] backend/app/services/forecasting/macro_series.py:401 — _query_mortgage_monthly loops over 5 mortgage fields on the same db Session and swallows exceptions per-field without db.rollback(), so one field's DB error cascades and empties out all later fields in the same call, contradicting its own 'graceful: сбой одного ряда не валит остальные' docstring.
  • [H] backend/app/services/forecasting/special_indices.py:1661_run() (the shared try/except wrapper around all six §25 index builders) swallows any exception from db.execute(...) without calling db.rollback(), even though all six builders share ONE SQLAlchemy Session for the whole report (per forecast_request_cache.py docstring: one Session per §22 report / Celery task).
  • [H] backend/app/services/forecasting/sales_series.py:571_query_source_a and _query_source_b catch any db.execute exception and return {} without calling db.rollback(), on a Session that is explicitly documented as shared/reused across many build_sales_series calls within one report (module docstring: 'db не в ключе (одна сессия на отчёт)').
  • [H] backend/app/services/job_settings.py:142 — get_all()/get_one() catch DB exceptions and return fallback data without calling db.rollback(), leaving the caller-supplied SQLAlchemy session in an aborted-transaction state.
  • [H] backend/app/services/site_finder/connection_capacity_lookup.py:355 — Gas/heat/gas-outlet query helpers catch DB exceptions with a bare except Exception and never call db.rollback() / use a SAVEPOINT, unlike the sibling _query_nearby_network_zones (which correctly wraps its query in db.begin_nested()). Once one of these statements fails, the SQLAlchemy session is left in a failed-transaction state, so every subsequent query on the same db in the same call poisons and fails too — and gets misattributed to the wrong cause.
  • [H] backend/app/services/site_finder/competitors.py:801 — The three sequential post-competitor DB lookups (avg_price, sold-count, objective price fallback) each wrap their db.execute in a bare except Exception with no rollback/SAVEPOINT, so a failure in an earlier one leaves the session's transaction aborted and silently poisons every later query on the same db, which then also fails and is misreported as 'query failed, continuing without X'.
  • [H] backend/app/services/site_finder/saturation.py:241 — compute_district_saturation() catches a bare Exception around db.execute() and returns None without rolling back, poisoning the shared request Session.
  • [H] backend/app/services/site_finder/supply_layers.py:576 — _safe_rows() catches Exception around db.execute() and returns [] without db.rollback(); compute_all_layers() calls it 3 times in a row (L1, L2, L3) on the same Session, so one failed layer silently zeroes out the other two as well.
  • [H] backend/app/services/site_finder/zone_regulation.py:468 — backfill_ekb_zone_regulations never rolls back the shared db session after a failed commit or other DB-level exception, unlike the sibling get_or_fetch_zone_regulation path which does write_db.rollback() in its except block.
  • [M] backend/app/services/cadastre/bulk_harvest.py:554 — backfill_parcel_geom's per-quarter except Exception (line 554) would itself swallow a NspdBulkWafError propagated from _grid_walk_category, contradicting its own adjacent comment (lines 556-557) that claims 'WAF 403 пробросится из client и прервёт прогон — это ожидаемо'. ← #2969
  • [M] backend/app/services/exporters/full_report_pdf.py:217_get_connection_capacity's except block also omits db.rollback() after a DB-backed lookup failure, which can silently force _generate_concept_result's market-price lookup into the class_norm fallback even when a fresh session would have succeeded. ← #2964
  • [M] backend/app/services/site_finder/pat_lookup.py:49 — Caught OperationalError/ProgrammingError is logged and swallowed without a db.rollback(), leaving the shared SQLAlchemy Session's transaction in an aborted state for any subsequent query on that same session within the request.
  • [M] backend/app/workers/tasks/scrape_objective.py:252 — _save_raw()'s INSERT (lines 89-146) is called outside of any except clause that catches general exceptions — the surrounding per-job try only catches ObjectiveAuthError/ObjectiveAPIError (line 370) — so a DB-level failure there propagates to the outer handler, where _finish_run's own DB call on the now-poisoned session is swallowed by a bare except Exception: pass (lines 403-409), silently leaving the run row stuck at status='running' forever. ← #2972
  • [L] backend/app/services/scrapers/nspd_denorm.py:327 — denorm_dump()'s docstring states the caller is responsible for commit/close, but the function itself unconditionally calls db.commit() at line 373 (confirmed by the test suite asserting db.commit.assert_called_once()), directly contradicting the documented contract. ← #2968

B. Silent caps (усечение как итог) (10)

  • [H] backend/app/api/v1/parcels.py:3687market_pulse.competitors_total is len(competitor_rows), but competitor_rows comes from a SQL query with LIMIT 20 (line 2251). The field name implies the total number of competitors within 3km, but it is really 'up to 20 nearest', and it is also used to compute coverage_pct = competitors_priced * 100 / competitors_total, with no truncation flag disclosing the cap.
  • [H] backend/app/services/cadastre/bulk_harvest.py:402 — _grid_walk_category's blanket except Exception (line 402-414) swallows NspdBulkWafError/NspdBulkRateLimitError instead of re-raising, contradicting harvest_quarter's documented 'Raises: NspdBulkWafError ... caller не retry' contract and inconsistent with the codebase's own corrected pattern in the sibling get_features_in_bbox_grid (nspd_bulk_client.py:519-529, explicitly labeled 'Issue #252-mirror') and in search_by_quarter (nspd_bulk_client.py:305, which explicitly re-raises NspdBulkWafError/RateLimitError/ServerError).
  • [H] backend/app/services/etl/objective_backfill.py:469 — Ambiguous-candidate resolution (core-pass and geo-pass) never excludes objective_complex_name values already taken in objective_complex_mapping, so a core that has one already-mapped candidate and one genuinely-available candidate is misclassified as ambiguous / can resolve to the wrong (doomed) candidate instead of the available one. ← ОТКЛОНЁН по замеру 20.08: гео-половина закрыта #2929, core-pass оставлен сознательно (имя+застройщик не различают «Старт»/«СТАРТ» — разные ЖК). Остаточная мелочь: _OBJECTIVE_PROJECTS_SQL без ORDER BY → candidates[0] у ambiguous недетерминирован, но наружу уходят только счётчики, а сам кандидат доживает лишь до метки в журнале отказов гео-прохода.
  • [H] backend/app/services/site_finder/eesk_reserve_loader.py:239 — load_ps_35_220 writes a numeric load-percentage string into power_supply_centers.load_index via COALESCE(load_index, CAST(:load_pct AS text)), but that column is a categorical enum ('open'|'limited'|'closed'|NULL per data/sql/180_connection_capacity.sql:35) populated elsewhere by rosseti_wfs_loader._map_load_index with exactly those three string values.
  • [M] backend/app/api/v1/parcels.py:965neighbors_summary.count_buildings_100m is len(neighbor_rows), where neighbor_rows comes from the neighbors CTE in _NEIGHBORS_SUMMARY_SQL which has LIMIT 30 (line 858) — same LIMIT-capped-count-presented-as-total pattern as the permits/competitors findings above, with no truncation flag.
  • [M] backend/app/services/exporters/full_report_html.py:1517 — _build_permits_nearby hard-slices items to the first 10 rows without checking/surfacing the upstream items_truncated flag or adding a "and N more" disclosure, unlike every other capped table in this same file.
  • [M] backend/app/services/scrapers/nspd_client.py:814 — search_by_quarter docstring says step 3 fetches 'каждого core layer' via get_features_in_bbox and prices the whole call at 6/11/22 requests (~3.6s/6.6s/13s), but 3 of the 5 core layers plus all zouit/risk layers actually go through get_features_in_bbox_grid at grid_n=7 (49 requests each per that function's own docstring at line ~544), making the true request count roughly 25-50x higher than documented ← #2968
  • [M] backend/app/services/site_finder/poi_score.py:141 — compute_poi_weighted_top7 (and similarly compute_poi_routing_decay at line 328) selects candidate POIs by pure nearest-distance SQL LIMIT before applying the category-weighted ranking, so a genuinely higher-weighted-but-farther POI can be excluded from the candidate window entirely. ← ОТКЛОНЁН по замеру 20.08 — 683 усечённых центра, 0 расхождений топ-7
  • [M] backend/app/services/site_finder/velocity.py:213 — The competitor query is capped with LIMIT 200 (line 213) and n_comps = len(comp_rows) (line 326) is then reported as competitors_count in every VelocityResult, with no truncation flag — unlike the analogous fix this same codebase already applied elsewhere (utility_infrastructure_loader.py's features_truncated, explicitly citing Epic #2445 A2's exact anti-pattern of a LIMIT-capped count masquerading as the true total).
  • [L] backend/app/services/etl/objective_backfill.py:697 — GeoReject docstring enumerates reason values as 'no_address' | 'no_geocode' | 'too_far' | 'ambiguous_multi' | 'call_limit', but the code also emits reason='partial_geocode' (line 918), which is undocumented and omitted from the enumerated set.

C. Неэффективный timeout-guard (3)

  • [H] backend/app/api/v1/admin_scrape.py:194 — queue_status's ThreadPoolExecutor with block still blocks on shutdown(wait=True) even after the code 'gives up' via result(timeout=...), so the documented ~600ms worst-case latency guarantee is false.
  • [H] backend/app/api/v1/photos.py:110 — DB session (checked out from the connection pool via Depends(get_db)) is held open for the entire duration of the synchronous upstream HTTP fetch to DOM.РФ.
  • [H] backend/app/services/exporters/report_maps.py:149_add_basemap's timeout protection is ineffective: with ThreadPoolExecutor(...) as pool: calls shutdown(wait=True) on exit even after .result(timeout=...) times out, so the function can still block indefinitely on a hung tile fetch.

D. Price без sanity-фильтра (3)

  • [H] backend/app/api/v1/parcels.py:2209obj_pricing CTE computes avg_price_per_m2_rub as a raw AVG(oll.price_per_m2_rub) with no sanity/outlier filter, unlike the sibling district_price_block (line 2902, BETWEEN 30000 AND 600000) and market_trend (line 2983, BETWEEN 30000 AND 500000) queries in the same file that guard against bad scraped prices.
  • [M] backend/app/api/v1/parcels.py:3383 — ПРОВЕРЕНО, правка не нужна (медиана устойчива: 5 комплексов из 310 с выбросами, сдвиг ≤456 ₽/м², <1%; см. комментарий) — geo_radius_price median query (percentile_cont(0.5) over objective_lots.price_per_m2_rub) has no price sanity filter, unlike the near-identical district_price_block query above it (line 2902) which bounds prices to 30000-600000.
  • [L] backend/app/services/site_finder/parcel_financial.py:152synthesize_teap_from_buildability never validates that max_building_pct is within a sane 0-100 range (or that max_far/max_floors are plausible) before using it to derive built area and GFA, so a bad upstream zoning value silently produces a physically impossible result presented as a normal financial figure. — PR #3000 (смержен 20.08; + второй дефект той же функции: пятно застройки выходило больше участка)

E. snapshot-инфляция счётчиков (3)

  • [H] backend/app/services/analytics_queries.py:1799 — _active_competitors_count()'s _q() helper does SELECT COUNT(*) FROM domrf_kn_objects WHERE region_cd=:rc AND site_status='Строящиеся' ... with no snapshot_date filter and no DISTINCT ON obj_id, so it counts every retained historical snapshot row per object, not distinct objects.
  • [H] backend/app/services/analytics_queries.py:509 — developer_portfolio() selects from domrf_kn_objects filtered only by dev_id, with no snapshot_date filter or DISTINCT ON obj_id, so it returns every retained historical snapshot of each project as a separate row.
  • [H] backend/app/services/scrapers/domrf_kn.py:537 — UPSERT_OBJECT_SQL's ON CONFLICT (obj_id, snapshot_date) DO UPDATE SET omits problem_flag, green_house, floor_min, floor_max, hobj_id, short_addr, dev_inn and region_cd, even though all eight are populated on INSERT (lines 498-516) and freshly computed every call by _norm_object.

F. ON CONFLICT пропускает колонки (1)

  • [M] backend/app/services/scrapers/domrf_kn.py:923 — UPSERT_PHOTO_SQL's ON CONFLICT (obj_id, obj_file_id) DO UPDATE SET updates ord_num/photo_url/photo_dttm/period_dt/size_bytes/photo_name/ready_desc/hidden but omits build_type, which is part of the INSERT column list (line 916) and populated every call from p.get('objBuildTypeShortDesc').

G. Прочие корректность/данные (31)

  • [H] backend/app/api/v1/parcels.py:2381 — The noise-source query (step 7) has no WHERE source_type IN (...) filter, so it pulls every row from osm_noise_sources_ekb within 2km — including 'water' and 'utility' rows that the very same file queries separately (with explicit source_type = 'water' / = 'utility' filters) for the hydrology and utilities blocks a few dozen lines below.
  • [H] backend/app/services/exporters/full_report_html.py:460 — _build_zoning's legacy-zoning fallback validates data on the zoning dict but then builds the table by reading from the still-empty nspd_zoning dict, silently discarding real fallback zoning data. ← #2959
  • [H] backend/app/services/scrapers/domrf_catalog.py:80 — Status keyword regex has no negation guard, so Russian negated forms ("нереализованных", "непроданных" — meaning UNSOLD/available) substring-match the sold keywords and get classified as SOLD. — PR #2979 (смержен 20.08; эффект сегодня нулевой — путь отключён, измерено)
  • [H] backend/app/services/scrapers/domrf_catalog_object.py:440 — The entire batch runs inside one long-lived, uncommitted outer transaction with no interim commits — a late failure (e.g. BrowserSession.aexit raising, or any unhandled exception after the loop) discards every already-succeeded per-object UPDATE. ← #2960
  • [H] backend/app/services/scrapers/nspd_client.py:610 — get_features_in_bbox_grid swallows every per-cell HTTP exception (return_exceptions=True + logger.warning + continue) and never raises, so a layer-wide WAF ban / outage produces an empty feature list indistinguishable from a genuine 'no zones here' result
  • [H] backend/app/services/scrapers/page_reservation_parser.py:129 — _detect_kind classifies the whole document by unanchored substring search for 'резервир' before 'изъят' anywhere in the full text, not by which act type the document actually is. — PR #2980 (смержен 20.08; парсер на прод строк не писал, измерено)
  • [H] backend/app/services/site_finder/best_layouts.py:195 — ЧАСТИЧНО: цена починена (PR #2868), площадь требует правки контракта → #2867. Механизм оказался не тот: не «все слагаемые NULL» (таких строк 0), а пустое окно продаж. avg_area_m2 in _INLINE_VELOCITY_SQL is COALESCE(...,0) when all contributing deals rows have NULL deals_total_avg_area_m2, and this 0 is then silently treated downstream as a genuine '<25 м²' apartment area instead of 'area unknown'.
  • [H] backend/app/services/site_finder/quarter_dump_lookup.py:767 — _get_risk_zones computes ST_Intersection with BOTH operands pre-cast to ::geography, reintroducing the exact PostGIS 3.4 'geography×geography ST_Intersection transform error' bug that the sibling _get_red_lines function in the SAME file explicitly documents and avoids (there, ST_Intersection runs in planar geometry and only the result is cast to ::geography, per the comment at lines 967-970). ← ОТКЛОНЁН по замеру 20.08 — не воспроизводится на PostGIS 3.4.3, слои пусты
  • [H] backend/app/services/site_finder/velocity.py:167 — class_filter references alias o. inside the latest_obj CTE, but that CTE's FROM clause (FROM domrf_kn_objects, line 189) has no alias o — the alias o is only introduced later by the outer query (FROM latest_obj o, line 206).
  • [M] backend/app/api/v1/admin_cadastre.py:83 — manual_list validation checks non-empty BEFORE stripping whitespace, so an all-whitespace quarters list silently bypasses the intended 400 error and creates a zero-target job. ← #2965
  • [M] backend/app/api/v1/admin_leads.py:152 — revenue_total and deals_total in the /stats KPI response are actually scoped to the months window (via window_leads CTE), not all-time totals, despite being named identically in style to leads_total which genuinely is all-time — a consumer trusting the '_total' suffix will display a partial-window figure as the grand total. ← #2963
  • [M] backend/app/api/v1/admin_scrape.py:1096 — cancel_geo_job always returns {"cancelled": true} even when the UPDATE matched zero rows (nonexistent job_id or job already in a terminal state), silently misreporting success.
  • [M] backend/app/services/cadastre/bulk_harvest.py:1464 — _save_territorial_zones falls back to a synthetic zone_id = md5(sorted properties) when NSPD's WMS feature carries no id/zone_id anywhere (properties or top-level feature id); two geometrically-distinct zones sharing identical properties collide on this hash and one polygon silently overwrites the other via ON CONFLICT (zone_id) DO UPDATE. ← ОТКЛОНЁН по замеру 20.08: синтетических zone_id на проде 0 из 1 строки, а таблицу cad_territorial_zones НЕ ЧИТАЕТ никто (ПЗЗ-зона в отчёте идёт из nspd_quarter_dumps.features_json через _get_zoning). Судьба самой таблицы вынесена в #2985 — чинить хеш до её решения бессмысленно.
  • [M] backend/app/services/exporters/full_report_docx.py:288 — ЗОУИТ-reconciliation sets zouit_count = len(overlaps) (count of overlap/border records), but the KV row is labeled "Кол-во типов ЗОУИТ" (count of distinct ZOUIT TYPES) — the correct value is len(zouit_types). ← #2961
  • [M] backend/app/services/scrapers/domrf_catalog_object.py:460 — No circuit breaker on repeated WafBlockedError: once the DOM.РФ WAF starts blocking the session (the exact scenario referenced in the anti-ban comment at lines 444-450, incident #2443), the loop keeps sending one live request per remaining obj_id to the already-banned session instead of aborting the batch. ← #2971
  • [M] backend/app/services/scrapers/ekb_geoportal_client.py:217_get_feature treats an HTTP-200 response whose JSON body lacks a usable "features" list identically to a genuine "no features found at this location" — with no logging — so a GeoServer WFS error/degenerate response (common failure mode: CQL/typeName errors returned as HTTP 200 with an OWS ExceptionReport-shaped JSON) is silently reported as "this parcel has no PZZ zone/ЗОУИТ/КРТ here" instead of "the query failed". ← ОТКЛОНЁН по замеру 20.08 — ошибки WFS не притворяются пустым результатом
  • [M] backend/app/services/scrapers/stealth.py:293 — download_binary has no retry/backoff on transient failures (429/5xx), unlike get_json which retries up to 5 times with exponential backoff under the same WAF. — PR #2999 (смержен 20.08; ретраи по образцу get_json + контроль на паузы)
  • [M] backend/app/services/site_finder/best_layouts.py:1113 — The objects_total_in_radius field means two different things across the two 'empty response' branches: line 1113 reports the pre-exclude/pre-filter group count, while line 1194 (a few dozen lines later, same function, same field name) reports the post-exclude/post-filter group count. ← ТРЕБУЕТ РЕШЕНИЯ — какая семантика поля верна, см. коммент 20.08 ← ОТКЛОНЁН по замеру 20.08: objects_total_in_radius — свойство РАДИУСА, а не пользовательского фильтра, и существующий тест это закрепляет (исключение конкурента оставляет счётчик = 1). Разбор в комментарии к эпику от 20.08.
  • [M] backend/app/services/site_finder/best_layouts.py:402 — _SUPPLY_ONLY_LOTS_SQL's area_bin CASE bucket for on_sale lots maps area_pd IS NULL into the same '<25' bucket as genuinely tiny (<25 m²) apartments, rather than excluding/flagging lots with missing area data. — PR #2983 (смержен 20.08; 11 557 квартир без площади сидели в корзине «<25 м²» при 7 013 настоящих)
  • [M] backend/app/services/site_finder/competitors.py:553 — _SOLD_COUNT_SQL's mapped CTE only sources from objective_complex_mapping, omitting the nearest_cx spatial/name gap-fill branch that both the velocity CTE (in _COMPETITORS_SQL) and _OBJECTIVE_PRICE_FALLBACK_SQL include — so flats_sold is never computed for competitors whose velocity/price come from the spatial gap-fill match, silently collapsing their stage_at_horizon to the neutral default. ← ЗАБЛОКИРОВАН #2962 — мост gap-fill сломан, копировать нельзя
  • [M] backend/app/services/site_finder/eias_heat_loader.py:519 — load_heat_reserves opens a single Session before iterating all 8 organizations, each doing multiple 60s-timeout HTTP round-trips to a slow/geo-blocked external host, and commits only once at the very end — so a single Postgres transaction (opened implicitly by the first per-row SAVEPOINT) stays open across several minutes of external network I/O for the whole batch. — PR #2973 (смержен)
  • [M] backend/app/workers/beat_schedule.py:551 — newbuilding-crossload-nightly cron string fires 3 hours earlier than intended because the comment double-converts UTC→MSK when Celery's global timezone is already Europe/Moscow.
  • [M] backend/app/services/weather_cache.py:164 — precipitation_total_mm (and the seasonal total_precip_mm equivalent) silently default to 0 when precipitation data is entirely missing, while every sibling metric (uv_index_max, avg_precip_per_day_mm, etc.) correctly defaults to None for the same missing-data condition — silent data dishonesty.
  • [M] backend/app/services/site_finder/weight_profiles.py:99 — _SELECT_DEFAULT has no ORDER BY and returns an arbitrary row via LIMIT 1, while create_profile/update_profile's unset-then-set sequence for is_default is not atomic across concurrent requests, so two profiles can end up with is_default=TRUE and get_default_profile can non-deterministically flip between them. — PR #2976 (смержен 20.08, индекс на проде фальсифицирован)
  • [M] backend/app/workers/tasks/scrape_kn.py:42 — Redis singleton lock key for a kn-API sweep is built by joining the developers list in caller-supplied order, so the same developer set submitted in a different order produces a different lock key and the lock silently fails to prevent concurrent duplicate sweeps. ← #2970
  • [L] backend/app/services/exporters/full_report_html.py:1127metrics.get("sell_through_pct") is rendered raw (via _fmt) instead of through _fmt_pct_raw, even though the underlying value is already on a 0-100 scale (sold/(sold+available)*100, see market_metrics.py:194-195) and the row label explicitly says '%'. ← НАХОДКА НЕВЕРНА, не «починено»: _fmt шкалу не меняет, значение округлено в market_metrics.py:136, единицу несёт подпись строки (сверка 19.08)
  • [L] backend/app/services/generative/placement.py:297 — When a program item overrides the catalog footprint dimensions (item.footprint_w_m/footprint_d_m both set), the "placed N of M" warning still logs the catalog's house.footprint_w_m/house.footprint_d_m instead of the actual fp_w/fp_d that were used for placement. — PR #3003 (смержен 21.08)
  • [L] backend/app/services/scrapers/rosstat_emiss.py:228 — _decode_csv tries cp1251 before plain utf-8 in its fallback chain; cp1251 almost never raises UnicodeDecodeError (it maps nearly all byte values), so it silently 'succeeds' on corrupted/truncated UTF-8 content instead of falling through to the intended utf-8/errors='replace' path. ← ОТКЛОНЁН по замеру 20.08. Порчи нет: 0 следов мохибейка («Ð», «Ñ», «â€», «Ã») в 2862 строках macro_indicator. Перестановка сделала бы ХУЖЕ: utf-8-sig уже ловит валидный utf-8 первым, а настоящие CP1251-файлы после неё ушли бы в utf-8/replace. Побочно проверено и подтверждено эмпирически: третий элемент цепочки utf-8 НЕДОСТИЖИМ (200k случайных байтовых строк — ни одного случая, где utf-8-sig падает, а utf-8 проходит), а cp1251 декодирует и обрезанный utf-8, и случайные байты, поэтому ветка errors="replace" со своим logger.warning не может сработать НИКОГДА — отлаживающий будет искать это предупреждение в логах впустую. Эвристику «похоже ли на мохибейк» не добавляю: калибровать её не на чем (0 случаев), а правило без образцов ловит ровно те случаи, которые придумали вместе с ним.
  • [L] backend/app/services/site_finder/gate_verdict.py:441 — cad_utility_label (and thus the ZOUIT_NETWORK_OBREMENENIE vs ZOUIT_CAD_BLOCKER label choice) is taken from the first overlap with a resolved network_kind, but the aggregated coverage/threshold decision mixes in generic keyword-matched overlaps with no confirmed network_kind — so a blocker can be labeled as a specific network encumbrance even though most of the blocking coverage came from an unrelated/unclassified keyword match. — PR #3001 (смержен 20.08; довод пункта опровергнут замером — смешиваются РАЗНЫЕ ВИДЫ СЕТЕЙ, а не сети с keyword; вывод верен)
  • [L] backend/app/services/site_finder/vodokanal_reserve_loader.py:488load_water_reserves_from_docx builds result including "period": period and logs that full dict (line 489), but the actual return statement (line 490) filters result.items() to isinstance(v, int) only, which always drops period (a str or None) — so the value actually returned (and thus what load_water_reserves/the Celery task sync_water_reserves surfaces) silently diverges from what was just logged. — PR #3003 (смержен 21.08; фильтр стоял ради аннотации dict[str, int], она тоже исправлена)
  • [L] backend/app/workers/tasks/cbr_macro_sync.py:116 — Task is declared with bind=True, max_retries=2 but never calls self.retry() and has no autoretry_for, so the retry configuration has zero effect — the task fails permanently on first error despite the parameter suggesting up to 2 retries. — PR #2977 (смержен 20.08; заодно ещё 10 тасок + AST-гейт)

H. Doc/comment drift (22)

  • [H] backend/app/api/v1/admin_scrape.py:212 — The Redis queue_depth probe (channel.client.llen("celery")) has no timeout at all, unlike the inspect() calls above it, so it can hang the request indefinitely if the broker connection stalls.
  • [H] backend/app/services/dadata_client.py:185_suggest_geocode crashes with AttributeError instead of returning None when DaData's data field is not a dict.
  • [H] backend/app/services/forecasting/report_assembler.py:205 — _domrf_coverage() fallback feeds the confidence engine's 'главный sparse-риск проекта domrf↔objective (~2.5%)' signal with an unrelated metric (analyze.market_data_coverage_pct = % of nearby competitors with a priced Objective listing), and this fallback is the only path ever exercised in production because the sole current producer of supply_layers (orchestrator.py _summarize_supply_layers, line ~154-157) never emits supply_layers.domrf_coverage.
  • [H] backend/app/services/scrapers/ekb_ppt_tep_parser.py:72_page_contains_table's docstring claims cross-reference/ToC false positives are detected and suppressed, but the implementation is a bare regex search with no such logic — so table-of-contents entries or narrative cross-references (e.g. "показатели приведены в таблице 12") are indistinguishable from the real table caption. — PR #2988 (смержен 20.08; докстрока приведена к коду + замер уточнил довод пункта: косвенные падежи регекс НЕ ловит, опасно только оглавление)
  • [H] backend/app/services/scrapers/nspd_client.py:842 — search_by_quarter's docstring claims the whole operation is atomic ('Partial-success НЕ возвращается... failure → exception'), but this only holds for parcels/buildings (legacy get_features_in_bbox path); grid-walked layers (territorial_zones, red_lines, engineering_structures, all zouit, all risks) never raise on failure per finding above
  • [M] backend/app/api/v1/admin_scrape.py:1121 — resume_geo_job has no status guard on its UPDATE (unlike cancel_geo_job) and unconditionally re-enqueues the worker task, contradicting its own docstring ('Re-enqueue paused/failed job') by allowing a currently-RUNNING job to be double-dispatched, and reports success even for a nonexistent job_id.
  • [M] backend/app/services/analytics_queries.py:1423 — Contradictory in-file documentation about the vocabulary of objective_corpus_room_month.district: _velocity_baseline()'s docstring (line 1423) claims it 'matches domrf_kn_objects.district_name' (admin vocab), while _elasticity_coef()'s docstring (lines 1959-1962) states the same column is MICRO-neighborhood vocab ('Втузгородок', 'ЖБИ', ...) and that passing an admin district name gives 0 rows (labeled bug #1211). recommend_mix() calls _velocity_baseline, _velocity_baseline_per_bucket, and _district_velocity_trend with district_row['district_name'] (admin vocab from ekb_districts, e.g. 'Кировский') and calls _elasticity_coef without the districts resolver param, taking exactly the legacy admin-vocab path that _elasticity_coef's own docstring calls out as 'отдельный bug class' for this file's callers. ← #2968
  • [M] backend/app/services/forecasting/macro_series.py:305 — get_monthly_macro's docstring claims the empty-list return only happens when months_back < 0, but the implementation clamps months_back with max(0, months_back) before computing the grid start, so the grid can never actually be empty for a negative months_back -- the documented behavior is unreachable/wrong. ← #2968
  • [M] backend/app/services/generative/exporters/pdf.py:182 — The exported concept PDF's methodology footnote hardcodes 'распродажа 30 мес' regardless of the actually-computed DCF sales window, so the disclosed assumption can be factually wrong for the very numbers on the same page.
  • [M] backend/app/services/site_finder/osrm_client_local.py:139 — Per-element float(d) conversion of OSRM distances happens outside the try/except block, contradicting the function's documented contract that ANY unexpected-format response raises OsrmLocalUnavailableError.
  • [M] backend/app/services/site_finder/ors_client.py:131 — Same pattern as osrm_client_local.py: float(sec) for each ORS matrix duration is computed outside the try/except, so a malformed element type raises a raw exception instead of the documented OrsUnavailableError.
  • [M] backend/app/workers/lifecycle.py:92 — Zombie-resume query for kn_scrape_runs only catches 'running' rows that already have objects_snapshot set, contradicting the function's own stated invariant that ANY running row at worker_ready is by definition a zombie. — PR #2975 (смержен 20.08, код проверен в контейнере)
  • [M] backend/app/workers/tasks/izyatie_ocr_ingest.py:101 — Docstring claims per-batch Python dedup + a two-step (cad_num, doc_url) upsert prevents duplicate land_reservation rows for act_number-less records, but the actual code implements neither — every weekly re-run reinserts brand-new duplicate rows. ← #2966
  • [L] backend/app/services/forecasting/confidence_engine.py:100 — Comment claims _HISTORY_MONTHS_LOW mirrors §9.6's _MIN_OBS=8, but the actual constant is 12
  • [L] backend/app/services/forecasting/macro_coefficient.py:99 — Stale comment claims the backed-weight sum is 0.45, but since #946 promoted inflation to a backed channel with weight 0.08 (line 110), the actual current backed-weight sum is 0.53. ← #2968
  • [L] backend/app/services/forecasting/sales_series.py:496 — Docstring/code contradiction: build_sales_series (and its docstring) claims the returned series is empty (months=[]) 'только если сетка пуста (months_back < 0)', but the code clamps negative months_back to 0 before computing the grid, so the grid is never empty for any input. ← #2968
  • [L] backend/app/services/forecasting/special_indices.py:589_timing_overlap's docstring states the formula is exp(−|Δмесяцев| / half_life) with 'расхождение в half_life мес → 0.5', but that formula does not equal 0.5 at Δ=half_life (it equals e^-1≈0.368); the actual, correct implementation uses 0.5 ** (Δ/half_life) (line 601), which does hit exactly 0.5 at Δ=half_life, matching the 'inline comment fix' but contradicting the docstring's stated formula.
  • [L] backend/app/services/objective_etl.py:466 — get_sqlite_info() has a TOCTOU race: it checks Path.exists() and then calls Path.stat() unguarded, outside the try/except that only covers the sqlite3.connect block. — PR #3003 (смержен 21.08)
  • [L] backend/app/services/scrapers/domrf_catalog.py:375 — Comment claims a BFS traversal of the NEXT_DATA JSON tree, but the implementation uses stack.pop() (LIFO), which is depth-first in reverse-child order — contradicting the documented search-order guarantee for picking the winning plan_image_url.
  • [L] backend/app/services/scrapers/domrf_catalog_object.py:426 — stats["skipped"] is declared and returned but never incremented anywhere in the function — every distinct failure mode (WAF block, 404, parse error, DB row not found) is lumped into stats["failed"], so callers cannot use the documented skipped/failed split to tell a benign/temporary condition (e.g. WAF ban) apart from a genuine data/parsing regression. — PR #2974 (смержен)
  • [L] backend/app/services/scrapers/nspd_client.py:263 — QuarterDump class docstring states 'Default = только core, чтобы не сжигать rate-limit на 17 запросов', but search_by_quarter's actual default is include_zouit=True (line 805), so the default call already includes 5 ЗОУИТ layers (and, per the finding above, at grid-walk cost not the '1 request per layer' the surrounding cost table implies) — PR #3002 (смержен 21.08; заодно поправлено число «17 запросов» — grid-walk даёт по 49 на слой)
  • [L] backend/app/services/site_finder/cadastre_fetch.py:101 — The docstring of find_active_on_demand_job claims a 60-second grace window for FAILED on-demand jobs ('Если в БД есть FAILED on-demand за последние 60 секунд — тоже None'), but the SQL implementing the function contains no reference to 'failed' status or any created_at/time-based filter at all — it only matches status IN ('queued','running','paused'). — PR #3002 (смержен 21.08; окна «60 секунд» в SQL нет вовсе, failed не возвращается никогда)

Найдено exhaustive-прогоном 2026-07-07 (wf_7ef04945). Фаза 1 = backend. Далее Фаза 2 = frontend/src (287 файлов), Фаза 3 = data/sql (153).

> **Обновление 2026-08-19.** Кластер **C закрыт целиком** (3 пункта): `admin_scrape.py:194` и `report_maps.py:149` — PR #2927 (executor не глушится shutdown(wait=True)), `photos.py:110` — PR #2928 (соединение отпускается до внешнего фетча) + покрытие рискованной ветки PR #2930. По `objective_backfill.py:469` (кластер B) в PR #2929 закрыта **половина** — гео-проход; core-pass оставлен открытым сознательно, обоснование замером в комментарии ниже. `domrf_catalog.py:80` проверен и правки НЕ требует: ноль строк на проде, заблокирован WAF-баном — тоже в комментарии. > **СВЕРЕНО С КОДОМ 2026-08-13** (origin/main @ 9e83eb4a; отметки обновлены 13.08 17:50). Из 89 находок **закрыто 26**, осталось **63**; пунктов, оказавшихся неверными, не найдено (у 5 уточнена формулировка, вывод устоял). Первые 19 были закрыты семью правками, а отметки не проставлял никто — учёт отставал от кода на все 19; ещё три закрыты волной 13.08 (PR #2863, #2866 + проверка без правки по geo_radius_price). Отметки ниже расставлены по результатам сверки; разбор с `файл:строка` и рабочий список остатка — в комментарии под задачей. > Остаток по серьёзности: высокая **17**, средняя **28**, низкая **18**. Мульти-агентный **исчерпывающий line-by-line** ревью всего `backend/app/**` (263 файла, 90 639 строк, 45 чанков). Каждый файл прочитан целиком; каждая находка проверена 2 независимыми скептиками (adversarial verify). **89 подтверждённых** (из 140 кандидатов, 51 отсеян). Сгруппировано по паттернам — фиксить волнами. ## A. Session-poisoning (нет rollback/SAVEPOINT) (16) - [x] **[C]** `backend/app/services/site_finder/developer_attribution.py:153` — get_developer_attribution swallows OperationalError/ProgrammingError/DataError (and generic Exception) from db.execute() without calling db.rollback(), leaving the shared request-scoped Session's transaction in Postgres's aborted state. - [x] **[H]** `backend/app/services/forecasting/orchestrator.py:127` — _safe_call swallows any exception from a §9.x layer without db.rollback(), so a real DB-level error in one layer poisons the shared SQLAlchemy Session for every later layer that reuses the same `db` object. - [x] **[H]** `backend/app/services/forecasting/macro_series.py:401` — _query_mortgage_monthly loops over 5 mortgage fields on the same `db` Session and swallows exceptions per-field without db.rollback(), so one field's DB error cascades and empties out all later fields in the same call, contradicting its own 'graceful: сбой одного ряда не валит остальные' docstring. - [x] **[H]** `backend/app/services/forecasting/special_indices.py:1661` — `_run()` (the shared try/except wrapper around all six §25 index builders) swallows any exception from `db.execute(...)` without calling `db.rollback()`, even though all six builders share ONE SQLAlchemy Session for the whole report (per forecast_request_cache.py docstring: one Session per §22 report / Celery task). - [x] **[H]** `backend/app/services/forecasting/sales_series.py:571` — `_query_source_a` and `_query_source_b` catch any `db.execute` exception and return `{}` without calling `db.rollback()`, on a Session that is explicitly documented as shared/reused across many `build_sales_series` calls within one report (module docstring: 'db не в ключе (одна сессия на отчёт)'). - [x] **[H]** `backend/app/services/job_settings.py:142` — get_all()/get_one() catch DB exceptions and return fallback data without calling db.rollback(), leaving the caller-supplied SQLAlchemy session in an aborted-transaction state. - [x] **[H]** `backend/app/services/site_finder/connection_capacity_lookup.py:355` — Gas/heat/gas-outlet query helpers catch DB exceptions with a bare `except Exception` and never call db.rollback() / use a SAVEPOINT, unlike the sibling `_query_nearby_network_zones` (which correctly wraps its query in `db.begin_nested()`). Once one of these statements fails, the SQLAlchemy session is left in a failed-transaction state, so every subsequent query on the same `db` in the same call poisons and fails too — and gets misattributed to the wrong cause. - [x] **[H]** `backend/app/services/site_finder/competitors.py:801` — The three sequential post-competitor DB lookups (avg_price, sold-count, objective price fallback) each wrap their `db.execute` in a bare `except Exception` with no rollback/SAVEPOINT, so a failure in an earlier one leaves the session's transaction aborted and silently poisons every later query on the same `db`, which then also fails and is misreported as 'query failed, continuing without X'. - [x] **[H]** `backend/app/services/site_finder/saturation.py:241` — compute_district_saturation() catches a bare Exception around db.execute() and returns None without rolling back, poisoning the shared request Session. - [x] **[H]** `backend/app/services/site_finder/supply_layers.py:576` — _safe_rows() catches Exception around db.execute() and returns [] without db.rollback(); compute_all_layers() calls it 3 times in a row (L1, L2, L3) on the same Session, so one failed layer silently zeroes out the other two as well. - [x] **[H]** `backend/app/services/site_finder/zone_regulation.py:468` — backfill_ekb_zone_regulations never rolls back the shared `db` session after a failed commit or other DB-level exception, unlike the sibling get_or_fetch_zone_regulation path which does `write_db.rollback()` in its except block. - [x] **[M]** `backend/app/services/cadastre/bulk_harvest.py:554` — backfill_parcel_geom's per-quarter `except Exception` (line 554) would itself swallow a NspdBulkWafError propagated from _grid_walk_category, contradicting its own adjacent comment (lines 556-557) that claims 'WAF 403 пробросится из client и прервёт прогон — это ожидаемо'. ← #2969 - [x] **[M]** `backend/app/services/exporters/full_report_pdf.py:217` — `_get_connection_capacity`'s except block also omits `db.rollback()` after a DB-backed lookup failure, which can silently force `_generate_concept_result`'s market-price lookup into the class_norm fallback even when a fresh session would have succeeded. ← #2964 - [x] **[M]** `backend/app/services/site_finder/pat_lookup.py:49` — Caught OperationalError/ProgrammingError is logged and swallowed without a db.rollback(), leaving the shared SQLAlchemy Session's transaction in an aborted state for any subsequent query on that same session within the request. - [x] **[M]** `backend/app/workers/tasks/scrape_objective.py:252` — _save_raw()'s INSERT (lines 89-146) is called outside of any except clause that catches general exceptions — the surrounding per-job try only catches ObjectiveAuthError/ObjectiveAPIError (line 370) — so a DB-level failure there propagates to the outer handler, where _finish_run's own DB call on the now-poisoned session is swallowed by a bare `except Exception: pass` (lines 403-409), silently leaving the run row stuck at status='running' forever. ← #2972 - [x] **[L]** `backend/app/services/scrapers/nspd_denorm.py:327` — denorm_dump()'s docstring states the caller is responsible for commit/close, but the function itself unconditionally calls db.commit() at line 373 (confirmed by the test suite asserting `db.commit.assert_called_once()`), directly contradicting the documented contract. ← #2968 ## B. Silent caps (усечение как итог) (10) - [x] **[H]** `backend/app/api/v1/parcels.py:3687` — `market_pulse.competitors_total` is `len(competitor_rows)`, but `competitor_rows` comes from a SQL query with `LIMIT 20` (line 2251). The field name implies the total number of competitors within 3km, but it is really 'up to 20 nearest', and it is also used to compute `coverage_pct = competitors_priced * 100 / competitors_total`, with no truncation flag disclosing the cap. - [x] **[H]** `backend/app/services/cadastre/bulk_harvest.py:402` — _grid_walk_category's blanket `except Exception` (line 402-414) swallows NspdBulkWafError/NspdBulkRateLimitError instead of re-raising, contradicting harvest_quarter's documented 'Raises: NspdBulkWafError ... caller не retry' contract and inconsistent with the codebase's own corrected pattern in the sibling get_features_in_bbox_grid (nspd_bulk_client.py:519-529, explicitly labeled 'Issue #252-mirror') and in search_by_quarter (nspd_bulk_client.py:305, which explicitly re-raises NspdBulkWafError/RateLimitError/ServerError). - [ ] **[H]** `backend/app/services/etl/objective_backfill.py:469` — Ambiguous-candidate resolution (core-pass and geo-pass) never excludes objective_complex_name values already taken in objective_complex_mapping, so a core that has one already-mapped candidate and one genuinely-available candidate is misclassified as ambiguous / can resolve to the wrong (doomed) candidate instead of the available one. ← ОТКЛОНЁН по замеру 20.08: гео-половина закрыта #2929, core-pass оставлен сознательно (имя+застройщик не различают «Старт»/«СТАРТ» — разные ЖК). Остаточная мелочь: `_OBJECTIVE_PROJECTS_SQL` без ORDER BY → `candidates[0]` у ambiguous недетерминирован, но наружу уходят только счётчики, а сам кандидат доживает лишь до метки в журнале отказов гео-прохода. - [x] **[H]** `backend/app/services/site_finder/eesk_reserve_loader.py:239` — load_ps_35_220 writes a numeric load-percentage string into power_supply_centers.load_index via COALESCE(load_index, CAST(:load_pct AS text)), but that column is a categorical enum ('open'|'limited'|'closed'|NULL per data/sql/180_connection_capacity.sql:35) populated elsewhere by rosseti_wfs_loader._map_load_index with exactly those three string values. - [x] **[M]** `backend/app/api/v1/parcels.py:965` — `neighbors_summary.count_buildings_100m` is `len(neighbor_rows)`, where `neighbor_rows` comes from the `neighbors` CTE in `_NEIGHBORS_SUMMARY_SQL` which has `LIMIT 30` (line 858) — same LIMIT-capped-count-presented-as-total pattern as the permits/competitors findings above, with no truncation flag. - [x] **[M]** `backend/app/services/exporters/full_report_html.py:1517` — _build_permits_nearby hard-slices `items` to the first 10 rows without checking/surfacing the upstream `items_truncated` flag or adding a "and N more" disclosure, unlike every other capped table in this same file. - [x] **[M]** `backend/app/services/scrapers/nspd_client.py:814` — search_by_quarter docstring says step 3 fetches 'каждого core layer' via get_features_in_bbox and prices the whole call at 6/11/22 requests (~3.6s/6.6s/13s), but 3 of the 5 core layers plus all zouit/risk layers actually go through get_features_in_bbox_grid at grid_n=7 (49 requests each per that function's own docstring at line ~544), making the true request count roughly 25-50x higher than documented ← #2968 - [ ] **[M]** `backend/app/services/site_finder/poi_score.py:141` — compute_poi_weighted_top7 (and similarly compute_poi_routing_decay at line 328) selects candidate POIs by pure nearest-distance SQL LIMIT before applying the category-weighted ranking, so a genuinely higher-weighted-but-farther POI can be excluded from the candidate window entirely. ← ОТКЛОНЁН по замеру 20.08 — 683 усечённых центра, 0 расхождений топ-7 - [x] **[M]** `backend/app/services/site_finder/velocity.py:213` — The competitor query is capped with `LIMIT 200` (line 213) and `n_comps = len(comp_rows)` (line 326) is then reported as `competitors_count` in every VelocityResult, with no truncation flag — unlike the analogous fix this same codebase already applied elsewhere (utility_infrastructure_loader.py's `features_truncated`, explicitly citing Epic #2445 A2's exact anti-pattern of a LIMIT-capped count masquerading as the true total). - [x] **[L]** `backend/app/services/etl/objective_backfill.py:697` — GeoReject docstring enumerates reason values as 'no_address' | 'no_geocode' | 'too_far' | 'ambiguous_multi' | 'call_limit', but the code also emits reason='partial_geocode' (line 918), which is undocumented and omitted from the enumerated set. ## C. Неэффективный timeout-guard (3) - [x] **[H]** `backend/app/api/v1/admin_scrape.py:194` — queue_status's ThreadPoolExecutor `with` block still blocks on shutdown(wait=True) even after the code 'gives up' via result(timeout=...), so the documented ~600ms worst-case latency guarantee is false. - [x] **[H]** `backend/app/api/v1/photos.py:110` — DB session (checked out from the connection pool via Depends(get_db)) is held open for the entire duration of the synchronous upstream HTTP fetch to DOM.РФ. - [x] **[H]** `backend/app/services/exporters/report_maps.py:149` — `_add_basemap`'s timeout protection is ineffective: `with ThreadPoolExecutor(...) as pool:` calls `shutdown(wait=True)` on exit even after `.result(timeout=...)` times out, so the function can still block indefinitely on a hung tile fetch. ## D. Price без sanity-фильтра (3) - [x] **[H]** `backend/app/api/v1/parcels.py:2209` — `obj_pricing` CTE computes `avg_price_per_m2_rub` as a raw `AVG(oll.price_per_m2_rub)` with no sanity/outlier filter, unlike the sibling `district_price_block` (line 2902, `BETWEEN 30000 AND 600000`) and `market_trend` (line 2983, `BETWEEN 30000 AND 500000`) queries in the same file that guard against bad scraped prices. - [x] **[M]** `backend/app/api/v1/parcels.py:3383` — ПРОВЕРЕНО, правка не нужна (медиана устойчива: 5 комплексов из 310 с выбросами, сдвиг ≤456 ₽/м², <1%; см. комментарий) — `geo_radius_price` median query (`percentile_cont(0.5)` over `objective_lots.price_per_m2_rub`) has no price sanity filter, unlike the near-identical `district_price_block` query above it (line 2902) which bounds prices to 30000-600000. - [x] **[L]** `backend/app/services/site_finder/parcel_financial.py:152` — `synthesize_teap_from_buildability` never validates that `max_building_pct` is within a sane 0-100 range (or that `max_far`/`max_floors` are plausible) before using it to derive built area and GFA, so a bad upstream zoning value silently produces a physically impossible result presented as a normal financial figure. — ✅ PR #3000 (смержен 20.08; + второй дефект той же функции: пятно застройки выходило больше участка) ## E. snapshot-инфляция счётчиков (3) - [x] **[H]** `backend/app/services/analytics_queries.py:1799` — _active_competitors_count()'s _q() helper does `SELECT COUNT(*) FROM domrf_kn_objects WHERE region_cd=:rc AND site_status='Строящиеся' ...` with no snapshot_date filter and no DISTINCT ON obj_id, so it counts every retained historical snapshot row per object, not distinct objects. - [x] **[H]** `backend/app/services/analytics_queries.py:509` — developer_portfolio() selects from domrf_kn_objects filtered only by dev_id, with no snapshot_date filter or DISTINCT ON obj_id, so it returns every retained historical snapshot of each project as a separate row. - [x] **[H]** `backend/app/services/scrapers/domrf_kn.py:537` — UPSERT_OBJECT_SQL's ON CONFLICT (obj_id, snapshot_date) DO UPDATE SET omits problem_flag, green_house, floor_min, floor_max, hobj_id, short_addr, dev_inn and region_cd, even though all eight are populated on INSERT (lines 498-516) and freshly computed every call by _norm_object. ## F. ON CONFLICT пропускает колонки (1) - [x] **[M]** `backend/app/services/scrapers/domrf_kn.py:923` — UPSERT_PHOTO_SQL's ON CONFLICT (obj_id, obj_file_id) DO UPDATE SET updates ord_num/photo_url/photo_dttm/period_dt/size_bytes/photo_name/ready_desc/hidden but omits build_type, which is part of the INSERT column list (line 916) and populated every call from p.get('objBuildTypeShortDesc'). ## G. Прочие корректность/данные (31) - [x] **[H]** `backend/app/api/v1/parcels.py:2381` — The noise-source query (step 7) has no `WHERE source_type IN (...)` filter, so it pulls every row from `osm_noise_sources_ekb` within 2km — including 'water' and 'utility' rows that the very same file queries separately (with explicit `source_type = 'water'` / `= 'utility'` filters) for the hydrology and utilities blocks a few dozen lines below. - [x] **[H]** `backend/app/services/exporters/full_report_html.py:460` — _build_zoning's legacy-zoning fallback validates data on the `zoning` dict but then builds the table by reading from the still-empty `nspd_zoning` dict, silently discarding real fallback zoning data. ← #2959 - [x] **[H]** `backend/app/services/scrapers/domrf_catalog.py:80` — Status keyword regex has no negation guard, so Russian negated forms ("нереализованных", "непроданных" — meaning UNSOLD/available) substring-match the sold keywords and get classified as SOLD. — ✅ PR #2979 (смержен 20.08; эффект сегодня нулевой — путь отключён, измерено) - [x] **[H]** `backend/app/services/scrapers/domrf_catalog_object.py:440` — The entire batch runs inside one long-lived, uncommitted outer transaction with no interim commits — a late failure (e.g. BrowserSession.__aexit__ raising, or any unhandled exception after the loop) discards every already-succeeded per-object UPDATE. ← #2960 - [x] **[H]** `backend/app/services/scrapers/nspd_client.py:610` — get_features_in_bbox_grid swallows every per-cell HTTP exception (return_exceptions=True + logger.warning + continue) and never raises, so a layer-wide WAF ban / outage produces an empty feature list indistinguishable from a genuine 'no zones here' result - [x] **[H]** `backend/app/services/scrapers/page_reservation_parser.py:129` — _detect_kind classifies the whole document by unanchored substring search for 'резервир' before 'изъят' anywhere in the full text, not by which act type the document actually is. — ✅ PR #2980 (смержен 20.08; парсер на прод строк не писал, измерено) - [ ] **[H]** `backend/app/services/site_finder/best_layouts.py:195` — ЧАСТИЧНО: цена починена (PR #2868), площадь требует правки контракта → #2867. Механизм оказался не тот: не «все слагаемые NULL» (таких строк 0), а пустое окно продаж. avg_area_m2 in _INLINE_VELOCITY_SQL is COALESCE(...,0) when all contributing deals rows have NULL deals_total_avg_area_m2, and this 0 is then silently treated downstream as a genuine '<25 м²' apartment area instead of 'area unknown'. - [ ] **[H]** `backend/app/services/site_finder/quarter_dump_lookup.py:767` — _get_risk_zones computes ST_Intersection with BOTH operands pre-cast to ::geography, reintroducing the exact PostGIS 3.4 'geography×geography ST_Intersection transform error' bug that the sibling _get_red_lines function in the SAME file explicitly documents and avoids (there, ST_Intersection runs in planar geometry and only the result is cast to ::geography, per the comment at lines 967-970). ← ОТКЛОНЁН по замеру 20.08 — не воспроизводится на PostGIS 3.4.3, слои пусты - [x] **[H]** `backend/app/services/site_finder/velocity.py:167` — class_filter references alias `o.` inside the `latest_obj` CTE, but that CTE's FROM clause (`FROM domrf_kn_objects`, line 189) has no alias `o` — the alias `o` is only introduced later by the outer query (`FROM latest_obj o`, line 206). - [x] **[M]** `backend/app/api/v1/admin_cadastre.py:83` — manual_list validation checks non-empty BEFORE stripping whitespace, so an all-whitespace quarters list silently bypasses the intended 400 error and creates a zero-target job. ← #2965 - [x] **[M]** `backend/app/api/v1/admin_leads.py:152` — revenue_total and deals_total in the /stats KPI response are actually scoped to the `months` window (via window_leads CTE), not all-time totals, despite being named identically in style to leads_total which genuinely is all-time — a consumer trusting the '_total' suffix will display a partial-window figure as the grand total. ← #2963 - [x] **[M]** `backend/app/api/v1/admin_scrape.py:1096` — cancel_geo_job always returns `{"cancelled": true}` even when the UPDATE matched zero rows (nonexistent job_id or job already in a terminal state), silently misreporting success. - [ ] **[M]** `backend/app/services/cadastre/bulk_harvest.py:1464` — _save_territorial_zones falls back to a synthetic zone_id = md5(sorted properties) when NSPD's WMS feature carries no id/zone_id anywhere (properties or top-level feature id); two geometrically-distinct zones sharing identical properties collide on this hash and one polygon silently overwrites the other via ON CONFLICT (zone_id) DO UPDATE. ← ОТКЛОНЁН по замеру 20.08: синтетических zone_id на проде 0 из 1 строки, а таблицу `cad_territorial_zones` НЕ ЧИТАЕТ никто (ПЗЗ-зона в отчёте идёт из `nspd_quarter_dumps.features_json` через `_get_zoning`). Судьба самой таблицы вынесена в #2985 — чинить хеш до её решения бессмысленно. - [x] **[M]** `backend/app/services/exporters/full_report_docx.py:288` — ЗОУИТ-reconciliation sets zouit_count = len(overlaps) (count of overlap/border records), but the KV row is labeled "Кол-во типов ЗОУИТ" (count of distinct ZOUIT TYPES) — the correct value is len(zouit_types). ← #2961 - [x] **[M]** `backend/app/services/scrapers/domrf_catalog_object.py:460` — No circuit breaker on repeated WafBlockedError: once the DOM.РФ WAF starts blocking the session (the exact scenario referenced in the anti-ban comment at lines 444-450, incident #2443), the loop keeps sending one live request per remaining obj_id to the already-banned session instead of aborting the batch. ← #2971 - [ ] **[M]** `backend/app/services/scrapers/ekb_geoportal_client.py:217` — `_get_feature` treats an HTTP-200 response whose JSON body lacks a usable "features" list identically to a genuine "no features found at this location" — with no logging — so a GeoServer WFS error/degenerate response (common failure mode: CQL/typeName errors returned as HTTP 200 with an OWS ExceptionReport-shaped JSON) is silently reported as "this parcel has no PZZ zone/ЗОУИТ/КРТ here" instead of "the query failed". ← ОТКЛОНЁН по замеру 20.08 — ошибки WFS не притворяются пустым результатом - [x] **[M]** `backend/app/services/scrapers/stealth.py:293` — download_binary has no retry/backoff on transient failures (429/5xx), unlike get_json which retries up to 5 times with exponential backoff under the same WAF. — ✅ PR #2999 (смержен 20.08; ретраи по образцу get_json + контроль на паузы) - [ ] **[M]** `backend/app/services/site_finder/best_layouts.py:1113` — The `objects_total_in_radius` field means two different things across the two 'empty response' branches: line 1113 reports the pre-exclude/pre-filter group count, while line 1194 (a few dozen lines later, same function, same field name) reports the post-exclude/post-filter group count. ← ТРЕБУЕТ РЕШЕНИЯ — какая семантика поля верна, см. коммент 20.08 ← ОТКЛОНЁН по замеру 20.08: `objects_total_in_radius` — свойство РАДИУСА, а не пользовательского фильтра, и существующий тест это закрепляет (исключение конкурента оставляет счётчик = 1). Разбор в комментарии к эпику от 20.08. - [x] **[M]** `backend/app/services/site_finder/best_layouts.py:402` — _SUPPLY_ONLY_LOTS_SQL's area_bin CASE bucket for on_sale lots maps area_pd IS NULL into the same '<25' bucket as genuinely tiny (<25 m²) apartments, rather than excluding/flagging lots with missing area data. — ✅ PR #2983 (смержен 20.08; 11 557 квартир без площади сидели в корзине «<25 м²» при 7 013 настоящих) - [ ] **[M]** `backend/app/services/site_finder/competitors.py:553` — _SOLD_COUNT_SQL's `mapped` CTE only sources from `objective_complex_mapping`, omitting the `nearest_cx` spatial/name gap-fill branch that both the velocity CTE (in _COMPETITORS_SQL) and _OBJECTIVE_PRICE_FALLBACK_SQL include — so flats_sold is never computed for competitors whose velocity/price come from the spatial gap-fill match, silently collapsing their stage_at_horizon to the neutral default. ← ЗАБЛОКИРОВАН #2962 — мост gap-fill сломан, копировать нельзя - [x] **[M]** `backend/app/services/site_finder/eias_heat_loader.py:519` — load_heat_reserves opens a single Session before iterating all 8 organizations, each doing multiple 60s-timeout HTTP round-trips to a slow/geo-blocked external host, and commits only once at the very end — so a single Postgres transaction (opened implicitly by the first per-row SAVEPOINT) stays open across several minutes of external network I/O for the whole batch. — ✅ PR #2973 (смержен) - [x] **[M]** `backend/app/workers/beat_schedule.py:551` — newbuilding-crossload-nightly cron string fires 3 hours earlier than intended because the comment double-converts UTC→MSK when Celery's global timezone is already Europe/Moscow. - [x] **[M]** `backend/app/services/weather_cache.py:164` — precipitation_total_mm (and the seasonal total_precip_mm equivalent) silently default to 0 when precipitation data is entirely missing, while every sibling metric (uv_index_max, avg_precip_per_day_mm, etc.) correctly defaults to None for the same missing-data condition — silent data dishonesty. - [x] **[M]** `backend/app/services/site_finder/weight_profiles.py:99` — _SELECT_DEFAULT has no ORDER BY and returns an arbitrary row via LIMIT 1, while create_profile/update_profile's unset-then-set sequence for is_default is not atomic across concurrent requests, so two profiles can end up with is_default=TRUE and get_default_profile can non-deterministically flip between them. — ✅ PR #2976 (смержен 20.08, индекс на проде фальсифицирован) - [x] **[M]** `backend/app/workers/tasks/scrape_kn.py:42` — Redis singleton lock key for a kn-API sweep is built by joining the `developers` list in caller-supplied order, so the same developer set submitted in a different order produces a different lock key and the lock silently fails to prevent concurrent duplicate sweeps. ← #2970 - [x] **[L]** `backend/app/services/exporters/full_report_html.py:1127` — `metrics.get("sell_through_pct")` is rendered raw (via `_fmt`) instead of through `_fmt_pct_raw`, even though the underlying value is already on a 0-100 scale (`sold/(sold+available)*100`, see market_metrics.py:194-195) and the row label explicitly says '%'. ← НАХОДКА НЕВЕРНА, не «починено»: `_fmt` шкалу не меняет, значение округлено в `market_metrics.py:136`, единицу несёт подпись строки (сверка 19.08) - [x] **[L]** `backend/app/services/generative/placement.py:297` — When a program item overrides the catalog footprint dimensions (item.footprint_w_m/footprint_d_m both set), the "placed N of M" warning still logs the catalog's house.footprint_w_m/house.footprint_d_m instead of the actual fp_w/fp_d that were used for placement. — ✅ PR #3003 (смержен 21.08) - [ ] **[L]** `backend/app/services/scrapers/rosstat_emiss.py:228` — _decode_csv tries cp1251 before plain utf-8 in its fallback chain; cp1251 almost never raises UnicodeDecodeError (it maps nearly all byte values), so it silently 'succeeds' on corrupted/truncated UTF-8 content instead of falling through to the intended utf-8/errors='replace' path. ← ОТКЛОНЁН по замеру 20.08. Порчи нет: 0 следов мохибейка («Ð», «Ñ», «â€», «Ã») в 2862 строках macro_indicator. Перестановка сделала бы ХУЖЕ: utf-8-sig уже ловит валидный utf-8 первым, а настоящие CP1251-файлы после неё ушли бы в utf-8/replace. Побочно проверено и подтверждено эмпирически: третий элемент цепочки `utf-8` НЕДОСТИЖИМ (200k случайных байтовых строк — ни одного случая, где utf-8-sig падает, а utf-8 проходит), а cp1251 декодирует и обрезанный utf-8, и случайные байты, поэтому ветка `errors="replace"` со своим logger.warning не может сработать НИКОГДА — отлаживающий будет искать это предупреждение в логах впустую. Эвристику «похоже ли на мохибейк» не добавляю: калибровать её не на чем (0 случаев), а правило без образцов ловит ровно те случаи, которые придумали вместе с ним. - [x] **[L]** `backend/app/services/site_finder/gate_verdict.py:441` — cad_utility_label (and thus the ZOUIT_NETWORK_OBREMENENIE vs ZOUIT_CAD_BLOCKER label choice) is taken from the first overlap with a resolved network_kind, but the aggregated coverage/threshold decision mixes in generic keyword-matched overlaps with no confirmed network_kind — so a blocker can be labeled as a specific network encumbrance even though most of the blocking coverage came from an unrelated/unclassified keyword match. — ✅ PR #3001 (смержен 20.08; довод пункта опровергнут замером — смешиваются РАЗНЫЕ ВИДЫ СЕТЕЙ, а не сети с keyword; вывод верен) - [x] **[L]** `backend/app/services/site_finder/vodokanal_reserve_loader.py:488` — `load_water_reserves_from_docx` builds `result` including `"period": period` and logs that full dict (line 489), but the actual `return` statement (line 490) filters `result.items()` to `isinstance(v, int)` only, which always drops `period` (a str or None) — so the value actually returned (and thus what `load_water_reserves`/the Celery task `sync_water_reserves` surfaces) silently diverges from what was just logged. — ✅ PR #3003 (смержен 21.08; фильтр стоял ради аннотации dict[str, int], она тоже исправлена) - [x] **[L]** `backend/app/workers/tasks/cbr_macro_sync.py:116` — Task is declared with `bind=True, max_retries=2` but never calls `self.retry()` and has no `autoretry_for`, so the retry configuration has zero effect — the task fails permanently on first error despite the parameter suggesting up to 2 retries. — ✅ PR #2977 (смержен 20.08; заодно ещё 10 тасок + AST-гейт) ## H. Doc/comment drift (22) - [x] **[H]** `backend/app/api/v1/admin_scrape.py:212` — The Redis queue_depth probe (`channel.client.llen("celery")`) has no timeout at all, unlike the inspect() calls above it, so it can hang the request indefinitely if the broker connection stalls. - [x] **[H]** `backend/app/services/dadata_client.py:185` — `_suggest_geocode` crashes with AttributeError instead of returning None when DaData's `data` field is not a dict. - [x] **[H]** `backend/app/services/forecasting/report_assembler.py:205` — _domrf_coverage() fallback feeds the confidence engine's 'главный sparse-риск проекта domrf↔objective (~2.5%)' signal with an unrelated metric (analyze.market_data_coverage_pct = % of nearby competitors with a priced Objective listing), and this fallback is the only path ever exercised in production because the sole current producer of supply_layers (orchestrator.py _summarize_supply_layers, line ~154-157) never emits supply_layers.domrf_coverage. - [x] **[H]** `backend/app/services/scrapers/ekb_ppt_tep_parser.py:72` — `_page_contains_table`'s docstring claims cross-reference/ToC false positives are detected and suppressed, but the implementation is a bare regex search with no such logic — so table-of-contents entries or narrative cross-references (e.g. "показатели приведены в таблице 12") are indistinguishable from the real table caption. — ✅ PR #2988 (смержен 20.08; докстрока приведена к коду + замер уточнил довод пункта: косвенные падежи регекс НЕ ловит, опасно только оглавление) - [x] **[H]** `backend/app/services/scrapers/nspd_client.py:842` — search_by_quarter's docstring claims the whole operation is atomic ('Partial-success НЕ возвращается... failure → exception'), but this only holds for parcels/buildings (legacy get_features_in_bbox path); grid-walked layers (territorial_zones, red_lines, engineering_structures, all zouit, all risks) never raise on failure per finding above - [x] **[M]** `backend/app/api/v1/admin_scrape.py:1121` — resume_geo_job has no status guard on its UPDATE (unlike cancel_geo_job) and unconditionally re-enqueues the worker task, contradicting its own docstring ('Re-enqueue paused/failed job') by allowing a currently-RUNNING job to be double-dispatched, and reports success even for a nonexistent job_id. - [x] **[M]** `backend/app/services/analytics_queries.py:1423` — Contradictory in-file documentation about the vocabulary of objective_corpus_room_month.district: _velocity_baseline()'s docstring (line 1423) claims it 'matches domrf_kn_objects.district_name' (admin vocab), while _elasticity_coef()'s docstring (lines 1959-1962) states the same column is MICRO-neighborhood vocab ('Втузгородок', 'ЖБИ', ...) and that passing an admin district name gives 0 rows (labeled bug #1211). recommend_mix() calls _velocity_baseline, _velocity_baseline_per_bucket, and _district_velocity_trend with district_row['district_name'] (admin vocab from ekb_districts, e.g. 'Кировский') and calls _elasticity_coef without the `districts` resolver param, taking exactly the legacy admin-vocab path that _elasticity_coef's own docstring calls out as 'отдельный bug class' for this file's callers. ← #2968 - [x] **[M]** `backend/app/services/forecasting/macro_series.py:305` — get_monthly_macro's docstring claims the empty-list return only happens when months_back < 0, but the implementation clamps months_back with max(0, months_back) before computing the grid start, so the grid can never actually be empty for a negative months_back -- the documented behavior is unreachable/wrong. ← #2968 - [x] **[M]** `backend/app/services/generative/exporters/pdf.py:182` — The exported concept PDF's methodology footnote hardcodes 'распродажа 30 мес' regardless of the actually-computed DCF sales window, so the disclosed assumption can be factually wrong for the very numbers on the same page. - [x] **[M]** `backend/app/services/site_finder/osrm_client_local.py:139` — Per-element `float(d)` conversion of OSRM distances happens outside the try/except block, contradicting the function's documented contract that ANY unexpected-format response raises `OsrmLocalUnavailableError`. - [x] **[M]** `backend/app/services/site_finder/ors_client.py:131` — Same pattern as osrm_client_local.py: `float(sec)` for each ORS matrix duration is computed outside the try/except, so a malformed element type raises a raw exception instead of the documented `OrsUnavailableError`. - [x] **[M]** `backend/app/workers/lifecycle.py:92` — Zombie-resume query for kn_scrape_runs only catches 'running' rows that already have objects_snapshot set, contradicting the function's own stated invariant that ANY running row at worker_ready is by definition a zombie. — ✅ PR #2975 (смержен 20.08, код проверен в контейнере) - [x] **[M]** `backend/app/workers/tasks/izyatie_ocr_ingest.py:101` — Docstring claims per-batch Python dedup + a two-step (cad_num, doc_url) upsert prevents duplicate land_reservation rows for act_number-less records, but the actual code implements neither — every weekly re-run reinserts brand-new duplicate rows. ← #2966 - [x] **[L]** `backend/app/services/forecasting/confidence_engine.py:100` — Comment claims _HISTORY_MONTHS_LOW mirrors §9.6's _MIN_OBS=8, but the actual constant is 12 - [x] **[L]** `backend/app/services/forecasting/macro_coefficient.py:99` — Stale comment claims the backed-weight sum is 0.45, but since #946 promoted inflation to a backed channel with weight 0.08 (line 110), the actual current backed-weight sum is 0.53. ← #2968 - [x] **[L]** `backend/app/services/forecasting/sales_series.py:496` — Docstring/code contradiction: `build_sales_series` (and its docstring) claims the returned series is empty (`months=[]`) 'только если сетка пуста (months_back < 0)', but the code clamps negative months_back to 0 before computing the grid, so the grid is never empty for any input. ← #2968 - [x] **[L]** `backend/app/services/forecasting/special_indices.py:589` — `_timing_overlap`'s docstring states the formula is `exp(−|Δмесяцев| / half_life)` with 'расхождение в half_life мес → 0.5', but that formula does not equal 0.5 at Δ=half_life (it equals e^-1≈0.368); the actual, correct implementation uses `0.5 ** (Δ/half_life)` (line 601), which does hit exactly 0.5 at Δ=half_life, matching the 'inline comment fix' but contradicting the docstring's stated formula. - [x] **[L]** `backend/app/services/objective_etl.py:466` — get_sqlite_info() has a TOCTOU race: it checks Path.exists() and then calls Path.stat() unguarded, outside the try/except that only covers the sqlite3.connect block. — ✅ PR #3003 (смержен 21.08) - [x] **[L]** `backend/app/services/scrapers/domrf_catalog.py:375` — Comment claims a BFS traversal of the __NEXT_DATA__ JSON tree, but the implementation uses stack.pop() (LIFO), which is depth-first in reverse-child order — contradicting the documented search-order guarantee for picking the winning plan_image_url. - [x] **[L]** `backend/app/services/scrapers/domrf_catalog_object.py:426` — stats["skipped"] is declared and returned but never incremented anywhere in the function — every distinct failure mode (WAF block, 404, parse error, DB row not found) is lumped into stats["failed"], so callers cannot use the documented skipped/failed split to tell a benign/temporary condition (e.g. WAF ban) apart from a genuine data/parsing regression. — ✅ PR #2974 (смержен) - [x] **[L]** `backend/app/services/scrapers/nspd_client.py:263` — QuarterDump class docstring states 'Default = только core, чтобы не сжигать rate-limit на 17 запросов', but search_by_quarter's actual default is include_zouit=True (line 805), so the default call already includes 5 ЗОУИТ layers (and, per the finding above, at grid-walk cost not the '1 request per layer' the surrounding cost table implies) — ✅ PR #3002 (смержен 21.08; заодно поправлено число «17 запросов» — grid-walk даёт по 49 на слой) - [x] **[L]** `backend/app/services/site_finder/cadastre_fetch.py:101` — The docstring of find_active_on_demand_job claims a 60-second grace window for FAILED on-demand jobs ('Если в БД есть FAILED on-demand за последние 60 секунд — тоже None'), but the SQL implementing the function contains no reference to 'failed' status or any created_at/time-based filter at all — it only matches status IN ('queued','running','paused'). — ✅ PR #3002 (смержен 21.08; окна «60 секунд» в SQL нет вовсе, failed не возвращается никогда) --- _Найдено exhaustive-прогоном 2026-07-07 (wf_7ef04945). Фаза 1 = backend. Далее Фаза 2 = frontend/src (287 файлов), Фаза 3 = data/sql (153)._
Author
Collaborator

Сверка всех 89 находок с кодом — 13.08.2026, origin/main @ 9e83eb4a

Эпик от 07.07 стоял с нулём отмеченных пунктов. Проверил каждый против сегодняшнего кода.

Раздел Всего Закрыто Осталось
A. Отравление сессии 16 11 5
B. Молчаливое усечение 10 4 6
C. Неработающий сторож по времени 3 0 3
D. Цена без проверки правдоподобия 3 0 3
E. Раздувание счётчиков снимками 3 3 0
F. ON CONFLICT пропускает столбцы 1 1 0
G. Прочая корректность и данные 31 0 31
H. Расхождение описаний с кодом 22 0 22
Итого 89 19 70

Остаток по серьёзности: высокая 22, средняя 30, низкая 18.

Почему это стоило отдельной работы

Все 19 закрыты семью правками (39dd6333, 82fdabcc, 22f3c44d, 01360e9c, d3f3370b, 9a6b5601, f987e819), каждая ссылается на #2464 — и ни одна отметка не проставлена. Учёт отставал от кода на все девятнадцать.

Цена такого расхождения не теоретическая: сегодня в соседнем продукте агент построил дубль уже существующей правки, потому что не посмотрел смежное. Эпик с нулём отметок гарантирует повторение — следующий исполнитель возьмёт из списка сделанное.

Отметки в теле задачи расставлены.

Аудит не ошибся ни разу

Отдельно проверял исход «пункт неверен» — его нет ни одного. У пяти пунктов уточнена формулировка (не тот номер строки после правок, механизм описан общее, чем есть), но вывод в каждом случае устоял.

Это довод в пользу метода: аудит собирался с двумя независимыми скептиками на находку, 51 кандидат из 140 был отсеян на входе. Месяц спустя оставшиеся 89 подтвердились.

Что осталось — высокая серьёзность

Полный список в отчёте сверки; здесь то, что стоит взять первым.

Соединение из пула удерживается на время внешнего запросаapi/v1/photos.py:110 (8 с, больше при перенаправлениях) и exporters/report_maps.py:149 (подложка карты, срок 10 с не срабатывает; путь наследуют обе точки выгрузки).

Сторож по времени не возвращает управлениеapi/v1/admin_scrape.py:194: выход из блока пула потоков ждёт зависшие потоки, при том что описание на :89-95 обещает 600 мс.

Числовой процент пишется в категориальный столбецsite_finder/eesk_reserve_loader.py:239: ломает распределение в connection_capacity_lookup.py:186-188.

Цена без границ правдоподобияapi/v1/parcels.py:2322, при том что соседние запросы по той же таблице ограничивают 30 000–600 000 (:3036, :3117). Контроль рядом, а здесь его нет.

Отказ источника неотличим от «данных нет»scrapers/nspd_client.py:610: ошибки ячеек сетки только пишутся в журнал. Тот же вид, что весь день ловился в МЕРЕ.

«Нереализованных» и «непроданных» распознаются как «продано»scrapers/domrf_catalog.py:80.

Оговорка

Сверка — по коду, не по поведению. «Механизм на месте» не означает «проверено на проде»: для 19 закрытых я убедился, что дефект в origin/main не воспроизводится, но прод-подтверждения эффекта не делал. Для раздела A это приемлемо (наличие отката проверяется чтением), для разделов про данные — нет, и там при работе стоит мерить.

## Сверка всех 89 находок с кодом — 13.08.2026, origin/main @ 9e83eb4a Эпик от 07.07 стоял с **нулём отмеченных пунктов**. Проверил каждый против сегодняшнего кода. | Раздел | Всего | Закрыто | Осталось | |---|---:|---:|---:| | A. Отравление сессии | 16 | **11** | 5 | | B. Молчаливое усечение | 10 | **4** | 6 | | C. Неработающий сторож по времени | 3 | 0 | 3 | | D. Цена без проверки правдоподобия | 3 | 0 | 3 | | E. Раздувание счётчиков снимками | 3 | **3** | 0 | | F. ON CONFLICT пропускает столбцы | 1 | **1** | 0 | | G. Прочая корректность и данные | 31 | 0 | 31 | | H. Расхождение описаний с кодом | 22 | 0 | 22 | | **Итого** | **89** | **19** | **70** | Остаток по серьёзности: высокая **22**, средняя **30**, низкая **18**. ## Почему это стоило отдельной работы Все 19 закрыты **семью правками** (39dd6333, 82fdabcc, 22f3c44d, 01360e9c, d3f3370b, 9a6b5601, f987e819), каждая ссылается на #2464 — и **ни одна отметка не проставлена**. Учёт отставал от кода на все девятнадцать. Цена такого расхождения не теоретическая: сегодня в соседнем продукте агент построил дубль уже существующей правки, потому что не посмотрел смежное. Эпик с нулём отметок гарантирует повторение — следующий исполнитель возьмёт из списка сделанное. Отметки в теле задачи расставлены. ## Аудит не ошибся ни разу Отдельно проверял исход «пункт неверен» — его нет **ни одного**. У пяти пунктов уточнена формулировка (не тот номер строки после правок, механизм описан общее, чем есть), но вывод в каждом случае устоял. Это довод в пользу метода: аудит собирался с двумя независимыми скептиками на находку, 51 кандидат из 140 был отсеян на входе. Месяц спустя оставшиеся 89 подтвердились. ## Что осталось — высокая серьёзность Полный список в отчёте сверки; здесь то, что стоит взять первым. **Соединение из пула удерживается на время внешнего запроса** — `api/v1/photos.py:110` (8 с, больше при перенаправлениях) и `exporters/report_maps.py:149` (подложка карты, срок 10 с не срабатывает; путь наследуют обе точки выгрузки). **Сторож по времени не возвращает управление** — `api/v1/admin_scrape.py:194`: выход из блока пула потоков ждёт зависшие потоки, при том что описание на :89-95 обещает 600 мс. **Числовой процент пишется в категориальный столбец** — `site_finder/eesk_reserve_loader.py:239`: ломает распределение в `connection_capacity_lookup.py:186-188`. **Цена без границ правдоподобия** — `api/v1/parcels.py:2322`, при том что соседние запросы по той же таблице ограничивают 30 000–600 000 (`:3036`, `:3117`). Контроль рядом, а здесь его нет. **Отказ источника неотличим от «данных нет»** — `scrapers/nspd_client.py:610`: ошибки ячеек сетки только пишутся в журнал. Тот же вид, что весь день ловился в МЕРЕ. **«Нереализованных» и «непроданных» распознаются как «продано»** — `scrapers/domrf_catalog.py:80`. ## Оговорка Сверка — по коду, не по поведению. «Механизм на месте» не означает «проверено на проде»: для 19 закрытых я убедился, что дефект в `origin/main` не воспроизводится, но прод-подтверждения эффекта не делал. Для раздела A это приемлемо (наличие отката проверяется чтением), для разделов про данные — нет, и там при работе стоит мерить.
Author
Collaborator

Кластер D разобран целиком — с замерами, и один пункт закрываю БЕЗ правки

D1 — parcels.py obj_pricing.avg_price_per_m2_rub без границ → PR #2863, смержен

Границы BETWEEN 30000 AND 600000 (те же, что в двух соседних запросах по objective_lots)
теперь стоят через FILTER, а не WHERE, чтобы не поехали units_sold / units_available.

Замер через тот же путь, что и продукт (physflat-дедуп + маппинг на domrf_obj_id):

вне диапазона 204 лота из 2 279 827 · 118 в 10 замапленных проектах, 86 в незамапленных
меняется 6 проектов из 308, худший на 2.4%, market_avg_price 138 056 → 138 008, NULL нет

Поправка к себе: раньше в ветке я насчитал «26 из 890, худший в 24 раза» — это был замер
по неверной популяции (все project_name, без дедупа и маппинга, включая 573 проекта,
которые до экрана не доходят). Правильные цифры выше.

Чинить всё равно надо: ограничения сверху нет по построению, максимум в таблице —
19 198 429 ₽/м² (ЖК «Дебют»), и он вне экрана только потому, что проект пока не
замаплен (308 имён из 881, список пополняется).

D2 — geo_radius_price медиана без границ → правка не нужна, и вот почему

Находка формально верна: percentile_cont(0.5) по objective_lots.price_per_m2_rub
никаких границ не имеет. Но прежде чем ставить фильтр, замерил, что он изменит.
Медиана устойчива к хвостам по построению — это и подтвердилось:

комплексов                                      310
из них с лотами вне диапазона                     5
медиана изменилась бы                             5
из них больше чем на 1%                           0
максимальный сдвиг                          456 ₽/м²   (на медианах 100-150 тыс, т.е. ~0.3%)

Считал по одиночным комплексам — это самая жёсткая нарезка: реальный запрос требует
≥10 лотов и ≥2 ЖК в радиусе, объединение популяций только размывает выброс дальше.

Вывод: фильтр здесь дал бы изменение ниже шума и ещё одну ветку в запросе. Оставляю как есть,
пункт закрываю как проверенный. Если данные поедут (например, появится ЖК, где выбросы —
заметная доля лотов), сигналом станет with_outliers; сейчас это 5 комплексов из 310.

Отмечаю в чеклисте оба пункта.

D3 — parcel_financial.py max_building_pct без валидации

Ещё не смотрел, остаётся открытым.


Заодно из соседних кластеров, тоже с замерами:

  • G, velocity.py фильтр класса ссылался на алиас o, которого нет в CTE →
    прод-EXPLAIN даёт missing FROM-clause entry for table "o". Ветка мёртвая (никто не
    передаёт obj_class), но упала бы молча — исключение глотает except, и блок темпа
    продаж просто исчез бы из отчёта. PR #2865.
  • H, шесть комментариев beat_schedule.py считали сдвиг МСК дважды. По логам beat
    задача уходит в 21:30 UTC = 00:30 МСК, а комментарий обещал 03:30 МСК. Расписание НЕ
    трогаю (03:30 МСК завело бы ETL в окно newbuilding_enrich, с которым он делит
    источник) — правлю подпись под факт. PR #2866.
  • B, best_layouts.py подставлял 0 ₽/м² там, где сделок за окно не было, хотя схема
    уже объявляла float | None и Python уже умел None — COALESCE делал честную ветку
    недостижимой. PR #2868. Площадь в том же месте требует правки контракта → вынес в #2867.
## Кластер D разобран целиком — с замерами, и один пункт закрываю БЕЗ правки ### D1 — `parcels.py` `obj_pricing.avg_price_per_m2_rub` без границ → PR #2863, смержен Границы `BETWEEN 30000 AND 600000` (те же, что в двух соседних запросах по `objective_lots`) теперь стоят через `FILTER`, а не `WHERE`, чтобы не поехали `units_sold` / `units_available`. Замер через **тот же путь, что и продукт** (physflat-дедуп + маппинг на `domrf_obj_id`): ``` вне диапазона 204 лота из 2 279 827 · 118 в 10 замапленных проектах, 86 в незамапленных меняется 6 проектов из 308, худший на 2.4%, market_avg_price 138 056 → 138 008, NULL нет ``` Поправка к себе: раньше в ветке я насчитал «26 из 890, худший в 24 раза» — это был замер по неверной популяции (все `project_name`, без дедупа и маппинга, включая 573 проекта, которые до экрана не доходят). Правильные цифры выше. Чинить всё равно надо: ограничения сверху нет по построению, максимум в таблице — **19 198 429 ₽/м²** (ЖК «Дебют»), и он вне экрана только потому, что проект пока не замаплен (308 имён из 881, список пополняется). ### D2 — `geo_radius_price` медиана без границ → **правка не нужна, и вот почему** Находка формально верна: `percentile_cont(0.5)` по `objective_lots.price_per_m2_rub` никаких границ не имеет. Но прежде чем ставить фильтр, замерил, что он изменит. Медиана устойчива к хвостам по построению — это и подтвердилось: ``` комплексов 310 из них с лотами вне диапазона 5 медиана изменилась бы 5 из них больше чем на 1% 0 максимальный сдвиг 456 ₽/м² (на медианах 100-150 тыс, т.е. ~0.3%) ``` Считал по **одиночным** комплексам — это самая жёсткая нарезка: реальный запрос требует ≥10 лотов и ≥2 ЖК в радиусе, объединение популяций только размывает выброс дальше. Вывод: фильтр здесь дал бы изменение ниже шума и ещё одну ветку в запросе. Оставляю как есть, пункт закрываю как проверенный. Если данные поедут (например, появится ЖК, где выбросы — заметная доля лотов), сигналом станет `with_outliers`; сейчас это 5 комплексов из 310. Отмечаю в чеклисте оба пункта. ### D3 — `parcel_financial.py` `max_building_pct` без валидации Ещё не смотрел, остаётся открытым. --- Заодно из соседних кластеров, тоже с замерами: - **G**, `velocity.py` фильтр класса ссылался на алиас `o`, которого нет в CTE → прод-EXPLAIN даёт `missing FROM-clause entry for table "o"`. Ветка мёртвая (никто не передаёт `obj_class`), но упала бы молча — исключение глотает `except`, и блок темпа продаж просто исчез бы из отчёта. PR #2865. - **H**, шесть комментариев `beat_schedule.py` считали сдвиг МСК дважды. По логам beat задача уходит в 21:30 UTC = 00:30 МСК, а комментарий обещал 03:30 МСК. Расписание НЕ трогаю (03:30 МСК завело бы ETL в окно `newbuilding_enrich`, с которым он делит источник) — правлю подпись под факт. PR #2866. - **B**, `best_layouts.py` подставлял `0 ₽/м²` там, где сделок за окно не было, хотя схема уже объявляла `float | None` и Python уже умел None — `COALESCE` делал честную ветку недостижимой. PR #2868. Площадь в том же месте требует правки контракта → вынес в #2867.
Author
Collaborator

Перепроверка всех оставшихся [H]-находок — 19 штук, каждая с замером и разбором скептика

Находки датированы 07.07, прошло пять недель. Прогнал их заново против main@53bb769e
и живого прода: 7 проверяющих + 7 скептиков + сверка. Скептику ставилась задача
опровергнуть, и он возвращал ДВА раздельных бита — устоял ли ВЫВОД и годно ли
ОБОСНОВАНИЕ (вывод бывает верным при негодном доводе, склеивать нельзя).

вывод устоял            19 из 19
обоснование негодно      7 из 19  ← вывод тот же, доводы переписаны
вердикты                 подтверждено 11 · переформулировано 7 · неприменимо 1
серьёзность сейчас       средняя 5 · низкая 10 · никакой 4
размер правки            S 15 · M 2 · не чинить 2

Ни одна находка не оказалась выдумкой. Но у семи обоснование пришлось переписать
чаще всего потому, что замер делался не по той популяции или «ноль» объявлялся
результатом без названной причины.

Чинить в первую очередь (по видимости × размеру)

Находка Где Что Размер
E-nspd-1 nspd_client.py:610-613 grid-walk глушит КАЖДОЕ per-cell исключение → WAF-бан слоя неотличим от «здесь зон нет». Прод: territorial_zones_count=0 у 124 из 669 дампов, из них у 50 legacy-слой сработал, а grid — нет. Близнец nspd_bulk_client.py:519-579 уже починен — отзеркалить S
G-doc-drift-2-2 nspd_client.py:844 та же дыра, вид сбоку: докстрока обещает атомарность, которой нет для grid-слоёв. Чинится тем же PR S
F-doc-drift-1-3 report_assembler.py:216-223 «главный sparse-риск domrf↔objective» кормится посторонней метрикой. Прод: 1998 из 1998 форсайт-ранов идут через fallback, из штатного слота — 0. Убрать fallback → честное «неизвестно» S
A-session-2 bulk_harvest.py:402 голый except Exception глушит NspdBulkWafError вопреки контракту harvest_quarter. Тот же дефект ещё на :185 и :282 S
B-caps-1 eesk_reserve_loader.py:239-242 числовой процент пишется в категориальную load_index (open/limited/closed). Прод: 3416 строк, open 2741 / limited 346 / closed 329 — числовых пока нет, но лоадер их запишет S
B-caps-3 photos.py:74-124 сессия из пула (5+10 на весь API) держится весь внешний HTTP к ДОМ.РФ. Корень глубже: ленивому кэшу физически некуда писать — /app/data в контейнере нет M

Не чинить — с причиной и числом

Находка Почему
E-nspd-3 quarter_dump_lookup.py:772 Заявленный баг не воспроизводится на прод-PostGIS 3.4.3: 500 пар полигонов, обе формы отработали и совпали до 1e-6. Плюс risks_count=0 у 669/669 дампов. Унификация под соседнюю функцию ухудшила бы точность
D-exporters-1 full_report_html.py:462 Терять нечего: pzz_zones_ekb0 строк с 15.06. Ленивый ход — удалить мёртвую legacy-ветку в обоих экспортёрах
D-exporters-2 domrf_catalog.py:80 Регекс правда ловит «нереализованных»→sold, воспроизвёл. Но скрапер выключен: catalog_updated_at0 из 983 088 квартир. Правка на один \b, когда снимут WAF-паузу
G-doc-drift-2-1 ekb_ppt_tep_parser.py:72 Докстрока обещает фильтр оглавления, которого нет. Подгонять эвристику не на чем: ekb_ppt_tep0 строк, PDF-образцов нет
E-nspd-2 page_reservation_parser.py:129 Дефект есть, но ветка не выполняется ни по одному расписанию: land_reservation — 270 строк, все изъятие, «резервирование» — 0 за всю историю. Правку нельзя отличить от no-op

Отдельно: находка, где правка «по тексту аудита» навредила бы

A-session-3 objective_backfill.py:473-479. Аудит предлагал авто-резолвить
неоднозначного кандидата в «единственное свободное имя». Проверяющий прогнал реальную
функцию на проде: такой авто-резолв сгенерировал бы три заведомо неверных маппинга
(ЛСР↔Голос, Формула Строительства↔Брусника, TEN↔Nova). Дефект в коде есть, но
рекомендация аудита — вредная.

Чего проверка не смогла

  • Частоту срабатывания латентных путей (admin_scrape.py:194, :214) — ретенция
    логов равна времени жизни контейнера, а контейнеры пересозданы сегодня в 12:31.
    Инструмент слеп по построению, это не «ноль».
  • Синтезирующий агент 15-й по счёту завис (6 попыток без прогресса) — сводку выше
    собрал сам из результатов 14 отработавших. Данные все на месте.

Отмечу в чеклисте только то, что реально закрыто правками; остальное остаётся открытым
с уточнёнными формулировками и приоритетом.

## Перепроверка всех оставшихся [H]-находок — 19 штук, каждая с замером и разбором скептика Находки датированы 07.07, прошло пять недель. Прогнал их заново против `main@53bb769e` и живого прода: 7 проверяющих + 7 скептиков + сверка. Скептику ставилась задача **опровергнуть**, и он возвращал ДВА раздельных бита — устоял ли ВЫВОД и годно ли ОБОСНОВАНИЕ (вывод бывает верным при негодном доводе, склеивать нельзя). ``` вывод устоял 19 из 19 обоснование негодно 7 из 19 ← вывод тот же, доводы переписаны вердикты подтверждено 11 · переформулировано 7 · неприменимо 1 серьёзность сейчас средняя 5 · низкая 10 · никакой 4 размер правки S 15 · M 2 · не чинить 2 ``` Ни одна находка не оказалась выдумкой. Но **у семи обоснование пришлось переписать** — чаще всего потому, что замер делался не по той популяции или «ноль» объявлялся результатом без названной причины. ## Чинить в первую очередь (по видимости × размеру) | Находка | Где | Что | Размер | |---|---|---|---| | E-nspd-1 | `nspd_client.py:610-613` | grid-walk глушит КАЖДОЕ per-cell исключение → WAF-бан слоя неотличим от «здесь зон нет». Прод: `territorial_zones_count=0` у **124 из 669** дампов, из них у **50** legacy-слой сработал, а grid — нет. Близнец `nspd_bulk_client.py:519-579` уже починен — отзеркалить | S | | G-doc-drift-2-2 | `nspd_client.py:844` | та же дыра, вид сбоку: докстрока обещает атомарность, которой нет для grid-слоёв. Чинится тем же PR | S | | F-doc-drift-1-3 | `report_assembler.py:216-223` | «главный sparse-риск domrf↔objective» кормится посторонней метрикой. Прод: **1998 из 1998** форсайт-ранов идут через fallback, из штатного слота — **0**. Убрать fallback → честное «неизвестно» | S | | A-session-2 | `bulk_harvest.py:402` | голый `except Exception` глушит `NspdBulkWafError` вопреки контракту `harvest_quarter`. Тот же дефект ещё на `:185` и `:282` | S | | B-caps-1 | `eesk_reserve_loader.py:239-242` | числовой процент пишется в категориальную `load_index` (`open`/`limited`/`closed`). Прод: 3416 строк, `open 2741 / limited 346 / closed 329` — числовых пока нет, но лоадер их запишет | S | | B-caps-3 | `photos.py:74-124` | сессия из пула (5+10 на весь API) держится весь внешний HTTP к ДОМ.РФ. Корень глубже: ленивому кэшу физически некуда писать — `/app/data` в контейнере нет | M | ## Не чинить — с причиной и числом | Находка | Почему | |---|---| | E-nspd-3 `quarter_dump_lookup.py:772` | **Заявленный баг не воспроизводится** на прод-PostGIS 3.4.3: 500 пар полигонов, обе формы отработали и совпали до 1e-6. Плюс `risks_count=0` у 669/669 дампов. Унификация под соседнюю функцию ухудшила бы точность | | D-exporters-1 `full_report_html.py:462` | Терять нечего: `pzz_zones_ekb` — **0 строк** с 15.06. Ленивый ход — удалить мёртвую legacy-ветку в обоих экспортёрах | | D-exporters-2 `domrf_catalog.py:80` | Регекс правда ловит «нереализованных»→sold, воспроизвёл. Но скрапер выключен: `catalog_updated_at` — **0 из 983 088** квартир. Правка на один `\b`, когда снимут WAF-паузу | | G-doc-drift-2-1 `ekb_ppt_tep_parser.py:72` | Докстрока обещает фильтр оглавления, которого нет. Подгонять эвристику не на чем: `ekb_ppt_tep` — **0 строк**, PDF-образцов нет | | E-nspd-2 `page_reservation_parser.py:129` | Дефект есть, но ветка не выполняется ни по одному расписанию: `land_reservation` — 270 строк, **все** `изъятие`, «резервирование» — 0 за всю историю. Правку нельзя отличить от no-op | ## Отдельно: находка, где правка «по тексту аудита» навредила бы **A-session-3** `objective_backfill.py:473-479`. Аудит предлагал авто-резолвить неоднозначного кандидата в «единственное свободное имя». Проверяющий прогнал реальную функцию на проде: такой авто-резолв сгенерировал бы **три заведомо неверных маппинга** (ЛСР↔Голос, Формула Строительства↔Брусника, TEN↔Nova). Дефект в коде есть, но рекомендация аудита — вредная. ## Чего проверка не смогла - **Частоту** срабатывания латентных путей (`admin_scrape.py:194`, `:214`) — ретенция логов равна времени жизни контейнера, а контейнеры пересозданы сегодня в 12:31. Инструмент слеп по построению, это не «ноль». - Синтезирующий агент 15-й по счёту завис (6 попыток без прогресса) — сводку выше собрал сам из результатов 14 отработавших. Данные все на месте. Отмечу в чеклисте только то, что реально закрыто правками; остальное остаётся открытым с уточнёнными формулировками и приоритетом.
lekss361 added the
priority/p1
scope/backend
site-finder
tech-debt
labels 2026-08-16 10:25:06 +00:00
Author
Collaborator

Находка кластера G: отрицания в регексе статуса — правку НЕ делаю, ноль заблокирован выше

domrf_catalog.py:80 _STATUS_KW_RE не различает утверждение и отрицание. Паттерн реализован[аоы]? не имеет границы слева, поэтому:

текст на странице что распознает что значит на самом деле
«нереализованных квартир: 5» sold свободны
«не продан» sold свободна
«свободных нет» free занята

Опаснее всего на «Уровне 3» (parse_catalog_flat, строки 563-579) — там перебираются все блоки страницы, и первое же совпадение sold делает break. Достаточно одного счётчика «нереализованных» в сайдбаре, чтобы вся квартира стала проданной.

Почему правки нет

Замер на проде (19.08):

всего квартир KN            983 088
с catalog_url_hash                0   ← входной SELECT парсера
со статусом                  29 419   ← пишет domrf_kn.py:667 (JSON API), не этот парсер

Парсер каталога ни разу не отработал: его SELECT (WHERE catalog_url_hash IS NOT NULL) возвращает ноль строк, а в beat_schedule.py:436 он закомментирован после WAF hard-ban ДОМ.РФ на IP сервера (#2443). Статусы в таблице пришли из JSON-свипа, где регекса нет вообще.

То есть дефект реальный, но популяция затронутых строк — ноль, и останется нулём, пока не выполнятся два условия. Чинить сейчас — менять код, который нельзя ни запустить, ни проверить: тест был бы зелёным по построению, а не потому что механизм работает.

Критерий, когда это станет работой

Включать правку в тот же заход, что и расписание — не раньше и не позже:

-- перестало быть нулём → регекс снова на боевом пути
SELECT count(*) FROM domrf_kn_flats WHERE catalog_url_hash IS NOT NULL;

Фикс на будущее — отрицание перед ключевым словом и граница слева: (?<![а-яё]) плюс отбрасывание совпадений, которым предшествует не / нет в пределах пары слов. Проверять на реальном сохранённом HTML, не на синтетике.

### Находка кластера G: отрицания в регексе статуса — **правку НЕ делаю, ноль заблокирован выше** `domrf_catalog.py:80` `_STATUS_KW_RE` не различает утверждение и отрицание. Паттерн `реализован[аоы]?` не имеет границы слева, поэтому: | текст на странице | что распознает | что значит на самом деле | |---|---|---| | «**не**реализованных квартир: 5» | `sold` | свободны | | «**не** продан» | `sold` | свободна | | «свободных **нет**» | `free` | занята | Опаснее всего на «Уровне 3» (`parse_catalog_flat`, строки 563-579) — там перебираются **все** блоки страницы, и первое же совпадение `sold` делает `break`. Достаточно одного счётчика «нереализованных» в сайдбаре, чтобы вся квартира стала проданной. ### Почему правки нет Замер на проде (19.08): ``` всего квартир KN 983 088 с catalog_url_hash 0 ← входной SELECT парсера со статусом 29 419 ← пишет domrf_kn.py:667 (JSON API), не этот парсер ``` Парсер каталога **ни разу не отработал**: его SELECT (`WHERE catalog_url_hash IS NOT NULL`) возвращает ноль строк, а в `beat_schedule.py:436` он закомментирован после WAF hard-ban ДОМ.РФ на IP сервера (#2443). Статусы в таблице пришли из JSON-свипа, где регекса нет вообще. То есть дефект реальный, но популяция затронутых строк — ноль, и останется нулём, пока не выполнятся **два** условия. Чинить сейчас — менять код, который нельзя ни запустить, ни проверить: тест был бы зелёным по построению, а не потому что механизм работает. ### Критерий, когда это станет работой Включать правку в тот же заход, что и расписание — не раньше и не позже: ```sql -- перестало быть нулём → регекс снова на боевом пути SELECT count(*) FROM domrf_kn_flats WHERE catalog_url_hash IS NOT NULL; ``` Фикс на будущее — отрицание перед ключевым словом и граница слева: `(?<![а-яё])` плюс отбрасывание совпадений, которым предшествует `не` / `нет` в пределах пары слов. Проверять на реальном сохранённом HTML, не на синтетике.
Author
Collaborator

Сплошная сверка реестра с кодом (19.08): устарели 4 отметки из 59

Прогнал все 59 открытых пунктов через проверку «воспроизводится ли дефект в коде сегодня», каждый вердикт «уже починено» отдельно пытался опровергнуть скептик, читавший код заново.

сверено                     59
устарело (было закрыто)      4   (6.8%)
вердикт «починено» опровергнут  0
остаются открытыми          55
неясных                      0

Учёт оказался точнее, чем я ожидал: расхождение всего на 4 позиции, и все четыре — в безопасную сторону (числились открытыми, фактически закрыты). Реестр ни разу не соврал в опасную: ни одна отметка не была бы закрыта ошибочно.

Проставлено 3 галочки

  • site_finder/eesk_reserve_loader.py:239fe019f26 (14.08). Проверил сам: записи в load_index не осталось, только комментарии, объясняющие снятие.
  • etl/objective_backfill.py:697 → PR #2929. Половина исходной находки; core-pass остаётся открытым отдельным пунктом (обоснование замером — в комментарии к PR).
  • exporters/full_report_html.py:1127закрыт как невалидный, а не как починенный. _fmt шкалу не меняет, значение уже округлено в market_metrics.py:136, единицу несёт подпись строки; предложенная в находке правка дала бы дубль «%». Это первый пункт эпика, оказавшийся неверным по существу — шапка эпика утверждала, что таких нет.

Одну галочку НЕ ставлю

api/v1/parcels.py:2381 проверка тоже показала закрытым — но потому, что читала моё рабочее дерево. Правка живёт в PR #2931, который ещё не смержен. Поставить галочку сейчас значило бы записать в реестр несуществующее состояние. Отмечу после мержа.

Побочные утверждения проверки, которые не подтвердились

Проверка сообщила, что objective_backfill.py:704 документирует причину отказа all_candidates_taken, которая «нигде не выставляется». Это неверно — эмиттер стоит в origin/main:910. Проверил лично, потому что речь шла о только что смерженном коде.

Второе побочное: full_report_html.py:979/987 якобы даёт дубль единицы «%». Фактически заголовок столбца «Свободно, %» и ячейка «18.4%» — избыточно, но не ошибка. Заводить не стал.

Пять самых серьёзных из оставшихся 55

  1. site_finder/quarter_dump_lookup.py:767ST_Intersection с обоими операндами в ::geography, ровно тот баг PostGIS, который соседний _get_red_lines в этом же файле документирует и обходит. Цена: риск-зоны участка падают в ошибку или пустоту, §6 отчёта молча без пересечений ЗОУИТ.
  2. scrapers/domrf_catalog.py:80 — статусный регекс без отрицаний. Цена: инверсия статуса квартир. Отдельно отмечу: правку сюда делать рано, замер выше в этой задаче показал ноль строк на проде и блокировку WAF-баном.
  3. exporters/full_report_html.py:460 — fallback валидирует zoning, а таблицу строит из пустого nspd_zoning. Цена: реальные данные ПЗЗ выбрасываются молча.
  4. etl/objective_backfill.py:469 — core-pass решает «неоднозначно» до сверки с занятыми. Цена: недосбор связок Объектива; оставлен сознательно, обоснование в #2929.
  5. services/job_settings.py:142except Exception → fallback без db.rollback(). Цена: тема самого эпика, чужая сессия остаётся в aborted-tx.

Чего сверке не хватило

  • Вердикты сняты по коду рабочего дерева. Ни один из закрытых не подтверждён поведением живого контейнера — только исходником и git log -S.
  • best_layouts.py:195 одной галочкой не закрывается: цена починена PR #2868, площадь вынесена в #2867. Отметку нужно разбивать.
  • У 55 открытых нет привязки к issue или PR — «кто чинит» не определено ни по одной позиции.
## Сплошная сверка реестра с кодом (19.08): устарели 4 отметки из 59 Прогнал все 59 открытых пунктов через проверку «воспроизводится ли дефект в коде сегодня», каждый вердикт «уже починено» отдельно пытался опровергнуть скептик, читавший код заново. ``` сверено 59 устарело (было закрыто) 4 (6.8%) вердикт «починено» опровергнут 0 остаются открытыми 55 неясных 0 ``` Учёт оказался точнее, чем я ожидал: расхождение всего на 4 позиции, и все четыре — в **безопасную** сторону (числились открытыми, фактически закрыты). Реестр ни разу не соврал в опасную: ни одна отметка не была бы закрыта ошибочно. ### Проставлено 3 галочки - `site_finder/eesk_reserve_loader.py:239` → `fe019f26` (14.08). Проверил сам: записи в `load_index` не осталось, только комментарии, объясняющие снятие. - `etl/objective_backfill.py:697` → PR #2929. Половина исходной находки; core-pass остаётся открытым отдельным пунктом (обоснование замером — в комментарии к PR). - `exporters/full_report_html.py:1127` → **закрыт как невалидный, а не как починенный**. `_fmt` шкалу не меняет, значение уже округлено в `market_metrics.py:136`, единицу несёт подпись строки; предложенная в находке правка дала бы дубль «%». Это первый пункт эпика, оказавшийся неверным по существу — шапка эпика утверждала, что таких нет. ### Одну галочку НЕ ставлю `api/v1/parcels.py:2381` проверка тоже показала закрытым — но потому, что читала моё рабочее дерево. Правка живёт в PR #2931, который **ещё не смержен**. Поставить галочку сейчас значило бы записать в реестр несуществующее состояние. Отмечу после мержа. ### Побочные утверждения проверки, которые не подтвердились Проверка сообщила, что `objective_backfill.py:704` документирует причину отказа `all_candidates_taken`, которая «нигде не выставляется». **Это неверно** — эмиттер стоит в `origin/main:910`. Проверил лично, потому что речь шла о только что смерженном коде. Второе побочное: `full_report_html.py:979/987` якобы даёт дубль единицы «%». Фактически заголовок столбца «Свободно, %» и ячейка «18.4%» — избыточно, но не ошибка. Заводить не стал. ### Пять самых серьёзных из оставшихся 55 1. `site_finder/quarter_dump_lookup.py:767` — `ST_Intersection` с обоими операндами в `::geography`, ровно тот баг PostGIS, который соседний `_get_red_lines` в этом же файле документирует и обходит. **Цена:** риск-зоны участка падают в ошибку или пустоту, §6 отчёта молча без пересечений ЗОУИТ. 2. `scrapers/domrf_catalog.py:80` — статусный регекс без отрицаний. **Цена:** инверсия статуса квартир. Отдельно отмечу: правку сюда делать рано, замер выше в этой задаче показал ноль строк на проде и блокировку WAF-баном. 3. `exporters/full_report_html.py:460` — fallback валидирует `zoning`, а таблицу строит из пустого `nspd_zoning`. **Цена:** реальные данные ПЗЗ выбрасываются молча. 4. `etl/objective_backfill.py:469` — core-pass решает «неоднозначно» до сверки с занятыми. **Цена:** недосбор связок Объектива; оставлен сознательно, обоснование в #2929. 5. `services/job_settings.py:142` — `except Exception` → fallback без `db.rollback()`. **Цена:** тема самого эпика, чужая сессия остаётся в aborted-tx. ### Чего сверке не хватило - Вердикты сняты по коду рабочего дерева. Ни один из закрытых не подтверждён поведением живого контейнера — только исходником и `git log -S`. - `best_layouts.py:195` одной галочкой не закрывается: цена починена PR #2868, площадь вынесена в #2867. Отметку нужно разбивать. - У 55 открытых нет привязки к issue или PR — «кто чинит» не определено ни по одной позиции.
Author
Collaborator

Мутационная проверка кластера A: 4 из 11 защит не сторожатся тестами

Пункты кластера A отмечены закрытыми. Проверил не «есть ли begin_nested в коде», а упадёт ли хоть один тест, если его снять. Метод: временно удалить with db.begin_nested(): с де-индентацией тела, прогнать тесты, упоминающие модуль, вернуть исходник.

место тесты без SAVEPOINT вывод
developer_attribution.py rc=1, 1 failed сторожится
competitors.py rc=1, 1 failed сторожится
supply_layers.py rc=1, 1 failed сторожится
forecasting/orchestrator.py rc=1, 1 failed сторожится
forecasting/macro_series.py rc=1, 1 failed сторожится
forecasting/special_indices.py rc=1, 1 failed сторожится
forecasting/sales_series.py rc=1, 1 failed сторожится
saturation.py rc=0, 19 passed не сторожится
zone_regulation.py rc=0, 39 passed не сторожится
pat_lookup.py rc=0, 6 passed не сторожится
connection_capacity_lookup.py rc=0, 107 passed не сторожится

Что это значит и чего НЕ значит

Код везде на месте — я снимал защиту временно и возвращал, все исходники восстановлены (git status чист). Отметки о закрытии верны: дефект исправлен во всех одиннадцати.

Не сторожатся именно регрессии. У четырёх мест любой будущий рефакторинг может снять SAVEPOINT, и CI промолчит.

Почему так вышло

Причина видна в tests/test_saturation.py:143-162: мок begin_nested — пустой контекст-менеджер, у сессии нет aborted-состояния. Поэтому второй execute в тесте проходит независимо от того, обёрнут первый в SAVEPOINT или нет. Тест написан аккуратно и читается убедительно — комментарий в нём даже объясняет, что на реальном Postgres было бы иначе, — но проверить он может только то, что исключение не проглотилось.

Это не упрёк автору: без aborted-состояния в моке такую проверку и не сделать, а поднимать Postgres в юнит-тесте дорого.

Рабочий приём

В PR #2937 (job_settings, последний открытый пункт кластера) мок воспроизводит семантику: упавший запрос переводит сессию в aborted, дальнейшие падают, откат SAVEPOINT восстанавливает. Плюс отдельный тест на сам мок — он требует, чтобы без SAVEPOINT мок обязательно отравлялся. Без этой проверки основные тесты были бы зелёными по построению.

Тот же мок можно перенести в четыре беззубых места — правка тестовая, кода не касается.

Предложение по учёту

Отметка [x] сейчас означает «код написан». Для кластера A это недостаточно: смысл пункта — чтобы защита работала и продолжала работать. Предлагаю считать пункт закрытым только когда снятие защиты роняет тест, и завести это отдельной задачей на четыре места.

## Мутационная проверка кластера A: 4 из 11 защит не сторожатся тестами Пункты кластера A отмечены закрытыми. Проверил не «есть ли `begin_nested` в коде», а **упадёт ли хоть один тест, если его снять**. Метод: временно удалить `with db.begin_nested():` с де-индентацией тела, прогнать тесты, упоминающие модуль, вернуть исходник. | место | тесты без SAVEPOINT | вывод | |---|---|---| | `developer_attribution.py` | rc=1, 1 failed | сторожится | | `competitors.py` | rc=1, 1 failed | сторожится | | `supply_layers.py` | rc=1, 1 failed | сторожится | | `forecasting/orchestrator.py` | rc=1, 1 failed | сторожится | | `forecasting/macro_series.py` | rc=1, 1 failed | сторожится | | `forecasting/special_indices.py` | rc=1, 1 failed | сторожится | | `forecasting/sales_series.py` | rc=1, 1 failed | сторожится | | **`saturation.py`** | rc=0, **19 passed** | **не сторожится** | | **`zone_regulation.py`** | rc=0, **39 passed** | **не сторожится** | | **`pat_lookup.py`** | rc=0, **6 passed** | **не сторожится** | | **`connection_capacity_lookup.py`** | rc=0, **107 passed** | **не сторожится** | ### Что это значит и чего НЕ значит **Код везде на месте** — я снимал защиту временно и возвращал, все исходники восстановлены (`git status` чист). Отметки о закрытии верны: дефект исправлен во всех одиннадцати. Не сторожатся именно **регрессии**. У четырёх мест любой будущий рефакторинг может снять SAVEPOINT, и CI промолчит. ### Почему так вышло Причина видна в `tests/test_saturation.py:143-162`: мок `begin_nested` — пустой контекст-менеджер, у сессии нет aborted-состояния. Поэтому второй `execute` в тесте проходит независимо от того, обёрнут первый в SAVEPOINT или нет. Тест написан аккуратно и читается убедительно — комментарий в нём даже объясняет, что на реальном Postgres было бы иначе, — но проверить он может только то, что исключение не проглотилось. Это не упрёк автору: без aborted-состояния в моке такую проверку и не сделать, а поднимать Postgres в юнит-тесте дорого. ### Рабочий приём В PR #2937 (`job_settings`, последний открытый пункт кластера) мок **воспроизводит** семантику: упавший запрос переводит сессию в `aborted`, дальнейшие падают, откат SAVEPOINT восстанавливает. Плюс отдельный тест на сам мок — он требует, чтобы **без** SAVEPOINT мок обязательно отравлялся. Без этой проверки основные тесты были бы зелёными по построению. Тот же мок можно перенести в четыре беззубых места — правка тестовая, кода не касается. ### Предложение по учёту Отметка `[x]` сейчас означает «код написан». Для кластера A это недостаточно: смысл пункта — чтобы защита работала и продолжала работать. Предлагаю считать пункт закрытым только когда снятие защиты роняет тест, и завести это отдельной задачей на четыре места.
Author
Collaborator

Поправка к предыдущему комментарию: метод был неточен

В таблице выше я снимал первый with db.begin_nested(): в файле, а не тот, о котором говорит находка. Для файлов с несколькими SAVEPOINT это делает вывод недействительным: тест вида assert db.begin_nested.call_count >= 1 переживёт снятие одного из нескольких и промолчит.

Так и оказалось у connection_capacity_lookup.py: у него есть специальный тест tests/services/site_finder/test_connection_capacity_savepoint.py::test_uses_begin_nested_savepoint, который проверяет ровно этот вызов. Моя пометка «не сторожится» там почти наверняка неверна — я снял не тот SAVEPOINT.

Перемеряю строго: снимаю все begin_nested в файле. Что уже подтверждено на строгом методе:

  • saturation.py — вывод устоял. Снятие ЕДИНСТВЕННОГО SAVEPOINT оставляет 465 тестов зелёными (прогон по всем шести файлам, упоминающим модуль).
  • pat_lookup.py — ни один тест-файл вообще не проверяет вызов begin_nested; строгий прогон идёт.
  • zone_regulation.py, connection_capacity_lookup.py — считаются, числа допишу.

Отдельная находка, независимая от пересчёта: call_count >= 1 — слабое утверждение. Оно говорит «хоть один SAVEPOINT был», но не «нужный запрос обёрнут». В файле с несколькими защищёнными местами такой тест не заметит, что защиту сняли ровно с того, ради которого он написан. Точнее было бы проверять, что конкретный execute идёт внутри savepoint'а, либо считать точное число.

Вывод, который НЕ меняется: код везде на месте, речь только о защите от регрессий. Но «4 из 11» — число, которое я обязан пересчитать, и до пересчёта на него опираться нельзя.

## Поправка к предыдущему комментарию: метод был неточен В таблице выше я снимал **первый** `with db.begin_nested():` в файле, а не тот, о котором говорит находка. Для файлов с несколькими SAVEPOINT это делает вывод недействительным: тест вида `assert db.begin_nested.call_count >= 1` переживёт снятие одного из нескольких и промолчит. Так и оказалось у `connection_capacity_lookup.py`: у него есть **специальный** тест `tests/services/site_finder/test_connection_capacity_savepoint.py::test_uses_begin_nested_savepoint`, который проверяет ровно этот вызов. Моя пометка «не сторожится» там почти наверняка неверна — я снял не тот SAVEPOINT. Перемеряю строго: снимаю **все** `begin_nested` в файле. Что уже подтверждено на строгом методе: - **`saturation.py` — вывод устоял.** Снятие ЕДИНСТВЕННОГО SAVEPOINT оставляет **465 тестов зелёными** (прогон по всем шести файлам, упоминающим модуль). - **`pat_lookup.py`** — ни один тест-файл вообще не проверяет вызов `begin_nested`; строгий прогон идёт. - `zone_regulation.py`, `connection_capacity_lookup.py` — считаются, числа допишу. Отдельная находка, независимая от пересчёта: **`call_count >= 1` — слабое утверждение**. Оно говорит «хоть один SAVEPOINT был», но не «нужный запрос обёрнут». В файле с несколькими защищёнными местами такой тест не заметит, что защиту сняли ровно с того, ради которого он написан. Точнее было бы проверять, что конкретный `execute` идёт внутри savepoint'а, либо считать точное число. Вывод, который НЕ меняется: код везде на месте, речь только о защите от регрессий. Но «4 из 11» — число, которое я обязан пересчитать, и до пересчёта на него опираться нельзя.
Author
Collaborator

Строгий пересчёт: не 4, а 3. И одна из моих пометок была неверной

Снимал все with db.begin_nested(): в файле (не первый попавшийся), прогонял все тест-файлы, упоминающие модуль, возвращал исходник. Дерево после прогона чистое.

место снято SAVEPOINT результат вывод
saturation.py 1 (все) rc=0, 465 passed не сторожится
zone_regulation.py 1 (все) rc=0, 195 passed не сторожится
pat_lookup.py 1 (все) rc=0, 21 passed не сторожится
connection_capacity_lookup.py 5 (все) rc=1, 4 failed сторожится

connection_capacity_lookup я пометил ошибочно. У него пять SAVEPOINT и специальный тест test_connection_capacity_savepoint.py; мой первый прогон снял один из пяти, call_count >= 1 это пережил, и я записал файл в беззащитные. Строгий прогон это опроверг: снятие всех пяти роняет 4 теста.

Итог по кластеру A: 7 сторожатся, 3 нет, 1 (мой) исправлен и сторожится — то есть из 11 закрытых пунктов регрессии не отслеживаются у трёх.

Что при этом всё равно верно про connection_capacity_lookup

Снятие одного SAVEPOINT из пяти проходит незамеченным. assert db.begin_nested.call_count >= 1 отвечает на вопрос «был ли хоть один», а не «обёрнут ли нужный запрос». Файл защищён от полного отката правки и не защищён от точечного — а точечный как раз и вероятен при рефакторинге одной функции.

Это не ошибка автора теста: с MagicMock без aborted-состояния сильнее и не сделать.

Что делаю дальше

Переношу в три беззащитных места мок из PR #2937 — тот, что воспроизводит семантику Postgres (упавший запрос → aborted; дальнейшие падают; выход из SAVEPOINT с исключением снимает aborted), вместе с контролем на сам мок. Правка тестовая, кода не касается.

Для connection_capacity_lookup отдельно заменю call_count >= 1 на проверку, что конкретный execute идёт внутри savepoint'а.

## Строгий пересчёт: не 4, а 3. И одна из моих пометок была неверной Снимал **все** `with db.begin_nested():` в файле (не первый попавшийся), прогонял все тест-файлы, упоминающие модуль, возвращал исходник. Дерево после прогона чистое. | место | снято SAVEPOINT | результат | вывод | |---|---:|---|---| | `saturation.py` | 1 (все) | rc=0, **465 passed** | не сторожится | | `zone_regulation.py` | 1 (все) | rc=0, **195 passed** | не сторожится | | `pat_lookup.py` | 1 (все) | rc=0, **21 passed** | не сторожится | | `connection_capacity_lookup.py` | **5 (все)** | rc=1, **4 failed** | **сторожится** | **`connection_capacity_lookup` я пометил ошибочно.** У него пять SAVEPOINT и специальный тест `test_connection_capacity_savepoint.py`; мой первый прогон снял один из пяти, `call_count >= 1` это пережил, и я записал файл в беззащитные. Строгий прогон это опроверг: снятие всех пяти роняет 4 теста. Итог по кластеру A: **7 сторожатся, 3 нет, 1 (мой) исправлен и сторожится** — то есть из 11 закрытых пунктов регрессии не отслеживаются у **трёх**. ### Что при этом всё равно верно про `connection_capacity_lookup` Снятие **одного** SAVEPOINT из пяти проходит незамеченным. `assert db.begin_nested.call_count >= 1` отвечает на вопрос «был ли хоть один», а не «обёрнут ли нужный запрос». Файл защищён от полного отката правки и не защищён от точечного — а точечный как раз и вероятен при рефакторинге одной функции. Это не ошибка автора теста: с `MagicMock` без aborted-состояния сильнее и не сделать. ### Что делаю дальше Переношу в три беззащитных места мок из PR #2937 — тот, что воспроизводит семантику Postgres (упавший запрос → `aborted`; дальнейшие падают; выход из SAVEPOINT с исключением снимает `aborted`), вместе с контролем на сам мок. Правка тестовая, кода не касается. Для `connection_capacity_lookup` отдельно заменю `call_count >= 1` на проверку, что конкретный `execute` идёт внутри savepoint'а.
Author
Collaborator

Замер по page_reservation_parser.py:129 — дефект реален, неверных строк не породил

_detect_kind ищет «резервир» и «изъят» несвязанным поиском по всему тексту, и первое слово побеждает независимо от того, о чём документ. То есть акт об изъятии ранее зарезервированного участка получил бы тип «резервирование».

Что в данных (land_reservation, 19.08):

тип        строк   участков
изъятие      297         27

Строк с типом «резервирование» — ноль. Если бы дефект срабатывал, они бы там были: ошибка направлена именно в эту сторону (резерв перебивает изъятие).

Проверка на неоднозначные акты по basis_act: 0 из 297 содержат оба слова. Оговорка: basis_act — это ссылка на акт, а не полный текст PDF, по которому работает _detect_kind; полный текст не сохраняется, поэтому прямо измерить долю неоднозначных документов нельзя.

Почему не чиню сейчас

Правка означала бы смену эвристики (по первому вхождению, по заголовку, по преамбуле) на выборке, где текущая эвристика не ошиблась ни разу из 297. Риск изменить верные строки есть, выигрыш не измерим.

Разумнее сначала сделать неоднозначность видимой: логировать случаи, когда в тексте есть оба слова. Тогда через какое-то время станет известно, встречается ли этот класс вообще, — и правка (если понадобится) будет опираться на числа, а не на предположение. Это отдельная маленькая задача, кодом парсера не рискующая.

Пункт оставляю открытым: дефект в коде есть, просто его цена сегодня равна нулю и это измерено, а не предположено.

### Замер по `page_reservation_parser.py:129` — дефект реален, неверных строк не породил `_detect_kind` ищет «резервир» и «изъят» несвязанным поиском по всему тексту, и первое слово побеждает независимо от того, о чём документ. То есть акт об изъятии ранее **зарезервированного** участка получил бы тип «резервирование». Что в данных (`land_reservation`, 19.08): ``` тип строк участков изъятие 297 27 ``` Строк с типом «резервирование» — **ноль**. Если бы дефект срабатывал, они бы там были: ошибка направлена именно в эту сторону (резерв перебивает изъятие). Проверка на неоднозначные акты по `basis_act`: 0 из 297 содержат оба слова. Оговорка: `basis_act` — это ссылка на акт, а не полный текст PDF, по которому работает `_detect_kind`; полный текст не сохраняется, поэтому прямо измерить долю неоднозначных документов нельзя. ### Почему не чиню сейчас Правка означала бы смену эвристики (по первому вхождению, по заголовку, по преамбуле) на выборке, где текущая эвристика не ошиблась ни разу из 297. Риск изменить верные строки есть, выигрыш не измерим. Разумнее сначала сделать неоднозначность **видимой**: логировать случаи, когда в тексте есть оба слова. Тогда через какое-то время станет известно, встречается ли этот класс вообще, — и правка (если понадобится) будет опираться на числа, а не на предположение. Это отдельная маленькая задача, кодом парсера не рискующая. Пункт оставляю открытым: дефект в коде есть, просто его цена сегодня равна нулю и это измерено, а не предположено.
Author
Collaborator

Сводка за 20.08.2026

Закрыто правками

место PR суть
parcels.py — два блока analyze_parcel #2949 глотали ошибку БД без SAVEPOINT
шесть lookup'ов site_finder #2951 то же; соседний ppt_tep_lookup SAVEPOINT имел
full_report_html.py:460 #2959 легаси-зонирование проверяли и тут же выбрасывали
domrf_catalog_object.py:440 #2960 весь батч в одной незакоммиченной транзакции

Прод-проверка по коду в контейнере: begin_nested в parcels.py 18 → 20, все шесть lookup'ов по 1 (у zone_regulation 3).

Отклонено — с замером, а не по впечатлению

quarter_dump_lookup.py:767 — «_get_risk_zones воспроизводит PostGIS 3.4 geography × geography transform error».

На боевом PostGIS 3.4.3 не воспроизводится:

ST_Area(ST_Intersection(polygon::geography, polygon::geography))     → 679552.38
ST_Length(ST_Intersection(linestring::geography, polygon::geography)) → 2538.97

Ловушка в комментарии соседнего _get_red_lines названа точнее, чем в пункте: она про LINESTRING, а риск-слои полигональные. И главное — функция ни разу не получала данных: риск-слои дали ноль объектов по всем 669 дампам, при 4935 ЗОУИТ и 1337 инженерных. Подробный разбор в #2934.

Форма запроса действительно расходится с соседями по файлу (те делают пересечение в planar и кастуют результат). Привести к общему виду стоит — но как единообразие, а не как починку наблюдаемого дефекта, и не сейчас: пока слои пусты, правка непроверяема.

ekb_geoportal_client.py:217 — «WFS-ошибка приходит под HTTP 200 с ExceptionReport-подобным JSON и молча читается как „объектов нет“».

Проба с прода по четырём случаям:

валидный слой, есть зона   ключи=[crs, features, numberMatched, …]  features=1
валидный слой, пусто       те же ключи                              features=0
НЕсуществующий слой        JSONDecodeError (тело — XML, не JSON)
битый CQL                  JSONDecodeError

Ошибки не притворяются пустым результатом — они бросают исключение, которое выше ловится и логируется (parcels.py: logger.warning("zone regulation resolve failed …")). Описанный режим на этом геопортале не наступает.

Заодно проверил смежную гипотезу — тихую усечённость выдачи:

терзоны, весь ЕКБ   matched=5587  returned=5587  len=5587   полно
КРТ,     весь ЕКБ   matched=47    returned=47    len=47     полно
ППТ,     весь ЕКБ   matched=284   returned=284   len=284    полно

Тоже нет.

Побочно — две тихие остановки, найденные по дороге

  • НСПД не отдаёт данные с 27.07 — 24 суток, 61 дамп подряд с ошибкой (#2956). Сторож молчал, потому что каждый упавший harvest обновлял отметку свежести; починено в #2957.
  • Каталог DOM.РФ стоит с 19.05: 13200 объектов из 13801 ни разу не скрейплены. Проба 20.08 показала страницу «Доступ заблокирован [403]» с капчей — не cooldown-бан, ждать нечего (#2443).
## Сводка за 20.08.2026 ### Закрыто правками | место | PR | суть | |---|---|---| | `parcels.py` — два блока `analyze_parcel` | #2949 | глотали ошибку БД без SAVEPOINT | | шесть lookup'ов `site_finder` | #2951 | то же; соседний `ppt_tep_lookup` SAVEPOINT имел | | `full_report_html.py:460` | #2959 | легаси-зонирование проверяли и тут же выбрасывали | | `domrf_catalog_object.py:440` | #2960 | весь батч в одной незакоммиченной транзакции | Прод-проверка по коду в контейнере: `begin_nested` в `parcels.py` 18 → 20, все шесть lookup'ов по 1 (у `zone_regulation` 3). ### Отклонено — с замером, а не по впечатлению **`quarter_dump_lookup.py:767`** — «`_get_risk_zones` воспроизводит PostGIS 3.4 geography × geography transform error». На боевом PostGIS 3.4.3 не воспроизводится: ``` ST_Area(ST_Intersection(polygon::geography, polygon::geography)) → 679552.38 ST_Length(ST_Intersection(linestring::geography, polygon::geography)) → 2538.97 ``` Ловушка в комментарии соседнего `_get_red_lines` названа точнее, чем в пункте: она про LINESTRING, а риск-слои полигональные. И главное — функция ни разу не получала данных: риск-слои дали ноль объектов по всем 669 дампам, при 4935 ЗОУИТ и 1337 инженерных. Подробный разбор в #2934. Форма запроса действительно расходится с соседями по файлу (те делают пересечение в planar и кастуют результат). Привести к общему виду стоит — но как единообразие, а не как починку наблюдаемого дефекта, и не сейчас: пока слои пусты, правка непроверяема. **`ekb_geoportal_client.py:217`** — «WFS-ошибка приходит под HTTP 200 с ExceptionReport-подобным JSON и молча читается как „объектов нет“». Проба с прода по четырём случаям: ``` валидный слой, есть зона ключи=[crs, features, numberMatched, …] features=1 валидный слой, пусто те же ключи features=0 НЕсуществующий слой JSONDecodeError (тело — XML, не JSON) битый CQL JSONDecodeError ``` Ошибки не притворяются пустым результатом — они бросают исключение, которое выше ловится и логируется (`parcels.py`: `logger.warning("zone regulation resolve failed …")`). Описанный режим на этом геопортале не наступает. Заодно проверил смежную гипотезу — тихую усечённость выдачи: ``` терзоны, весь ЕКБ matched=5587 returned=5587 len=5587 полно КРТ, весь ЕКБ matched=47 returned=47 len=47 полно ППТ, весь ЕКБ matched=284 returned=284 len=284 полно ``` Тоже нет. ### Побочно — две тихие остановки, найденные по дороге - НСПД не отдаёт данные с 27.07 — 24 суток, 61 дамп подряд с ошибкой (#2956). Сторож молчал, потому что каждый упавший harvest обновлял отметку свежести; починено в #2957. - Каталог DOM.РФ стоит с 19.05: 13200 объектов из 13801 ни разу не скрейплены. Проба 20.08 показала страницу «Доступ заблокирован [403]» с капчей — не cooldown-бан, ждать нечего (#2443).
Author
Collaborator

Пункт competitors.py:553 — остановлен на замере, вынесен в #2962

Начал делать: _SOLD_COUNT_SQL действительно не имеет gap-fill, который есть у velocity и у цены. Скопировал мост, написал двусторонний тест на живой БД — зелено, на origin/main красно по нужной причине.

Померил на боевых данных перед выпуском:

было конкурентов с flats_sold   307
стало                           492  (+185)
изменившихся среди прежних       88  ← должно быть 0

Восемьдесят восемь прежних не должны были меняться. Разбор дал две вещи.

Первая — моя. При общем DISTINCT ON поверх обеих веток физлот с одинаковым ключом достаётся одной ветке, а вторая его теряет. Развёл дедуп по веткам — стало +185 / 0 изменившихся, как и должно.

Вторая — не моя, и она серьёзнее. Сам мост сломан: objective_lots.complex_id не связывает ЖК. На проде 28.7 project_name на один complex_id (максимум 156), под «ЖК Мичуринский» лежат лоты Малахита, Амундсена, Атлас Ривер — 119 чужих ЖК. Для 185 gap-fill конкурентов подтягивается в среднем 1470 лотов, своих из них 432 — раздутие 4×.

На этом мосту уже стоят живые скорость продаж и медианная цена. Мой PR стал бы третьим копированием (после #1615, где мост так же «закрыли пробел покрытия» в цене).

Ветку не выпускаю. Замеры и направление починки — в #2962. Пункт эпика остаётся открытым и блокируется тем issue: чинить надо мост, а не тиражировать его.

## Пункт `competitors.py:553` — остановлен на замере, вынесен в #2962 Начал делать: `_SOLD_COUNT_SQL` действительно не имеет gap-fill, который есть у velocity и у цены. Скопировал мост, написал двусторонний тест на живой БД — зелено, на `origin/main` красно по нужной причине. Померил на боевых данных перед выпуском: ``` было конкурентов с flats_sold 307 стало 492 (+185) изменившихся среди прежних 88 ← должно быть 0 ``` Восемьдесят восемь прежних не должны были меняться. Разбор дал две вещи. **Первая — моя.** При общем `DISTINCT ON` поверх обеих веток физлот с одинаковым ключом достаётся одной ветке, а вторая его теряет. Развёл дедуп по веткам — стало +185 / 0 изменившихся, как и должно. **Вторая — не моя, и она серьёзнее.** Сам мост сломан: `objective_lots.complex_id` не связывает ЖК. На проде 28.7 `project_name` на один `complex_id` (максимум 156), под «ЖК Мичуринский» лежат лоты Малахита, Амундсена, Атлас Ривер — 119 чужих ЖК. Для 185 gap-fill конкурентов подтягивается в среднем 1470 лотов, своих из них 432 — **раздутие 4×**. На этом мосту уже стоят живые скорость продаж и медианная цена. Мой PR стал бы третьим копированием (после #1615, где мост так же «закрыли пробел покрытия» в цене). **Ветку не выпускаю.** Замеры и направление починки — в #2962. Пункт эпика остаётся открытым и блокируется тем issue: чинить надо мост, а не тиражировать его.
Author
Collaborator

Пункт poi_score.py:141 — отклоняю по замеру

Утверждение: кандидаты отбираются ORDER BY distance ASC LIMIT top_n*10, поэтому POI с бо́льшим весом категории, но подальше, может не попасть в окно и выпасть из топ-7.

Теоретически верно: weight = cat_weight / (d + 100), отношение весов 6:1 (метро 6.0 против default 1.0), значит вшестеро более далёкий POI может законно обойти ближний.

На проде не срабатывает.

Замер 1 — меняется ли топ-7

Взял 800 центров в самых плотных местах (сами POI как точки отсчёта — там плотность максимальна), сравнил топ-7 с окном 70 и без окна:

центров, где окно реально срезает   683
топ-7 совпал                        683
топ-7 разошёлся                       0

Замер 2 — насколько близко к грани

Считал отношение «вес лучшего POI ЗА окном» / «вес 7-го в окне». Единица означала бы ровно грань:

максимум   0.750
95-й проц  0.627
медиана    0.462

Даже в худшем из 683 случаев лучший исключённый POI набирает три четверти порога. Запас четверть и больше — на грани не стоим.

Механизм понятен: чтобы обойти 7-го, внешнему POI нужно cat_weight/(d+100) > w7. В плотной застройке среди 70 ближайших почти всегда есть остановка или школа в сотне метров, и порог w7 оказывается выше, чем может дать метро с окраины окна.

Про вторую функцию из пункта

compute_poi_routing_decay:328 ограничивает кандидатов не десятикратным top_n, а ors_client.MAX_MATRIX_DESTINATIONS = 1000. Это важно, потому что там затухание мягче (у школ полный вес до ⅓ радиуса, у парков — ступенька на весь радиус), и к окну она чувствительнее.

Но окно там не срезает вообще никогда:

всего POI в osm_poi_ekb                4845
максимум POI в радиусе 2 км по ВСЕМ центрам   723
центров свыше 1000                        0

Итог

Правку не делаю: она изменила бы живое ранжирование ради случая, которого нет ни в одном из 683 срезающих центров, при запасе минимум 25 %. Если плотность POI заметно вырастет или веса категорий разъедутся сильнее чем 6:1, замер стоит повторить — критерий записан здесь.

## Пункт `poi_score.py:141` — отклоняю по замеру Утверждение: кандидаты отбираются `ORDER BY distance ASC LIMIT top_n*10`, поэтому POI с бо́льшим весом категории, но подальше, может не попасть в окно и выпасть из топ-7. Теоретически верно: `weight = cat_weight / (d + 100)`, отношение весов 6:1 (метро 6.0 против default 1.0), значит вшестеро более далёкий POI может законно обойти ближний. **На проде не срабатывает.** ### Замер 1 — меняется ли топ-7 Взял 800 центров в самых плотных местах (сами POI как точки отсчёта — там плотность максимальна), сравнил топ-7 с окном 70 и без окна: ``` центров, где окно реально срезает 683 топ-7 совпал 683 топ-7 разошёлся 0 ``` ### Замер 2 — насколько близко к грани Считал отношение «вес лучшего POI ЗА окном» / «вес 7-го в окне». Единица означала бы ровно грань: ``` максимум 0.750 95-й проц 0.627 медиана 0.462 ``` Даже в худшем из 683 случаев лучший исключённый POI набирает три четверти порога. Запас четверть и больше — на грани не стоим. Механизм понятен: чтобы обойти 7-го, внешнему POI нужно `cat_weight/(d+100) > w7`. В плотной застройке среди 70 ближайших почти всегда есть остановка или школа в сотне метров, и порог `w7` оказывается выше, чем может дать метро с окраины окна. ### Про вторую функцию из пункта `compute_poi_routing_decay:328` ограничивает кандидатов не десятикратным `top_n`, а `ors_client.MAX_MATRIX_DESTINATIONS = 1000`. Это важно, потому что там затухание **мягче** (у школ полный вес до ⅓ радиуса, у парков — ступенька на весь радиус), и к окну она чувствительнее. Но окно там не срезает вообще никогда: ``` всего POI в osm_poi_ekb 4845 максимум POI в радиусе 2 км по ВСЕМ центрам 723 центров свыше 1000 0 ``` ### Итог Правку не делаю: она изменила бы живое ранжирование ради случая, которого нет ни в одном из 683 срезающих центров, при запасе минимум 25 %. Если плотность POI заметно вырастет или веса категорий разъедутся сильнее чем 6:1, замер стоит повторить — критерий записан здесь.
Author
Collaborator

Пункт best_layouts.py:1113 — останавливаюсь, нужно решение о смысле поля

Расхождение реально, и оно шире, чем в пункте: веток три, а не две.

ветка «нет конкурентов после фильтра»  → groups_total_pre_filter   (ДО фильтра)
ветка «нет velocity-данных»            → len(complex_groups)       (ПОСЛЕ фильтра)
штатная ветка                          → groups_total_after_filter (ПОСЛЕ фильтра)

Я довёл правку до конца — привёл все три к пост-фильтровой величине, добавил гейт, прогнал. И откатил, потому что доказать, какая семантика верна, из кода нельзя, а число видит пользователь.

Что говорит за пост-фильтровое значение

Комментарий в самом коде: complex_groups — «знаменатель coverage», а покрытие считается по отфильтрованному набору. Две ветки из трёх так и делают.

Что говорит за до-фильтровое

Имя поля. objects_total_in_radius — «всего объектов в радиусе»: это свойство радиуса, а не пользовательского фильтра. И существующий тест test_best_layouts.py::test_exclude_competitor_obj_ids закрепляет именно его:

req = _request(exclude_competitor_obj_ids=[20])
...
assert resp.data_quality.objects_total_in_radius == 1   # исключили единственного

Прочитать это как «снимок поведения» или как «намеренный контракт» по докстрингу нельзя — там сказано только «→ пустой ответ».

Насколько это сегодня наблюдаемо

Никак. Замер прода 20.08: 4094 разбора за 120 суток, ни одного с exclude_competitor_obj_ids или filter_competitor_obj_ids в параметрах. При пустом фильтре _keep пропускает всё, и обе величины тождественно равны. Расхождение проявится при первом же использовании фильтра.

Что нужно решить

Одно из двух, и оба варианта дёшевы:

  1. Пост-фильтр везде — поле остаётся знаменателем coverage; до-фильтровое число уже отдаётся отдельно, в raw_objects_total, так что прозрачность не теряется. Существующий тест переписывается с явным обоснованием.
  2. До-фильтр везде — поле остаётся «сколько в радиусе»; тогда знаменателем coverage должно стать отдельное поле, и менять придётся штатную ветку.

Первый вариант меняет число только в ветке «всё отфильтровано», второй — в штатной. Поэтому решать стоит до того, как фильтром начнут пользоваться.

Оставляю пункт открытым и помечаю как требующий решения, а не забытым.

## Пункт `best_layouts.py:1113` — останавливаюсь, нужно решение о смысле поля Расхождение реально, и оно шире, чем в пункте: веток **три**, а не две. ``` ветка «нет конкурентов после фильтра» → groups_total_pre_filter (ДО фильтра) ветка «нет velocity-данных» → len(complex_groups) (ПОСЛЕ фильтра) штатная ветка → groups_total_after_filter (ПОСЛЕ фильтра) ``` Я довёл правку до конца — привёл все три к пост-фильтровой величине, добавил гейт, прогнал. И **откатил**, потому что доказать, какая семантика верна, из кода нельзя, а число видит пользователь. ### Что говорит за пост-фильтровое значение Комментарий в самом коде: `complex_groups` — «знаменатель coverage», а покрытие считается по отфильтрованному набору. Две ветки из трёх так и делают. ### Что говорит за до-фильтровое Имя поля. `objects_total_in_radius` — «всего объектов в радиусе»: это свойство радиуса, а не пользовательского фильтра. И существующий тест `test_best_layouts.py::test_exclude_competitor_obj_ids` закрепляет именно его: ```python req = _request(exclude_competitor_obj_ids=[20]) ... assert resp.data_quality.objects_total_in_radius == 1 # исключили единственного ``` Прочитать это как «снимок поведения» или как «намеренный контракт» по докстрингу нельзя — там сказано только «→ пустой ответ». ### Насколько это сегодня наблюдаемо Никак. Замер прода 20.08: **4094 разбора за 120 суток, ни одного** с `exclude_competitor_obj_ids` или `filter_competitor_obj_ids` в параметрах. При пустом фильтре `_keep` пропускает всё, и обе величины тождественно равны. Расхождение проявится при первом же использовании фильтра. ### Что нужно решить Одно из двух, и оба варианта дёшевы: 1. **Пост-фильтр везде** — поле остаётся знаменателем coverage; до-фильтровое число уже отдаётся отдельно, в `raw_objects_total`, так что прозрачность не теряется. Существующий тест переписывается с явным обоснованием. 2. **До-фильтр везде** — поле остаётся «сколько в радиусе»; тогда знаменателем coverage должно стать отдельное поле, и менять придётся штатную ветку. Первый вариант меняет число только в ветке «всё отфильтровано», второй — в штатной. Поэтому решать стоит до того, как фильтром начнут пользоваться. Оставляю пункт открытым и помечаю как требующий решения, а не забытым.
Author
Collaborator

Обновил чек-лист по факту сделанного

Счётчик расходился с реальностью: показывал 46/43, хотя за 20.08 семнадцать пунктов закрыты правками. Стало 63 закрыто / 26 открыто.

Проставлены галочки (каждая со ссылкой на PR):

full_report_html.py:460        #2959    domrf_catalog_object.py:440  #2960
full_report_docx.py:288        #2961    admin_leads.py:152           #2963
full_report_pdf.py:217         #2964    admin_cadastre.py:83         #2965
izyatie_ocr_ingest.py:101      #2966    bulk_harvest.py:554          #2969
scrape_kn.py:42                #2970    domrf_catalog_object.py:460  #2971
scrape_objective.py:252        #2972

одним PR #2968 (документация против кода):
  nspd_denorm.py:327 · nspd_client.py:814 · analytics_queries.py:1423
  macro_series.py:305 · sales_series.py:496 · macro_coefficient.py:99

Помечены БЕЗ галочки — они не сделаны, и выдавать их за сделанные нельзя:

пункт пометка
quarter_dump_lookup.py:767 отклонён по замеру — не воспроизводится на PostGIS 3.4.3, риск-слои пусты
ekb_geoportal_client.py:217 отклонён по замеру — ошибки WFS не притворяются пустым результатом
poi_score.py:141 отклонён по замеру — 683 усечённых центра, 0 расхождений топ-7
competitors.py:553 заблокирован #2962 — мост gap-fill тянет чужие ЖК, копировать нельзя
best_layouts.py:1113 требует решения о семантике поля

Разница между «отклонён» и «сделан» здесь существенная: у отклонённых есть замер, показывающий, что чинить нечего или нечем проверить. У заблокированного — причина в другом issue. Ни один из пяти не закрыт молча.

Три из них я довёл до готовой правки и откатил, когда замер или существующий тест показали, что премиса неверна.

## Обновил чек-лист по факту сделанного Счётчик расходился с реальностью: показывал 46/43, хотя за 20.08 семнадцать пунктов закрыты правками. Стало **63 закрыто / 26 открыто**. **Проставлены галочки** (каждая со ссылкой на PR): ``` full_report_html.py:460 #2959 domrf_catalog_object.py:440 #2960 full_report_docx.py:288 #2961 admin_leads.py:152 #2963 full_report_pdf.py:217 #2964 admin_cadastre.py:83 #2965 izyatie_ocr_ingest.py:101 #2966 bulk_harvest.py:554 #2969 scrape_kn.py:42 #2970 domrf_catalog_object.py:460 #2971 scrape_objective.py:252 #2972 одним PR #2968 (документация против кода): nspd_denorm.py:327 · nspd_client.py:814 · analytics_queries.py:1423 macro_series.py:305 · sales_series.py:496 · macro_coefficient.py:99 ``` **Помечены БЕЗ галочки** — они не сделаны, и выдавать их за сделанные нельзя: | пункт | пометка | |---|---| | `quarter_dump_lookup.py:767` | отклонён по замеру — не воспроизводится на PostGIS 3.4.3, риск-слои пусты | | `ekb_geoportal_client.py:217` | отклонён по замеру — ошибки WFS не притворяются пустым результатом | | `poi_score.py:141` | отклонён по замеру — 683 усечённых центра, 0 расхождений топ-7 | | `competitors.py:553` | заблокирован #2962 — мост gap-fill тянет чужие ЖК, копировать нельзя | | `best_layouts.py:1113` | требует решения о семантике поля | Разница между «отклонён» и «сделан» здесь существенная: у отклонённых есть замер, показывающий, что чинить нечего или нечем проверить. У заблокированного — причина в другом issue. Ни один из пяти не закрыт молча. Три из них я довёл до готовой правки и откатил, когда замер или существующий тест показали, что премиса неверна.
Author
Collaborator

Пункт bulk_harvest.py:1464 — отклоняю по замеру, но по дороге всплыло другое

Утверждение: при отсутствии id у WMS-фичи zone_id синтезируется как md5(отсортированные properties), и две геометрически разные зоны с одинаковыми свойствами схлопываются через ON CONFLICT (zone_id) DO UPDATE — один полигон молча затирает другой.

Механизм в коде именно такой (ключ — {quarter_cad}_{md5}, то есть коллизия возможна только внутри квартала). Но на проде он ни разу не сработал:

строк в cad_territorial_zones                    1
из них с синтетическим zone_id                   0

Единственная строка — 66:41:0614005, zone_id 1614620304 (настоящий id НСПД, не хеш), записана 24.05.2026.

Что всплыло

Таблица практически пуста при том, что данные есть:

тер-зон в дампах nspd_quarter_dumps       2079   (по 524 дампам)
строк в cad_territorial_zones                1
последняя запись                     2026-05-24

И её никто не читает. Проверил со всех сторон:

  • ни одного SELECT в коде — единственные упоминания это сама _save_territorial_zones (пишет) и миграция 102 (создаёт);
  • ни одной вьюхи или матвьюхи, ссылающейся на неё (pg_rewrite/pg_depend);
  • ни одного внешнего ключа на неё (pg_constraint).

То есть это write-only хранилище, в которое за три месяца попала одна строка. Зонирование в отчёте берётся не отсюда, а из nspd_dump_data["nspd_zoning"] и геопортала ЕКБ.

Вывод по пункту

Чинить хеш-коллизию в ветке, которая ни разу не исполнялась, в таблице, которую никто не читает, — работа без адресата. Пункт закрываю отклонением.

Отдельный вопрос — нужна ли таблица вообще: удалять её или её наполнение я не предлагаю, это решение владельца. Но если она задумывалась как источник для чего-то, то это «оборванная проводка», а не пустой кэш: 2079 зон лежат в дампах и не доезжают.

## Пункт `bulk_harvest.py:1464` — отклоняю по замеру, но по дороге всплыло другое Утверждение: при отсутствии id у WMS-фичи `zone_id` синтезируется как `md5(отсортированные properties)`, и две геометрически разные зоны с одинаковыми свойствами схлопываются через `ON CONFLICT (zone_id) DO UPDATE` — один полигон молча затирает другой. Механизм в коде именно такой (ключ — `{quarter_cad}_{md5}`, то есть коллизия возможна только внутри квартала). **Но на проде он ни разу не сработал:** ``` строк в cad_territorial_zones 1 из них с синтетическим zone_id 0 ``` Единственная строка — `66:41:0614005`, zone_id `1614620304` (настоящий id НСПД, не хеш), записана **24.05.2026**. ## Что всплыло Таблица практически пуста при том, что данные есть: ``` тер-зон в дампах nspd_quarter_dumps 2079 (по 524 дампам) строк в cad_territorial_zones 1 последняя запись 2026-05-24 ``` И её никто не читает. Проверил со всех сторон: - ни одного `SELECT` в коде — единственные упоминания это сама `_save_territorial_zones` (пишет) и миграция 102 (создаёт); - ни одной вьюхи или матвьюхи, ссылающейся на неё (`pg_rewrite`/`pg_depend`); - ни одного внешнего ключа на неё (`pg_constraint`). То есть это write-only хранилище, в которое за три месяца попала одна строка. Зонирование в отчёте берётся не отсюда, а из `nspd_dump_data["nspd_zoning"]` и геопортала ЕКБ. ## Вывод по пункту Чинить хеш-коллизию в ветке, которая ни разу не исполнялась, в таблице, которую никто не читает, — работа без адресата. Пункт закрываю отклонением. Отдельный вопрос — нужна ли таблица вообще: удалять её или её наполнение я не предлагаю, это решение владельца. Но если она задумывалась как источник для чего-то, то это «оборванная проводка», а не пустой кэш: 2079 зон лежат в дампах и не доезжают.
Author
Collaborator

Итог прохода 20–21.08: 80 закрыто, 9 с записанным решением, «просто не сделанных» — ноль

Реестр эпика приведён в состояние, где у каждого пункта есть либо правка, либо решение с числом. Ни одна строка больше не читается как «до этого не дошли руки».

Девять незакрытых — и почему

пункт решение
backend/app/services/etl/objective_backfill.py:469 отклонён по замеру
backend/app/services/site_finder/poi_score.py:141 отклонён по замеру
backend/app/services/site_finder/best_layouts.py:195 частично, остаток вынесен
backend/app/services/site_finder/quarter_dump_lookup.py:767 отклонён по замеру
backend/app/services/cadastre/bulk_harvest.py:1464 отклонён по замеру
backend/app/services/scrapers/ekb_geoportal_client.py:217 отклонён по замеру
backend/app/services/site_finder/best_layouts.py:1113 отклонён по замеру
backend/app/services/site_finder/competitors.py:553 заблокирован (#2962)
backend/app/services/scrapers/rosstat_emiss.py:228 отклонён по замеру

Каждое «отклонён» опирается на замер прода, а не на суждение. Самые показательные:

  • quarter_dump_lookup.py:767 — заявленный баг PostGIS не воспроизводится на 3.4.3: 500 пар полигонов, обе формы совпали до 1e-6.
  • bulk_harvest.py:1464 — синтетических zone_id на проде 0 из 1 строки, а таблицу cad_territorial_zones не читает никто (вынесено в #2985).
  • rosstat_emiss.py:2280 следов мохибейка в 2862 строках; перестановка кодировок сделала бы хуже. Побочно доказано, что третий элемент цепочки недостижим, а её logger.warning не сработает никогда.
  • objective_backfill.py:469 — гео-половина закрыта #2929, core-pass оставлен сознательно: имя и застройщик не различают «Старт» и «СТАРТ» — это разные ЖК в разных концах города.

Три случая, где замер опроверг ДОВОД пункта, а вывод устоял

Это стоит отметить отдельно — если бы я чинил по формулировке, чинил бы не то:

  1. gate_verdict.py:441 — пункт говорит о смешении сетевых зон с keyword-совпадениями без network_kind. Таких на проде ноль из 3493. Но вывод («конкретная причина приписывается чужой площади») верен по другой причине: смешиваются разные виды сетей — 316 пересечений, 155 зон. Починено #3001.
  2. ekb_ppt_tep_parser.py:72 — пример «приведены в таблице 12» регекс не ловит: он требует именительное «Таблица N». Опасно ровно оглавление. Зафиксировано характеризующим тестом #2988.
  3. parcel_financial.py:152 — пункт про валидацию входа; тесты попутно вскрыли второй дефект той же функции, существовавший независимо: пятно застройки выходило больше участка при КСИТ > 1 (#3000).

Что нашлось по дороге и в эпик не входило

  • #2986 — ключ gisogd_permits терял 23.9 % реестра (2243 документа схлопывались, потому что разрешение и изменения к нему носят один номер). Починено #2987, данные вернутся прогоном 25.08.
  • #2985cad_territorial_zones: запрос к НСПД на каждый квартал, 1 строка в таблице, ни одного читателя.
  • #2982act_number не извлекается ни у одной строки land_reservation: регекс ждёт суффикс областных актов.
  • #2984 — правка разбора даты не чинит старые строки (ON CONFLICT DO NOTHING), понадобилась миграция-backfill.
## Итог прохода 20–21.08: 80 закрыто, 9 с записанным решением, «просто не сделанных» — ноль Реестр эпика приведён в состояние, где **у каждого пункта есть либо правка, либо решение с числом**. Ни одна строка больше не читается как «до этого не дошли руки». ### Девять незакрытых — и почему | пункт | решение | |---|---| | `backend/app/services/etl/objective_backfill.py:469` | отклонён по замеру | | `backend/app/services/site_finder/poi_score.py:141` | отклонён по замеру | | `backend/app/services/site_finder/best_layouts.py:195` | частично, остаток вынесен | | `backend/app/services/site_finder/quarter_dump_lookup.py:767` | отклонён по замеру | | `backend/app/services/cadastre/bulk_harvest.py:1464` | отклонён по замеру | | `backend/app/services/scrapers/ekb_geoportal_client.py:217` | отклонён по замеру | | `backend/app/services/site_finder/best_layouts.py:1113` | отклонён по замеру | | `backend/app/services/site_finder/competitors.py:553` | заблокирован (#2962) | | `backend/app/services/scrapers/rosstat_emiss.py:228` | отклонён по замеру | Каждое «отклонён» опирается на замер прода, а не на суждение. Самые показательные: - `quarter_dump_lookup.py:767` — заявленный баг PostGIS **не воспроизводится** на 3.4.3: 500 пар полигонов, обе формы совпали до 1e-6. - `bulk_harvest.py:1464` — синтетических `zone_id` на проде **0 из 1 строки**, а таблицу `cad_territorial_zones` не читает никто (вынесено в #2985). - `rosstat_emiss.py:228` — **0 следов мохибейка** в 2862 строках; перестановка кодировок сделала бы хуже. Побочно доказано, что третий элемент цепочки недостижим, а её `logger.warning` не сработает никогда. - `objective_backfill.py:469` — гео-половина закрыта #2929, core-pass оставлен сознательно: имя и застройщик не различают «Старт» и «СТАРТ» — это разные ЖК в разных концах города. ### Три случая, где замер опроверг ДОВОД пункта, а вывод устоял Это стоит отметить отдельно — если бы я чинил по формулировке, чинил бы не то: 1. **`gate_verdict.py:441`** — пункт говорит о смешении сетевых зон с keyword-совпадениями без `network_kind`. Таких на проде **ноль из 3493**. Но вывод («конкретная причина приписывается чужой площади») верен по другой причине: смешиваются **разные виды сетей** — 316 пересечений, 155 зон. Починено #3001. 2. **`ekb_ppt_tep_parser.py:72`** — пример «приведены в таблице 12» регекс **не ловит**: он требует именительное «Таблица N». Опасно ровно оглавление. Зафиксировано характеризующим тестом #2988. 3. **`parcel_financial.py:152`** — пункт про валидацию входа; тесты попутно вскрыли **второй дефект той же функции**, существовавший независимо: пятно застройки выходило больше участка при КСИТ > 1 (#3000). ### Что нашлось по дороге и в эпик не входило - **#2986** — ключ `gisogd_permits` терял **23.9 % реестра** (2243 документа схлопывались, потому что разрешение и изменения к нему носят один номер). Починено #2987, данные вернутся прогоном 25.08. - **#2985** — `cad_territorial_zones`: запрос к НСПД на каждый квартал, 1 строка в таблице, ни одного читателя. - **#2982** — `act_number` не извлекается ни у одной строки `land_reservation`: регекс ждёт суффикс областных актов. - **#2984** — правка разбора даты не чинит старые строки (`ON CONFLICT DO NOTHING`), понадобилась миграция-backfill.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2464
No description provided.