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