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(
|
||||
z
|
||||
.object({ roomNumber: z.string(), occupancies: z.array(z.record(z.string(), z.unknown())) })
|
||||
.passthrough(),
|
||||
.object({
|
||||
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(
|
||||
|
||||
@@ -55,7 +55,7 @@ export interface ExpenseByTypeRow {
|
||||
}
|
||||
export interface GanttOccupancy {
|
||||
studentName: string;
|
||||
studentId?: string;
|
||||
studentId?: string | number;
|
||||
checkInDate: string;
|
||||
checkOutDate: string | null;
|
||||
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 dayjs from 'dayjs';
|
||||
import {
|
||||
attendanceLabelMap,
|
||||
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 {
|
||||
tooltip: {
|
||||
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 },
|
||||
data: ganttData.flatMap((r) =>
|
||||
(r.occupancies || []).map((o) => ({
|
||||
name: o.studentName,
|
||||
value: [
|
||||
r.roomNumber,
|
||||
o.checkInDate,
|
||||
o.checkOutDate || new Date().toISOString().slice(0, 10),
|
||||
!o.checkOutDate,
|
||||
] as [string, string, string, boolean],
|
||||
})),
|
||||
(r.occupancies || []).map((o) => {
|
||||
const activeEnd = periodEnd && periodEnd < today ? periodEnd : today;
|
||||
return {
|
||||
name: o.studentName,
|
||||
value: [
|
||||
r.roomNumber,
|
||||
o.checkInDate,
|
||||
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 }> = ({
|
||||
data,
|
||||
isMobile,
|
||||
}) => {
|
||||
export const GanttCard: React.FC<{
|
||||
data: GanttRoom[];
|
||||
isMobile: boolean;
|
||||
periodEnd?: string;
|
||||
}> = ({ data, isMobile, periodEnd }) => {
|
||||
const vp = useInViewport('200px');
|
||||
return (
|
||||
<LazySection
|
||||
@@ -74,7 +75,7 @@ export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
|
||||
>
|
||||
{data.length > 0 ? (
|
||||
<ReactECharts
|
||||
option={buildGanttOption(data)}
|
||||
option={buildGanttOption(data, { periodEnd })}
|
||||
style={{ width: '100%', height: isMobile ? 300 : 450 }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -495,7 +495,7 @@ const DashboardPage: React.FC = () => {
|
||||
<ClassroomHeatmapCard data={classroomOccupancy} isMobile={isMobile} />
|
||||
|
||||
{/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */}
|
||||
<GanttCard data={ganttData} isMobile={isMobile} />
|
||||
<GanttCard data={ganttData} isMobile={isMobile} periodEnd={period[1]} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -117,6 +117,7 @@ async getGanttData(
|
||||
.leftJoinAndSelect('o.student', 'student')
|
||||
.leftJoinAndSelect('o.room', 'room')
|
||||
.where('room.status != :archived', { archived: 'archived' })
|
||||
.andWhere('o.status = :status', { status: 'active' })
|
||||
.orderBy('room.roomNumber', 'ASC')
|
||||
.addOrderBy('o.checkInDate', 'ASC');
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const queriesService = (attendanceRepo?: unknown) =>
|
||||
|
||||
const createQb = () => ({
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
@@ -13,9 +14,11 @@ const createQb = () => ({
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
addGroupBy: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
getRawOne: jest.fn().mockResolvedValue({ cnt: '0' }),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
describe('DashboardService — teacher class scope', () => {
|
||||
@@ -48,6 +51,18 @@ describe('DashboardService — teacher class scope', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const qb = createQb();
|
||||
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
|
||||
Reference in New Issue
Block a user