gendesign/data/sql/149_developer_attribution.sql
Light1YT b211183940
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
feat(site-finder): детерминированная атрибуция застройщика в analyze (#1088)
Сервис 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

477 lines
33 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 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;