diff --git a/frontend/src/components/analytics/ObjectSaleChart.tsx b/frontend/src/components/analytics/ObjectSaleChart.tsx index 8c7d64a4..950e16cb 100644 --- a/frontend/src/components/analytics/ObjectSaleChart.tsx +++ b/frontend/src/components/analytics/ObjectSaleChart.tsx @@ -23,31 +23,34 @@ export function ObjectSaleChart({ objId }: { objId: number | string }) { yAxis: { show: false }, }; } + // Каждый тип (apartments / parking / nonliv) приходит от DOM.RF отдельным + // запросом со СВОИМ набором месяцев — они не обязаны совпадать (parking + // может стартовать позже). Строим ось X из union всех месяцев и маппим + // значения каждого типа по месяцу через Map (а не позиционно), чтобы бары + // и линия цены не «съезжали» на чужие месяцы и точки не обрезались молча. const byType: Record< string, { - months: string[]; - realised: number[]; - contracted: number[]; - price: (number | null)[]; + realised: Map; + price: Map; } > = {}; for (const p of points) { const t = p.type; - if (!byType[t]) - byType[t] = { months: [], realised: [], contracted: [], price: [] }; - byType[t].months.push((p.report_month ?? "").slice(0, 7)); - byType[t].realised.push(p.realised ?? 0); - byType[t].contracted.push(p.contracted ?? 0); - byType[t].price.push(p.price_avg); + if (!byType[t]) byType[t] = { realised: new Map(), price: new Map() }; + const m = (p.report_month ?? "").slice(0, 7); + byType[t].realised.set(m, p.realised ?? 0); + byType[t].price.set(m, p.price_avg); } - const months = Object.values(byType)[0]?.months ?? []; + const months = Array.from( + new Set(points.map((p) => (p.report_month ?? "").slice(0, 7))), + ).sort(); const series = Object.entries(byType).flatMap(([type, d]) => [ { name: `${type} · реализовано`, type: "bar", stack: type, - data: d.realised, + data: months.map((m) => d.realised.get(m) ?? null), itemStyle: { color: type === "apartments" ? "#1d4ed8" : "#9333ea" }, }, { @@ -56,7 +59,10 @@ export function ObjectSaleChart({ objId }: { objId: number | string }) { yAxisIndex: 1, smooth: true, symbol: "circle", - data: d.price.map((v) => (v ? Math.round(v) : null)), + data: months.map((m) => { + const v = d.price.get(m); + return v ? Math.round(v) : null; + }), lineStyle: { color: type === "apartments" ? "#0a7a3a" : "#c2410c" }, itemStyle: { color: type === "apartments" ? "#0a7a3a" : "#c2410c" }, connectNulls: true, diff --git a/frontend/src/components/analytics/__tests__/ObjectSaleChart.test.tsx b/frontend/src/components/analytics/__tests__/ObjectSaleChart.test.tsx new file mode 100644 index 00000000..32674e2f --- /dev/null +++ b/frontend/src/components/analytics/__tests__/ObjectSaleChart.test.tsx @@ -0,0 +1,143 @@ +import { render } from "@testing-library/react"; +import { vi } from "vitest"; + +import type { ObjectSaleGraphPoint } from "@/types/analytics"; + +// Capture the ECharts `option` passed to ChartShell instead of mounting the +// (SSR-disabled, heavy) ReactECharts dynamic import. +const lastOption: { current: Record | null } = { current: null }; +vi.mock("../ChartShell", () => ({ + ChartShell: ({ option }: { option: Record }) => { + lastOption.current = option; + return null; + }, +})); + +const useObjectSaleGraph = vi.fn(); +vi.mock("@/lib/analytics-api", () => ({ + useObjectSaleGraph: (objId: number | string) => useObjectSaleGraph(objId), +})); + +import { ObjectSaleChart } from "../ObjectSaleChart"; + +interface Series { + name: string; + type: string; + data: (number | null)[]; +} + +function renderWith(points: ObjectSaleGraphPoint[]) { + useObjectSaleGraph.mockReturnValue({ data: points, isLoading: false }); + lastOption.current = null; + render(); + expect(lastOption.current).not.toBeNull(); + const opt = lastOption.current as unknown as { + xAxis: { data: string[] }; + series: Series[]; + }; + const seriesByName = Object.fromEntries( + opt.series.map((s) => [s.name, s.data]), + ); + return { months: opt.xAxis.data, seriesByName }; +} + +function pt( + type: string, + report_month: string, + realised: number, + price_avg: number | null, +): ObjectSaleGraphPoint { + return { + type, + report_month, + realised, + contracted: realised, + area_sq: null, + price_avg, + }; +} + +describe("ObjectSaleChart", () => { + it("builds X axis from the union of all types' months, sorted", () => { + // apartments: May–Jul; parking starts later (Jun–Aug) — divergent sets. + const { months } = renderWith([ + pt("apartments", "2025-05-01", 5, 100000), + pt("apartments", "2025-06-01", 6, 110000), + pt("apartments", "2025-07-01", 7, 120000), + pt("parking", "2025-06-01", 1, 50000), + pt("parking", "2025-07-01", 2, 51000), + pt("parking", "2025-08-01", 3, 52000), + ]); + expect(months).toEqual([ + "2025-05", + "2025-06", + "2025-07", + "2025-08", + ]); + }); + + it("aligns each series to the union axis by month, null where absent", () => { + const { seriesByName } = renderWith([ + pt("apartments", "2025-05-01", 5, 100000), + pt("apartments", "2025-06-01", 6, 110000), + pt("apartments", "2025-07-01", 7, 120000), + pt("parking", "2025-06-01", 1, 50000), + pt("parking", "2025-07-01", 2, 51000), + pt("parking", "2025-08-01", 3, 52000), + ]); + // apartments has no Aug → null in the last slot; parking has no May → null first. + expect(seriesByName["apartments · реализовано"]).toEqual([5, 6, 7, null]); + expect(seriesByName["parking · реализовано"]).toEqual([null, 1, 2, 3]); + // Price line aligned identically (rounded), null where the type has no month. + expect(seriesByName["apartments · цена ₽/м²"]).toEqual([ + 100000, + 110000, + 120000, + null, + ]); + expect(seriesByName["parking · цена ₽/м²"]).toEqual([ + null, + 50000, + 51000, + 52000, + ]); + }); + + it("does not silently drop a later type's extra months (regression)", () => { + // First type (apartments, sorts first) has FEWER months than parking — + // the old positional code would have clipped parking's extra tail. + const { months, seriesByName } = renderWith([ + pt("apartments", "2025-05-01", 5, 100000), + pt("parking", "2025-05-01", 1, 50000), + pt("parking", "2025-06-01", 2, 51000), + pt("parking", "2025-07-01", 3, 52000), + ]); + expect(months).toEqual(["2025-05", "2025-06", "2025-07"]); + expect(seriesByName["parking · реализовано"]).toEqual([1, 2, 3]); + expect(seriesByName["apartments · реализовано"]).toEqual([5, null, null]); + }); + + it("keeps happy-path (identical months) unchanged", () => { + // Current prod reality: every type shares the same month grid. + const { months, seriesByName } = renderWith([ + pt("apartments", "2025-05-01", 5, 100000), + pt("apartments", "2025-06-01", 6, 110000), + pt("parking", "2025-05-01", 1, 50000), + pt("parking", "2025-06-01", 2, 51000), + ]); + expect(months).toEqual(["2025-05", "2025-06"]); + expect(seriesByName["apartments · реализовано"]).toEqual([5, 6]); + expect(seriesByName["parking · реализовано"]).toEqual([1, 2]); + expect(seriesByName["apartments · цена ₽/м²"]).toEqual([100000, 110000]); + }); + + it("shows empty-state title when there are no points", () => { + useObjectSaleGraph.mockReturnValue({ data: [], isLoading: false }); + lastOption.current = null; + render(); + const opt = lastOption.current as unknown as { + title?: { text: string }; + }; + expect(opt.title?.text).toBe("Нет данных по продажам"); + }); +});