Merge pull request 'fix(tradein/pdf): честность отчёта — убрать фейк-сроки, хардкод EKБ, противоречивый дисконт (R2)' (#2494) from fix/tradein-pdf-honesty into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
This commit is contained in:
commit
a3dfa2a99f
3 changed files with 232 additions and 32 deletions
|
|
@ -40,7 +40,6 @@ import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
import matplotlib
|
import matplotlib
|
||||||
|
|
||||||
|
|
@ -376,11 +375,60 @@ def _mono(text: str) -> str:
|
||||||
return f"<span class='mono'>{text}</span>"
|
return f"<span class='mono'>{text}</span>"
|
||||||
|
|
||||||
|
|
||||||
def _report_number(estimate_id: UUID) -> str:
|
# Город (region 66) → буквенный префикс № отчёта. Раньше _report_number
|
||||||
"""№ отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
|
# захардкоживал «EKБ» для ВСЕХ оценок — объект в Серове / Нижнем Тагиле получал
|
||||||
short = int(estimate_id.int) % 10_000
|
# екатеринбургский код на обложке, в шапках и в футере (#pdf-honesty). Префикс
|
||||||
long = int(estimate_id.int) % 10_000_000_000
|
# теперь выводится из адреса оценки; ключи в нижнем регистре, ё→е (симметрично
|
||||||
return f"EKБ-{short:04d}-{long:010d}"
|
# _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:
|
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
|
if estimate.expires_at is not None
|
||||||
else today + dt.timedelta(days=30)
|
else today + dt.timedelta(days=30)
|
||||||
)
|
)
|
||||||
report_num = _report_number(estimate.estimate_id)
|
report_num = _report_number(estimate)
|
||||||
|
|
||||||
# Короткий адрес (для cover): берём первую часть до запятой
|
# Короткий адрес (для cover): берём первую часть до запятой
|
||||||
full_address = input_snapshot.get("address", "—")
|
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 "Не указано"
|
repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
|
||||||
balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False 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;
|
||||||
# Если есть days_on_market в analogs — берём min/max, иначе фиксированно.
|
# если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty).
|
||||||
days_min, days_max = _days_on_market_range(estimate.analogs)
|
days_range = _days_on_market_range(estimate.analogs)
|
||||||
|
|
||||||
# Deals range — если deals есть, считаем; иначе fallback к listings range
|
# Deals range — если deals есть, считаем; иначе fallback к listings range
|
||||||
deals_low, deals_high = _deals_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_low_rub,
|
||||||
estimate.range_high_rub,
|
estimate.range_high_rub,
|
||||||
sub_label="Активный рынок с аналогичным состоянием ремонта",
|
sub_label="Активный рынок с аналогичным состоянием ремонта",
|
||||||
days_min=days_min,
|
days_min=days_range[0] if days_range else None,
|
||||||
days_max=days_max,
|
days_max=days_range[1] if days_range else None,
|
||||||
show_days=True,
|
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_bar = _price_range_chart_svg(
|
||||||
deals_low,
|
deals_low,
|
||||||
deals_high,
|
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;">
|
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:4pt;">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="advice-title">Цены в объявлениях — ожидания собственников</td>
|
<td class="advice-title">Цены в объявлениях — ожидания собственников</td>
|
||||||
<td class="advice-text">Фактические сделки проходят на 10–18% ниже, что подтверждают
|
<td class="advice-text">{advice_discount_text}</td>
|
||||||
Росреестр, ДомКлик и продажи агентств недвижимости</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
|
<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]:
|
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int] | None:
|
||||||
"""Min/max days_on_market по аналогам. Fallback 4-118 при отсутствии данных."""
|
"""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]
|
days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
|
||||||
if not days:
|
if not days:
|
||||||
return 4, 118
|
return None
|
||||||
return min(days), max(days)
|
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} комнаты"
|
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
|
||||||
|
|
||||||
# Полоска диапазона
|
# Полоска диапазона — срок экспозиции только при реальных days_on_market
|
||||||
days_min, days_max = _days_on_market_range(estimate.analogs)
|
# (иначе не рисуем, не выдумываем «4-118 дней», #pdf-honesty).
|
||||||
|
days_range = _days_on_market_range(estimate.analogs)
|
||||||
range_bar = _price_range_chart_svg(
|
range_bar = _price_range_chart_svg(
|
||||||
estimate.range_low_rub,
|
estimate.range_low_rub,
|
||||||
estimate.range_high_rub,
|
estimate.range_high_rub,
|
||||||
sub_label="Рынок",
|
sub_label="Рынок",
|
||||||
days_min=days_min,
|
days_min=days_range[0] if days_range else None,
|
||||||
days_max=days_max,
|
days_max=days_range[1] if days_range else None,
|
||||||
show_days=True,
|
show_days=days_range is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Топ-5 примеров (отсортированных по distance)
|
# Топ-5 примеров (отсортированных по distance)
|
||||||
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
|
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
|
||||||
examples_rows = _examples_rows(top5)
|
examples_rows = _examples_rows(top5)
|
||||||
|
|
||||||
report_num = _report_number(estimate.estimate_id)
|
report_num = _report_number(estimate)
|
||||||
today = dt.date.today()
|
today = dt.date.today()
|
||||||
footer_html = _page_footer(
|
footer_html = _page_footer(
|
||||||
brand,
|
brand,
|
||||||
|
|
@ -1391,6 +1460,21 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
||||||
today = dt.date.today()
|
today = dt.date.today()
|
||||||
period_start = today - dt.timedelta(days=estimate.period_months * 30)
|
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 (не захардкоженный список).
|
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
|
||||||
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
|
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
|
||||||
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
|
_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]
|
top5 = estimate.actual_deals[:5]
|
||||||
examples_rows = _examples_rows(top5)
|
examples_rows = _examples_rows(top5)
|
||||||
|
|
||||||
report_num = _report_number(estimate.estimate_id)
|
report_num = _report_number(estimate)
|
||||||
footer_html = _page_footer(
|
footer_html = _page_footer(
|
||||||
brand,
|
brand,
|
||||||
report_num,
|
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};
|
<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;">
|
background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
|
||||||
По данным реальных сделок, квартиры продаются в среднем на 10–18% дешевле,
|
{deals_discount_text}
|
||||||
чем заявлено в объявлениях (Екатеринбург, 2026)
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
|
<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)
|
brand_short = _html.escape(brand.name)
|
||||||
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
|
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()
|
today = dt.date.today()
|
||||||
footer_html = _page_footer(
|
footer_html = _page_footer(
|
||||||
brand,
|
brand,
|
||||||
|
|
@ -2015,7 +2098,7 @@ def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot:
|
||||||
Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
|
Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
|
||||||
"""
|
"""
|
||||||
address = _html.escape(input_snapshot.get("address", "—"))
|
address = _html.escape(input_snapshot.get("address", "—"))
|
||||||
report_num = _report_number(estimate.estimate_id)
|
report_num = _report_number(estimate)
|
||||||
today = dt.date.today()
|
today = dt.date.today()
|
||||||
return f"""
|
return f"""
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
||||||
_wp_mock = MagicMock()
|
_wp_mock = MagicMock()
|
||||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
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.brand import Brand # noqa: E402
|
||||||
from app.services.exporters import trade_in_pdf as mod # 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."""
|
"""Minimal balanced-tag check: every non-void tag that opens must close."""
|
||||||
|
|
||||||
_VOID: ClassVar[set[str]] = {
|
_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:
|
def __init__(self) -> None:
|
||||||
|
|
@ -235,3 +244,108 @@ def test_build_cover_without_sold_renders_only_asking() -> None:
|
||||||
# existing layout intact
|
# existing layout intact
|
||||||
assert "Диапазон цен в объявлениях" in html
|
assert "Диапазон цен в объявлениях" in html
|
||||||
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()
|
est = _zero_estimate()
|
||||||
html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC)
|
html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC)
|
||||||
assert "Ленина" in html # address
|
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:
|
def test_generate_pdf_insufficient_skips_offer_page() -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue