feat(permits): Celery beat monthly + analyze recent_permits (#105 Phase 4+5) #213
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
вторичка
ИРД
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#213
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/105-beat-and-analyze"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Closes #105 (Phase 1+2 в PR #211, Phase 3 geocoding deferred к отдельный issue).
Phase 4 — Celery beat schedule (+7 LOC)
backend/app/workers/beat_schedule.py— entry:Запускает
refresh_allежемесячно — все 5 лет 2022-2026.Phase 5 —
/analyzeintegration (+98 LOC + 28 schemas)Backend response добавляет:
engineering.recent_permits_in_quarter[]— до 50 записей за last 24moengineering.permits_summary:rns_count,rve_countrns_total_area_sqm,rve_total_unitsby_developer[top-3]— топ застройщиков по количеству permitsFilter:
LEFT(cadastral_number, LEN(quarter)) = quarter(3-segment match). Spatial 500m radius — deferred Phase 3 (требует geocoding или СНСК-66 → 4326 CRS resolve).Schemas:
RecentPermit+PermitsSummaryвschemas/parcel.pyдля типизации.Tests
test_analyze_recent_permits.py(NEW, 3 mock): in_quarter / empty / 404 regressiontest_analyze_inline_weights+test_analyze_market_price— comment updates для shifted execute orderingTest plan
refresh_allчерез beat →SELECT COUNT(*) FROM ekburg_construction_permits≥3000curl POST /analyzeна test cad ЕКБ →engineering.recent_permits_in_quarter+permits_summaryekburg-permits-monthlynext_run приcelery beat -l info(manual)Follow-up issues (НЕ в этом PR)
recent_permits_in_quarter→recent_permits_500m(после Phase 3)Closes #105.
Related: #211 (Phase 1+2 parser + schema + ETL).
Deep Code Review — verdict
Summary
7eb686b· Base:e450546· Mergeable: true (no conflicts)🔴 Critical (BLOCK merge)
1.
units_countcolumn does NOT exist вekburg_construction_permits—backend/app/api/v1/parcels.py:1873, 1896, 1910SQL запрос ссылается на колонку, которой нет в схеме:
Проверка
data/sql/96_ekburg_construction_permits.sql:15-59— таблица содержит только:total_area_sqm NUMERIC(общая площадь)living_area_sqm NUMERIC(площадь жилых помещений по проекту)living_area_fact_sqm NUMERIC(площадь жилых фактически, только РВЭ)Никакой
units_countв DDL не существует.Runtime impact (silent degradation):
psycopg.errors.UndefinedColumn: column "units_count" does not existexcept Exception as e: logger.warning(...)— ошибка глотается/analyzeзапроса вekburg_construction_permitsбудет логироваться warning + ответ degraded к пустомуpermits_summary+ пустомуrecent_permits_in_quarterПочему 3 теста зелёные: они мокают
db.execute()черезMagicMock(test_analyze_recent_permits.py:43-101) и подсовывают dict с произвольнымunits_countключом — SELECT никогда не выполняется на реальный Postgres. Это false-confidence — нужен integration test или schema-aware unit test (PydanticRecentPermit.model_validate(row)).Fix варианты (выбрать один с product owner — не угадывать):
A) Если поле "units" нужно для РВЭ → добавить в schema migration
97_ekburg_permits_units_count.sql:И обновить
services/scrapers/ekburg_permits.pyпарсер для извлечения этого поля (если колонка в xlsx вообще есть).B) Если "units" = квартиры → использовать
living_area_sqmили новое derived поле. Тогда:units_count, добавитьliving_area_sqmrve_total_units→rve_total_living_areaPermitsSummary:rve_total_units: int→rve_total_living_area_sqm: floatC) Если поле вообще не нужно → удалить
units_countотовсюду (SELECT, dict, schema, summary), убратьrve_total_unitsиз summary.Без этого fix Phase 5 deploy = no-op + log spam.
🟠 High (должен быть пофиксен до merge)
2. PR body описывает response shape, которая не соответствует коду —
parcels.py:2056-2058PR body говорит:
Реальный код кладёт оба поля в top-level response dict (
recent_permits_in_quarterиpermits_summaryна одном уровне сmarket_price,zoning, etc.) — там нетengineeringключа в analyze response.Это либо:
engineering.*структуру → переделать backend (но frontend пока никак не консумит —grep recent_permits frontend/= 0 matches, OK)Минимум — поправить PR body до merge, чтобы integration / qa-tester ожидали правильную структуру.
🟡 Medium
3. Duplicate
_quarter_prefixderivation —parcels.py:1856DRY: переиспользовать
_quarter_cad(уже вычислен 50 строк выше для market_price block). Удалить строку 1856 и подменить_quarter_prefix→_quarter_cadв SQL binding на 1880.4. SAVEPOINT не нужен для pure SELECT —
parcels.py:1866with db.begin_nested():нужен в loop'ах с per-row UPSERT (см.backend.mdправило). Для одного SELECT savepoint избыточен и добавляет round-trip к Postgres (BEGIN SAVEPOINT/RELEASE). Все остальные SELECT-блоки в analyze тоже обёрнуты — это видимо established pattern для error isolation, но perbackend.mdSAVEPOINT-rule он не оправдан для read-only. Не критично, существующий codebase pattern.5.
refresh_allблокирует worker 25-50 минут sync —tasks/ekburg_permits_sync.py:149Это pre-existing design из #211, не введено этим PR. Но при beat-запуске 1-го числа в 02:00 UTC slot
celeryqueue будет занят 25-50 минут. Если в этот момент стрельнёт другая monthly task (refresh-ekb-districts-medians5-го числа в 01:00 UTC, не пересекаются — OK) — нет конфликта. Acceptable, off-peak. Из scope этого PR.Рекомендация для отдельного follow-up issue: переделать
refresh_allнаgroup([refresh_year.s(y) for y in years])чтобы 5 задач шли параллельно в worker pool.🟢 Low / nits
6. Schema
PermitsSummary.by_developer: list[dict[str, Any]]—schemas/parcel.py:31dict[str, Any]теряет типизацию. Лучше дочерняя модель:Не критично — TS codegen всё равно сгенерит
Record<string, any>[], но Pydantic validation strict'нее.7.
RecentPermit.issue_date: str | None—schemas/parcel.py:13В коде
r["issue_date"].isoformat()(date → str). Можно использоватьdatetime.date | Noneнапрямую — Pydantic v2 сериализует в ISO автоматически + сохраняет типизацию. Сейчасstrтеряет семантику.8. Tests не покрывают LIMIT 50 boundary —
test_analyze_recent_permits.py3 теста: 2 permits / 0 permits / 404. Нет теста с >50 строк → проверить что LIMIT работает + по
issue_date DESCотсортировано правильно. Низкий приоритет, edge case.Cross-file impact analysis
tasks.ekburg_permits_sync.refresh_all— task name match (@celery_app.task(name="tasks.ekburg_permits_sync.refresh_all")вtasks/ekburg_permits_sync.py:143)"0 2 1 * *"корректно парсится_parse_cron(5 fields, day_of_month=1, hour=2 UTC = 05:00 МСК)refresh-ekb-districts-medians04:00 МСК day 5;nspd-geo-zombie-cleanupкаждую минуту короткий;poi-sync-weeklyпонедельник 03:00 — разные slot'ы)grep recent_permits frontend/= 0) — backward-compat OKnpm run codegenдля добавленияrecent_permits_in_quarter+permits_summaryвapi-types.tsunits_countcolumn reference — см. 🔴 #1, blast radius = весь/analyzeendpoint для всех cad_num'ов ЕКБtest_analyze_inline_weightsиtest_analyze_market_priceкорректны (проверил_geotech_riskвызывается на line 2048 в response dict — после permits block 1865-1932)Vault cross-check
code/modules/ekburg_permits_parser.mdуже существует (из #211)decisions/дляquarter prefix vs spatial 500mdeferred choice — нужно создатьdecisions/2026-05-16-ekburg-permits-quarter-prefix-deferred-spatial.md(Phase 3 deferred rationale)units_countошибки — после fix создатьfixes/Bug_Ekburg_Permits_UnitsCount_Missing_Column.mdCAST(:q AS text)использован корректно (psycopg v3 trap avoided) —LEFT(cadastral_number, LENGTH(CAST(:q AS text))) = CAST(:q AS text)logger.warning(не print) — convention OKexcept Exception as e: logger.warning(...)— log+swallow pattern matches existing analyze blocks (zoning, market_price, etc.), consistentPerformance findings
WHERE LEFT(cadastral_number, LENGTH(...)) = ...НЕ используетidx_ekburg_permits_developer/idx_ekburg_permits_issue_date.LEFT(cadastral_number, N)это expression, не cad_num как есть → planner делает Seq Scanekburg_construction_permits(per #211) ~3000-15000 rows за 5 лет → Seq Scan приемлем (≤10ms)CREATE INDEX idx_ekburg_permits_cad_prefix ON ekburg_construction_permits (LEFT(cadastral_number, 14));— но 14 hardcoded и не работает для разных длин quarter prefix. Лучше дождаться Phase 3 (spatial geom + GIST).ORDER BY issue_date DESC LIMIT 50—idx_ekburg_permits_issue_dateпоможет (после filter)Positive observations
CAST(:q AS text)(правильно для psycopg v3, не:q::text)with db.begin_nested():— error isolation pattern consistent с другими analyze blocksexcept Exception as e: logger.warning— graceful degradation, не валит весь /analyze_parse_cronhelper +queue=celery+ comment с МСК timezone hintRecentPermit+PermitsSummary) — не один godclasstask=...↔@celery_app.task(name=...)↔tasks/__init__.pyregistryRecommended next steps
units_countс product (option A/B/C выше)data/sql/97_ekburg_permits_units_count.sqlмиграцию + парсер updateliving_area_sqmполеunits_countотовсюду (SELECT, dict, schema, summary, tests)@pytest.mark.integration) который реально hit'ит test DB — schema mismatch поймает CIengineering.*упоминание (response — top-level)_quarter_prefix(medium #3)npm run codegenво frontend (после schema стабилизации)decisions/2026-05-16-ekburg-permits-quarter-prefix-deferred-spatial.mdComplexity / blast radius score
Auto-merge policy: scope blocked (touches
backend/app/api/v1/**+backend/app/services/**adjacent +backend/app/workers/**) → human merge required даже при APPROVE. Текущий verdict = BLOCK, не APPROVE — fix required перед merge.Deep Code Review — verdict (re-review SHA
3abaf04)Summary
parcels.py,schemas/parcel.py,beat_schedule.py; P3: 3 tests)7eb686b: 397/-6 → 388/-6)Verification of previous findings
parcels.pySELECT'итunits_count3abaf04удаляет column из SELECT (line 1868-1871), dict-build (line 1883-1894),RecentPermit.units_count,PermitsSummary.rve_total_units, fixture rows. Grepunits_countв parcels.py = 0 hits. Schema validation: 9 SELECT columns ↔ 9 dict keys ↔ 9RecentPermitfields ↔ все 13 столбцов SQLekburg_construction_permits(permit_type, permit_number, issue_date, expiry_date, developer_inn, developer_name, object_name, object_type, construction_address, cadastral_number, total_area_sqm, living_area_sqm, living_area_fact_sqm) — все 9 запрашиваемых существуют.engineering.recent_permits_in_quarternesting, code → top-level"recent_permits_in_quarter": recent_permits, "permits_summary": permits_summaryлежат на корне response, не вложены вengineering. Не блокирующее (frontend ещё не консьюмит), но PR body всё ещё врёт. Reduced 🟠→🟢 — fix in PR body или follow-up._quarter_prefix(был на line 1856)_quarter_cad(line 1806, market_price block). Grep_quarter_prefix= 0 hits. SELECT uses{"q": _quarter_cad}(line 1878).with db.begin_nested():). Защитный wrapper — если query упадёт (race с migration, недостающий column), rollback не порушит outer transaction. Consistent с pattern в market_price/zoning/success_recommendation. Acceptable.refresh_allблокирует worker slot 25-50 min — carryover #211Cross-file impact analysis
RecentPermit/PermitsSummarydefined inschemas/parcel.py(lines 7-29) но не импортируются вparcels.py— response остаётсяdict[str, Any]. Не блокирующее (schemas служат документацией и могут быть подключены позже), но decoupling между schema и runtime означает что schema drift не задетектится. 🟢 nit: добавить smoke testRecentPermit(**recent_permits[0])в test_analyze_recent_permits.test_analyze_inline_weights.py+test_analyze_market_price.pyобновлены — позиции 13/14 сдвинуты для нового permits call. Согласовано с фактическим порядком в parcels.py (market_price block @ 1808 перед permits @ 1863).tasks.ekburg_permits_sync.refresh_allсуществует (referenced PR #211 merged),_parse_cron("0 2 1 * *")соответствует documentedcrontab(0 2 1 * *).🟡 Medium (non-blocking)
engineering.recent_permits_in_quarternesting claim расходится с фактическим top-level placement. Либо обновить body, либо переместить подengineeringключ (но это уже behavior change — лучше fix body).refresh_alllock на worker — carryover из #211, не этого PR.🟢 Low / nits
schemas/parcel.py:7-29RecentPermit/PermitsSummaryопределены но не используются runtime — добавить либоresponse_modelна endpoint, либо хотя бы schema-roundtrip assert в test для anti-drift.Positive observations
_quarter_caddedup устраняет duplication — DRY.CAST(:q AS text)— корректный psycopg v3 pattern (per.claude/rules/backend.mdCAST trap).assert "units_count" not in rve+assert "rve_total_units" not in summary— anti-regression assertions.Complexity / blast radius score
Auto-merge decision
❌ Auto-merge BLOCKED per scope policy — PR touches:
backend/app/api/v1/parcels.py(blocked path)backend/app/services/-adjacentworkers/beat_schedule.py(Celery schedule = production runtime)backend/app/schemas/parcel.py(API contract changes)✅ APPROVE verdict, но human merge required. @ user — ready для
gh pr merge 213 --squash --delete-branchили Forgejo UI merge button.Recommended next steps
engineering.recent_permits_in_quarter→recent_permits_in_quarter(top-level).SELECT COUNT(*) FROM ekburg_construction_permitsпосле первого beat run ≥3000.curl POST /api/v1/parcels/{cad}/analyzeна ЕКБ cad → проверить ключиrecent_permits_in_quarter,permits_summaryна top-level.celery beat -l infoпоказываетekburg-permits-monthlyв schedule.code/modules/ekburg_construction_permits.mdобновить с Phase 4+5 wiring.