From 84a65d40dd410d2a7172d435041c7c208e80731e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 10 Aug 2026 08:39:59 +0000 Subject: [PATCH] =?UTF-8?q?fix(site-finder):=20=C2=A74.1=20=C2=AB=D0=9F?= =?UTF-8?q?=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=D1=82=D1=8C=C2=BB=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D1=82=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B2=D0=B5=D1=81=D0=B0=20POI=20(#2790)=20(#2810)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/[cad]/AnalysisPageContent.tsx | 51 +++++- .../AnalysisPageContent.weights.test.tsx | 159 ++++++++++++++++++ .../site-finder/WeightProfilePanel.tsx | 15 +- .../Section3SettingsAndCompetitors.tsx | 41 +++-- .../__tests__/useParcelAnalyzeQuery.test.ts | 12 +- frontend/src/lib/api/weightProfiles.ts | 67 +++----- frontend/src/lib/site-finder-api.ts | 42 ++++- 7 files changed, 318 insertions(+), 69 deletions(-) create mode 100644 frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 03be7b0d..41cd3e4d 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -15,7 +15,12 @@ import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5At import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast"; import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept"; import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives"; -import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api"; +import { + AnalyzeWeightsContext, + adaptEgrn, + useParcelAnalyzeQuery, +} from "@/lib/site-finder-api"; +import type { PoiCategoryKey } from "@/lib/api/weightProfiles"; import type { ParcelAnalysis, PendingConceptProgram, @@ -29,7 +34,40 @@ interface Props { // ── Page Content (client — needs TanStack Query) ─────────────────────────────── +/** + * Обёртка над телом страницы: держит применённые в §4.1 POI-веса и кладёт их в + * контекст ВЫШЕ всех вызовов useParcelAnalyzeQuery (#2790). Своё состояние + * нельзя было оставить в теле: собственный вызов useParcelAnalyzeQuery читал бы + * контекст «сверху», то есть null, и страница разъехалась бы на два разных + * анализа — свой у шапки, свой у секций. + * + * null = веса не применяли → запрос как раньше, без тела. + */ export function AnalysisPageContent({ cad }: Props) { + const [appliedWeights, setAppliedWeights] = useState | null>(null); + + return ( + + + + ); +} + +function AnalysisPageBody({ + cad, + appliedWeights, + onWeightsApply, +}: Props & { + appliedWeights: Record | null; + onWeightsApply: (weights: Record) => void; +}) { const [horizon, setHorizon] = useState(12); const queryClient = useQueryClient(); @@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) { {/* ── Группа «Стройка и рынок» ──────────────────────────────── */} - {/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */} - + {/* 4. Рынок и конкуренты — IMPLEMENTED in A7. Веса POI из §4.1 + поднимаем сюда: «Применить» меняет ключ analyze-запроса → скор + пересчитывается по ползункам во ВСЕХ секциях (#2790). */} + {/* 5. Атмосфера — IMPLEMENTED in A11 */} diff --git a/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx new file mode 100644 index 00000000..4cc7e35e --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx @@ -0,0 +1,159 @@ +/** + * #2790 п.1 — «Применить» у весов POI в §4.1 ничего не применяло. + * + * Состояние весов жило в `Section31Settings` и читалось только обратно в ту же + * панель: до `/analyze` оно не доезжало никогда (слова `weights` в + * AnalysisPageContent не было вовсе). Пользователь двигал ползунки, жал + * «Применить» и получал ТОТ ЖЕ скор, посчитанный по системным весам. + * + * Тест идёт живым путём: рендерит настоящую страницу с настоящей §4.1 и + * настоящим `useParcelAnalyzeQuery` (замокан только тяжёлый обвес — карты, + * прогноз, концепция) и смотрит, что уходит в сеть. На коде до фикса второй + * POST /analyze не случается вообще → красный. + */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AnalysisPageContent } from "../AnalysisPageContent"; + +// Тяжёлые секции не участвуют в контракте «ползунки → запрос»: они тянут +// Leaflet / ECharts / собственные poll-запросы. §3 (настройки + панель весов) — +// НАСТОЯЩАЯ, как и useParcelAnalyzeQuery: они и есть предмет теста. +vi.mock("@/components/site-finder/ChatDock", () => ({ ChatDock: () => null })); +vi.mock("@/components/site-finder/GateVerdictBanner", () => ({ + GateVerdictBanner: () => null, +})); +vi.mock("@/components/site-finder/HorizonSelector", () => ({ + HorizonSelector: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({ + Section1ParcelInfo: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({ + Section2NetworksUtilities: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({ + Section4Estimate: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({ + Section5Atmosphere: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({ + Section6Forecast: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section7Concept", () => ({ + Section7Concept: () => null, +})); +vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({ + SectionAlternatives: () => null, +})); +vi.mock("@/components/site-finder/BestLayoutsBlock", () => ({ + BestLayoutsBlock: () => null, +})); + +const CAD = "66:41:0702017:131"; + +const ANALYSIS = { + cad_num: CAD, + score: 18.91, + district: { district_name: "Чкаловский" }, + egrn: null, + competitors: [], +}; + +/** Тела всех POST /analyze в порядке отправки. undefined = запрос без тела. */ +const analyzeBodies: Array | undefined> = []; + +const fetchMock = vi.fn(); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + analyzeBodies.length = 0; + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input, init) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/analyze")) { + const raw = init?.body; + analyzeBodies.push( + typeof raw === "string" + ? (JSON.parse(raw) as Record) + : undefined, + ); + return jsonResponse(ANALYSIS); + } + if (url.includes("/api/v1/me")) { + return jsonResponse({ + username: "admin", + role: "admin", + allowed_paths: ["/**"], + deny_paths: [], + }); + } + if (url.includes("/weight-profiles")) { + return jsonResponse([]); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +/** Ползунок конкретной категории по подписи строки в панели весов. */ +function sliderFor(label: string): HTMLInputElement { + const row = screen.getByText(label).closest("div"); + if (!row) throw new Error(`не нашёл строку ползунка «${label}»`); + const input = row.querySelector('input[type="range"]'); + if (!input) throw new Error(`в строке «${label}» нет ползунка`); + return input as HTMLInputElement; +} + +describe("§4.1 «Применить» доносит веса до /analyze (#2790)", () => { + it("отправляет ползунки в тело повторного analyze", async () => { + renderPage(); + + // Первичный анализ — без весов (ничего не применяли): тело не шлём вовсе, + // бэкенд считает по системным. Это же и baseline для «стало другим». + await waitFor(() => expect(analyzeBodies.length).toBe(1)); + expect(analyzeBodies[0]).toBeUndefined(); + + fireEvent.click(await screen.findByText("POI Веса")); + fireEvent.change(sliderFor("Парки"), { target: { value: "3" } }); + fireEvent.change(sliderFor("Трамвайные ост. (−)"), { + target: { value: "-2" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Применить" })); + + // Главное утверждение: analyze уходит ЗАНОВО и несёт ровно те веса, что + // выставлены ползунками. До фикса второго запроса не было — красный здесь. + await waitFor(() => expect(analyzeBodies.length).toBe(2)); + const applied = analyzeBodies[1]?.weights as Record; + expect(applied.park).toBe(3); + expect(applied.tram_stop).toBe(-2); + // Нетронутые категории уходят как есть — бэкенд мержит поверх системных, + // но панель отправляет полный набор, чтобы ответ совпадал с ползунками. + expect(applied.school).toBe(1.5); + }); +}); diff --git a/frontend/src/components/site-finder/WeightProfilePanel.tsx b/frontend/src/components/site-finder/WeightProfilePanel.tsx index 60fcbe5b..5d5731f5 100644 --- a/frontend/src/components/site-finder/WeightProfilePanel.tsx +++ b/frontend/src/components/site-finder/WeightProfilePanel.tsx @@ -10,6 +10,7 @@ import { POI_LABELS, POI_WEIGHT_MAX, POI_WEIGHT_MIN, + SYSTEM_PROFILE_USER_ID, useCreateProfile, useWeightProfiles, type PoiCategoryKey, @@ -110,7 +111,18 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) { } function handleApply() { - onWeightsChange({ ...draft }, selectedProfileId); + // Системный пресет не адресуем через profile_id: resolve_weights() ищет + // профиль в области ВЛАДЕЛЬЦА, а владелец пресета — `__system__`, не + // текущий пользователь. Бэкенд его не найдёт, тихо возьмёт дефолтные веса и + // отрапортует `weights_profile.source = "profile"` (#2782). Поэтому для + // пресета отдаём profileId = null — вызывающая сторона пошлёт inline-веса, + // а они ровно те, что на ползунках. + const selected = profiles.find((p) => p.id === selectedProfileId) ?? null; + const addressableId = + selected && selected.user_id !== SYSTEM_PROFILE_USER_ID + ? selected.id + : null; + onWeightsChange({ ...draft }, addressableId); } const handleSaveProfile = useCallback(async () => { @@ -258,6 +270,7 @@ export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) { {profiles.map((p) => ( ))} diff --git a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx index 670fb483..6c616440 100644 --- a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx +++ b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx @@ -28,6 +28,10 @@ interface Props { cad: string; /** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */ data: ParcelAnalysis; + /** Уже применённые POI-веса; null = ничего не применяли (системные). */ + weights: Record | null; + /** «Применить» в панели весов — страница перезапрашивает analyze (#2790). */ + onWeightsApply: (weights: Record) => void; } interface FilterState { @@ -86,25 +90,18 @@ function FilterChip({ label, selected, onToggle }: ChipProps) { function Section31Settings({ filters, onFiltersChange, + weights, + onWeightsApply, }: { filters: FilterState; onFiltersChange: (f: FilterState) => void; + weights: Record | null; + onWeightsApply: (weights: Record) => void; }) { - const [weights, setWeights] = useState>( - () => ({ ...POI_DEFAULT_WEIGHTS }), - ); - function toggleChip(key: keyof Omit) { onFiltersChange({ ...filters, [key]: !filters[key] }); } - function handleWeightsChange( - newWeights: Record, - _profileId: number | null, - ) { - setWeights(newWeights); - } - const chips: Array<{ key: keyof Omit; label: string; @@ -136,8 +133,8 @@ function Section31Settings({ margin: "4px 0 0", }} > - Фильтры применяются к конкурентам локально — без повторного запроса к - бэкенду + Радиус и фильтры применяются к конкурентам локально. Веса POI — + пересчёт анализа на бэкенде по кнопке «Применить»

@@ -259,8 +256,8 @@ function Section31Settings({ Профиль весов POI @@ -769,7 +766,12 @@ function applyFilters( // ── Section 3 wrapper ───────────────────────────────────────────────────────── -export function Section3SettingsAndCompetitors({ cad, data }: Props) { +export function Section3SettingsAndCompetitors({ + cad, + data, + weights, + onWeightsApply, +}: Props) { const [filters, setFilters] = useState({ radiusKm: 2, onlyUnderConstruction: false, @@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { {/* Sub-sections */}
- + {/* Competitor table — moved before 3.2/3.3 for context */} {filteredCompetitors.length > 0 && ( diff --git a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts index f84f2033..81f0e074 100644 --- a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts +++ b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts @@ -16,6 +16,7 @@ * directly with a real AbortSignal and a per-URL `fetch` stub, under fake * timers, and assert on abort behaviour + the happy path. */ +import { renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // ── Capture the options passed to useQuery ─────────────────────────────────── @@ -117,12 +118,15 @@ const CAD = "66:41:0701045:42"; * polling queryFn. Reads `captured.options` via a fresh binding so TS control- * flow doesn't pin it (the hook mutates it opaquely through the mock). * - * `useQuery` is fully mocked (it just records its options, no React state), so - * the rules-of-hooks invariant does not apply to this call — disable locally. + * Хук зовём через `renderHook`, а не напрямую: с #2790 он читает применённые + * веса из `AnalyzeWeightsContext` (`useContext`), а вне рендера у React нет + * dispatcher'а → «Cannot read properties of null». `useQuery` по-прежнему + * замокан и просто записывает options; провайдера над хуком нет, значит + * контекст = null, то есть ровно тот случай «весов не применяли», который этот + * тест и гоняет. */ function getQueryFn(): CapturedQueryOptions["queryFn"] { - // eslint-disable-next-line react-hooks/rules-of-hooks - useParcelAnalyzeQuery(CAD, 12); + renderHook(() => useParcelAnalyzeQuery(CAD, 12)); const options = captured.options; if (options === null) throw new Error("useQuery options not captured"); return options.queryFn; diff --git a/frontend/src/lib/api/weightProfiles.ts b/frontend/src/lib/api/weightProfiles.ts index 4aa28bd7..1534e8ce 100644 --- a/frontend/src/lib/api/weightProfiles.ts +++ b/frontend/src/lib/api/weightProfiles.ts @@ -27,14 +27,18 @@ export interface WeightProfileCreate { description?: string | null; } -export interface WeightProfileUpdate { - profile_name?: string; - weights?: Record; - is_default?: boolean; - description?: string | null; -} - // ── Constants ───────────────────────────────────────────────────────────────── + +/** + * Владелец системных пресетов (Эконом / Комфорт / Бизнес) — mirrors + * `SYSTEM_USER_ID` в backend/app/services/site_finder/weight_profiles.py. + * Профили с этим user_id общие для всех и НЕ адресуемы через `profile_id`: + * `resolve_weights()` ищет профиль в области владельца, у чужого пользователя + * его не найдёт и молча вернёт системные веса с ответом `source="profile"` + * (#2782). Их веса уходят в analyze inline — см. WeightProfilePanel. + */ +export const SYSTEM_PROFILE_USER_ID = "__system__"; + // ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES. // Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py. @@ -103,13 +107,20 @@ const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles"; // ── Hooks ───────────────────────────────────────────────────────────────────── -/** List all weight profiles for a given user_id. */ +/** + * Профили пользователя + системные пресеты (#2790). + * + * `include_system=true` домешивает в конец списка три общих пресета (Эконом / + * Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`). + * Без него у пользователя без своих профилей дропдаун пустой — пресеты лежали в + * проде с 16.05.2026 и не были видны никому. + */ export function useWeightProfiles(userId: string) { return useQuery({ queryKey: ["weight-profiles", userId], queryFn: () => apiFetch( - `${BASE_PATH}?user_id=${encodeURIComponent(userId)}`, + `${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`, ), enabled: !!userId, }); @@ -132,35 +143,9 @@ export function useCreateProfile() { }); } -/** Update an existing weight profile by id. */ -export function useUpdateProfile(userId: string, profileId: number) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (payload) => - apiFetch( - `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, - { - method: "PUT", - body: JSON.stringify(payload), - }, - ), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] }); - }, - }); -} - -/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */ -export function useDeleteProfile(userId: string) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (profileId) => - apiFetch( - `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, - { method: "DELETE" }, - ), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] }); - }, - }); -} +// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали +// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет. +// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026, +// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни +// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда; +// понадобится UI — хуки вернутся из истории (мертвее они там не станут). diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index ca64a76d..ebd0542b 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -9,6 +9,7 @@ */ import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { createContext, useContext } from "react"; import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api"; import { abortableSleep } from "@/lib/abortableSleep"; import type { @@ -503,9 +504,36 @@ export interface PoiScoreResponse { const ANALYZE_POLL_INTERVAL_MS = 2000; const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap +/** + * Применённые в §4.1 POI-веса (#2790). `null` = ничего не применяли → запрос + * уходит без тела, как и раньше (бэкенд считает по системным весам). + * + * Почему контекст, а не проп: на странице анализа `useParcelAnalyzeQuery(cad)` + * зовут ШЕСТЬ мест (§1, §2, §4, §5, сама страница, /ptica) — все они делят один + * ключ кэша `["parcel-analyze", cad, horizon]` и один дорогой (10-30 c) запрос. + * Если веса доедут только до части из них, ключи разойдутся: половина страницы + * покажет скор по одним весам, половина по другим, и /analyze уйдёт дважды. + * Контекст держит всех потребителей ключа на одном значении по построению — + * забыть прокинуть проп в новую секцию нельзя. + */ +export const AnalyzeWeightsContext = createContext | null>(null); + export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { + const weights = useContext(AnalyzeWeightsContext); + // Стабильный кусок ключа: порядок ключей объекта не гарантирован, сортируем. + // null (весов не применяли) оставляем null — ключ тогда совпадает с ключом до + // #2790, кэш не сбрасывается на ровном месте. + const weightsKey = weights + ? JSON.stringify(Object.entries(weights).sort()) + : null; + return useQuery({ - queryKey: ["parcel-analyze", cad, horizon], + // Префикс ["parcel-analyze", cad] сохранён: по нему инвалидируют custom-POI + // мутации (useCustomPois) — они матчатся по префиксу, любой хвост подойдёт. + queryKey: ["parcel-analyze", cad, horizon, weightsKey], // TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts // on unmount and whenever the queryKey changes (смена cad/horizon). Thread // it through the POST/GET fetches and check it before each poll iteration so @@ -522,11 +550,19 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { cad, )}/analyze?horizon=${horizon}`; + // Inline POI-веса (#201) из §4.1. Шлём именно inline, а не profile_id: + // тело запроса == ползункам панели, и ответ рапортует source="inline" — + // расхождению между показанными весами и посчитанным скором взяться + // неоткуда (в отличие от profile_id, см. #2782). + const analyzeInit: RequestInit = weights + ? { method: "POST", signal, body: JSON.stringify({ weights }) } + : { method: "POST", signal }; + // First request — POST /analyze. apiFetchWithStatus surfaces the 202 // Accepted code instead of treating it as a successful payload. const first = await apiFetchWithStatus< ParcelAnalyzeResponse | AnalyzeAcceptedResponse - >(analyzeUrl, { method: "POST", signal }); + >(analyzeUrl, analyzeInit); // 200 → geometry was cached, full analysis is ready. if (first.status === 200) { @@ -553,7 +589,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { // rather than returning the stub (symmetry with the first request). const second = await apiFetchWithStatus< ParcelAnalyzeResponse | AnalyzeAcceptedResponse - >(analyzeUrl, { method: "POST", signal }); + >(analyzeUrl, analyzeInit); if (second.status === 200) { return second.body as ParcelAnalyzeResponse; }