fix(analytics): ObjectSaleChart выровнять серии по union месяцев (#1246) #1309
2 changed files with 162 additions and 13 deletions
|
|
@ -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<string, number>;
|
||||
price: Map<string, number | null>;
|
||||
}
|
||||
> = {};
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | null } = { current: null };
|
||||
vi.mock("../ChartShell", () => ({
|
||||
ChartShell: ({ option }: { option: Record<string, unknown> }) => {
|
||||
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(<ObjectSaleChart objId={1} />);
|
||||
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(<ObjectSaleChart objId={1} />);
|
||||
const opt = lastOption.current as unknown as {
|
||||
title?: { text: string };
|
||||
};
|
||||
expect(opt.title?.text).toBe("Нет данных по продажам");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue