fix(honesty): true competitors/neighbors totals + permits truncation disclosure (#2464 cluster B) #2471
11 changed files with 731 additions and 136 deletions
|
|
@ -857,6 +857,20 @@ _NEIGHBORS_SUMMARY_SQL = text("""
|
|||
ORDER BY distance_m ASC
|
||||
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 (
|
||||
SELECT cad_num,
|
||||
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)
|
||||
FROM overlap_rows o),
|
||||
'[]'::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 — сами вычисления не
|
||||
меняются. Формат возвращаемого 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` (тестируется через
|
||||
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 []
|
||||
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:
|
||||
logger.warning("neighbors query failed: %s", e)
|
||||
return {"data_available": False, "note": f"neighbors query failed: {e}"}
|
||||
|
|
@ -959,14 +982,12 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
|
|||
]
|
||||
has_existing = len(overlap_buildings) > 0
|
||||
|
||||
return {
|
||||
"data_available": True,
|
||||
"radius_m": 100,
|
||||
"count_buildings_100m": len(neighbor_rows),
|
||||
"avg_floors_100m": avg_floors,
|
||||
"max_floors_100m": max_floors,
|
||||
"median_cost_per_m2_100m": median_cost,
|
||||
"neighbors": [
|
||||
# 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"),
|
||||
|
|
@ -988,7 +1009,19 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str
|
|||
"status": r.get("status"),
|
||||
}
|
||||
for r in neighbor_rows[:20]
|
||||
],
|
||||
]
|
||||
|
||||
return {
|
||||
"data_available": True,
|
||||
"radius_m": 100,
|
||||
# #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,
|
||||
"max_floors_100m": max_floors,
|
||||
"median_cost_per_m2_100m": median_cost,
|
||||
"neighbors": neighbors_list,
|
||||
"has_existing_buildings": has_existing,
|
||||
"overlap_buildings": overlap_buildings,
|
||||
"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.
|
||||
# _INLINE_FETCH_WAIT_S — суммарно ждём fast-path при analyze fallback.
|
||||
#
|
||||
|
|
@ -2256,6 +2353,34 @@ def analyze_parcel(
|
|||
.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 мес
|
||||
# в радиусе 5км. ready_dt = planned commissioning. Группируем по obj_class
|
||||
# + по кварталам сдачи. Константы — см. PIPELINE_* выше.
|
||||
|
|
@ -3680,56 +3805,12 @@ def analyze_parcel(
|
|||
except Exception as _ve:
|
||||
logger.warning("velocity compute failed for %s: %s", cad_num, _ve)
|
||||
|
||||
# OBJ-3 aggregate fix: market_pulse — агрегаты ТОЛЬКО по ЖК с ненулевыми ценами.
|
||||
# Конкуренты без маппинга в Objective (NULL avg_price_per_m2_rub) остаются
|
||||
# в competitor list для карты, но исключаются из расчётов рыночных метрик.
|
||||
_competitors_with_price = [c for c in competitor_rows if c["avg_price_per_m2_rub"] is not None]
|
||||
_competitors_total = len(competitor_rows)
|
||||
_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
|
||||
# #2464 cluster B: competitors_total — честный total из отдельного COUNT(*)
|
||||
# (5a выше) — НЕ len(competitor_rows), которое капалось на LIMIT 20 запроса
|
||||
# конкурентов. См. _build_market_pulse docstring для полной логики агрегата.
|
||||
market_pulse: dict[str, Any] = _build_market_pulse(
|
||||
competitor_rows, int(_competitors_total_true), velocity_data
|
||||
)
|
||||
# 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 района (обеспеченность школа/детсад/поликлиника
|
||||
# на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ _NO_DATA = "нет данных"
|
|||
# Кап видимых строк тепло/вода-таблиц (зеркало _HEAT_ROW_CAP / _WATER_ROW_CAP в HTML).
|
||||
_HEAT_ROW_CAP = 15
|
||||
_WATER_ROW_CAP = 25
|
||||
# Кап видимых строк таблицы разрешений §6 (зеркало _PERMITS_ROW_CAP в HTML).
|
||||
_PERMITS_ROW_CAP = 10
|
||||
|
||||
# Титул документа + заголовки секций (зеркало _TITLE_* в full_report_html).
|
||||
_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 → честная плашка-фраза
|
||||
(полное предложение, не аббревиатура). Зеркало `_build_permits_nearby` в
|
||||
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"))
|
||||
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)
|
||||
|
||||
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)
|
||||
rows.append(
|
||||
[
|
||||
|
|
@ -1040,6 +1048,11 @@ def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None:
|
|||
data.get("distance_m"),
|
||||
]
|
||||
)
|
||||
hidden = total - len(rows)
|
||||
if hidden > 0:
|
||||
rows.append(
|
||||
[f"… и ещё {hidden} записей (полный список в веб-версии §6)", _DASH, _DASH, _DASH]
|
||||
)
|
||||
headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"]
|
||||
_add_data_table(doc, headers, rows)
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ _NON_EKB_GENERIC: tuple[str, ...] = (
|
|||
# Кап видимых строк тепло/вода-таблиц (сверх — «и ещё K систем …»).
|
||||
_HEAT_ROW_CAP = 15
|
||||
_WATER_ROW_CAP = 25
|
||||
# Кап видимых строк таблицы разрешений §6 (сверх — «и ещё K записей …»).
|
||||
_PERMITS_ROW_CAP = 10
|
||||
# Усечение длинных бюрократических имён систем.
|
||||
_SYSTEM_NAME_MAX = 120
|
||||
|
||||
|
|
@ -1495,6 +1497,13 @@ def _build_permits_nearby(result: dict[str, Any]) -> str:
|
|||
`result` — analyze-payload (НЕ forecast): читает `permits_nearby` (см.
|
||||
`permits_nearby.get_permits_nearby`). Пусто / total_count=0 → честная плашка-фраза
|
||||
(полное предложение, не аббревиатура). Все динамические строки через `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"))
|
||||
total = nearby.get("total_count")
|
||||
|
|
@ -1514,7 +1523,7 @@ def _build_permits_nearby(result: dict[str, Any]) -> str:
|
|||
]
|
||||
|
||||
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)
|
||||
rows.append(
|
||||
[
|
||||
|
|
@ -1524,6 +1533,16 @@ def _build_permits_nearby(result: dict[str, Any]) -> str:
|
|||
data.get("distance_m"),
|
||||
]
|
||||
)
|
||||
hidden = total - len(rows)
|
||||
if hidden > 0:
|
||||
rows.append(
|
||||
[
|
||||
f"… и ещё {hidden} записей (полный список в веб-версии §6)",
|
||||
_DASH,
|
||||
_DASH,
|
||||
_DASH,
|
||||
]
|
||||
)
|
||||
headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"]
|
||||
return _kv_table(kv) + _data_table(headers, rows)
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,14 @@ class VelocityResult:
|
|||
# per-project absorption там ill-defined и НЕ должен драйвить график) и на
|
||||
# degenerate/zero путях.
|
||||
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]:
|
||||
return {
|
||||
|
|
@ -131,6 +139,7 @@ class VelocityResult:
|
|||
if self.project_absorption_sqm_per_month is not 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_proxy_fraction = 0.0
|
||||
n_comps = len(comp_rows)
|
||||
# #2464 cluster B: Step 1 query капнут `LIMIT 200` (см. выше) — если выборка
|
||||
# уперлась в лимит, реальных конкурентов в радиусе может быть больше.
|
||||
n_comps_truncated = n_comps >= 200
|
||||
sample_no_data = sorted(
|
||||
[
|
||||
{
|
||||
|
|
@ -356,6 +368,7 @@ def compute_velocity(
|
|||
sample_competitors=sample_no_data,
|
||||
objective_coverage_pct=coverage_pct,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
competitors_truncated=n_comps_truncated,
|
||||
)
|
||||
if rr_result is not None:
|
||||
return rr_result
|
||||
|
|
@ -379,6 +392,7 @@ def compute_velocity(
|
|||
proxy_sqm_per_deal=None,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
|
||||
competitors_truncated=n_comps_truncated,
|
||||
)
|
||||
|
||||
# ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
|
||||
|
|
@ -486,6 +500,7 @@ def compute_velocity(
|
|||
sample_competitors=sample_no_data,
|
||||
objective_coverage_pct=coverage_pct,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
competitors_truncated=n_comps_truncated,
|
||||
)
|
||||
if rr_result is not None:
|
||||
return rr_result
|
||||
|
|
@ -521,6 +536,7 @@ def compute_velocity(
|
|||
proxy_sqm_per_deal=None,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
project_absorption_sqm_per_month=None, # degenerate path — ill-defined
|
||||
competitors_truncated=n_comps_truncated,
|
||||
)
|
||||
|
||||
# Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354).
|
||||
|
|
@ -599,6 +615,7 @@ def compute_velocity(
|
|||
proxy_sqm_per_deal=None,
|
||||
ekb_median_proxy_fraction=ekb_median_proxy_fraction,
|
||||
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]],
|
||||
objective_coverage_pct: float = 0.0,
|
||||
ekb_median_proxy_fraction: float = 0.0,
|
||||
competitors_truncated: bool = False,
|
||||
) -> VelocityResult | None:
|
||||
"""Fallback velocity через rosreestr_deals JOIN по cad_quarter участка.
|
||||
|
||||
|
|
@ -707,6 +725,7 @@ def _compute_rosreestr_fallback(
|
|||
# RANK 1: квартальный total deal-count БЕЗ per-project декомпозиции —
|
||||
# per-project absorption ill-defined → None, НЕ драйвит DCF-график.
|
||||
project_absorption_sqm_per_month=None,
|
||||
competitors_truncated=competitors_truncated,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,26 +7,26 @@
|
|||
Стратегия mock: аналогична test_analyze_market_price.py — DB mock через
|
||||
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()
|
||||
1. WKT query → .mappings().first()
|
||||
2. District → .mappings().first()
|
||||
3. POI rows → .mappings().all()
|
||||
4. Competitor rows → .mappings().all()
|
||||
5. Pipeline rows → .mappings().all()
|
||||
6. Centroid lat/lon → .mappings().first()
|
||||
7. Noise rows → .mappings().all()
|
||||
8. Hydrology → .mappings().all()
|
||||
9. Utilities → .mappings().all()
|
||||
10. parcel_meta (cad_parcels) → .mappings().first() ← NEW #29 G2
|
||||
11. Market trend → .mappings().first()
|
||||
12. Zoning (begin_nested) → .mappings().first()
|
||||
13. Success recommendation (begin_nested) → .mappings().all()
|
||||
14. Market price (begin_nested) → .mappings().first()
|
||||
15. Recent permits (begin_nested) → .mappings().all()
|
||||
16. _geotech_risk (industrial count) → .scalar()
|
||||
17. _neighbors_summary (neighbor_rows) → .mappings().all()
|
||||
18. _neighbors_summary (overlap_row) → .mappings().first()
|
||||
5. competitors_total honest COUNT(*) → .scalar() ← NEW #2464 cluster B
|
||||
6. Pipeline rows → .mappings().all()
|
||||
7. Centroid lat/lon → .mappings().first()
|
||||
8. Noise rows → .mappings().all()
|
||||
9. Hydrology → .mappings().all()
|
||||
10. Utilities → .mappings().all()
|
||||
11. parcel_meta (cad_parcels) → .mappings().first() ← NEW #29 G2
|
||||
12. Market trend → .mappings().first()
|
||||
13. Zoning (begin_nested) → .mappings().first()
|
||||
14. Success recommendation (begin_nested) → .mappings().all()
|
||||
15. Market price (begin_nested) → .mappings().first()
|
||||
16. Recent permits (begin_nested) → .mappings().all()
|
||||
17. _geotech_risk (industrial count) → .scalar()
|
||||
18. _neighbors_summary (single statement, neighbors+overlap+total) → .mappings().first()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -82,20 +82,23 @@ def _make_db_for_analyze(
|
|||
("first", district_row), # 2: district
|
||||
("all", []), # 3: POI rows
|
||||
("all", []), # 4: competitor rows
|
||||
("all", []), # 5: pipeline rows
|
||||
("first", centroid_row), # 6: centroid
|
||||
("all", []), # 7: noise rows
|
||||
("all", []), # 8: hydrology rows
|
||||
("all", []), # 9: utilities rows
|
||||
("first", pm_mock), # 10: parcel_meta ← #29 G2
|
||||
("first", None), # 11: market trend
|
||||
("first", None), # 12: zoning (begin_nested)
|
||||
("all", []), # 13: success recommendation (begin_nested)
|
||||
("first", None), # 14: market price (begin_nested)
|
||||
("all", []), # 15: recent permits (begin_nested)
|
||||
("scalar", 0), # 16: geotech_risk
|
||||
("all", []), # 17: neighbors
|
||||
("first", None), # 18: overlap
|
||||
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
|
||||
("all", []), # 6: pipeline rows
|
||||
("first", centroid_row), # 7: centroid
|
||||
("all", []), # 8: noise rows
|
||||
("all", []), # 9: hydrology rows
|
||||
("all", []), # 10: utilities rows
|
||||
("first", pm_mock), # 11: parcel_meta ← #29 G2
|
||||
("first", None), # 12: market trend
|
||||
("first", None), # 13: zoning (begin_nested)
|
||||
("all", []), # 14: success recommendation (begin_nested)
|
||||
("first", None), # 15: market price (begin_nested)
|
||||
("all", []), # 16: recent permits (begin_nested)
|
||||
("scalar", 0), # 17: geotech_risk
|
||||
# 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:
|
||||
|
|
|
|||
|
|
@ -125,21 +125,24 @@ def _make_db_for_analyze() -> MagicMock:
|
|||
("first", district_row), # 2: district
|
||||
("all", []), # 3: POI rows
|
||||
("all", []), # 4: competitor rows
|
||||
("all", []), # 5: pipeline rows
|
||||
("first", centroid_row), # 6: centroid
|
||||
("all", []), # 7: noise rows
|
||||
("all", []), # 8: hydrology rows
|
||||
("all", []), # 9: utilities rows
|
||||
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
|
||||
("all", []), # 6: pipeline rows
|
||||
("first", centroid_row), # 7: centroid
|
||||
("all", []), # 8: noise rows
|
||||
("all", []), # 9: hydrology rows
|
||||
("all", []), # 10: utilities rows
|
||||
# (далее nspd_zoning merge не ходит в db.execute — резолвер замокан)
|
||||
("first", None), # 10: parcel_meta
|
||||
("first", None), # 11: market trend
|
||||
("first", None), # 12: zoning (begin_nested)
|
||||
("all", []), # 13: success recommendation
|
||||
("first", None), # 14: market price
|
||||
("all", []), # 15: recent permits
|
||||
("scalar", 0), # 16: geotech_risk
|
||||
("all", []), # 17: neighbors
|
||||
("first", None), # 18: overlap
|
||||
("first", None), # 11: parcel_meta
|
||||
("first", None), # 12: market trend
|
||||
("first", None), # 13: zoning (begin_nested)
|
||||
("all", []), # 14: success recommendation
|
||||
("first", None), # 15: market price
|
||||
("all", []), # 16: recent permits
|
||||
("scalar", 0), # 17: geotech_risk
|
||||
# 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:
|
||||
|
|
|
|||
|
|
@ -277,20 +277,23 @@ def _make_db_for_analyze() -> MagicMock:
|
|||
("first", district_row), # 2: district
|
||||
("all", []), # 3: POI rows
|
||||
("all", []), # 4: competitor rows
|
||||
("all", []), # 5: pipeline rows
|
||||
("first", centroid_row), # 6: centroid
|
||||
("all", []), # 7: noise rows
|
||||
("all", []), # 8: hydrology rows
|
||||
("all", []), # 9: utilities rows
|
||||
("first", None), # 10: parcel_meta
|
||||
("first", None), # 11: market trend
|
||||
("first", None), # 12: zoning (begin_nested)
|
||||
("all", []), # 13: success recommendation (begin_nested)
|
||||
("first", None), # 14: market price (begin_nested)
|
||||
("all", []), # 15: recent permits (begin_nested)
|
||||
("scalar", 0), # 16: geotech_risk
|
||||
("all", []), # 17: neighbors
|
||||
("first", None), # 18: overlap
|
||||
("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B
|
||||
("all", []), # 6: pipeline rows
|
||||
("first", centroid_row), # 7: centroid
|
||||
("all", []), # 8: noise rows
|
||||
("all", []), # 9: hydrology rows
|
||||
("all", []), # 10: utilities rows
|
||||
("first", None), # 11: parcel_meta
|
||||
("first", None), # 12: market trend
|
||||
("first", None), # 13: zoning (begin_nested)
|
||||
("all", []), # 14: success recommendation (begin_nested)
|
||||
("first", None), # 15: market price (begin_nested)
|
||||
("all", []), # 16: recent permits (begin_nested)
|
||||
("scalar", 0), # 17: geotech_risk
|
||||
# 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:
|
||||
|
|
|
|||
226
backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py
Normal file
226
backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py
Normal 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"}
|
||||
|
|
@ -479,7 +479,11 @@ def test_permits_nearby_non_empty_summary_and_list() -> 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 = [
|
||||
{
|
||||
"doc_group": "RS",
|
||||
|
|
@ -501,15 +505,76 @@ def test_permits_nearby_items_capped_at_ten() -> None:
|
|||
}
|
||||
doc = _new_doc()
|
||||
_build_permits_nearby(doc, result)
|
||||
# Вторая таблица (документы): шапка + не более 10 строк.
|
||||
# Вторая таблица (документы): шапка + 10 показанных + 1 disclosure-строка.
|
||||
docs_table = doc.tables[1]
|
||||
assert len(docs_table.rows) == 11
|
||||
assert len(docs_table.rows) == 12
|
||||
text = _all_text(doc)
|
||||
assert "РНС-0" in text
|
||||
assert "РНС-9" 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:
|
||||
"""_build_section_6(doc, forecast, result) прокидывает result → блок разрешений виден."""
|
||||
doc = _new_doc()
|
||||
|
|
|
|||
|
|
@ -468,6 +468,67 @@ def test_permits_nearby_org_name_escaped() -> None:
|
|||
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:
|
||||
"""analyze_result прокинут в §6 → блок разрешений рядом виден в part_b HTML."""
|
||||
analyze = {
|
||||
|
|
|
|||
|
|
@ -576,6 +576,108 @@ def test_rosreestr_fallback_project_absorption_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():
|
||||
"""sample_competitors каждого элемента содержит by_room_bucket."""
|
||||
comp_rows = [_comp_row(1), _comp_row(2)]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue