From ab3cb4fcb3882c237bb1e54e49794887b3b61325 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 12:05:50 +0500 Subject: [PATCH 001/121] =?UTF-8?q?auth:=20=D1=82=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B9=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82?= =?UTF-8?q?=20buyer1=20(pilot)=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D1=86=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BF=D0=BE=D0=BA=D1=83=D0=BF=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По просьбе владельца 02.09.2026. YAML-роль pilot (доступ только /trade-in/**), DB-роль manager — по образцу praktika/kopylov: самостоятельный внешний аккаунт. Квота 50 оценок/мес выдаётся через account_quota_overrides.monthly_limit (дефолт 15), НЕ unlimited. Строка в roles.yaml обязательна не только для RouteGuard: без неё session-юзер получает 403 на чтение СВОЕЙ оценки (get_role → KeyError, см. #3316). --- auth/roles.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/auth/roles.yaml b/auth/roles.yaml index 1273eb38..d2f1b028 100644 --- a/auth/roles.yaml +++ b/auth/roles.yaml @@ -133,6 +133,11 @@ users: # продукта; ранее expired с 2026-06-27). Безлимитная квота оценок # выдана через account_quota_overrides.unlimited (migration 191), # не через код — см. app.services.account_quota.is_unlimited. + buyer1: pilot # Тестовый доступ потенциального покупателя — заведён 2026-09-02 по + # просьбе владельца. Квота 50 оценок/мес через + # account_quota_overrides.monthly_limit (не unlimited). DB-роль + # manager (как praktika/kopylov — самостоятельный внешний аккаунт, + # не employee под чьим-то manager_id). admintest: admin # temp QA 2026-05-26 pilottest: pilot # temp QA 2026-05-26 analysttest: analyst # temp QA 2026-06-07 (#962) From 012d98205fb0f10b670a805d77766b6d263303b9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 12:25:14 +0500 Subject: [PATCH 002/121] =?UTF-8?q?v2:=20=D0=B1=D0=B5=D0=B9=D0=B4=D0=B6=20?= =?UTF-8?q?=C2=AB=D0=9C=D0=BE=D0=B8=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D1=8B?= =?UTF-8?q?=C2=BB=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BA=D0=B2=D0=BE=D1=82=D1=83=20=C2=AB=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20?= =?UTF-8?q?/=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BD=D0=BE=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец выдал покупателю 50 оценок и не смог найти, где виден остаток. Ответ был: нигде. Бэкенд отдаёт limit/used/remaining/unlimited целиком, а фронт рендерил голое used с фолбэком на длину истории — «0» у свежего аккаунта читался как «отчётов нет», а был месячным счётчиком (#3320 п.4: одна цифра с двумя смыслами). Теперь: «3 / 50» + title с расшифровкой. Фолбэк на history.length убран — lifetime-число с потолком 50 строк смыслово другое; нет квоты (unlimited или не загрузилась) — нет бейджа: лучше ничего, чем не то. Тест по значению, с открытием меню; фальсификация: возврат голого used красит «Unable to find 3 / 50». 204 теста (v2 + mera-public) зелёные. --- tradein-mvp/frontend/src/app/v2/page.tsx | 11 ++++- .../src/components/trade-in/v2/TopNav.tsx | 42 +++++++++++-------- .../v2/__tests__/TopNav.quota.test.tsx | 42 +++++++++++++++++++ 3 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/__tests__/TopNav.quota.test.tsx diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx index 043c7a83..1bc3617e 100644 --- a/tradein-mvp/frontend/src/app/v2/page.tsx +++ b/tradein-mvp/frontend/src/app/v2/page.tsx @@ -677,7 +677,14 @@ export default function TradeInV2Page() { // TopNav «Мои отчёты» badge: per-user estimate count. quota.used is the // authoritative monthly counter; fall back to the loaded history length, then // to undefined (TopNav renders 0) for pre-load states. - const reportsCount = quota.data?.used ?? history.data?.length; + // Счётчик у «Мои отчёты» — МЕСЯЧНАЯ квота, и вместе с use идёт limit: + // «использовано / доступно». Прежний фолбэк на history.length убран — + // это lifetime-число с потолком 50 строк, у него другой смысл, и одна + // цифра с двумя смыслами уже попала в аудит (#3320 п.4). Нет квоты — + // нет цифры: честнее спрятать, чем показать не то. + const quotaBadge = quota.data && !quota.data.unlimited + ? { used: quota.data.used, limit: quota.data.limit } + : undefined; // Real user for the TopNav menu. undefined until /me resolves → TopNav shows // its neutral «Гость» fallback (never the old "Андрей Петров / Брусника"). @@ -1045,7 +1052,7 @@ export default function TradeInV2Page() { logoutMutation.mutate()} showTeamLink={showTeamNavItem} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx index 70674679..9716c025 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/TopNav.tsx @@ -20,8 +20,8 @@ import { navLabels } from "./ui-config"; import { useSupportChat } from "./SupportChatContext"; // Real logged-in user identity, derived by the page from useMe() -// ({username, role, brand}). Deliberately excludes `reports` — the «Мои -// отчёты» badge count is a separate prop fed from useQuota().used. +// ({username, role, brand}). Deliberately excludes the quota — the «Мои +// отчёты» badge is a separate prop fed from useQuota(). interface TopNavUser { name: string; org: string; @@ -32,9 +32,12 @@ interface TopNavUser { interface TopNavProps { active: number; onNavigate: (i: number) => void; - // «Мои отчёты» badge count — page feeds it from useQuota().used (per-user - // estimate count). Defaults to 0 for unwired usage. - reports?: number; + // Бейдж «Мои отчёты»: месячная квота «использовано / доступно» из + // useQuota(). undefined → бейджа нет (unlimited-аккаунт или квота не + // загрузилась). Раньше тут было голое число used с фолбэком на длину + // истории — одна цифра с двумя смыслами (#3320 п.4): «0» читался как + // «отчётов нет», а был месячным счётчиком, и остаток не показывался нигде. + quotaBadge?: { used: number; limit: number }; // Real logged-in user. undefined → neutral «Гость» placeholder, NEVER the // old design fixture ("Андрей Петров / Брусника"). user?: TopNavUser; @@ -114,7 +117,7 @@ const menuItemDisabledStyle: CSSProperties = { export default function TopNav({ active, onNavigate, - reports = 0, + quotaBadge, user, onLogout, showTeamLink = false, @@ -460,18 +463,21 @@ export default function TopNav({ /> Мои отчёты{" "} - - {reports} - + {quotaBadge && ( + + {quotaBadge.used} / {quotaBadge.limit} + + )}
({ + useSupportChat: () => ({ openChat: vi.fn() }), +})); + +import TopNav from "../TopNav"; + +const USER = { name: "Тест", org: "Орг", email: "t@t", initials: "ТТ" }; + +describe("TopNav: бейдж квоты", () => { + it("показывает использовано / доступно, а не голое число", () => { + render( + {}} user={USER} + quotaBadge={{ used: 3, limit: 50 }} />, + ); + // Меню выпадающее — бейдж существует только после открытия. + fireEvent.click(screen.getByText("ТТ")); + expect( + screen.getByText((_, el) => el?.textContent === "3 / 50"), + ).toBeTruthy(); + }); + + it("без квоты (unlimited или не загрузилась) бейджа нет — лучше ничего, чем не то", () => { + render( {}} user={USER} />); + fireEvent.click(screen.getByText("ТТ")); + expect( + screen.queryByText((_, el) => /^\d+ \/ \d+$/.test(el?.textContent ?? "")), + ).toBeNull(); + // сам пункт меню на месте + expect(screen.getByText(/Мои отчёты/)).toBeTruthy(); + }); +}); From 7b61ecb122d3080a0108371333048afe44086a10 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:40:36 +0500 Subject: [PATCH 003/121] =?UTF-8?q?fix(mera-public):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=B6=20=D0=B8=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B6=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BD=D0=B0=20/estimate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка лэндинга требовала этаж и состояние обязательными и клала их в черновик. Целевая страница на монтировании звала takeDraft, а та стирала черновик целиком, прочитав только адрес, комнаты, площадь и город: оба обязательных поля уничтожались непрочитанными. Человек заполнял два поля, единственным эффектом которых было их же удаление. takeDraft по-прежнему забирает черновик «на вынос» (адрес не должен подставляться на следующей неделе в той же вкладке), но перекладывает непрочитанные floor/condition в отдельный ключ вместе с адресом, к которому они относятся, — чтобы платный шаг (#2896) не приклеил этаж одной квартиры к другой. Потребителя у полей пока нет: платного шага нет. Closes #3321 --- .../estimate-draft-survives-estimate.test.tsx | 72 ++++++++++++++++++ .../_components/v3/FreeCheckCard.tsx | 1 - .../src/app/mera-public/estimate-draft.ts | 73 ++++++++++++++++++- 3 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx new file mode 100644 index 00000000..0539e722 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx @@ -0,0 +1,72 @@ +/** + * Этаж и состояние переживают ПЕРЕХОД, а не только запись в хранилище. + * + * Дефект #3321 был ровно в стыке: карточка требовала оба поля обязательными и + * честно клала их в черновик, а `/estimate` на монтировании звала `takeDraft`, + * которая стирала черновик ЦЕЛИКОМ, прочитав из него адрес, комнаты, площадь и + * город. Оба обязательных поля уничтожались непрочитанными — человек заполнял + * то, что гарантированно выбрасывалось. + * + * Поэтому здесь обе стороны перехода настоящие: форма заполняется как человеком + * и отправляется, затем монтируется целевая страница. Тест на одном хранилище + * остался бы зелёным при возврате дефекта — стирает-то целевая страница. + */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { EstimateFlow } from "../_components/estimate/EstimateFlow"; +import { FreeCheckCard } from "../_components/v3/FreeCheckCard"; +import { readDraftExtras } from "../estimate-draft"; + +beforeEach(() => { + window.sessionStorage.clear(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, assign: vi.fn() }, + }); +}); + +function fill(labelPart: string, value: string) { + const field = screen.getByLabelText(new RegExp(labelPart, "i")); + fireEvent.change(field, { target: { value } }); +} + +/** Заполняет и отправляет карточку лэндинга; возвращает выбранное состояние. */ +function submitLandingCard(): string { + const { unmount } = render(); + + fill("город", "Екатеринбург"); + fill("адрес", "Екатеринбург, Ленина 5"); + fill("М²", "54"); + fill("комнат", "2"); + fill("этаж", "7/16"); + + // Состояние — группа кнопок с aria-pressed. Берём подпись у самой кнопки, а + // не из своих догадок о наборе вариантов. + const conditions = screen.getByRole("group", { name: /состояние/i }); + const chosen = conditions.querySelectorAll("button")[0]; + const label = chosen.textContent ?? ""; + fireEvent.click(chosen); + + fireEvent.click(screen.getByRole("button", { name: /мо[её]му дому/i })); + unmount(); + return label; +} + +describe("Черновик: обязательные поля переживают переход на /estimate", () => { + it("этаж и состояние доступны после монтирования целевой страницы", () => { + const condition = submitLandingCard(); + expect(condition, "в группе «Состояние» не нашлось кнопок").not.toBe(""); + + // Тот самый шаг, который раньше уничтожал оба поля. + render(); + + const extras = readDraftExtras(); + expect(extras, "после /estimate от черновика не осталось ничего").not.toBeNull(); + expect(extras?.floor).toBe("7/16"); + expect(extras?.condition).toBe(condition); + // Хвост без адреса нельзя предъявлять платному шагу: он не отличит этаж + // этой квартиры от этажа предыдущей в той же вкладке. + expect(extras?.address).toBe("Екатеринбург, Ленина 5"); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx index 3afc400c..11d5a423 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx @@ -201,7 +201,6 @@ export function FreeCheckCard() { // Уходим на отдельный экран проверки — там живёт автокомплит и результат. // Черновик кладём в sessionStorage, а НЕ в query-параметры: адрес в URL // попал бы в access-логи Caddy рядом с IP посетителя (см. estimate-draft.ts). - // Этаж не передаём — его не использует ни проба покрытия, ни экран // Всё, что человек ввёл обязательным полем, доезжает до следующего шага. // Раньше этаж здесь терялся под доводом «повторно набрать дешевле, чем // хранить лишнее» — довод был неверен: поля этажа на целевой странице нет, diff --git a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts index b24e8567..d36314ef 100644 --- a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts +++ b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts @@ -19,6 +19,13 @@ */ const KEY = "mera:estimate-draft"; +/** + * Поля, которые целевая страница НЕ читает, но которые собраны обязательными и + * ждут платного шага (#2896). Отдельный ключ, потому что черновик забирается + * «на вынос»: без переноса `takeDraft` стирал бы вместе с адресом ровно то, что + * обещал донести (#3321). + */ +const EXTRAS_KEY = "mera:estimate-draft-extras"; export interface EstimateDraft { address: string; @@ -45,6 +52,20 @@ export interface EstimateDraft { floor?: string; } +/** + * Хвост черновика: то, что собрали на лэндинге, но на `/estimate` не читают. + * + * Адрес лежит здесь ВМЕСТЕ с полями, а не подразумевается: иначе этаж «7/16» + * от квартиры на Ленина переживёт свой черновик и на платном шаге приклеится к + * другому дому в той же вкладке. Потребитель обязан сверить адрес, а не верить + * тому, что нашёл. + */ +export interface EstimateDraftExtras { + address: string; + condition?: string; + floor?: string; +} + /** * Все обращения обёрнуты: в приватном режиме Safari и при выключенном * хранилище `sessionStorage` бросает на записи, а на чтении может вернуть @@ -73,7 +94,7 @@ export function takeDraft(): EstimateDraft | null { const value = parsed as Record; if (typeof value.address !== "string") return null; - return { + const draft: EstimateDraft = { address: value.address, rooms: typeof value.rooms === "string" ? value.rooms : undefined, area: typeof value.area === "string" ? value.area : undefined, @@ -82,6 +103,56 @@ export function takeDraft(): EstimateDraft | null { typeof value.condition === "string" ? value.condition : undefined, floor: typeof value.floor === "string" ? value.floor : undefined, }; + // Состояние и этаж форма `/estimate` не спрашивает и не показывает: удали + // их вместе с черновиком — и человек заполнил два обязательных поля, чей + // единственный эффект был потрачен на удаление. Перекладываем туда, где их + // заберёт платный шаг; черновик как таковой по-прежнему уходит «на вынос». + saveDraftExtras(draft); + return draft; + } catch { + return null; + } +} + +function saveDraftExtras(draft: EstimateDraft): void { + try { + // Пусто — не оставляем прошлый хвост лежать: он относится к прошлому + // адресу, а не к этому. + if (!draft.condition && !draft.floor) { + window.sessionStorage.removeItem(EXTRAS_KEY); + return; + } + const extras: EstimateDraftExtras = { + address: draft.address, + condition: draft.condition, + floor: draft.floor, + }; + window.sessionStorage.setItem(EXTRAS_KEY, JSON.stringify(extras)); + } catch { + // Не сохранился — платный шаг переспросит. + } +} + +/** + * Читает, но НЕ забирает: платный шаг может открыться не с первого раза, а + * второе чтение того же хвоста ничего не портит. Вкладка закроется — исчезнет + * само. + */ +export function readDraftExtras(): EstimateDraftExtras | null { + try { + const raw = window.sessionStorage.getItem(EXTRAS_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return null; + const value = parsed as Record; + if (typeof value.address !== "string") return null; + + return { + address: value.address, + condition: + typeof value.condition === "string" ? value.condition : undefined, + floor: typeof value.floor === "string" ? value.floor : undefined, + }; } catch { return null; } From 08cabe8d98f22cc5fe10ea5fbebf31baeab9c330 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:42:49 +0500 Subject: [PATCH 004/121] =?UTF-8?q?fix(tradein-v2):=20=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=8F=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20=D0=B1=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D1=80=D0=B8=D1=81=D1=83?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F=20=D0=BD=D1=83=D0=BB=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд по контракту не умеет отдать null в street-deals: «сделок на улице нет» приезжает как count=0 с нулевыми ценами. Витрина сохраняла этот ноль через ?? и показывала его как данные — «0,00 млн ₽ · −100% к цене объявления» зелёным и диапазон «0,00 – 0,00». - usableStreetDeals() схлопывает пустую оболочку в null на границе мапперов (mapSources / mapSummary / mapHistory / mapResultPanel) — дальше null уже везде означает «нет данных», отдельного состояния заводить не нужно; - футер таблицы сделок брал N и M из РАЗНЫХ выборок (строки — actual_deals оценки, знаменатель — коридор по улице), выходило «Показано 10 из 3»; тотала у той же выборки в ответе нет, поэтому знаменатель убран; - в deals-only ветке (n_analogs=0) полоса диапазона строилась из ДКП-цен, но была подписана «В ОБЪЯВЛЕНИЯХ» — подпись согласована с карточкой 1. Closes #3320 --- .../components/trade-in/v2/SourcesView.tsx | 8 +- .../v2/__tests__/honestZeros.test.tsx | 149 ++++++++++++++++++ .../src/components/trade-in/v2/mappers.ts | 58 +++++-- 3 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/__tests__/honestZeros.test.tsx diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx index 8ee85362..3d151798 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx @@ -664,8 +664,12 @@ export default function SourcesView({ background: KPI_BG, }} > - Показано {data.dealRows.length} из {data.marketDeals.kpi.count}{" "} - фактических сделок + {/* #3320 — «из M» здесь брало M из ДРУГОЙ выборки (KPI выше — + корридор ДКП по улице, а строки таблицы — actual_deals оценки + по дому/району): выходило «Показано 10 из 3». Тотала у той же + выборки в ответе нет, поэтому знаменатель убран, а не починен + — разводить подпись на две выборки читается ещё хуже. */} + Показано {data.dealRows.length} фактических сделок
) : ( diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/honestZeros.test.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/honestZeros.test.tsx new file mode 100644 index 00000000..8832d700 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/honestZeros.test.tsx @@ -0,0 +1,149 @@ +// #3320 — числа посчитаны честно, показаны нечестно. +// +// Бэкенд по схеме НЕ УМЕЕТ отдать null в street-deals: «сделок на улице нет» +// приезжает как count=0 и нулевые цены (trade_in.py:2258, менять там нечего — +// это контракт). Витрина такой ноль сохраняла через `??` и рисовала его как +// данные: «05 РЫНОК» показывал медиану сделок «0,00 млн ₽ · −100% к цене +// объявления» зелёным и диапазон «0,00 – 0,00». +// +// Тут держатся три места, где посчитанное честно показывалось нечестно: +// пустая выборка по улице, футер таблицы сделок (N и M брались из РАЗНЫХ +// выборок) и полоса диапазона в deals-only ветке (подписана «В ОБЪЯВЛЕНИЯХ» +// при нуле объявлений). + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import SourcesView from "../SourcesView"; +import { mapResultPanel, mapSources } from "../mappers"; +import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture"; +import type { + AggregatedEstimate, + AnalogLot, + StreetDealsResponse, +} from "@/types/trade-in"; + +/** Ответ street-deals «сделок нет» ровно в том виде, в каком его шлёт бэкенд. */ +const EMPTY_STREET_DEALS: StreetDealsResponse = { + street: "Космонавтов", + period_from: "2025-09-02", + period_to: "2026-09-02", + count: 0, + median_price_rub: 0, + median_price_per_m2: 0, + range_low_rub: 0, + range_high_rub: 0, + deals: [], +}; + +function deal(over: Partial): AnalogLot { + return { + address: "ул. Космонавтов, 12", + area_m2: 55, + rooms: 2, + floor: 5, + total_floors: 9, + price_rub: 8_000_000, + price_per_m2: 145_454, + listing_date: "2026-02-14", + days_on_market: null, + photo_url: null, + source: "rosreestr", + source_url: null, + distance_m: null, + tier: "T1_per_street", + date_precision: "quarter", + lat: null, + lon: null, + ...over, + }; +} + +function estimateWith(deals: AnalogLot[]): AggregatedEstimate { + return { ...FIXTURE_ESTIMATE, actual_deals: deals }; +} + +describe("05 РЫНОК — пустая выборка сделок не рисуется данными", () => { + it("count=0 с нулевыми ценами → KPI сделок в состоянии «нет данных»", () => { + const data = mapSources(estimateWith([]), EMPTY_STREET_DEALS); + + expect(data.marketDeals.kpi).toEqual({ + count: "—", + median: "—", + delta: "—", + range: "—", + }); + + const { container } = render(); + + // Тот же пустой блок, что и при отсутствующем ответе street-deals. + expect( + screen.getByText(/Сделок по этому адресу за 12 месяцев не найдено/), + ).toBeTruthy(); + // Ни «−100% к цене объявления», ни «0,00 – 0,00» на экране быть не может. + expect(container.textContent).not.toContain("−100"); + expect(container.textContent).not.toMatch(/(? { + // Схлопывание пустой оболочки не должно гасить реальную выборку: маппер + // обязан упасть на actual_deals, чьи строки видно в таблице ниже KPI. + const data = mapSources( + estimateWith([deal({ price_rub: 8_000_000 })]), + EMPTY_STREET_DEALS, + ); + + expect(data.marketDeals.kpi.count).toBe("1"); + expect(data.marketDeals.kpi.median).toBe("8,00"); + }); +}); + +describe("05 РЫНОК — футер таблицы сделок считает одну выборку", () => { + it("N и M не смешивают street-deals и actual_deals", () => { + // Расходящиеся числа: в таблице 3 сделки оценки, коридор по улице знает 1 — + // прежний футер писал «Показано 3 из 1 фактических сделок». + const streetDeals: StreetDealsResponse = { + ...EMPTY_STREET_DEALS, + count: 1, + median_price_rub: 12_000_000, + median_price_per_m2: 200_000, + range_low_rub: 12_000_000, + range_high_rub: 12_000_000, + deals: [deal({ price_rub: 12_000_000 })], + }; + const estimate = estimateWith([ + deal({ address: "ул. Космонавтов, 12" }), + deal({ address: "ул. Космонавтов, 14" }), + deal({ address: "ул. Космонавтов, 16" }), + ]); + + render(); + + expect(screen.getByText("Показано 3 фактических сделок")).toBeTruthy(); + expect(screen.queryByText(/Показано \d+ из \d+ фактических/)).toBeNull(); + }); +}); + +describe("02 РЕЗУЛЬТАТ — полоса диапазона называет свою выборку", () => { + it("deals-only (n_analogs=0) → полоса подписана сделками, как и карточка 1", () => { + const dealsOnly: AggregatedEstimate = { + ...FIXTURE_ESTIMATE, + analogs: [], + n_analogs: 0, + insufficient_data: false, + }; + + const { cards, ranges } = mapResultPanel(dealsOnly); + + expect(ranges.ads.label.join(" ")).not.toContain("ОБЪЯВЛЕНИЯХ"); + expect(ranges.ads.label.join(" ")).toContain("ПО СДЕЛКАМ РОСРЕЕСТРА"); + // Формулировка та же, что #2629 дал карточке над этой полосой. + expect(cards[0].title.join(" ")).toContain("ПО СДЕЛКАМ РОСРЕЕСТРА"); + }); + + it("объявления есть → полоса остаётся про объявления", () => { + expect(mapResultPanel(FIXTURE_ESTIMATE).ranges.ads.label.join(" ")).toContain( + "ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", + ); + }); +}); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 72e45266..eca517da 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -613,6 +613,28 @@ function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] return [best?.listing_date ?? null, best?.date_precision ?? null]; } +/** + * #3320 — «нет сделок на улице» приезжает с бэкенда НУЛЯМИ, а не null: схема + * StreetDealsResponse не допускает null в count/median/range (trade_in.py:2258 + * отдаёт count=0 и нулевые цены). Дальше по витрине `??` такой ноль СОХРАНЯЕТ — + * и пустая выборка рисуется как данные: «0,00 млн ₽ · −100 % к цене + * объявления» зелёным и «0,00 – 0,00» в диапазоне. + * + * Схлопываем пустую оболочку в null на границе мапперов — ниже null уже везде + * означает «нет данных» (прочерк / EmptyTableNote), отдельного состояния + * заводить не нужно. + */ +function usableStreetDeals( + sd: StreetDealsResponse | null | undefined, +): StreetDealsResponse | null { + if (sd == null || sd.count <= 0) return null; + // count > 0 при нулевой медиане — тоже оболочка без цен (медиана считается по + // тем же строкам, что и count: ноль тут значит «денег в выборке нет»). + return Number.isFinite(sd.median_price_rub) && sd.median_price_rub > 0 + ? sd + : null; +} + /** * Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with * real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's @@ -1083,9 +1105,11 @@ export function marketPositionPill( export function mapResultPanel( e: AggregatedEstimate, - streetDeals?: StreetDealsResponse | null, + streetDealsRaw?: StreetDealsResponse | null, analytics?: HouseAnalyticsResponse | null, ): ResultPanelData { + // #3320 — пустая (нулевая) выборка по улице = «нет данных», не нули. + const streetDeals = usableStreetDeals(streetDealsRaw); const dealTier = resolveDealTier(e, streetDeals); // H1 — the «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» flagship card must NOT render as a hole of @@ -1240,7 +1264,14 @@ export function mapResultPanel( }; const adsBar: RangeBar = { - label: ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"], + // #3320 — полоса строится из median/range самой оценки, а в deals-only + // ветке (n_analogs === 0) эти числа посчитаны по ДКП, не по объявлениям: + // подпись «В ОБЪЯВЛЕНИЯХ» обещала бы выборку, которой нет. Формулировка — + // та же, что #2629 дал карточке 1 над этой полосой («РЕКОМЕНДОВАННАЯ ЦЕНА + // ПО СДЕЛКАМ РОСРЕЕСТРА»), чтобы подпись и число читались одной парой. + label: dealsOnlyPrice + ? ["РЕКОМЕНДОВАННЫЙ ДИАПАЗОН", "ПО СДЕЛКАМ РОСРЕЕСТРА (ДКП)"] + : ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"], median: `${fmtMln(e.median_price_rub)} млн ₽`, lo: `${fmtMln(e.range_low_rub)} млн`, hi: `${fmtMln(e.range_high_rub)} млн`, @@ -1270,8 +1301,10 @@ export function mapResultPanel( export function mapSummary( e: AggregatedEstimate, analytics?: HouseAnalyticsResponse | null, - streetDeals?: StreetDealsResponse | null, + streetDealsRaw?: StreetDealsResponse | null, ): Summary { + // #3320 — пустая (нулевая) выборка по улице = «нет данных», не нули. + const streetDeals = usableStreetDeals(streetDealsRaw); const kpi = analytics?.kpi; const houseSold = kpi?.sold_count ?? null; @@ -1600,16 +1633,16 @@ export function mapHistory( .filter(Boolean) .join(" "); + // #3320 — числа только из НЕПУСТОЙ выборки (нулевая оболочка → прочерки). + // Название улицы выше берётся из сырого ответа: оно известно и без сделок. + const sd = usableStreetDeals(streetDeals); const dkpKpi = { - count: streetDeals?.count != null ? String(streetDeals.count) : "—", + count: sd != null ? String(sd.count) : "—", median: - streetDeals != null && Number.isFinite(streetDeals.median_price_per_m2) - ? numRu(streetDeals.median_price_per_m2) - : "—", - range: - streetDeals != null - ? mlnRangeSp(streetDeals.range_low_rub, streetDeals.range_high_rub) + sd != null && Number.isFinite(sd.median_price_per_m2) + ? numRu(sd.median_price_per_m2) : "—", + range: sd != null ? mlnRangeSp(sd.range_low_rub, sd.range_high_rub) : "—", }; const dkpRows: DkpRowData[] = (salesVsListings?.pairs ?? []).map((pr) => { @@ -2132,9 +2165,12 @@ function buildAdFilters(e: AggregatedEstimate | null): string[] { */ export function mapSources( estimate: AggregatedEstimate | null, - streetDeals: StreetDealsResponse | null, + streetDealsRaw: StreetDealsResponse | null, ): SourcesData { const e = estimate; + // #3320 — пустая (нулевая) выборка по улице = «нет данных», не нули: иначе + // KPI сделок читается как «медиана 0,00 млн ₽ · −100 % к цене объявления». + const streetDeals = usableStreetDeals(streetDealsRaw); // Fix #8 — flag rows whose ₽/м² is an outlier vs. the pool (never silently // fold them into the median/spread math). From b35f444d5064ed23749361d1f8d5bbd66cd8a4e8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:43:58 +0500 Subject: [PATCH 005/121] =?UTF-8?q?fix(rosseti):=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20external=5Fid=20?= =?UTF-8?q?=D0=A6=D0=9F=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B8=D0=BE=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?fid=20GeoServer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feature['id'] в WFS-ответе — сессионный fid, новый на каждый GetFeature. ON CONFLICT (source, external_id) не срабатывал ни разу, каждый weekly-прогон дописывал полный комплект ~488 фич: 4880 строк на 481 ЦП. Задуманный sha1-фолбэк по атрибутам был мёртв — fid присутствует всегда. Ключ теперь считается ТОЛЬКО по стабильным атрибутам (нормализованное имя, класс напряжения, координаты в 1e-5 градуса), fid игнорируется. Координата квантуется в целое, а не форматируется как float: ключ обязан совпадать байт-в-байт с бэкфиллом в SQL. sha256 вместо sha1 — встроен в PG16, pgcrypto не нужен. Починка разбора старые строки не убирает (ключи не совпадут, ON CONFLICT ничего не перезапишет) → data/sql/99c_power_supply_centers_dedup.sql: пересчёт ключа существующих строк + схлопывание копий (победитель — свежайший snapshot, NULLS LAST явно), с печатью чисел до/после и идемпотентностью. Refs #3322 --- .../site_finder/rosseti_wfs_loader.py | 49 +++++-- .../tests/test_connection_capacity_loaders.py | 51 +++++++ data/sql/99c_power_supply_centers_dedup.sql | 125 ++++++++++++++++++ 3 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 data/sql/99c_power_supply_centers_dedup.sql diff --git a/backend/app/services/site_finder/rosseti_wfs_loader.py b/backend/app/services/site_finder/rosseti_wfs_loader.py index 5c7d27f4..5f62d6a5 100644 --- a/backend/app/services/site_finder/rosseti_wfs_loader.py +++ b/backend/app/services/site_finder/rosseti_wfs_loader.py @@ -19,6 +19,7 @@ per-row SAVEPOINT при UPSERT (битая фича не валит weekly-sync import hashlib import json import logging +import math import re import httpx @@ -101,20 +102,44 @@ def parse_voltage_class(name: str | None) -> str | None: return m.group(1).replace(".", "/") -def _stable_external_id(feature: dict, props: dict) -> str: - """Стабильный external_id фичи: feature['id'] или хэш ключевых полей. +def _coord_e5(value: float | None) -> str: + """Координата → целое в единицах 1e-5 градуса (~1 м), полукруглением от нуля. - WFS обычно отдаёт стабильный ``feature['id']``; если его нет — детерминированный - sha1 по (sc_name, координаты) чтобы UPSERT оставался идемпотентным. + Целое, а не форматированный float: ключ обязан совпадать байт-в-байт с SQL- + бэкфиллом (99c), а текстовое представление double в питоне и в PG разное. + ``floor(|v|*1e5 + 0.5)`` со знаком = ``round(numeric)`` в PG (half-away-from-zero). """ - fid = feature.get("id") - if fid: - return str(fid) - geom = feature.get("geometry") or {} - coords = geom.get("coordinates") - seed = f"{props.get('sc_name', '')}|{coords}" - # sha1 здесь — стабильный дедуп-id фичи, не криптография. - return "h:" + hashlib.sha1(seed.encode("utf-8")).hexdigest()[:16] + if value is None: + return "" + n = math.floor(abs(value) * 100000 + 0.5) + return str(-n if value < 0 else n) + + +def _stable_external_id(feature: dict, props: dict) -> str: + """Стабильный external_id фичи — хэш атрибутов. ``feature['id']`` ИГНОРИРУЕТСЯ. + + GeoServer отдаёт СЕССИОННЫЙ fid (``sc_points_fullview.fid--``), новый на + каждый GetFeature → ON CONFLICT (source, external_id) не срабатывал ни разу и + таблица росла ×10 (4880 строк на 481 ЦП, #3322). Ключ считаем только по стабильным + атрибутам: нормализованное имя | класс напряжения | координаты в 1e-5 градуса. + + ФОРМУЛА ПРОДУБЛИРОВАНА в ``data/sql/99c_power_supply_centers_dedup.sql`` (бэкфилл + существующих строк) — менять только синхронно. sha256, а не sha1: sha256 встроен + в PG16, sha1 требует pgcrypto. Префикс ``h:`` отличает новый ключ от старого fid. + """ + geom_pair = _point_geom_sql(feature) + coords = geom_pair[1] if geom_pair else {} + sc_name = props.get("sc_name") + seed = "|".join( + ( + normalize_sc_name(sc_name), + parse_voltage_class(sc_name) or "", + _coord_e5(coords.get("lon")), + _coord_e5(coords.get("lat")), + ) + ) + # sha256 здесь — стабильный дедуп-id фичи, не криптография. + return "h:" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16] def _map_load_index(props: dict) -> str | None: diff --git a/backend/tests/test_connection_capacity_loaders.py b/backend/tests/test_connection_capacity_loaders.py index ff550bae..705b5c0d 100644 --- a/backend/tests/test_connection_capacity_loaders.py +++ b/backend/tests/test_connection_capacity_loaders.py @@ -18,6 +18,7 @@ Pure / mock-based — без реальной сети и БД. Покрывае from __future__ import annotations +import hashlib import io from contextlib import contextmanager from typing import Any @@ -96,6 +97,56 @@ def test_map_load_index_unknown_and_missing() -> None: assert rw._map_load_index({"sc_indexload_id": "мусор"}) is None +# ── _stable_external_id (#3322: сессионный fid раздувал таблицу ×10) ─────────── + + +def _wfs_feature(fid: str, name: str, lon: float, lat: float) -> dict[str, Any]: + return { + "id": fid, + "geometry": {"type": "Point", "coordinates": [lon, lat]}, + "properties": {"sc_name": name}, + } + + +def test_stable_external_id_ignores_session_fid() -> None: + """Разные сессионные fid + одинаковые атрибуты → ОДИН ключ (регрессия #3322).""" + a = _wfs_feature("sc_points_fullview.fid--1a2b3c", "ПС 110/10 Уктус", 60.47123, 56.77456) + b = _wfs_feature("sc_points_fullview.fid--9f8e7d", "ПС 110/10 Уктус", 60.47123, 56.77456) + + key_a = rw._stable_external_id(a, a["properties"]) + assert key_a == rw._stable_external_id(b, b["properties"]) + # Проверка ПО ЗНАЧЕНИЮ: ключ = sha256 по «имя|напряжение|lon_e5|lat_e5», + # не fid. Тот же seed повторён в data/sql/99c_power_supply_centers_dedup.sql. + expected = "h:" + hashlib.sha256("уктус|110/10|6047123|5677456".encode()).hexdigest()[:16] + assert key_a == expected == "h:844e54f0152d2799" + + +def test_stable_external_id_differs_on_attributes() -> None: + """Одинаковый fid, разные атрибуты (координата / имя) → РАЗНЫЕ ключи.""" + base = _wfs_feature("sc_points_fullview.fid--same", "ПС 110/10 Уктус", 60.47123, 56.77456) + moved = _wfs_feature("sc_points_fullview.fid--same", "ПС 110/10 Уктус", 60.47124, 56.77456) + renamed = _wfs_feature("sc_points_fullview.fid--same", "ПС 110/10 Северная", 60.47123, 56.77456) + + keys = {rw._stable_external_id(f, f["properties"]) for f in (base, moved, renamed)} + assert len(keys) == 3 + assert rw._stable_external_id(moved, moved["properties"]) == "h:8c5b5fa5f35c651b" + assert rw._stable_external_id(renamed, renamed["properties"]) == "h:297e00f6e0d4f3b8" + + +def test_stable_external_id_no_geometry() -> None: + """Фича без геометрии: координатные компоненты пустые, ключ всё равно стабилен.""" + f: dict[str, Any] = {"id": "fid--x", "properties": {"sc_name": "ПС 110/10 Уктус"}} + assert rw._stable_external_id(f, f["properties"]) == "h:eb91917f35aff23f" + + +def test_coord_e5_half_away_from_zero() -> None: + """Квантование как round(numeric) в PG: полукругление ОТ нуля, симметрично знаку.""" + assert rw._coord_e5(60.123455) == "6012346" + assert rw._coord_e5(-60.123455) == "-6012346" + assert rw._coord_e5(60.6) == "6060000" + assert rw._coord_e5(None) == "" + + # ── sanitize_tp_capacity_mva (кВА-санитайз) ─────────────────────────────────── diff --git a/data/sql/99c_power_supply_centers_dedup.sql b/data/sql/99c_power_supply_centers_dedup.sql new file mode 100644 index 00000000..581fc4ec --- /dev/null +++ b/data/sql/99c_power_supply_centers_dedup.sql @@ -0,0 +1,125 @@ +-- 99c_power_supply_centers_dedup.sql +-- Issue #3322 — power_supply_centers раздут ×10: 4880 строк на 481 уникальный ЦП. +-- +-- Причина. rosseti_wfs_loader брал external_id из feature['id'] WFS-ответа, а +-- GeoServer отдаёт СЕССИОННЫЙ fid (новый на каждый GetFeature) → ON CONFLICT +-- (source, external_id) не срабатывал ни разу, каждый weekly-прогон добавлял +-- полный набор ~488 фич заново. Починка разбора сама старые строки не убирает +-- (ON CONFLICT ничего не перезапишет, ключи не совпадут) → нужен этот бэкфилл. +-- +-- Что делает файл: +-- (а) пересчитывает external_id по НОВОЙ формуле (см. ниже) для всех строк +-- source='rosseti_wfs'; +-- (б) схлопывает копии: победитель группы — свежайший снапшот +-- (fetched_at DESC NULLS LAST, id DESC — DESC в PG это NULLS FIRST, +-- поэтому NULLS LAST задан ЯВНО); +-- (в) печатает числа: строк до / после, удалено, переключено на новый ключ. +-- Ожидание после прогона — ~481-488 строк (столько ЦП отдаёт источник). +-- (г) идемпотентен: на повторном прогоне ключи уже совпадают → 0 удалений, +-- 0 обновлений, «до» = «после». +-- +-- ФОРМУЛА КЛЮЧА (дублирует rosseti_wfs_loader._stable_external_id — менять только +-- синхронно, иначе следующий weekly-прогон вставит второй комплект строк): +-- seed = sc_name_norm || '|' || voltage_class || '|' || lon_e5 || '|' || lat_e5 +-- external_id = 'h:' || left(hex(sha256(utf8(seed))), 16) +-- где lon_e5/lat_e5 — координата в единицах 1e-5 градуса (~1 м), округление +-- round() = half-away-from-zero (в питоне ровно то же: floor(|v|*1e5+0.5) со знаком); +-- пустая строка, если geom отсутствует. Целые, а не форматированный float — +-- текстовое представление double в питоне и в PG различается. +-- sha256, а не sha1: sha256 встроен в PG16, sha1 потребовал бы pgcrypto. +-- +-- Байт-в-байт совпадение с питоном держится на том, что SQL НИЧЕГО не нормализует +-- сам: sc_name_norm и voltage_class — уже готовые колонки, их записал тот же +-- normalize_sc_name / parse_voltage_class. Если normalize_sc_name когда-нибудь +-- изменится, старые sc_name_norm разъедутся с новыми ключами — тогда нужен +-- повторный прогон логики этого файла (он идемпотентен, ре-apply безопасен). +-- +-- Порядок: миграция ПЕРЕД деплоем кода (schema-first) — новый код после неё +-- попадает ON CONFLICT-ом в уже схлопнутые строки. +-- +-- Naming: deploy.yml применяет файлы по `ls -1 data/sql/*.sql | sort`; +-- '99c_' идёт после '99b_grant_quarter_price_index_fdw.sql' ('b' < 'c'). + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +DO $$ +DECLARE + rows_before bigint; + names_before bigint; + rows_after bigint; + names_after bigint; + deleted bigint; + rekeyed bigint; +BEGIN + SELECT count(*), count(DISTINCT sc_name_norm) + INTO rows_before, names_before + FROM power_supply_centers + WHERE source = 'rosseti_wfs'; + + CREATE TEMP TABLE psc_new_key ON COMMIT DROP AS + SELECT + id, + fetched_at, + 'h:' || substring( + encode( + sha256(convert_to( + sc_name_norm + || '|' || coalesce(voltage_class, '') + || '|' || CASE WHEN geom IS NULL THEN '' + ELSE round(ST_X(geom)::numeric * 100000)::text END + || '|' || CASE WHEN geom IS NULL THEN '' + ELSE round(ST_Y(geom)::numeric * 100000)::text END, + 'UTF8' + )), + 'hex' + ) FROM 1 FOR 16 + ) AS new_key + FROM power_supply_centers + WHERE source = 'rosseti_wfs'; + + -- (б) схлопывание: оставляем свежайший снапшот каждой группы. + -- Резервы (reserve_mva и пр.) не теряются: rosseti/eesk-лоадеры пишут их + -- UPDATE-ом по sc_name_norm, т.е. во ВСЕ копии сразу, победитель их несёт. + WITH ranked AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY new_key + ORDER BY fetched_at DESC NULLS LAST, id DESC + ) AS rn + FROM psc_new_key + ) + DELETE FROM power_supply_centers p + USING ranked r + WHERE p.id = r.id + AND r.rn > 1; + GET DIAGNOSTICS deleted = ROW_COUNT; + + -- (а) пересчёт ключа у выживших. После DELETE каждый new_key принадлежит + -- ровно одной строке → UNIQUE (source, external_id) не нарушается. + -- IS DISTINCT FROM даёт идемпотентность: второй прогон обновит 0 строк. + UPDATE power_supply_centers p + SET external_id = k.new_key + FROM psc_new_key k + WHERE p.id = k.id + AND p.external_id IS DISTINCT FROM k.new_key; + GET DIAGNOSTICS rekeyed = ROW_COUNT; + + SELECT count(*), count(DISTINCT sc_name_norm) + INTO rows_after, names_after + FROM power_supply_centers + WHERE source = 'rosseti_wfs'; + + RAISE NOTICE '#3322 power_supply_centers: было % строк / % имён -> стало % строк / % имён (удалено %, переключено на стабильный ключ %)', + rows_before, names_before, rows_after, names_after, deleted, rekeyed; + + -- 700 — потолок здравого смысла: источник отдаёт ~488 ЦП по области. + -- Превышение = формула ключа не схлопнула дубли, смотреть глазами. + IF rows_after > 700 THEN + RAISE WARNING '#3322: после дедупа осталось % строк (ожидалось ~481-488) — проверить формулу ключа', rows_after; + END IF; +END $$; + +COMMIT; From 0ed934ea09984f2cd0fd0adfb92fa8ffc33c6ccd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:44:38 +0500 Subject: [PATCH 006/121] =?UTF-8?q?fix(scraper-kit):=20=D1=87=D0=B5=D0=BA?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D0=BD=D1=82=20avito=5Fcity=5Fsweep=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B6=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20=D0=B4=D0=BE=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20(#3319)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0 из 67 прогонов за 60 дней имели done_buckets в counters: точку писала одна строка внутри цикла якорей, а каждый выход (mark_done — включая ранний #1950 «SERP собран, detail заблокирован», — mark_banned, mark_failed) отдавал голый counters.to_dict(). Точка держалась только на jsonb-мерже в runs.py, то есть на свойстве чужого модуля, которого этот файл не проверяет. - payload любого выхода собирается одной функцией _ckpt() — done_buckets несут все 14 записей, а не одна; - якорь, умерший по таймауту, больше не считается пройденным (тот же инвариант, что у generic-except): SERP мог успеть, detail нет, и резюм пропускал такой якорь навсегда при штатно завершившемся прогоне; - SIGTERM-дрейн помечается counters.interrupted=1 и участвует в резюме. Статус остаётся 'done' — ни один читатель статуса не меняется; метка та же, что у rosreestr_dkp-дрейна. 'done' в _RESUME_STATUSES НЕ добавлен: чистый полный обход резюмить нечего. Дрейн перед IMV-фазой помечен отдельно (imv_phase_drained) — якоря там пройдены все, подхват собрал бы ноль. --- .../tests/test_3319_citysweep_checkpoint.py | 214 ++++++++++++++++++ .../src/scraper_kit/orchestration/pipeline.py | 56 +++-- .../scraper_kit/orchestration/scheduler.py | 9 +- 3 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py diff --git a/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py new file mode 100644 index 00000000..6ae05a73 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py @@ -0,0 +1,214 @@ +"""Чекпоинт avito_city_sweep доживает до финализатора (#3319). + +Прод-факт, из которого выросла задача: 0 из 67 прогонов за 60 дней имеют в +counters ключ done_buckets. Механизм #3074 (запись точки) и механизм #930 +(подхват точки) существуют оба, но между ними нет ни одного прогона: точку +писала ровно одна строка внутри цикла якорей, а КАЖДЫЙ выход из прогона +(mark_done — включая ранний выход #1950 «SERP собран, detail заблокирован», — +mark_banned, mark_failed) отдавал голый counters.to_dict() без неё. + +Три инварианта, ради которых тест: + 1. done-выход несёт done_buckets — иначе точка существует только в логе. + 2. Якорь, умерший по таймауту, НЕ пройден: SERP мог успеть, detail нет. + Пройденным его записать = резюм пропустит его навсегда и молча. + 3. SIGTERM-дрейн отличим от полного обхода (counters.interrupted=1) и + участвует в резюме — статус у обоих 'done', счётчики частичные. +""" + +from __future__ import annotations + +import os + +# Settings собирается автофикстурой conftest'а и требует database_url. Выставляем +# до остальных импортов — так же, как в test_3074_avito_anchor_checkpoint.py. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import json +import types +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +ANCHOR_A = (56.83, 60.60, "ekb-center") +ANCHOR_B = (56.79, 60.63, "ekb-south") + + +class _FakeDb: + """Все UPDATE'ы с counters (heartbeat И финализаторы) складываются по порядку.""" + + def __init__(self) -> None: + self.writes: list[dict[str, Any]] = [] + + def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params and "counters" in params: + self.writes.append(json.loads(params["counters"])) + return MagicMock() + + def commit(self) -> None: ... + def rollback(self) -> None: ... + + +class _FakeAsyncSession: + def __init__(self, *_a: Any, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _FakeAsyncSession: + return self + + async def __aexit__(self, *_e: Any) -> None: + return None + + +class _FakeScraper: + """Двойник AvitoScraper: помнит визиты, роняет заданный якорь заданной ошибкой.""" + + visited: list[tuple[float, float]] = [] # noqa: RUF012 — тестовый сборник + raise_on: tuple[float, float] | None = None + exc: type[BaseException] | None = None + lots_per_anchor: int = 0 + + def __init__(self, *_a: Any, **_kw: Any) -> None: + self._browser = None + self._cffi = None + + async def fetch_around(self, lat: float, lon: float, *_a: Any, **_kw: Any) -> list: + _FakeScraper.visited.append((lat, lon)) + if _FakeScraper.raise_on == (lat, lon) and _FakeScraper.exc is not None: + raise _FakeScraper.exc("якорь сорвался") + return [MagicMock() for _ in range(_FakeScraper.lots_per_anchor)] + + +def _config() -> types.SimpleNamespace: + return types.SimpleNamespace( + scraper_fetch_mode="cffi", + scraper_proxy_url=None, + use_proxy_pool_browser=False, + browser_http_endpoint=None, + environment="test", + avito_serp_ok_not_banned=True, + ) + + +async def _run( + *, + raise_on: tuple[float, float] | None = None, + exc: type[BaseException] | None = None, + shutdown_after_first: bool = False, + saved: tuple[int, int] = (0, 0), + lots_per_anchor: int = 0, +) -> _FakeDb: + from scraper_kit.orchestration import pipeline as pl + + _FakeScraper.visited = [] + _FakeScraper.raise_on = raise_on + _FakeScraper.exc = exc + _FakeScraper.lots_per_anchor = lots_per_anchor + db = _FakeDb() + + def _shutdown() -> bool: + return shutdown_after_first and bool(_FakeScraper.visited) + + with ( + patch.object(pl, "AvitoScraper", _FakeScraper), + patch.object(pl, "AsyncSession", _FakeAsyncSession), + patch.object(pl, "save_listings", lambda *_a, **_kw: saved), + patch.object(pl.runs, "is_cancelled", lambda *_a: False), + ): + await pl.run_avito_city_sweep( + db, # type: ignore[arg-type] + run_id=3319, + config=_config(), + matcher=MagicMock(), + enrichment=MagicMock(), + anchors=[ANCHOR_A, ANCHOR_B], + enrich_houses=False, + enrich_imv=False, + detail_top_n=0, + shutdown_requested=_shutdown, + ) + return db + + +@pytest.mark.asyncio +async def test_done_exit_carries_checkpoint() -> None: + """Финализатор полного обхода несёт done_buckets, а не голые счётчики.""" + db = await _run() + + assert db.writes[-1].get("done_buckets") == ["ekb-center", "ekb-south"], ( + "финальный (done) выход отдал counters без чекпоинта — точки в прогоне нет" + ) + + +@pytest.mark.asyncio +async def test_serp_ok_done_exit_carries_checkpoint() -> None: + """Ранний done-выход #1950 («SERP собран, detail заблокирован») — тоже. + + Именно этим выходом кончается типичный прод-прогон, и он происходит РАНЬШЕ + единственной строки, которая писала точку. + """ + from scraper_kit.orchestration import pipeline as pl + + db = await _run( + raise_on=(ANCHOR_B[0], ANCHOR_B[1]), + exc=pl.AvitoBlockedError, + saved=(1, 0), # SERP intake > 0 → ветка ставит 'done', а не 'banned' + lots_per_anchor=1, + ) + + last = db.writes[-1] + assert "enrichment_abort_note" in last, "сработала не та ветка выхода" + assert last.get("done_buckets") == ["ekb-center"], ( + "ранний done-выход потерял якорь, пройденный до блокировки" + ) + + +@pytest.mark.asyncio +async def test_timed_out_anchor_is_not_checkpointed() -> None: + """Якорь, умерший по таймауту, не считается пройденным. + + Иначе резюм пропустит его навсегда, и это будет незаметно: прогон + завершается штатно, просто часть города не собирается никогда. + """ + db = await _run(raise_on=(ANCHOR_A[0], ANCHOR_A[1]), exc=TimeoutError) + + ckpt = db.writes[-1].get("done_buckets") + assert "ekb-center" not in ckpt, "якорь-таймаут попал в чекпоинт" + assert "ekb-south" in ckpt, "исправный якорь не зафиксирован" + + +@pytest.mark.asyncio +async def test_drain_exit_is_distinguishable_from_full_done() -> None: + """SIGTERM-дрейн помечен interrupted=1; полный обход — нет.""" + drained = await _run(shutdown_after_first=True) + full = await _run() + + assert drained.writes[-1].get("interrupted") == 1, ( + "оборванный дрейном прогон неотличим от полного обхода" + ) + assert drained.writes[-1].get("done_buckets") == ["ekb-center"] + assert "interrupted" not in full.writes[-1], "полный обход помечен как оборванный" + + +def _prev_run(counters: dict[str, Any]) -> types.SimpleNamespace: + return types.SimpleNamespace( + prev_id=4707, + prev_status="done", + prev_counters=counters, + same_params=True, + age_h=2.0, + interval_days="7", + ) + + +def test_drained_done_is_resumable_but_clean_done_is_not() -> None: + """Метка дрейна доходит до решения о резюме — иначе она диагностика ради себя.""" + from scraper_kit.orchestration.scheduler import _resume_decision + + ckpt = {"done_buckets": ["ekb-center"], "resume_chain": 0} + + resume_from, verdict = _resume_decision(_prev_run({**ckpt, "interrupted": 1})) + assert resume_from == 4707, f"дрейн не подхвачен: {verdict}" + + resume_from, verdict = _resume_decision(_prev_run(ckpt)) + assert resume_from is None, "полный обход подхватывать нечего" + assert verdict["resume_reason"] == "status_done" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 71599c87..2cbacf9c 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -1183,6 +1183,18 @@ async def run_avito_city_sweep( ) _done_anchors: set[str] = set(_skip_anchors) + def _ckpt(**extra: Any) -> dict[str, Any]: + """Счётчики прогона ВМЕСТЕ с чекпоинтом — payload любого выхода (#3319). + + До этого точку писала ровно одна строка внутри цикла якорей, а все + финализаторы (mark_done/mark_banned/mark_failed, включая ранний выход + #1950 «SERP OK, detail заблокирован») отдавали голый `counters.to_dict()`. + Точка держалась исключительно на jsonb-мерже в runs.py — на свойстве + ЧУЖОГО модуля, которого этот файл ничем не проверяет; выход, случившийся + раньше первой записи (или писатель без мержа), терял её молча. + """ + return {**counters.to_dict(), "done_buckets": sorted(_done_anchors), **extra} + _loc = get_city_location(city_slug) # #262 wave 2: avito_slug у CityLocation Optional — не у каждого известного города # он подтверждён (403/429 на исчерпанном пуле при проверке, либо omonym-коллизия). @@ -1287,7 +1299,7 @@ async def run_avito_city_sweep( len(_anchors), name, ) - runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.update_heartbeat(db, run_id, _ckpt()) return counters elif shutdown_requested(): # Кооперативный SIGTERM-drain (#1182 Phase 3a): останавливаемся @@ -1301,8 +1313,13 @@ async def run_avito_city_sweep( len(_anchors), name, ) - runs.update_heartbeat(db, run_id, counters.to_dict()) - runs.mark_done(db, run_id, counters.to_dict()) + # #3319: 'done' с counters.interrupted=1 — НЕ полный обход + # (та же метка, что у rosreestr_dkp-дрейна). Без неё оборванный + # деплоем прогон неотличим от честно обошедшего все якоря: + # статус тот же, счётчики частичные, а резюм его не берёт. + # Читатели статуса не трогаем — 'done' остаётся 'done'. + runs.update_heartbeat(db, run_id, _ckpt(interrupted=1)) + runs.mark_done(db, run_id, _ckpt(interrupted=1)) return counters logger.info( @@ -1762,6 +1779,12 @@ async def run_avito_city_sweep( _avito_anchor_timeout, ) counters.errors_count += 1 + # #3319: тот же инвариант, что у generic-except ниже. Якорь, + # умерший по таймауту, ПРОЙДЕН НЕ БЫЛ: SERP мог успеть, а + # detail/houses — нет, и какая именно часть осталась несобранной, + # здесь неизвестно. Считать его пройденным значит, что резюм + # пропустит его навсегда — молча, при штатно завершившемся прогоне. + _anchor_ok = False except (AvitoBlockedError, AvitoRateLimitedError) as e: logger.error( "city-sweep run_id=%d ABORT at anchor #%d/%d (%s) — blocked: %s", @@ -1773,7 +1796,7 @@ async def run_avito_city_sweep( ) counters.errors_count += 1 counters.anchors_done = idx - runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.update_heartbeat(db, run_id, _ckpt()) # #1950: если SERP уже собрал лоты и заблокировало только detail/houses, # ставим 'done' (не 'banned') — partial intake сохранён. # За флагом avito_serp_ok_not_banned (default True). @@ -1794,14 +1817,14 @@ async def run_avito_city_sweep( runs.mark_done( db, run_id, - {**counters.to_dict(), "enrichment_abort_note": _note}, # type: ignore[arg-type] + _ckpt(enrichment_abort_note=_note), # type: ignore[arg-type] ) else: runs.mark_banned( db, run_id, str(e), - counters.to_dict(), + _ckpt(), ban_kind=ban_kind_of_exception(e), ) return counters @@ -1819,9 +1842,7 @@ async def run_avito_city_sweep( # завершится штатно. Тот же инвариант, что у combo в yandex-свипе. if _anchor_ok: _done_anchors.add(name) - runs.update_heartbeat( - db, run_id, {**counters.to_dict(), "done_buckets": sorted(_done_anchors)} - ) + runs.update_heartbeat(db, run_id, _ckpt()) # ── IMV-фаза: финальный обход тронутых домов ────────── if enrich_imv and all_touched_house_ids: @@ -1831,7 +1852,7 @@ async def run_avito_city_sweep( run_id, len(all_touched_house_ids), ) - runs.mark_done(db, run_id, counters.to_dict()) + runs.mark_done(db, run_id, _ckpt()) return counters elif shutdown_requested(): # SIGTERM-drain до IMV-фазы: финализируем без дорогой IMV-оценки. @@ -1841,7 +1862,10 @@ async def run_avito_city_sweep( run_id, len(all_touched_house_ids), ) - runs.mark_done(db, run_id, counters.to_dict()) + # #3319: тоже дрейн, но якоря пройдены ВСЕ — резюмить нечего + # (пропустил бы весь список и собрал ноль), поэтому метка + # диагностическая, а не резюм-флаг `interrupted`. + runs.mark_done(db, run_id, _ckpt(imv_phase_drained=1)) return counters logger.info( @@ -1855,7 +1879,7 @@ async def run_avito_city_sweep( # без update_heartbeat, и reap_zombies помечает живой run # 'zombie' → последующий mark_done становится no-op (дубль-sweep). def _imv_heartbeat() -> None: - runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.update_heartbeat(db, run_id, _ckpt()) imv_result = await enrichment.process_houses_imv_batch( db, @@ -1867,7 +1891,7 @@ async def run_avito_city_sweep( counters.imv_enriched += imv_result.saved counters.imv_failed += imv_result.errors counters.errors_count += imv_result.errors - runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.update_heartbeat(db, run_id, _ckpt()) logger.info( "city-sweep run_id=%d: IMV phase done — attempted=%d enriched=%d failed=%d", run_id, @@ -1892,9 +1916,9 @@ async def run_avito_city_sweep( db.rollback() except Exception: pass - runs.update_heartbeat(db, run_id, counters.to_dict()) + runs.update_heartbeat(db, run_id, _ckpt()) - runs.mark_done(db, run_id, counters.to_dict()) + runs.mark_done(db, run_id, _ckpt()) logger.info( "city-sweep run_id=%d done: anchors=%d/%d lots=%d (ins=%d/upd=%d) " "houses=%d/%d detail=%d/%d imv=%d/%d errors=%d", @@ -1916,7 +1940,7 @@ async def run_avito_city_sweep( except Exception as exc: logger.exception("city-sweep run_id=%d: fatal error", run_id) - runs.mark_failed(db, run_id, str(exc), counters.to_dict()) + runs.mark_failed(db, run_id, str(exc), _ckpt()) raise diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index 0892d454..d49712c7 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -638,7 +638,14 @@ def _resume_decision(row: Any) -> tuple[int | None, dict[str, Any]]: } _boot_reaped_zombie = row.prev_status == "zombie" and prev_counters.get("boot_reaped") is True - if row.prev_status not in _RESUME_STATUSES and not _boot_reaped_zombie: + # 'done' с counters.interrupted=1 — SIGTERM-drain (#3319): статус штатный, но обход + # оборван на границе корзины, часть дерева не собрана. Сам статус в _RESUME_STATUSES + # не добавлен НАМЕРЕННО: чистое 'done' — полный проход, резюмить у него нечего, а + # подхват такой точки означал бы, что источник больше никогда не обходится целиком. + # Метка — та же, что у rosreestr_dkp-дрейна (app/services/scheduler.py), поэтому ни + # один читатель статуса не меняется. + _drained_done = row.prev_status == "done" and bool(prev_counters.get("interrupted")) + if row.prev_status not in _RESUME_STATUSES and not _boot_reaped_zombie and not _drained_done: verdict["resume_reason"] = f"status_{row.prev_status}" elif not row.same_params: verdict["resume_reason"] = "params_changed" From 390a6f05b187a4dcf9afcd6ac02092e10b8df121 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:46:10 +0500 Subject: [PATCH 007/121] =?UTF-8?q?fix(mera-public):=20=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0=20?= =?UTF-8?q?=D1=83=20=D1=85=D0=B2=D0=BE=D1=81=D1=82=D0=B0=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=B0=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=B4=D0=B5,=20=D0=BD=D0=B5=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readDraftExtras требует адрес, под который спрашивают, и на несовпадении отдаёт null. Гарантия «потребитель обязан сверить» жила только в комментарии, а takeDraft выходит раньше записи хвоста на пустом и на битом черновике — то есть хвост от предыдущей квартиры доживал до следующего захода в той же вкладке и дождался бы потребителя, который сверить забыл. Плюс два непокрытых кейса: черновик без floor/condition убирает прошлый хвост; хвост не отдаётся чужому адресу. --- .../estimate-draft-survives-estimate.test.tsx | 12 +++++---- .../__tests__/estimate-draft.test.ts | 25 ++++++++++++++++++- .../src/app/mera-public/estimate-draft.ts | 19 ++++++++------ 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx index 0539e722..146f70ca 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx @@ -45,6 +45,9 @@ function submitLandingCard(): string { // не из своих догадок о наборе вариантов. const conditions = screen.getByRole("group", { name: /состояние/i }); const chosen = conditions.querySelectorAll("button")[0]; + // Без этой проверки пустая группа роняет тест TypeError'ом на textContent — + // отказом, по которому не видно, что именно разъехалось с формой. + expect(chosen, "в группе «Состояние» не нашлось кнопок").toBeTruthy(); const label = chosen.textContent ?? ""; fireEvent.click(chosen); @@ -56,17 +59,16 @@ function submitLandingCard(): string { describe("Черновик: обязательные поля переживают переход на /estimate", () => { it("этаж и состояние доступны после монтирования целевой страницы", () => { const condition = submitLandingCard(); - expect(condition, "в группе «Состояние» не нашлось кнопок").not.toBe(""); // Тот самый шаг, который раньше уничтожал оба поля. render(); - const extras = readDraftExtras(); + // Читаем так же, как будет читать платный шаг: под тот адрес, о котором + // спрашивают. Чужому адресу хвост не отдаётся — проверено отдельно в + // estimate-draft.test.ts. + const extras = readDraftExtras("Екатеринбург, Ленина 5"); expect(extras, "после /estimate от черновика не осталось ничего").not.toBeNull(); expect(extras?.floor).toBe("7/16"); expect(extras?.condition).toBe(condition); - // Хвост без адреса нельзя предъявлять платному шагу: он не отличит этаж - // этой квартиры от этажа предыдущей в той же вкладке. - expect(extras?.address).toBe("Екатеринбург, Ленина 5"); }); }); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts index 8449c935..5e5044a2 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { normalizeDraftRooms, saveDraft, takeDraft } from "../estimate-draft"; +import { + normalizeDraftRooms, + readDraftExtras, + saveDraft, + takeDraft, +} from "../estimate-draft"; /** * Черновик, который лэндинг передаёт на экран проверки. @@ -107,6 +112,24 @@ describe("этаж переживает переход на целевую ст expect(d?.floor).toBeUndefined(); }); + it("черновик без этажа и состояния убирает прошлый хвост", () => { + // Иначе этаж от предыдущей квартиры пережил бы свой черновик и лежал бы, + // дожидаясь потребителя. + saveDraft({ address: "Ленина 5", floor: "7/16", condition: "Хорошее" }); + takeDraft(); + saveDraft({ address: "Малышева 51", rooms: "2" }); + takeDraft(); + expect(readDraftExtras("Ленина 5")).toBeNull(); + expect(readDraftExtras("Малышева 51")).toBeNull(); + }); + + it("хвост не отдаётся другому адресу", () => { + saveDraft({ address: "Ленина 5", floor: "7/16" }); + takeDraft(); + expect(readDraftExtras("Малышева 51")).toBeNull(); + expect(readDraftExtras("Ленина 5")?.floor).toBe("7/16"); + }); + it("этаж не строка — поле выпадает, остальной черновик цел", () => { window.sessionStorage.setItem( "mera:estimate-draft", diff --git a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts index d36314ef..8063187d 100644 --- a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts +++ b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts @@ -54,11 +54,7 @@ export interface EstimateDraft { /** * Хвост черновика: то, что собрали на лэндинге, но на `/estimate` не читают. - * - * Адрес лежит здесь ВМЕСТЕ с полями, а не подразумевается: иначе этаж «7/16» - * от квартиры на Ленина переживёт свой черновик и на платном шаге приклеится к - * другому дому в той же вкладке. Потребитель обязан сверить адрес, а не верить - * тому, что нашёл. + * Адрес лежит здесь вместе с полями — сверку делает `readDraftExtras`. */ export interface EstimateDraftExtras { address: string; @@ -137,18 +133,25 @@ function saveDraftExtras(draft: EstimateDraft): void { * Читает, но НЕ забирает: платный шаг может открыться не с первого раза, а * второе чтение того же хвоста ничего не портит. Вкладка закроется — исчезнет * само. + * + * `expectedAddress` обязателен, а не «желателен»: `takeDraft` выходит раньше + * записи хвоста на пустом/битом черновике, поэтому хвост от ПРЕДЫДУЩЕЙ + * квартиры доживает до следующего захода в той же вкладке. Сверку нельзя + * оставлять на совесть потребителя — не тот адрес, значит ничего нет. */ -export function readDraftExtras(): EstimateDraftExtras | null { +export function readDraftExtras( + expectedAddress: string, +): EstimateDraftExtras | null { try { const raw = window.sessionStorage.getItem(EXTRAS_KEY); if (!raw) return null; const parsed: unknown = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null) return null; const value = parsed as Record; - if (typeof value.address !== "string") return null; + if (value.address !== expectedAddress) return null; return { - address: value.address, + address: expectedAddress, condition: typeof value.condition === "string" ? value.condition : undefined, floor: typeof value.floor === "string" ? value.floor : undefined, From 4feb61c006630394645824f697a80402a30372fa Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:49:27 +0500 Subject: [PATCH 008/121] =?UTF-8?q?fix(tradein):=20=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=BE=D0=BB=D0=B2=D0=B8=D1=82=D1=8C=20=D1=80=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=B0,?= =?UTF-8?q?=20roles.yaml=20=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA?= =?UTF-8?q?=D0=BE=20fallback=20(#3316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Роль жила в двух местах сразу: люди заводятся в БД (`tradein_users.role`), а `get_role` читал ТОЛЬКО `auth/roles.yaml` — и никто эти два источника не сверял. Дефект двусторонний: * вверх: менеджер заводил сотрудника с именем, которое уже числится в roles.yaml админом (проверялись лишь regex и уникальность в БД) — на входе тот получал admin из YAML, то есть чтение ЛЮБОЙ чужой оценки (admin проходит мимо ownership-check в trade_in.py) и безлимитную квоту; * вниз: сотрудник, которого в roles.yaml нет, ловил KeyError → 403 на СОБСТВЕННУЮ оценку. Источник теперь один и лечится один раз — в `app.core.auth.get_role`: реестр (`tradein_users.role` / `auth.users.role`) спрашивается первым, roles.yaml остаётся fallback для legacy-юзеров, у которых строки в реестре нет. Реестр недоступен → тоже fallback: падение БД не выключает legacy-вход. Вызывающие (rbac, trade_in, team, account_quota) не менялись. Сопутствующее, чтобы поведение существующих аккаунтов не поехало: * rbac_guard выбирает матчер путей по РОДУ роли (роль реестра → DB_ROLE_PATHS), иначе employee/manager на legacy-пути получил бы 403 на всё; * get_user_scope отдаёт scope роли реестра из того же DB_ROLE_PATHS; * право на персональный `unlimited` осталось за roles.yaml (account_quota + _batch_quota_status) — фикс убирает эскалацию, а не раздаёт новую; * `_batch_quota_status` берёт роли из уже прочитанных строк — иначе список «Команды» снова стал бы N+1. Defense-in-depth: create_employee отдаёт 409 на username, за которым в roles.yaml числится не-employee роль. --- tradein-mvp/backend/app/api/v1/team.py | 44 +++++- tradein-mvp/backend/app/core/auth.py | 85 ++++++++++- tradein-mvp/backend/app/core/rbac.py | 8 +- .../backend/app/services/account_quota.py | 13 +- .../backend/tests/test_role_single_source.py | 142 ++++++++++++++++++ 5 files changed, 273 insertions(+), 19 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_role_single_source.py diff --git a/tradein-mvp/backend/app/api/v1/team.py b/tradein-mvp/backend/app/api/v1/team.py index d42bd250..1a422822 100644 --- a/tradein-mvp/backend/app/api/v1/team.py +++ b/tradein-mvp/backend/app/api/v1/team.py @@ -78,7 +78,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from sqlalchemy.sql.elements import TextClause -from app.core.auth import get_role +from app.core.auth import get_role, yaml_role from app.core.config import settings from app.core.db import get_db from app.core.password import hash_password @@ -288,7 +288,9 @@ def _upsert_quota_override( ) -def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]: +def _batch_quota_status( + db: Session, usernames: list[str], known_roles: dict[str, str] | None = None +) -> dict[str, dict[str, Any]]: """Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563). @@ -351,13 +353,21 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str result: dict[str, dict[str, Any]] = {} for username in usernames: override = override_by_username.get(username) - try: - role = get_role(username) - except KeyError: - role = None + # #3316: get_role ходит в реестр, а вызывающий уже прочитал роли этих + # же строк — иначе батч снова стал бы N+1 (ловит + # test_list_employees_query_count_is_not_n_plus_1). Роль реестра — + # ровно то, что вернул бы get_role: он спрашивает реестр первым. + role: str | None + if known_roles is not None and username in known_roles: + role = known_roles[username] + else: + try: + role = get_role(username) + except KeyError: + role = None if role == "admin": unlimited = True - elif role is not None: + elif yaml_role(username) is not None: unlimited = bool(override is not None and override["unlimited"]) else: # username не в roles.yaml — is_unlimited() короткое замыкание на @@ -432,6 +442,20 @@ async def create_employee( `identity_db` — реестр (строка сотрудника), `db` — продуктовая квота; в дефолтном режиме это одна и та же сессия и одна транзакция. """ + # #3316 defense-in-depth: имя, за которым в roles.yaml уже числятся права + # (admin/pilot/analyst), занять нельзя. Роль резолвится из реестра первой + # (app.core.auth.get_role), так что эскалации не было бы и без этой + # проверки — но совпадение имён само по себе означает двух разных людей с + # одним логином, и дешевле отказать на входе, чем разбирать это в логах. + legacy = yaml_role(body.username) + if legacy is not None and legacy != "employee": + logger.warning( + "create_employee: %r refused — username занят в roles.yaml (role=%s)", + body.username, + legacy, + ) + raise HTTPException(status_code=409, detail="username reserved in roles config") + schema = identity_schema() existing = identity_db.execute( text(f"SELECT id FROM {schema.users_table} WHERE username = :u"), @@ -762,7 +786,11 @@ async def list_employees( .all() ) - quota_by_username = _batch_quota_status(db, [row["username"] for row in rows]) + quota_by_username = _batch_quota_status( + db, + [row["username"] for row in rows], + known_roles={row["username"]: row["role"] for row in rows}, + ) return [_employee_out(row, quota_by_username[row["username"]]) for row in rows] diff --git a/tradein-mvp/backend/app/core/auth.py b/tradein-mvp/backend/app/core/auth.py index affe6c4e..0906875b 100644 --- a/tradein-mvp/backend/app/core/auth.py +++ b/tradein-mvp/backend/app/core/auth.py @@ -8,6 +8,11 @@ from repo root. We deliberately do NOT share code between repos via When updating one copy, update the other. +⚠️ РАСХОЖДЕНИЕ С ЗЕРКАЛОМ (#3316, намеренное — не «синхронизировать» обратно): +здесь `get_role` резолвит роль СНАЧАЛА из реестра людей (`tradein_users.role` / +`auth.users.role`), и только потом из YAML. У основного бэкенда реестра нет, +там копия остаётся YAML-only. + Caddy gates the whole site with basic_auth (см. `caddy/users.caddy.snippet`) и пропускает в backend заголовок `X-Authenticated-User: ` через `header_up X-Authenticated-User {http.auth.user.id}` в каждом reverse_proxy. @@ -25,13 +30,15 @@ import logging import re from functools import lru_cache from pathlib import Path -from typing import Literal, TypedDict +from typing import Literal, TypedDict, cast import yaml logger = logging.getLogger(__name__) -Role = Literal["admin", "pilot", "analyst", "expired"] +# legacy roles.yaml-роли + роли реестра ('admin'|'manager'|'employee', CHECK +# tradein м.192 / auth м.004). Оба набора приходят из одного `get_role` (#3316). +Role = Literal["admin", "pilot", "analyst", "expired", "manager", "employee"] class UserScope(TypedDict): @@ -155,8 +162,65 @@ def _load_roles_config() -> dict: # --------------------------------------------------------------------------- +def yaml_role(username: str) -> Role | None: + """Роль из roles.yaml (без похода в реестр) или None, если юзера там нет. + + Нужна там, где спрашивают именно про legacy-файл, а не про эффективную роль: + `team.create_employee` (#3316) не даёт занять имя, за которым в YAML уже + числятся права. + """ + users: dict[str, Role] = _load_roles_config()["users"] + return users.get(username) + + +def _registry_role(username: str) -> str | None: + """Роль из реестра людей (`tradein_users.role` / `auth.users.role`) или None. + + None означает «реестр про этого юзера ничего не сказал»: строки нет, роль + пустая, либо реестр вообще недоступен. Во всех трёх случаях решение + остаётся за roles.yaml — падение БД не имеет права выключить legacy-вход. + + Имя таблицы берётся из фиксированного словаря `identity_schema()`, значение + едет bind-параметром: снаружи в SQL не попадает ничего. + """ + try: + from sqlalchemy import text + + from app.services.identity_store import identity_schema, identity_session + + schema = identity_schema() + with identity_session() as db: + row = db.execute( + text(f"SELECT role FROM {schema.users_table} WHERE username = :username"), + {"username": username}, + ).fetchone() + except Exception: + logger.exception( + "registry role lookup failed for %r — fallback to roles.yaml", + username, + ) + return None + if row is None or not row.role: + return None + return str(row.role) + + def get_role(username: str) -> Role: - """Return the role for *username* or raise KeyError if unknown.""" + """Эффективная роль *username*: реестр (БД) первый, roles.yaml — fallback. + + Raises KeyError, если юзера нет ни там, ни там. + + #3316: раньше роль резолвилась ТОЛЬКО из roles.yaml, при том что люди + заводятся в БД (`tradein_users`) — два дефекта разом. Вверх: сотрудник, + чьё имя совпало с YAML-админом, получал admin (IDOR по чужим оценкам + + безлимит квоты). Вниз: сотрудник, которого в YAML нет, получал KeyError → + 403 на СОБСТВЕННУЮ оценку. Единственный источник истины теперь один, и он + здесь — вызывающие (rbac, trade_in, team, account_quota) не меняются. + """ + db_role = _registry_role(username) + if db_role is not None: + return cast(Role, db_role) + config = _load_roles_config() users: dict[str, Role] = config["users"] if username not in users: @@ -217,13 +281,22 @@ def get_user_scope(username: str) -> UserScope: """ config = _load_roles_config() role = get_role(username) - role_def = config["roles"][role] + role_def = config["roles"].get(role) + if role_def is None: + # Роль реестра (employee/manager) — её scope живёт в DB_ROLE_PATHS, а не + # в roles.yaml (#3316: get_role теперь может вернуть и такую роль). + from app.services.auth_session import get_db_role_scope + + allowed_paths, deny_paths = get_db_role_scope(role) + else: + allowed_paths = list(role_def.get("paths", []) or []) + deny_paths = list(role_def.get("deny", []) or []) display_name, org, email = get_profile_for_user(username) return UserScope( username=username, role=role, - allowed_paths=list(role_def.get("paths", []) or []), - deny_paths=list(role_def.get("deny", []) or []), + allowed_paths=allowed_paths, + deny_paths=deny_paths, brand=get_brand_for_user(username), display_name=display_name, org=org, diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index 06f14afc..62701225 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -38,7 +38,7 @@ from fastapi.responses import JSONResponse, Response from app.core.auth import get_role, is_path_allowed from app.core.config import settings -from app.services.auth_session import get_db_role_scope, get_session_user +from app.services.auth_session import DB_ROLE_PATHS, get_db_role_scope, get_session_user from app.services.identity_store import identity_session logger = logging.getLogger(__name__) @@ -334,7 +334,11 @@ async def rbac_guard( # scope-narrowed юзер не смог бы получить свою роль вовсе. if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT): external_path = _EXTERNAL_PREFIX + path - if from_session: + # Матчер выбирается по РОДУ роли, а не только по источнику (#3316): + # с DB-first резолвом legacy trusted-header путь тоже может отдать роль + # реестра (employee/manager), а её паттернов в roles.yaml нет — сверка + # с `is_path_allowed` дала бы 403 на всё. + if from_session or role in DB_ROLE_PATHS: allowed = _db_role_path_allowed(role, external_path) else: try: diff --git a/tradein-mvp/backend/app/services/account_quota.py b/tradein-mvp/backend/app/services/account_quota.py index 10d778ca..7f420657 100644 --- a/tradein-mvp/backend/app/services/account_quota.py +++ b/tradein-mvp/backend/app/services/account_quota.py @@ -46,7 +46,7 @@ from fastapi import HTTPException from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.auth import get_role +from app.core.auth import get_role, yaml_role from app.core.config import settings logger = logging.getLogger(__name__) @@ -61,8 +61,7 @@ def limit_exhausted_message(limit: int) -> str: отличаться от глобального MONTHLY_LIMIT для персонального override ИЛИ anon default_limit, см. #b2c-antiabuse-2).""" return ( - f"Лимит из {limit} оценок в этом месяце исчерпан. " - "За полной версией обращайтесь к Копылову." + f"Лимит из {limit} оценок в этом месяце исчерпан. За полной версией обращайтесь к Копылову." ) @@ -97,6 +96,14 @@ def is_unlimited(db: Session, username: str) -> bool: return False if role == "admin": return True + # #3316: get_role резолвит роль из реестра (БД) первой, поэтому сотрудник + # team-API больше не даёт KeyError. Право на ПЕРСОНАЛЬНЫЙ безлимит при этом + # осталось там же, где было — за roles.yaml: фикс убирает эскалацию, а не + # раздаёт новую. Иначе руками проставленный `unlimited` начал бы работать + # для аккаунтов, которым он раньше молча игнорировался (и разъехался бы с + # `_batch_quota_status` в списке «Команды»). + if yaml_role(username) is None: + return False row = db.execute( text( """ diff --git a/tradein-mvp/backend/tests/test_role_single_source.py b/tradein-mvp/backend/tests/test_role_single_source.py new file mode 100644 index 00000000..da358dcd --- /dev/null +++ b/tradein-mvp/backend/tests/test_role_single_source.py @@ -0,0 +1,142 @@ +"""#3316 — роль резолвится из ОДНОГО источника: реестр (БД) первый, roles.yaml — fallback. + +Проверяется значение роли, а не факт вызова механизма: + * имя из roles.yaml, заведённое в реестре сотрудником → роль `employee` + (эскалации в admin нет: ни IDOR по чужим оценкам, ни безлимитной квоты); + * сотрудник, которого в roles.yaml НЕТ → роль резолвится, ownership-check + пропускает его к СВОЕЙ оценке и держит на чужой (раньше был KeyError → 403); + * legacy-юзер (есть в YAML, в реестре строки нет) → роль ровно как раньше — + сверяется ВЕСЬ маппинг roles.yaml, а не один аккаунт; + * реестр недоступен → fallback на YAML (падение БД не выключает legacy-вход). + +FastAPI здесь не поднимается: резолвер — чистая функция от (реестр, YAML), +реестр подменяется фейковой сессией. +""" + +from __future__ import annotations + +import os +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from types import SimpleNamespace +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +from app.core import auth +from app.services import identity_store + +_MISSING = object() + + +class _FakeSession: + """Отдаёт одну строку `SELECT role ...` — или ничего, если роли нет.""" + + def __init__(self, role: str | None) -> None: + self.role = role + self.sql: str | None = None + self.params: dict[str, Any] | None = None + + def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any: + self.sql = str(sql) + self.params = params + row = None if self.role is None else SimpleNamespace(role=self.role) + return SimpleNamespace(fetchone=lambda: row) + + +@pytest.fixture +def registry(monkeypatch: pytest.MonkeyPatch) -> Callable[..., _FakeSession | None]: + """`registry(role)` — что реестр отвечает на запрос роли. + + role=None → строки нет (legacy-юзер); role=_MISSING → реестр падает. + """ + + def install(role: str | None | object) -> _FakeSession | None: + if role is _MISSING: + + @contextmanager + def broken_session() -> Iterator[Any]: + raise RuntimeError("registry down") + yield # pragma: no cover — нужен, чтобы функция была генератором + + monkeypatch.setattr(identity_store, "identity_session", broken_session) + return None + + session = _FakeSession(role) # type: ignore[arg-type] + + @contextmanager + def fake_session() -> Iterator[_FakeSession]: + yield session + + monkeypatch.setattr(identity_store, "identity_session", fake_session) + return session + + return install + + +def _yaml_users() -> dict[str, str]: + return dict(auth._load_roles_config()["users"]) + + +def _yaml_admin() -> str: + for username, role in _yaml_users().items(): + if role == "admin": + return username + pytest.skip("в auth/roles.yaml нет ни одного admin — тест неприменим") + + +def test_registry_employee_beats_yaml_admin(registry: Callable[..., Any]) -> None: + """Эскалация закрыта: имя YAML-админа + строка `employee` в реестре = employee.""" + victim_name = _yaml_admin() + session = registry("employee") + + assert auth.get_role(victim_name) == "employee" + # username едет bind-параметром, а не склейкой в SQL. + assert session.params == {"username": victim_name} + assert victim_name not in (session.sql or "") + + +def test_employee_absent_from_yaml_resolves_and_owns_estimate( + registry: Callable[..., Any], +) -> None: + """Сотрудник вне roles.yaml: роль есть, своя оценка читается, чужая — нет.""" + name = "employee_not_in_yaml_3316" + assert name not in _yaml_users() + registry("employee") + + from fastapi import HTTPException + + from app.api.v1.trade_in import _assert_estimate_access + + # Продуктовое поведение проверяется ПЕРВЫМ: до #3316 здесь прилетал 403 + # («user not in roles config») на СОБСТВЕННУЮ оценку сотрудника. + _assert_estimate_access(name, name) + + assert auth.get_role(name) == "employee" + + with pytest.raises(HTTPException) as exc: + _assert_estimate_access("someone_else", name) + assert exc.value.status_code == 404 + + +def test_legacy_yaml_users_keep_their_roles(registry: Callable[..., Any]) -> None: + """В реестре строки нет → роли ВСЕХ YAML-юзеров ровно те же, что и были.""" + registry(None) + users = _yaml_users() + assert users, "roles.yaml без юзеров — сверять нечего" + assert {username: auth.get_role(username) for username in users} == users + + +def test_registry_failure_falls_back_to_yaml(registry: Callable[..., Any]) -> None: + """Реестр недоступен → legacy-вход продолжает работать по YAML.""" + registry(_MISSING) + assert auth.get_role(_yaml_admin()) == "admin" + + +def test_yaml_role_is_yaml_only(registry: Callable[..., Any]) -> None: + """Предикат гварда create_employee смотрит ИМЕННО в YAML, мимо реестра.""" + registry("employee") + assert auth.yaml_role(_yaml_admin()) == "admin" + assert auth.yaml_role("employee_not_in_yaml_3316") is None From 35db6608ee0fbf804e4094ca3b9321969d665f7b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:50:47 +0500 Subject: [PATCH 009/121] =?UTF-8?q?fix(tradein-v2):=20=D1=84=D1=83=D1=82?= =?UTF-8?q?=D0=B5=D1=80=2004,=20=D0=BF=D0=B0=D0=B4=D0=B5=D0=B6=20=D1=81?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20=D0=B8=20=D1=86=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B4=D0=B5=D0=BB=D1=8C=D1=82=D1=8B=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #3328: - HistoryView: «Показано N из M сделок» брало M из street-deals, а строки — из sales-vs-listings; после гашения пустого коридора выходило «из —». Знаменатель убран, как в 05 РЫНОК; - обе подписи склоняются через pluralRu («3 фактические сделки», «2 сделки»); - цвет дельты медианы сделок считается по знаку (mapSources.deltaColor): зелёным красились и минус, и прочерк — это второй причинный узел жалобы «−100% зелёным»; - подпись полосы совпала с карточкой 1 буквально («ПО СДЕЛКАМ РОСРЕЕСТРА»); - тест: убран тавтологичный assert про EmptyTableNote (её рисует пустой dealRows, а не guard), добавлены кейсы на футер 04 и на цвет дельты. --- .../components/trade-in/v2/HistoryView.tsx | 8 ++- .../components/trade-in/v2/SourcesView.tsx | 13 ++++- .../v2/__tests__/honestZeros.test.tsx | 51 ++++++++++++++++--- .../src/components/trade-in/v2/mappers.ts | 11 +++- .../src/components/trade-in/v2/types.ts | 14 ++++- 5 files changed, 84 insertions(+), 13 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx index 59fc6beb..08c66eda 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HistoryView.tsx @@ -415,7 +415,13 @@ export default function HistoryView({ data }: HistoryViewProps) { background: tokens.surfaceTint, }} > - Показано {data.dkpRows.length} из {data.dkpKpi.count} сделок + {/* #3320 — «из M» брало знаменатель из ДРУГОЙ выборки: строки ниже — + пары ДКП↔объявление (sales-vs-listings), а dkpKpi.count — коридор + street-deals. Расходились и числами («Показано 7 из 3»), и после + гашения пустого коридора («Показано 7 из —»). Тотала у той же + выборки в ответе нет — знаменатель убран, как в 05 РЫНОК. */} + Показано {data.dkpRows.length}{" "} + {pluralRu(data.dkpRows.length, ["сделка", "сделки", "сделок"])} diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx index 3d151798..e930ec58 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx @@ -4,6 +4,7 @@ import type { CSSProperties, ReactNode } from "react"; import { safeUrl } from "@/lib/safeUrl"; import type { AggregatedEstimate } from "@/types/trade-in"; import { tokens } from "./tokens"; +import { pluralRu } from "./mappers"; import type { AdRowData, DealRowData, SourcesData } from "./mappers"; import { SourcesMap } from "./SourcesMap"; @@ -637,7 +638,10 @@ export default function SourcesView({ label="МЕДИАНА СДЕЛОК" value={data.marketDeals.kpi.median} unit={`млн ₽ · ${data.marketDeals.kpi.delta}`} - unitColor={tokens.success} + // #3320 — цвет по знаку дельты (её считает mapSources): зелёным + // здесь красился и минус, и прочерк. undefined → нейтральный + // дефолт плитки. + unitColor={data.marketDeals.kpi.deltaColor} /> ) : ( diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/honestZeros.test.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/honestZeros.test.tsx index 8832d700..7399241e 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/honestZeros.test.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/honestZeros.test.tsx @@ -14,9 +14,11 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; +import HistoryView from "../HistoryView"; import SourcesView from "../SourcesView"; -import { mapResultPanel, mapSources } from "../mappers"; -import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture"; +import { mapHistory, mapResultPanel, mapSources } from "../mappers"; +import { tokens } from "../tokens"; +import { FIXTURE_ESTIMATE, FIXTURE_SALES } from "@/app/ui-preview/estimate/fixture"; import type { AggregatedEstimate, AnalogLot, @@ -74,17 +76,31 @@ describe("05 РЫНОК — пустая выборка сделок не рис range: "—", }); + // Цвет подписи тоже гаснет: зелёным красилась даже дельта, которой нет. + expect(data.marketDeals.kpi.deltaColor).toBeUndefined(); + const { container } = render(); - // Тот же пустой блок, что и при отсутствующем ответе street-deals. - expect( - screen.getByText(/Сделок по этому адресу за 12 месяцев не найдено/), - ).toBeTruthy(); // Ни «−100% к цене объявления», ни «0,00 – 0,00» на экране быть не может. expect(container.textContent).not.toContain("−100"); expect(container.textContent).not.toMatch(/(? { + // Второй причинный узел жалобы «−100% зелёным»: цвет был захардкожен. + // Сделки по фикстуре (8,00 млн) ниже медианы объявлений (9,85 млн). + const below = mapSources(estimateWith([deal({ price_rub: 8_000_000 })]), null); + expect(below.marketDeals.kpi.delta).toContain("−"); + expect(below.marketDeals.kpi.deltaColor).toBe(tokens.danger); + + const above = mapSources( + estimateWith([deal({ price_rub: 12_000_000 })]), + null, + ); + expect(above.marketDeals.kpi.delta).toContain("+"); + expect(above.marketDeals.kpi.deltaColor).toBe(tokens.success); + }); + it("count=0, но у оценки есть свои сделки → числа из НИХ, а не прочерки", () => { // Схлопывание пустой оболочки не должно гасить реальную выборку: маппер // обязан упасть на actual_deals, чьи строки видно в таблице ниже KPI. @@ -119,8 +135,27 @@ describe("05 РЫНОК — футер таблицы сделок считае render(); - expect(screen.getByText("Показано 3 фактических сделок")).toBeTruthy(); - expect(screen.queryByText(/Показано \d+ из \d+ фактических/)).toBeNull(); + // («Показано N из M объявлений» над таблицей аналогов — законный футер + // СВОЕЙ выборки, поэтому проверяется именно подпись под сделками.) + const footer = screen.getByText(/Показано \d+ .*сделк/); + expect(footer.textContent).toBe("Показано 3 фактические сделки"); + expect(footer.textContent).not.toMatch(/\sиз\s/); + }); + + it("04 ПРОДАЖИ — футер ДКП-таблицы тоже считает свою выборку", () => { + // Строки тут — пары ДКП↔объявление, а прежний знаменатель брался из + // коридора street-deals: 2 пары против count=7, а при пустом коридоре + // (после гашения нулей) выходило «Показано 2 из —». + const data = mapHistory( + null, + { ...EMPTY_STREET_DEALS, count: 7, median_price_rub: 12_000_000 }, + { ...FIXTURE_SALES, pairs: FIXTURE_SALES.pairs.slice(0, 2) }, + ); + + render(); + + expect(screen.getByText(/Показано 2 сделки/)).toBeTruthy(); + expect(screen.queryByText(/Показано \d+ из/)).toBeNull(); }); }); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index eca517da..3d1167b3 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -1270,7 +1270,7 @@ export function mapResultPanel( // та же, что #2629 дал карточке 1 над этой полосой («РЕКОМЕНДОВАННАЯ ЦЕНА // ПО СДЕЛКАМ РОСРЕЕСТРА»), чтобы подпись и число читались одной парой. label: dealsOnlyPrice - ? ["РЕКОМЕНДОВАННЫЙ ДИАПАЗОН", "ПО СДЕЛКАМ РОСРЕЕСТРА (ДКП)"] + ? ["РЕКОМЕНДОВАННЫЙ ДИАПАЗОН", "ПО СДЕЛКАМ РОСРЕЕСТРА"] : ["ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ", "(БЕЗ УЧЁТА РЕМОНТА)"], median: `${fmtMln(e.median_price_rub)} млн ₽`, lo: `${fmtMln(e.range_low_rub)} млн`, @@ -2240,6 +2240,15 @@ export function mapSources( // §M8 — единая формулировка дельт: всё считается «относительно цен // объявлений» (deltaPct = медиана сделок / медиана объявлений − 1). delta: deltaPct != null ? `${fmtPct(deltaPct)} к цене объявления` : "—", + // #3320 — знак → цвет, тот же признак, что у пар ДКП↔объявление в 04 + // (deltaColor у DkpRowData): продали дороже объявления — success, + // дешевле — danger, числа нет — нейтральный цвет плитки. + deltaColor: + deltaPct == null + ? undefined + : deltaPct < 0 + ? tokens.danger + : tokens.success, range: mlnRangeSp(dealLo, dealHi), }, }; diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts index 2155e18b..61e4b8af 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/types.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/types.ts @@ -227,7 +227,19 @@ export interface MarketAds { } export interface MarketDeals { - kpi: { count: string; median: string; delta: string; range: string }; + kpi: { + count: string; + median: string; + delta: string; + range: string; + /** + * #3320 — цвет подписи дельты по её знаку (как deltaColor у DkpRowData): + * сделки выше объявлений — success, ниже — danger, «—» — undefined + * (плитка красит нейтральным по умолчанию). Раньше цвет был захардкожен + * зелёным и красил им же и минус, и прочерк. + */ + deltaColor?: string; + }; } // ---- OVERLAY: АНАЛИТИКА ДОМА ---------------------------------------------- From 6a2d873c707772b269b31c08dca8c08d5dadd85f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:51:47 +0500 Subject: [PATCH 010/121] =?UTF-8?q?fix(rosseti):=20=D1=81=D1=87=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BE=D1=80=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=D1=82=D1=83=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=20?= =?UTF-8?q?=D0=B2=20SQL=20=D1=82=D0=B5=D0=BC=20=D0=B6=D0=B5=20double,=20?= =?UTF-8?q?=D1=87=D1=82=D0=BE=20=D0=B2=20=D0=BF=D0=B8=D1=82=D0=BE=D0=BD?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #3329. round(ST_X(geom)::numeric * 100000) округляет по кратчайшему десятичному представлению float8, а питон — по двоичному double: расхождение на 0.19% реальных координат (761 из 400000), напр. 64.423605 → питон 6442360 (6442360.499999999), numeric-путь 6442361. Каждое расхождение = вечный дубль ЦП, который сам не зарастёт — миграция применяется один раз (_schema_migrations). Теперь в SQL sign/floor/abs над float8 без каста в numeric: IEEE754 бит в бит как math.floor в питоне. test_coord_e5 брал 60.123455, где двоичное и десятичное округление совпадают — защита, которая не защищает. Добавлено расходящееся значение 64.423605. RAISE WARNING при rows_after > 700 заменён на RAISE EXCEPTION: warning не останавливает прогон, файл помечался бы applied навсегда вместе с дублями. Refs #3322 --- .../site_finder/rosseti_wfs_loader.py | 5 +++- .../tests/test_connection_capacity_loaders.py | 12 +++++++-- data/sql/99c_power_supply_centers_dedup.sql | 26 ++++++++++++++----- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/backend/app/services/site_finder/rosseti_wfs_loader.py b/backend/app/services/site_finder/rosseti_wfs_loader.py index 5f62d6a5..d586a4b3 100644 --- a/backend/app/services/site_finder/rosseti_wfs_loader.py +++ b/backend/app/services/site_finder/rosseti_wfs_loader.py @@ -107,7 +107,10 @@ def _coord_e5(value: float | None) -> str: Целое, а не форматированный float: ключ обязан совпадать байт-в-байт с SQL- бэкфиллом (99c), а текстовое представление double в питоне и в PG разное. - ``floor(|v|*1e5 + 0.5)`` со знаком = ``round(numeric)`` в PG (half-away-from-zero). + Округление ДВОИЧНОЕ (по значению double, не по десятичному представлению): + 64.423605*1e5 == 6442360.499999999 → 6442360, хотя «по десятичному» было бы + 6442361. В 99c та же семантика: floor/abs/sign над float8, БЕЗ каста в numeric + (каст округляет по кратчайшему десятичному repr и расходится в 0.19% координат). """ if value is None: return "" diff --git a/backend/tests/test_connection_capacity_loaders.py b/backend/tests/test_connection_capacity_loaders.py index 705b5c0d..ecc35091 100644 --- a/backend/tests/test_connection_capacity_loaders.py +++ b/backend/tests/test_connection_capacity_loaders.py @@ -139,8 +139,16 @@ def test_stable_external_id_no_geometry() -> None: assert rw._stable_external_id(f, f["properties"]) == "h:eb91917f35aff23f" -def test_coord_e5_half_away_from_zero() -> None: - """Квантование как round(numeric) в PG: полукругление ОТ нуля, симметрично знаку.""" +def test_coord_e5_rounds_on_binary_double_not_decimal() -> None: + """Округление по ДВОИЧНОМУ double, не по десятичному представлению. + + 64.423605*1e5 == 6442360.499999999 → 6442360; «по десятичному» вышло бы 6442361 + (так считал бы round(ST_X(geom)::numeric*100000) — расхождение на 0.19% реальных + координат). 99c обязана давать те же цифры, поэтому семантика закреплена тестом. + """ + assert rw._coord_e5(64.423605) == "6442360" + assert rw._coord_e5(-64.423605) == "-6442360" + # 60.123455*1e5 == ровно 6012345.5 → полукругление ОТ нуля, симметрично знаку. assert rw._coord_e5(60.123455) == "6012346" assert rw._coord_e5(-60.123455) == "-6012346" assert rw._coord_e5(60.6) == "6060000" diff --git a/data/sql/99c_power_supply_centers_dedup.sql b/data/sql/99c_power_supply_centers_dedup.sql index 581fc4ec..f8f4e963 100644 --- a/data/sql/99c_power_supply_centers_dedup.sql +++ b/data/sql/99c_power_supply_centers_dedup.sql @@ -23,9 +23,17 @@ -- seed = sc_name_norm || '|' || voltage_class || '|' || lon_e5 || '|' || lat_e5 -- external_id = 'h:' || left(hex(sha256(utf8(seed))), 16) -- где lon_e5/lat_e5 — координата в единицах 1e-5 градуса (~1 м), округление --- round() = half-away-from-zero (в питоне ровно то же: floor(|v|*1e5+0.5) со знаком); --- пустая строка, если geom отсутствует. Целые, а не форматированный float — --- текстовое представление double в питоне и в PG различается. +-- floor(|v|*1e5 + 0.5) со знаком — ДВОИЧНОЕ, ровно как в питоне; пустая строка, +-- если geom отсутствует. Целые, а не форматированный float — текстовое +-- представление double в питоне и в PG различается. +-- +-- ПОЧЕМУ НЕ round(...::numeric): каст float8→numeric берёт кратчайшее десятичное +-- представление, и округление идёт по нему, а не по двоичному double. На реальных +-- координатах расходится в 0.19% случаев (замер: 761 из 400000), напр. 64.423605 +-- → питон 6442360 (двоичное 6442360.499999999), numeric-путь 6442361. Каждое +-- расхождение = вечный дубль ЦП, который сам не зарастёт: миграция применяется +-- один раз (_schema_migrations). Поэтому в SQL считаем ТЕМ ЖЕ double: floor/abs/ +-- sign над float8 — это IEEE754, бит в бит как math.floor в питоне. -- sha256, а не sha1: sha256 встроен в PG16, sha1 потребовал бы pgcrypto. -- -- Байт-в-байт совпадение с питоном держится на том, что SQL НИЧЕГО не нормализует @@ -68,9 +76,11 @@ BEGIN sc_name_norm || '|' || coalesce(voltage_class, '') || '|' || CASE WHEN geom IS NULL THEN '' - ELSE round(ST_X(geom)::numeric * 100000)::text END + ELSE (sign(ST_X(geom)) + * floor(abs(ST_X(geom)) * 100000 + 0.5))::bigint::text END || '|' || CASE WHEN geom IS NULL THEN '' - ELSE round(ST_Y(geom)::numeric * 100000)::text END, + ELSE (sign(ST_Y(geom)) + * floor(abs(ST_Y(geom)) * 100000 + 0.5))::bigint::text END, 'UTF8' )), 'hex' @@ -116,9 +126,11 @@ BEGIN rows_before, names_before, rows_after, names_after, deleted, rekeyed; -- 700 — потолок здравого смысла: источник отдаёт ~488 ЦП по области. - -- Превышение = формула ключа не схлопнула дубли, смотреть глазами. + -- Превышение = формула ключа не схлопнула дубли. EXCEPTION, а не WARNING: + -- иначе файл пометится applied навсегда, а дубли останутся. Откат всей + -- транзакции ничего не теряет и оставляет миграцию непринятой до разбора. IF rows_after > 700 THEN - RAISE WARNING '#3322: после дедупа осталось % строк (ожидалось ~481-488) — проверить формулу ключа', rows_after; + RAISE EXCEPTION '#3322: после дедупа осталось % строк (ожидалось ~481-488) — формула ключа не схлопнула дубли, транзакция откачена', rows_after; END IF; END $$; From 41f21c4969c63c8125267f45c441c8c98f713ce2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:53:02 +0500 Subject: [PATCH 011/121] =?UTF-8?q?docs(scraper-kit):=20=D0=BE=D0=B1=D0=BE?= =?UTF-8?q?=D1=81=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20#3319=20?= =?UTF-8?q?=E2=80=94=20=D1=87=D1=82=D0=BE=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=20=D1=82=D0=B5=D1=80=D1=8F=D0=BB=D0=BE=20=D1=87=D0=B5?= =?UTF-8?q?=D0=BA=D0=BF=D0=BE=D0=B8=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью опровергло формулировку «точку терял каждый финализатор»: все четыре писателя в runs.py мержат jsonb (`counters || :counters`), записанный ключ переживал mark_done/mark_banned/mark_failed. Правка закрывает выходы РАНЬШЕ первого end-of-anchor heartbeat (cancel/дрейн на первом якоре, ранний done #1950 на якоре №1) — комментарий и докстринг переписаны на это. Замер «0 из 67 за 60 дней» назван тем, чем он является: запись появилась 26.08.2026 (#3074) при такте avito 7 суток, выборка почти вся из эры без механизма. Плюс тест на прогон без ключей (эра до #3074) — метка дрейна не меняет вердикт «нечего подхватывать». --- .../tests/test_3319_citysweep_checkpoint.py | 23 ++++++++++++++----- .../src/scraper_kit/orchestration/pipeline.py | 18 ++++++++++----- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py index 6ae05a73..a238119b 100644 --- a/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py +++ b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py @@ -1,11 +1,15 @@ """Чекпоинт avito_city_sweep доживает до финализатора (#3319). -Прод-факт, из которого выросла задача: 0 из 67 прогонов за 60 дней имеют в -counters ключ done_buckets. Механизм #3074 (запись точки) и механизм #930 -(подхват точки) существуют оба, но между ними нет ни одного прогона: точку -писала ровно одна строка внутри цикла якорей, а КАЖДЫЙ выход из прогона -(mark_done — включая ранний выход #1950 «SERP собран, detail заблокирован», — -mark_banned, mark_failed) отдавал голый counters.to_dict() без неё. +Точку писала ровно одна строка — end-of-anchor heartbeat в конце итерации цикла +якорей. Финализаторы её не стирали (все писатели в runs.py мержат jsonb: +`counters || :counters`), дыра в другом: выходы, случившиеся РАНЬШЕ первой такой +записи, точки не оставляли вовсе — cancel/SIGTERM-дрейн на границе первого якоря +и ранний done #1950 («SERP собран, detail заблокирован») на якоре №1. Ими и +кончается типичный прод-прогон с `anchors_done: 1` из 5. + +Замер «0 из 67 прогонов за 60 дней несут done_buckets» тут НЕ доказательство: +строка записи появилась только 26.08.2026 (#3074) при такте avito 7 суток — +выборка почти целиком из эры, где механизма не существовало. Три инварианта, ради которых тест: 1. done-выход несёт done_buckets — иначе точка существует только в логе. @@ -212,3 +216,10 @@ def test_drained_done_is_resumable_but_clean_done_is_not() -> None: resume_from, verdict = _resume_decision(_prev_run(ckpt)) assert resume_from is None, "полный обход подхватывать нечего" assert verdict["resume_reason"] == "status_done" + + # Прогон из эры до #3074: ключей нет вовсе — метка дрейна не должна менять + # вердикт «нечего подхватывать» на что-то другое. + _, verdict = _resume_decision(_prev_run({})) + assert verdict["resume_reason"] == "status_done" + _, verdict = _resume_decision(_prev_run({"interrupted": 1})) + assert verdict["resume_reason"] == "no_checkpoint" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 2cbacf9c..654927ef 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -1186,12 +1186,18 @@ async def run_avito_city_sweep( def _ckpt(**extra: Any) -> dict[str, Any]: """Счётчики прогона ВМЕСТЕ с чекпоинтом — payload любого выхода (#3319). - До этого точку писала ровно одна строка внутри цикла якорей, а все - финализаторы (mark_done/mark_banned/mark_failed, включая ранний выход - #1950 «SERP OK, detail заблокирован») отдавали голый `counters.to_dict()`. - Точка держалась исключительно на jsonb-мерже в runs.py — на свойстве - ЧУЖОГО модуля, которого этот файл ничем не проверяет; выход, случившийся - раньше первой записи (или писатель без мержа), терял её молча. + До этого точку писала ровно одна строка — end-of-anchor heartbeat в конце + итерации цикла. Финализаторы её НЕ стирали: все четыре писателя в runs.py + мержат jsonb (`counters || :counters`), уже записанный ключ переживал и + mark_done, и mark_banned, и mark_failed. Закрывается другая дыра — выходы, + случившиеся РАНЬШЕ первой такой записи: cancel/SIGTERM-дрейн на границе + первого якоря и ранний done #1950 («SERP собран, detail заблокирован») на + якоре №1. Именно им и кончается типичный прод-прогон, у которого + `anchors_done: 1` из 5. + + Замер «0 из 67 прогонов за 60 дней несут done_buckets» сам по себе этого НЕ + доказывает: строка записи появилась только 26.08.2026 (#3074), а такт avito + — 7 суток, так что выборка почти целиком из эры, где механизма не было. """ return {**counters.to_dict(), "done_buckets": sorted(_done_anchors), **extra} From cde95aa9f6ebde0886be0171f2ed832a3cee2746 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:59:14 +0500 Subject: [PATCH 012/121] =?UTF-8?q?test(tradein):=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B4-=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9=20=D0=B2=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=BA=D0=B5=20#3316=20+=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D1=80=D0=BE=D0=BC=D0=B8=D1=81=D1=81=20=D1=84?= =?UTF-8?q?=D0=BE=D0=BB=D0=B1=D1=8D=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review PR #3331: приёмка «роли не изменились» гонялась с ПУСТЫМ реестром, а в проде строка в БД есть у 12 из 13 юзеров и DB-роль ИНАЯ (kopylov: manager при YAML pilot, user1: employee при YAML pilot). Добавлены два кейса именно этой конфигурации: * YAML pilot + реестр employee → employee, и scope не поехал: allow/deny DB_ROLE_PATHS['employee'] сверяются со списками роли pilot из roles.yaml целиком — дрейф ЛЮБОГО из двух списков теперь красный тест, а не тихо потерянный/выданный раздел в проде; * YAML pilot + реестр manager → manager, и лишних путей на tradein-периметре нет: manager отличается от employee ровно префиксом /api/v1/team/** (вне /trade-in/**), deny-списки совпадают. Докстринг `_registry_role`: зафиксирован компромисс — при недоступном реестре фолбэк временно возвращает авторитетность roles.yaml, то есть состояние, которое фикс и лечит. Сегодня безопасно (прод-коллизий имён нет, новые закрыты 409-гвардом create_employee); появится коллизия — ветку менять на fail-closed. --- tradein-mvp/backend/app/core/auth.py | 9 ++++ .../backend/tests/test_role_single_source.py | 46 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/tradein-mvp/backend/app/core/auth.py b/tradein-mvp/backend/app/core/auth.py index 0906875b..512b924a 100644 --- a/tradein-mvp/backend/app/core/auth.py +++ b/tradein-mvp/backend/app/core/auth.py @@ -180,6 +180,15 @@ def _registry_role(username: str) -> str | None: пустая, либо реестр вообще недоступен. Во всех трёх случаях решение остаётся за roles.yaml — падение БД не имеет права выключить legacy-вход. + ⚠️ Осознанный компромисс (#3316 review): последняя ветка — недоступный + реестр — на время сбоя ВОЗВРАЩАЕТ авторитетность roles.yaml, то есть ровно + то состояние, которое этот фикс и лечит. Сегодня это безопасно: коллизий + имён между реестром и YAML на проде нет, а новые закрыты 409-гвардом в + `team.create_employee`. Если коллизия всё же появится (ручной INSERT в + реестр, расширение roles.yaml) — сбой БД станет окном эскалации, и тогда + эту ветку надо менять на fail-closed (отказ вместо YAML-роли), а не + дописывать проверки у вызывающих. + Имя таблицы берётся из фиксированного словаря `identity_schema()`, значение едет bind-параметром: снаружи в SQL не попадает ничего. """ diff --git a/tradein-mvp/backend/tests/test_role_single_source.py b/tradein-mvp/backend/tests/test_role_single_source.py index da358dcd..c60e00a8 100644 --- a/tradein-mvp/backend/tests/test_role_single_source.py +++ b/tradein-mvp/backend/tests/test_role_single_source.py @@ -129,6 +129,52 @@ def test_legacy_yaml_users_keep_their_roles(registry: Callable[..., Any]) -> Non assert {username: auth.get_role(username) for username in users} == users +def _yaml_pilot() -> str: + for username, role in _yaml_users().items(): + if role == "pilot": + return username + pytest.skip("в auth/roles.yaml нет ни одного pilot — тест неприменим") + + +def test_prod_config_pilot_in_yaml_employee_in_registry(registry: Callable[..., Any]) -> None: + """Прод-конфигурация 12 из 13 аккаунтов: строка в реестре ЕСТЬ и роль там иная. + + Реестр главнее (`employee`), а объём прав от этого не меняется: scope + DB-роли `employee` обязан совпадать с вчерашним YAML-scope роли `pilot`. + Списки сверяются целиком — дрейф ЛЮБОГО из двух ловится здесь, а не + тихой потерей/выдачей раздела в проде. + """ + from app.services.auth_session import DB_ROLE_PATHS + + registry("employee") + assert auth.get_role(_yaml_pilot()) == "employee" + + pilot = auth._load_roles_config()["roles"]["pilot"] + allow, deny = DB_ROLE_PATHS["employee"] + assert sorted(allow) == sorted(pilot["paths"]) + assert sorted(deny) == sorted(pilot["deny"] or []) + + +def test_prod_config_pilot_in_yaml_manager_in_registry(registry: Callable[..., Any]) -> None: + """Конфигурация kopylov: YAML pilot + реестр manager → manager. + + Лишних путей на tradein-периметре это не даёт: manager отличается от + employee ровно одним префиксом `/api/v1/team/**` (дашборд «Команды», + ВНЕ `/trade-in/**`), а deny-списки совпадают. + """ + from app.services.auth_session import DB_ROLE_PATHS + + registry("manager") + assert auth.get_role(_yaml_pilot()) == "manager" + + emp_allow, emp_deny = DB_ROLE_PATHS["employee"] + mgr_allow, mgr_deny = DB_ROLE_PATHS["manager"] + extra = set(mgr_allow) - set(emp_allow) + assert extra == {"/api/v1/team/**"} + assert not any(p.startswith("/trade-in") for p in extra) + assert sorted(mgr_deny) == sorted(emp_deny) + + def test_registry_failure_falls_back_to_yaml(registry: Callable[..., Any]) -> None: """Реестр недоступен → legacy-вход продолжает работать по YAML.""" registry(_MISSING) From 18e0f1d9991342b39ae37d243c6ba965dd7c57e4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 16:46:44 +0500 Subject: [PATCH 013/121] =?UTF-8?q?test(tradein):=20=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20test=5Frbac.?= =?UTF-8?q?py=20=D0=BE=D1=82=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=B0=20(#3?= =?UTF-8?q?316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI-красное на голове ветки: 4 теста в tests/test_rbac.py ждали YAML-роль (kopylov=pilot, user1=pilot), а в CI-базе реестр засеян миграцией 193 (kopylov=manager, user*=employee) — DB-first резолвер честно отдавал роль из БД. Локально те же тесты были зелёными ровно потому, что БД нет и работал YAML-fallback: результат файла зависел от ОКРУЖЕНИЯ, а такой тест не проверяет ничего. Чинится не подгонкой чисел в ассертах, а изоляцией: файл проверяет ИМЕННО legacy-путь roles.yaml (разбор файла, globs, guard и /me на trusted-header), и теперь заявляет это явно — autouse-фикстура `_legacy_yaml_only` глушит реестр (`_registry_role` → None). Ассерты на YAML-роли после этого законны в любом окружении. Приоритет реестра, эквивалентность scope employee↔pilot и конфигурация kopylov (DB manager + YAML pilot) покрыты отдельно — tests/test_role_single_source.py. Проверено обоими способами: полный `pytest tests` без сида и он же с плагином-имитацией засеянного реестра (подменяется тот же шов, что и в проде, `identity_store.identity_session`) — 5290 passed, 35 skipped в обоих. --- tradein-mvp/backend/tests/test_rbac.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tradein-mvp/backend/tests/test_rbac.py b/tradein-mvp/backend/tests/test_rbac.py index ea0837dd..dc3ecc0b 100644 --- a/tradein-mvp/backend/tests/test_rbac.py +++ b/tradein-mvp/backend/tests/test_rbac.py @@ -42,6 +42,23 @@ def _reset_auth_cache() -> None: auth_mod.reset_cache_for_tests() +@pytest.fixture(autouse=True) +def _legacy_yaml_only(monkeypatch: pytest.MonkeyPatch) -> None: + """Реестр в ЭТОМ файле молчит — здесь проверяется legacy-путь roles.yaml. + + #3316 сделал `get_role` DB-first (реестр → YAML-fallback), и без этой + изоляции результат файла зависел бы от ОКРУЖЕНИЯ: локально без БД шла + YAML-ветка и всё было зелено, а в CI, где реестр засеян миграцией 193 + (kopylov=manager, user*=employee), те же ассерты краснели. Тест, который + отвечает по-разному в двух окружениях, не проверяет ничего. + + Здесь закреплена ровно YAML-семантика (разбор файла, globs, поведение + guard'а и /me на trusted-header пути); DB-first, приоритет реестра и + эквивалентность scope employee↔pilot покрыты tests/test_role_single_source.py. + """ + monkeypatch.setattr(auth_mod, "_registry_role", lambda username: None) + + # --------------------------------------------------------------------------- # Test app — использует РЕАЛЬНЫЙ rbac_guard (app/core/rbac.py), а не копию. # --------------------------------------------------------------------------- From 13c4420d1fce902deda6db0bf14050471302dfc4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 16:48:50 +0500 Subject: [PATCH 014/121] =?UTF-8?q?fix(deploy):=20=D0=B3=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=20=D0=9F=D0=A2=D0=98=D0=A6=D0=AB=20=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20health=20worker/beat/frontend,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20curl=20backend?= =?UTF-8?q?=20(#3324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Деплой ПТИЦЫ проверял ровно один признак — `curl backend /health`. Worker и beat не имели healthcheck'а в compose вообще, у frontend была TCP-only проба, которую деплой не читал. Crash-loop воркера, вставший beat и фронт с 500 уезжали зелёным деплоем: признак «прод жив» отсутствовал в старом состоянии ровно так же, как в новом. compose: worker — `celery inspect ping -d celery@$(hostname)` (адресно в ЭТОТ узел, без -d ответил бы любой воркер на брокере); beat — свежесть shelve-файла расписания (на inspect ping beat не отвечает; поминутная beat-задача гарантирует обновление mtime не реже ~3 мин при sync_every=180 с, порог 10 мин = 3× запас). Фиктивной `true`-пробы нет: она повторяла бы State.Running. deploy.yml: после подъёма — ожидание healthy для backend/worker/beat/frontend через docker inspect, HTTP-статус фронта (TCP мало), сверка running-образа с локально скачанным $IMAGE_TAG (приём #2679 из deploy-tradein). Каждая проверка при провале печатает контейнер, статус, healthcheck-лог и хвост логов; итог — явный rc в логе и exit им же. Порядок «миграции до подъёма кода» не тронут, `up -d --wait` не используется намеренно (подъём разбит на несколько up). --- .forgejo/workflows/deploy.yml | 149 ++++++++++++++++++++++++++++++++++ docker-compose.prod.yml | 38 +++++++++ 2 files changed, 187 insertions(+) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 8b8edc7a..a6ce13a3 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -1037,6 +1037,155 @@ jobs: fi echo "→ backend healthy на /health." + # ── Гейт деплоя (#3324) ──────────────────────────────────────────── + # ДО этого блока весь гейт ПТИЦЫ = один `curl backend /health` выше: + # worker/beat не проверялись вообще (у них и healthcheck'а в compose не + # было), у frontend была только TCP-проба внутри контейнера, которую + # деплой не читал. То есть crash-loop воркера, вставший beat и фронт, + # отдающий 500, уезжали ЗЕЛЁНЫМ деплоем. Дисциплина перенесена из + # deploy-tradein.yml (health каждого сервиса + HTTP фронта + сверка + # образов), сюда добавлено чтение docker-health, потому что у ПТИЦЫ + # пробы теперь описаны в compose. + # `up -d --wait` НЕ используется намеренно: подъём здесь разбит на + # несколько `up` (bulk без worker'а → guard #3029 → caddy → forwarder), + # и общий --wait ждал бы ещё и профильные/инфраструктурные сервисы, + # ломая порядок «миграции до подъёма кода». Читаем состояние явно. + # Все проверки выполняются ДО выхода (не падаем на первой) — один + # прогон обязан показать ВСЕ поломанные сервисы, а не первый по списку. + cid() { docker compose -p gendesign -f docker-compose.prod.yml ps -aq "$1" 2>/dev/null || true; } + + diagnose() { # $1 сервис, $2 id контейнера (может быть пустым) + local svc="$1" c="$2" + echo "── ДИАГНОЗ $svc ──" + if [ -z "$c" ]; then + echo " контейнера нет вообще (docker compose ps -aq $svc пусто)" + return 0 + fi + docker inspect -f ' state={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}{{end}} restarts={{.RestartCount}} exit_code={{.State.ExitCode}} image={{.Image}}' "$c" || true + echo " healthcheck log:" + docker inspect -f '{{json .State.Health}}' "$c" 2>/dev/null | head -c 2000 || true + echo "" + echo " последние 40 строк логов $svc:" + docker logs --tail 40 "$c" 2>&1 | sed 's/^/ /' || true + } + + wait_healthy() { # $1 сервис, $2 таймаут, с + local svc="$1" deadline="$2" c="" status="" alive="" waited=0 + while [ "$waited" -lt "$deadline" ]; do + c="$(cid "$svc")" + if [ -n "$c" ]; then + status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none:{{.State.Status}}{{end}}' "$c" 2>/dev/null || echo '')" + case "$status" in + healthy) + echo "→ $svc healthy (за ${waited}s)" + return 0 + ;; + none:running) + # Контейнер без healthcheck-конфига = создан ДО этой правки + # compose и в этом прогоне не пересоздавался (штатный случай — + # worker, пропущенный guard'ом #3029). Валить деплой за это + # нельзя, но и молчать нельзя: падаем на «стабильный running» + # (двойное чтение, как tgbot/scraper в deploy-tradein.yml). + sleep 3 + alive="$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo unknown)" + if [ "$alive" = "running" ]; then + echo "→ $svc: healthcheck не сконфигурирован (контейнер не пересоздавался), running стабилен" + return 0 + fi + ;; + esac + fi + waited=$((waited + 3)) + sleep 3 + done + echo "ERROR (#3324): $svc не стал healthy за ${deadline}s (последний статус: '${status:-<контейнера нет>}') — деплой FAILED" + diagnose "$svc" "$c" + return 1 + } + + health_rc=0 + for gate_svc in backend worker beat frontend; do + wait_healthy "$gate_svc" 240 || health_rc=$? + done + + # Фронт: HTTP-СТАТУС, а не только «порт слушает». Compose-проба фронта + # намеренно TCP-only (в node:alpine нет ни curl, ни wget), и она не + # отличает живой Next.js от процесса, отдающего 500 на каждый запрос. + # Тянем с хоста через опубликованный 127.0.0.1:3000. basePath у ПТИЦЫ + # нет (frontend/next.config.*), корень — настоящий маршрут приложения. + # Годным считаем 2xx/3xx: редирект middleware'а на логин — это живой + # роутинг, а не поломка (тот же критерий, что `curl -f` в tradein). + fe_rc=0 + fe_code=000 + for i in $(seq 1 5); do + fe_code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://localhost:3000/ || echo 000)" + case "$fe_code" in + 2*|3*) break ;; + esac + sleep 3 + done + case "$fe_code" in + 2*|3*) echo "→ frontend отвечает HTTP $fe_code на /." ;; + *) + echo "ERROR (#3324): frontend на http://localhost:3000/ вернул '$fe_code' (000 = соединения нет) — деплой FAILED" + diagnose frontend "$(cid frontend)" + fe_rc=1 + ;; + esac + + # Сверка образов (приём #2679 из deploy-tradein.yml, адаптирован под + # ПТИЦУ). Здесь не одно «backend-семейство»: backend и beat бегут один + # образ gendesign-backend, worker и frontend — свои. Поэтому эталон не + # «образ backend'а», а то, что реально лежит локально под тегом + # $IMAGE_TAG после pull'а: контейнер, оставшийся на другом id, работает + # на старом коде при зелёном деплое. + # Гард свежести самого :latest в registry — отдельный шаг выше + # (scripts/check-latest-image-revision.sh, #2950); здесь проверяется + # следующее звено: доехал ли уже скачанный образ до контейнера. + check_image() { # $1 сервис, $2 репозиторий образа + local svc="$1" repo="$2" want run c + want="$(docker image inspect -f '{{.Id}}' "$repo:$IMAGE_TAG" 2>/dev/null || echo '')" + c="$(cid "$svc")" + run="$(docker inspect -f '{{.Image}}' "$c" 2>/dev/null || echo '')" + if [ -z "$want" ]; then + echo "ERROR (#3324): локально нет образа $repo:$IMAGE_TAG — сверять не с чем (pull не отработал?)" + return 1 + fi + if [ -z "$run" ]; then + echo "ERROR (#3324): контейнера сервиса $svc НЕТ — это не «отставший образ», а неполный стек" + return 1 + fi + if [ "$want" != "$run" ]; then + echo "ERROR (#3324): $svc ОТСТАЛ: работает на $run, а $repo:$IMAGE_TAG — это $want" + echo " лечение: docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps $svc" + diagnose "$svc" "$c" + return 1 + fi + echo "→ $svc на свежем $repo:$IMAGE_TAG ($run)" + } + + image_rc=0 + check_image backend ghcr.io/lekss361/gendesign-backend || image_rc=$? + check_image beat ghcr.io/lekss361/gendesign-backend || image_rc=$? + check_image frontend ghcr.io/lekss361/gendesign-frontend || image_rc=$? + # worker сверяем ТОЛЬКО если этот прогон его пересоздавал: guard #3029 + # намеренно оставляет worker на старом образе, пока идёт живой прогон + # скрейпа, и это уже отражено WARNING'ом выше. Падать здесь означало бы + # красить деплой за штатное поведение guard'а. + case " $WORKER_SERVICES " in + *" worker "*) check_image worker ghcr.io/lekss361/gendesign-worker || image_rc=$? ;; + *) echo "→ сверка образа worker'а пропущена: guard #3029 не пересоздавал его в этом прогоне (см. WARNING выше)" ;; + esac + + # Явный rc: «зелёная сводка» ниже печатается ДО выхода, поэтому итог + # обязан быть числом в логе, а не выводом из отсутствия ERROR-строк. + gate_rc=0 + [ "$health_rc" = 0 ] || gate_rc=1 + [ "$fe_rc" = 0 ] || gate_rc=1 + [ "$image_rc" = 0 ] || gate_rc=1 + echo "Гейт деплоя (#3324): health_rc=$health_rc frontend_rc=$fe_rc image_rc=$image_rc → rc=$gate_rc" + exit "$gate_rc" + # Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:` # молча (result=skipped), когда build падает (например, битый blob в # buildcache роняет `docker/build-push-action` — до ретрая выше, #2841). diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8f869960..76e9dd00 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -313,6 +313,25 @@ services: # /data/anton-sqlite/analysis.db). - /opt/gendesign/site-finder:/data/anton-sqlite:ro command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=8", "--queues=celery,scrape_kn,geo"] + # #3324: до этого у worker'а healthcheck'а не было ВООБЩЕ — контейнер в + # crash-loop'е (ImportError в новом коде, протухший uv.lock) уезжал зелёным + # деплоем: деплой смотрел только `curl backend /health`. + # Проба — `inspect ping` ИМЕННО В ЭТОТ узел (`-d celery@$(hostname)`, у нас + # nodename дефолтный: в command нет `-n`). Без `-d` ping вернул бы OK на + # ответ ЛЮБОГО воркера на брокере — мёртвый контейнер выглядел бы живым. + # Ответ на ping = жив parent-процесс и держится соединение с Redis, т.е. + # ровно та связность, без которой очереди не разбираются. `$$` — экранировка + # для compose (в контейнер уезжает литеральное `$(hostname)`). + # Интервал 60s (не 30s как у backend): каждая проба — отдельный запуск + # celery-CLI с импортом приложения, дешёвым его не назовёшь. + # start_period 90s: холодный старт worker'а с Chromium-образа заметно + # медленнее backend'а. 3 промаха подряд (~3 мин) → unhealthy. + healthcheck: + test: ["CMD-SHELL", "celery -A app.workers.celery_app inspect ping -d celery@$$(hostname) -t 10 >/dev/null 2>&1"] + interval: 60s + timeout: 30s + retries: 3 + start_period: 90s # #976 cross-DB ETL tradein→gendesign: worker запускает etl_newbuilding_crossload task, # которому нужен прямой TCP-доступ к tradein-postgres через gendesign_shared. # default — обязательно явно, иначе сервис выпадет из дефолтной сети. @@ -336,6 +355,25 @@ services: # хранит только last_run_at для periodic tasks — потеря на restart OK, # beat перестроит из `celery_app.conf.beat_schedule` на старте. command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info", "--schedule=/tmp/celerybeat-schedule"] + # #3324: beat тоже жил без healthcheck'а. На `inspect ping` beat НЕ отвечает + # (remote control — свойство воркера, не планировщика), поэтому проба другая: + # СВЕЖЕСТЬ shelve-файла расписания. Celery beat синкует его на диск не реже + # чем раз в `Scheduler.sync_every` = 180 с — но только когда в этом окне была + # отправлена задача. У нас в расписании есть поминутная (nspd-geo-zombie- + # cleanup, `* * * * *`) и двухминутная задачи, так что живой beat обновляет + # mtime примерно каждые 3 минуты, а вставший — не обновляет вовсе. Порог 10 + # минут = 3× запас к этой каденции. + # Почему glob `celerybeat-schedule*`: имя на диске зависит от того, какой + # backend выберет shelve/dbm в образе (gnu → тот же файл, dumb → .dat/.dir). + # Почему не `pgrep`/`true`: PID-1 процесс жив ровно пока жив контейнер — + # такая проба повторяет `State.Running` и не ловит подвисший планировщик. + # `grep -q .`: сам find возвращает 0 и когда не нашёл ничего. + healthcheck: + test: ["CMD-SHELL", "find /tmp -maxdepth 1 -name 'celerybeat-schedule*' -mmin -10 | grep -q ."] + interval: 60s + timeout: 10s + retries: 3 + start_period: 120s # ── infra-postgres: лёгкий кластер ОСТАЮЩЕЙСЯ инфраструктуры (#3061) ──────── # Переезд продукта Beget (46.173.16.127) → Selectel Poincare (188.124.37.140), From 1b1977efa317116aebc9cd3ce19c1b428358a9e2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 16:58:40 +0500 Subject: [PATCH 015/121] =?UTF-8?q?fix(deploy):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=B9=D1=82=D0=B0=20=D0=9F=D0=A2=D0=98=D0=A6=D0=AB=20=E2=80=94?= =?UTF-8?q?=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B0=D1=83=D1=82=20=D1=81=D0=B5?= =?UTF-8?q?=D1=81=D1=81=D0=B8=D0=B8,=20crash-loop,=20=D0=BC=D0=BD=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?id=20(#3324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit command_timeout: 30m — дефолт appleboy/ssh-action 10m короче worst-case гейта (~17 мин), сессию убило бы посреди диагноза и авария читалась бы обрывом связи. Деградация «нет health-конфига» требовала лишь running дважды: crash-loop с временем жизни больше паузы проходил как стабильный (обе проверки видят running, просто это разные жизни контейнера). Теперь сверяется RestartCount до/после окна 15 с; окно учитывается в счётчике ожидания, иначе таймаут 240 с растянулся бы на ~24 мин и упёрся в command_timeout. cid(): `ps -aq` возвращает несколько id при залежавшемся exited-контейнере → docker inspect падает → пустой статус → ложный красный. tail -n1. worker healthcheck: убран `2>&1` (глушил причину, которую деплой печатает из .State.Health.Log), retries 3→5 — при interval 60s тройка промахов = 3 минуты, столько длится обычный флап Redis, а из unhealthy контейнер сам не выходит. --- .forgejo/workflows/deploy.yml | 31 ++++++++++++++++++++++++++----- docker-compose.prod.yml | 11 ++++++++--- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index a6ce13a3..9b2c1866 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -596,6 +596,11 @@ jobs: # #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy # оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение. fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} + # #3324: дефолт appleboy/ssh-action — command_timeout 10m, а worst-case + # гейта в конце скрипта ~17 мин (4 сервиса × 240s ожидания healthy + + # фронт + diagnose). Сессию убило бы посреди печати диагноза, и авария + # выглядела бы обрывом связи, а не мёртвым контейнером. + command_timeout: 30m envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS,WORKER_RECREATE_GUARD,WORKER_GUARD_MAX_SKIP_H script: | set -euo pipefail @@ -1052,7 +1057,11 @@ jobs: # ломая порядок «миграции до подъёма кода». Читаем состояние явно. # Все проверки выполняются ДО выхода (не падаем на первой) — один # прогон обязан показать ВСЕ поломанные сервисы, а не первый по списку. - cid() { docker compose -p gendesign -f docker-compose.prod.yml ps -aq "$1" 2>/dev/null || true; } + # tail -n1: у сервиса может остаться залежавшийся exited-контейнер, и + # тогда `ps -aq` вернёт НЕСКОЛЬКО id через \n — `docker inspect` с таким + # аргументом падает, статус приходит пустым и гейт краснеет на ровном + # месте. Последний id — самый свежий контейнер сервиса. + cid() { docker compose -p gendesign -f docker-compose.prod.yml ps -aq "$1" 2>/dev/null | tail -n1 || true; } diagnose() { # $1 сервис, $2 id контейнера (может быть пустым) local svc="$1" c="$2" @@ -1070,7 +1079,7 @@ jobs: } wait_healthy() { # $1 сервис, $2 таймаут, с - local svc="$1" deadline="$2" c="" status="" alive="" waited=0 + local svc="$1" deadline="$2" c="" status="" alive="" r0="" r1="" waited=0 while [ "$waited" -lt "$deadline" ]; do c="$(cid "$svc")" if [ -n "$c" ]; then @@ -1086,12 +1095,24 @@ jobs: # worker, пропущенный guard'ом #3029). Валить деплой за это # нельзя, но и молчать нельзя: падаем на «стабильный running» # (двойное чтение, как tgbot/scraper в deploy-tradein.yml). - sleep 3 + # Одного `running` дважды НЕДОСТАТОЧНО: crash-loop с временем + # жизни больше паузы читается как «стабилен» — контейнер оба + # раза running, просто это разные его жизни. Поэтому вместе со + # статусом сверяем RestartCount: изменился за окно = именно + # тот дефект, ради которого этот гейт и писался. + r0="$(docker inspect -f '{{.RestartCount}}' "$c" 2>/dev/null || echo '')" + sleep 15 alive="$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo unknown)" - if [ "$alive" = "running" ]; then - echo "→ $svc: healthcheck не сконфигурирован (контейнер не пересоздавался), running стабилен" + r1="$(docker inspect -f '{{.RestartCount}}' "$c" 2>/dev/null || echo '')" + if [ "$alive" = "running" ] && [ -n "$r0" ] && [ "$r0" = "$r1" ]; then + echo "→ $svc: healthcheck не сконфигурирован (контейнер не пересоздавался), running стабилен (RestartCount=$r0 не изменился за 15s)" return 0 fi + # waited растёт на длину ЭТОЙ паузы тоже — иначе таймаут + # 240s превратился бы в ~24 минуты реального ожидания и упёрся + # бы в command_timeout SSH-сессии. + waited=$((waited + 15)) + echo " $svc: running нестабилен — status='$alive', RestartCount ${r0:-<нет>}→${r1:-<нет>} (контейнер перезапускался внутри окна наблюдения); продолжаю ждать" ;; esac fi diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 76e9dd00..03a76d08 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -325,12 +325,17 @@ services: # Интервал 60s (не 30s как у backend): каждая проба — отдельный запуск # celery-CLI с импортом приложения, дешёвым его не назовёшь. # start_period 90s: холодный старт worker'а с Chromium-образа заметно - # медленнее backend'а. 3 промаха подряд (~3 мин) → unhealthy. + # медленнее backend'а. + # stderr НЕ глушим: docker хранит вывод пробы в .State.Health.Log, и деплой + # печатает его в диагнозе — с `2>&1` там была бы пустота вместо причины. + # retries 5 (не 3): при interval 60s тройка промахов = 3 минуты, столько + # длится обычный флап Redis, а из unhealthy контейнер сам не выходит по + # restart-политике — следующий деплой краснел бы за исправный воркер. healthcheck: - test: ["CMD-SHELL", "celery -A app.workers.celery_app inspect ping -d celery@$$(hostname) -t 10 >/dev/null 2>&1"] + test: ["CMD-SHELL", "celery -A app.workers.celery_app inspect ping -d celery@$$(hostname) -t 10 >/dev/null"] interval: 60s timeout: 30s - retries: 3 + retries: 5 start_period: 90s # #976 cross-DB ETL tradein→gendesign: worker запускает etl_newbuilding_crossload task, # которому нужен прямой TCP-доступ к tradein-postgres через gendesign_shared. From e2e7e17f953923bebe7624ea4726514f1ff4ebbe Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:35:48 +0500 Subject: [PATCH 016/121] =?UTF-8?q?fix(domclick):=20=D0=BA=D0=B0=D0=B6?= =?UTF-8?q?=D0=B4=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=BE=D1=80=D0=B0=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D0=B0=D0=B5=D1=82=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=20(#3332)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обрыв «пул прокси пуст» уходил из цикла между attempted++ и записью исхода, поэтому тождество attempted = enriched + failed + blocked ломалось ровно на 1 (прод: 5 прогонов с diff=1). Исход честно failed, не blocked: к площадке не ходили, отказала наша инфраструктура — тот же разряд, что у транспортных сбоев (#3283); причина прогона по-прежнему в no_proxy_stop=1 + mark_failed. Та же дыра закрыта у save_detail_enrichment(...) is False: карточка разобрана, но строки уже нет — попытка была, исхода не было. Closes #3332 --- .../app/tasks/domclick_detail_backfill.py | 22 +++ .../test_3332_domclick_counter_identity.py | 179 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index a6e4a11e..4d35cd87 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -447,6 +447,19 @@ async def run_domclick_detail_backfill( enrichment = await fetch_detail(source_url, browser_fetcher=bf, cookies=cookies) if save_detail_enrichment(db, listing_id, enrichment): counters.enriched += 1 + else: + # #3332, та же дыра в тождестве, что и у пустого пула: карточку + # забрали, а строки уже нет (удалена/деактивирована между + # снимком и UPDATE) — попытка была, исхода не было. Тихо + # терять её нельзя: расхождение читается как потерянный блок. + counters.failed += 1 + logger.warning( + "domclick_detail_backfill: run_id=%d listing %s — карточка " + "разобрана, но UPDATE не нашёл строку id=%d", + run_id, + source_url, + listing_id, + ) consecutive_blocks = 0 consecutive_soft = 0 @@ -471,6 +484,15 @@ async def run_domclick_detail_backfill( # Прогон 5399 умер именно так: три «блока» подряд, из них два # 500 от сайдкара и один пустой пул, отказов площадки — ноль. if _caused_by_empty_pool(e): + # #3332: попытка уже посчитана в attempted (строка выше), а этот + # выход из цикла шёл мимо ВСЕХ исходов — тождество + # attempted = enriched + failed + blocked ломалось ровно на 1 + # (прод: 5 прогонов с diff=1, каждый оборванный пустым пулом). + # Исход честно failed, а не blocked: к площадке не ходили, это + # отказ нашей стороны — тот же разряд, что у транспортных сбоев + # ниже. Причина не теряется: в записи прогона стоит + # no_proxy_stop=1 и mark_failed с текстом про пул. + counters.failed += 1 logger.error( "domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, " "к площадке не ходили. enriched=%d attempted=%d", diff --git a/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py b/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py new file mode 100644 index 00000000..feb35609 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py @@ -0,0 +1,179 @@ +"""Тождество счётчиков domclick_detail_backfill: attempted == сумма исходов (#3332). + +`counters.attempted` инкрементируется ДО попытки, исход дописывается уже в +разборе результата. Обрыв «пул прокси пуст» (`_caused_by_empty_pool` → +`no_proxy_stop = True; break`) уходил из цикла между этими двумя точками, поэтому +текущая попытка оставалась без исхода: прод дал 5 прогонов с +`attempted - (enriched + failed + blocked) == 1`. Расхождение читается как +потерянный отказ площадки, хотя площадку не трогали вовсе. + +Исход выбран `failed`, а не `blocked`: к площадке не ходили, отказала наша +инфраструктура — тот же разряд, что у транспортных сбоев (`_is_transport_failure`, +#3283). Причину прогона несёт `no_proxy_stop=1` + `mark_failed`, счётчик её не +подменяет. + +Вторая дыра того же класса (закрыта здесь же): `save_detail_enrichment` вернул +False — карточка разобрана, но строки уже нет; попытка была, исхода не было. + +Проверка ПО ЗНАЧЕНИЮ: сравниваются числа, а не «не бросило исключение». +Харнесс зеркалит tests/test_3196_domclick_ban_kind.py. +""" + +from __future__ import annotations + +import os +import sys +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) + +import pytest # noqa: E402 +from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402 +from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402 + +from app.tasks.domclick_detail_backfill import ( # noqa: E402 + DomClickDetailBackfillResult, + run_domclick_detail_backfill, +) + +_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail" +_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment" +_RUNS = "app.tasks.domclick_detail_backfill.runs_mod" +_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep" +_SETTINGS = "app.tasks.domclick_detail_backfill.settings" +_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher" +_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc" + + +def _make_snapshot(n: int) -> list[dict]: + return [ + { + "id": i + 1, + "source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}", + } + for i in range(n) + ] + + +def _mock_db(snapshot: list[dict]) -> MagicMock: + db = MagicMock() + sel = MagicMock() + sel.mappings.return_value.all.return_value = snapshot + db.execute.return_value = sel + return db + + +def _mock_session_svc() -> MagicMock: + svc = MagicMock() + svc.load_session.return_value = {"CAS_ID": "123"} + svc.COOKIE_EXPIRY_WARN_DAYS = 5 + svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30) + return svc + + +def _mock_browser_fetcher_cls() -> MagicMock: + instance = AsyncMock() + instance.__aenter__ = AsyncMock(return_value=instance) + instance.__aexit__ = AsyncMock(return_value=False) + instance.request_context_reset = MagicMock() + return MagicMock(return_value=instance) + + +def _empty_pool_block() -> DomClickBlockedError: + """Как это приходит с прода: fetch_detail заворачивает ЛЮБОЙ сбой фетча в + DomClickBlockedError через `raise ... from exc`, поэтому пустой пул лежит в + __cause__ и снаружи выглядит блокировкой.""" + blocked = DomClickBlockedError("browser fetch failed") + blocked.__cause__ = NoProxyAvailableError("domclick") + return blocked + + +async def _run( + fetch: AsyncMock, *, snapshot: int, save_ok: bool = True +) -> tuple[DomClickDetailBackfillResult, MagicMock]: + db = _mock_db(_make_snapshot(snapshot)) + runs = MagicMock() + with ( + patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")), + patch(_SESSION_SVC, _mock_session_svc()), + patch(_RUNS, runs), + patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()), + patch(_FETCH, fetch), + patch(_SAVE, return_value=save_ok), + patch(_SLEEP, new_callable=AsyncMock), + ): + counters = await run_domclick_detail_backfill( + db, + run_id=3332, + params={"batch_size": snapshot, "budget_sec": 3600, "max_consecutive_blocks": 5}, + ) + return counters, runs + + +def _assert_identity(counters: DomClickDetailBackfillResult, *, expected_attempted: int) -> None: + outcomes = counters.enriched + counters.failed + counters.blocked + assert counters.attempted == expected_attempted, ( + f"attempted={counters.attempted}, ожидали {expected_attempted} попыток" + ) + assert counters.attempted == outcomes, ( + f"тождество нарушено: attempted={counters.attempted}, " + f"сумма исходов={outcomes} (enriched={counters.enriched} " + f"failed={counters.failed} blocked={counters.blocked}), " + f"потеряно {counters.attempted - outcomes} попыток без исхода" + ) + + +@pytest.mark.asyncio +async def test_empty_pool_mid_run_keeps_identity() -> None: + """2 успеха, на 3-й попытке пул пуст → attempted=3 == 2 enriched + 1 failed.""" + fetch = AsyncMock(side_effect=[MagicMock(), MagicMock(), _empty_pool_block()]) + counters, runs = await _run(fetch, snapshot=5) + + _assert_identity(counters, expected_attempted=3) + assert counters.failed == 1, ( + f"failed={counters.failed}: оборванная пустым пулом попытка должна быть " + "засчитана отказом НАШЕЙ стороны" + ) + assert counters.blocked == 0, ( + f"blocked={counters.blocked}: площадка не отказывала, запроса к ней не было — " + "начислять блок значит лгать про причину (#3283)" + ) + # Причина обрыва по-прежнему в записи прогона, а не подменена счётчиком. + runs.mark_failed.assert_called_once() + assert runs.mark_failed.call_args.args[3]["no_proxy_stop"] == 1 + + +@pytest.mark.asyncio +async def test_missing_row_on_save_keeps_identity() -> None: + """save_detail_enrichment вернул False (строки уже нет) → попытка не теряется.""" + fetch = AsyncMock(return_value=MagicMock()) + counters, _ = await _run(fetch, snapshot=2, save_ok=False) + + _assert_identity(counters, expected_attempted=2) + assert counters.enriched == 0 and counters.failed == 2, ( + f"enriched={counters.enriched} failed={counters.failed}: " + "непрошедший UPDATE — не успех и не блок" + ) + + +@pytest.mark.asyncio +async def test_blocked_and_enriched_counted_once() -> None: + """Контроль на противоположную ошибку: блоки/успехи по-прежнему по одному разу.""" + fetch = AsyncMock( + side_effect=[ + MagicMock(), + DomClickBlockedError("refused", status=403), + MagicMock(), + ] + ) + counters, _ = await _run(fetch, snapshot=3) + + _assert_identity(counters, expected_attempted=3) + assert (counters.enriched, counters.blocked, counters.failed) == (2, 1, 0), ( + f"enriched={counters.enriched} blocked={counters.blocked} failed={counters.failed}, " + "ожидали 2/1/0 — правка #3332 не должна начислять исход дважды" + ) From 148da3e5a79068a4185df291db64421452bb1471 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:36:22 +0500 Subject: [PATCH 017/121] =?UTF-8?q?fix(tradein):=20thin-market=20IMV=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B4=D0=B2=D0=B8=D0=B3=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D1=8C=D0=B3=D0=B8=20(#3323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit avito_imv_thin_market_threshold рождал только warning: IMV с market_count=1 всё равно уходил в blend (w=0.5 при A > median*1.15) и растягивал range_high. Гейт поставлен в _apply_imv_blend — единственной точке, через которую IMV влияет на деньги (обе ветки якоря, imv_anchor и imv_eval, сходятся там): market_count < threshold → no-op, якорь остаётся display-only в карточке. market_count >= threshold и market_count=None (порог не передан) — поведение прежнее. market_count=0 больше не читается как «неизвестно». Warning теперь говорит, что IMV ОТБРОШЕН, а не просто «тонкий рынок». --- tradein-mvp/backend/app/services/estimator.py | 39 +++++++-- .../backend/tests/test_estimator_imv_blend.py | 79 +++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 06763e6f..1a5fae76 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1320,6 +1320,8 @@ def _apply_imv_blend( anchor_higher: int | None, weight: float, threshold: float, + market_count: int | None = None, + thin_market_threshold: int = 0, ) -> tuple[int, int, float, bool, int | None]: """Чистая (testable без БД) blend-трансформация для #651. @@ -1330,12 +1332,22 @@ def _apply_imv_blend( Если A ниже медианы — медиану НЕ трогаем, но диапазон можем расширить, чтобы включить A (информативность). Null-guard: при anchor_total=None — no-op. + #3323: тонкий рынок (`market_count` < `thin_market_threshold`) → якорь + статистически ненадёжен и ОТБРАСЫВАЕТСЯ из денежного пути целиком: ни blend + медианы, ни расширение range_high. Гейт стоит здесь — в единственной точке, + через которую IMV влияет на деньги, а не в ветках построения якоря. + `thin_market_threshold=0` (default) = гейт выключен: market_count неизвестен → + поведение прежнее. + Returns (new_median_price, new_range_high, new_median_ppm2, blended, anchor_used_total). """ if anchor_total is None or anchor_total <= 0 or median_price <= 0 or area <= 0: return median_price, range_high, median_ppm2, False, None + if market_count is not None and market_count < thin_market_threshold: + return median_price, range_high, median_ppm2, False, None + blended = False new_median = median_price new_ppm2 = median_ppm2 @@ -3348,7 +3360,12 @@ def _price_from_inputs( int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None ) anchor_label = "оценке Avito IMV" - _imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None + # `is not None`: market_count=0 — самый тонкий рынок, а не «неизвестно». + _imv_mc = ( + int(imv_anchor["market_count"]) + if imv_anchor.get("market_count") is not None + else None + ) avito_imv_summary = AvitoImvSummary( recommended_price=anchor_total, lower_price=( @@ -3391,11 +3408,15 @@ def _price_from_inputs( ), ) - # #audit-5b: thin-market warning. + # #audit-5b / #3323: thin-market warning. Раньше порог рождал ТОЛЬКО warning, + # а IMV всё равно двигал headline и растягивал range_high — теперь гейт в + # `_apply_imv_blend` отбрасывает якорь, и текст говорит именно это. if avito_imv_summary is not None and avito_imv_summary.thin_market: logger.warning( - "avito_imv thin_market #audit-5b: market_count=%s" - " (< avito_imv_thin_market_threshold=%d) — IMV reliability low", + "avito_imv thin_market #3323: market_count=%s" + " (< avito_imv_thin_market_threshold=%d) — IMV ОТБРОШЕН из денежного" + " пути (ни blend медианы, ни расширение range_high); остаётся" + " display-only в карточке avito_imv", avito_imv_summary.market_count, settings.avito_imv_thin_market_threshold, ) @@ -3411,6 +3432,10 @@ def _price_from_inputs( anchor_higher=anchor_higher, weight=settings.estimate_imv_blend_weight, threshold=settings.estimate_imv_blend_threshold, + market_count=( + avito_imv_summary.market_count if avito_imv_summary is not None else None + ), + thin_market_threshold=settings.avito_imv_thin_market_threshold, ) if blended: logger.info( @@ -3442,7 +3467,11 @@ def _price_from_inputs( # median/expected_sold/ranges блок не трогает. if avito_imv_summary is None: if imv_anchor is not None and imv_anchor.get("recommended_price"): - _disp_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None + _disp_mc = ( + int(imv_anchor["market_count"]) + if imv_anchor.get("market_count") is not None + else None + ) avito_imv_summary = AvitoImvSummary( recommended_price=int(imv_anchor["recommended_price"]), lower_price=( diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index 77bf6a57..024bbf5e 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -55,6 +55,85 @@ def test_blend_premium_raises_median_and_extends_range() -> None: assert anchor_used == 100_000_000 +def test_blend_thin_market_gate_drops_imv_from_money_path_3323() -> None: + """#3323: market_count=1 → IMV не двигает ни headline, ни range_high. + + Те же числа, что в test_blend_premium_raises_median_and_extends_range + (там 50М → 75М, range 60М → 110М). С тонким рынком ждём ЧИСТЫЙ расчёт: + медиана 50М и range_high 60М — без вклада IMV. + """ + area = 80.0 + median_price = 50_000_000 + range_high = 60_000_000 + median_ppm2 = median_price / area + + new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( + median_price=median_price, + range_high=range_high, + median_ppm2=median_ppm2, + area=area, + anchor_total=100_000_000, + anchor_higher=110_000_000, + weight=0.5, + threshold=1.15, + market_count=1, + thin_market_threshold=10, + ) + + assert blended is False + assert new_median == 50_000_000 + assert new_range_high == 60_000_000 + assert new_ppm2 == median_ppm2 + assert anchor_used is None + + +def test_blend_thick_market_unchanged_regression_3323() -> None: + """#3323 регрессия: market_count >= threshold → числа как до гейта.""" + area = 80.0 + new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( + median_price=50_000_000, + range_high=60_000_000, + median_ppm2=50_000_000 / area, + area=area, + anchor_total=100_000_000, + anchor_higher=110_000_000, + weight=0.5, + threshold=1.15, + market_count=10, + thin_market_threshold=10, + ) + + assert blended is True + assert new_median == 75_000_000 + assert new_range_high == 110_000_000 + assert new_ppm2 == 75_000_000 / area + assert anchor_used == 100_000_000 + + +def test_blend_thin_market_gate_boundary_3323() -> None: + """#3323 граница: threshold-1 → гейт закрыт, threshold → открыт.""" + area = 80.0 + kwargs: dict[str, Any] = { + "median_price": 50_000_000, + "range_high": 60_000_000, + "median_ppm2": 50_000_000 / area, + "area": area, + "anchor_total": 100_000_000, + "anchor_higher": 110_000_000, + "weight": 0.5, + "threshold": 1.15, + "thin_market_threshold": 10, + } + + gated = _apply_imv_blend(**kwargs, market_count=9) + assert gated[:2] == (50_000_000, 60_000_000) + assert gated[3] is False + + passed = _apply_imv_blend(**kwargs, market_count=10) + assert passed[:2] == (75_000_000, 110_000_000) + assert passed[3] is True + + def test_blend_no_op_when_anchor_below_median() -> None: """A < median → медиану НЕ понижаем (однонаправленность), но диапазон может расшириться.""" area = 50.0 From c093212eaeaff8d2aadc3d4d4bdcecf2fca41800 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:37:13 +0500 Subject: [PATCH 018/121] =?UTF-8?q?fix(mera-public):=20=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B9=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D1=8B=20=D0=B2=20=D1=80=D0=B0=D1=83=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=D1=85=20=D0=B8=D0=B3=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Остаток «100 − уверенные» в подписи города был однородным только на словах: внутрь него входят пустые пробы, на которые describeCoverage отвечает «рядом не продаётся ничего похожего» и расчёт не предлагает. Текст обещал расчёт и на них — две наши же строки спорили друг с другом. Остаток разложен на долю «посчитаем, но разброс шире» и долю «считать не станем». Возраст объявления бэкенд считает percentile_cont(0.5) — это медиана, а «в среднем» под скошенным распределением называет другую величину. Раунды игры брались первыми тремя строками витрины, а она отсортирована по полноте и свежести: на проде это давало три студии с одной улицы. Выбор жадный по комнатности и улице, детерминированный — страница серверная. --- .../__tests__/coverage-copy.test.ts | 40 +++++++++- .../__tests__/varied-rounds.test.ts | 77 +++++++++++++++++++ .../mera-public/_components/v3/deal-view.ts | 34 ++++++++ .../src/app/mera-public/coverage-copy.ts | 25 ++++-- .../frontend/src/app/mera-public/page.tsx | 6 +- 5 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts index 3d03e999..57cb0f5f 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts @@ -63,7 +63,18 @@ describe("describeCoverage", () => { it("когда дата известна у всех — доля не упоминается вовсе", () => { // «дата известна у 30 из 30» — шум: доля есть, но она ничего не сообщает. const [, ageTile] = describeCoverage(probe({ n_listings: 30, n_with_age: 30 })).tiles; - expect(ageTile.label).toBe("столько в среднем висит объявление"); + expect(ageTile.label).toBe("столько и дольше висит каждое второе объявление"); + }); + + it("возраст подписан как медиана — «в среднем» под percentile_cont(0.5) неправда", () => { + // Бэкенд считает `percentile_cont(0.5)` (trade_in.py::_coverage_probe). + // На возрасте объявлений распределение скошено вправо: среднее заметно + // больше медианы, и подпись «в среднем» называла бы другую величину. + for (const over of [{ n_with_age: 6 }, { n_with_age: 34 }]) { + const [, ageTile] = describeCoverage(probe(over)).tiles; + expect(ageTile.label).not.toMatch(/в среднем/i); + expect(ageTile.label).toMatch(/каждое второе|половин|медиан/i); + } }); it("город вне покрытия — говорим прямо и не предлагаем платный шаг", () => { @@ -142,9 +153,30 @@ describe("describeCityExpectation", () => { const said = describeCityExpectation("Ревда"); expect(said?.text).toContain("проверках"); expect(said?.text).not.toMatch(/точност/i); - // Доли складываются в целое: 16 уверенных + 84 остальных — иначе текст - // утверждает больше, чем измерено. - expect(said?.text).toContain("84"); + }); + + it("остаток разложен: где считаем с широким разбросом, а где не считаем вовсе", () => { + // Прежний текст называл остаток одним числом (100 − уверенные) и обещал + // расчёт на всём нём. Но пустые пробы сидят внутри этого остатка, а + // `describeCoverage` отвечает на них «рядом не продаётся ничего похожего» + // и платный шаг не предлагает. Числа берём из замера, а не из строки: + // подстрокой «3» нашлось бы и внутри «83». + for (const measure of CITY_COVERAGE) { + const thin = 100 - measure.confidentPct - measure.emptyPct; + const said = describeCityExpectation(measure.city); + const nums = (said?.text.match(/\d+/g) ?? []).map(Number); + + expect(measure.emptyPct, `${measure.city}: нечего разделять`).toBeGreaterThan(0); + expect(nums, `${measure.city}: нет доли уверенных`).toContain(measure.confidentPct); + expect(nums, `${measure.city}: нет доли «посчитаем, но шире»`).toContain(thin); + expect(nums, `${measure.city}: нет доли пустых когорт`).toContain(measure.emptyPct); + expect(measure.confidentPct + thin + measure.emptyPct).toBe(100); + // Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает + // расчёт там, где следующий же экран в нём откажет. + expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch( + /считать не станем/, + ); + } }); it("неизвестный город не выдумывает величину", () => { diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts new file mode 100644 index 00000000..88aa458a --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts @@ -0,0 +1,77 @@ +/** + * Какие сделки попадают в игру «Угадай цену». + * + * Витрина отсортирована по полноте и свежести, а не по разнообразию, поэтому + * первые три её строки на проде оказывались тремя студиями с одной улицы: игра + * трижды спрашивала цену одной и той же квартиры. Проверяется ЗНАЧЕНИЕ выбора + * — какие именно строки взяты, — а не факт вызова функции. + */ +import { describe, expect, it } from "vitest"; + +import { pickVariedDeals } from "../_components/v3/deal-view"; +import type { ShowcaseDeal } from "../public-api"; + +const deal = (rooms: number, street: string | null, area_m2 = 40): ShowcaseDeal => + ({ + district: "Кировский", + rooms, + area_m2, + floor: 3, + total_floors: 9, + deal_quarter: "II квартал 2026", + predicted_rub: 5_000_000, + fact_rub: 5_000_000, + err_pct: 0, + n_analogs: 8, + note: "", + street_name: street, + street_scheme: null, + lat: null, + lon: null, + }) as ShowcaseDeal; + +/** Порядок — как на витрине: студии одной улицы стоят первыми. */ +const showcase: readonly ShowcaseDeal[] = [ + deal(0, "улица Ленина", 25), + deal(0, "улица Ленина", 27), + deal(0, "улица Ленина", 29), + deal(2, "улица Ленина", 62), + deal(1, "улица Мира", 38), + deal(2, "улица Щорса", 58), +]; + +describe("pickVariedDeals", () => { + it("три раунда — три разные комнатности и три разные улицы", () => { + const picked = pickVariedDeals(showcase, 3); + expect(picked).toHaveLength(3); + expect(picked.map((d) => d.rooms)).toEqual([0, 1, 2]); + expect(picked.map((d) => d.street_name)).toEqual([ + "улица Ленина", + "улица Мира", + "улица Щорса", + ]); + // Прежний slice(0, 3) взял бы три студии с Ленина — вот они, и их нет. + expect(picked.map((d) => d.area_m2)).toEqual([25, 38, 58]); + }); + + it("когда разнообразия нет — берём сколько есть, а не показываем меньше раундов", () => { + // Игра не рендерится, пока раундов меньше трёх. Отдать два «ради чистоты + // выбора» значило бы погасить секцию из-за однородной витрины. + const same = [deal(0, "улица Ленина", 25), deal(0, "улица Ленина", 27), deal(0, "улица Ленина", 29)]; + expect(pickVariedDeals(same, 3)).toHaveLength(3); + }); + + it("строки без улицы не считаются повтором улицы", () => { + // `street_name` пуст примерно у 8% строк — такая строка про улицу молчит. + const noStreet = [deal(1, null, 38), deal(1, "улица Мира", 40), deal(2, null, 58)]; + expect(pickVariedDeals(noStreet, 2).map((d) => d.area_m2)).toEqual([38, 58]); + }); + + it("выбор детерминирован — страница рендерится на сервере", () => { + // Разный набор на сервере и в браузере — это разъехавшаяся гидрация, + // поэтому никакого случайного выбора здесь быть не может. + const first = pickVariedDeals(showcase, 3); + const second = pickVariedDeals(showcase, 3); + expect(first.map((d) => d.area_m2)).toEqual(second.map((d) => d.area_m2)); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts index b8751743..0a5bdcda 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts @@ -130,6 +130,40 @@ export function toGameRound(deal: ShowcaseDeal, index: number): GameRound { }; } +/** + * Строки витрины для игры — РАЗНЫЕ по типу квартиры и по улице. + * + * `slice(0, n)` брал первые в порядке витрины, а он отсортирован по полноте и + * свежести (`_sort_key` в `landing_showcase_deals.py`), а не по разнообразию: + * на проде это давало три студии с одной улицы, и игра выглядела как одна и та + * же квартира трижды. Жадно берём первую строку, у которой И комнатность, И + * улица ещё не встречались; когда таких не осталось — добираем первыми + * попавшимися, потому что раундов должно быть столько, сколько просили. + * + * Порядок строго по входу: страница рендерится на сервере, и любой `random` + * здесь означал бы разный набор на сервере и в браузере. + */ +export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): ShowcaseDeal[] { + const picked: ShowcaseDeal[] = []; + const rooms = new Set(); + const streets = new Set(); + for (const deal of deals) { + if (picked.length >= n) break; + if (rooms.has(deal.rooms)) continue; + // Улицы нет примерно у 8% строк — такая строка не «повторяет улицу», она + // про неё молчит, и держать её из-за этого вне игры не за что. + if (deal.street_name !== null && streets.has(deal.street_name)) continue; + picked.push(deal); + rooms.add(deal.rooms); + if (deal.street_name !== null) streets.add(deal.street_name); + } + for (const deal of deals) { + if (picked.length >= n) break; + if (!picked.includes(deal)) picked.push(deal); + } + return picked; +} + /** * Разброс расхождений ПОКАЗАННЫХ строк. * diff --git a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts index 42246c37..ffa884a0 100644 --- a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts +++ b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts @@ -17,7 +17,8 @@ * объявления, а не срок продажи: выборка цензурирована, эти квартиры ещё * продаются, реальная экспозиция проданных всегда меньше. Написать под * цифрой «за столько продаются» — соврать; поэтому подпись здесь одна и - * тестом запинена. И при `median === null` (бэкенд занулил её, когда + * тестом запинена. Величина при этом МЕДИАННАЯ (`percentile_cont(0.5)` на + * бэкенде), а не средняя — «в среднем» под ней было такой же неправдой. И при `median === null` (бэкенд занулил её, когда * возраст известен меньше чем по пяти объявлениям) плитки просто нет — * ноль вместо неизвестного значения был бы худшей из ошибок. */ @@ -73,10 +74,14 @@ function tilesFor(probe: CoverageProbe): CoverageTile[] { const partial = probe.n_with_age < probe.n_listings; tiles.push({ value: `${probe.median_listing_age_days} дн.`, - // Формулировка запинена тестом — см. шапку модуля. + // Формулировка запинена тестом — см. шапку модуля. «В среднем» отсюда + // убрано 02.09: бэкенд считает `percentile_cont(0.5)` + // (`trade_in.py::_coverage_probe`) — это МЕДИАНА, и на скошенном вправо + // возрасте объявлений она заметно меньше среднего. «Каждое второе» — + // то же самое утверждение словами, которые не надо расшифровывать. label: partial - ? `столько в среднем висит объявление; дата известна у ${probe.n_with_age} из ${probe.n_listings}` - : "столько в среднем висит объявление", + ? `столько и дольше висит каждое второе объявление; дата известна у ${probe.n_with_age} из ${probe.n_listings}` + : "столько и дольше висит каждое второе объявление", }); } @@ -103,13 +108,17 @@ export function describeCityExpectation(city: string): CityExpectation | null { const measure = CITY_COVERAGE.find((m) => m.city === city); if (!measure) return null; - const rest = 100 - measure.confidentPct; + // Остаток от уверенных — НЕ однородный: в него входят и пустые пробы, где + // считать нечего. Пока он назывался одним числом, текст обещал расчёт и на + // них тоже, а `describeCoverage` в той же ситуации отвечает «не считаем» — + // две наши же строки спорили друг с другом. Делим остаток надвое. + const thin = 100 - measure.confidentPct - measure.emptyPct; return { text: `В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` + - `из 100; в остальных ${rest} данных меньше — расчёт мы всё равно сделаем, но разброс ` + - `будет шире, и в ответе это будет написано. В ${measure.emptyPct} случаях из 100 рядом ` + - "не находится ни одной похожей квартиры.", + `из 100. Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` + + `ответе это будет написано. В оставшихся ${measure.emptyPct} рядом не находится ни одной ` + + "похожей квартиры — так и скажем, а считать не станем.", source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`, }; } diff --git a/tradein-mvp/frontend/src/app/mera-public/page.tsx b/tradein-mvp/frontend/src/app/mera-public/page.tsx index ace48006..f3a753d6 100644 --- a/tradein-mvp/frontend/src/app/mera-public/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/page.tsx @@ -12,7 +12,7 @@ import { StepsV3 } from "./_components/v3/StepsV3"; import { StickyCtaV3 } from "./_components/v3/StickyCtaV3"; import { TwoPathsV3 } from "./_components/v3/TwoPathsV3"; import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3"; -import { toGameRound } from "./_components/v3/deal-view"; +import { pickVariedDeals, toGameRound } from "./_components/v3/deal-view"; import { fetchLandingStats, fetchShowcase } from "./public-api"; import styles from "./landing.module.css"; @@ -81,7 +81,9 @@ const MIN_ROUNDS = 3; export default async function MeraPublicPage() { const [stats, showcase] = await Promise.all([fetchLandingStats(), fetchShowcase()]); const deals = showcase?.deals ?? []; - const rounds = deals.slice(0, MIN_ROUNDS).map(toGameRound); + // Не первые три, а три РАЗНЫЕ — иначе игра предлагает угадать одну и ту же + // студию с одной улицы трижды (см. `pickVariedDeals`). + const rounds = pickVariedDeals(deals, MIN_ROUNDS).map(toGameRound); return ( <> From 177207223bc3ca62fb37f9fc67446004d250c5bd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:41:38 +0500 Subject: [PATCH 019/121] =?UTF-8?q?fix(caddy):=20=D1=81=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B9=20internal-?= =?UTF-8?q?=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=20=D1=81=20=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=83?= =?UTF-8?q?=D1=82=D0=B8=20=D0=B8=20=D1=87=D0=B0=D0=BD=D0=BA=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=83=D1=80=D0=B0=20=D1=81=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3324, п.3: Caddy на gendsgn.ru инжектил X-Internal-Auth-Secret в каждый анонимный запрос /trade-in/api/* и /trade-in/* (хоп во фронт). Сравнение недостижимо по построению: backend читает этот заголовок только в legacy trusted-header ветке rbac.py, и только после непустого X-Authenticated-User, который тот же самый хоп строкой ниже удаляет. Второй потребитель секрета (вебхук GlitchTip) берёт его из query-параметра. Защита от подделки заголовков внутри gendesign_shared — проверка в rbac.py, она не тронута; инжект работал в обратную сторону, раздавая секрет наружу. #3324, п.1: meraocenka.ru раздавал анонимам постраничные чанки App Router чужих маршрутов (chunks/app/{admin,team,scrapers,...}/) — код закрытого контура с именами внутренних ручек. Матчер отсекает 404-ом «есть подкаталог, и он не mera-public»; лэндингу нужны только chunks/app/<файл>.js и chunks/app/mera-public/* (замер на проде). Карта чанков в webpack-*.js остаётся — имена файлов по-прежнему читаются, полное разделение = split build. Регресс-тесты в smoke-mera-perimeter.sh отличают 404 Caddy (пустое тело) от 404 Next и отдельно проверяют, что статика лэндинга жива. --- caddy/sites/apps.caddy | 70 ++++++++++++++++++++++++++++----- scripts/smoke-mera-perimeter.sh | 45 +++++++++++++++++++++ 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index 085146a4..83f6293e 100644 --- a/caddy/sites/apps.caddy +++ b/caddy/sites/apps.caddy @@ -175,10 +175,28 @@ gendsgn.ru { # "удалить заголовок" (Caddyfile reverse_proxy directive: `-` = # delete) — корректное поведение не должно зависеть от того, как именно # Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции. - # X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается - # из env (Set-операция с непустым значением, никак не связана с auth-гейтом - # basic_auth), это единственное, что теперь отсекает подделку заголовков - # изнутри gendesign_shared network для legacy dual-mode пути. + # X-Internal-Auth-Secret ЗДЕСЬ БОЛЬШЕ НЕ ПОДСТАВЛЯЕТСЯ (#3324). До этой + # правки Caddy инжектил его в КАЖДЫЙ запрос этого хопа — включая + # анонимный, до всякого логина trade-in. + # + # ПОЧЕМУ ОН БЫЛ МЁРТВЫМ. Единственное место, где backend читает этот + # ЗАГОЛОВОК, — `app/core/rbac.py` (legacy trusted-header ветка): он + # сверяется ТОЛЬКО после того, как в запросе нашёлся непустой + # X-Authenticated-User (иначе ветка отдаёт 401 раньше, на «no + # authenticated user»). А X-Authenticated-User на этом же хопе строкой + # ниже удаляется — то есть пара «имя + секрет» через Caddy прийти не + # может по построению, сравнение недостижимо. Второй потребитель + # секрета, приёмник вебхуков GlitchTip (`app/api/v1/glitchtip.py`), + # берёт его из query-параметра `?secret=`, а не из заголовка, и на эту + # правку не реагирует. + # + # ЧТО ОТСЕКАЕТ ПОДДЕЛКУ ЗАГОЛОВКОВ ИЗНУТРИ gendesign_shared — проверка + # в rbac.py, а не факт подстановки в Caddy; она остаётся нетронутой. + # Инжект работал в обратную сторону: раздавал внутренний секрет по + # анонимному пути (он же виден в env контейнера Caddy). Легитимный + # dual-mode трафик — внутрисетевой (`docker exec tradein-backend curl + # -H 'X-Authenticated-User: …' -H 'X-Internal-Auth-Secret: …'`, + # см. auth/roles.yaml) — ходит мимо Caddy и сам несёт оба заголовка. handle /trade-in/api/* { # `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api; # FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только @@ -186,7 +204,6 @@ gendsgn.ru { uri strip_prefix /trade-in reverse_proxy tradein-backend:8000 { header_up -X-Authenticated-User - header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} } } @@ -224,9 +241,13 @@ gendsgn.ru { # Next.js basePath=/trade-in — фронт сам ждёт префикса в URL reverse_proxy tradein-frontend:3000 { # См. комментарий над /trade-in/api/* выше — та же логика (явное - # удаление вместо Set с пустым {http.auth.user.id}). + # удаление вместо Set с пустым {http.auth.user.id}), и по той же + # причине здесь больше нет инжекта X-Internal-Auth-Secret + # (#3324). На этом хопе он был мёртв ещё очевиднее: адресат — + # Next-сервер tradein-frontend, в его коде заголовок не читается + # и дальше в backend не пробрасывается (SSR не форвардит + # входящие заголовки — `headers()` во фронте не используется). header_up -X-Authenticated-User - header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} } } @@ -463,7 +484,33 @@ meraocenka.ru { # `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок, # CPU-нагрузка по запросу), который на лэндинге не используется вообще: # next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется. + # + # #3324: внутри разрешённой статики закрыто ПОДДЕРЕВО ЧУЖИХ МАРШРУТОВ. + # App Router раскладывает код постранично: `chunks/app/<маршрут>/page-.js` + # (замер на проде 02.09.2026 — лэндингу нужны РОВНО `chunks/app/layout-*`, + # `chunks/app/error-*`, `chunks/app/global-error-*`, `chunks/app/mera-public/*` + # и общие хэш-чанки прямо в `chunks/`). Всё остальное под `chunks/app/` — + # это /admin, /scrapers, /team, /history, /cache, /versions, /v2, + # /sale-share, /login, /ui-preview: страницы закрытого контура, которые + # аноним с публичного домена качал целиком и читал в них имена внутренних + # ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» — + # `not` вместо негативного lookahead, RE2 его не поддерживает. + # + # Ограничение честно: это убирает КОД, но не имена файлов — карта чанков + # лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень + # маршрутов из неё по-прежнему вычитывается. Полное разделение — split + # build (отдельный public-бандл), отдельная задача. + # + # `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок + # директив Caddy), поэтому вложенный матчер не зависит от сортировки + # handle-блоков между собой. handle /trade-in/_next/static/* { + @foreignRouteChunk { + path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/ + not path /trade-in/_next/static/chunks/app/mera-public/* + } + respond @foreignRouteChunk 404 + reverse_proxy tradein-frontend:3000 { header_up -X-Authenticated-User } @@ -499,9 +546,12 @@ meraocenka.ru { # strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не # часть маршрута FastAPI. # - # X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130): - # публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с - # анонимного домена — расширять доверие без нужды. + # X-Internal-Auth-Secret здесь НЕ подставляется: публичные ручки его не + # проверяют, а инжектить внутренний секрет в хоп с анонимного домена — + # расширять доверие без нужды. С #3324 это уже не отличие от B2B-блока + # gendsgn.ru, а общее правило: инжекта не осталось нигде в Caddyfile — + # заголовок нужен только внутрисетевому dual-mode трафику, который ходит + # мимо прокси и несёт его сам. handle /trade-in/api/public/* { uri strip_prefix /trade-in reverse_proxy tradein-backend:8000 { diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index 478adcda..a0906279 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -119,6 +119,51 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API) # Ловит расширение матчера обратно до `/trade-in/_next/*`. check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404 +# 2c-bis. Внутри разрешённой статики закрыто поддерево ЧУЖИХ маршрутов (#3324): +# App Router кладёт код постранично в chunks/app/<маршрут>/, и до этой +# правки аноним скачивал с публичного домена бандлы /admin, /team, +# /scrapers — с именами внутренних ручек внутри. +# +# КОД 404 ЗДЕСЬ НЕДОСТАТОЧЕН: несуществующий чанк Next тоже отдаёт 404, +# поэтому проверка не отличила бы «Caddy отсёк» от «Caddy проксировал, а +# файла нет» — и осталась бы зелёной после отката матчера. Отличаем по +# ТЕЛУ: `respond 404` Caddy пустой (0 байт), 404 от Next — непустой +# (замер на проде 02.09.2026: 9 байт). +check_caddy_404() { + local desc="$1" url="$2" + local out code size + out=$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url" 2>/dev/null) + code=${out%% *} + size=${out##* } + if [ "$code" = "404" ] && [ "$size" = "0" ]; then + echo "PASS: $desc ($url -> 404, пустое тело = отсёк Caddy)" + else + echo "FAIL: $desc ($url -> got '${out:-}', expected '404 0')" + fail=1 + fi +} + +check_caddy_404 "meraocenka.ru — чанки /admin не раздаются" \ + "$BASE_MERA/trade-in/_next/static/chunks/app/admin/page-smoke.js" +check_caddy_404 "meraocenka.ru — чанки /admin/analytics не раздаются" \ + "$BASE_MERA/trade-in/_next/static/chunks/app/admin/analytics/page-smoke.js" +check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \ + "$BASE_MERA/trade-in/_next/static/chunks/app/team/page-smoke.js" + +# Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться +# живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы +# успешной, а публичный сайт молча остался бы без JS. +layout_chunk=$(curl -s --max-time 15 "$BASE_MERA/" 2>/dev/null \ + | grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1) +if [ -z "$layout_chunk" ]; then + # Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о + # чём — поэтому это FAIL, а не «пропустим». + echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)" + fail=1 +else + check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200 +fi + # 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/* # доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене # по-прежнему 404. From ef166eaf2191d399e22dc19482a32d1f350af936 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:43:56 +0500 Subject: [PATCH 020/121] =?UTF-8?q?fix(mera-public):=20=D0=B7=D0=B0=D1=89?= =?UTF-8?q?=D0=B8=D1=82=D1=8B=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D1=82=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B2=20coverage-=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B5=20=D0=B8?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B5=20=D1=80=D0=B0=D1=83?= =?UTF-8?q?=D0=BD=D0=B4=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверка «уверенные + средние + пустые = 100» была верна по определению средней доли и упасть не могла. Инвариант перенесён на сам замер: уверенные и пустые обязаны оставлять место третьей доле. Если её всё-таки не останется (перезамер 85 + 15), текст теперь двухчастный — «Ещё в 0 данных меньше» отрендериться не может. `street_name !== null` считал пустую строку названием улицы, и вторая такая строка вылетала из игры как «повтор». Проверка на пустоту. --- .../__tests__/coverage-copy.test.ts | 21 +++++++++++++- .../__tests__/varied-rounds.test.ts | 8 ++++++ .../mera-public/_components/v3/deal-view.ts | 8 ++++-- .../src/app/mera-public/coverage-copy.ts | 28 +++++++++++++++---- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts index 57cb0f5f..e46e5232 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts @@ -170,7 +170,12 @@ describe("describeCityExpectation", () => { expect(nums, `${measure.city}: нет доли уверенных`).toContain(measure.confidentPct); expect(nums, `${measure.city}: нет доли «посчитаем, но шире»`).toContain(thin); expect(nums, `${measure.city}: нет доли пустых когорт`).toContain(measure.emptyPct); - expect(measure.confidentPct + thin + measure.emptyPct).toBe(100); + // Инвариант ЗАМЕРА, а не арифметики: уверенные и пустые обязаны + // оставлять место третьей доле, иначе рендер печатает «Ещё в 0». + expect( + measure.confidentPct + measure.emptyPct, + `${measure.city}: средней доле не осталось места`, + ).toBeLessThan(100); // Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает // расчёт там, где следующий же экран в нём откажет. expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch( @@ -179,6 +184,20 @@ describe("describeCityExpectation", () => { } }); + it("когда средней доли не осталось — предложения про неё нет вовсе", () => { + // Перезамер вида 85 уверенных + 15 пустых законен, и «Ещё в 0 данных + // меньше» на нём было бы сообщением о доле, которой не существует. + const said = describeCityExpectation("Ревда", { + city: "Ревда", + confidentPct: 85, + emptyPct: 15, + sampleN: 100, + }); + expect(said?.text).not.toMatch(/Ещё в 0|в 0 /); + expect((said?.text.match(/\d+/g) ?? []).map(Number)).toEqual([85, 100, 15]); + expect(said?.text).toMatch(/считать не станем/); + }); + it("неизвестный город не выдумывает величину", () => { expect(describeCityExpectation("Москва")).toBeNull(); }); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts index 88aa458a..8143d250 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts @@ -67,6 +67,14 @@ describe("pickVariedDeals", () => { expect(pickVariedDeals(noStreet, 2).map((d) => d.area_m2)).toEqual([38, 58]); }); + it("пустая строка в street_name — не ключ улицы, а её отсутствие", () => { + // `!== null` считал "" настоящим названием, и вторая такая строка + // вылетала как «повтор улицы» — повтора здесь нет, есть два молчания. + // Третья строка — та, которую при этом брали ВМЕСТО второй. + const blank = [deal(1, "", 38), deal(2, "", 58), deal(2, "улица Мира", 60)]; + expect(pickVariedDeals(blank, 2).map((d) => d.area_m2)).toEqual([38, 58]); + }); + it("выбор детерминирован — страница рендерится на сервере", () => { // Разный набор на сервере и в браузере — это разъехавшаяся гидрация, // поэтому никакого случайного выбора здесь быть не может. diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts index 0a5bdcda..5bbd2f0e 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts @@ -151,11 +151,13 @@ export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): Show if (picked.length >= n) break; if (rooms.has(deal.rooms)) continue; // Улицы нет примерно у 8% строк — такая строка не «повторяет улицу», она - // про неё молчит, и держать её из-за этого вне игры не за что. - if (deal.street_name !== null && streets.has(deal.street_name)) continue; + // про неё молчит, и держать её из-за этого вне игры не за что. Проверка + // именно на пустоту, а не на `!== null`: пустая строка — то же самое + // молчание, и второй такой строке нельзя вылетать как «повтору улицы». + if (deal.street_name && streets.has(deal.street_name)) continue; picked.push(deal); rooms.add(deal.rooms); - if (deal.street_name !== null) streets.add(deal.street_name); + if (deal.street_name) streets.add(deal.street_name); } for (const deal of deals) { if (picked.length >= n) break; diff --git a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts index ffa884a0..442c0edb 100644 --- a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts +++ b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts @@ -24,6 +24,7 @@ */ import { CITY_COVERAGE, CITY_COVERAGE_SOURCE } from "./landing-facts"; +import type { CityCoverageMeasure } from "./landing-facts"; import type { CoverageProbe } from "./public-api"; export interface CoverageTile { @@ -104,8 +105,13 @@ export interface CityExpectation { * что выбрасывать города из списка не за что. Разница между ними * количественная — её и показываем числом, а не отсутствием опции. */ -export function describeCityExpectation(city: string): CityExpectation | null { - const measure = CITY_COVERAGE.find((m) => m.city === city); +export function describeCityExpectation( + city: string, + // Замер вторым аргументом — чтобы ветку «средней доли не осталось» можно + // было проверить, не подделывая CITY_COVERAGE: сегодня такого города в + // замере нет, а появиться он может при первом же перезамере. + measure: CityCoverageMeasure | undefined = CITY_COVERAGE.find((m) => m.city === city), +): CityExpectation | null { if (!measure) return null; // Остаток от уверенных — НЕ однородный: в него входят и пустые пробы, где @@ -113,12 +119,22 @@ export function describeCityExpectation(city: string): CityExpectation | null { // них тоже, а `describeCoverage` в той же ситуации отвечает «не считаем» — // две наши же строки спорили друг с другом. Делим остаток надвое. const thin = 100 - measure.confidentPct - measure.emptyPct; + const head = + `В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} ` + + "проверках из 100. "; + // Средней доли может не остаться вовсе (перезамер вида 85 уверенных + 15 + // пустых) — тогда предложение про неё выпадает целиком. «Ещё в 0 данных + // меньше» было бы не округлением, а сообщением о доле, которой нет. + const empty = thin > 0 ? measure.emptyPct : 100 - measure.confidentPct; + const tail = + `В оставшихся ${empty} рядом не находится ни одной похожей квартиры — ` + + "так и скажем, а считать не станем."; return { text: - `В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` + - `из 100. Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` + - `ответе это будет написано. В оставшихся ${measure.emptyPct} рядом не находится ни одной ` + - "похожей квартиры — так и скажем, а считать не станем.", + thin > 0 + ? `${head}Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` + + `ответе это будет написано. ${tail}` + : head + tail, source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`, }; } From 4f4345e27c2aeb7038c14b1ac51cd38f535a4d9e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:44:32 +0500 Subject: [PATCH 021/121] =?UTF-8?q?fix(tradein):=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=88=D0=B8=D1=82=D1=8C=20thin-market=20=D0=B3?= =?UTF-8?q?=D0=B5=D0=B9=D1=82=20IMV=20=E2=80=94=20Guard-1b=20=D0=B8=20GET-?= =?UTF-8?q?=D0=BF=D1=83=D1=82=D1=8C=20(#3323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEDIUM-1: imv_anchor_present ставился по anchor_total МИМО гейта — на тонком рынке якорь отброшен, а Guard-1b (#764) продолжал глушить квартальную поправку «потому что якорь есть»: headline не получал ни одной поправки, отброшенный якорь двигал деньги вычитанием. Теперь present = not thin_market. MEDIUM-2: trade_in.py (GET ?id= — расшаренная ссылка/PDF) — третья точка сборки карточки: market_count=0 читался как «неизвестно», thin_market не передавался вовсе → одна оценка показывала thin_market=True в POST и False при переоткрытии. --- tradein-mvp/backend/app/api/v1/trade_in.py | 11 +++- tradein-mvp/backend/app/services/estimator.py | 8 ++- .../backend/tests/test_estimator_imv_blend.py | 18 ++++++ .../tests/test_estimator_quarter_index.py | 56 ++++++++++++++++++- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 61e086cd..e5542b73 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -776,7 +776,16 @@ def load_estimate( recommended_price=int(imv_raw["recommended_price"]), lower_price=int(imv_raw["lower_price"]) if imv_raw.get("lower_price") else None, higher_price=int(imv_raw["higher_price"]) if imv_raw.get("higher_price") else None, - market_count=int(imv_raw["market_count"]) if imv_raw.get("market_count") else None, + # #3323: `is not None` (0 — самый тонкий рынок, не «неизвестно») + thin_market + # считаем тем же порогом, что POST-путь в estimator, иначе одна и та же + # оценка при переоткрытии по ссылке / в PDF теряла флаг тонкого рынка. + market_count=( + int(imv_raw["market_count"]) if imv_raw.get("market_count") is not None else None + ), + thin_market=( + imv_raw.get("market_count") is not None + and int(imv_raw["market_count"]) < settings.avito_imv_thin_market_threshold + ), ) if imv_raw is not None and imv_raw.get("recommended_price") else None diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 1a5fae76..3541e3fd 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3422,7 +3422,13 @@ def _price_from_inputs( ) if anchor_total is not None: - imv_anchor_present = True + # #3323: на тонком рынке якорь отброшен гейтом ниже, значит и Guard-1b + # (#764, quarter-index) не должен глушить поправку «потому что якорь есть» — + # иначе headline не получит НИ ОДНОЙ поправки, и отброшенный IMV подвинет + # деньги вычитанием. avito_imv_summary здесь уже собран обеими ветками. + imv_anchor_present = ( + not avito_imv_summary.thin_market if avito_imv_summary is not None else True + ) new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( median_price=median_price, range_high=range_high, diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index 024bbf5e..3decd220 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -110,6 +110,24 @@ def test_blend_thick_market_unchanged_regression_3323() -> None: assert anchor_used == 100_000_000 +def test_blend_market_count_none_passes_gate_3323() -> None: + """#3323 контракт: market_count неизвестен (None) → гейт не срабатывает.""" + new_median, _, _, blended, _ = _apply_imv_blend( + median_price=50_000_000, + range_high=60_000_000, + median_ppm2=625_000.0, + area=80.0, + anchor_total=100_000_000, + anchor_higher=110_000_000, + weight=0.5, + threshold=1.15, + market_count=None, + thin_market_threshold=10, + ) + assert blended is True + assert new_median == 75_000_000 + + def test_blend_thin_market_gate_boundary_3323() -> None: """#3323 граница: threshold-1 → гейт закрыт, threshold → открыт.""" area = 80.0 diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_index.py b/tradein-mvp/backend/tests/test_estimator_quarter_index.py index dea55ceb..13b89e2d 100644 --- a/tradein-mvp/backend/tests/test_estimator_quarter_index.py +++ b/tradein-mvp/backend/tests/test_estimator_quarter_index.py @@ -388,6 +388,7 @@ def _run_estimate_qi( *, anchor_tier_override: str | None = None, analog_indexes: dict[str, float] | None = None, + imv_anchor: dict[str, Any] | None = None, ): """Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate. @@ -446,7 +447,7 @@ def _run_estimate_qi( "app.services.estimator._get_asking_sold_ratio", return_value=(None, None), ), - patch("app.services.estimator._fetch_house_imv_anchor", return_value=None), + patch("app.services.estimator._fetch_house_imv_anchor", return_value=imv_anchor), # Stub singular target-quarter lookup patch( "app.services.estimator._lookup_quarter_index", @@ -881,6 +882,59 @@ def test_guard1b_imv_blend_prevents_correction() -> None: assert "квартал" not in (est.confidence_explanation or "").lower() +def test_guard1b_thin_market_imv_does_not_block_quarter_index_3323() -> None: + """#3323: тонкий рынок → якорь отброшен, значит Guard-1b НЕ глушит поправку. + + Тот же вход, что в test_guard1b_imv_blend_prevents_correction (anchor 30М ≫ + медианы 6М), но market_count=1 < порога 10. Ждём по значению: blend не + сработал (не 18М) и квартальная поправка ПРИМЕНИЛАСЬ → 6М × 1.2. + """ + base_median = round(_BASE_PPM2 * _AREA) # 6_000_000 + thin_anchor = { + "recommended_price": 30_000_000, + "lower_price": 25_000_000, + "higher_price": 35_000_000, + "market_count": 1, # < settings.avito_imv_thin_market_threshold (10) + "rooms": 1, + "area_m2": _AREA, + } + + est = _run_estimate_qi( + analogs=_ANALOGS_OTHER_QUARTER, + dadata_cadnum=f"{_TARGET_QUARTER}:350", + qi_lookup_result=(1.2, 30), + analog_indexes={_OTHER_QUARTER: 1.0}, + imv_anchor=thin_anchor, + ) + + assert est.median_price_rub == round(base_median * 1.2) # 7_200_000 + assert est.median_price_rub != round(6_000_000 * 0.5 + 30_000_000 * 0.5) # не blend + assert "квартал" in (est.confidence_explanation or "").lower() + + +def test_guard1b_thick_market_imv_still_blocks_quarter_index_3323() -> None: + """#3323 регрессия: market_count >= порога → blend как раньше, поправка подавлена.""" + thick_anchor = { + "recommended_price": 30_000_000, + "lower_price": 25_000_000, + "higher_price": 35_000_000, + "market_count": 500, + "rooms": 1, + "area_m2": _AREA, + } + + est = _run_estimate_qi( + analogs=_ANALOGS_OTHER_QUARTER, + dadata_cadnum=f"{_TARGET_QUARTER}:350", + qi_lookup_result=(1.2, 30), + analog_indexes={_OTHER_QUARTER: 1.0}, + imv_anchor=thick_anchor, + ) + + assert est.median_price_rub == round(6_000_000 * 0.5 + 30_000_000 * 0.5) # 18_000_000 + assert "квартал" not in (est.confidence_explanation or "").lower() + + def test_guard1b_imv_anchor_below_blend_threshold_prevents_correction() -> None: """Guard-1b: IMV anchor присутствует но ниже blend-порога (blended=False). From 5d9ff13daea8d3b47ca8fea805497e5fee58c99d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:47:28 +0500 Subject: [PATCH 022/121] =?UTF-8?q?fix(caddy):=20=D0=B8=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20=5Fnot-found=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=20=D1=87=D0=B0=D0=BD?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью #3340: `chunks/app/_not-found/` — внутренняя страница Next, а не маршрут продукта; фильтр её глушил, и клиентская навигация по битому href падала в error-boundary вместо честного «не найдено». Закрытого кода она не несёт. Заодно в комментарии матчера явно зафиксировано, что `static/css/app/<маршрут>/*` оставлен без фильтра сознательно — тот же класс «имена, а не код», потолок уже задекларирован. --- caddy/sites/apps.caddy | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index 83f6293e..8695088d 100644 --- a/caddy/sites/apps.caddy +++ b/caddy/sites/apps.caddy @@ -496,10 +496,17 @@ meraocenka.ru { # ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» — # `not` вместо негативного lookahead, RE2 его не поддерживает. # + # `_not-found` в исключениях — это не маршрут продукта, а внутренняя + # страница Next (404 при клиентской навигации по битому href). Закрытого + # кода не несёт, а без исключения клиентский переход падал бы в + # error-boundary вместо честного «не найдено». + # # Ограничение честно: это убирает КОД, но не имена файлов — карта чанков # лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень - # маршрутов из неё по-прежнему вычитывается. Полное разделение — split - # build (отдельный public-бандл), отдельная задача. + # маршрутов из неё по-прежнему вычитывается. По той же причине оставлены + # без фильтра и `static/css/app/<маршрут>/*`: это тот же класс «имена, а не + # код», и городить второй матчер ради него смысла нет. Полное разделение — + # split build (отдельный public-бандл), отдельная задача. # # `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок # директив Caddy), поэтому вложенный матчер не зависит от сортировки @@ -507,7 +514,7 @@ meraocenka.ru { handle /trade-in/_next/static/* { @foreignRouteChunk { path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/ - not path /trade-in/_next/static/chunks/app/mera-public/* + not path /trade-in/_next/static/chunks/app/mera-public/* /trade-in/_next/static/chunks/app/_not-found/* } respond @foreignRouteChunk 404 From 5541aa9d376621278819599288bc255c1d2cb661 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 3 Sep 2026 09:50:41 +0300 Subject: [PATCH 023/121] =?UTF-8?q?fix(mera-public):=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D0=B0=D1=8F=20=D0=BA=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=8F=20=E2=80=94=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D1=81=D0=B5=D0=B1=D1=8F=20=D0=BE=D1=86=D0=B5=D0=BD=D1=89?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=BC,=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0?= =?UTF-8?q?=D0=B4=D0=BA=D0=B8-=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BD=D0=B5=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три юр-правки по документу владельца продукта «Сайт_МЕРА_v2» (31.08.2026), сделаны локально 31.08, но не были закоммичены — на meraocenka.ru всё оставалось по-старому. 1. «Оценщик» про собственный алгоритм. Витрина сделок (`landing_showcase_deals.py`, REJECTION_RULE/NOTE) и сноска статьи «Как оценить квартиру» теперь говорят «расчёт МЕРЫ»: самоназвание обесценивало дисклеймер «не официальный отчёт оценщика». Комментарии и докстринги не тронуты — посетитель их не видит. 2. «Путь 2»: «стоимость услуг фиксированная и известна заранее» — читалось как фиксированная цена квартиры. 3. Названия площадок убраны из видимой копии лендинга и веб-отчёта. Канон — `publicLabel` + `sourcePublicLabel()` в `source-registry.ts`: одна площадка = один номер «Источник N» и один цвет точки (цвет остаётся опознавателем между блоками), Росреестр под своим именем, неизвестный id → «Другой источник» (раньше fallback отдавал сырой id). Три параллельные реализации `sourceLabel` сведены к одной; `sourceLabel()` с реальными именами живёт для админки. Backend: якорь confidence_explanation «по оценке Avito IMV» → «по оценочной модели площадки». Подсказки геокодера «Yandex / Nominatim» и «по Яндексу» сняты из клиентских форм. Гейт `public-copy-no-platform-names.test.ts` сканирует mera-public/** и components/trade-in/** без комментариев, по Unicode-границе слова; список запретов регистрозависимый намеренно (строчные id `"avito"` законны), поэтому капс-варианты и словоформы перечислены явно — «6202 ОБЪЯВЛЕНИЯ ДОМКЛИК» в статье именно так проходил первую версию гейта. Не закрыто здесь: клиентский PDF (#3341) и ссылки на объявления на доменах площадок. Текст витрины хранится в БД (`landing_showcase_runs.rejection_rule`, `landing_showcase_deals.note`), планировщика у пересчёта нет — после деплоя нужен ручной пересчёт или UPDATE трёх подстрок на проде. --- tradein-mvp/backend/app/services/estimator.py | 10 +- .../app/tasks/landing_showcase_deals.py | 6 +- .../test_2677_house_anchor_repair_basis.py | 2 +- .../public-copy-no-platform-names.test.ts | 173 ++++++++++++++++++ .../_components/v3/CostOfErrorV3.tsx | 2 +- .../mera-public/_components/v3/SourcesV3.tsx | 9 +- .../mera-public/_components/v3/TwoPathsV3.tsx | 2 +- .../mera-public/articles/articles-content.ts | 4 +- .../frontend/src/app/mera-public/content.ts | 26 ++- .../src/app/mera-public/landing-v3.module.css | 30 +++ tradein-mvp/frontend/src/app/page.tsx | 5 +- .../components/trade-in/CianValuationCard.tsx | 7 +- .../src/components/trade-in/DealsCard.tsx | 6 +- .../src/components/trade-in/EstimateForm.tsx | 2 +- .../src/components/trade-in/HeroSummary.tsx | 18 +- .../src/components/trade-in/IMVBenchmark.tsx | 25 +-- .../src/components/trade-in/ListingsCard.tsx | 6 +- .../src/components/trade-in/MapPicker.tsx | 4 +- .../components/trade-in/PriceHistoryChart.tsx | 25 ++- .../components/trade-in/SourcesProgress.tsx | 2 +- .../components/trade-in/StreetDealsCard.tsx | 5 +- .../src/components/trade-in/offer-rates.ts | 4 +- .../src/components/trade-in/saleShareUtils.ts | 21 +-- .../src/components/trade-in/trade-in.css | 27 ++- .../components/trade-in/v2/AnalyticsView.tsx | 26 +-- .../components/trade-in/v2/LocationDrawer.tsx | 7 +- .../components/trade-in/v2/ParamsPanel.tsx | 2 +- .../src/components/trade-in/v2/mappers.ts | 33 ++-- .../frontend/src/lib/source-registry.ts | 67 +++++-- 29 files changed, 430 insertions(+), 126 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 06763e6f..a3915707 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3347,7 +3347,10 @@ def _price_from_inputs( anchor_higher = ( int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None ) - anchor_label = "оценке Avito IMV" + # Публичная копия: клиент видит эту строку в confidence_explanation + # (HeroSummary), название площадки туда не должно утекать — + # та же норма, что publicLabel в source-registry.ts (решение 31.08.2026). + anchor_label = "оценочной модели площадки" _imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None avito_imv_summary = AvitoImvSummary( recommended_price=anchor_total, @@ -3379,7 +3382,10 @@ def _price_from_inputs( elif imv_eval is not None and imv_eval.recommended_price: anchor_total = int(imv_eval.recommended_price) anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None - anchor_label = "оценке Avito IMV" + # Публичная копия: клиент видит эту строку в confidence_explanation + # (HeroSummary), название площадки туда не должно утекать — + # та же норма, что publicLabel в source-registry.ts (решение 31.08.2026). + anchor_label = "оценочной модели площадки" avito_imv_summary = AvitoImvSummary( recommended_price=anchor_total, lower_price=(int(imv_eval.lower_price) if imv_eval.lower_price else None), diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py index 881f3728..d36fb741 100644 --- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -110,10 +110,10 @@ logger = logging.getLogger(__name__) # диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут # значило бы завести проверку, которая в проде не срабатывает никогда. REJECTION_RULE = ( - "Строка не попадает на витрину, только если данных нет: оценщик не дал " + "Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал " "ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или " "площадь. Величина отклонения на отбор и отбраковку не влияет — иначе " - "витрина показывала бы лучший хвост, а не работу оценщика. Санитарный " + "витрина показывала бы лучший хвост, а не работу расчёта. Санитарный " "диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён " "к выборке до расчёта, по цене самой сделки." ) @@ -123,7 +123,7 @@ NOTE = ( "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " "Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): " "она бывает занижена сторонами, и тогда " - "строка выглядит как промах оценщика, хотя врёт документ. " + "строка выглядит как промах расчёта, хотя врёт документ. " "Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома " "есть у 2.7% строк, поэтому дом не показан и показан быть не может." "Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная " diff --git a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py index 6a4ce8be..65c56b3c 100644 --- a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py +++ b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py @@ -194,7 +194,7 @@ def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None: """ est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair") assert est.median_price_rub == int(_BASE_MEDIAN * 0.94) - assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "") + assert "скорректирована по оценочной модели площадки" not in (est.confidence_explanation or "") def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None: diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts new file mode 100644 index 00000000..60c12c89 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts @@ -0,0 +1,173 @@ +/** + * Реальные названия площадок-источников не должны попадать в видимый + * клиентский UI (лендинг «Мера» + компоненты отчёта trade-in). + * + * Канон: `src/lib/source-registry.ts` — `publicLabel`/`sourcePublicLabel()`. + * Одна площадка = один и тот же нейтральный номер («Источник N»), Росреестр — + * исключение (не листинговая площадка, юр-риска в названии нет). Причина — + * тот же юр-риск, что закрывал #2211/#3264-scope: конкретное название + * площадки в клиентском отчёте — коммерческая информация, которую посетитель + * видеть не должен. + * + * ОТЛИЧИЕ от соседнего `public-copy-no-infra.test.ts`: тот гейт специально + * сканирует ВЕСЬ текст файла, включая комментарии («на боевой базе» в + * комментарии так же вредно, как в строке — комментарий может стать + * заготовкой будущей копии). Здесь наоборот: название площадки в комментарии + * ЗАКОННО и нужно разработчику — оно объясняет происхождение данных + * (`// avito_imv — реальная Avito IMV-оценка`), а `sourceLabel`/`SOURCES` + * в `source-registry.ts` обязаны хранить реальные лейблы для админки. Поэтому + * перед проверкой из текста вырезаются `//`- и `/* *\/`-комментарии, и гейт + * смотрит только на то, что реально дойдёт до посетителя в виде строки. + * + * Ограничение вырезки комментариев: `//` не режется, если ему предшествует + * `:` (т.е. это `https://`/`http://` внутри строки, а не начало комментария). + * Полноценный JS/TS-парсер тут избыточен — в скоупе сканирования нет `//` + * иначе как в URL или в начале комментария (проверено вручную). + * + * Второе отличие от инфра-гейта: тот matches подстрокой (`includes`) — + * инфраструктурные имена никогда не встречаются внутри TS-идентификаторов. + * Названия площадок встречаются: тип `CianValuationSummary`, хук + * `useEstimateCianPriceChanges`, переменная `cianId` — это ЗАКОННЫЙ код + * (правило задачи: имена переменных/типов/хуков не трогаем, меняется только + * то, что видит человек). Поэтому здесь матчинг по границе слова + * (Unicode-aware `\p{L}`/`\p{N}`, не ASCII `\b`, иначе он вообще не сработает + * на кириллице), а не голый `includes`. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const MERA_PUBLIC_ROOT = join(__dirname, ".."); +const TRADE_IN_COMPONENTS_ROOT = join(__dirname, "../../../components/trade-in"); + +/** + * Реальные названия площадок. Список ЯВНЫЙ (не эвристика по паттерну) — + * см. докстринг соседнего инфра-гейта про тот же выбор: эвристика ловила бы + * законные вещи (напр. домены listing-ссылок avito.ru/cian.ru, которые + * остаются в href по правилу «URL объявлений не трогаем»). + * + * Матчинг НАМЕРЕННО регистрозависимый: строчные `"avito"` / `"cian"` / + * `"yandex"` — это id источников (`keys: ["avito", "avito_imv"]`, + * `sourcePublicLabel("cian")`), и они законны. Поэтому капс-варианты + * (плитки, сноски статей — «6202 ОБЪЯВЛЕНИЯ ДОМКЛИК» жил в статье и + * проходил гейт) и словоформы («Домклике», «Яндексу») перечислены явно. + * Яндекс/Yandex здесь и как площадка (Я.Недвижимость), и как геокодер + * («Yandex / Nominatim» в подсказке адреса): посетителю не нужен ни тот, + * ни другой. + */ +const BANNED = [ + "Avito", + "AVITO", + "Авито", + "АВИТО", + "Циан", + "ЦИАН", + "Циана", + "Циане", + "Cian", + "CIAN", + "Я.Недвижимость", + "Я.НЕДВИЖИМОСТЬ", + "ДомКлик", + "Домклик", + "ДОМКЛИК", + "Домклике", + "Домклика", + "Domclick", + "DOMCLICK", + "Яндекс", + "ЯНДЕКС", + "Яндексу", + "Яндекса", + "Яндекс.Недвижимость", + "Yandex", + "YANDEX", + "Этажи", + "ЭТАЖИ", + "Avito Оценка", + "Я.Оценка", + "Циан Оценка", +]; + +/** + * Файлы копии в скоупе: лендинг «Мера» + компоненты отчёта trade-in. + * `src/app/scrapers/**` и `src/components/scrapers/**` (admin-only, отдельные + * top-level каталоги) вне обоих корней сканирования — их сюда не заносит. + */ +function copySources(dir: string, acc: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (name === "__tests__" || name === "node_modules") continue; + const full = join(dir, name); + if (statSync(full).isDirectory()) { + copySources(full, acc); + } else if (name.endsWith(".ts") || name.endsWith(".tsx")) { + acc.push(full); + } + } + return acc; +} + +/** Вырезает `/* *\/`- и `//`-комментарии; `//` внутри `https://` не режет. */ +function stripComments(text: string): string { + // Newlines внутри блочного комментария сохраняем (заменяем на "\n" по числу + // строк), иначе после вырезки съедутся номера строк в отчёте о находке. + const noBlock = text.replace(/\/\*[\s\S]*?\*\//g, (m) => + "\n".repeat((m.match(/\n/g) ?? []).length), + ); + return noBlock + .split("\n") + .map((line) => { + // Ищем `//`, которому НЕ предшествует `:` (т.е. не часть `http(s)://`). + const idx = line.search(/(? { + const files = [ + ...copySources(MERA_PUBLIC_ROOT), + ...copySources(TRADE_IN_COMPONENTS_ROOT), + ]; + + it("файлы копии вообще найдены — иначе гейт зелёный по построению", () => { + expect(files.length).toBeGreaterThan(20); + expect(files.some((f) => f.endsWith("HeroSummary.tsx"))).toBe(true); + expect(files.some((f) => f.endsWith("IMVBenchmark.tsx"))).toBe(true); + }); + + it.each(BANNED)("нигде в видимой копии не встречается «%s»", (needle) => { + const hits: string[] = []; + for (const file of files) { + const raw = readFileSync(file, "utf8"); + const text = stripComments(raw); + text.split("\n").forEach((line, i) => { + if (hasWholeWord(line, needle)) { + hits.push(`${file}:${i + 1}`); + } + }); + } + expect( + hits, + `«${needle}» — реальное название площадки в видимой клиентской копии. ` + + `Используй sourcePublicLabel() из src/lib/source-registry.ts вместо ` + + `хардкода: посетитель отчёта не должен видеть, какие площадки реально ` + + `участвовали в оценке (юр-риск, #3264-scope). В комментариях это имя ` + + `законно — гейт сюда не смотрит.`, + ).toEqual([]); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx index 1d700573..69132cb7 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx @@ -66,7 +66,7 @@ export function CostOfErrorV3({ stats }: { stats: LandingStats }) { tiles.push({ key: "share", value: share.text, - title: "столько объявлений на Домклике снижали цену", + title: "столько объявлений на одной из площадок снижали цену", text: [share.note, share.sample].filter(Boolean).join(" · "), }); } diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx index ea0a9a75..2aebb587 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx @@ -51,9 +51,12 @@ export function SourcesV3() {

{group.title}

    {group.items.map((item) => ( -
  • -
  • +
  • ))}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx index 2642138b..ae38d5b1 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx @@ -63,7 +63,7 @@ export function TwoPathsV3() { Отчёта достаточно, чтобы справиться самому. Но если нет времени или не хочется вести показы и торг — передайте продажу нашим специалистам: объявление, фото, показы, переговоры, сделка. Работаем - по цене из вашего отчёта, стоимость фиксированная и известна + по цене из вашего отчёта, стоимость услуг фиксированная и известна заранее. Ни к чему не обязывает — просто узнайте условия.

` в trade-in.css / `srcDot` в landing-v3.module.css. */ + readonly dot: string; +} + export interface SourceGroup { readonly title: string; - readonly items: readonly string[]; + readonly items: readonly SourceChip[]; readonly note: string; } @@ -330,22 +338,28 @@ export interface SourceGroup { * Осознанное сужение: в `SOURCES` у сделок есть ещё «Этажи» (kind: "deals"), * но на публичной странице говорим только про Росреестр — это продуктовое * решение владельца («сделки Росреестра + объявления площадок»), а не - * недосмотр. Лейбл берём через `sourceLabel`, чтобы не разъехаться с реестром. + * недосмотр. Лейбл — публичный (`publicLabel`, «Источник N»), а не название + * площадки (юр-риск, владелец продукта запретил показывать бренды посетителю + * лендинга); цвет точки (`dot`) при этом сохраняется — он опознаёт источник + * между секциями, как и раньше. */ export const SOURCE_GROUPS: readonly SourceGroup[] = [ { title: "Зарегистрированные сделки", - items: [sourceLabel("rosreestr")], + items: [{ label: sourcePublicLabel("rosreestr"), dot: sourceDot("rosreestr") }], note: "Цены, по которым квартиры действительно перешли к новым собственникам — по договорам купли-продажи. Сначала смотрим сделки по вашему дому, а если их мало — по ближайшему окружению.", }, { title: "Объявления о продаже", - items: LIVE_LISTING_SOURCES.map((s) => s.label), + items: LIVE_LISTING_SOURCES.map((s) => ({ label: s.publicLabel, dot: s.dot })), note: "Что просят прямо сейчас за похожие квартиры: площадь, этаж, тип дома, состояние.", }, { title: "Оценочные модели площадок", - items: SOURCES.filter((s) => s.kind === "valuation").map((s) => s.label), + items: SOURCES.filter((s) => s.kind === "valuation").map((s) => ({ + label: s.publicLabel, + dot: s.dot, + })), note: "Собственные оценки площадок мы не игнорируем, но и не выдаём за свои: они идут в дело как сверка, когда по дому не набралось ни сделок, ни близких аналогов. Если расчёт был скорректирован по такой оценке, это написано в самом отчёте.", }, ]; diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css index 8c6feab8..7ad86759 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css +++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css @@ -3019,6 +3019,36 @@ background: var(--b2c-accent); } +/* + * Цвет точки источника — тот же опознаватель, что в отчёте (src-dot.* в + * trade-in.css), но лендинг (`landing-v3.module.css`) — отдельный CSS-модуль + * без доступа к переменным из trade-in.css (разные скоупы). Значения ниже + * продублированы 1:1 из trade-in.css (--src-* рядом с viz-палитрой), чтобы + * цвет точки совпадал между лендингом и отчётом. Правишь там — правь здесь: + * связь держится этим комментарием, а не кодом. + * + * Сами цвета ≈ фирменные цвета площадок (решение 31.08.2026): название + * заменено на «Источник N», опознаватель — цвет, и он намеренно узнаваем. + */ +.srcChipDot--cian { + background: #0468ff; /* trade-in.css --src-cian */ +} +.srcChipDot--avito { + background: #00aaff; /* trade-in.css --src-avito */ +} +.srcChipDot--dom { + background: #21a038; /* trade-in.css --src-dom */ +} +.srcChipDot--yandex { + background: #ffcc00; /* trade-in.css --src-yandex */ +} +.srcChipDot--etagi { + background: #f15044; /* trade-in.css --src-etagi */ +} +.srcChipDot--rosreestr { + background: var(--b2c-ink); /* trade-in.css --fg */ +} + .srcNote { margin: 0; font-size: 14px; diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 125466c3..834247d2 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -40,7 +40,8 @@ import { LIVE_LISTING_SOURCES } from "@/lib/source-registry"; // #2211 находка 7: список источников объявлений выводится из source-registry // (источник n1 полностью выключен #2204 → его тут больше нет), а не хардкодится числом. -const LISTING_SOURCE_NAMES = LIVE_LISTING_SOURCES.map((s) => s.label).join(", "); +// Публичный лейбл (не название площадки) — клиентская страница отчёта, юр-риск. +const LISTING_SOURCE_NAMES = LIVE_LISTING_SOURCES.map((s) => s.publicLabel).join(", "); function useEstimateId() { if (typeof window === "undefined") return null; @@ -348,7 +349,7 @@ export default function TradeInPage() {