fix(tradein/pdf): честность отчёта — убрать фейк-сроки, хардкод EKБ, противоречивый дисконт (R2) #2494
3 changed files with 232 additions and 32 deletions
|
|
@ -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),
|
||||
# а не хардкод «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
|
|||
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:4pt;">
|
||||
<tr>
|
||||
<td class="advice-title">Цены в объявлениях — ожидания собственников</td>
|
||||
<td class="advice-text">Фактические сделки проходят на 10–18% ниже, что подтверждают
|
||||
Росреестр, ДомКлик и продажи агентств недвижимости</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%» на обложке), а не хардкод «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)
|
|||
|
||||
<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;">
|
||||
По данным реальных сделок, квартиры продаются в среднем на 10–18% дешевле,
|
||||
чем заявлено в объявлениях (Екатеринбург, 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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue