-- 149_developer_attribution.sql -- #1088 (GG-форсайт) — детерминированный geo-join «пятно земли ↔ застройщик» для -- Site Finder analyze/competitors. Связывает РНС-сторону (ekburg_construction_permits) -- с домрф-стороной (domrf_kn_objects) через НОРМАЛИЗОВАННЫЙ ИНН — единственный -- стабильный кросс-источниковый ключ застройщика — и резолвит участок → застройщик -- (имя+ИНН), его проект/статус, и track-record застройщика по району/кварталу. -- -- ── ЗАЧЕМ ──────────────────────────────────────────────────────────────────────── -- До #1088 analyze/competitors «слепы» к РНС: domrf_kn_objects.dev_inn даёт застройщика -- для домрф-объектов, но НЕТ связки с РНС-разрешениями и НЕТ канон-имени застройщика -- (одно ИНН пишется по-разному: prod — 163 ИНН в permits имеют >1 написания имени; -- «ИП Хабибуллин О.В.» vs «ИП Хабибуллин Олег Вахалиевич»). Этот слой даёт: -- 1. fn_normalize_inn — детерминированная нормализация ИНН (10/12-знач, иначе NULL) -- 2. developer_registry — MATERIALIZED реестр застройщиков по норм-ИНН: канон-имя -- + track-record (refresh через fn_refresh_developer_registry, unique-idx по ИНН) -- 3. developer_attribution — алиас-VIEW с denormalized полями для analyze-блока -- 4. fn_developer_for_parcel(cad_num) — резолвер участок→застройщик (3-ступенчатый -- fallback: точный кадастр → spatial 150 м → кадастровый квартал) -- -- ── PROD-ФАКТЫ (подтверждено EXPLAIN/COUNT, 2026-06-13) ─────────────────────────── -- ekburg_construction_permits: 4059 строк; developer_inn TEXT not-null=3564 (87.8%), -- все pure-digit; 3401×10-знач (юрлица), 154×12-знач (ИП), 9×11-знач (СЛИПШИЙСЯ -- мусор → норм. отбрасывает в NULL). cadastral_number not-null=4054 (99.9%); 3439 -- full-cad, 57 multi-value (запятая/«;» — несколько ЗУ в одной РНС), 2 quarter-only. -- geom GEOMETRY(Point,4326) not-null=3994 (98.4%), GIST gist_ekburg_permits_geom. -- НЕТ btree на cadastral_number → exact-lookup = Seq Scan 33мс (фикс ниже). -- domrf_kn_objects: 10699 строк = 1543 distinct obj_id × снапшоты (2026-04-27..06-10), -- ВСЕ region_cd=66. dev_inn TEXT, на latest snapshot not-null=1543 (100%), все -- 10-знач. ИНН СТАБИЛЕН между снапшотами (0 объектов меняют ИНН). cad_quarter — -- формат «66:41:NNNNNNN», заполнен на 1131/1543 объектах (73%), НА LATEST snapshot -- часто NULL → берём с ЛЮБОГО снапшота где NOT NULL (тоже стабилен, 0 изменений). -- cad_building_num — ВЕЗДЕ NULL (не используем). НЕТ колонки geom (только -- latitude/longitude NUMERIC, у всех 1543) → domrf spatial идёт через MakePoint. -- district_name заполнен на 1315/1543 (85%), 8 админ-районов ЕКБ. -- Кросс-матч норм-ИНН: 888 distinct в permits, 428 в domrf, СОВПАДАЮТ 292 (68.2% -- застройщиков domrf имеют РНС в ЕКБ). 1740 permit-строк (42.9%) принадлежат -- застройщику из domrf; 1240 domrf-объектов (80.4%) — застройщику из permits. -- -- ── ИНН-НОРМАЛИЗАЦИЯ (детерминированная, edge-cases) ────────────────────────────── -- fn_normalize_inn: btrim → если результат matches '^[0-9]{10}$' ИЛИ '^[0-9]{12}$' -- → вернуть как есть; ИНАЧЕ NULL. Покрывает edge-cases: -- NULL / '' / '0' → NULL (495 permit-строк, 0 domrf) -- 11-знач '66711382990' → NULL (9 permit-строк — два слипшихся ИНН/ИП; чинить -- эвристикой НЕ беремся — детерминированно мусор) -- ' 6671138299 ' (пробелы)→ '6671138299' (btrim) -- буквы/дефисы/запятые → NULL (не pure-digit) -- Ведущие нули НЕ режем: российский ИНН — фикс-длины строка-идентификатор, '00…' — -- значимые цифры (срезка сломала бы матч). IMMUTABLE — годен для functional index. -- -- ── КАНОН-ИМЯ (dedupe, детерминированный) ───────────────────────────────────────── -- Один ИНН → много написаний. Канон выбираем так (ORDER BY, без LLM/рандома): -- 1) приоритет источника: domrf-имя > permits-имя (domrf — юрлица из реестра дом.рф, -- чище и полнее, чем ручной ввод РНС); -- 2) внутри источника — самое ЧАСТОЕ написание (по числу строк); -- 3) tie-break — лексикографически наименьшее (btrim'нутое) → стабильно при ре-apply. -- -- ── РЕЗОЛВЕР УЧАСТОК→ЗАСТРОЙЩИК (3 ступени, детерминированный приоритет) ─────────── -- fn_developer_for_parcel(cad_num) возвращает атрибуцию с match_method: -- 'exact_cadastral' — permits.cadastral_number = cad_num (точное, max precision); -- 'spatial_150m' — permit.geom в 150 м от geom участка (GIST envelope + geography -- refine); берём ближайший permit; -- 'quarter' — по кадастровому кварталу: permits и domrf того же квартала. -- Ступени НЕ взаимоисключающи на уровне данных, но метод проставляется по ЛУЧШЕЙ -- сработавшей ступени (exact > spatial > quarter). Возвращаем ВСЕХ кандидатов с -- match_method и distance_m — caller (backend) решает, брать топ-1 или ранжировать. -- -- ── SPATIAL GIST-TRAP (важно для перфа) ─────────────────────────────────────────── -- ST_DWithin(geom::geography, …) НЕ использует GIST (индекс на geometry, не geography) -- → Seq Scan 91мс. Правильно: ST_DWithin(geom, geom, degrees) для GIST-envelope + -- ST_DWithin(::geography, ::geography, meters) для точной метрики. EXPLAIN: 91→12мс, -- 1689→132 буфера, Index Scan using gist_ekburg_permits_geom. 0.0025° envelope на -- широте ЕКБ (~56.8°N) безопасно перекрывает 150 м (1°lat≈111 км, 1°lon≈61 км). -- -- ── DEPLOY ──────────────────────────────────────────────────────────────────────── -- Auto-applied deploy.yml через _schema_migrations (ровно один раз, NN=149). -- Idempotent: CREATE OR REPLACE FUNCTION/VIEW + CREATE INDEX IF NOT EXISTS + -- DROP MATERIALIZED VIEW IF EXISTS ... CASCADE перед CREATE MATERIALIZED VIEW (matview -- не поддерживает CREATE OR REPLACE). CREATE MATERIALIZED VIEW наполняется данными -- СРАЗУ (без WITH NO DATA) → реестр готов к чтению по завершении миграции; первый -- плановый fn_refresh_developer_registry() просто обновит его при след. sync. -- Migration order: эта SQL-миграция ПЕРВОЙ; backend-сервис -- (app/services/site_finder/developer_attribution.py — рекомендация в отчёте) — следом. -- ЧИСТО ADDITIVE: ни одна существующая таблица/вью/колонка не меняется и не дропается. BEGIN; -- ────────────────────────────────────────────────────────────────────────────────── -- 1. fn_normalize_inn — детерминированная нормализация ИНН -- ────────────────────────────────────────────────────────────────────────────────── -- btrim + валидация длины/цифр. 10 (юрлицо) или 12 (физлицо/ИП) → как есть, иначе NULL. -- IMMUTABLE STRICT: STRICT → NULL-вход даёт NULL-выход без вызова тела; IMMUTABLE → -- можно использовать в functional index и планировщик кэширует результат. CREATE OR REPLACE FUNCTION fn_normalize_inn(raw text) RETURNS text LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT CASE WHEN btrim(raw) ~ '^[0-9]{10}$' OR btrim(raw) ~ '^[0-9]{12}$' THEN btrim(raw) ELSE NULL END; $$; COMMENT ON FUNCTION fn_normalize_inn(text) IS '#1088: детерминированная нормализация ИНН → 10/12-значная pure-digit строка, ' 'иначе NULL. NULL/пусто/0/11-знач-мусор/буквы → NULL. Ведущие нули сохраняются ' '(фикс-длины идентификатор). IMMUTABLE — годна для functional index.'; -- ────────────────────────────────────────────────────────────────────────────────── -- 2. Индексы для горячего пути резолвера (закрывают Seq Scan / ускоряют join по ИНН) -- ────────────────────────────────────────────────────────────────────────────────── -- 2a. btree на cadastral_number — exact-lookup (= cad_num) делал Seq Scan (4059 строк, -- 33мс). С индексом — Bitmap Index Scan (0.09мс). Equality collation-safe. CREATE INDEX IF NOT EXISTS idx_ekburg_permits_cadastral ON ekburg_construction_permits (cadastral_number); -- 2a-bis. text_pattern_ops btree — для quarter-ступени LIKE 'QUARTER:%'. БД collation -- en_US.utf8 (НЕ C) → обычный btree НЕ обслуживает ни LIKE-prefix, ни range -- ['X:','X;') (collation сортирует ':'/';'/цифры не по байтам → range вернул 0 -- строк, см. dry-run-баг). text_pattern_ops индексирует по C-семантике байтов → -- LIKE 'prefix%' даёт Bitmap Index Scan (0.74мс vs Seq Scan 6мс от regex '^'). CREATE INDEX IF NOT EXISTS idx_ekburg_permits_cad_cpat ON ekburg_construction_permits (cadastral_number text_pattern_ops); -- 2b. functional index на нормализованный ИНН — ускоряет join permits⋈domrf по -- fn_normalize_inn(developer_inn). Partial (WHERE norm IS NOT NULL) — мусорные -- ИНН (NULL после норм.) в индекс не попадают, держим его компактным. CREATE INDEX IF NOT EXISTS idx_ekburg_permits_inn_norm ON ekburg_construction_permits (fn_normalize_inn(developer_inn)) WHERE fn_normalize_inn(developer_inn) IS NOT NULL; -- 2c. functional index на норм-ИНН для domrf (join вторая сторона). domrf -- версионируется по snapshot_date → индекс по (norm_inn) хватает для матча -- застройщика (ИНН стабилен между снапшотами). CREATE INDEX IF NOT EXISTS idx_domrf_kn_objects_inn_norm ON domrf_kn_objects (fn_normalize_inn(dev_inn)) WHERE fn_normalize_inn(dev_inn) IS NOT NULL; -- ────────────────────────────────────────────────────────────────────────────────── -- 3. developer_registry — реестр застройщиков по нормализованному ИНН + track-record -- ────────────────────────────────────────────────────────────────────────────────── -- Канон-имя застройщика (dedupe вариантов) + агрегаты по ОБЕИМ сторонам: -- permits-сторона: число РНС/РВЭ, суммарные площади, диапазон лет, набор кварталов; -- domrf-сторона: число ЖК (latest snapshot per obj_id), суммарно квартир/в продаже, -- набор районов, набор классов. -- -- MATERIALIZED (не plain VIEW): как VIEW point-lookup по ИНН стоил 52мс, а резолвер -- 142-184мс — потому что план КАЖДЫЙ РАЗ пересчитывал весь реестр (DISTINCT ON 10699 -- domrf-строк + два FULL JOIN + dedupe-имя) ради джойна track-record к ~14-460 -- кандидатам участка. Реестр мал (1024 застройщика) и меняется РЕДКО (permits — -- monthly sync #105; domrf — снапшоты ~раз в сутки), поэтому материализуем: резолвер -- и прямой lookup читают готовые 1024 строки через unique-индекс за микросекунды. -- Refresh — fn_refresh_developer_registry() по расписанию (см. ниже + рекомендация -- backend). DROP ... CASCADE для идемпотентности (matview не поддерживает CREATE OR -- REPLACE): уронит зависимый developer_attribution VIEW — он пересоздаётся ниже в §4. DROP MATERIALIZED VIEW IF EXISTS developer_registry CASCADE; CREATE MATERIALIZED VIEW developer_registry AS WITH -- 3.1 domrf latest snapshot per obj_id (ИНН/имя стабильны между снапшотами). -- Только поля, нужные registry-агрегатам (canonical_name + domrf_agg). cad_quarter -- здесь НЕ тянем (registry агрегирует по району/классу, не по кварталу — а резолвер -- quarter_domrf читает domrf_kn_objects напрямую). Это снимает O(N) коррелированный -- subplan (был 93k буферов / 10699 loops в EXPLAIN) — один проход DISTINCT ON. domrf_latest AS ( SELECT DISTINCT ON (o.obj_id) o.obj_id, fn_normalize_inn(o.dev_inn) AS inn, btrim(o.dev_name) AS dev_name, o.obj_class, o.site_status, o.flat_count, o.free_flats, o.district_name FROM domrf_kn_objects o WHERE fn_normalize_inn(o.dev_inn) IS NOT NULL ORDER BY o.obj_id, o.snapshot_date DESC ), -- 3.2 Канон-имя: domrf-имя приоритетнее permits-имени; внутри — самое частое; -- tie → min lexical. Собираем кандидаты обеих сторон с приоритетом источника. name_candidates AS ( SELECT inn, dev_name AS name, 1 AS source_priority -- domrf > permits FROM domrf_latest WHERE dev_name IS NOT NULL AND dev_name <> '' UNION ALL SELECT fn_normalize_inn(developer_inn) AS inn, btrim(developer_name) AS name, 2 AS source_priority FROM ekburg_construction_permits WHERE fn_normalize_inn(developer_inn) IS NOT NULL AND developer_name IS NOT NULL AND btrim(developer_name) <> '' ), canonical_name AS ( SELECT DISTINCT ON (inn) inn, name AS developer_name FROM ( SELECT inn, name, source_priority, count(*) AS freq FROM name_candidates GROUP BY inn, name, source_priority ) ranked ORDER BY inn, source_priority ASC, freq DESC, name ASC ), -- 3.3 permits track-record по застройщику (норм-ИНН) permits_agg AS ( SELECT fn_normalize_inn(developer_inn) AS inn, count(*) AS permits_total, count(*) FILTER (WHERE permit_type = 'RNS') AS permits_rns, count(*) FILTER (WHERE permit_type = 'RVE') AS permits_rve, sum(total_area_sqm) AS permits_total_area_sqm, sum(living_area_sqm) AS permits_living_area_sqm, min(issue_date) AS permits_first_issue_date, max(issue_date) AS permits_last_issue_date, array_agg(DISTINCT substring(cadastral_number from '^(\d{2}:\d{2}:\d{6,7})')) FILTER (WHERE cadastral_number ~ '^\d{2}:\d{2}:\d{6,7}') AS permits_quarters FROM ekburg_construction_permits WHERE fn_normalize_inn(developer_inn) IS NOT NULL GROUP BY fn_normalize_inn(developer_inn) ), -- 3.4 domrf track-record по застройщику (по latest-snapshot объектам) domrf_agg AS ( SELECT inn, count(*) AS domrf_objects_total, sum(flat_count) AS domrf_flats_total, sum(free_flats) AS domrf_free_flats_total, count(*) FILTER (WHERE site_status = 'Строящиеся') AS domrf_active_objects, array_agg(DISTINCT district_name) FILTER (WHERE district_name IS NOT NULL) AS domrf_districts, array_agg(DISTINCT obj_class) FILTER (WHERE obj_class IS NOT NULL) AS domrf_classes FROM domrf_latest GROUP BY inn ) SELECT COALESCE(p.inn, d.inn) AS developer_inn, cn.developer_name AS developer_name, -- track-record permits-сторона COALESCE(p.permits_total, 0) AS permits_total, COALESCE(p.permits_rns, 0) AS permits_rns, COALESCE(p.permits_rve, 0) AS permits_rve, p.permits_total_area_sqm, p.permits_living_area_sqm, p.permits_first_issue_date, p.permits_last_issue_date, p.permits_quarters, -- track-record domrf-сторона COALESCE(d.domrf_objects_total, 0) AS domrf_objects_total, COALESCE(d.domrf_active_objects, 0) AS domrf_active_objects, d.domrf_flats_total, d.domrf_free_flats_total, d.domrf_districts, d.domrf_classes, -- известен ли застройщик обеим сторонам (кросс-валидация) (p.inn IS NOT NULL AND d.inn IS NOT NULL) AS in_both_sources FROM permits_agg p FULL OUTER JOIN domrf_agg d ON p.inn = d.inn LEFT JOIN canonical_name cn ON cn.inn = COALESCE(p.inn, d.inn); COMMENT ON MATERIALIZED VIEW developer_registry IS '#1088: реестр застройщиков по НОРМАЛИЗОВАННОМУ ИНН (fn_normalize_inn) — канон-имя ' '(domrf>permits, most-frequent, tie→min-lexical) + track-record обеих сторон ' '(permits РНС/РВЭ/площади/кварталы, domrf ЖК/квартиры/районы/классы). ' 'in_both_sources — кросс-валидация (prod: 292 ИНН). FULL OUTER JOIN — застройщик ' 'может быть только в одной стороне. MATERIALIZED — refresh через ' 'fn_refresh_developer_registry() (permits monthly / domrf daily).'; -- 3.5 UNIQUE индекс на developer_inn — обязателен для REFRESH ... CONCURRENTLY -- (иначе refresh блокирует читателей). Одновременно обслуживает point-lookup по ИНН -- (резолвер джойнит сюда, analyze тянет track-record по конкретному застройщику). -- developer_inn здесь уже НОРМАЛИЗОВАН и NOT NULL (COALESCE двух not-null норм-ИНН). CREATE UNIQUE INDEX IF NOT EXISTS uq_developer_registry_inn ON developer_registry (developer_inn); -- 3.6 fn_refresh_developer_registry — обёртка REFRESH CONCURRENTLY (вызывает Celery- -- beat / sync-задачи permits+domrf после обновления источников). CONCURRENTLY — не -- блокирует SELECT-читателей (analyze) во время refresh; требует unique-индекс выше. CREATE OR REPLACE FUNCTION fn_refresh_developer_registry() RETURNS void LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY developer_registry; END; $$; COMMENT ON FUNCTION fn_refresh_developer_registry() IS '#1088: REFRESH MATERIALIZED VIEW CONCURRENTLY developer_registry. Вызывать после ' 'обновления источников (ekburg_permits_sync monthly / domrf_kn daily snapshot).'; -- ────────────────────────────────────────────────────────────────────────────────── -- 4. developer_attribution — алиас-VIEW для analyze-блока (стабильное имя контракта) -- ────────────────────────────────────────────────────────────────────────────────── -- ТЗ #1088 называет целевую сущность `developer_attribution`. Держим её тонким VIEW -- поверх materialized developer_registry: тот же набор строк, имя зафиксировано в -- контракте (backend ходит сюда). VIEW поверх matview читается так же дёшево (matview -- — физическая таблица с unique-индексом). Пересоздаётся ЗДЕСЬ после DROP ... CASCADE -- в §3 (CASCADE роняет этот зависимый VIEW при ре-apply). CREATE OR REPLACE VIEW developer_attribution AS SELECT * FROM developer_registry; COMMENT ON VIEW developer_attribution IS '#1088: стабильное имя контракта для analyze-блока. Алиас materialized ' 'developer_registry (канон застройщик + track-record по норм-ИНН).'; -- ────────────────────────────────────────────────────────────────────────────────── -- 5. fn_developer_for_parcel — резолвер участок → застройщик (3-ступенчатый fallback) -- ────────────────────────────────────────────────────────────────────────────────── -- Вход: cad_num участка. Геометрию/квартал берём ВНУТРИ из cad_parcels_geom (view над -- cad_parcels, GIST cad_parcels_geom_gist) + _quarter_from_cad по строке номера. -- Выход: SETOF строк-кандидатов с источником/методом/дистанцией + denormalized -- track-record (join к developer_registry по ИНН). Caller (backend) ранжирует/берёт -- топ-1. p_radius_m — радиус spatial-ступени (default 150). -- -- STABLE (не IMMUTABLE) — читает таблицы. Параметр geom не принимаем: cad_num — -- единственный вход (геометрия резолвится внутри), чтобы контракт был как у -- competitors.get_competitors(cad_num). Если участка нет в cad_parcels_geom — -- spatial/quarter-domrf по geom не сработают, но exact-cadastral и quarter-permits -- (по строке cad_num) всё равно отработают. CREATE OR REPLACE FUNCTION fn_developer_for_parcel( p_cad_num text, p_radius_m double precision DEFAULT 150.0 ) RETURNS TABLE ( developer_inn text, developer_name text, source text, -- 'permits' | 'domrf' match_method text, -- 'exact_cadastral' | 'spatial_150m' | 'quarter' distance_m double precision, project text, -- permit object_name / domrf comm_name status text, -- permit_type / domrf site_status obj_class text, permits_total integer, domrf_objects_total integer, domrf_active_objects integer, in_both_sources bool ) LANGUAGE sql STABLE AS $$ WITH quarter AS ( -- кадастровый квартал из номера участка: '66:41:0313010:2' → '66:41:0313010' SELECT substring(p_cad_num from '^(\d{2}:\d{2}:\d{6,7})') AS q ), parcel AS ( SELECT geom, ST_Centroid(geom) AS centroid FROM cad_parcels_geom WHERE cad_num = p_cad_num AND geom IS NOT NULL LIMIT 1 ), -- ── Ступень 1: точный кадастр (permits.cadastral_number = cad_num) ────────────── -- Две ветки, объединённые UNION (каждая sargable / узкая): -- 1a. РОВНОЕ равенство = cad_num → Bitmap Index Scan по idx_ekburg_permits_cadastral -- (0.09мс; покрывает single-value строки = 98.6% permits); -- 1b. multi-value строки РНС (несколько ЗУ через запятую/«;» — prod: 57 строк): -- cad_num как подстрока на границе. Regex применяется ТОЛЬКО к узкому -- подмножеству (LIKE '%,%' OR '%;%') — не Seq Scan по всем 4059. exact_match AS ( SELECT fn_normalize_inn(e.developer_inn) AS inn, 'permits'::text AS source, 'exact_cadastral'::text AS match_method, 0.0::double precision AS distance_m, e.object_name AS project, e.permit_type AS status, e.object_type AS obj_class FROM ekburg_construction_permits e WHERE e.cadastral_number = p_cad_num -- 1a: btree equality UNION SELECT fn_normalize_inn(e.developer_inn) AS inn, 'permits'::text, 'exact_cadastral'::text, 0.0::double precision, e.object_name, e.permit_type, e.object_type FROM ekburg_construction_permits e WHERE (e.cadastral_number LIKE '%,%' OR e.cadastral_number LIKE '%;%') -- 1b: multi-value subset AND e.cadastral_number ~ ('(^|[,; ])' || replace(p_cad_num, '.', '\.') || '($|[,; ])') ), -- ── Ступень 2: spatial 150 м (permit.geom рядом с geom участка) ───────────────── -- GIST-friendly: ST_DWithin(geom, geom, deg) даёт index-scan по -- gist_ekburg_permits_geom, затем geography-refine на точные метры. 0.0025° ⊃ 150м. spatial_match AS ( SELECT fn_normalize_inn(e.developer_inn) AS inn, 'permits'::text AS source, 'spatial_150m'::text AS match_method, ST_Distance(e.geom::geography, pa.centroid::geography) AS distance_m, e.object_name AS project, e.permit_type AS status, e.object_type AS obj_class FROM ekburg_construction_permits e CROSS JOIN parcel pa WHERE e.geom IS NOT NULL AND ST_DWithin(e.geom, pa.geom, p_radius_m / 111000.0) AND ST_DWithin(e.geom::geography, pa.centroid::geography, p_radius_m) ), -- ── Ступень 3a: кадастровый квартал — permits ─────────────────────────────────── -- LIKE 'QUARTER:%' — sargable через text_pattern_ops индекс (idx_ekburg_permits_ -- cad_cpat): все ЗУ квартала имеют вид 'QUARTER:NNN'. → Bitmap Index Scan (0.74мс), -- вместо Seq Scan от regex '^' (6мс). НЕ range ['X:','X;') — он collation-unsafe на -- en_US.utf8 (вернул 0 строк в dry-run); LIKE-prefix на text_pattern_ops надёжен. -- '%' в q.q невозможен (квартал — '\d{2}:\d{2}:\d{6,7}'), escaping не нужен. quarter_permits AS ( SELECT fn_normalize_inn(e.developer_inn) AS inn, 'permits'::text AS source, 'quarter'::text AS match_method, NULL::double precision AS distance_m, e.object_name AS project, e.permit_type AS status, e.object_type AS obj_class FROM ekburg_construction_permits e, quarter q WHERE q.q IS NOT NULL AND e.cadastral_number LIKE q.q || ':%' ), -- ── Ступень 3b: кадастровый квартал — domrf (latest snapshot per obj_id) ───────── quarter_domrf AS ( SELECT DISTINCT ON (o.obj_id) fn_normalize_inn(o.dev_inn) AS inn, 'domrf'::text AS source, 'quarter'::text AS match_method, NULL::double precision AS distance_m, o.comm_name AS project, o.site_status AS status, o.obj_class FROM domrf_kn_objects o, quarter q WHERE q.q IS NOT NULL AND btrim(o.cad_quarter) = q.q ORDER BY o.obj_id, o.snapshot_date DESC ), -- Объединяем кандидатов всех ступеней. Если сработала ступень exact — caller -- получит её первой (distance_m=0); spatial/quarter дополняют контекст соседей. all_candidates AS ( SELECT inn, source, match_method, distance_m, project, status, obj_class FROM exact_match UNION ALL SELECT inn, source, match_method, distance_m, project, status, obj_class FROM spatial_match UNION ALL SELECT inn, source, match_method, distance_m, project, status, obj_class FROM quarter_permits UNION ALL SELECT inn, source, match_method, distance_m, project, status, obj_class FROM quarter_domrf ) SELECT c.inn AS developer_inn, reg.developer_name, c.source, c.match_method, c.distance_m, c.project, c.status, c.obj_class, reg.permits_total, reg.domrf_objects_total, reg.domrf_active_objects, reg.in_both_sources FROM all_candidates c LEFT JOIN developer_registry reg ON reg.developer_inn = c.inn WHERE c.inn IS NOT NULL -- детерминированный порядок: лучший метод первым, ближе — раньше, затем ИНН ORDER BY CASE c.match_method WHEN 'exact_cadastral' THEN 1 WHEN 'spatial_150m' THEN 2 WHEN 'quarter' THEN 3 ELSE 4 END, c.distance_m ASC NULLS LAST, c.inn; $$; COMMENT ON FUNCTION fn_developer_for_parcel(text, double precision) IS '#1088: резолвер участок→застройщик. 3 ступени (exact_cadastral > spatial_150m > ' 'quarter), геометрия/квартал берутся из cad_parcels_geom по cad_num. Возвращает ' 'SETOF кандидатов с match_method/distance_m + track-record из developer_registry. ' 'Caller берёт топ-1 (ORDER BY method, distance) или ранжирует. p_radius_m=150 по ' 'умолчанию. GIST-friendly spatial (ST_DWithin geom degrees + geography refine).'; COMMIT;