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) => (
-
-
- {item}
+
+
+ {item.label}
))}
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() {
Отчёта достаточно, чтобы справиться самому. Но если нет времени или
не хочется вести показы и торг — передайте продажу нашим
специалистам: объявление, фото, показы, переговоры, сделка. Работаем
- по цене из вашего отчёта, стоимость фиксированная и известна
+ по цене из вашего отчёта, стоимость услуг фиксированная и известна
заранее. Ни к чему не обязывает — просто узнайте условия.