fix(admin,server): 修复 Dashboard 甘特图时间线与数据校验
Some checks failed
CI / check (pull_request) Failing after 1m54s

This commit is contained in:
2026-08-07 16:38:16 +08:00
parent 5f9566e26d
commit 8d4ebcf9c0
9 changed files with 160 additions and 19 deletions

View 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);
});
});

View File

@@ -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(

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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],
};
}),
), ),
}, },
], ],

View File

@@ -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 }}
/> />
) : ( ) : (

View File

@@ -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>
); );
}; };

View File

@@ -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');

View File

@@ -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) };