"""Beat schedule builder for Celery. Priority order: 1. DB-driven — читает таблицу job_settings при старте beat-контейнера. 2. Fallback — env-based расписание (SCRAPE_KN_CRON, OBJECTIVE_SYNC_CRON), используется если БД недоступна или таблица пустая. 3. Hardcoded entries — добавляются поверх DB-расписания (не в job_settings): - refresh-ekb-districts-medians (аналитика, ежемесячно 5-го в 04:00) - nspd-geo-zombie-cleanup (каждую минуту, замена нестабильного worker_ready) - poi-sync-weekly (OSM POI, понедельник 03:00) - noise-sync-weekly (OSM шум, понедельник 03:30) Публичный API: build_beat_schedule() → dict. """ import logging from celery.schedules import crontab from app.core.config import settings logger = logging.getLogger(__name__) # Словарь job_type → celery task name (полное dotted name). # Используется при построении beat_schedule из job_settings. TASK_MAP: dict[str, str] = { "scrape_kn": "tasks.scrape_kn.scrape_kn_region", "objective_sync": "tasks.scrape_objective.sync_all_groups", # nspd_geo и objective_etl не имеют cron — запускаются вручную. # При необходимости можно добавить сюда. } def _parse_cron(spec: str) -> crontab: """Parse 'M H DoM Mon DoW' crontab string into Celery crontab. Empty fields default to '*'.""" parts = spec.strip().split() if len(parts) != 5: raise ValueError(f"crontab spec must have 5 fields, got: {spec!r}") minute, hour, dom, month, dow = parts return crontab( minute=minute, hour=hour, day_of_month=dom, month_of_year=month, day_of_week=dow, ) def _default_regions() -> list[int]: return [int(x.strip()) for x in settings.scrape_kn_default_regions.split(",") if x.strip()] def _build_beat_schedule_from_db() -> dict: """Строит beat_schedule из job_settings БД. Returns: schedule (может быть пустой если все enabled rows без cron или disabled). `get_all` маскирует DB-unreachable case через возврат _DEFAULTS (4 synthetic rows, все enabled=True с дефолтными cron'ами) — это значит "DB unreachable" и "fresh install (table empty)" неотличимы от "normal с _DEFAULTS". В обоих случаях schedule НЕ будет пустым — _DEFAULTS дадут scrape_kn + objective_sync. КРИТИЧНО для operator-disable scenario: когда DB reachable и оператор сделал `UPDATE job_settings SET enabled=false WHERE job_type IN (...)` — get_all вернёт реальные rows (НЕ _DEFAULTS), loop их пропустит → schedule={}. Caller (build_beat_schedule) НЕ должен срывался на env fallback в этом случае, иначе disable бесполезен. См. инцидент 2026-05-24 (WAF cooldown disable scrape_kn → old code fallback'ал scrape_kn обратно из env). """ from app.core.db import SessionLocal from app.services.job_settings import get_all db = SessionLocal() try: rows = get_all(db) finally: db.close() schedule: dict = {} for row in rows: jt = row["job_type"] if not row.get("enabled"): continue cron_str = row.get("cron_schedule") if not cron_str: # NULL cron = только ручной запуск, не добавляем в beat continue if jt not in TASK_MAP: logger.warning( "build_beat_schedule: job_type '%s' не имеет TASK_MAP-записи — пропускаем", jt ) continue try: cron = _parse_cron(cron_str) except Exception as e: logger.warning( "build_beat_schedule: невалидный cron '%s' для '%s': %s", cron_str, jt, e ) continue queue = row.get("queue_name", "celery") or "celery" if jt == "scrape_kn": # scrape_kn запускается отдельной задачей на каждый регион extra = row.get("extra_config") or {} regions: list[int] = extra.get("default_regions") or _default_regions() for rc in regions: schedule[f"kn-region-{rc}"] = { "task": TASK_MAP[jt], "schedule": cron, "args": [rc, None], "options": {"queue": queue}, } logger.info( "build_beat_schedule: scrape_kn cron=%r regions=%s queue=%s", cron_str, regions, queue, ) else: kwargs: dict = {} if jt == "objective_sync": kwargs = {"triggered_by": "beat"} schedule[jt] = { "task": TASK_MAP[jt], "schedule": cron, "kwargs": kwargs, "options": {"queue": queue}, } logger.info("build_beat_schedule: %s cron=%r queue=%s", jt, cron_str, queue) # КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ), # раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00). schedule["ekb-krt-sync-weekly"] = { "task": "tasks.ekb_krt_sync.sync_krt_sites", "schedule": _parse_cron("0 5 * * wed"), "options": {"queue": "celery"}, } # РИАСУРТ Свердл (#108): региональные градостроительные зоны агломерации ЕКБ меняются # медленно → ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК). NB: harvest_all_* # пока на ПЛЕЙСХОЛДЕР-bbox 5 МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO) — фактический # прод-прогон только после уточнения границ. schedule["riasurt-sverdl-harvest-quarterly"] = { "task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl", "schedule": _parse_cron("0 4 1 1,4,7,10 *"), "options": {"queue": "celery"}, } return schedule def _build_beat_schedule_fallback() -> dict: """Fallback-расписание из env vars — используется если БД недоступна на старте.""" logger.warning("beat_schedule: строим fallback из env vars") schedule: dict = {} # scrape_kn try: for rc in _default_regions(): schedule[f"kn-region-{rc}"] = { "task": "tasks.scrape_kn.scrape_kn_region", "schedule": _parse_cron(settings.scrape_kn_cron), "args": [rc, None], } except Exception as e: logger.warning("beat_schedule fallback: scrape_kn failed: %s", e) # objective_sync try: from app.core.db import SessionLocal from app.services.objective_sync_config import get_cron_schedule_safe cron_str = get_cron_schedule_safe(SessionLocal) except Exception: cron_str = settings.objective_sync_cron try: schedule["objective-sync"] = { "task": "tasks.scrape_objective.sync_all_groups", "schedule": _parse_cron(cron_str), "kwargs": {"triggered_by": "beat"}, } except Exception as e: logger.warning("beat_schedule fallback: objective_sync failed: %s", e) # refresh ekb-districts medians — ежемесячно 5-го числа в 04:00 МСК try: schedule["refresh-ekb-districts-medians"] = { "task": "tasks.refresh_analytics.refresh_ekb_districts_medians", "schedule": _parse_cron("0 4 5 * *"), "kwargs": {"window_months": 24, "min_deals": 50}, } except Exception as e: logger.warning("beat_schedule fallback: refresh_analytics failed: %s", e) # КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ), # раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00). schedule["ekb-krt-sync-weekly"] = { "task": "tasks.ekb_krt_sync.sync_krt_sites", "schedule": _parse_cron("0 5 * * wed"), "options": {"queue": "celery"}, } # РИАСУРТ Свердл (#108): ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК). # NB: harvest_all_* на ПЛЕЙСХОЛДЕР-bbox 5 МО (riasurt_sverdl_harvest.MO_BBOXES TODO). schedule["riasurt-sverdl-harvest-quarterly"] = { "task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl", "schedule": _parse_cron("0 4 1 1,4,7,10 *"), "options": {"queue": "celery"}, } return schedule def build_beat_schedule() -> dict: """Строит beat_schedule из DB (job_settings) + добавляет hardcoded entries. `_build_beat_schedule_from_db` использует `get_all` который сам fallback'ает на _DEFAULTS при DB unreachable / fresh install → возвращает 4 synthetic rows (scrape_kn + objective_sync enabled). То есть schedule из DB-builder ПУСТ только когда оператор явно disabled все cron-able jobs через UPDATE job_settings — это **намерение**, респектим без env-fallback. Hardcoded entries (refresh-analytics, OSM POI/noise, nspd cleanup) добавляются ВСЕГДА — они не управляются через job_settings. """ schedule = _build_beat_schedule_from_db() # Всегда добавляем refresh_analytics (этот job нет в job_settings, # он технический и не требует конфигурации через UI). if "refresh-ekb-districts-medians" not in schedule: try: schedule["refresh-ekb-districts-medians"] = { "task": "tasks.refresh_analytics.refresh_ekb_districts_medians", "schedule": _parse_cron("0 4 5 * *"), "kwargs": {"window_months": 24, "min_deals": 50}, "options": {"queue": "celery"}, } except Exception as e: logger.warning("beat_schedule: refresh_analytics failed: %s", e) # Zombie cleanup: каждую минуту проверяет nspd_geo_jobs с heartbeat > 2 мин # и re-enqueue'ит их. Замена worker_ready signal handler'а — он не fires # стабильно в нашем setup, beat-task надёжнее. schedule["nspd-geo-zombie-cleanup"] = { "task": "tasks.nspd_geo.cleanup_zombies", "schedule": _parse_cron("* * * * *"), "options": {"queue": "celery"}, } # Bug #1236: парный cleanup для cadastre_jobs. Переводит застрявшие # 'running' jobs (heartbeat > 5 мин) в 'paused', чтобы оператор мог # resume через admin API. Раз в 2 минуты — cadastre fanout меньше nspd_geo, # per-minute опрос DB не оправдан. schedule["cadastre-zombie-cleanup"] = { "task": "tasks.cadastre.cleanup_zombies", "schedule": _parse_cron("*/2 * * * *"), "options": {"queue": "geo"}, } # OSM POI sync — еженедельно в понедельник в 03:00 МСК schedule["poi-sync-weekly"] = { "task": "tasks.poi_sync.sync_osm_poi_ekb", "schedule": _parse_cron("0 3 * * mon"), "options": {"queue": "celery"}, } # OSM noise sources sync — еженедельно в понедельник в 03:30 МСК # (через 30 мин после poi-sync, чтобы не нагружать Overpass одновременно) schedule["noise-sync-weekly"] = { "task": "tasks.noise_sync.sync_osm_noise_sources_ekb", "schedule": _parse_cron("30 3 * * mon"), "options": {"queue": "celery"}, } # #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК. # Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233). schedule["ekburg-permits-monthly"] = { "task": "tasks.ekburg_permits_sync.refresh_all", "schedule": _parse_cron("0 5 1 * *"), "options": {"queue": "celery"}, } # Developer registry refresh (#1088 «GG-форсайт») — ежемесячно 1-го числа в 05:30 МСК, # через 30 мин ПОСЛЕ ekburg-permits-monthly (05:00), чтобы permits-сторона реестра # обновлялась на свежих РНС/РВЭ. fn_refresh_developer_registry() → REFRESH MATERIALIZED # VIEW CONCURRENTLY developer_registry (миграция 149; не блокирует читателей analyze). # Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233). Техническая # infra-задача (не в job_settings, как refresh-quarter-price-index). domrf-сторона # обновляется ежедневно (scrape_kn), но ИНН/track-record меняются редко — monthly хватает. schedule["developer-registry-refresh-monthly"] = { "task": "tasks.developer_registry_refresh.refresh_developer_registry", "schedule": _parse_cron("30 5 1 * *"), # 05:30 MSK, 1st of month "options": {"queue": "celery"}, } # ПЗЗ территориальных зон ЕКБ из PKK6 ArcGIS — DISABLED 2026-06-13 (issue #259). # # ПОЧЕМУ ОТКЛЮЧЕНО: источник pkk.rosreestr.ru/.../PKK6/ZONES deprecated (Росреестр # вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни # разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём: # zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD # territorial_zones dumps (Phase 2.5 bulk_harvest, layer 875838). Активный beat # каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал # ошибку как error → рекуррентил GlitchTip BACKEND-1B. # # Ручной перезапуск (если появится рабочий источник) остаётся через admin-эндпоинт # POST /api/v1/admin/scrape/pzz-sync. Ref: #233 (исходно «0 rows»), #259 (disable). # schedule["pzz-sync-monthly"] = { # "task": "tasks.pzz_sync.sync_pzz_zones_ekb", # "schedule": _parse_cron("0 3 1 * *"), # "options": {"queue": "celery"}, # } # Catalog-object scrape — наполняет ~25 NULL колонок domrf_kn_objects из SSR-страниц. # kn-API не отдаёт wall_type, energy_eff, ceiling_height_m, parking_* и т.д. # Вторник 04:00 UTC. batch 300/run → 1532 объекта за ~5 недель полного обновления. # # DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed # extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 UTC) насыпет # 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после # cooldown 24-48h (проверить через targeted test). # schedule["scrape-kn-catalog-objects-weekly"] = { # "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects", # "schedule": _parse_cron("0 4 * * 2"), # Tuesday 04:00 UTC # "kwargs": {"region_code": 66, "max_objects": 300}, # "options": {"queue": "celery"}, # } # Quarter price index refresh (#762) — monthly on the 5th at 05:00 MSK. # Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233). # Chains: mv_quarter_price_per_m2 → mv_quarter_price_index (in order, both CONCURRENTLY). # Runs 1 h after refresh-ekb-districts-medians (04:00 MSK) so deals data is settled. # Not in job_settings (technical infra task, no UI config needed). schedule["refresh-quarter-price-index"] = { "task": "tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain", "schedule": _parse_cron("0 5 5 * *"), # 05:00 MSK, 5th of month "options": {"queue": "celery"}, } # NSPD quarter dump refresh — re-enabled 2026-05-17 после Sub-PR B (#260) # переключения search_by_quarter на grid-walk. Foundation (#247) + integration # (#260) теперь возвращают полноценные dumps (territorial_zones, ЗОУИТ, risk # zones, engineering structures) вместо 0-3 features из single-pixel WMS. # # Schedule: Mon 04:00 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233). # batch_size=50 ограничивает fanout per # tick — при 11k+ кварталов в cad_quarters_geom полный backfill займёт # несколько недель, но защищает от WAF rate-limit burst. # max_age_days=90 — refresh свежее квартала; новые / отсутствующие dumps # тоже попадают через harvest_stale_quarters (берёт NULL fetched_at). schedule["nspd-harvest-stale-quarters"] = { "task": "tasks.nspd_sync.harvest_stale_quarters", "schedule": _parse_cron("0 4 * * mon"), # 04:00 МСК "kwargs": {"region_code": 66, "max_age_days": 90, "batch_size": 50}, "options": {"queue": "celery"}, } # CBR макро-показатели (ключевая ставка) → macro_indicator (#945 PR B, # GG-форсайт). Ставка меняется редко (8 заседаний ЦБ в год) — еженедельного # прогона с запасом достаточно. ON CONFLICT делает прогон идемпотентным # (полный бэкфилл с 2019-01-01 каждый раз; дёшево — ~1 SOAP-запрос + ~1500 # upsert'ов). Понедельник 05:30 МСК (Celery conf.timezone=Europe/Moscow, т.е. # crontab трактуется в МСК) — после publish итогов пятничного заседания. Задача # техническая, не управляется через job_settings — поэтому добавлена сюда (как # poi-sync / refresh-analytics). Оператор может перенести расписание правкой crontab. schedule["cbr-macro-sync-weekly"] = { "task": "tasks.cbr_macro_sync.cbr_macro_sync", "schedule": _parse_cron("30 5 * * mon"), "options": {"queue": "celery"}, } # Росстат макро-показатели (демография региона 65 / ЕКБ) → macro_indicator # (#946 EPIC2). Ряды Росстата (open-data) обновляются редко (годовые) — ежемесячного # прогона с большим запасом достаточно. ON CONFLICT делает прогон идемпотентным # (полный ре-fetch каждый раз; дёшево — пара CSV-файлов). 1-е число месяца 05:45 МСК # (Celery conf.timezone=Europe/Moscow → crontab в МСК); по нагрузке не пересекается с # cbr-macro (пн 05:30). ИПЦ/доходы/СМР-цены — EMISS-only за WAF, пока не тянутся # (см. rosstat_emiss docstring). Задача техническая, не управляется через job_settings — # добавлена сюда (как cbr/poi/refresh). Оператор может перенести расписание правкой crontab. schedule["rosstat-macro-sync-monthly"] = { "task": "tasks.rosstat_macro_sync.rosstat_macro_sync", "schedule": _parse_cron("45 5 1 * *"), "options": {"queue": "celery"}, } # Supply-layers пересчёт (#950 EPIC 6 Step5, GG-форсайт) → supply_layers (м.125). # Детерминированно UPSERT-ит L1(open)/L2(hidden)/L3(future) по всем районам Свердл. # обл. через supply_layers.compute_all_layers. ON CONFLICT идемпотентен в рамках # одного snapshot_date (same-day re-run обновляет мутабельные колонки). # # Понедельник 06:00 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК). # ORDERING ASSUMPTION: scrape_kn (наполняет domrf_kn_objects.free_flats, м.124 — # источник L2 hidden) идёт Mon 04:15 МСК (settings.scrape_kn_cron='15 4 * * mon'). # 06:00 даёт ~1.75 ч запаса, чтобы supply-layers считались по СВЕЖИМ free_flats. # Если scrape_kn перенесут позже/он затянется — сдвинуть этот cron соответственно # (или сделать chain). Задача техническая, не в job_settings (как cbr/poi/refresh). schedule["supply-layers-refresh-weekly"] = { "task": "tasks.supply_layers_refresh.supply_layers_refresh", "schedule": _parse_cron("0 6 * * mon"), "options": {"queue": "celery"}, } # ИРД-overlay harvest (#1067 B5): grid-walk готовых НСПД ИРД-слоёв по кварталам ЕКБ → # ird_overlays (м.132). Понедельник 05:00 МСК — до supply-layers (06:00), не конкурируя # с scrape_kn (04:15). Геометрия зон стабильна, еженедельно достаточно. Техническая, # не в job_settings (как cbr/poi/supply-layers). schedule["ird-harvest-weekly"] = { "task": "tasks.ird_harvest.harvest_ird_overlays", "schedule": _parse_cron("0 5 * * mon"), "options": {"queue": "celery"}, } # ЗОУИТ-harvest ЕКБ-геопортала (#1063/#1064/#1065/#1066, #1067 GG-форсайт): WFS-фетч # gknspecial_zone по full_name-префиксам → ird_overlays (layer_kind=servitude/water_*/ # airfield/okn_protection_zone/zrz). Понедельник 05:30 МСК — после ird-harvest (05:00), # до supply-layers (06:00). Геометрия зон стабильна, еженедельно достаточно. schedule["gknspecial-harvest-weekly"] = { "task": "tasks.gknspecial_harvest.harvest_gknspecial_zones", "schedule": _parse_cron("30 5 * * mon"), "options": {"queue": "celery"}, } # Opportunity-ЗУ harvest (#1086): grid-walk НСПД opportunity-слоёв по кварталам ЕКБ → # ird_overlays (layer_kind='opportunity_*'). 1-е число месяца 05:30 МСК — реже ИРД (weekly), # opportunity-земля меняется медленнее ограничений. Тот же движок, что ird-harvest. schedule["opportunity-harvest-monthly"] = { "task": "tasks.opportunity_harvest.harvest_opportunity_overlays", "schedule": _parse_cron("30 5 1 * *"), "options": {"queue": "celery"}, } # Cross-load ETL tradein→gendesign (#976 950-E5): tradein.houses → newbuilding_listings. # Ночной запуск: 00:30 UTC = 03:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК). # Не в job_settings (технический ETL, не требует конфигурации UI). # Идемпотентен через ON CONFLICT (source, ext_house_id). # Если TRADEIN_DATABASE_URL не задан → warn-log, {"disabled": True} без исключения. schedule["newbuilding-crossload-nightly"] = { "task": "tasks.etl_newbuilding_crossload.etl_newbuilding_crossload", "schedule": _parse_cron("30 0 * * *"), # 00:30 UTC = 03:30 МСК "options": {"queue": "celery"}, } # ППТ/ПМТ harvest (#1085): WFS-фетч геопортала ЕКБ по bbox агломерации → planning_projects. # 1-е число месяца 06:00 МСК — после opportunity (05:30). Документы планировки стабильны, # ежемесячно достаточно. WFS BBOX (не grid-walk) — один запрос на слой. schedule["planning-harvest-monthly"] = { "task": "tasks.planning_harvest.harvest_planning_projects", "schedule": _parse_cron("0 6 1 * *"), "options": {"queue": "celery"}, } # Прогрев кэша ПЗЗ-регламента зон ЕКБ (#1059/C8b): WFS-перечисление терзон → urbanCard → # zone_regulation_cache. 1-е число месяца 06:30 МСК — после planning (06:00). Регламент # меняется только при правке ПЗЗ, ежемесячно достаточно. Наполняет кэш C8b проактивно. schedule["zone-regulation-refresh-monthly"] = { "task": "tasks.zone_regulation_refresh.refresh_zone_regulations", "schedule": _parse_cron("30 6 1 * *"), "options": {"queue": "celery"}, } # КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ), # раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00). schedule["ekb-krt-sync-weekly"] = { "task": "tasks.ekb_krt_sync.sync_krt_sites", "schedule": _parse_cron("0 5 * * wed"), "options": {"queue": "celery"}, } # КРТ-геометрии ЕКБ (#1130): WFS-фичи portal_geo_oks_krt → ekb_krt_geometry. # Геометрия КРТ-территорий стабильна (новые площадки — квартальные решения ПАГЕ), # раз в неделю достаточно. Среда 05:30 МСК — сразу после ekb-krt-sync-weekly (05:00), # одна сессия геопортала, не дублируем нагрузку. schedule["ekb-krt-geometry-sync-weekly"] = { "task": "tasks.krt_geometry_sync.sync_krt_geometry", "schedule": _parse_cron("30 5 * * wed"), "options": {"queue": "celery"}, } # Функциональные зоны генплана ЕКБ-2045 (#1058): ГИСОГД-СО WFS → # ekb_genplan_functional_zone (м.139). ~6436 полигонов, EPSG:4326. Генплан # меняется редко (раз в год+), еженедельно избыточно но дёшево — WFS-фетч + # upsert по globalid идемпотентен. Понедельник 06:30 МСК — после supply-layers # (06:00). Техническая, не в job_settings. schedule["genplan-zones-sync-weekly"] = { "task": "tasks.genplan_zones_sync.sync_functional_zones", "schedule": _parse_cron("30 6 * * mon"), "options": {"queue": "celery"}, } # ОКН-объекты ЕКБ/Свердл.обл из АИС ЕГРКН (#1066): POST searchMap → # okn_objects (м.141). ~800+ фич (54 фед + 213 рег + remainder по ЕКБ; # область добавляет дополнительные). Воскресенье 04:30 МСК — не конкурирует # с ird-harvest (понедельник 05:00) и gknspecial (05:30). Реестр ОКН меняется # медленно — еженедельно достаточно. Техническая, не в job_settings. schedule["okn-objects-sync-weekly"] = { "task": "tasks.okn_objects_sync.sync_okn_objects", "schedule": _parse_cron("30 4 * * sun"), "options": {"queue": "celery"}, } # ОКН-анализ ЕКБ (#1062): OCR PDF-сканов изъятия с екатеринбург.рф → land_reservation. # Постановления об изъятии выходят редко (2-4 раза в год) → еженедельного прогона хватает. # Пятница 07:00 МСК — не пересекается с monday-кластером (ird/gkn/supply/cbr). # Tesseract rus: каждый PDF ~30 стр., OCR ~1-2 мин/PDF → итого <10 мин/прогон. schedule["izyatie-ocr-ingest-weekly"] = { "task": "tasks.izyatie_ocr_ingest.ingest_izyatie_ocr", "schedule": _parse_cron("0 7 * * fri"), "options": {"queue": "celery"}, } # District-level индексы локаций (#948 Part B, §8.2) → location (м.146): reuse # per-district forecast-функций (market_metrics/future_supply/POI), нормализация # 0..1, upsert по 8 районам ЕКБ. Понедельник 07:00 МСК — ПОСЛЕ supply-layers # (06:00) и genplan (06:30), чтобы future_supply-индекс считался по СВЕЖЕМУ складу # предложения. Индексы меняются медленно (рынок поглощает сток неделями) — # еженедельно достаточно. ON CONFLICT идемпотентен. Техническая, не в job_settings # (как cbr/supply-layers). Оператор может перенести расписание правкой crontab. schedule["location-refresh-weekly"] = { "task": "tasks.location_refresh.location_refresh", "schedule": _parse_cron("0 7 * * mon"), "options": {"queue": "celery"}, } # Refresh mv_layout_velocity (#1666) — питает «лучшие планировки» best_layouts (#113). # До этого MV рефрешился ТОЛЬКО ручным вызовом refresh_layout_velocity() → в проде # данные молча устаревали. REFRESH ... CONCURRENTLY (non-blocking) по mv_layout_velocity. # # Воскресенье 03:00 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233). # Намеренно ВНЕ monday-кластера тяжёлых site_finder-рефрешей (scrape_kn 04:15, # ird 05:00, gknspecial 05:30, supply-layers 06:00, genplan 06:30, location 07:00) и # вне воскресного okn-objects (04:30) — час запаса, не конкурирует за БД/CPU. # MV агрегирует темпы вымывания планировок (меняются медленно) → еженедельно хватает. # Техническая infra-задача, не в job_settings (как refresh-quarter-price-index / supply-layers). schedule["refresh-layout-velocity"] = { "task": "tasks.refresh_layout_velocity.refresh_layout_velocity", "schedule": _parse_cron("0 3 * * sun"), # 03:00 MSK, воскресенье "options": {"queue": "celery"}, } # Sales-tracker MVs (#61) — питают Site Finder Velocity Score (4-й критерий) + # recommend_mix / sellout-forecast. Оба MV (mv_sales_tracker_velocity_by_district, # mv_sales_tracker_absorption_curves) рефрешатся CONCURRENTLY (non-blocking, требуют # unique-индексы из миграции 161). Источник — objective_lots / objective_lots_history # (Объектив-шахматки), наполняются objective_sync (Mon 04:15 МСК по умолчанию). # # Понедельник 04:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233) — # ПОСЛЕ objective_sync (04:15), чтобы агрегаты считались по свежему снапшоту; в # окне до тяжёлого monday-кластера site_finder-рефрешей (ird 05:00, gknspecial 05:30, # supply-layers 06:00). Refresh лёгкий (~6с на 1.1M lots). Техническая infra-задача, # не в job_settings (как refresh-quarter-price-index / refresh-layout-velocity). schedule["mv-sales-tracker-refresh-weekly"] = { "task": "tasks.mv_sales_tracker_refresh.refresh_sales_tracker_mvs", "schedule": _parse_cron("30 4 * * mon"), # 04:30 MSK, понедельник "options": {"queue": "celery"}, } return schedule