Commit graph

423 commits

Author SHA1 Message Date
cab19020a9 perf(site-finder): pushdown obj_pricing CTE to nearby competitors (#70/#307) (#1335)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m40s
Deploy / build-worker (push) Successful in 2m38s
Deploy / deploy (push) Successful in 1m12s
2026-06-14 16:58:43 +00:00
b405ae291d feat(site-finder): additive objective_lots price fallback for competitors (#307 OBJ-3) (#1332)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m57s
Deploy / build-worker (push) Successful in 2m51s
Deploy / build-frontend (push) Successful in 3m6s
Deploy / deploy (push) Successful in 1m12s
2026-06-14 16:10:02 +00:00
72e0b050f9 feat(site-finder): POI saturation per capita по районам ЕКБ (#42) (#1329)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 2m52s
Deploy / deploy (push) Successful in 1m12s
2026-06-14 15:05:22 +00:00
e5e0ccb283 feat(site-finder): сетевые обременения из ЗОУИТ (#1070, Refs #103) (#1328)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m36s
Deploy / build-worker (push) Successful in 2m40s
Deploy / deploy (push) Successful in 1m12s
2026-06-14 14:49:32 +00:00
5507bbc1a4 feat(site-finder): domrf→cad geom-match + parking_ratio конкурентов (#96) (#1327)
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
2026-06-14 14:49:28 +00:00
2c22c3f7ea feat(site-finder): per-building помещения/машино-места + parking_ratio (on-demand MVP, #96)
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m59s
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (push) Successful in 8m48s
CI / backend-tests (pull_request) Successful in 8m48s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 4m58s
Deploy / build-worker (push) Successful in 5m46s
Deploy / deploy (push) Successful in 1m30s
Оживляет мёртвый foundation list_objects_in_building (#168 Q3-deferred). Fix реального
блокера: NSPDOptions не парсил objdocId (camelCase из NSPD search) → objdoc_id всегда None,
метод нельзя было вызвать. AliasChoices(objdoc_id, objdocId) + registers.

premises_lookup.get_building_premises(cad_num) — резолв objdoc → list_objects_in_building,
graceful (WAF/сеть/not-found → None). parking_ratio = машино-места/помещения (None при 0
помещений, 0.0 при реальном отсутствии паркинга). Verified live 66:41:0106036:183.

MVP on-demand (без bulk/схемы/prod-записей). parking_ratio готов, но НЕ wired в analyze:
конкуренты приходят из ДОМ.РФ без cad_num → нужен domrf↔cad_buildings geom-match (отдельная
задача). Сервис примет cad_num как только matching появится. 11 тестов, ruff clean.

Refs #96
2026-06-14 18:48:16 +05:00
eb251ba7e7 ci(infra): coverage-gate (#68) + OpenAPI codegen-assert (#69) + uptime monitoring (#75)
All checks were successful
CI / changes (push) Successful in 13s
CI / changes (pull_request) Successful in 12s
CI / frontend-tests (push) Successful in 53s
CI / openapi-codegen-check (push) Successful in 1m58s
CI / frontend-tests (pull_request) Successful in 47s
CI / openapi-codegen-check (pull_request) Successful in 1m50s
CI / backend-tests (push) Successful in 8m54s
CI / backend-tests (pull_request) Successful in 8m45s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 2m50s
Deploy / build-frontend (push) Successful in 3m11s
Deploy / build-worker (push) Successful in 4m38s
Deploy / deploy (push) Successful in 2m1s
#68: pytest-cov + [tool.coverage] fail_under=65 (baseline 71%), backend-tests --cov + xml.
#69: openapi-codegen-check job (dump app.openapi() → openapi-typescript → git diff).
  Job использует node_modules-pinned openapi-typescript/prettier (НЕ npx --yes latest —
  иначе version-mismatch ложный diff). + regenerated api-types.ts из СВЕЖЕГО main openapi
  (8288 строк, включая #73 /freshness — синхронен).
#75: Uptime Kuma isolated stack + status.gendsgn.ru Caddy + external Telegram watchdog.
#77: no change — build cache достаточен.

Closes #68
Closes #69
Closes #75
Closes #77
2026-06-13 23:28:06 +05:00
d38417d8af feat(admin): freshness endpoint (#73) + README Forgejo refresh (#83) + migrations doc (#72)
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 2m39s
Deploy / build-worker (push) Successful in 7m20s
Deploy / deploy (push) Successful in 2m48s
#73: GET /admin/scrape/freshness — last_success/age/freshness/status для 5 источников
(kn/objective/nspd/nspd_geo/cadastre) + overall_status (worst по critical). Prod-verified
имена таблиц, FILTER/COALESCE корректны, CAST(:d AS interval). +4 теста.
#83: README — GitHub→Forgejo CI/CD, секция 6 subagents, ссылки перемаплены.
#72: docs/Schema_Migrations_vs_Alembic.md — _schema_migrations паттерн (не Alembic),
рекомендация снести пустой alembic-скелет (отдельный PR).

Closes #73
Closes #83
Closes #72
2026-06-13 18:25:32 +00:00
50555821fb feat(site_finder): routing POI decay (#41) + OSM utility distances (#44) + ЗОУИТ geom (#255bk)
Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m42s
CI / backend-tests (pull_request) Successful in 7m40s
Deploy / changes (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
#41: per-category routing-radius decay через reusable ORS /matrix client (foot-walk
время + per-category пороги + decay-curves), opt-in ?decay=routing, graceful straight-line
fallback (analyze не 500 при ORS down) + length-guard durations==destinations.
#44: +5 OSM-категорий (transformer/gas/water/sewerage/heat) via Overpass nwr;
nearest_{water_main,substation,gas,heat}_m в analyze.utilities.
#255bk: ST_AsGeoJSON(CAST(geom AS geometry)) в _get_cad_zouit_overlaps → geom_geojson.

Closes #41
Closes #44
2026-06-13 22:31:56 +05:00
658d724075 feat(analytics): velocity-anomaly alerts (#17) + ARN ДДУ price indicator (#99)
Some checks failed
CI / changes (push) Successful in 8s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m37s
CI / backend-tests (pull_request) Successful in 7m42s
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
#17: detect_velocity_anomalies + GET /analytics/velocity-alerts — z-score drop-detection
на domrf_kn_sale_graph (double-gate z≤-2 AND drop≤-30%, starvation-guards). Snapshot=richest
(не MAX — prod новейшие частичные), lookback anchored на latest report_month (scrape лаг ~4мес).
Prod: ЖК Центральный Парк -69%, ~14ms.

#99: mv_ddu_price_indicator (миграция 152) + POST /market/ddu-indicator — ARN-mirror
ценовой индекс per quarter×area_bucket из rosreestr_deals (ДДУ регион 66). MVP: subject-level,
period Q, window 2025-Q2+, methods 1/2 (basis/previous, prev_period_value honesty). Q1-2026
headline 1.0185 vs ARN 1.03 (±5%). Method 3 blocked (нет pre-2025-Q2 данных) — задокументировано.

Closes #17
Closes #99
2026-06-13 22:04:49 +05:00
8242e51b61 feat(generative): exporters (dxf/pdf) + generative test suite (#54 #55 #56)
Some checks failed
CI / frontend-tests (push) Has been skipped
CI / changes (push) Successful in 10s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m39s
CI / backend-tests (pull_request) Successful in 7m39s
Deploy / deploy (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / changes (push) Successful in 6s
Дополняет движок: exporters/{dxf,pdf}.py (ezdxf + WeasyPrint lazy) + 5 тест-модулей
(geometry/placement/teap_financial/exporters/api). Не вошли в предыдущий commit
(untracked-директории).
2026-06-13 21:33:20 +05:00
3b79533f9f feat(generative): движок концепций Stage 1a/1b/1c (#54 #55 #56)
Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / backend-tests (push) Has been cancelled
Заменяет generative stubs детерминированным end-to-end пайплайном:
- 1a geometry: WGS84-parse, метрическая AEQD-репроекция, setback-буфер, placement grid
- 1b placement: greedy section-fill + STRtree коллизии (full-gap), 3 стратегии, coverage cap
- 1c teap/financial: площади/квартиры/FAR/паркинг; выручка/затраты/маржа + IRR-proxy
- exporters: ezdxf DXF (геометрия) + WeasyPrint ТЭП/фин PDF (lazy import)
generate() заменил generate_stub в route (422 на degenerate). mypy-strict + ruff clean,
31 generative-тест + full suite 3078 passed. ConceptVariant заполняется реальными числами.

Closes #54
Closes #55
Closes #56
2026-06-13 21:32:32 +05:00
1883271908 fix(cadastre): NSPD 500-retry + per-layer skip (#252) + disable dead pzz-beat (#259) + geom backfill (#200)
Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m43s
CI / backend-tests (pull_request) Successful in 6m41s
Deploy / build-backend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
#252: NspdBulkServerError на HTTP 5xx + WMS <ServiceException>-body → quarter-level
autoretry; _grid_walk_category флагует layer_failed только если ВСЕ cells 5xx (none ok),
network-errors исключены — пустой/флапающий слой не ложно-скипается. harvest_meta.layer_X_failed.

#259: pzz-sync-monthly beat отключён (PKK6 deprecated, pzz_zones_ekb=0, заменён
zone_regulation_cache+NSPD dumps). logger.exception→warning (стоп GlitchTip BACKEND-1B),
raise сохранён для admin-endpoint.

#200: geom_unavailable флаг (миграция 151) + backfill_parcel_geom task — grid-walk
layer 36368 per quarter лечит 930/964 NULL-geom участков (NSPD search вернул центроиды),
остаток marks unavailable (не ретраить вечно). SAVEPOINT per-cell, idempotent.

Closes #252
Closes #259
Closes #200
2026-06-13 20:45:05 +05:00
84d08940c6 fix(analytics): свежая ипотечная ставка + recommend last_updated + drop dead route (#236 #237 #251)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Successful in 3m9s
Deploy / build-backend (push) Successful in 4m12s
Deploy / build-worker (push) Successful in 5m31s
Deploy / deploy (push) Successful in 1m19s
#236: _current_mortgage_rate сортировал cbr_mortgage_series по TEXT-полю period
лексикографически ('Январь'>'Февраль') → возвращал stale-точку. Сортировка по
parsed (year, month) + regex-guard на malformed period-строки. Остаётся на
subsidized weighted series (НЕ domrf market — иная метрика). Verified prod:
Февраль 2026 (было Январь 2026).

#237: scope.last_updated = MAX(snapshot_date) domrf_kn_objects в recommend_mix +
подпись «данные на <дата>» в recommend page.

#251: удалён never-implemented GET /parcels/{parcel_id} (501) + ParcelDetail схема
(frontend bare 1-сегментный route не зовёт).

Closes #236
Closes #237
Closes #251
2026-06-13 15:22:22 +00:00
3cf9fad683 fix(backend): экранировать Excel formula-injection (#1244) + увести chat-чтение с event loop (#1245)
Some checks failed
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m38s
CI / backend-tests (pull_request) Successful in 6m37s
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
#1244 (security): внешние/скрейпинг-строки (comm_name из DOM.РФ, headline/usp_text)
с ведущим = + - @ \t \r писались как есть → openpyxl сохранял как формулы
(data_type='f'), исполнялись при открытии в Excel/LibreOffice. _sanitize_formula
префиксует такие строки апострофом (OWASP CSV-injection escape); числа/даты/bool
не трогаются. _write_kv labels тоже санитизируются. Подтверждено на openpyxl 3.1.5.

#1245 (concurrency): async ask() вызывал sync get_report_for_chat() (sync SQLAlchemy
тянет крупный JSONB §22-отчёт) напрямую — блокировал event loop, в отличие от
LLM-ветки (run_in_threadpool). Обёрнуто в run_in_threadpool.

Closes #1244
Closes #1245
2026-06-13 18:10:21 +05:00
99c9a130bc feat(forecast): расширить горизонт прогноза до 24 мес (ТЗ §12.1)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Successful in 46s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Successful in 46s
CI / backend-tests (push) Successful in 6m35s
CI / backend-tests (pull_request) Successful in 6m30s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 2m14s
Deploy / build-frontend (push) Successful in 2m55s
Deploy / build-worker (push) Successful in 4m6s
Deploy / deploy (push) Successful in 1m40s
API отвергал ?horizon=24 (422), хотя ТЗ §12.1 называет 6/12/18/24, а движок
УЖЕ считает 24 на каждом ране: _DEFAULT_HORIZONS=(6,12,18,24) во всех 6 точках
стека (orchestrator/forecast-task/demand_supply_forecast/scenarios/
special_indices/report_assembler), PIPELINE_HORIZON_MONTHS=24.
_hidden_release_fraction клампит h/18→1.0 на 24 (без переполнения),
future_supply._horizon_weight расширяет окно чисто — скрытых ≤18 потолков нет.
Чистое расширение валидатора-enum, не новая математика.

Backend: _ALLOWED_FORECAST_HORIZONS → {6,12,18,24}, Query/docstring/error-msg.
Frontend: HorizonSelector HORIZONS=[6,12,18,24] (тип horizon=number, union не нужен;
прочие потребители data-driven через meta.horizons/forecasts_by_horizon).
Тесты: API принимает 24/отвергает 30; движок-тесты доказывают h=24 осмыслен
(поля посчитаны, demand(24)>demand(18), hidden созрел, индексы в диапазонах).

Closes #944 (Q1 горизонт 24)
2026-06-13 17:27:35 +05:00
b211183940 feat(site-finder): детерминированная атрибуция застройщика в analyze (#1088)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m33s
CI / backend-tests (pull_request) Successful in 6m33s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m44s
Deploy / build-worker (push) Successful in 5m24s
Deploy / deploy (push) Successful in 1m30s
Сервис get_developer_attribution поверх fn_developer_for_parcel (миграция 149):
топ-1 застройщик участка + track-record (РНС/РВЭ + домрф) + nearby_developers.
Дедуп по норм-ИНН (лучший match_method первым). Wire в analyze additive без флага
(чистый DB-резолвер по индексам, graceful → None). Beat-refresh developer_registry
ежемесячно 05:30 МСК после ekburg-permits. Pydantic-схемы + 18 тестов.

Источники: ekburg_construction_permits (РНС, ИНН) ⋈ domrf_kn_objects по
нормализованному ИНН + spatial/quarter fallback. 68% domrf-застройщиков имеют РНС.
EXPLAIN: point-lookup 0.06ms, резолвер ~30ms (functional/GIST индексы в миграции).

Closes #1088
2026-06-13 16:13:45 +05:00
3df163f741 fix(snapshot-pdf): Unicode '—' вместо HTML-entity '&mdash;' в Jinja or-fallback (#1234)
All checks were successful
CI / changes (pull_request) Successful in 6s
Deploy / changes (push) Successful in 9s
CI / frontend-tests (pull_request) Successful in 55s
Deploy / build-backend (push) Successful in 2m20s
Deploy / build-frontend (push) Successful in 3m25s
Deploy / build-worker (push) Successful in 4m16s
Deploy / deploy (push) Successful in 1m22s
CI / backend-tests (pull_request) Successful in 6m43s
2026-06-13 15:04:12 +05:00
eada9a25ac fix(scrapers): izyatie OCR — '6б:' → '66:' для кад-номеров МСК-66 (#1228) 2026-06-13 15:03:59 +05:00
367ed890f1 fix(objective-sync): log seed failure вместо bare except pass (#1226) 2026-06-13 15:03:34 +05:00
49d26b4696 fix(poi_loader): timedelta(days=730) вместо replace(year=-2) → leap-year 29 февраля safe (#1232) 2026-06-13 15:03:08 +05:00
c3c6e2cef1 fix(okn): expand registry coverage + load detail-card name/address (#1159)
okn_objects_sync собирал 428 ОКН из ~798 ЕГРОКН и сохранял raw_props={id}
без названия/адреса — analyze показывал 'ОКН рядом' без указания КАКОГО.

Coverage: расширил _ADDRESS_PASSES с 2 до 11 подстрок (Екатеринбург,
г. Екатеринбург, 7 районов ЕКБ, 2 формы Свердловской обл.). _PASSES =
декартово (address × category). first-wins дедуп сохраняет высшую категорию.

Detail: fetch_okn_detail(source_id) — GET Show/Show?id=, BeautifulSoup
парсит HTML (dl/dt/dd и tr/td), извлекает name, address, protection_category,
object_kind, dating. _enrich_with_detail сливает в raw_props с graceful
failover (cap=1500, exception per-object не валит прогон).

Lookup: parcel_okn_objects возвращает name/address из raw_props->>'name'.
Existing ird_analyze тесты не сломаны.

49 okn-related тестов passed, ruff clean.

Closes #1159
2026-06-13 15:02:50 +05:00
2e428405d2 fix(site-finder): расширить except в parcel_ird_overlaps до DataError (#1095)
parcel_ird_overlaps ловил только (OperationalError, ProgrammingError).
При D9b-wiring в analyze malformed WKT в ST_GeomFromText давал PostGIS
ERROR → SQLAlchemy DataError, который пробивал try/except → analyze
падал вместо graceful-degrade.

- ird_overlay_lookup: + DataError в imports и в except tuple.
- quarter_dump_lookup: симметричное расширение в _get_engineering_*.
- test_ird_overlay_lookup: parametrized test_graceful_when_db_fails
  покрывает 3 класса (Operational/Programming + DataError для WKT).

Closes #1095
2026-06-13 15:02:50 +05:00
d11c8d47e0 fix(workers): per-prefix db.commit() в gknspecial_harvest для durability (#1116)
harvest_gknspecial_zones делал единственный commit в конце всех 8 prefix-
циклов: WorkerLostError (OOM / рестарт хоста) в середине прогона →
~20k записанных строк откатывались, прогресс ноль.

Mirror ird_harvest.py:216 (per-quarter commit): db.commit() после
финального logger.info внутри for-prefix loop. SAVEPOINT-per-row уже
изолирует битые фичи; per-prefix commit добавляет durability между
префиксами. WFS-fail на префиксе → continue → commit не делается;
предыдущие префиксы уже durable.

+2 теста (durability, WFS-fail skip). 29 gknspecial тестов зелёные.

Closes #1116
2026-06-13 15:02:50 +05:00
8a30238564 fix(forecasting): propagate confounded flag DemandSupplyForecast → §15 (#1222)
DemandSupplyForecast.as_dict() не эмитил 'confounded'/'is_confounded_window',
report_assembler._confounded() всегда возвращал False и §15 confounded_window
factor в compute_report_confidence был мёртв: 48-мес окна, пересекающие
2024-07-01 шок никогда не тянули report confidence к 'low' и шок не назывался
в rationale.

Patch: добавлено confounded: bool в DemandSupplyForecast (от §9.5 macro_coef
OR §9.6 rate_sensitivity), exposed в as_dict(). _confounded() уже использовал
.get() defensively — блокер был в producer'е.

+3 теста: contract на real DemandSupplyForecast.as_dict(), end-to-end
assemble_report → confounded_window factor surfaces at level=low, weakest-link
тянет overall к 'low'. 61 report_assembler + 1034 forecasting тестов зелёные.

Closes #1222
2026-06-13 15:02:50 +05:00
703d3905b8 fix(site-finder): normalize supply room_bucket vocabulary to velocity side (#1229)
best_layouts._SUPPLY_BATCH_SQL эмитил {studio,euro-1,euro-2,1,2,3,4+},
а _INLINE_VELOCITY_SQL читает {студия,1,2,3,4+} из
objective_corpus_room_month (prod check: 'euro-*' rows отсутствуют).

Эффект: rooms=2 + area<50 уходили в euro-1/euro-2 supply-стороной →
выпадали из знаменателя bucket '2' → sold_pct_of_supply двушек
завышен, is_oversold ложно True. (rb='euro-*') dead lookups в supply_map.

Patch: убраны euro-* WHEN в supply CASE. SF-08 euro-биннинг отложен
до момента когда velocity-сторона начнёт его отдавать. +2 regression
теста (bucket match, string guard). 35 best_layouts тестов зелёные.

Closes #1229
2026-06-13 15:02:50 +05:00
780dd5640c fix(analytics): _district_market_saturation latest-snapshot filter (#1221)
domrf_kn_objects/sales_agg хранят ~3 snapshot'а на obj_id (weekly UPSERT,
retention=none). JOIN без latest-фильтра считал каждый ЖК N раз:
sold_median тянулся старыми perc → sat_factor занижен, гейт n>=5
проходил уже при 2 ЖК (n=6), sat_n завышен ~×N в scope. Сданные ЖК
просачивались через старые снапшоты со статусом 'Строящиеся'.

Patch: AND a.snapshot_date = (SELECT MAX(snapshot_date) FROM ...) для
обеих таблиц (зеркало #1210/#1212). COUNT(*) → COUNT(DISTINCT a.obj_id)
defensive. 29 recommend_mix + 8 saturation тестов зелёные.

Closes #1221
2026-06-13 15:02:50 +05:00
d694e735cd fix(site-finder): per-row SAVEPOINT в noise_loader (#1231)
Loop UPSERT в sync_noise_sources_to_db без begin_nested: один замкнутый
3-точечный natural=water way [A,B,A] даёт POLYGON((A,B,A)), PostGIS
отвергает (< 4 точек в ring) → outer tx rollback + raise → весь weekly
noise/water/utility sync падает, тот же way отравляет каждый прогон.

- Оборачиваем каждый UPSERT в `with db.begin_nested():` + per-row
  try/except → logger.warning + skipped++ (канон pzz_loader.py:111).
- В _way_to_polygon_wkt проверяем итоговое кольцо ≥ 4 точек (fail-safe).
- Outer except: добавлен logger.exception для видимости.

Closes #1231
2026-06-13 15:02:50 +05:00
285e8f974a fix(sf): ekb_ppt_tep post-фильтры + врезка в analyze (#1136)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 2m12s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 4m34s
Deploy / deploy (push) Successful in 1m11s
Post-фильтры парсера (по образцу ppt2018_22823):
- Табл.11: отбрасываем строки без zone_name (преамбульные артефакты
  merged-cells давали 2 фантома при 12 реальных зонах).
- Табл.13: dedup по (phase, composition, zone, area_ha) + фильтры
  короткого-без-зоны и нумерационного шума (~364 строк → ~30-40 реальных).

Analyze-врезка: новый ppt_tep_lookup.parcel_ppt_tep — JOIN
planning_projects ⋈ ekb_ppt_tep по doc_ref↔source_key/doc_full_name
(best-effort, без FK). Wired в build_ird_analyze_block рядом с
planning_projects/krt_requisites — DB-источники, graceful.

Seed-URL остаётся placeholder с # VERIFY (ingest пропускает с WARNING).

Tests: 27 в parser (3 новых), 7 lookup, 22 wiring (2 новых).
All ruff/syntax green.

Closes #1136
2026-06-13 09:32:52 +00:00
a5ff281bad fix(admin-jobs): UPSERT в PUT /admin/jobs/settings — не терять изменения (#1223)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m37s
Deploy / build-worker (push) Successful in 2m46s
Deploy / deploy (push) Successful in 1m19s
UPDATE без проверки rowcount затрагивал 0 строк для несуществующего
job_type; get_one возвращал hardcoded _fallback. PUT отвечал 200,
админ считал настройку сохранённой — но она терялась (новый job_type
до seed-миграции, опечатка в path).

Patch: update() проверяет result.rowcount: при 0 делает INSERT с
переданными колонками (непереданные → дефолты таблицы из м.81).
INSERT использует ON CONFLICT (job_type) DO NOTHING + повторный
UPDATE как защита от гонки. Поведение для существующих строк не
изменилось. 8 новых тестов (6 service + 2 API).

Closes #1223
2026-06-13 08:36:23 +00:00
f5e7fcdad9 fix(workers): cadastre permanent-fail targets_failed + beat cleanup (#1236)
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 6s
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
2026-06-13 08:36:13 +00:00
28c567a34e fix(scoring): correct price_feasibility reason — market rate, not subsidized (#1225)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m35s
Deploy / build-worker (push) Successful in 2m35s
Deploy / deploy (push) Successful in 1m21s
ProductScore.reason для price_feasibility (§14.2) называл основу платежа
'субсид. ставка', но compute_affordability с #981 DoD перешёл на рыночный
прокси key_rate + 4.5 п.п. (~19%, rate_kind='key_rate_proxy'); субсидированный
путь не вызывается. Метка делала рыночный платёж в §22-отчёте похожим на
льготный и противоречила affordability.degraded_reason в том же payload.

Текстовый фикс: 'рыночная ставка key_rate + спред, §7.9' — число платежа
было корректно, advisory-описание теперь совпадает.

Closes #1225
2026-06-13 08:20:30 +00:00
816bb3d3bd fix(workers): re-raise SoftTimeLimitExceeded в nspd_geo, set paused (#1235)
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
2026-06-13 08:20:18 +00:00
10df430cac fix(forecasting): _price_overlap zero-width discontinuity at §25.3 (#1224)
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 8s
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Degenerate price band (own_min==own_max или c_lo==c_hi, оба разрешены
CHECK 148 и Pydantic) внутри другой вилки возвращали 0.0 вместо 1.0:
фильтр w>0 выкидывал нулевую ширину → 0/positive=0.0. Это рвало
докстринг 'полное накрытие узкого = 1.0' и давало разрыв:
[148k,152k]→1.0 vs [150k,150k]→0.0, занижая среднее каннибализации.

Patch: вырожденные ширины обрабатываются ДО нормирования.
lo<=hi → точка внутри другой вилки → 1.0, вне → 0.0. +inf-обе-премиум
ветка перенесена в начало (избежать inf-inf=nan). +7 новых тестов в
TestPriceOverlap. 220 special_indices тестов зелёные.

Closes #1224
2026-06-13 08:10:46 +00:00
ac446236e7 fix(beat): rewrite 4 cron entries in MSK to match conf.timezone (#1233)
Some checks are pending
Deploy / changes (push) Waiting to run
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
CI / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
Celery conf.timezone=Europe/Moscow трактует crontab MSK-relative, но
ekburg-permits, pzz-sync, refresh-quarter-price-index и
nspd-harvest-stale-quarters были UTC-числа и бежали 3ч раньше.
Особенно refresh-quarter-price-index стрелял в 02:00 МСК — ДО
refresh-ekb-districts-medians в 04:00 МСК — ломая контракт
"runs after medians settled". Switched to MSK targets:
ekburg-permits 05:00 1-го, pzz-sync 03:00 1-го, refresh-quarter-price-index
05:00 5-го, nspd-harvest-stale-quarters 04:00 Пн. ruff clean.

Closes #1233
2026-06-13 08:06:21 +00:00
e71224ef54 fix(scrapers): commit outer tx в domrf_catalog scrape_catalog_batch (#1227)
Some checks are pending
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Waiting to run
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
begin_nested() лишь RELEASE'ит SAVEPOINT — без db.commit() все UPDATE
цен откатывались при db.close() в Celery task. Зеркалит правильный
паттерн scrape_catalog_objects (domrf_catalog_object.py:460): try-
commit/except-rollback в конце функции. 50 catalog тестов зелёные.

Closes #1227
2026-06-13 08:06:15 +00:00
0c62d0b0c3 fix(site-finder): склеить разорванный regex в _PLANNING_NO_MATCH_SQL (#1230)
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
CI / changes (pull_request) Successful in 7s
CI / changes (push) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
Литерал regex был разорван переносом строки внутри одной пары '...':
ветки 'Донбасской\\n' и 'Лумумбы\\n' содержали NL+8 пробелов
и никогда не матчили однострочные planning_projects.full_name (PG ~ —
POSIX ARE без (?x)). Geom-only КРТ-fallback молча терял ППТ по этим двум
топонимам.

Patch: вынес паттерн в _KRT_TOPONYM_REGEX через Python adjacent-literal
concat, склеил в SQL через +. Все 17 веток в одной строке.
9 krt_lookup тестов зелёные.

Closes #1230
2026-06-13 12:43:28 +05:00
06c4bb9fd6 fix(workers): count lots_pf failure correctly, no silent 'done' (#1220)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-worker (push) Successful in 2m37s
Deploy / deploy (push) Successful in 1m18s
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
scrape_objective.sync_objective_group инкрементировал n_requests и reports_ok
ДО HTTP-стрима client.stream_report(...). Любая ошибка стрима (ObjectiveAuthError,
ObjectiveAPIError — оба ⊂ RuntimeError ⊂ Exception, ijson-сбой, обрыв сети)
проглатывалась generic except Exception в lots_pf-ветке без инкремента
reports_failed и без re-raise. Внешний except (ObjectiveAuthError|APIError)
был НЕДОСТИЖИМ для lots_pf. _finish_run(status='done' if reports_failed==0)
помечал run УСПЕШНЫМ при полностью провалившемся главном 600МБ отчёте «Лоты».
objective_lots/history оставались stale молча, админка показывала last_run_at,
downstream supply-layers L2 видел 'done', rows_lots=0.

Patch (зеркалит corp_sum — там паттерн уже правильный):
- n_requests/reports_ok перенесены ИЗ pre-HTTP в after-success (после
  успешного commit'а внутри `with stream_report(...)`).
- В except Exception lots_pf-ветке: n_requests += 1 + reports_failed += 1
  + logger.exception (с traceback). Graceful (no re-raise) сохранён —
  sibling jobs продолжают, но статус run теперь честный.
- corp_sum ветка не тронута. _finish_run сигнатура не меняется.

5 новых регрессионных тестов: success keeps reports_ok=1, auth-error /
api-error / generic Exception в lots_pf инкрементируют reports_failed (не
ok), _finish_run status='failed' при reports_failed>0. ruff clean.

Closes #1220
2026-06-13 07:34:57 +00:00
c48f45c48f fix(forecasting): resolve admin district to micros в _query_artificial_demand (#1205)
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
objective_lots.district хранит МИКРО-вокабуляр ('Уралмаш', 'ЖБИ', ...).
_query_artificial_demand фильтровал сырым АДМИН-именем ('Кировский' с
forecast.py:123) → ol.district='Кировский' = 0 строк → n_sold=0 → §25.5
Artificial Demand 'unavailable' с ложной причиной «нет проданных лотов»
в каждом district-scoped отчёте. Тот же класс бага, что #1211 в
_price_sensitivity.

Patch: импорт resolve_objective_districts + замена сырого
`ol.district = CAST(:district AS text)` на зеркальный паттерн
sales_series._SOURCE_B_SQL / market_metrics._SALES_WINDOW_SQL:
  (CAST(:has_district AS boolean) IS FALSE
   OR ol.district = ANY(CAST(:districts AS text[])))

Сигнатура _query_artificial_demand / _build_artificial_demand НЕ меняется
— caller остаётся admin-aware на входе.

+5 новых тестов (TestArtificialDemandDistrictResolution: резолвер вызван,
микро в bind, n_sold>0 после фикса), 6 обновлённых SQL-тестов. 21 passed
artificial_demand + 1030 forecasting тестов зелёные. ruff clean.

Closes #1205
2026-06-13 07:34:54 +00:00
0fffc535cd fix(analytics): rolling 6mo/6mo окна в _district_velocity_trend (#1203)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m35s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m37s
Deploy / deploy (push) Successful in 1m13s
_district_velocity_trend сравнивал recent vs prior, но окна были
ЗАХАРДКОЖЕНЫ датами 2025:
  recent = report_month >= 2025-07-01           (без верхней границы!)
  prior  = report_month BETWEEN 2025-01-01 AND 2025-06-30

objective_corpus_room_month обновляется еженедельно (objective_etl,
period_month без капа). На 2026-06 recent-окно ≈ 11-12 месяцев vs
prior=6 → при плоском рынке ratio ≈ 1.83, дальше навсегда упирается
в clamp(_, 2.0) → trend_factor=2.0 → macro_velocity_mult ×2 →
bucket_velocity ×2 → months_to_sellout занижен в ~1.8-2x.
Искажение растёт с каждым месяцем; trend-сигнал мёртв.

Patch: rolling 6mo/6mo окна относительно NOW():
  recent = report_month >= NOW() - INTERVAL '6 months'
  prior  = report_month >= NOW() - INTERVAL '12 months'
           AND report_month < NOW() - INTERVAL '6 months'

Равные окна, plain rolling, без хардкода. 29/29 velocity_trend +
recommend_mix тестов зелёные. ruff clean.

Closes #1203
2026-06-13 06:24:21 +00:00
ddbd924b02 fix(competitors): _AVG_PRICE_SQL — фильтр latest snapshot (#1210)
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Has been cancelled
domrf_kn_flats версионируется (UNIQUE(id, snapshot_date), м.50), scraper
UPSERT per snapshot — то же что для domrf_kn_objects (которое в L3 supply
после #1212 берём только latest). _AVG_PRICE_SQL фильтра snapshot_date НЕ
имел → AVG усреднял ИСТОРИЮ цен (stale на растущем рынке) → UI-поле
Competitor.avg_price_per_m2 + вход _price_similarity получали устаревшую
цену. COUNT '%прод%' множил sold ×N снапшотов → raw_sold/flat_count кратно
завышен → попадал в гард-нейтраль 0.5 или искажал stage_at_horizon как
×N-завышенный sold_pct.

Patch: WHERE f.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_flats).
Зеркало паттерна best_layouts._SUPPLY_BATCH_SQL и _COMPETITORS_SQL DISTINCT ON
(уже было latest). 51/51 competitors-тестов зелёные.

Closes #1210
2026-06-13 06:17:26 +00:00
5826812816 fix(domrf_kn): детерминированный sha256-fallback для flat_id (#1208)
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 6s
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Fallback flat_id в _norm_flat использовал abs(hash(elem)) % (2**63 - 1).
В CPython hash(str) РАНДОМИЗИРОВАН per-process (PYTHONHASHSEED нигде в
репо не зафиксирован — uvicorn/celery не выставляют его). Эффект:

- При resume упавшего sweep (resume_kn_run в новом процессе воркера) до
  10 объектов после checkpoint перечитываются. Квартиры без flatId
  получают ДРУГОЙ hash-id → ON CONFLICT (id, snapshot_date) не
  срабатывает → дубли строк одной квартиры в одном snapshot.
- То же при повторном прогоне за ту же дату — каждый раз новый id.
- Дубли инфлируют все агрегации (units_sold/price медианы, supply_layers).

Patch:
- abs(hash(elem)) → int.from_bytes(sha256(elem)[:8], "big") % (2**63 - 1).
- sha256 стабилен между процессами/перезапусками. 8 байт → BIGINT-fit.

6 новых юнит-тестов (test_domrf_kn_normalize.py): formula matches sha256,
stable across calls, distinct elems→distinct ids, flatId wins over
fallback, no-id→None, BIGINT-fit. ruff clean.

Closes #1208
2026-06-13 06:09:54 +00:00
c29f4aef57 fix(llm): cap серверного Retry-After через _MAX_BACKOFF_S=30s (#1209)
Some checks are pending
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / changes (push) Successful in 7s
В complete-loop'е min(...,30) применялся ТОЛЬКО к exponential backoff'у
(else-ветка). Серверный Retry-After уходил в time.sleep как есть.
_parse_retry_after принимает любое число секунд ("86400".isdigit() → True),
а provider'ы шлют Retry-After до 86400с при quota-exhaustion (OpenAI,
CDN-503). Эффект: time.sleep блокирует поток anyio-threadpool на часы
(до 48ч при llm_max_retries=2). Async-консьюмер (chat.py) мостится через
run_in_threadpool — поток держит токен пула (~40 потоков, общий с sync
Depends(get_db)) и DB-сессию → пул исчерпывается → стопор приложения.

Patch: вынес кап на module-level _MAX_BACKOFF_S=30s, применяю к ОБЕИМ
веткам (Retry-After И exp.backoff). raw_wait логируется отдельно для
наблюдаемости (видно когда провайдер просил больше).

Новый юнит-тест test_rate_limited_retry_after_capped: provider шлёт
retry_after=86400 → time.sleep вызывается с 30 (не 86400). 14/14
client тестов + 55/55 LLM-suite зелёные. ruff clean.

Closes #1209
2026-06-13 06:07:46 +00:00
4c2f19ace0 fix(market-metrics): resolve admin→micros for _price_sensitivity (#1211)
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
_price_sensitivity передавал сырое admin-имя ('Кировский') в _elasticity_coef,
который фильтрует objective_corpus_room_month.district по МИКРО-вокабуляру
(Втузгородок, ЖБИ, …) → регрессия получала 0 точек → всегда FALLBACK_ELASTICITY.
§9.2 district-level эластичность молча НЕ считалась в /analyze-пути (только
'Академический' совпадал в обоих вокабулярах случайно).

Fix: вызываем resolve_objective_districts() в _price_sensitivity и передаём
список микро через новый kwarg districts=[…] в _elasticity_coef. Резолвер
None ('не определён' / нет чистых алиасов) → пустой список → EKB-wide
регрессия. _elasticity_coef расширен с back-compat: districts=None →
legacy путь по district_name (другой caller в analytics_queries —
отдельный bug class, вне scope).

5 новых юнит-тестов TestPriceSensitivityDistrictResolution: admin→micros в
SQL bind, None→EKB-wide, regression preserved post-resolve, graceful.
76/76 market_metrics + 156/156 elasticity/sensitivity тестов зелёные.
ruff + psycopg v3 grep clean.

Closes #1211
2026-06-13 06:02:29 +00:00
bc2d393b05 fix(market_metrics): disambiguate ROLLUP grand-total via GROUPING() (#1214)
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m36s
Deploy / build-worker (push) Has been cancelled
_SALES_WINDOW_SQL делал GROUP BY ROLLUP (rooms_int), rooms_int nullable
(ETL пишет NULL для «неопределённого типа», sales_series.py:399 явно
обрабатывает None). Проданный лот с rooms_int IS NULL даёт ДВЕ строки
rooms_int IS NULL (NULL-группа + grand-total итог), неразличимые в
Python (оба if r["rooms_int"] is None).

MixedAggregate-план PG16 эмитит grand-total ПЕРВЫМ (среди hash-строк),
NULL-группа после → loop затирает units_total частичным счётом (живой
тест на PG16: 2000 → 200). Эффект: unit_velocity / absorption_rate
занижены, months_of_supply завышен → base_pace в demand_supply_forecast
неверный (recommendation.py:586) → reports/scoring врёт.

Patch:
- SQL: добавить GROUPING(rooms_int) AS is_total (=1 для grand-total).
- Python: ветвить по is_total, NULL-комнатную группу класть в
  by_room['unknown'] (отдельный бакет), аккумулировать через +=
  вместо assign (защита от будущих NULL-вариантов).
- Тесты: моки получили "is_total" поле (1 для grand-total, 0 иначе).

71/71 market_metrics тестов зелёные. ruff clean.

Closes #1214
2026-06-13 05:57:19 +00:00
8f3e461959 fix(competitors): _ACTIVE_STATUSES = русские значения как у домрф (#1213)
Some checks failed
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m27s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m59s
Deploy / deploy (push) Has been cancelled
_ACTIVE_STATUSES = frozenset({"sales", "construction"}) — английский словарь
никогда не совпадал с domrf_kn_objects.site_status, который scraper берёт
СЫРЫМ из siteStatus дом.рф (domrf_kn.py:316). Реальные prod-значения
русские: «Строящиеся»/«Сданные».

Прод-аудит:
- data/sql/105_add_sales_started_flag.sql фильтрует по 'Строящиеся' (~1322 строки).
- partial index 66_indexes_recommend.sql использует те же.
- analytics_queries.py, MarketTab.tsx, CompetitorTable.tsx — все на русских.

Эффект: у ВСЕХ Competitor в POST /parcels/{cad}/competitors is_active=False
и CompetitorsSummary.active_count=0 при любых данных — типизированный
контракт систематически врал.

Patch: _ACTIVE_STATUSES = frozenset({"Строящиеся"}). Заодно обновил два
unit-теста которые кодировали баг (использовали "sales"/"construction"
в моках, тестировали логику против сломанного словаря). Теперь моки
матчат реальную prod-форму.

51/51 competitors-тестов зелёные. ruff clean.

Closes #1213
2026-06-13 05:53:16 +00:00
d587b9e199 fix(supply_layers): L3 future-supply берёт ПОСЛЕДНИЙ снапшот → потом фильтр (#1212)
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 10s
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
_L3_FUTURE_SQL применял volatile-фильтры (ready_dt > horizon, free_flats
<= threshold) в WHERE ВНУТРИ CTE с DISTINCT ON (obj_id) ORDER BY
snapshot_date DESC. Это значит фильтр применялся ДО DISTINCT ON →
бралась «последний снапшот, ПРОШЕДШИЙ фильтр», а не последний в принципе.

Эффект на проде: объект, когда-то бывший «объявлен, не продаётся»
(free_flats=0/NULL), остаётся в L3 future-supply даже после открытия
продаж — свежий снапшот с free_flats=180 отфильтрован, взят старый
с free_flats=0. Двойной счёт с L1/L2 (open + future одного объекта),
stale flat_count/ready_dt в supply_layers и форсайте.

Patch: разделил фильтры на стабильные (в CTE: region_cd, district_name)
и volatile (во внешнем WHERE после DISTINCT ON: ready_dt, free_flats).
Зеркало паттерна L2-CTE (там volatile уже снаружи). Семантика теперь
матчит docstring: «свежий снапшот, затем фильтр».

62/62 supply_layers тестов зелёные. ruff clean. SQL psycopg v3
(CAST(:x AS interval)) уже корректен.

Closes #1212
2026-06-13 05:53:01 +00:00
c75e6a4cfa fix(scrape_kn): Redis-lock owner-token + resume retry on lock_held (#1216)
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m34s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Has been cancelled
Два concurrency-бага в scrape_kn:

1. Lock value="1" без owner-токена + TTL=30мин = заявленной длительности
   sweep'а (нулевой запас). Sweep > TTL → lock истекает, второй sweep
   стартует, первый при завершении безусловным r.delete сносит ЧУЖОЙ
   lock → возможен третий параллельный.

2. SIGKILL/redeploy mid-sweep: finally не выполняется, lock живёт до
   30 мин. worker_ready метит run 'zombie' и enqueue'ит resume_kn_run
   БЕЗ countdown, который ловит lock_held и возвращает skipped без
   retry → run теряется до недельного beat.

Patch:
- _region_lock: value = uuid4().hex; release через Lua check-and-delete
  (`if GET == ARGV[1] then DEL else 0 end`) — не сносим чужой lock.
- _LOCK_TTL_SECONDS 30 → 45 мин (полтора max sweep duration, запас).
- resume_kn_run: при lock_held → raise self.retry(countdown=300,
  max_retries=12) → ~час окно для подхвата вместо silent skip.
- scrape_kn_region (scheduled) намеренно остаётся skipped — beat
  поднимет в следующий weekly tick (другая семантика).

11 новых юнит-тестов (token uniqueness, Lua guard, retry-not-skip,
existing behaviors). 16/16 scrape_kn тестов зелёные. ruff clean.

Closes #1216
2026-06-13 05:44:24 +00:00
b00e338667 fix(nspd_geo): split zombie thresholds running=6m / paused=30m (#1215)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m38s
Deploy / deploy (push) Successful in 1m12s
В WAF-ветке `nspd_geo` worker спит до 240с (4 мин при consecutive_waf=8),
heartbeat коммитится ДО сна. Старый `cleanup_zombies` (`* * * * *`) с
порогом `INTERVAL '2 minutes'` для status IN ('running', 'paused')
гарантированно ре-enqueue'ил **живой** WAF-job:
- Два worker'а параллельно долбили забаненный NSPD-сервис, углубляя бан.
- SELECT pending без `FOR UPDATE SKIP LOCKED` → дубли запросов.
- Counter'ы done/failed двух инстансов затираются друг другом.

Бонус-баг: status='paused' (после 8 WAF подряд) воскресал через минуту,
аннулируя WAF-защиту.

Patch: разделил пороги через module-level константы и параметризованный
SQL (CAST(:x AS interval) per psycopg v3 rule):
- `_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"` — выше max WAF backoff (4 мин).
- `_ZOMBIE_PAUSED_THRESHOLD = "30 minutes"` — реальная WAF-пауза.

Beat-расписание (1 мин tick) не тронуто. WAF-логика / heartbeat-in-loop
(вариант B) / FOR UPDATE SKIP LOCKED (вариант «идеальный») вне scope.

4 новых юнит-теста (test_nspd_geo.py): psycopg v3 guard, оба порога
биндятся, два предиката вместо одного, paused > running.
18/18 nspd_geo тестов зелёные. ruff clean.

Closes #1215
2026-06-13 05:37:08 +00:00
323e399593 fix(audit): run_in_threadpool для write_audit_row в async middleware (#1202)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m43s
Deploy / build-worker (push) Successful in 2m49s
Deploy / deploy (push) Successful in 1m16s
audit_log_middleware зарегистрирован через app.middleware("http") (main.py:92)
→ async dispatch на event loop. После `await call_next(...)` response уже
отдан, но БЛОКИРУЕТ event loop пока идёт sync DB round-trip (SessionLocal/
execute/commit). Это останавливает ВСЕ другие запросы на loop'е до завершения
INSERT'а. При исчерпании connection pool (default 5+10, sync analyze держит
сессию ~15с inline-wait) checkout ждёт pool_timeout=30с НА loop'е → весь API,
включая /health, замирает.

Каждый аудируемый запрос: analyze, КАЖДЫЙ 2с-poll GET /forecast, export,
insight-write. На активной демо-сессии — десятки таких в минуту.

Fix: write_audit_row выполняется через fastapi.concurrency.run_in_threadpool
— sync IO в worker-потоке, event loop свободен для других запросов. Никаких
изменений в самой write_audit_row — она остаётся sync def с собственным
try/except + rollback.

16/16 audit-middleware тестов зелёные (поведение для caller'а не изменилось).

Closes #1202
2026-06-13 05:24:39 +00:00