Merge remote-tracking branch 'forgejo/main' into fix/tradein-pool-browser-yandex-cian
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI / changes (pull_request) Successful in 18s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m11s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI / changes (pull_request) Successful in 18s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m11s
This commit is contained in:
commit
78bfa9555b
11 changed files with 300 additions and 67 deletions
|
|
@ -216,6 +216,19 @@ def _confidence_label(c: float) -> str:
|
||||||
return "low"
|
return "low"
|
||||||
|
|
||||||
|
|
||||||
|
_ROMAN_QUARTERS = ("I", "II", "III", "IV")
|
||||||
|
|
||||||
|
|
||||||
|
def _deals_quarter_label(d: _dt.date) -> str:
|
||||||
|
"""Дата сделок Росреестра → «I квартал 2026».
|
||||||
|
|
||||||
|
Росреестр отдаёт сделки ПОКВАРТАЛЬНО без точных дат: max(deal_date) = начало
|
||||||
|
квартала поставки (2026-01-01 = I квартал 2026), а НЕ «источник устарел».
|
||||||
|
Месяц 1-3→I, 4-6→II, 7-9→III, 10-12→IV (римские).
|
||||||
|
"""
|
||||||
|
return f"{_ROMAN_QUARTERS[(d.month - 1) // 3]} квартал {d.year}"
|
||||||
|
|
||||||
|
|
||||||
# Человеко-читаемые имена категорий для verbal breakdown (X1).
|
# Человеко-читаемые имена категорий для verbal breakdown (X1).
|
||||||
_POI_CATEGORY_RU: dict[str, str] = {
|
_POI_CATEGORY_RU: dict[str, str] = {
|
||||||
"school": "Школа",
|
"school": "Школа",
|
||||||
|
|
@ -2873,12 +2886,16 @@ def analyze_parcel(
|
||||||
_deals_stale = (
|
_deals_stale = (
|
||||||
recent_n_val == 0 and max_period is not None and max_period < stale_cutoff
|
recent_n_val == 0 and max_period is not None and max_period < stale_cutoff
|
||||||
)
|
)
|
||||||
# Разные причины отсутствия сделок → честная микрокопия: устаревание
|
# Честная микрокопия. Росреестр отдаёт сделки ПОКВАРТАЛЬНО (max(deal_date)
|
||||||
# источника vs просто нет сделок рядом (источник свеж). Обе показывают
|
# = начало квартала поставки) — это НЕ «источник устарел». Для stale-ветки
|
||||||
|
# называем квартал поставки; иначе просто нет сделок рядом. Обе показывают
|
||||||
# предложение как основной контент.
|
# предложение как основной контент.
|
||||||
_offer_only_label = (
|
_offer_only_label = (
|
||||||
"Сделки устарели — показываем динамику цен предложения"
|
(
|
||||||
if _deals_stale
|
f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
|
||||||
|
"(поставка поквартальная); показываем динамику цен предложения"
|
||||||
|
)
|
||||||
|
if _deals_stale and max_period is not None
|
||||||
else "Нет сделок рядом — показываем динамику цен предложения"
|
else "Нет сделок рядом — показываем динамику цен предложения"
|
||||||
)
|
)
|
||||||
market_trend = {
|
market_trend = {
|
||||||
|
|
@ -2891,13 +2908,17 @@ def analyze_parcel(
|
||||||
"offer_trend": offer_block,
|
"offer_trend": offer_block,
|
||||||
}
|
}
|
||||||
elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff:
|
elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff:
|
||||||
# Recent-окно пусто И latest deal старше 3 мес → источник устарел (не «нет рядом»).
|
# Recent-окно пусто И latest deal старше 3 мес. Росреестр отдаёт сделки
|
||||||
# #2178: сюда попадаем ТОЛЬКО когда и offer-данных нет (offer_block is None) —
|
# ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — честно
|
||||||
# прежний source_stale остаётся честным «нечего показать».
|
# называем квартал поставки, НЕ «источник устарел». #2178: сюда попадаем
|
||||||
|
# ТОЛЬКО когда и offer-данных нет (offer_block is None).
|
||||||
market_trend = {
|
market_trend = {
|
||||||
"status": "source_stale",
|
"status": "source_stale",
|
||||||
"as_of_date": max_period.isoformat(),
|
"as_of_date": max_period.isoformat(),
|
||||||
"label": (f"Источник сделок устарел (данные до {max_period.strftime('%m.%Y')})"),
|
"label": (
|
||||||
|
f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
|
||||||
|
"(поставка поквартальная); тренд по сделкам не показываем"
|
||||||
|
),
|
||||||
"recent_deals_count": 0,
|
"recent_deals_count": 0,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,10 @@ _SVERDLOVSK_TOKEN = "свердлов"
|
||||||
# НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент
|
# НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент
|
||||||
# стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)»)
|
# стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)»)
|
||||||
# → search по всему имени (не $-якорь), фрагмент вырезаем, остаток склеиваем. #2119 B1.
|
# → search по всему имени (не $-якорь), фрагмент вырезаем, остаток склеиваем. #2119 B1.
|
||||||
_OUTPUT_SUFFIX_RE = re.compile(r"\((выход\s*№?\s*\d+)\)", re.IGNORECASE)
|
# Группы: 1=«выход», 2=номер (для канонизации «выход №N» без разнобоя пробелов вокруг №,
|
||||||
|
# иначе «выход № 1» и «выход №1» породили бы РАЗНЫЕ output_name → дубли по
|
||||||
|
# ON CONFLICT (grs_name_norm, output_name)).
|
||||||
|
_OUTPUT_SUFFIX_RE = re.compile(r"\(\s*(выход)\s*№?\s*(\d+)\s*\)", re.IGNORECASE)
|
||||||
|
|
||||||
# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6,
|
# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6,
|
||||||
# свободная %=7, уведомление(основание/срок/параметры)=8,9,10.
|
# свободная %=7, уведомление(основание/срок/параметры)=8,9,10.
|
||||||
|
|
@ -168,12 +171,16 @@ def _split_grs_name(raw_name: str) -> tuple[str, str | None]:
|
||||||
«ГРС Арти (выход №2)» → («ГРС Арти», «выход №2»); фрагмент «(выход №N)» бывает
|
«ГРС Арти (выход №2)» → («ГРС Арти», «выход №2»); фрагмент «(выход №N)» бывает
|
||||||
и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)» → («ГРС-1 Свердловск (5)»,
|
и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)» → («ГРС-1 Свердловск (5)»,
|
||||||
«выход №1»)) — вырезаем его, остаток склеиваем и нормализуем двойные пробелы.
|
«выход №1»)) — вырезаем его, остаток склеиваем и нормализуем двойные пробелы.
|
||||||
|
output_name канонизируется в «выход №N» (нижний регистр, ровно один пробел после
|
||||||
|
«выход», сразу «№N» без пробела) — разнобой пробелов вокруг «№» иначе дал бы
|
||||||
|
разные output_name и дубли по ON CONFLICT (grs_name_norm, output_name).
|
||||||
«ГРС Арамиль (5)» → без изменений (цифра в скобках — НЕ выход, часть имени).
|
«ГРС Арамиль (5)» → без изменений (цифра в скобках — НЕ выход, часть имени).
|
||||||
"""
|
"""
|
||||||
m = _OUTPUT_SUFFIX_RE.search(raw_name)
|
m = _OUTPUT_SUFFIX_RE.search(raw_name)
|
||||||
if not m:
|
if not m:
|
||||||
return raw_name, None
|
return raw_name, None
|
||||||
output = _MULTISPACE_RE.sub(" ", m.group(1)).strip()
|
# Канонический output_name: «выход №N» (число из группы 2, «№» приклеено к цифре).
|
||||||
|
output = f"{m.group(1).lower()} №{m.group(2)}"
|
||||||
# Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя
|
# Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя
|
||||||
# двойные пробелы, оставшиеся на месте выреза в середине).
|
# двойные пробелы, оставшиеся на месте выреза в середине).
|
||||||
grs_name = raw_name[: m.start()] + raw_name[m.end() :]
|
grs_name = raw_name[: m.start()] + raw_name[m.end() :]
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,36 @@ def test_split_grs_name_no_output() -> None:
|
||||||
assert gg._split_grs_name("ГРС Кольцово") == ("ГРС Кольцово", None)
|
assert gg._split_grs_name("ГРС Кольцово") == ("ГРС Кольцово", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_grs_name_output_before_station_digit_real_rows() -> None:
|
||||||
|
"""B1-хвост: РЕАЛЬНЫЕ прод-строки «(выход №N) (M)» → output_name извлекается.
|
||||||
|
|
||||||
|
Данные ГТЕ (asof 2026-06-01, снято 2026-07-02): FULL-строки, где «(выход №N)»
|
||||||
|
стоит ПЕРЕД станционным «(M)». До search-фикса они оседали в grs_name с пустым
|
||||||
|
output_name (5 строк). Локим на точных прод-именах — не регрессировать.
|
||||||
|
"""
|
||||||
|
cases = {
|
||||||
|
"ГРС-1 Свердловск (выход №1) (5)": ("ГРС-1 Свердловск (5)", "выход №1"),
|
||||||
|
"ГРС-1 Свердловск (выход №2) (5)": ("ГРС-1 Свердловск (5)", "выход №2"),
|
||||||
|
"ГРС-3 Екатеринбург (выход №1) (3)": ("ГРС-3 Екатеринбург (3)", "выход №1"),
|
||||||
|
"ГРС Реж (выход №1) (3)": ("ГРС Реж (3)", "выход №1"),
|
||||||
|
"ГРС Сухой Лог (выход №1) (4)": ("ГРС Сухой Лог (4)", "выход №1"),
|
||||||
|
}
|
||||||
|
for raw, expected in cases.items():
|
||||||
|
assert gg._split_grs_name(raw) == expected, raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_grs_name_output_canonicalized() -> None:
|
||||||
|
"""output_name канонизируется в «выход №N» независимо от пробелов/регистра/«№».
|
||||||
|
|
||||||
|
«Выход № 1» / «выход 1» / «выход №1» → все дают «выход №1» (иначе разный
|
||||||
|
output_name → дубли по ON CONFLICT (grs_name_norm, output_name)).
|
||||||
|
"""
|
||||||
|
for raw in ("ГРС Х (Выход № 1)", "ГРС Х (выход 1)", "ГРС Х (выход №1)"):
|
||||||
|
grs_name, output = gg._split_grs_name(raw)
|
||||||
|
assert output == "выход №1", raw
|
||||||
|
assert grs_name == "ГРС Х"
|
||||||
|
|
||||||
|
|
||||||
# ── _passes_region_filter ─────────────────────────────────────────────────────
|
# ── _passes_region_filter ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,34 +38,22 @@ function fmtPrice(v: number): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P2 (#1871): дата as_of_date (YYYY-MM-DD) → «месяц год» для caption о
|
* Росреестр отдаёт сделки ПОКВАРТАЛЬНО без точных дат: max(deal_date)=2026-01-01
|
||||||
* устаревшем источнике. Возвращает null если дата отсутствует/некорректна.
|
* означает «поставка за I квартал 2026», а НЕ «источник встал в январе». Дата
|
||||||
|
* YYYY-MM-DD → «I квартал 2026» (римские I/II/III/IV по месяцу). null при
|
||||||
|
* отсутствии/некорректной дате. Парсим компоненты напрямую (не new Date() — TZ).
|
||||||
*/
|
*/
|
||||||
const RU_MONTHS = [
|
const ROMAN_QUARTERS = ["I", "II", "III", "IV"];
|
||||||
"январь",
|
|
||||||
"февраль",
|
|
||||||
"март",
|
|
||||||
"апрель",
|
|
||||||
"май",
|
|
||||||
"июнь",
|
|
||||||
"июль",
|
|
||||||
"август",
|
|
||||||
"сентябрь",
|
|
||||||
"октябрь",
|
|
||||||
"ноябрь",
|
|
||||||
"декабрь",
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatAsOfMonth(asOf: string | undefined): string | null {
|
function formatDealsQuarter(asOf: string | undefined | null): string | null {
|
||||||
if (!asOf) return null;
|
if (!asOf) return null;
|
||||||
// Парсим YYYY-MM напрямую — НЕ через new Date() (UTC-полночь сдвинула бы
|
|
||||||
// месяц назад в TZ западнее UTC на границе, напр. 2026-01-01 → «декабрь 2025»).
|
|
||||||
const m = /^(\d{4})-(\d{2})/.exec(asOf);
|
const m = /^(\d{4})-(\d{2})/.exec(asOf);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const year = Number(m[1]);
|
const year = Number(m[1]);
|
||||||
const monthIdx = Number(m[2]) - 1;
|
const month = Number(m[2]);
|
||||||
if (monthIdx < 0 || monthIdx > 11) return null;
|
if (month < 1 || month > 12) return null;
|
||||||
return `${RU_MONTHS[monthIdx]} ${year}`;
|
const quarter = ROMAN_QUARTERS[Math.floor((month - 1) / 3)];
|
||||||
|
return `${quarter} квартал ${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -196,13 +184,15 @@ function OfferOnlyCard({
|
||||||
const sourceLine =
|
const sourceLine =
|
||||||
sourceParts.join(" ") + (period != null ? ` · ${period}` : "");
|
sourceParts.join(" ") + (period != null ? ` · ${period}` : "");
|
||||||
|
|
||||||
// Caveat — почему показываем предложение вместо сделок.
|
// Caveat — почему показываем предложение вместо сделок. Сделки Росреестра
|
||||||
const dealsMonth = formatAsOfMonth(dealsAsOfDate ?? undefined);
|
// приходят ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — это НЕ
|
||||||
|
// «источник устарел», а нормальный лаг квартальной выгрузки. Честная копия.
|
||||||
|
const dealsQuarter = formatDealsQuarter(dealsAsOfDate);
|
||||||
const caveat = dealsStale
|
const caveat = dealsStale
|
||||||
? dealsMonth != null
|
? dealsQuarter != null
|
||||||
? `Сделки Росреестра устарели (до ${dealsMonth}) — показана динамика цен предложения.`
|
? `Сделки Росреестра — за ${dealsQuarter} (поставка поквартальная); показана динамика цен предложения (Объектив).`
|
||||||
: "Сделки Росреестра устарели — показана динамика цен предложения."
|
: "Сделки Росреестра поставляются поквартально; показана динамика цен предложения (Объектив)."
|
||||||
: "Сделок Росреестра рядом нет — показана динамика цен предложения.";
|
: "Сделок Росреестра рядом нет — показана динамика цен предложения (Объектив).";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -303,8 +293,8 @@ export function MarketTrendBlock({ trend }: Props) {
|
||||||
trend.radius_km != null ? ` в радиусе ${trend.radius_km} км` : "";
|
trend.radius_km != null ? ` в радиусе ${trend.radius_km} км` : "";
|
||||||
const title = `Тренд рынка${radiusSuffix}`;
|
const title = `Тренд рынка${radiusSuffix}`;
|
||||||
|
|
||||||
// #2178: сделки устарели/отсутствуют, но живёт динамика предложения —
|
// #2178: сделок в recent-окне нет (поквартальная поставка / нет рядом), но живёт
|
||||||
// полноценная карточка тренда по предложению (Объектив), НЕ caveat-тупик.
|
// динамика предложения — полноценная карточка тренда по предложению (Объектив).
|
||||||
if (status === "offer_only") {
|
if (status === "offer_only") {
|
||||||
const offer = trend.offer_trend;
|
const offer = trend.offer_trend;
|
||||||
// Defensive: без валидного offer-блока падаем в честный caveat, не крашимся.
|
// Defensive: без валидного offer-блока падаем в честный caveat, не крашимся.
|
||||||
|
|
@ -321,19 +311,21 @@ export function MarketTrendBlock({ trend }: Props) {
|
||||||
return (
|
return (
|
||||||
<TrendCaveat
|
<TrendCaveat
|
||||||
title={title}
|
title={title}
|
||||||
message="Источник сделок устарел — тренд не показываем."
|
message="Сделки Росреестра поставляются поквартально; тренд по сделкам не показываем."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// P2 (#1871): источник сделок устарел — caveat вместо фейкового тренда.
|
// #1871/quarterly: сделки Росреестра приходят поквартально (as_of_date = начало
|
||||||
|
// квартала поставки), НЕ «устарели». Показываем честную квартальную подпись
|
||||||
|
// вместо тренда (recent-окно пусто, offer-данных тоже нет). Бэк-label в приоритете.
|
||||||
if (status === "source_stale") {
|
if (status === "source_stale") {
|
||||||
const asOfMonth = formatAsOfMonth(trend.as_of_date);
|
const quarter = formatDealsQuarter(trend.as_of_date);
|
||||||
const message =
|
const message =
|
||||||
trend.label ??
|
trend.label ??
|
||||||
(asOfMonth != null
|
(quarter != null
|
||||||
? `Источник сделок устарел (данные до ${asOfMonth}).`
|
? `Сделки Росреестра — за ${quarter} (поставка поквартальная); динамику цен предложения показать не удалось.`
|
||||||
: "Источник сделок устарел — тренд не показываем.");
|
: "Сделки Росреестра поставляются поквартально; тренд по сделкам не показываем.");
|
||||||
return <TrendCaveat title={title} message={message} />;
|
return <TrendCaveat title={title} message={message} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* Unit tests for §3 «Сети» number formatters (connection-capacity.ts).
|
||||||
|
*
|
||||||
|
* Focus: honest near-zero rendering of ГРС free capacity. При 1 знаке после
|
||||||
|
* запятой «0,044 тыс. м³/ч» округлялось бы в «0,0» — ГРС с крохотной, но
|
||||||
|
* НЕнулевой свободной мощностью выглядела бы закрытой. Для 0 < |v| < 0,05
|
||||||
|
* показываем 2 знака; настоящий 0 остаётся «0,0».
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { formatThousandM3Hour } from "../connection-capacity";
|
||||||
|
|
||||||
|
// toLocaleString("ru-RU") даёт запятую-десятичную и Unicode-минус (−) для
|
||||||
|
// отрицательных — сверяем именно на этих формах.
|
||||||
|
describe("formatThousandM3Hour — честность околонуля", () => {
|
||||||
|
it("крохотная положительная мощность не схлопывается в 0,0", () => {
|
||||||
|
expect(formatThousandM3Hour(0.044)).toBe("0,04 тыс. м³/ч");
|
||||||
|
expect(formatThousandM3Hour("0.044")).toBe("0,04 тыс. м³/ч");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("крохотная отрицательная мощность (дефицит) сохраняет знак и не нулится", () => {
|
||||||
|
// toLocaleString даёт минус (ASCII «-» или Unicode «−» в зависимости от ICU
|
||||||
|
// рантайма) + 2 знака — не «0,0». Сверяем знак + значимую часть, не глиф.
|
||||||
|
const out = formatThousandM3Hour(-0.044);
|
||||||
|
expect(out).not.toBeNull();
|
||||||
|
expect(out).toMatch(/^[-−]0,04 тыс\. м³\/ч$/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("настоящий ноль остаётся 0,0 (1 знак)", () => {
|
||||||
|
expect(formatThousandM3Hour(0)).toBe("0,0 тыс. м³/ч");
|
||||||
|
expect(formatThousandM3Hour("0")).toBe("0,0 тыс. м³/ч");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("значения ≥ 0,05 — прежний 1 знак после запятой", () => {
|
||||||
|
expect(formatThousandM3Hour(1.205)).toBe("1,2 тыс. м³/ч");
|
||||||
|
expect(formatThousandM3Hour(0.05)).toBe("0,1 тыс. м³/ч");
|
||||||
|
expect(formatThousandM3Hour(42.2)).toBe("42,2 тыс. м³/ч");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("null/undefined/нечисло → null (поле скрывается, не «0»)", () => {
|
||||||
|
expect(formatThousandM3Hour(null)).toBeNull();
|
||||||
|
expect(formatThousandM3Hour(undefined)).toBeNull();
|
||||||
|
expect(formatThousandM3Hour("н/д")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -132,14 +132,19 @@ export function formatMeters(
|
||||||
}
|
}
|
||||||
|
|
||||||
// тыс. м³/ч (свободная/проектная мощность ГРС). Приводит строку→число (Decimal→str).
|
// тыс. м³/ч (свободная/проектная мощность ГРС). Приводит строку→число (Decimal→str).
|
||||||
|
//
|
||||||
|
// Честность околонуля: при 1 знаке «0,044» округлился бы в «0,0» и ГРС с
|
||||||
|
// НЕнулевой (но крохотной) свободной мощностью выглядела бы закрытой. Для
|
||||||
|
// 0 < |v| < 0,05 показываем 2 знака («0,04» / «−0,04»); настоящий 0 — «0,0».
|
||||||
export function formatThousandM3Hour(
|
export function formatThousandM3Hour(
|
||||||
v: number | string | null | undefined,
|
v: number | string | null | undefined,
|
||||||
): string | null {
|
): string | null {
|
||||||
const n = toFiniteNumber(v);
|
const n = toFiniteNumber(v);
|
||||||
if (n === null) return null;
|
if (n === null) return null;
|
||||||
|
const digits = n !== 0 && Math.abs(n) < 0.05 ? 2 : 1;
|
||||||
return `${n.toLocaleString("ru-RU", {
|
return `${n.toLocaleString("ru-RU", {
|
||||||
minimumFractionDigits: 1,
|
minimumFractionDigits: digits,
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: digits,
|
||||||
})} тыс. м³/ч`;
|
})} тыс. м³/ч`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import logging
|
||||||
import time
|
import time
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||||
|
|
@ -68,6 +69,22 @@ def _assert_allowed_url(url: str) -> None:
|
||||||
raise HTTPException(status_code=400, detail="host not allowed")
|
raise HTTPException(status_code=400, detail="host not allowed")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_http_error(status_code: int, public_detail: str, log_context: str) -> HTTPException:
|
||||||
|
"""Defense-in-depth: не отдаём текст исключения в HTTP-ответ.
|
||||||
|
|
||||||
|
Текст psycopg/httpx-исключений может содержать DSN-фрагменты, внутренние
|
||||||
|
хосты, пути. Логируем полный exception с коротким error-id через
|
||||||
|
``logger.exception`` (уйдёт в GlitchTip), а в HTTP отдаём generic detail
|
||||||
|
с тем же error-id для корреляции.
|
||||||
|
|
||||||
|
ВАЖНО: вызывать только внутри ``except``-блока — ``logger.exception``
|
||||||
|
полагается на активный exc_info.
|
||||||
|
"""
|
||||||
|
error_id = uuid4().hex[:8]
|
||||||
|
logger.exception("%s (error-id=%s)", log_context, error_id)
|
||||||
|
return HTTPException(status_code=status_code, detail=f"{public_detail} (error-id: {error_id})")
|
||||||
|
|
||||||
|
|
||||||
_ALL_SOURCES = ["avito", "cian", "yandex"]
|
_ALL_SOURCES = ["avito", "cian", "yandex"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -547,14 +564,16 @@ async def scrape_avito_house(
|
||||||
try:
|
try:
|
||||||
enrichment = await fetch_house_catalog(house_url)
|
enrichment = await fetch_house_catalog(house_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("avito-house: fetch failed for %s", house_url)
|
raise _safe_http_error(
|
||||||
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
|
502, "fetch failed", f"avito-house: fetch failed for {house_url}"
|
||||||
|
) from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
counters = save_house_catalog_enrichment(db, enrichment)
|
counters = save_house_catalog_enrichment(db, enrichment)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("avito-house: save failed for %s", house_url)
|
raise _safe_http_error(
|
||||||
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
|
500, "save failed", f"avito-house: save failed for {house_url}"
|
||||||
|
) from e
|
||||||
|
|
||||||
logger.info("avito-house ok: %s → %s", house_url, counters)
|
logger.info("avito-house ok: %s → %s", house_url, counters)
|
||||||
return {"ok": True, "house_url": house_url, "counters": counters}
|
return {"ok": True, "house_url": house_url, "counters": counters}
|
||||||
|
|
@ -576,14 +595,16 @@ async def scrape_avito_detail(
|
||||||
try:
|
try:
|
||||||
enrichment = await fetch_detail(item_url)
|
enrichment = await fetch_detail(item_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("avito-detail: fetch failed for %s", item_url)
|
raise _safe_http_error(
|
||||||
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
|
502, "fetch failed", f"avito-detail: fetch failed for {item_url}"
|
||||||
|
) from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
updated = save_detail_enrichment(db, enrichment)
|
updated = save_detail_enrichment(db, enrichment)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("avito-detail: save failed for %s", item_url)
|
raise _safe_http_error(
|
||||||
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
|
500, "save failed", f"avito-detail: save failed for {item_url}"
|
||||||
|
) from e
|
||||||
|
|
||||||
if not updated:
|
if not updated:
|
||||||
# Listing с этим source_id ещё не существует в БД — был bypass через
|
# Listing с этим source_id ещё не существует в БД — был bypass через
|
||||||
|
|
@ -625,8 +646,9 @@ async def scrape_avito_detail_backfill(
|
||||||
try:
|
try:
|
||||||
result = await run_avito_detail_backfill(db, run_id=run_id, params=params)
|
result = await run_avito_detail_backfill(db, run_id=run_id, params=params)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("avito-detail-backfill: run_id=%d crashed", run_id)
|
raise _safe_http_error(
|
||||||
raise HTTPException(status_code=502, detail=f"backfill failed: {e}") from e
|
502, "backfill failed", f"avito-detail-backfill: run_id={run_id} crashed"
|
||||||
|
) from e
|
||||||
|
|
||||||
logger.info("avito-detail-backfill ok: run_id=%d %s", run_id, result.to_dict())
|
logger.info("avito-detail-backfill ok: run_id=%d %s", run_id, result.to_dict())
|
||||||
return {"ok": True, "run_id": run_id, "counters": result.to_dict()}
|
return {"ok": True, "run_id": run_id, "counters": result.to_dict()}
|
||||||
|
|
@ -666,17 +688,22 @@ async def scrape_avito_imv(
|
||||||
has_loggia=has_loggia,
|
has_loggia=has_loggia,
|
||||||
)
|
)
|
||||||
except IMVAddressNotFoundError as e:
|
except IMVAddressNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=f"IMV address not found: {e}") from e
|
# Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning
|
||||||
|
# без traceback, чтобы не шуметь exception-событиями в GlitchTip. Адрес — в лог,
|
||||||
|
# не в HTTP-ответ.
|
||||||
|
error_id = uuid4().hex[:8]
|
||||||
|
logger.warning("avito-imv: address not found for %s (error-id=%s)", address, error_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"IMV address not found (error-id: {error_id})"
|
||||||
|
) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("avito-imv: fetch failed for %s", address)
|
raise _safe_http_error(502, "fetch failed", f"avito-imv: fetch failed for {address}") from e
|
||||||
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
eval_id = save_imv_evaluation(db, result)
|
eval_id = save_imv_evaluation(db, result)
|
||||||
history_saved = save_imv_placement_history(db, eval_id, result.placement_history)
|
history_saved = save_imv_placement_history(db, eval_id, result.placement_history)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("avito-imv: save failed for %s", address)
|
raise _safe_http_error(500, "save failed", f"avito-imv: save failed for {address}") from e
|
||||||
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d",
|
"avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d",
|
||||||
|
|
|
||||||
|
|
@ -518,11 +518,19 @@ async def upload_photo(
|
||||||
status_code=409, detail=f"photo limit reached ({_MAX_PHOTOS_PER_ESTIMATE})"
|
status_code=409, detail=f"photo limit reached ({_MAX_PHOTOS_PER_ESTIMATE})"
|
||||||
)
|
)
|
||||||
|
|
||||||
content = await file.read()
|
# #2233: читаем тело чанками с жёстким капом, а не await file.read() целиком —
|
||||||
|
# иначе multi-GB аплоад буферизуется в RAM и OOM-killed backend (mem_limit 768m, #2214).
|
||||||
|
# 413 бросается СРАЗУ при превышении, остаток тела запроса НЕ читается.
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
while chunk := await file.read(64 * 1024):
|
||||||
|
total += len(chunk)
|
||||||
|
if total > _MAX_PHOTO_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="file too large (max 10 MB)")
|
||||||
|
chunks.append(chunk)
|
||||||
|
content = b"".join(chunks)
|
||||||
if not content:
|
if not content:
|
||||||
raise HTTPException(status_code=400, detail="empty file")
|
raise HTTPException(status_code=400, detail="empty file")
|
||||||
if len(content) > _MAX_PHOTO_BYTES:
|
|
||||||
raise HTTPException(status_code=413, detail="file too large (max 10 MB)")
|
|
||||||
|
|
||||||
# Sanitize: re-encode through Pillow to drop EXIF, kill polyglot payloads,
|
# Sanitize: re-encode through Pillow to drop EXIF, kill polyglot payloads,
|
||||||
# cap dimensions. Closes finding #6 from 2026-05-24 audit.
|
# cap dimensions. Closes finding #6 from 2026-05-24 audit.
|
||||||
|
|
|
||||||
|
|
@ -491,6 +491,69 @@ def test_upload_photo_owner_can_upload(trade_in_app: FastAPI, monkeypatch) -> No
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class _InfiniteUploadFile:
|
||||||
|
"""UploadFile stub whose async read(size) yields endless 64KB chunks (#2233).
|
||||||
|
|
||||||
|
Simulates a multi-GB stream. Counts read() calls so the test can assert the
|
||||||
|
handler stops reading right after the cap is exceeded instead of draining the
|
||||||
|
whole body into RAM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
content_type = "image/png"
|
||||||
|
filename = "huge.png"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.read_calls = 0
|
||||||
|
|
||||||
|
async def read(self, size: int = -1) -> bytes:
|
||||||
|
self.read_calls += 1
|
||||||
|
return b"\x00" * (64 * 1024)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upload_photo_streams_over_cap_returns_413_without_full_read() -> None:
|
||||||
|
"""#2233: a stream larger than 10 MB → 413 raised early, body NOT fully read.
|
||||||
|
|
||||||
|
Acceptance: read() is called only ~(10MB/64KB)+1 times (161), proving the loop
|
||||||
|
bails on the first chunk that pushes total past _MAX_PHOTO_BYTES rather than
|
||||||
|
buffering an unbounded upload (which would OOM the 768m-capped container, #2214).
|
||||||
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
import app.core.auth as auth_mod
|
||||||
|
from app.api.v1.trade_in import _MAX_PHOTO_BYTES, upload_photo
|
||||||
|
|
||||||
|
auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment]
|
||||||
|
|
||||||
|
# guard SELECT → owner; count SELECT → 0. INSERT must never be reached.
|
||||||
|
db = MagicMock()
|
||||||
|
guard_result = MagicMock()
|
||||||
|
guard_result.fetchone.return_value = SimpleNamespace(created_by="kopylov")
|
||||||
|
count_result = MagicMock()
|
||||||
|
count_result.scalar_one.return_value = 0
|
||||||
|
db.execute.side_effect = [guard_result, count_result]
|
||||||
|
|
||||||
|
upload = _InfiniteUploadFile()
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await upload_photo(
|
||||||
|
estimate_id=UUID(_ESTIMATE_ID),
|
||||||
|
db=db,
|
||||||
|
file=upload, # type: ignore[arg-type]
|
||||||
|
x_authenticated_user="kopylov",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 413
|
||||||
|
expected_reads = _MAX_PHOTO_BYTES // (64 * 1024) + 1 # 161
|
||||||
|
assert upload.read_calls == expected_reads
|
||||||
|
# sanity: bounded, nowhere near infinite
|
||||||
|
assert upload.read_calls < 200
|
||||||
|
# INSERT (3rd execute) never fired — nothing persisted
|
||||||
|
assert db.execute.call_count == 2
|
||||||
|
db.commit.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
# ── Derived analytics routes (representative: /houses, /imv-benchmark) ────────
|
# ── Derived analytics routes (representative: /houses, /imv-benchmark) ────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
@ -564,3 +564,31 @@ def test_data_quality_pct_in_range(client: TestClient) -> None:
|
||||||
assert (
|
assert (
|
||||||
0.0 <= pct <= 100.0
|
0.0 <= pct <= 100.0
|
||||||
), f"source={src['source']} field={field_name} pct={pct} вне [0,100]"
|
), f"source={src['source']} field={field_name} pct={pct} вне [0,100]"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Security: текст исключения не утекает в HTTP-ответ (#2234) ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_error_detail_hides_exception_text(client: TestClient) -> None:
|
||||||
|
"""Внутренняя зависимость кидает исключение с 'секретным' текстом →
|
||||||
|
|
||||||
|
в теле ответа НЕТ секрета, есть generic detail + error-id; статус-код прежний.
|
||||||
|
Роут /scrape/avito-detail: fetch_detail падает с DSN-подобным сообщением.
|
||||||
|
"""
|
||||||
|
secret = "postgres://user:SECRETPASS@internal-host:5432/db"
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.admin.fetch_detail",
|
||||||
|
new=AsyncMock(side_effect=Exception(secret)),
|
||||||
|
):
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/admin/scrape/avito-detail",
|
||||||
|
params={"item_url": "/ekaterinburg/kvartiry/1-k_kvartira_100"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 502
|
||||||
|
body = r.json()
|
||||||
|
detail = body["detail"]
|
||||||
|
assert "SECRETPASS" not in detail
|
||||||
|
assert secret not in detail
|
||||||
|
assert "error-id:" in detail
|
||||||
|
assert detail.startswith("fetch failed")
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,13 @@
|
||||||
handle_path /trade-in/api/* {
|
handle_path /trade-in/api/* {
|
||||||
# handle_path вырезает префикс /trade-in перед прокси →
|
# handle_path вырезает префикс /trade-in перед прокси →
|
||||||
# FastAPI получает /api/v1/...
|
# FastAPI получает /api/v1/...
|
||||||
|
#
|
||||||
|
# #2233: первый рубеж против OOM на фото-аплоаде — режем тело >12MB на
|
||||||
|
# уровне Caddy (backend капит на 10MB чанково, см. upload_photo). Scoped
|
||||||
|
# ТОЛЬКО на /trade-in/api/* — не трогает фронт/остальной сайт.
|
||||||
|
request_body {
|
||||||
|
max_size 12MB
|
||||||
|
}
|
||||||
reverse_proxy tradein-backend:8000
|
reverse_proxy tradein-backend:8000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue