feat(sf): ППТ/ПМТ WFS ingest → planning_projects (future-supply, #1104)
Миграция 134_planning_projects.sql + planning_harvest.py: ингест утверждённых ППТ/ПМТ ЕКБ из геопортал-WFS (один BBOX-запрос на слой, geom уже 4326 → SetSRID без Transform) → planning_projects (project_type/source_key UNIQUE, doc_status/год). SAVEPOINT per-row, beat monthly (1-е 06:00). Зависит от TLS-фикса #1103. Документ-центричная таблица отдельно от ird_overlays. 3 теста. ТЭП/PDF — follow-up. Refs #1085. Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
This commit is contained in:
parent
e7a0af5ddc
commit
9e4348629b
5 changed files with 356 additions and 0 deletions
|
|
@ -341,4 +341,13 @@ def build_beat_schedule() -> dict:
|
||||||
"options": {"queue": "celery"},
|
"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"},
|
||||||
|
}
|
||||||
|
|
||||||
return schedule
|
return schedule
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.forecast",
|
"app.workers.tasks.forecast",
|
||||||
"app.workers.tasks.ird_harvest",
|
"app.workers.tasks.ird_harvest",
|
||||||
"app.workers.tasks.opportunity_harvest",
|
"app.workers.tasks.opportunity_harvest",
|
||||||
|
"app.workers.tasks.planning_harvest",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
celery_app.conf.timezone = "Europe/Moscow"
|
celery_app.conf.timezone = "Europe/Moscow"
|
||||||
|
|
|
||||||
157
backend/app/workers/tasks/planning_harvest.py
Normal file
157
backend/app/workers/tasks/planning_harvest.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""Celery task: harvest ППТ/ПМТ ЕКБ из геопортал-WFS → ``planning_projects`` (#1085).
|
||||||
|
|
||||||
|
Future-supply: утверждённые проекты планировки (ППТ) и межевания (ПМТ) территории ЕКБ. Источник —
|
||||||
|
ЕКБ-геопортал GeoServer WFS (``ekb_geoportal_client``, #1083): слои ``ppt`` (dmd_prj_plan) и
|
||||||
|
``pmt`` (dmd_prj_mej). Каждая фича — документ планировки с геометрией охвата (EPSG:4326) +
|
||||||
|
doc_status / dmd_actual_year (live-verified 2026-06-06, vault EKB_Geoportal_WFS_Construction_Intel).
|
||||||
|
|
||||||
|
Отличие от ИРД/opportunity-harvest (НСПД WMS grid-walk по кварталам): WFS BBOX отдаёт все фичи слоя
|
||||||
|
в прямоугольнике ОДНИМ запросом (нет point-probe grid-walk), геометрия уже 4326 (нет ST_Transform).
|
||||||
|
Поэтому проходим ОДИН bbox агломерации на слой.
|
||||||
|
|
||||||
|
Mirror conventions (ird_harvest.py / backend.md): SessionLocal()+try/finally; SAVEPOINT per-row
|
||||||
|
(``with db.begin_nested():``); CAST(:x AS type) (psycopg v3); сбой фичи/слоя не валит прогон.
|
||||||
|
|
||||||
|
ТЭП/очередность из PDF пояснительных записок (pdfplumber) — отдельный проход (#1085 follow-up):
|
||||||
|
тут только WFS-слой (геометрия + статус документа). Расписание — ежемесячно (документы стабильны).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.scrapers.ekb_geoportal_client import EKBFeature, EKBGeoportalClient
|
||||||
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Слои геопортала для harvest — ключи WFS_LAYERS → project_type в planning_projects.
|
||||||
|
PLANNING_LAYERS: dict[str, str] = {"ppt": "ppt", "pmt": "pmt"}
|
||||||
|
|
||||||
|
# bbox агломерации ЕКБ (minlon, minlat, maxlon, maxlat) EPSG:4326 — охватывает город + пригороды.
|
||||||
|
EKB_AGGLOMERATION_BBOX: tuple[float, float, float, float] = (60.0, 56.6, 61.1, 57.1)
|
||||||
|
|
||||||
|
# UPSERT фичи. Геометрия WFS (GeoJSON 4326) → ST_GeomFromGeoJSON → SetSRID 4326 (уже 4326, не
|
||||||
|
# Transform). ON CONFLICT (project_type, source_key): обновляем реквизиты/геометрию/fetched_at.
|
||||||
|
_UPSERT_SQL = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO planning_projects (
|
||||||
|
project_type, source_key, full_name, doc_full_name, doc_status,
|
||||||
|
doc_status_name, project_name, dmd_actual_year, geom, raw_props, fetched_at
|
||||||
|
) VALUES (
|
||||||
|
CAST(:project_type AS text),
|
||||||
|
CAST(:source_key AS bigint),
|
||||||
|
CAST(:full_name AS text),
|
||||||
|
CAST(:doc_full_name AS text),
|
||||||
|
CAST(:doc_status AS text),
|
||||||
|
CAST(:doc_status_name AS text),
|
||||||
|
CAST(:project_name AS text),
|
||||||
|
CAST(:dmd_actual_year AS integer),
|
||||||
|
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geojson AS text)), 4326),
|
||||||
|
CAST(:raw_props AS jsonb),
|
||||||
|
CAST(:fetched_at AS timestamptz)
|
||||||
|
)
|
||||||
|
ON CONFLICT (project_type, source_key) DO UPDATE SET
|
||||||
|
full_name = EXCLUDED.full_name,
|
||||||
|
doc_full_name = EXCLUDED.doc_full_name,
|
||||||
|
doc_status = EXCLUDED.doc_status,
|
||||||
|
doc_status_name = EXCLUDED.doc_status_name,
|
||||||
|
project_name = EXCLUDED.project_name,
|
||||||
|
dmd_actual_year = EXCLUDED.dmd_actual_year,
|
||||||
|
geom = EXCLUDED.geom,
|
||||||
|
raw_props = EXCLUDED.raw_props,
|
||||||
|
fetched_at = EXCLUDED.fetched_at
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(val: object) -> int | None:
|
||||||
|
"""Год актуальности может прийти строкой/числом/None — нормализуем в int|None."""
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(str(val).strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_feature(
|
||||||
|
db: Session, *, project_type: str, feature: EKBFeature, fetched_at: str
|
||||||
|
) -> bool:
|
||||||
|
"""UPSERT одной WFS-фичи в planning_projects под SAVEPOINT. True если записана.
|
||||||
|
|
||||||
|
Пропускает фичи без geometry или без `key` (дедуп-ключ) — defensive, одна битая не валит батч.
|
||||||
|
"""
|
||||||
|
geom = feature.geometry
|
||||||
|
props = feature.properties or {}
|
||||||
|
source_key = props.get("key")
|
||||||
|
if not geom or source_key is None:
|
||||||
|
return False
|
||||||
|
doc_status = props.get("doc_status")
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
db.execute(
|
||||||
|
_UPSERT_SQL,
|
||||||
|
{
|
||||||
|
"project_type": project_type,
|
||||||
|
"source_key": int(source_key),
|
||||||
|
"full_name": props.get("full_name"),
|
||||||
|
"doc_full_name": props.get("doc_full_name"),
|
||||||
|
"doc_status": str(doc_status) if doc_status is not None else None,
|
||||||
|
"doc_status_name": props.get("doc_status_name"),
|
||||||
|
"project_name": props.get("project_name"),
|
||||||
|
"dmd_actual_year": _to_int(props.get("dmd_actual_year")),
|
||||||
|
"geojson": json.dumps(geom),
|
||||||
|
"raw_props": json.dumps(props, ensure_ascii=False),
|
||||||
|
"fetched_at": fetched_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"planning_harvest: upsert failed type=%s key=%s: %s", project_type, source_key, exc
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="tasks.planning_harvest.harvest_planning_projects")
|
||||||
|
def harvest_planning_projects(
|
||||||
|
bbox: tuple[float, float, float, float] | None = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Harvest ППТ/ПМТ ЕКБ из геопортал-WFS по bbox → upsert planning_projects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bbox: (minlon, minlat, maxlon, maxlat) EPSG:4326. None → bbox агломерации ЕКБ.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{'features': M} — записано/обновлено документов планировки.
|
||||||
|
"""
|
||||||
|
fetched_at = datetime.now(UTC).isoformat()
|
||||||
|
target_bbox = bbox if bbox is not None else EKB_AGGLOMERATION_BBOX
|
||||||
|
client = EKBGeoportalClient()
|
||||||
|
db = SessionLocal()
|
||||||
|
n_features = 0
|
||||||
|
try:
|
||||||
|
logger.info("planning_harvest: старт, bbox=%s", target_bbox)
|
||||||
|
for layer_key, project_type in PLANNING_LAYERS.items():
|
||||||
|
try:
|
||||||
|
feats = client.features_in_bbox(layer_key, target_bbox)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("planning_harvest: WFS failed layer=%s: %s", layer_key, exc)
|
||||||
|
continue
|
||||||
|
for feat in feats:
|
||||||
|
if _upsert_feature(
|
||||||
|
db, project_type=project_type, feature=feat, fetched_at=fetched_at
|
||||||
|
):
|
||||||
|
n_features += 1
|
||||||
|
db.commit()
|
||||||
|
logger.info("planning_harvest: готово, документов=%d", n_features)
|
||||||
|
return {"features": n_features}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
111
backend/tests/workers/test_planning_harvest.py
Normal file
111
backend/tests/workers/test_planning_harvest.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""Тесты harvest_planning_projects (#1085) — геопортал-WFS ППТ/ПМТ → planning_projects.
|
||||||
|
|
||||||
|
Сеть/БД не дёргаются: мокаем EKBGeoportalClient + SessionLocal, проверяем фетч обоих слоёв,
|
||||||
|
project_type-маппинг, skip битых фич, нормализацию года и upsert-параметры.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.workers.tasks import planning_harvest
|
||||||
|
|
||||||
|
|
||||||
|
class _Feat:
|
||||||
|
def __init__(self, geometry: dict[str, Any] | None, properties: dict[str, Any]) -> None:
|
||||||
|
self.geometry = geometry
|
||||||
|
self.properties = properties
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
"""EKBGeoportalClient-двойник: layer → 1 фича на слой."""
|
||||||
|
|
||||||
|
def __init__(self, *a: Any, **kw: Any) -> None:
|
||||||
|
self.bbox_calls: list[str] = []
|
||||||
|
|
||||||
|
def features_in_bbox(self, layer: str, bbox: Any) -> list[_Feat]:
|
||||||
|
self.bbox_calls.append(layer)
|
||||||
|
return [
|
||||||
|
_Feat(
|
||||||
|
{"type": "Polygon", "coordinates": [[[60, 56], [60.1, 56], [60, 56.1], [60, 56]]]},
|
||||||
|
{
|
||||||
|
"key": 1000010173321902 if layer == "ppt" else 1000010209297292,
|
||||||
|
"full_name": f"ПАГЕ № 1 ({layer})",
|
||||||
|
"doc_status": 1,
|
||||||
|
"doc_status_name": "действующий",
|
||||||
|
"project_name": "Тестовый проект",
|
||||||
|
"dmd_actual_year": "2021",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.executed: list[tuple[Any, dict[str, Any] | None]] = []
|
||||||
|
self.committed = False
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def begin_nested(self) -> Any:
|
||||||
|
yield
|
||||||
|
|
||||||
|
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
self.executed.append((sql, params))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(monkeypatch: Any, db: _FakeDB) -> _FakeClient:
|
||||||
|
client = _FakeClient()
|
||||||
|
monkeypatch.setattr(planning_harvest, "EKBGeoportalClient", lambda *a, **kw: client)
|
||||||
|
monkeypatch.setattr(planning_harvest, "SessionLocal", lambda: db)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_harvest_both_layers(monkeypatch: Any) -> None:
|
||||||
|
"""ППТ + ПМТ → upsert по фиче на слой; project_type-маппинг; year нормализован в int."""
|
||||||
|
db = _FakeDB()
|
||||||
|
client = _patch(monkeypatch, db)
|
||||||
|
|
||||||
|
result = planning_harvest.harvest_planning_projects()
|
||||||
|
|
||||||
|
assert result == {"features": 2}
|
||||||
|
assert sorted(client.bbox_calls) == ["pmt", "ppt"]
|
||||||
|
assert len(db.executed) == 2
|
||||||
|
types = {params["project_type"] for _, params in db.executed if params}
|
||||||
|
assert types == {"ppt", "pmt"}
|
||||||
|
_, p0 = db.executed[0]
|
||||||
|
assert p0 is not None
|
||||||
|
assert p0["dmd_actual_year"] == 2021 # строка "2021" → int
|
||||||
|
assert p0["doc_status"] == "1" # приведён к строке
|
||||||
|
assert isinstance(p0["geojson"], str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_feature_without_key_or_geom() -> None:
|
||||||
|
"""Фича без key / geometry → не пишется (defensive)."""
|
||||||
|
db = _FakeDB()
|
||||||
|
no_key = _Feat({"type": "Polygon", "coordinates": []}, {"full_name": "x"})
|
||||||
|
no_geom = _Feat(None, {"key": 5})
|
||||||
|
assert (
|
||||||
|
planning_harvest._upsert_feature(db, project_type="ppt", feature=no_key, fetched_at="t")
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
planning_harvest._upsert_feature(db, project_type="pmt", feature=no_geom, fetched_at="t")
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert db.executed == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_year_normalization() -> None:
|
||||||
|
assert planning_harvest._to_int("2021") == 2021
|
||||||
|
assert planning_harvest._to_int(2021) == 2021
|
||||||
|
assert planning_harvest._to_int(None) is None
|
||||||
|
assert planning_harvest._to_int("н/д") is None
|
||||||
78
data/sql/134_planning_projects.sql
Normal file
78
data/sql/134_planning_projects.sql
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
-- 134_planning_projects.sql
|
||||||
|
-- #1085 (Site Finder v2 / future-supply «GG-форсайт») — store ППТ/ПМТ ЕКБ (геометрия + реквизиты).
|
||||||
|
--
|
||||||
|
-- Future-supply: что/где утверждено к застройке. Источник — ЕКБ-геопортал GeoServer WFS
|
||||||
|
-- (ekb_geoportal_client.py, #1083): слои portal_geo_dmd_prj_plan (ППТ — проекты планировки) и
|
||||||
|
-- portal_geo_dmd_prj_mej (ПМТ — проекты межевания). Каждая фича — утверждённый документ
|
||||||
|
-- планировки с геометрией охвата + doc_status/год (live-verified 2026-06-06, vault research/
|
||||||
|
-- EKB_Geoportal_WFS_Construction_Intel_Jun06).
|
||||||
|
--
|
||||||
|
-- ОТДЕЛЬНАЯ таблица (не ird_overlays м.132): ird_overlays — НСПД-ограничения участка (per-feature
|
||||||
|
-- ЗОУИТ/ПЗЗ/ОКН + reg_numb_border + category/subcategory), а тут документ-центричная модель
|
||||||
|
-- геопортала (doc_status / dmd_actual_year / project_name / full_name) из другого источника (WFS,
|
||||||
|
-- уже EPSG:4326). Смешивать схемы — натяжка; держим раздельно, analyze читает overlap отдельно.
|
||||||
|
--
|
||||||
|
-- Что отдаёт WFS-фича (DescribeFeatureType + live): key (стабильный id портала), full_name
|
||||||
|
-- (документ-описание), doc_full_name/doc_status/doc_status_name (статус утверждения),
|
||||||
|
-- dmd_actual_year (год актуальности), project_name, geoloc (геометрия 4326).
|
||||||
|
--
|
||||||
|
-- Зерно (grain): одна строка = одна WFS-фича (один документ планировки) в слое. Дедуп-ключ —
|
||||||
|
-- (project_type, source_key): source_key = портальный `key`; project_type 'ppt'|'pmt'.
|
||||||
|
-- re-harvest UPSERT-ит (latest wins): документы стабильны, обновление = свежий fetched_at.
|
||||||
|
--
|
||||||
|
-- ТЭП/очередность из PDF пояснительных записок (pdfplumber) — отдельный проход (#1085 follow-up);
|
||||||
|
-- колонки tep/staging добавятся ALTER-ом позже (тут только WFS-слой: геометрия + статус).
|
||||||
|
--
|
||||||
|
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз по NN). Idempotent:
|
||||||
|
-- CREATE TABLE/INDEX IF NOT EXISTS + guarded DO для UNIQUE (прецедент м.125/132/133).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ── 1. Таблица planning_projects ───────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS planning_projects (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
project_type TEXT NOT NULL, -- 'ppt' (проект планировки) | 'pmt' (проект межевания)
|
||||||
|
source_key BIGINT NOT NULL, -- стабильный `key` фичи геопортала (дедуп внутри типа)
|
||||||
|
full_name TEXT, -- описание документа (ПАГЕ № / «Об утверждении ...»)
|
||||||
|
doc_full_name TEXT, -- doc_full_name WFS
|
||||||
|
doc_status TEXT, -- doc_status (код/строка статуса утверждения)
|
||||||
|
doc_status_name TEXT, -- читаемый статус ('действующий'/'утверждён'/...)
|
||||||
|
project_name TEXT, -- project_name WFS
|
||||||
|
dmd_actual_year INTEGER, -- год актуальности документа (dmd_actual_year)
|
||||||
|
geom geometry(Geometry, 4326) NOT NULL, -- охват документа (WFS geoloc, уже 4326)
|
||||||
|
raw_props JSONB, -- полный properties фичи (forward-compat / ТЭП-резолв)
|
||||||
|
fetched_at timestamptz NOT NULL DEFAULT now(), -- когда harvest снял (freshness)
|
||||||
|
CONSTRAINT planning_projects_type_chk CHECK (project_type IN ('ppt', 'pmt')),
|
||||||
|
CONSTRAINT planning_projects_source_key_chk CHECK (source_key > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE planning_projects IS
|
||||||
|
'ППТ/ПМТ ЕКБ (#1085, future-supply GG-форсайт): утверждённые проекты планировки/межевания из '
|
||||||
|
'ЕКБ-геопортала WFS (portal_geo_dmd_prj_plan/_mej). 1 строка = 1 WFS-фича (документ). UPSERT '
|
||||||
|
'harvest-воркером по uq_planning_projects (project_type, source_key). analyze читает overlap '
|
||||||
|
'участка через ST_Intersects(geom). ТЭП/очередность из PDF — отдельный проход (follow-up).';
|
||||||
|
COMMENT ON COLUMN planning_projects.source_key IS 'Стабильный `key` фичи ЕКБ-геопортала — дедуп-ключ внутри project_type';
|
||||||
|
COMMENT ON COLUMN planning_projects.doc_status_name IS 'Читаемый статус утверждения документа планировки (действующий/утверждён/...)';
|
||||||
|
|
||||||
|
-- ── 2. Дедуп/UPSERT-ключ (project_type, source_key) ────────────────────────────
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'uq_planning_projects'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE planning_projects
|
||||||
|
ADD CONSTRAINT uq_planning_projects
|
||||||
|
UNIQUE (project_type, source_key);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ── 3. Индексы ─────────────────────────────────────────────────────────────────
|
||||||
|
-- Главный read-path: spatial overlap участка (analyze: ST_Intersects(parcel.geom, pp.geom)).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_planning_projects_geom
|
||||||
|
ON planning_projects USING GIST (geom);
|
||||||
|
-- Фильтр по типу проекта (выборка «все ППТ» / «все ПМТ»).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_planning_projects_type
|
||||||
|
ON planning_projects (project_type);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Loading…
Add table
Reference in a new issue