fix(honesty): true competitors/neighbors totals + permits truncation disclosure (#2464 cluster B) #2471

Merged
bot-backend merged 1 commit from worktree-agent-a080ecae2420a5765 into main 2026-07-08 08:31:03 +00:00
11 changed files with 731 additions and 136 deletions

View file

@ -857,6 +857,20 @@ _NEIGHBORS_SUMMARY_SQL = text("""
ORDER BY distance_m ASC ORDER BY distance_m ASC
LIMIT 30 LIMIT 30
), ),
neighbors_total AS (
-- #2464 cluster B: честный COUNT(*) по ВСЕЙ 100м-выборке — БЕЗ LIMIT 30
-- (тот же WHERE, что у `neighbors` выше). count_buildings_100m раньше
-- считался как len(neighbors), тихо капаясь на 30 даже когда соседей
-- в радиусе больше.
SELECT COUNT(*) AS n
FROM cad_buildings b
WHERE ST_DWithin(
b.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
100
)
AND b.cad_num != CAST(:our_cad AS text)
),
overlap_rows AS ( overlap_rows AS (
SELECT cad_num, SELECT cad_num,
building_name, building_name,
@ -884,7 +898,8 @@ _NEIGHBORS_SUMMARY_SQL = text("""
(SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST) (SELECT json_agg(row_to_json(o) ORDER BY o.overlap_m2 DESC NULLS LAST)
FROM overlap_rows o), FROM overlap_rows o),
'[]'::json '[]'::json
) AS overlap_rows ) AS overlap_rows,
(SELECT n FROM neighbors_total) AS neighbors_total_count
""") """)
@ -901,6 +916,11 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
один сетевой round-trip (~47ms) на каждый analyze сами вычисления не один сетевой round-trip (~47ms) на каждый analyze сами вычисления не
меняются. Формат возвращаемого dict идентичен прежнему. меняются. Формат возвращаемого dict идентичен прежнему.
#2464 cluster B: `count_buildings_100m` — честный COUNT(*) по всей 100м-выборке
(CTE `neighbors_total`, БЕЗ LIMIT), а не len(neighbors) (капалось на LIMIT 30).
`neighbors_truncated` True если в радиусе больше соседей, чем показано в
списке `neighbors`.
SQL module-level `_NEIGHBORS_SUMMARY_SQL` (тестируется через SQL module-level `_NEIGHBORS_SUMMARY_SQL` (тестируется через
integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`). integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`).
""" """
@ -915,6 +935,9 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
) )
neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else [] neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else []
overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else [] overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else []
# #2464 cluster B: честный total из neighbors_total CTE (БЕЗ LIMIT 30) —
# НЕ len(neighbor_rows), которое капалось на 30 даже когда соседей больше.
neighbors_total_count = int(row["neighbors_total_count"]) if row else 0
except Exception as e: except Exception as e:
logger.warning("neighbors query failed: %s", e) logger.warning("neighbors query failed: %s", e)
return {"data_available": False, "note": f"neighbors query failed: {e}"} return {"data_available": False, "note": f"neighbors query failed: {e}"}
@ -959,36 +982,46 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
] ]
has_existing = len(overlap_buildings) > 0 has_existing = len(overlap_buildings) > 0
# neighbor_rows — до 30 (SQL CTE `neighbors` LIMIT), список ниже дополнительно
# режется до 20 для payload. `neighbors_truncated` сравнивает честный total с
# тем, что РЕАЛЬНО показано (len(neighbors_list)) — а не с промежуточным
# neighbor_rows (до 30) — иначе флаг мог соврать False на 21..30 соседях,
# где список уже обрезан до 20, а total ещё не превысил len(neighbor_rows).
neighbors_list = [
{
"cad_num": r["cad_num"],
"building_name": r.get("building_name"),
"floors": r.get("floors"),
"floors_parsed": _parse_floors(r.get("floors")),
"year_built": r.get("year_built"),
"area_m2": round(float(r["area"])) if r.get("area") else None,
"cost_per_m2": (
round(float(r["cost_value"]) / float(r["area"]))
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
else None
),
"distance_m": round(float(r["distance_m"])),
"readable_address": r.get("readable_address"),
# #2111 — функциональное назначение + статус здания (cad_buildings,
# populated bulk_harvest.py). purpose: TEXT категория, status:
# commissioned/under-construction и т.п.
"purpose": r.get("purpose"),
"status": r.get("status"),
}
for r in neighbor_rows[:20]
]
return { return {
"data_available": True, "data_available": True,
"radius_m": 100, "radius_m": 100,
"count_buildings_100m": len(neighbor_rows), # #2464 cluster B: честный total (neighbors_total CTE, без LIMIT) — НЕ
# len(neighbor_rows) (капалось на LIMIT 30 у CTE `neighbors`).
"count_buildings_100m": neighbors_total_count,
"neighbors_truncated": neighbors_total_count > len(neighbors_list),
"avg_floors_100m": avg_floors, "avg_floors_100m": avg_floors,
"max_floors_100m": max_floors, "max_floors_100m": max_floors,
"median_cost_per_m2_100m": median_cost, "median_cost_per_m2_100m": median_cost,
"neighbors": [ "neighbors": neighbors_list,
{
"cad_num": r["cad_num"],
"building_name": r.get("building_name"),
"floors": r.get("floors"),
"floors_parsed": _parse_floors(r.get("floors")),
"year_built": r.get("year_built"),
"area_m2": round(float(r["area"])) if r.get("area") else None,
"cost_per_m2": (
round(float(r["cost_value"]) / float(r["area"]))
if r.get("cost_value") and r.get("area") and float(r["area"]) > 0
else None
),
"distance_m": round(float(r["distance_m"])),
"readable_address": r.get("readable_address"),
# #2111 — функциональное назначение + статус здания (cad_buildings,
# populated bulk_harvest.py). purpose: TEXT категория, status:
# commissioned/under-construction и т.п.
"purpose": r.get("purpose"),
"status": r.get("status"),
}
for r in neighbor_rows[:20]
],
"has_existing_buildings": has_existing, "has_existing_buildings": has_existing,
"overlap_buildings": overlap_buildings, "overlap_buildings": overlap_buildings,
"note": ( "note": (
@ -1157,6 +1190,70 @@ def _compute_confidence(
} }
def _build_market_pulse(
competitor_rows: list[dict[str, Any]],
competitors_total: int,
velocity_data: dict[str, Any] | None,
) -> dict[str, Any]:
"""OBJ-3 aggregate: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
в `competitor_rows` (детальный список для карты/карточек), но исключаются
из расчётов рыночных метрик (market_avg_price_per_m2, top_sellers).
#2464 cluster B: `competitors_total` — честный total (передаётся вызывающим
кодом из отдельного COUNT(*) БЕЗ LIMIT, см. `analyze_parcel` 5a), НЕ
len(competitor_rows) (которое капалось на LIMIT 20 запроса конкурентов, и
заодно раздувало `coverage_pct` competitors_priced/20 вместо
competitors_priced/true_total). PURE вызывающий код передаёт честный total
отдельно, эта функция его не пересчитывает.
"""
competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
competitors_priced = len(competitors_with_price)
if competitors_with_price:
prices = [float(c["avg_price_per_m2_rub"]) for c in competitors_with_price]
market_avg_price = round(sum(prices) / len(prices))
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
with_sales = [
c
for c in competitors_with_price
if c["units_sold"] is not None and int(c["units_sold"]) > 0
]
top_sellers = sorted(
with_sales,
key=lambda c: int(c["units_sold"]),
reverse=True,
)[:5]
top_sellers_list = [
{
"obj_id": c["obj_id"],
"comm_name": c["comm_name"],
"dev_name": c["dev_name"],
"units_sold": int(c["units_sold"]),
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
}
for c in top_sellers
]
else:
market_avg_price = None
top_sellers_list = []
coverage_pct = (
round(competitors_priced * 100.0 / competitors_total, 1) if competitors_total > 0 else 0.0
)
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
# ЖК с objective_corpus_room_month данными (non-null by construction).
avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
return {
"avg_velocity_m2": avg_velocity,
"market_avg_price_per_m2": market_avg_price,
"competitors_total": competitors_total,
"competitors_with_price": competitors_priced,
"coverage_pct": coverage_pct,
"top_sellers": top_sellers_list,
}
# #93 — on-demand cadastre fetch tuning constants. # #93 — on-demand cadastre fetch tuning constants.
# _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback. # _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback.
# #
@ -2256,6 +2353,34 @@ def analyze_parcel(
.all() .all()
) )
# 5a) #2464 cluster B: честный COUNT(*) конкурентов в радиусе 3км — БЕЗ LIMIT.
# competitor_rows выше капнут `LIMIT 20` (топ-20 ближайших/строящихся для карты и
# детального списка) — market_pulse.competitors_total ниже раньше = len(competitor_rows),
# тихо капаясь на 20 даже когда в радиусе ЖК больше. Лёгкий COUNT переиспользует
# ТОТ ЖЕ latest_obj + ST_DWithin фильтр, но БЕЗ obj_lots_latest/obj_pricing джойнов
# (нужны только для цен детального списка, не для total) — на порядок дешевле
# полного запроса конкурентов (см. #1964 EXPLAIN про стоимость lots-дедупа).
_competitors_total_true = (
db.execute(
text("""
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT COUNT(*) FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
3000
)
"""),
{"wkt": geom_wkt},
).scalar()
or 0
)
# 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес # 5b) D4 (#36): Pipeline 24mo — ЖК-конкуренты сдающиеся в горизонте 24 мес
# в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class # в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class
# + по кварталам сдачи. Константы — см. PIPELINE_* выше. # + по кварталам сдачи. Константы — см. PIPELINE_* выше.
@ -3680,56 +3805,12 @@ def analyze_parcel(
except Exception as _ve: except Exception as _ve:
logger.warning("velocity compute failed for %s: %s", cad_num, _ve) logger.warning("velocity compute failed for %s: %s", cad_num, _ve)
# OBJ-3 aggregate fix: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами. # #2464 cluster B: competitors_total — честный total из отдельного COUNT(*)
# Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются # (5a выше) — НЕ len(competitor_rows), которое капалось на LIMIT 20 запроса
# в competitor list для карты, но исключаются из расчётов рыночных метрик. # конкурентов. См. _build_market_pulse docstring для полной логики агрегата.
_competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None] market_pulse: dict[str, Any] = _build_market_pulse(
_competitors_total = len(competitor_rows) competitor_rows, int(_competitors_total_true), velocity_data
_competitors_priced = len(_competitors_with_price)
if _competitors_with_price:
_prices = [float(c["avg_price_per_m2_rub"]) for c in _competitors_with_price]
_market_avg_price = round(sum(_prices) / len(_prices))
# top_sellers: ЖК с ненулевыми units_sold, топ-5 по объёму
_with_sales = [
c
for c in _competitors_with_price
if c["units_sold"] is not None and int(c["units_sold"]) > 0
]
_top_sellers = sorted(
_with_sales,
key=lambda c: int(c["units_sold"]),
reverse=True,
)[:5]
_top_sellers_list = [
{
"obj_id": c["obj_id"],
"comm_name": c["comm_name"],
"dev_name": c["dev_name"],
"units_sold": int(c["units_sold"]),
"avg_price_per_m2_rub": int(c["avg_price_per_m2_rub"]),
}
for c in _top_sellers
]
else:
_market_avg_price = None
_top_sellers_list = []
_coverage_pct = (
round(_competitors_priced * 100.0 / _competitors_total, 1)
if _competitors_total > 0
else 0.0
) )
# avg_velocity_m2 — берём из velocity_data если есть; это уже только по
# ЖК с objective_corpus_room_month данными (non-null by construction).
_avg_velocity = velocity_data["monthly_velocity_sqm"] if velocity_data else None
market_pulse: dict[str, Any] = {
"avg_velocity_m2": _avg_velocity,
"market_avg_price_per_m2": _market_avg_price,
"competitors_total": _competitors_total,
"competitors_with_price": _competitors_priced,
"coverage_pct": _coverage_pct,
"top_sellers": _top_sellers_list,
}
# #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника # #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника
# на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается # на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается

View file

@ -89,6 +89,8 @@ _NO_DATA = "нет данных"
# Кап видимых строк тепло/вода-таблиц (зеркало _HEAT_ROW_CAP / _WATER_ROW_CAP в HTML). # Кап видимых строк тепло/вода-таблиц (зеркало _HEAT_ROW_CAP / _WATER_ROW_CAP в HTML).
_HEAT_ROW_CAP = 15 _HEAT_ROW_CAP = 15
_WATER_ROW_CAP = 25 _WATER_ROW_CAP = 25
# Кап видимых строк таблицы разрешений §6 (зеркало _PERMITS_ROW_CAP в HTML).
_PERMITS_ROW_CAP = 10
# Титул документа + заголовки секций (зеркало _TITLE_* в full_report_html). # Титул документа + заголовки секций (зеркало _TITLE_* в full_report_html).
_TITLE_DOC = "Отчёт по участку — Site Finder ПТИЦА" _TITLE_DOC = "Отчёт по участку — Site Finder ПТИЦА"
@ -1008,6 +1010,12 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None:
`permits_nearby.get_permits_nearby`). Пусто / total_count=0 честная плашка-фраза `permits_nearby.get_permits_nearby`). Пусто / total_count=0 честная плашка-фраза
(полное предложение, не аббревиатура). Зеркало `_build_permits_nearby` в (полное предложение, не аббревиатура). Зеркало `_build_permits_nearby` в
full_report_html. full_report_html.
#2464 cluster B: `total_count` — честный total апстрима (get_permits_nearby
считает его COUNT'ом БЕЗ SQL LIMIT), `items` уже капнут апстримом на 30. Здесь
список дополнительно режется до `_PERMITS_ROW_CAP` (10) для компактности раньше
это резалось МОЛЧА. Дисклоузим разницу `total_count - показано` строкой
«и ещё N », как тепло/вода-таблицы выше (`_build_connection_capacity`).
""" """
nearby = _as_dict(result.get("permits_nearby")) nearby = _as_dict(result.get("permits_nearby"))
total = nearby.get("total_count") total = nearby.get("total_count")
@ -1030,7 +1038,7 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None:
_add_kv_table(doc, kv) _add_kv_table(doc, kv)
rows: list[list[Any]] = [] rows: list[list[Any]] = []
for item in _as_list(nearby.get("items"))[:10]: for item in _as_list(nearby.get("items"))[:_PERMITS_ROW_CAP]:
data = _as_dict(item) data = _as_dict(item)
rows.append( rows.append(
[ [
@ -1040,6 +1048,11 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None:
data.get("distance_m"), data.get("distance_m"),
] ]
) )
hidden = total - len(rows)
if hidden > 0:
rows.append(
[f"… и ещё {hidden} записей (полный список в веб-версии §6)", _DASH, _DASH, _DASH]
)
headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"] headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"]
_add_data_table(doc, headers, rows) _add_data_table(doc, headers, rows)

View file

@ -96,6 +96,8 @@ _NON_EKB_GENERIC: tuple[str, ...] = (
# Кап видимых строк тепло/вода-таблиц (сверх — «и ещё K систем …»). # Кап видимых строк тепло/вода-таблиц (сверх — «и ещё K систем …»).
_HEAT_ROW_CAP = 15 _HEAT_ROW_CAP = 15
_WATER_ROW_CAP = 25 _WATER_ROW_CAP = 25
# Кап видимых строк таблицы разрешений §6 (сверх — «и ещё K записей …»).
_PERMITS_ROW_CAP = 10
# Усечение длинных бюрократических имён систем. # Усечение длинных бюрократических имён систем.
_SYSTEM_NAME_MAX = 120 _SYSTEM_NAME_MAX = 120
@ -1495,6 +1497,13 @@ def _build_permits_nearby(result: dict[str, Any]) -> str:
`result` analyze-payload (НЕ forecast): читает `permits_nearby` (см. `result` analyze-payload (НЕ forecast): читает `permits_nearby` (см.
`permits_nearby.get_permits_nearby`). Пусто / total_count=0 честная плашка-фраза `permits_nearby.get_permits_nearby`). Пусто / total_count=0 честная плашка-фраза
(полное предложение, не аббревиатура). Все динамические строки через `html.escape`. (полное предложение, не аббревиатура). Все динамические строки через `html.escape`.
#2464 cluster B: `total_count` — честный total апстрима (get_permits_nearby
считает его COUNT'ом БЕЗ SQL LIMIT), `items` уже капнут апстримом на 30
(`items_truncated`). Здесь список дополнительно режется до `_PERMITS_ROW_CAP`
(10) для компактности PDF раньше это резалось МОЛЧА. Дисклоузим разницу
`total_count - показано` строкой «и ещё N », как тепло/вода-таблицы выше
(`_build_connection_capacity`).
""" """
nearby = _as_dict(result.get("permits_nearby")) nearby = _as_dict(result.get("permits_nearby"))
total = nearby.get("total_count") total = nearby.get("total_count")
@ -1514,7 +1523,7 @@ def _build_permits_nearby(result: dict[str, Any]) -> str:
] ]
rows: list[list[Any]] = [] rows: list[list[Any]] = []
for item in _as_list(nearby.get("items"))[:10]: for item in _as_list(nearby.get("items"))[:_PERMITS_ROW_CAP]:
data = _as_dict(item) data = _as_dict(item)
rows.append( rows.append(
[ [
@ -1524,6 +1533,16 @@ def _build_permits_nearby(result: dict[str, Any]) -> str:
data.get("distance_m"), data.get("distance_m"),
] ]
) )
hidden = total - len(rows)
if hidden > 0:
rows.append(
[
f"… и ещё {hidden} записей (полный список в веб-версии §6)",
_DASH,
_DASH,
_DASH,
]
)
headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"] headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"]
return _kv_table(kv) + _data_table(headers, rows) return _kv_table(kv) + _data_table(headers, rows)

View file

@ -108,6 +108,14 @@ class VelocityResult:
# per-project absorption там ill-defined и НЕ должен драйвить график) и на # per-project absorption там ill-defined и НЕ должен драйвить график) и на
# degenerate/zero путях. # degenerate/zero путях.
project_absorption_sqm_per_month: float | None = None project_absorption_sqm_per_month: float | None = None
# #2464 cluster B: True когда запрос конкурентов (Step 1, `LIMIT 200`) упёрся
# в лимит — competitors_count тогда "минимум 200" (реальных ЖК в радиусе может
# быть больше), а не честный total. Тот же silent-cap класс, что чинили в
# utility_infrastructure_loader (`features_truncated`, epic #2445 A2); здесь —
# флаг, а не второй COUNT(*): 200 конкурентов-ЖК в 3км радиусе — крайне
# редкий edge (плотная застройка), доп. geo-запрос на каждый analyze того не
# стоит — честная disclosure флагом дешевле.
competitors_truncated: bool = False
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return { return {
@ -131,6 +139,7 @@ class VelocityResult:
if self.project_absorption_sqm_per_month is not None if self.project_absorption_sqm_per_month is not None
else None else None
), ),
"competitors_truncated": self.competitors_truncated,
} }
@ -324,6 +333,9 @@ def compute_velocity(
ekb_median = _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH ekb_median = _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
ekb_median_proxy_fraction = 0.0 ekb_median_proxy_fraction = 0.0
n_comps = len(comp_rows) n_comps = len(comp_rows)
# #2464 cluster B: Step 1 query капнут `LIMIT 200` (см. выше) — если выборка
# уперлась в лимит, реальных конкурентов в радиусе может быть больше.
n_comps_truncated = n_comps >= 200
sample_no_data = sorted( sample_no_data = sorted(
[ [
{ {
@ -356,6 +368,7 @@ def compute_velocity(
sample_competitors=sample_no_data, sample_competitors=sample_no_data,
objective_coverage_pct=coverage_pct, objective_coverage_pct=coverage_pct,
ekb_median_proxy_fraction=ekb_median_proxy_fraction, ekb_median_proxy_fraction=ekb_median_proxy_fraction,
competitors_truncated=n_comps_truncated,
) )
if rr_result is not None: if rr_result is not None:
return rr_result return rr_result
@ -379,6 +392,7 @@ def compute_velocity(
proxy_sqm_per_deal=None, proxy_sqm_per_deal=None,
ekb_median_proxy_fraction=ekb_median_proxy_fraction, ekb_median_proxy_fraction=ekb_median_proxy_fraction,
project_absorption_sqm_per_month=None, # degenerate path — ill-defined project_absorption_sqm_per_month=None, # degenerate path — ill-defined
competitors_truncated=n_comps_truncated,
) )
# ── Step 2b: разбивка по комнатности (room_bucket) ─────────────────────── # ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
@ -486,6 +500,7 @@ def compute_velocity(
sample_competitors=sample_no_data, sample_competitors=sample_no_data,
objective_coverage_pct=coverage_pct, objective_coverage_pct=coverage_pct,
ekb_median_proxy_fraction=ekb_median_proxy_fraction, ekb_median_proxy_fraction=ekb_median_proxy_fraction,
competitors_truncated=n_comps_truncated,
) )
if rr_result is not None: if rr_result is not None:
return rr_result return rr_result
@ -521,6 +536,7 @@ def compute_velocity(
proxy_sqm_per_deal=None, proxy_sqm_per_deal=None,
ekb_median_proxy_fraction=ekb_median_proxy_fraction, ekb_median_proxy_fraction=ekb_median_proxy_fraction,
project_absorption_sqm_per_month=None, # degenerate path — ill-defined project_absorption_sqm_per_month=None, # degenerate path — ill-defined
competitors_truncated=n_comps_truncated,
) )
# Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354). # Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354).
@ -599,6 +615,7 @@ def compute_velocity(
proxy_sqm_per_deal=None, proxy_sqm_per_deal=None,
ekb_median_proxy_fraction=ekb_median_proxy_fraction, ekb_median_proxy_fraction=ekb_median_proxy_fraction,
project_absorption_sqm_per_month=project_absorption, project_absorption_sqm_per_month=project_absorption,
competitors_truncated=n_comps_truncated,
) )
@ -611,6 +628,7 @@ def _compute_rosreestr_fallback(
sample_competitors: list[dict[str, Any]], sample_competitors: list[dict[str, Any]],
objective_coverage_pct: float = 0.0, objective_coverage_pct: float = 0.0,
ekb_median_proxy_fraction: float = 0.0, ekb_median_proxy_fraction: float = 0.0,
competitors_truncated: bool = False,
) -> VelocityResult | None: ) -> VelocityResult | None:
"""Fallback velocity через rosreestr_deals JOIN по cad_quarter участка. """Fallback velocity через rosreestr_deals JOIN по cad_quarter участка.
@ -707,6 +725,7 @@ def _compute_rosreestr_fallback(
# RANK 1: квартальный total deal-count БЕЗ per-project декомпозиции — # RANK 1: квартальный total deal-count БЕЗ per-project декомпозиции —
# per-project absorption ill-defined → None, НЕ драйвит DCF-график. # per-project absorption ill-defined → None, НЕ драйвит DCF-график.
project_absorption_sqm_per_month=None, project_absorption_sqm_per_month=None,
competitors_truncated=competitors_truncated,
) )

View file

@ -7,26 +7,26 @@
Стратегия mock: аналогична test_analyze_market_price.py DB mock через Стратегия mock: аналогична test_analyze_market_price.py DB mock через
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch. dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
Порядок db.execute calls в analyze_parcel (с #29 G2): Порядок db.execute calls в analyze_parcel (с #29 G2, #2464 cluster B):
0. UNION ALL geom + source .mappings().first() 0. UNION ALL geom + source .mappings().first()
1. WKT query .mappings().first() 1. WKT query .mappings().first()
2. District .mappings().first() 2. District .mappings().first()
3. POI rows .mappings().all() 3. POI rows .mappings().all()
4. Competitor rows .mappings().all() 4. Competitor rows .mappings().all()
5. Pipeline rows .mappings().all() 5. competitors_total honest COUNT(*) .scalar() NEW #2464 cluster B
6. Centroid lat/lon .mappings().first() 6. Pipeline rows .mappings().all()
7. Noise rows .mappings().all() 7. Centroid lat/lon .mappings().first()
8. Hydrology .mappings().all() 8. Noise rows .mappings().all()
9. Utilities .mappings().all() 9. Hydrology .mappings().all()
10. parcel_meta (cad_parcels) .mappings().first() NEW #29 G2 10. Utilities .mappings().all()
11. Market trend .mappings().first() 11. parcel_meta (cad_parcels) .mappings().first() NEW #29 G2
12. Zoning (begin_nested) .mappings().first() 12. Market trend .mappings().first()
13. Success recommendation (begin_nested) .mappings().all() 13. Zoning (begin_nested) .mappings().first()
14. Market price (begin_nested) .mappings().first() 14. Success recommendation (begin_nested) .mappings().all()
15. Recent permits (begin_nested) .mappings().all() 15. Market price (begin_nested) .mappings().first()
16. _geotech_risk (industrial count) .scalar() 16. Recent permits (begin_nested) .mappings().all()
17. _neighbors_summary (neighbor_rows) .mappings().all() 17. _geotech_risk (industrial count) .scalar()
18. _neighbors_summary (overlap_row) .mappings().first() 18. _neighbors_summary (single statement, neighbors+overlap+total) .mappings().first()
""" """
from __future__ import annotations from __future__ import annotations
@ -82,20 +82,23 @@ def _make_db_for_analyze(
("first", district_row), # 2: district ("first", district_row), # 2: district
("all", []), # 3: POI rows ("all", []), # 3: POI rows
("all", []), # 4: competitor rows ("all", []), # 4: competitor rows
("all", []), # 5: pipeline rows ("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
("first", centroid_row), # 6: centroid ("all", []), # 6: pipeline rows
("all", []), # 7: noise rows ("first", centroid_row), # 7: centroid
("all", []), # 8: hydrology rows ("all", []), # 8: noise rows
("all", []), # 9: utilities rows ("all", []), # 9: hydrology rows
("first", pm_mock), # 10: parcel_meta ← #29 G2 ("all", []), # 10: utilities rows
("first", None), # 11: market trend ("first", pm_mock), # 11: parcel_meta ← #29 G2
("first", None), # 12: zoning (begin_nested) ("first", None), # 12: market trend
("all", []), # 13: success recommendation (begin_nested) ("first", None), # 13: zoning (begin_nested)
("first", None), # 14: market price (begin_nested) ("all", []), # 14: success recommendation (begin_nested)
("all", []), # 15: recent permits (begin_nested) ("first", None), # 15: market price (begin_nested)
("scalar", 0), # 16: geotech_risk ("all", []), # 16: recent permits (begin_nested)
("all", []), # 17: neighbors ("scalar", 0), # 17: geotech_risk
("first", None), # 18: overlap # 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
# возвращает ОДНУ строку с этими тремя ключами.
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
] ]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:

View file

@ -125,21 +125,24 @@ def _make_db_for_analyze() -> MagicMock:
("first", district_row), # 2: district ("first", district_row), # 2: district
("all", []), # 3: POI rows ("all", []), # 3: POI rows
("all", []), # 4: competitor rows ("all", []), # 4: competitor rows
("all", []), # 5: pipeline rows ("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
("first", centroid_row), # 6: centroid ("all", []), # 6: pipeline rows
("all", []), # 7: noise rows ("first", centroid_row), # 7: centroid
("all", []), # 8: hydrology rows ("all", []), # 8: noise rows
("all", []), # 9: utilities rows ("all", []), # 9: hydrology rows
("all", []), # 10: utilities rows
# (далее nspd_zoning merge не ходит в db.execute — резолвер замокан) # (далее nspd_zoning merge не ходит в db.execute — резолвер замокан)
("first", None), # 10: parcel_meta ("first", None), # 11: parcel_meta
("first", None), # 11: market trend ("first", None), # 12: market trend
("first", None), # 12: zoning (begin_nested) ("first", None), # 13: zoning (begin_nested)
("all", []), # 13: success recommendation ("all", []), # 14: success recommendation
("first", None), # 14: market price ("first", None), # 15: market price
("all", []), # 15: recent permits ("all", []), # 16: recent permits
("scalar", 0), # 16: geotech_risk ("scalar", 0), # 17: geotech_risk
("all", []), # 17: neighbors # 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
("first", None), # 18: overlap # json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
# возвращает ОДНУ строку с этими тремя ключами.
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
] ]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:

View file

@ -277,20 +277,23 @@ def _make_db_for_analyze() -> MagicMock:
("first", district_row), # 2: district ("first", district_row), # 2: district
("all", []), # 3: POI rows ("all", []), # 3: POI rows
("all", []), # 4: competitor rows ("all", []), # 4: competitor rows
("all", []), # 5: pipeline rows ("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
("first", centroid_row), # 6: centroid ("all", []), # 6: pipeline rows
("all", []), # 7: noise rows ("first", centroid_row), # 7: centroid
("all", []), # 8: hydrology rows ("all", []), # 8: noise rows
("all", []), # 9: utilities rows ("all", []), # 9: hydrology rows
("first", None), # 10: parcel_meta ("all", []), # 10: utilities rows
("first", None), # 11: market trend ("first", None), # 11: parcel_meta
("first", None), # 12: zoning (begin_nested) ("first", None), # 12: market trend
("all", []), # 13: success recommendation (begin_nested) ("first", None), # 13: zoning (begin_nested)
("first", None), # 14: market price (begin_nested) ("all", []), # 14: success recommendation (begin_nested)
("all", []), # 15: recent permits (begin_nested) ("first", None), # 15: market price (begin_nested)
("scalar", 0), # 16: geotech_risk ("all", []), # 16: recent permits (begin_nested)
("all", []), # 17: neighbors ("scalar", 0), # 17: geotech_risk
("first", None), # 18: overlap # 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через
# json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first()
# возвращает ОДНУ строку с этими тремя ключами.
("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}),
] ]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:

View file

@ -0,0 +1,226 @@
"""Тесты: epic #2464 cluster B — silent-cap honesty fixes в analyze_parcel.
Mock-based не требуют живой БД.
Покрывает:
1. `_build_market_pulse` (market_pulse.competitors_total / coverage_pct)
раньше competitors_total = len(competitor_rows) (LIMIT 20 в SQL), теперь
принимает честный total отдельным параметром (см. analyze_parcel 5a
COUNT(*) БЕЗ LIMIT) и считает coverage_pct против него.
2. `_neighbors_summary` (count_buildings_100m / neighbors_truncated)
раньше count_buildings_100m = len(neighbor_rows) (LIMIT 30 в CTE
`neighbors`), теперь честный total из CTE `neighbors_total` (БЕЗ LIMIT).
Reference fix pattern: `features_truncated` в
`app/services/site_finder/utility_infrastructure_loader.py` (epic #2445 A2).
"""
from __future__ import annotations
from unittest.mock import MagicMock
from app.api.v1 import parcels as parcels_module
# ── Вспомогательные фабрики ───────────────────────────────────────────────────
def _competitor_row(
obj_id: int,
avg_price_per_m2_rub: float | None,
units_sold: int | None = None,
) -> dict:
return {
"obj_id": obj_id,
"comm_name": f"ЖК-{obj_id}",
"dev_name": "TestDev",
"avg_price_per_m2_rub": avg_price_per_m2_rub,
"units_sold": units_sold,
}
# ── _build_market_pulse: competitors_total / coverage_pct honesty ────────────
class TestBuildMarketPulseHonesty:
"""market_pulse.competitors_total / coverage_pct — честный total (#2464 cluster B)."""
def test_below_cap_total_matches_len(self) -> None:
"""Ниже LIMIT (мало конкурентов в радиусе) — true total == len(rows), zero
behavior change: старое поведение (len(competitor_rows)) совпадает с новым."""
rows = [_competitor_row(i, 150_000.0) for i in range(1, 6)] # 5 конкурентов
pulse = parcels_module._build_market_pulse(rows, competitors_total=5, velocity_data=None)
assert pulse["competitors_total"] == 5
assert pulse["competitors_with_price"] == 5
assert pulse["coverage_pct"] == 100.0
def test_at_cap_total_is_honest_not_len_of_rows(self) -> None:
"""#2464 cluster B core regression: competitor_rows капнут LIMIT 20 (детальный
список), но реальных ЖК в радиусе 45 (честный COUNT). competitors_total ДОЛЖЕН
отражать 45, а НЕ len(rows)=20 (старый баг silent cap репортился как total)."""
rows = [_competitor_row(i, 150_000.0) for i in range(1, 21)] # ровно LIMIT 20
true_total = 45
pulse = parcels_module._build_market_pulse(
rows, competitors_total=true_total, velocity_data=None
)
assert pulse["competitors_total"] == true_total
assert pulse["competitors_total"] != len(
rows
), "regression guard: competitors_total НЕ должен деградировать до len(competitor_rows)"
def test_coverage_pct_computed_against_true_total_not_capped_list(self) -> None:
"""coverage_pct = priced / TRUE total — раньше делилось на len(rows) (капнутый
список), что давало ложные 100% покрытия даже когда реальное покрытие ниже."""
# Все 20 показанных строк имеют цену → раньше coverage_pct считался бы 100%.
rows = [_competitor_row(i, 150_000.0) for i in range(1, 21)]
true_total = 40 # реально в радиусе 40 ЖК, из них с ценой только эти 20.
pulse = parcels_module._build_market_pulse(
rows, competitors_total=true_total, velocity_data=None
)
assert pulse["competitors_with_price"] == 20
# 20/40*100 = 50.0, НЕ 100.0 (старая формула len(rows)-денутый would дать 100%).
assert pulse["coverage_pct"] == 50.0
def test_zero_competitors_honest_zero_coverage(self) -> None:
"""Нет конкурентов в радиусе → coverage_pct=0.0 (не деление на ноль)."""
pulse = parcels_module._build_market_pulse([], competitors_total=0, velocity_data=None)
assert pulse["competitors_total"] == 0
assert pulse["competitors_with_price"] == 0
assert pulse["coverage_pct"] == 0.0
assert pulse["market_avg_price_per_m2"] is None
assert pulse["top_sellers"] == []
def test_unpriced_competitors_excluded_from_market_avg_but_counted_in_total(self) -> None:
"""ЖК без маппинга (avg_price_per_m2_rub=None) остаются в competitor_rows
(детальный список для карты), но не влияют на market_avg_price_per_m2."""
rows = [
_competitor_row(1, 100_000.0),
_competitor_row(2, None),
_competitor_row(3, 200_000.0),
]
pulse = parcels_module._build_market_pulse(rows, competitors_total=3, velocity_data=None)
assert pulse["competitors_with_price"] == 2
assert pulse["market_avg_price_per_m2"] == 150_000
def test_velocity_data_feeds_avg_velocity_m2(self) -> None:
"""avg_velocity_m2 берётся из velocity_data.monthly_velocity_sqm, если есть."""
rows = [_competitor_row(1, 100_000.0)]
pulse = parcels_module._build_market_pulse(
rows, competitors_total=1, velocity_data={"monthly_velocity_sqm": 750.5}
)
assert pulse["avg_velocity_m2"] == 750.5
def test_top_sellers_top5_sorted_by_units_sold(self) -> None:
"""top_sellers — топ-5 по units_sold среди приценённых конкурентов."""
rows = [_competitor_row(i, 100_000.0, units_sold=i * 10) for i in range(1, 8)]
pulse = parcels_module._build_market_pulse(rows, competitors_total=7, velocity_data=None)
assert len(pulse["top_sellers"]) == 5
sold = [s["units_sold"] for s in pulse["top_sellers"]]
assert sold == sorted(sold, reverse=True)
assert sold[0] == 70 # obj_id=7 продал больше всех
# ── _neighbors_summary: count_buildings_100m / neighbors_truncated honesty ───
def _neighbor_row(cad_num: str, distance_m: float = 10.0) -> dict:
return {
"cad_num": cad_num,
"building_name": None,
"floors": None,
"year_built": None,
"cost_value": None,
"area": None,
"readable_address": None,
"purpose": None,
"status": None,
"distance_m": distance_m,
}
def _make_neighbors_db(neighbors_json: list, neighbors_total_count: int, overlap_json=None):
row = {
"neighbors": neighbors_json,
"overlap_rows": overlap_json or [],
"neighbors_total_count": neighbors_total_count,
}
result = MagicMock()
result.mappings.return_value.first.return_value = row
db = MagicMock()
db.execute.return_value = result
return db
class TestNeighborsSummaryHonesty:
"""count_buildings_100m / neighbors_truncated — честный total (#2464 cluster B)."""
def test_below_cap_count_matches_len_no_truncation(self) -> None:
"""Ниже LIMIT 30 — true total == len(neighbors), neighbors_truncated=False
(zero behavior change для типичного случая)."""
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(5)]
db = _make_neighbors_db(neighbors, neighbors_total_count=5)
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert summary["data_available"] is True
assert summary["count_buildings_100m"] == 5
assert summary["neighbors_truncated"] is False
def test_at_cap_count_is_honest_total_not_len_of_list(self) -> None:
"""#2464 cluster B core regression: `neighbors` CTE капнут LIMIT 30, но
реальных соседей в радиусе 47 (честный COUNT из `neighbors_total`).
count_buildings_100m ДОЛЖЕН отражать 47, а НЕ len(neighbors)=30."""
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(30)] # ровно LIMIT 30
true_total = 47
db = _make_neighbors_db(neighbors, neighbors_total_count=true_total)
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert summary["count_buildings_100m"] == true_total
assert summary["count_buildings_100m"] != len(
neighbors
), "regression guard: count_buildings_100m НЕ должен деградировать до len(neighbor_rows)"
assert summary["neighbors_truncated"] is True
def test_neighbors_list_itself_unaffected_by_count_fix(self) -> None:
"""Constraint: детальный список `neighbors` (payload для UI, уже капнут до 20
в существующем коде отдельно от SQL LIMIT 30) остаётся прежним по составу/
порядку фикс трогает только count/флаг, НЕ список."""
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(30)]
db = _make_neighbors_db(neighbors, neighbors_total_count=47)
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert len(summary["neighbors"]) == 20
assert summary["neighbors"][0]["cad_num"] == "66:41:0000000:0"
def test_neighbors_truncated_reflects_actual_shown_list_not_intermediate_30(self) -> None:
"""neighbors_truncated должен сравнивать честный total с тем, что РЕАЛЬНО
показано в `neighbors` (капнуто до 20), а НЕ с промежуточным neighbor_rows
(до 30 из SQL) иначе флаг может ложно молчать (False) на 21..30-м соседе,
где список уже обрезан до 20, а SQL ещё не упёрся в свой LIMIT 30."""
# SQL вернул 25 строк (не уперся в LIMIT 30) — но список для UI режется до 20.
neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(25)]
db = _make_neighbors_db(neighbors, neighbors_total_count=25)
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert len(summary["neighbors"]) == 20
assert summary["count_buildings_100m"] == 25
# 25 (total) > 20 (реально показано) → truncated=True, даже хотя SQL LIMIT 30
# не был исчерпан.
assert summary["neighbors_truncated"] is True
def test_query_failure_degrades_gracefully(self) -> None:
"""DB-ошибка → data_available=False, note с текстом ошибки (unchanged behavior)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
assert summary == {"data_available": False, "note": "neighbors query failed: boom"}

View file

@ -479,7 +479,11 @@ def test_permits_nearby_non_empty_summary_and_list() -> None:
def test_permits_nearby_items_capped_at_ten() -> None: def test_permits_nearby_items_capped_at_ten() -> None:
"""items[:10] — таблица документов не превышает шапку + 10 строк (зеркало HTML).""" """items[:10] — показываются первые 10 строк документов (зеркало HTML).
#2464 cluster B: сверх 10 добавляется honest-строка «и ещё N записей», поэтому
таблица = шапка + 10 документов + 1 disclosure = 12 строк.
"""
items = [ items = [
{ {
"doc_group": "RS", "doc_group": "RS",
@ -501,15 +505,76 @@ def test_permits_nearby_items_capped_at_ten() -> None:
} }
doc = _new_doc() doc = _new_doc()
_build_permits_nearby(doc, result) _build_permits_nearby(doc, result)
# Вторая таблица (документы): шапка + не более 10 строк. # Вторая таблица (документы): шапка + 10 показанных + 1 disclosure-строка.
docs_table = doc.tables[1] docs_table = doc.tables[1]
assert len(docs_table.rows) == 11 assert len(docs_table.rows) == 12
text = _all_text(doc) text = _all_text(doc)
assert "РНС-0" in text assert "РНС-0" in text
assert "РНС-9" in text assert "РНС-9" in text
assert "РНС-10" not in text assert "РНС-10" not in text
def test_permits_nearby_truncation_disclosed_beyond_row_cap() -> None:
"""#2464 cluster B: total_count (честный COUNT апстрима, БЕЗ SQL LIMIT) > показанных
10 honest-строка «и ещё N записей (полный список в веб-версии §6)», а не молчаливый
срез как тепло/вода-таблицы §3 (`_build_connection_capacity`)."""
items = [
{
"doc_group": "RS",
"doc_name": f"РНС-{i}",
"date_doc": "2025-01-01",
"approved_organization": "Орган",
"distance_m": float(i),
}
for i in range(12)
]
result = {
"permits_nearby": {
# total_count=15 > показанных 10 → hidden = 5 (апстрим уже капнул items на 30,
# здесь режем до 10; disclosure считается от честного total_count).
"total_count": 15,
"rs_count": 15,
"rv_count": 0,
"nearest_distance_m": 0.0,
"items": items,
}
}
doc = _new_doc()
_build_permits_nearby(doc, result)
text = _all_text(doc)
assert "… и ещё 5 записей (полный список в веб-версии §6)" in text
def test_permits_nearby_no_disclosure_when_all_shown() -> None:
"""total_count <= 10 (всё влезло) → БЕЗ строки «и ещё» (zero behavior change для
маленьких выборок): таблица = шапка + N документов, без disclosure-строки."""
items = [
{
"doc_group": "RS",
"doc_name": f"РНС-{i}",
"date_doc": "2025-01-01",
"approved_organization": "Орган",
"distance_m": float(i),
}
for i in range(7)
]
result = {
"permits_nearby": {
"total_count": 7,
"rs_count": 7,
"rv_count": 0,
"nearest_distance_m": 0.0,
"items": items,
}
}
doc = _new_doc()
_build_permits_nearby(doc, result)
docs_table = doc.tables[1]
assert len(docs_table.rows) == 8 # шапка + 7, без disclosure
text = _all_text(doc)
assert "и ещё" not in text
def test_section_6_wires_permits_nearby_from_analyze() -> None: def test_section_6_wires_permits_nearby_from_analyze() -> None:
"""_build_section_6(doc, forecast, result) прокидывает result → блок разрешений виден.""" """_build_section_6(doc, forecast, result) прокидывает result → блок разрешений виден."""
doc = _new_doc() doc = _new_doc()

View file

@ -468,6 +468,67 @@ def test_permits_nearby_org_name_escaped() -> None:
assert "<b>hack</b>" not in out assert "<b>hack</b>" not in out
def test_permits_nearby_truncation_disclosed_beyond_row_cap() -> None:
"""#2464 cluster B: >10 items (upstream cap) → «и ещё N записей» дисклоузится,
вместо молчаливого среза до 10 (total_count честный total апстрима,
permits_nearby.get_permits_nearby считает его COUNT'ом БЕЗ SQL LIMIT)."""
items = [
{
"doc_group": "RS",
"doc_name": f"РНС{i}",
"date_doc": "2025-01-01",
"approved_organization": "Администрация",
"distance_m": float(i * 10),
}
for i in range(12)
]
result = {
"permits_nearby": {
"total_count": 15, # > len(items)=12: апстрим уже капнул items на 30,
# здесь ещё раз режем до 10 — итоговый hidden = 15-10 = 5.
"rs_count": 15,
"rv_count": 0,
"nearest_distance_m": 0.0,
"items": items,
"items_truncated": True,
}
}
out = _build_permits_nearby(result)
assert "РНС №9" in out # 10-й показанный (index 9, row cap=10)
assert "РНС №10" not in out # 11-й — за кэпом, НЕ показан напрямую
assert "и ещё 5 записей" in out
assert "веб-версии §6" in out
def test_permits_nearby_no_truncation_note_when_all_shown() -> None:
"""total_count совпадает с показанными (<=10) → БЕЗ приписки «и ещё» (zero behavior
change для маленьких выборок, как раньше)."""
items = [
{
"doc_group": "RS",
"doc_name": f"РНС{i}",
"date_doc": "2025-01-01",
"approved_organization": "Администрация",
"distance_m": float(i * 10),
}
for i in range(7)
]
result = {
"permits_nearby": {
"total_count": 7,
"rs_count": 7,
"rv_count": 0,
"nearest_distance_m": 0.0,
"items": items,
"items_truncated": False,
}
}
out = _build_permits_nearby(result)
assert "и ещё" not in out
for i in range(7):
assert f"РНС{i}" in out
def test_section_6_wires_permits_nearby_from_analyze() -> None: def test_section_6_wires_permits_nearby_from_analyze() -> None:
"""analyze_result прокинут в §6 → блок разрешений рядом виден в part_b HTML.""" """analyze_result прокинут в §6 → блок разрешений рядом виден в part_b HTML."""
analyze = { analyze = {

View file

@ -576,6 +576,108 @@ def test_rosreestr_fallback_project_absorption_is_none():
assert result.as_dict()["project_absorption_sqm_per_month"] is None assert result.as_dict()["project_absorption_sqm_per_month"] is None
def test_competitors_truncated_false_below_cap():
"""#2464 cluster B: n_comps < 200 (Step 1 LIMIT) → competitors_truncated=False."""
n = 12
comp_rows = [_comp_row(i, distance_m=300.0 + i * 100) for i in range(1, n + 1)]
sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.competitors_count == n
assert result.competitors_truncated is False
assert result.as_dict()["competitors_truncated"] is False
def test_competitors_truncated_true_at_cap():
"""#2464 cluster B: Step 1 competitor query капнут LIMIT 200 — если запрос вернул
ровно 200 строк, реальных ЖК в радиусе может быть больше competitors_truncated=True.
Раньше этот silent-cap никак не сигнализировался наружу."""
n = 200
comp_rows = [_comp_row(i, distance_m=300.0 + i) for i in range(1, n + 1)]
sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is not None
assert result.competitors_count == n
assert result.competitors_truncated is True
assert result.as_dict()["competitors_truncated"] is True
def test_competitors_truncated_propagates_through_rosreestr_fallback():
"""competitors_truncated пробрасывается и через rosreestr_fallback путь (низкое
Objective-покрытие форсирует fallback, но Step 1 LIMIT 200 капается независимо
от того, какой путь дальше используется для объёма продаж)."""
n = 200
comp_rows = [_comp_row(i, distance_m=300.0 + i) for i in range(1, n + 1)]
# Все конкуренты unmapped → forces rosreestr_fallback (coverage 0% < 50%).
sales_rows = [
_sales_row(i, total_sqm=0.0, months=0, has_mapping=False) for i in range(1, n + 1)
]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
rr_row = MagicMock()
start_d = datetime.date.fromisoformat("2025-01-01")
end_d = datetime.date.fromisoformat("2025-06-01")
rr_row.__getitem__ = lambda self, k: {
"total_deals": 60,
"period_start": start_d,
"period_end": end_d,
}[k]
rr_result_mock = MagicMock()
rr_result_mock.mappings.return_value.first.return_value = rr_row
original_side_effect = list(db.execute.side_effect)
db.execute.side_effect = [*original_side_effect, rr_result_mock]
with patch(
"app.services.site_finder.velocity._get_ekb_median",
return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0),
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT, cad_quarter="66:41:0702048")
assert result is not None
assert result.velocity_source == "rosreestr_fallback"
assert result.competitors_truncated is True
def test_as_dict_includes_competitors_truncated_default_false():
"""as_dict() содержит competitors_truncated; дефолт False (backward-compat,
старые вызовы VelocityResult(...) без явного значения не ломаются)."""
vr = VelocityResult(
competitors_count=5,
monthly_velocity_sqm=3000.0,
ekb_median_sqm=4500.0,
velocity_score=0.333,
confidence="medium",
months_observed=4,
period_start="2024-11",
period_end="2025-02",
sample_competitors=[],
by_room_bucket={},
velocity_data_available=True,
velocity_source="objective",
objective_coverage_pct=66.7,
proxy_used=False,
proxy_sqm_per_deal=None,
ekb_median_proxy_fraction=0.25,
)
assert vr.competitors_truncated is False
assert vr.as_dict()["competitors_truncated"] is False
def test_sample_competitors_include_by_room_bucket(): def test_sample_competitors_include_by_room_bucket():
"""sample_competitors каждого элемента содержит by_room_bucket.""" """sample_competitors каждого элемента содержит by_room_bucket."""
comp_rows = [_comp_row(1), _comp_row(2)] comp_rows = [_comp_row(1), _comp_row(2)]