From 27d1b09c721fc4c246fdbfc4798669388c7c0771 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 22:04:17 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(trade-in-pdf):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=82=D1=80=D0=B8=20=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=83=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=D0=B7=20PDF-=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D1=91=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отчёт печатал измерения, которых не делал: 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 чисто. --- .../app/services/exporters/trade_in_pdf.py | 141 ++++++++++++++---- .../services/test_trade_in_pdf_dual_price.py | 118 ++++++++++++++- 2 files changed, 228 insertions(+), 31 deletions(-) diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index b0428d84..ead22a51 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -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"{text}" -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), + # а не хардкод «10–18%», который противоречил бы вычисленному «−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 - + @@ -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%» на обложке), а не хардкод «10–18%», + # который противоречил бы вычисленному «−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)
- По данным реальных сделок, квартиры продаются в среднем на 10–18% дешевле, - чем заявлено в объявлениях (Екатеринбург, 2026) + {deals_discount_text}

@@ -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"""
diff --git a/tradein-mvp/backend/tests/services/test_trade_in_pdf_dual_price.py b/tradein-mvp/backend/tests/services/test_trade_in_pdf_dual_price.py index ac78775a..2f405445 100644 --- a/tradein-mvp/backend/tests/services/test_trade_in_pdf_dual_price.py +++ b/tradein-mvp/backend/tests/services/test_trade_in_pdf_dual_price.py @@ -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 «10–18%» 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 "10–18%" 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 "10–18%" 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 "10–18%" 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 "10–18%" not in html + assert "продаются дешевле, чем заявлено" in html -- 2.45.3 From 20bdc7dec8e721f8b365b2026f77a09dfbc923be Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 22:12:42 +0300 Subject: [PATCH 2/2] =?UTF-8?q?test(tradein/pdf):=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20insufficient-page=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=20=D0=BF=D0=BE=D0=B4=20city-aware=20?= =?UTF-8?q?=E2=84=96=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_insufficient_data_page_contains_address_and_report_num ассертил старый захардкоженный «EKБ-» (mixed-script баг). Оценка без target_address → город не распознан → нейтральный префикс «МЕРА» (#pdf-honesty). Обновлён ассерт: «МЕРА-» присутствует, «EKБ-» отсутствует. --- tradein-mvp/backend/tests/test_pdf_security.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index 0f04bfe7..7e05c92b 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -122,7 +122,10 @@ def test_insufficient_data_page_contains_address_and_report_num() -> None: est = _zero_estimate() html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC) assert "Ленина" in html # address - assert "EKБ-" in html # report number prefix + # #pdf-honesty: № отчёта city-aware — у оценки без target_address город не распознан + # → нейтральный префикс «МЕРА» (раньше был захардкожен «EKБ» для любого объекта). + assert "МЕРА-" in html + assert "EKБ-" not in html # старый mixed-script хардкод убран def test_generate_pdf_insufficient_skips_offer_page() -> None: -- 2.45.3

Цены в объявлениях — ожидания собственниковФактические сделки проходят на 10–18% ниже, что подтверждают - Росреестр, ДомКлик и продажи агентств недвижимости{advice_discount_text}
Ремонт оценивается по состоянию, а не по вложенным суммам