fix(trade-in-pdf): убрать три ложных утверждения из PDF-отчёта
Some checks failed
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 1m0s

Отчёт печатал измерения, которых не делал:

1. Выдуманный «срок продажи 4–118 дней». _days_on_market_range при
   отсутствии реальных days_on_market (~48% оценок) возвращал хардкод
   (4, 118), а обложка и страница объявлений рисовали его как
   измеренный срок экспозиции. Теперь функция возвращает None, и оба
   call-site'а не рисуют срок (show_days=False) — как уже делает
   страница сделок.

2. № отчёта хардкодил «EKБ» для любой оценки — объект в Серове /
   Нижнем Тагиле получал екатеринбургский код на обложке, в шапках и
   футере. Префикс теперь выводится из target_address (ЕКБ / НТ / СЕР /
   …); неизвестный город → нейтральный «МЕРА», а не ложный «EKБ».

3. Статичный «сделки на 10–18% ниже» противоречил рассчитанному в том
   же PDF «−N%». Совет на обложке и баннер на странице сделок теперь
   ссылаются на реальный _discount_pct (тот же, что chip «−N%»), с
   нейтральной формулировкой когда дисконт неизвестен. Удалён ложный
   хвост «(Екатеринбург, 2026)».

Тесты: +11 кейсов (suppression (4,118), city-aware № отчёта,
computed-discount вместо «10–18%»). Все 23 проходят, ruff чисто.
This commit is contained in:
bot-backend 2026-07-12 22:04:17 +03:00
parent fb123bd97e
commit 27d1b09c72
2 changed files with 228 additions and 31 deletions

View file

@ -40,7 +40,6 @@ import logging
import pathlib
import re
from urllib.parse import urlparse
from uuid import UUID
import matplotlib
@ -376,11 +375,60 @@ def _mono(text: str) -> str:
return f"<span class='mono'>{text}</span>"
def _report_number(estimate_id: UUID) -> str:
"""№ отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
short = int(estimate_id.int) % 10_000
long = int(estimate_id.int) % 10_000_000_000
return f"EKБ-{short:04d}-{long:010d}"
# Город (region 66) → буквенный префикс № отчёта. Раньше _report_number
# захардкоживал «EKБ» для ВСЕХ оценок — объект в Серове / Нижнем Тагиле получал
# екатеринбургский код на обложке, в шапках и в футере (#pdf-honesty). Префикс
# теперь выводится из адреса оценки; ключи в нижнем регистре, ё→е (симметрично
# _resolve_target_city в estimator.py). Город вне карты → _DEFAULT_REPORT_PREFIX.
_CITY_REPORT_PREFIX: dict[str, str] = {
"екатеринбург": "ЕКБ",
"нижний тагил": "НТ",
"каменск-уральский": "КУ",
"первоуральск": "ПРВ",
"серов": "СЕР",
"новоуральск": "НВУ",
"ревда": "РЕВ",
"полевской": "ПЛВ",
"асбест": "АСБ",
"верхняя пышма": "ВП",
"березовский": "БРЗ",
"краснотурьинск": "КРТ",
"камышлов": "КМШ",
}
# Нейтральный префикс, когда город адреса не распознан (пустой / вне региона) —
# НЕ выдумываем локацию, не подставляем ложный «EKБ» (#pdf-honesty).
_DEFAULT_REPORT_PREFIX = "МЕРА"
# Длинные имена раньше коротких — чтобы «нижний тагил» матчился целиком.
_REPORT_CITY_RE = re.compile(
r"\b(?:"
+ "|".join(re.escape(c) for c in sorted(_CITY_REPORT_PREFIX, key=len, reverse=True))
+ r")\b"
)
def _report_city_prefix(address: str | None) -> str:
"""Буквенный префикс № отчёта по городу из адреса (region 66).
«МЕРА» если город не распознан не выдумываем локацию (раньше был хардкод
«EKБ» для любого адреса, включая не-екатеринбургские). ёе нормализация до
поиска (симметрично _resolve_target_city в estimator.py)."""
if not address:
return _DEFAULT_REPORT_PREFIX
norm = address.replace("ё", "е").replace("Ё", "е").lower()
m = _REPORT_CITY_RE.search(norm)
return _CITY_REPORT_PREFIX[m.group(0)] if m else _DEFAULT_REPORT_PREFIX
def _report_number(estimate: AggregatedEstimate) -> str:
"""№ отчёта: 'PREFIX-NNNN-XXXXXXXXXX'.
PREFIX буквенный код города оценки (ЕКБ / НТ / по target_address, «МЕРА»
если город не распознан); NNNN/XXXXXXXXXX детерминированные коды от
estimate_id. Раньше PREFIX был захардкожен «EKБ» для всех оценок (#pdf-honesty)."""
prefix = _report_city_prefix(estimate.target_address or estimate.canonical_address)
short = int(estimate.estimate_id.int) % 10_000
long = int(estimate.estimate_id.int) % 10_000_000_000
return f"{prefix}-{short:04d}-{long:010d}"
def _expires_date(estimate: AggregatedEstimate) -> dt.date:
@ -1000,7 +1048,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
if estimate.expires_at is not None
else today + dt.timedelta(days=30)
)
report_num = _report_number(estimate.estimate_id)
report_num = _report_number(estimate)
# Короткий адрес (для cover): берём первую часть до запятой
full_address = input_snapshot.get("address", "")
@ -1039,9 +1087,9 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else ""
# Active market subband — 4-118 days range (fallback без данных).
# Если есть days_on_market в analogs — берём min/max, иначе фиксированно.
days_min, days_max = _days_on_market_range(estimate.analogs)
# Срок экспозиции показываем ТОЛЬКО при реальных days_on_market в analogs;
# если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty).
days_range = _days_on_market_range(estimate.analogs)
# Deals range — если deals есть, считаем; иначе fallback к listings range
deals_low, deals_high = _deals_range(
@ -1052,11 +1100,27 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
estimate.range_low_rub,
estimate.range_high_rub,
sub_label="Активный рынок с аналогичным состоянием ремонта",
days_min=days_min,
days_max=days_max,
show_days=True,
days_min=days_range[0] if days_range else None,
days_max=days_range[1] if days_range else None,
show_days=days_range is not None,
)
# Совет «Цены в объявлениях — ожидания собственников» ссылается на РЕАЛЬНЫЙ
# рассчитанный дисконт запрос→продажа (тот же, что chip «N%» в dual-price),
# а не хардкод «1018%», который противоречил бы вычисленному «N%» (#pdf-honesty).
discount_pct = _discount_pct(estimate)
if discount_pct is not None:
advice_discount_text = (
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
f"ДомКлик и продажи агентств недвижимости"
)
else:
advice_discount_text = (
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
"Росреестр, ДомКлик и продажи агентств недвижимости"
)
deals_bar = _price_range_chart_svg(
deals_low,
deals_high,
@ -1137,8 +1201,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:4pt;">
<tr>
<td class="advice-title">Цены в объявлениях ожидания собственников</td>
<td class="advice-text">Фактические сделки проходят на 1018% ниже, что подтверждают
Росреестр, ДомКлик и продажи агентств недвижимости</td>
<td class="advice-text">{advice_discount_text}</td>
</tr>
<tr>
<td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
@ -1163,11 +1226,16 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
"""
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]:
"""Min/max days_on_market по аналогам. Fallback 4-118 при отсутствии данных."""
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int] | None:
"""Min/max days_on_market по аналогам, либо None если реальных данных нет.
Раньше возвращал хардкод (4, 118) при отсутствии days_on_market обложка и
страница объявлений печатали этот выдуманный коридор как измеренный срок
экспозиции (~48% оценок без days_on_market). Теперь None caller не рисует
срок (show_days=False), как уже делает страница сделок (#pdf-honesty)."""
days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
if not days:
return 4, 118
return None
return min(days), max(days)
@ -1225,22 +1293,23 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
)
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
# Полоска диапазона
days_min, days_max = _days_on_market_range(estimate.analogs)
# Полоска диапазона — срок экспозиции только при реальных days_on_market
# (иначе не рисуем, не выдумываем «4-118 дней», #pdf-honesty).
days_range = _days_on_market_range(estimate.analogs)
range_bar = _price_range_chart_svg(
estimate.range_low_rub,
estimate.range_high_rub,
sub_label="Рынок",
days_min=days_min,
days_max=days_max,
show_days=True,
days_min=days_range[0] if days_range else None,
days_max=days_range[1] if days_range else None,
show_days=days_range is not None,
)
# Топ-5 примеров (отсортированных по distance)
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
examples_rows = _examples_rows(top5)
report_num = _report_number(estimate.estimate_id)
report_num = _report_number(estimate)
today = dt.date.today()
footer_html = _page_footer(
brand,
@ -1391,6 +1460,21 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
today = dt.date.today()
period_start = today - dt.timedelta(days=estimate.period_months * 30)
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
# (тот же _discount_pct, что chip «N%» на обложке), а не хардкод «1018%»,
# который противоречил бы вычисленному «N%» в том же PDF. Убран и ложный
# хвост «(Екатеринбург, 2026)» — локация не привязана к объекту (#pdf-honesty).
discount_pct = _discount_pct(estimate)
if discount_pct is not None:
deals_discount_text = (
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в "
f"объявлениях — по этому объекту на {discount_pct}%"
)
else:
deals_discount_text = (
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в объявлениях"
)
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
@ -1438,7 +1522,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
top5 = estimate.actual_deals[:5]
examples_rows = _examples_rows(top5)
report_num = _report_number(estimate.estimate_id)
report_num = _report_number(estimate)
footer_html = _page_footer(
brand,
report_num,
@ -1495,8 +1579,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid {_DANGER};
background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
По данным реальных сделок, квартиры продаются в среднем на 1018% дешевле,
чем заявлено в объявлениях (Екатеринбург, 2026)
{deals_discount_text}
</div>
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
@ -1558,7 +1641,7 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
brand_short = _html.escape(brand.name)
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
report_num = _report_number(estimate.estimate_id)
report_num = _report_number(estimate)
today = dt.date.today()
footer_html = _page_footer(
brand,
@ -2015,7 +2098,7 @@ def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot:
Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
"""
address = _html.escape(input_snapshot.get("address", ""))
report_num = _report_number(estimate.estimate_id)
report_num = _report_number(estimate)
today = dt.date.today()
return f"""
<div>

View file

@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
from app.schemas.trade_in import AggregatedEstimate # noqa: E402
from app.schemas.trade_in import AggregatedEstimate, AnalogLot # noqa: E402
from app.services.brand import Brand # noqa: E402
from app.services.exporters import trade_in_pdf as mod # noqa: E402
@ -79,7 +79,16 @@ class _WellFormed(HTMLParser):
"""Minimal balanced-tag check: every non-void tag that opens must close."""
_VOID: ClassVar[set[str]] = {
"br", "img", "hr", "meta", "input", "rect", "path", "circle", "line", "polyline",
"br",
"img",
"hr",
"meta",
"input",
"rect",
"path",
"circle",
"line",
"polyline",
}
def __init__(self) -> None:
@ -235,3 +244,108 @@ def test_build_cover_without_sold_renders_only_asking() -> None:
# existing layout intact
assert "Диапазон цен в объявлениях" in html
assert "Диапазон цен по фактическим сделкам" in html
# ── _days_on_market_range — no fabricated (4, 118) corridor (#pdf-honesty) ─────
def _lot(days_on_market: int | None, price_rub: int = 10_000_000) -> AnalogLot:
return AnalogLot(
address="Екатеринбург, ул. Ленина, 1",
area_m2=50.0,
rooms=2,
floor=3,
total_floors=9,
price_rub=price_rub,
price_per_m2=int(price_rub / 50),
listing_date=None,
days_on_market=days_on_market,
)
def test_days_range_none_when_no_lots() -> None:
# Раньше возвращал хардкод (4, 118) — выдуманный срок экспозиции.
assert mod._days_on_market_range([]) is None
def test_days_range_none_when_all_days_missing() -> None:
assert mod._days_on_market_range([_lot(None), _lot(None)]) is None
def test_days_range_min_max_when_data_present() -> None:
assert mod._days_on_market_range([_lot(12), _lot(None), _lot(88)]) == (12, 88)
def test_cover_without_days_data_still_renders_range_chart() -> None:
# Нет days_on_market → show_days=False, но ценовой бар всё равно строится.
est = _estimate(analogs=[_lot(None)])
html = mod._build_cover(est, _SNAPSHOT, _BRAND)
_assert_well_formed(html)
assert "Диапазон цен в объявлениях" in html
# ── _report_number — city-aware prefix, never hardcoded «EKБ» (#pdf-honesty) ───
def test_report_number_ekb_prefix_for_ekb_address() -> None:
est = _estimate(target_address="Екатеринбург, ул. Ленина, 1")
assert mod._report_number(est).startswith("ЕКБ-")
def test_report_number_city_prefix_for_non_ekb_address() -> None:
assert mod._report_number(_estimate(target_address="Нижний Тагил, ул. Мира, 5")).startswith(
"НТ-"
)
assert mod._report_number(_estimate(target_address="Серов, ул. Ленина, 3")).startswith("СЕР-")
def test_report_number_neutral_prefix_when_city_unknown() -> None:
# None адрес и адрес вне региона → нейтральный «МЕРА», НЕ ложный «EKБ».
assert mod._report_number(_estimate(target_address=None)).startswith("МЕРА-")
assert mod._report_number(_estimate(target_address="Москва, ул. Тверская, 1")).startswith(
"МЕРА-"
)
def test_report_number_never_hardcodes_old_ekb_literal() -> None:
# Старый баг: «EKБ-» (латинские EK + кириллическая Б) для ЛЮБОГО адреса.
for addr in (None, "Серов, ул. Ленина, 3", "Москва, ул. Тверская, 1"):
assert not mod._report_number(_estimate(target_address=addr)).startswith("EK")
def test_report_number_falls_back_to_canonical_address() -> None:
est = _estimate(target_address=None, canonical_address="Первоуральск, ул. Вайнера, 2")
assert mod._report_number(est).startswith("ПРВ-")
# ── Static «1018%» haircut replaced by computed «N%» (#pdf-honesty) ──────────
def test_cover_advice_uses_computed_discount_not_static_range() -> None:
est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80) # 20%
html = mod._build_cover(est, _SNAPSHOT, _BRAND)
assert "1018%" not in html
assert "на 20%" in html
def test_cover_advice_neutral_when_no_discount() -> None:
html = mod._build_cover(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
assert "1018%" not in html
assert "проходят ниже цен в объявлениях" in html
def test_deals_banner_uses_computed_discount_not_static_range() -> None:
est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80) # 20%
html = mod._build_deals_page(est, _SNAPSHOT, _BRAND)
_assert_well_formed(html)
assert "1018%" not in html
# Ложный локейшн-хвост «(Екатеринбург, 2026)» удалён.
assert "Екатеринбург, 2026" not in html
assert "на 20%" in html
def test_deals_banner_neutral_when_no_discount() -> None:
html = mod._build_deals_page(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
_assert_well_formed(html)
assert "1018%" not in html
assert "продаются дешевле, чем заявлено" in html