fix(admin,server): 修复 Dashboard 甘特图时间线与数据校验
Some checks failed
CI / check (pull_request) Failing after 1m54s
Some checks failed
CI / check (pull_request) Failing after 1m54s
This commit is contained in:
44
apps/admin/src/api/schemas/dashboard.integration.test.ts
Normal file
44
apps/admin/src/api/schemas/dashboard.integration.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ganttRoomsSchema } from './dashboard';
|
||||||
|
|
||||||
|
describe('ganttRoomsSchema 接口校验', () => {
|
||||||
|
const validPayload = [
|
||||||
|
{
|
||||||
|
roomNumber: 'A101',
|
||||||
|
occupancies: [
|
||||||
|
{
|
||||||
|
studentName: '张三',
|
||||||
|
studentId: 3,
|
||||||
|
checkInDate: '2026-05-01',
|
||||||
|
checkOutDate: null,
|
||||||
|
billingStartDate: '2026-05-01',
|
||||||
|
billingEndDate: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('接受合法的甘特图数据(studentId 为数字)', () => {
|
||||||
|
expect(ganttRoomsSchema.safeParse(validPayload).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝缺少 checkInDate 的入住记录', () => {
|
||||||
|
const payload = [
|
||||||
|
{
|
||||||
|
roomNumber: 'A101',
|
||||||
|
occupancies: [{ studentName: '张三', checkOutDate: null }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(ganttRoomsSchema.safeParse(payload).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝缺少 studentName 的入住记录', () => {
|
||||||
|
const payload = [
|
||||||
|
{
|
||||||
|
roomNumber: 'A101',
|
||||||
|
occupancies: [{ checkInDate: '2026-05-01', checkOutDate: null }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(ganttRoomsSchema.safeParse(payload).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,8 +59,19 @@ export const classAttendanceRankingSchema = z
|
|||||||
|
|
||||||
export const ganttRoomsSchema = z.array(
|
export const ganttRoomsSchema = z.array(
|
||||||
z
|
z
|
||||||
.object({ roomNumber: z.string(), occupancies: z.array(z.record(z.string(), z.unknown())) })
|
.object({
|
||||||
.passthrough(),
|
roomNumber: z.string(),
|
||||||
|
occupancies: z.array(
|
||||||
|
z.object({
|
||||||
|
studentName: z.string(),
|
||||||
|
studentId: z.union([z.string(), z.number()]).optional(),
|
||||||
|
checkInDate: z.string(),
|
||||||
|
checkOutDate: z.string().nullable(),
|
||||||
|
billingStartDate: z.string().optional(),
|
||||||
|
billingEndDate: z.string().nullable().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const classroomOccupanciesSchema = z.array(
|
export const classroomOccupanciesSchema = z.array(
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export interface ExpenseByTypeRow {
|
|||||||
}
|
}
|
||||||
export interface GanttOccupancy {
|
export interface GanttOccupancy {
|
||||||
studentName: string;
|
studentName: string;
|
||||||
studentId?: string;
|
studentId?: string | number;
|
||||||
checkInDate: string;
|
checkInDate: string;
|
||||||
checkOutDate: string | null;
|
checkOutDate: string | null;
|
||||||
billingStartDate?: string;
|
billingStartDate?: string;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildGanttOption } from './DashboardCharts';
|
||||||
|
import type { GanttOccupancy } from './Dashboard.types';
|
||||||
|
|
||||||
|
const ganttRoom = (occupancies: GanttOccupancy[]) => [
|
||||||
|
{ roomNumber: 'A101', occupancies },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('buildGanttOption 甘特图时间线', () => {
|
||||||
|
it('未退宿的入住条在查看过去月份时截断到 periodEnd,而不是画到今天', () => {
|
||||||
|
const option = buildGanttOption(
|
||||||
|
ganttRoom([
|
||||||
|
{
|
||||||
|
studentName: '张三',
|
||||||
|
checkInDate: '2026-05-01',
|
||||||
|
checkOutDate: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{ periodEnd: '2026-06-30', today: '2026-08-07' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
|
||||||
|
expect(series[0].data[0].value[2]).toBe('2026-06-30');
|
||||||
|
expect(series[0].data[0].value[3]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未退宿的入住条在查看当前月时截断到今天', () => {
|
||||||
|
const option = buildGanttOption(
|
||||||
|
ganttRoom([
|
||||||
|
{
|
||||||
|
studentName: '张三',
|
||||||
|
checkInDate: '2026-07-01',
|
||||||
|
checkOutDate: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{ periodEnd: '2026-08-31', today: '2026-08-07' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
|
||||||
|
expect(series[0].data[0].value[2]).toBe('2026-08-07');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('已退宿的入住条保留真实退宿日期', () => {
|
||||||
|
const option = buildGanttOption(
|
||||||
|
ganttRoom([
|
||||||
|
{
|
||||||
|
studentName: '李四',
|
||||||
|
checkInDate: '2026-05-01',
|
||||||
|
checkOutDate: '2026-06-15',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{ periodEnd: '2026-06-30', today: '2026-08-07' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>;
|
||||||
|
expect(series[0].data[0].value[2]).toBe('2026-06-15');
|
||||||
|
expect(series[0].data[0].value[3]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { EChartsOption } from '../../components/ECharts';
|
import type { EChartsOption } from '../../components/ECharts';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
attendanceLabelMap,
|
attendanceLabelMap,
|
||||||
COLORS,
|
COLORS,
|
||||||
@@ -201,7 +202,13 @@ export function buildClassroomHeatmapOption(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption {
|
export function buildGanttOption(
|
||||||
|
ganttData: GanttRoom[],
|
||||||
|
options?: { periodEnd?: string; today?: string },
|
||||||
|
): EChartsOption {
|
||||||
|
const today = options?.today ?? dayjs().format('YYYY-MM-DD');
|
||||||
|
const periodEnd = options?.periodEnd;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
|
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
|
||||||
@@ -246,15 +253,18 @@ export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption {
|
|||||||
},
|
},
|
||||||
encode: { x: [1, 2], y: 0 },
|
encode: { x: [1, 2], y: 0 },
|
||||||
data: ganttData.flatMap((r) =>
|
data: ganttData.flatMap((r) =>
|
||||||
(r.occupancies || []).map((o) => ({
|
(r.occupancies || []).map((o) => {
|
||||||
name: o.studentName,
|
const activeEnd = periodEnd && periodEnd < today ? periodEnd : today;
|
||||||
value: [
|
return {
|
||||||
r.roomNumber,
|
name: o.studentName,
|
||||||
o.checkInDate,
|
value: [
|
||||||
o.checkOutDate || new Date().toISOString().slice(0, 10),
|
r.roomNumber,
|
||||||
!o.checkOutDate,
|
o.checkInDate,
|
||||||
] as [string, string, string, boolean],
|
o.checkOutDate || activeEnd,
|
||||||
})),
|
!o.checkOutDate,
|
||||||
|
] as [string, string, string, boolean],
|
||||||
|
};
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -61,10 +61,11 @@ export const ClassroomHeatmapCard: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
|
export const GanttCard: React.FC<{
|
||||||
data,
|
data: GanttRoom[];
|
||||||
isMobile,
|
isMobile: boolean;
|
||||||
}) => {
|
periodEnd?: string;
|
||||||
|
}> = ({ data, isMobile, periodEnd }) => {
|
||||||
const vp = useInViewport('200px');
|
const vp = useInViewport('200px');
|
||||||
return (
|
return (
|
||||||
<LazySection
|
<LazySection
|
||||||
@@ -74,7 +75,7 @@ export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
|
|||||||
>
|
>
|
||||||
{data.length > 0 ? (
|
{data.length > 0 ? (
|
||||||
<ReactECharts
|
<ReactECharts
|
||||||
option={buildGanttOption(data)}
|
option={buildGanttOption(data, { periodEnd })}
|
||||||
style={{ width: '100%', height: isMobile ? 300 : 450 }}
|
style={{ width: '100%', height: isMobile ? 300 : 450 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -495,7 +495,7 @@ const DashboardPage: React.FC = () => {
|
|||||||
<ClassroomHeatmapCard data={classroomOccupancy} isMobile={isMobile} />
|
<ClassroomHeatmapCard data={classroomOccupancy} isMobile={isMobile} />
|
||||||
|
|
||||||
{/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */}
|
{/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */}
|
||||||
<GanttCard data={ganttData} isMobile={isMobile} />
|
<GanttCard data={ganttData} isMobile={isMobile} periodEnd={period[1]} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ async getGanttData(
|
|||||||
.leftJoinAndSelect('o.student', 'student')
|
.leftJoinAndSelect('o.student', 'student')
|
||||||
.leftJoinAndSelect('o.room', 'room')
|
.leftJoinAndSelect('o.room', 'room')
|
||||||
.where('room.status != :archived', { archived: 'archived' })
|
.where('room.status != :archived', { archived: 'archived' })
|
||||||
|
.andWhere('o.status = :status', { status: 'active' })
|
||||||
.orderBy('room.roomNumber', 'ASC')
|
.orderBy('room.roomNumber', 'ASC')
|
||||||
.addOrderBy('o.checkInDate', 'ASC');
|
.addOrderBy('o.checkInDate', 'ASC');
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const queriesService = (attendanceRepo?: unknown) =>
|
|||||||
|
|
||||||
const createQb = () => ({
|
const createQb = () => ({
|
||||||
leftJoin: jest.fn().mockReturnThis(),
|
leftJoin: jest.fn().mockReturnThis(),
|
||||||
|
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
select: jest.fn().mockReturnThis(),
|
select: jest.fn().mockReturnThis(),
|
||||||
addSelect: jest.fn().mockReturnThis(),
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
@@ -13,9 +14,11 @@ const createQb = () => ({
|
|||||||
groupBy: jest.fn().mockReturnThis(),
|
groupBy: jest.fn().mockReturnThis(),
|
||||||
addGroupBy: jest.fn().mockReturnThis(),
|
addGroupBy: jest.fn().mockReturnThis(),
|
||||||
orderBy: jest.fn().mockReturnThis(),
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
limit: jest.fn().mockReturnThis(),
|
limit: jest.fn().mockReturnThis(),
|
||||||
getRawMany: jest.fn().mockResolvedValue([]),
|
getRawMany: jest.fn().mockResolvedValue([]),
|
||||||
getRawOne: jest.fn().mockResolvedValue({ cnt: '0' }),
|
getRawOne: jest.fn().mockResolvedValue({ cnt: '0' }),
|
||||||
|
getMany: jest.fn().mockResolvedValue([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DashboardService — teacher class scope', () => {
|
describe('DashboardService — teacher class scope', () => {
|
||||||
@@ -48,6 +51,18 @@ describe('DashboardService — teacher class scope', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('DashboardService — boundary conditions', () => {
|
describe('DashboardService — boundary conditions', () => {
|
||||||
|
it('excludes archived occupancy records from the gantt timeline', async () => {
|
||||||
|
const qb = createQb();
|
||||||
|
const occRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||||
|
const queries = queriesService();
|
||||||
|
|
||||||
|
await queries.getGanttData(occRepo as never, () => undefined);
|
||||||
|
|
||||||
|
expect(qb.andWhere).toHaveBeenCalledWith('o.status = :status', {
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('uses a deny-all predicate instead of an empty SQL IN list', async () => {
|
it('uses a deny-all predicate instead of an empty SQL IN list', async () => {
|
||||||
const qb = createQb();
|
const qb = createQb();
|
||||||
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||||
|
|||||||
Reference in New Issue
Block a user