From 8cadf8970e1579aa76ca4e4790800864439c1a4b Mon Sep 17 00:00:00 2001
From: wangziqi
Date: Mon, 13 Jul 2026 12:01:38 +0800
Subject: [PATCH 01/13] fix(admin): restore teacher profile form values
---
apps/admin/src/pages/Users/index.tsx | 8 ++++--
.../user-profile-form.integration.test.ts | 27 +++++++++++++++++++
.../src/pages/Users/user-profile-form.ts | 16 +++++++++++
3 files changed, 49 insertions(+), 2 deletions(-)
create mode 100644 apps/admin/src/pages/Users/user-profile-form.integration.test.ts
create mode 100644 apps/admin/src/pages/Users/user-profile-form.ts
diff --git a/apps/admin/src/pages/Users/index.tsx b/apps/admin/src/pages/Users/index.tsx
index 739285f..b6684d1 100644
--- a/apps/admin/src/pages/Users/index.tsx
+++ b/apps/admin/src/pages/Users/index.tsx
@@ -15,6 +15,10 @@ import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
+import {
+ userProfileResponseToFormValues,
+ type UserProfileResponse,
+} from './user-profile-form';
const UsersPage: React.FC = () => {
const [data, setData] = useState([]);
@@ -36,8 +40,8 @@ const UsersPage: React.FC = () => {
const handleOpenProfile = async (record: any) => {
setProfileUser(record);
try {
- const res: any = await api.get(`/rbac/users/${record.id}/profile`);
- profileForm.setFieldsValue(res);
+ const res = await api.get(`/rbac/users/${record.id}/profile`);
+ profileForm.setFieldsValue(userProfileResponseToFormValues(res));
} catch {
profileForm.setFieldsValue({});
}
diff --git a/apps/admin/src/pages/Users/user-profile-form.integration.test.ts b/apps/admin/src/pages/Users/user-profile-form.integration.test.ts
new file mode 100644
index 0000000..9455a8d
--- /dev/null
+++ b/apps/admin/src/pages/Users/user-profile-form.integration.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { userProfileResponseToFormValues } from './user-profile-form';
+
+describe('user profile form mapping', () => {
+ it('unwraps the nested profile returned by the user profile endpoint', () => {
+ expect(
+ userProfileResponseToFormValues({
+ id: 9,
+ username: 'teacher01',
+ name: '测试教师',
+ profile: {
+ joinedAt: '2026-07-01',
+ qualifications: '教师资格证',
+ subjects: ['语文', '历史'],
+ },
+ }),
+ ).toEqual({
+ joinedAt: '2026-07-01',
+ qualifications: '教师资格证',
+ subjects: ['语文', '历史'],
+ });
+ });
+
+ it('returns empty form values when the user has no profile', () => {
+ expect(userProfileResponseToFormValues({ profile: null })).toEqual({});
+ });
+});
diff --git a/apps/admin/src/pages/Users/user-profile-form.ts b/apps/admin/src/pages/Users/user-profile-form.ts
new file mode 100644
index 0000000..8a60032
--- /dev/null
+++ b/apps/admin/src/pages/Users/user-profile-form.ts
@@ -0,0 +1,16 @@
+export interface UserProfileFormValues {
+ joinedAt?: string;
+ qualifications?: string;
+ subjects?: string[];
+}
+
+export interface UserProfileResponse {
+ id?: number;
+ username?: string;
+ name?: string;
+ profile?: UserProfileFormValues | null;
+}
+
+export const userProfileResponseToFormValues = (
+ response: UserProfileResponse,
+): UserProfileFormValues => response.profile || {};
From 2874ab7bee1c9e2768acf00edbfca208ad859854 Mon Sep 17 00:00:00 2001
From: wangziqi
Date: Mon, 13 Jul 2026 12:01:48 +0800
Subject: [PATCH 02/13] fix(occupancies): assign room resources during
transfers
---
apps/admin/src/pages/Occupancies/index.tsx | 81 +++++++++++++++++--
.../occupancy-form.integration.test.ts | 27 +++++++
.../src/pages/Occupancies/occupancy-form.ts | 21 +++++
.../src/occupancies/dto/occupancy.dto.spec.ts | 28 +++++++
.../src/occupancies/dto/occupancy.dto.ts | 6 +-
.../occupancies/occupancies.service.spec.ts | 6 +-
6 files changed, 156 insertions(+), 13 deletions(-)
create mode 100644 apps/admin/src/pages/Occupancies/occupancy-form.integration.test.ts
create mode 100644 apps/admin/src/pages/Occupancies/occupancy-form.ts
create mode 100644 apps/server/src/occupancies/dto/occupancy.dto.spec.ts
diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx
index 5344a0e..6ea930e 100644
--- a/apps/admin/src/pages/Occupancies/index.tsx
+++ b/apps/admin/src/pages/Occupancies/index.tsx
@@ -32,6 +32,7 @@ import { downloadBlob } from '../../utils/download';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
+import { buildTransferPayload } from './occupancy-form';
const { RangePicker } = DatePicker;
@@ -59,6 +60,8 @@ const OccupanciesPage: React.FC = () => {
const [batchCheckOutForm] = Form.useForm();
const [availableBeds, setAvailableBeds] = useState([]);
const [availableLockers, setAvailableLockers] = useState([]);
+ const [transferAvailableBeds, setTransferAvailableBeds] = useState([]);
+ const [transferAvailableLockers, setTransferAvailableLockers] = useState([]);
const fetchData = useCallback(async () => {
setLoading(true);
@@ -110,6 +113,30 @@ const OccupanciesPage: React.FC = () => {
} catch (e) { console.error(e); }
};
+ const handleTransferRoomChange = async (roomId: number) => {
+ transferForm.setFieldValue('newBedId', undefined);
+ transferForm.setFieldValue('newLockerId', undefined);
+ if (!roomId) {
+ setTransferAvailableBeds([]);
+ setTransferAvailableLockers([]);
+ return;
+ }
+ try {
+ const [beds, lockers] = await Promise.all([
+ api.get(`/rooms/${roomId}/beds/available`),
+ api.get(`/rooms/${roomId}/lockers/available`),
+ ]);
+ setTransferAvailableBeds(beds);
+ setTransferAvailableLockers(lockers);
+ if (beds.length === 1) transferForm.setFieldValue('newBedId', beds[0].id);
+ } catch (e) {
+ console.error(e);
+ setTransferAvailableBeds([]);
+ setTransferAvailableLockers([]);
+ message.error('目标宿舍床位和柜子加载失败');
+ }
+ };
+
const filteredData = useMemo(() => {
if (!searchText) return data;
const keyword = searchText.toLowerCase();
@@ -170,13 +197,10 @@ const OccupanciesPage: React.FC = () => {
const values = await transferForm.validateFields();
setSaving(true);
try {
- await api.put(`/occupancies/${transferModal.id}/transfer`, {
- newRoomId: values.newRoomId,
- transferDate: values.transferDate.format('YYYY-MM-DD'),
- oldBillingEndDate: values.oldBillingEndDate?.format('YYYY-MM-DD'),
- newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'),
- reason: values.reason,
- });
+ await api.put(
+ `/occupancies/${transferModal.id}/transfer`,
+ buildTransferPayload(values),
+ );
message.success('换房成功');
setTransferModal(null);
transferForm.resetFields();
@@ -261,6 +285,9 @@ const OccupanciesPage: React.FC = () => {
size="small"
icon={}
onClick={() => {
+ setTransferAvailableBeds([]);
+ setTransferAvailableLockers([]);
+ transferForm.resetFields();
setTransferModal(record);
transferForm.setFieldsValue({ transferDate: dayjs() });
}}
@@ -708,7 +735,12 @@ const OccupanciesPage: React.FC = () => {
title={`换房 - ${transferModal?.student?.name}`}
open={!!transferModal}
onOk={handleTransfer}
- onCancel={() => setTransferModal(null)}
+ onCancel={() => {
+ setTransferModal(null);
+ transferForm.resetFields();
+ setTransferAvailableBeds([]);
+ setTransferAvailableLockers([]);
+ }}
okText="确认换房"
confirmLoading={saving}
width={500}
@@ -719,6 +751,7 @@ const OccupanciesPage: React.FC = () => {
showSearch
optionFilterProp="label"
placeholder="选择目标宿舍"
+ onChange={handleTransferRoomChange}
options={rooms
.filter((r: any) => r.id !== transferModal?.roomId)
.map((r: any) => ({
@@ -728,6 +761,38 @@ const OccupanciesPage: React.FC = () => {
}))}
/>
+
+
+ {transferAvailableBeds.length > 0 && (
+
+ 空闲 {transferAvailableBeds.length} 张床位
+
+ )}
+
+
diff --git a/apps/admin/src/pages/Occupancies/occupancy-form.integration.test.ts b/apps/admin/src/pages/Occupancies/occupancy-form.integration.test.ts
new file mode 100644
index 0000000..4bd4b2a
--- /dev/null
+++ b/apps/admin/src/pages/Occupancies/occupancy-form.integration.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import dayjs from 'dayjs';
+import { buildTransferPayload } from './occupancy-form';
+
+describe('occupancy transfer form', () => {
+ it('submits the target room resources with the transfer dates', () => {
+ expect(
+ buildTransferPayload({
+ newRoomId: 5,
+ newBedId: 12,
+ newLockerId: 18,
+ transferDate: dayjs('2026-07-13'),
+ oldBillingEndDate: dayjs('2026-07-13'),
+ newBillingStartDate: dayjs('2026-07-14'),
+ reason: '调整宿舍',
+ }),
+ ).toEqual({
+ newRoomId: 5,
+ newBedId: 12,
+ newLockerId: 18,
+ transferDate: '2026-07-13',
+ oldBillingEndDate: '2026-07-13',
+ newBillingStartDate: '2026-07-14',
+ reason: '调整宿舍',
+ });
+ });
+});
diff --git a/apps/admin/src/pages/Occupancies/occupancy-form.ts b/apps/admin/src/pages/Occupancies/occupancy-form.ts
new file mode 100644
index 0000000..e25171b
--- /dev/null
+++ b/apps/admin/src/pages/Occupancies/occupancy-form.ts
@@ -0,0 +1,21 @@
+import type { Dayjs } from 'dayjs';
+
+export interface TransferFormValues {
+ newRoomId: number;
+ newBedId: number;
+ newLockerId?: number;
+ transferDate: Dayjs;
+ oldBillingEndDate?: Dayjs;
+ newBillingStartDate?: Dayjs;
+ reason?: string;
+}
+
+export const buildTransferPayload = (values: TransferFormValues) => ({
+ newRoomId: values.newRoomId,
+ newBedId: values.newBedId,
+ newLockerId: values.newLockerId || undefined,
+ transferDate: values.transferDate.format('YYYY-MM-DD'),
+ oldBillingEndDate: values.oldBillingEndDate?.format('YYYY-MM-DD'),
+ newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'),
+ reason: values.reason,
+});
diff --git a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts
new file mode 100644
index 0000000..5e07f45
--- /dev/null
+++ b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts
@@ -0,0 +1,28 @@
+import { validate } from 'class-validator';
+import { CheckInDto, TransferRoomDto } from './occupancy.dto';
+
+describe('manual occupancy DTO bed requirements', () => {
+ it('requires a bed for manual check-in', async () => {
+ const dto = Object.assign(new CheckInDto(), {
+ studentId: 1,
+ roomId: 2,
+ checkInDate: '2026-07-13',
+ });
+
+ const errors = await validate(dto);
+
+ expect(errors.some((error) => error.property === 'bedId')).toBe(true);
+ });
+
+ it('requires a new bed for a room transfer while keeping the locker optional', async () => {
+ const dto = Object.assign(new TransferRoomDto(), {
+ newRoomId: 3,
+ transferDate: '2026-07-13',
+ });
+
+ const errors = await validate(dto);
+
+ expect(errors.some((error) => error.property === 'newBedId')).toBe(true);
+ expect(errors.some((error) => error.property === 'newLockerId')).toBe(false);
+ });
+});
diff --git a/apps/server/src/occupancies/dto/occupancy.dto.ts b/apps/server/src/occupancies/dto/occupancy.dto.ts
index a3e22db..8aafeec 100644
--- a/apps/server/src/occupancies/dto/occupancy.dto.ts
+++ b/apps/server/src/occupancies/dto/occupancy.dto.ts
@@ -25,9 +25,8 @@ export class CheckInDto {
@IsOptional()
@IsInt()
responsibleOrganizationId?: number;
- @IsOptional()
@IsInt()
- bedId?: number; // 后续改 required
+ bedId: number;
@IsOptional()
@IsInt()
@@ -58,9 +57,8 @@ export class TransferRoomDto {
@IsString()
oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate
- @IsOptional()
@IsInt()
- newBedId?: number;
+ newBedId: number;
@IsOptional()
@IsInt()
diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts
index ede4aa7..982c9f0 100644
--- a/apps/server/src/occupancies/occupancies.service.spec.ts
+++ b/apps/server/src/occupancies/occupancies.service.spec.ts
@@ -28,7 +28,10 @@ describe('OccupanciesService — responsible organization', () => {
roomRepo,
studentRepo,
{} as Repository,
- {} as Repository,
+ {
+ findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }),
+ update: jest.fn(),
+ } as any as Repository,
{} as Repository,
{} as Repository,
{} as DataSource,
@@ -38,6 +41,7 @@ describe('OccupanciesService — responsible organization', () => {
studentId: 3,
roomId: 2,
checkInDate: '2026-07-10',
+ bedId: 4,
});
expect(occupancyRepo.create).toHaveBeenCalledWith(
From dfd3cf67722546357d9256cde5f75a79c271a9f1 Mon Sep 17 00:00:00 2001
From: wangziqi
Date: Mon, 13 Jul 2026 12:04:47 +0800
Subject: [PATCH 03/13] fix(archive): align aggregate result field
---
.../src/archive/archive.service.spec.ts | 37 +++++++++++++++++++
apps/server/src/archive/archive.service.ts | 2 +-
2 files changed, 38 insertions(+), 1 deletion(-)
create mode 100644 apps/server/src/archive/archive.service.spec.ts
diff --git a/apps/server/src/archive/archive.service.spec.ts b/apps/server/src/archive/archive.service.spec.ts
new file mode 100644
index 0000000..4fe9957
--- /dev/null
+++ b/apps/server/src/archive/archive.service.spec.ts
@@ -0,0 +1,37 @@
+import { ArchiveService } from './archive.service';
+
+describe('ArchiveService.getProfile', () => {
+ it('returns the admission archive under the public result field', async () => {
+ const student = { id: 7, name: '测试学生' };
+ const result = {
+ id: 3,
+ studentId: 7,
+ cultureFinalScore: 450,
+ admittedCollege: '测试学院',
+ };
+
+ const studentRepo = { findOne: jest.fn().mockResolvedValue(student) };
+ const profileRepo = { findOne: jest.fn().mockResolvedValue(null) };
+ const enrollmentRepo = { find: jest.fn().mockResolvedValue([]) };
+ const examScoreRepo = { find: jest.fn().mockResolvedValue([]) };
+ const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) };
+ const resultRepo = { findOne: jest.fn().mockResolvedValue(result) };
+ const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
+
+ const service = new ArchiveService(
+ studentRepo as never,
+ profileRepo as never,
+ enrollmentRepo as never,
+ examScoreRepo as never,
+ learningRecordRepo as never,
+ resultRepo as never,
+ attachmentRepo as never,
+ {} as never,
+ );
+
+ const response = await service.getProfile(7);
+
+ expect(response).toMatchObject({ student, result });
+ expect(response).not.toHaveProperty('resultArchive');
+ });
+});
diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts
index 93a98f3..69c788a 100644
--- a/apps/server/src/archive/archive.service.ts
+++ b/apps/server/src/archive/archive.service.ts
@@ -76,7 +76,7 @@ export class ArchiveService {
enrollments,
examScores,
learningRecords,
- resultArchive,
+ result: resultArchive,
attachments,
};
}
From 6396d3e93474eedf7479301a808a06be972db4ed Mon Sep 17 00:00:00 2001
From: wangziqi
Date: Mon, 13 Jul 2026 12:09:53 +0800
Subject: [PATCH 04/13] fix(schedules): support editable schedule notes
---
apps/admin/src/pages/Schedules/index.tsx | 13 +++++++++
.../schedule-form.integration.test.ts | 21 ++++++++++++++
.../src/pages/Schedules/schedule-form.ts | 4 +++
.../src/schedules/dto/schedule.dto.spec.ts | 29 +++++++++++++++++++
apps/server/src/schedules/dto/schedule.dto.ts | 3 ++
5 files changed, 70 insertions(+)
create mode 100644 apps/server/src/schedules/dto/schedule.dto.spec.ts
diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx
index 42547cf..c37e756 100644
--- a/apps/admin/src/pages/Schedules/index.tsx
+++ b/apps/admin/src/pages/Schedules/index.tsx
@@ -947,6 +947,19 @@ const SchedulesPage: React.FC = () => {
/>
+
+
+
+
{
endTime: '18:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
+ notes: '需要投影设备',
});
expect(values.classroomId).toBe(1);
expect(values.weekDay).toBe(5);
+ expect(values.notes).toBe('需要投影设备');
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
'2026-07-01',
@@ -36,6 +38,7 @@ describe('schedule edit form mapping', () => {
teacherId: 4,
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
+ notes: ' 临时调整教室 ',
}),
).toEqual({
classId: 1,
@@ -47,6 +50,24 @@ describe('schedule edit form mapping', () => {
endTime: '17:20',
startDate: '2026-08-01',
endDate: '2026-08-31',
+ notes: '临时调整教室',
});
});
});
+
+
+describe('schedule notes normalization', () => {
+ it('omits whitespace-only notes from the payload', () => {
+ expect(
+ buildSchedulePayload({
+ classId: 1,
+ classroomId: 2,
+ weekDay: 6,
+ subject: '作文',
+ timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
+ dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
+ notes: ' ',
+ }).notes,
+ ).toBeUndefined();
+ });
+});
diff --git a/apps/admin/src/pages/Schedules/schedule-form.ts b/apps/admin/src/pages/Schedules/schedule-form.ts
index 42d1d7f..dc4560c 100644
--- a/apps/admin/src/pages/Schedules/schedule-form.ts
+++ b/apps/admin/src/pages/Schedules/schedule-form.ts
@@ -6,6 +6,7 @@ export interface ScheduleFormValues {
weekDay: number;
subject: string;
teacherId?: number;
+ notes?: string;
timeRange: [Dayjs, Dayjs];
dateRange: [Dayjs, Dayjs];
}
@@ -17,6 +18,7 @@ export interface EditableSchedule {
weekDay: number;
subject: string;
teacherId: number | null;
+ notes?: string | null;
startTime: string;
endTime: string;
startDate: string;
@@ -29,6 +31,7 @@ export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormVa
weekDay: schedule.weekDay,
subject: schedule.subject,
teacherId: schedule.teacherId ?? undefined,
+ notes: schedule.notes ?? undefined,
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
});
@@ -39,6 +42,7 @@ export const buildSchedulePayload = (values: ScheduleFormValues) => ({
weekDay: values.weekDay,
subject: values.subject,
teacherId: values.teacherId,
+ notes: values.notes?.trim() || undefined,
startTime: values.timeRange[0].format('HH:mm'),
endTime: values.timeRange[1].format('HH:mm'),
startDate: values.dateRange[0].format('YYYY-MM-DD'),
diff --git a/apps/server/src/schedules/dto/schedule.dto.spec.ts b/apps/server/src/schedules/dto/schedule.dto.spec.ts
new file mode 100644
index 0000000..5e85269
--- /dev/null
+++ b/apps/server/src/schedules/dto/schedule.dto.spec.ts
@@ -0,0 +1,29 @@
+import 'reflect-metadata';
+import { validate } from 'class-validator';
+import { CreateScheduleDto, UpdateScheduleDto } from './schedule.dto';
+
+const createSchedule = (notes: string) =>
+ Object.assign(new CreateScheduleDto(), {
+ classId: 1,
+ classroomId: 2,
+ weekDay: 1,
+ startTime: '09:00',
+ endTime: '10:00',
+ startDate: '2026-07-01',
+ endDate: '2026-07-31',
+ subject: '语文',
+ notes,
+ });
+
+describe('schedule notes validation', () => {
+ it('rejects notes longer than 500 characters when creating', async () => {
+ const errors = await validate(createSchedule('a'.repeat(501)));
+ expect(errors.some((error) => error.property === 'notes')).toBe(true);
+ });
+
+ it('rejects notes longer than 500 characters when updating', async () => {
+ const dto = Object.assign(new UpdateScheduleDto(), { notes: 'a'.repeat(501) });
+ const errors = await validate(dto);
+ expect(errors.some((error) => error.property === 'notes')).toBe(true);
+ });
+});
diff --git a/apps/server/src/schedules/dto/schedule.dto.ts b/apps/server/src/schedules/dto/schedule.dto.ts
index 3d6869f..016560a 100644
--- a/apps/server/src/schedules/dto/schedule.dto.ts
+++ b/apps/server/src/schedules/dto/schedule.dto.ts
@@ -7,6 +7,7 @@ import {
Matches,
Min,
Max,
+ MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
@@ -59,6 +60,7 @@ export class CreateScheduleDto {
@IsOptional()
@IsString()
+ @MaxLength(500)
notes?: string;
@IsOptional()
@@ -115,6 +117,7 @@ export class UpdateScheduleDto {
@IsOptional()
@IsString()
+ @MaxLength(500)
notes?: string;
}
From 0377acd33b4d40d24ff52076503d1b92ae604ae2 Mon Sep 17 00:00:00 2001
From: wangziqi
Date: Mon, 13 Jul 2026 12:16:39 +0800
Subject: [PATCH 05/13] refactor: remove residual campus and department fields
---
.../archive/archive-report.service.spec.ts | 31 +++++++++++++++++++
.../src/archive/archive-report.service.ts | 1 -
.../src/archive/dto/archive.dto.spec.ts | 18 +++++++++++
apps/server/src/archive/dto/archive.dto.ts | 1 -
.../src/entities/student-profile.entity.ts | 3 --
.../src/schedules/dto/schedule.dto.spec.ts | 18 +++++++++++
apps/server/src/schedules/dto/schedule.dto.ts | 3 --
7 files changed, 67 insertions(+), 8 deletions(-)
create mode 100644 apps/server/src/archive/archive-report.service.spec.ts
create mode 100644 apps/server/src/archive/dto/archive.dto.spec.ts
diff --git a/apps/server/src/archive/archive-report.service.spec.ts b/apps/server/src/archive/archive-report.service.spec.ts
new file mode 100644
index 0000000..457b337
--- /dev/null
+++ b/apps/server/src/archive/archive-report.service.spec.ts
@@ -0,0 +1,31 @@
+import { ArchiveReportService } from './archive-report.service';
+
+describe('ArchiveReportService retired profile fields', () => {
+ it('does not render the retired campus field in a student report', async () => {
+ const service = new ArchiveReportService(
+ { findOne: jest.fn().mockResolvedValue({ campusLocation: '旧校区', grade: '高三' }) } as never,
+ { find: jest.fn().mockResolvedValue([]) } as never,
+ { find: jest.fn().mockResolvedValue([]) } as never,
+ { find: jest.fn().mockResolvedValue([]) } as never,
+ { findOne: jest.fn().mockResolvedValue(null) } as never,
+ { find: jest.fn().mockResolvedValue([]) } as never,
+ {
+ findOne: jest.fn().mockResolvedValue({
+ id: 1,
+ name: '测试学生',
+ gender: '男',
+ phone: '',
+ ethnicity: '',
+ emergencyContact: '',
+ emergencyPhone: '',
+ }),
+ } as never,
+ );
+
+ const html = await service.generateReportHtml(1);
+
+ expect(html).not.toContain('旧校区');
+ expect(html).not.toContain('校区');
+ expect(html).toContain('高三');
+ });
+});
diff --git a/apps/server/src/archive/archive-report.service.ts b/apps/server/src/archive/archive-report.service.ts
index 5e58672..036af73 100644
--- a/apps/server/src/archive/archive-report.service.ts
+++ b/apps/server/src/archive/archive-report.service.ts
@@ -308,7 +308,6 @@ ${this.buildLearningAndResult(learnings, result, now)}
民族${this.esc(student.ethnicity || '-')}
紧急联系人${this.esc(student.emergencyContact || '-')}
紧急电话${this.esc(student.emergencyPhone || '-')}
- 校区${this.esc(profile?.campusLocation || '-')}
年级${this.esc(profile?.grade || '-')}
`;
diff --git a/apps/server/src/archive/dto/archive.dto.spec.ts b/apps/server/src/archive/dto/archive.dto.spec.ts
new file mode 100644
index 0000000..4094440
--- /dev/null
+++ b/apps/server/src/archive/dto/archive.dto.spec.ts
@@ -0,0 +1,18 @@
+import 'reflect-metadata';
+import { plainToInstance } from 'class-transformer';
+import { validate } from 'class-validator';
+import { UpsertProfileDto } from './archive.dto';
+
+describe('UpsertProfileDto retired fields', () => {
+ it('removes the retired campusLocation field under whitelist validation', async () => {
+ const dto = plainToInstance(UpsertProfileDto, {
+ grade: '高三',
+ campusLocation: '旧校区',
+ });
+
+ await validate(dto, { whitelist: true });
+
+ expect(dto).toMatchObject({ grade: '高三' });
+ expect(dto).not.toHaveProperty('campusLocation');
+ });
+});
diff --git a/apps/server/src/archive/dto/archive.dto.ts b/apps/server/src/archive/dto/archive.dto.ts
index 404cb77..9d7c6a9 100644
--- a/apps/server/src/archive/dto/archive.dto.ts
+++ b/apps/server/src/archive/dto/archive.dto.ts
@@ -5,7 +5,6 @@ export class UpsertProfileDto {
@IsOptional() @IsString() targetMajor?: string;
@IsOptional() @IsString() subjectDirection?: string;
@IsOptional() @IsString() grade?: string;
- @IsOptional() @IsString() campusLocation?: string;
@IsOptional() @IsDateString() profileDate?: string;
@IsOptional() @IsString() notes?: string;
}
diff --git a/apps/server/src/entities/student-profile.entity.ts b/apps/server/src/entities/student-profile.entity.ts
index 9a83f78..bac56a9 100644
--- a/apps/server/src/entities/student-profile.entity.ts
+++ b/apps/server/src/entities/student-profile.entity.ts
@@ -33,9 +33,6 @@ export class StudentProfile {
@Column({ length: 20, nullable: true })
grade: string;
- @Column({ name: 'campus_location', length: 100, nullable: true })
- campusLocation: string;
-
@Column({ name: 'profile_date', type: 'date', nullable: true })
profileDate: string;
diff --git a/apps/server/src/schedules/dto/schedule.dto.spec.ts b/apps/server/src/schedules/dto/schedule.dto.spec.ts
index 5e85269..62fc926 100644
--- a/apps/server/src/schedules/dto/schedule.dto.spec.ts
+++ b/apps/server/src/schedules/dto/schedule.dto.spec.ts
@@ -27,3 +27,21 @@ describe('schedule notes validation', () => {
expect(errors.some((error) => error.property === 'notes')).toBe(true);
});
});
+
+it('removes the retired departmentId field from create requests', async () => {
+ const dto = Object.assign(new CreateScheduleDto(), {
+ classId: 1,
+ classroomId: 2,
+ weekDay: 1,
+ startTime: '09:00',
+ endTime: '10:00',
+ startDate: '2026-07-01',
+ endDate: '2026-07-31',
+ subject: '语文',
+ departmentId: 99,
+ });
+
+ await validate(dto, { whitelist: true });
+
+ expect(dto).not.toHaveProperty('departmentId');
+});
diff --git a/apps/server/src/schedules/dto/schedule.dto.ts b/apps/server/src/schedules/dto/schedule.dto.ts
index 016560a..b193c59 100644
--- a/apps/server/src/schedules/dto/schedule.dto.ts
+++ b/apps/server/src/schedules/dto/schedule.dto.ts
@@ -63,9 +63,6 @@ export class CreateScheduleDto {
@MaxLength(500)
notes?: string;
- @IsOptional()
- @IsInt()
- departmentId?: number;
}
export class UpdateScheduleDto {
From effa34434be28a1d316a6868643b9bce17bd586e Mon Sep 17 00:00:00 2001
From: wangziqi
Date: Mon, 13 Jul 2026 12:20:13 +0800
Subject: [PATCH 06/13] refactor(students): remove obsolete class id input
---
.../src/students/dto/student.dto.spec.ts | 18 ++++++++++++++++++
apps/server/src/students/dto/student.dto.ts | 3 ---
2 files changed, 18 insertions(+), 3 deletions(-)
create mode 100644 apps/server/src/students/dto/student.dto.spec.ts
diff --git a/apps/server/src/students/dto/student.dto.spec.ts b/apps/server/src/students/dto/student.dto.spec.ts
new file mode 100644
index 0000000..916514d
--- /dev/null
+++ b/apps/server/src/students/dto/student.dto.spec.ts
@@ -0,0 +1,18 @@
+import 'reflect-metadata';
+import { validate } from 'class-validator';
+import { CreateStudentDto } from './student.dto';
+
+describe('CreateStudentDto relationship boundaries', () => {
+ it('removes classId because class membership is managed by the classes API', async () => {
+ const dto = Object.assign(new CreateStudentDto(), {
+ name: '测试学生',
+ organizationId: 1,
+ classId: 9,
+ });
+
+ await validate(dto, { whitelist: true });
+
+ expect(dto).toMatchObject({ name: '测试学生', organizationId: 1 });
+ expect(dto).not.toHaveProperty('classId');
+ });
+});
diff --git a/apps/server/src/students/dto/student.dto.ts b/apps/server/src/students/dto/student.dto.ts
index 40c4bdd..bc08e95 100644
--- a/apps/server/src/students/dto/student.dto.ts
+++ b/apps/server/src/students/dto/student.dto.ts
@@ -38,9 +38,6 @@ export class CreateStudentDto {
@IsOptional()
@IsString()
supervisor?: string;
- @IsOptional()
- @IsInt()
- classId?: number;
}
export class UpdateStudentDto {
From 0515e6ed276a9525679d7188018e8ddaea4bd8b5 Mon Sep 17 00:00:00 2001
From: wangziqi
Date: Mon, 13 Jul 2026 14:01:45 +0800
Subject: [PATCH 07/13] fix(integration): simplify manual DingTalk sync setup
---
.../src/pages/IntegrationConfig/index.tsx | 190 ++++++++++--------
...ntegration-config-form.integration.test.ts | 26 +++
.../integration-config-form.ts | 13 ++
.../src/integration/config/dto/config.dto.ts | 6 +-
.../config/integration-config.service.spec.ts | 49 +++++
.../config/integration-config.service.ts | 7 +-
6 files changed, 203 insertions(+), 88 deletions(-)
create mode 100644 apps/admin/src/pages/IntegrationConfig/integration-config-form.integration.test.ts
create mode 100644 apps/admin/src/pages/IntegrationConfig/integration-config-form.ts
create mode 100644 apps/server/src/integration/config/integration-config.service.spec.ts
diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx
index e553f6f..7226466 100644
--- a/apps/admin/src/pages/IntegrationConfig/index.tsx
+++ b/apps/admin/src/pages/IntegrationConfig/index.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
- Card, Form, Input, Button, Space, Spin, Switch, Alert, Descriptions, Tag,
- Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
+ Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider,
+ Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
Row, Col, List,
} from 'antd';
import {
@@ -15,12 +15,15 @@ import api from '../../api';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
import PermissionButton from '../../components/PermissionButton';
+import {
+ buildDingTalkConfigPayload,
+ isAppSecretRequired,
+ type DingTalkConfigFormValues,
+} from './integration-config-form';
interface DingTalkConfig {
agentId: string;
- appSecret: string;
corpId: string;
- startEnable: boolean;
}
interface DingOrgTreeNodeExt {
@@ -94,9 +97,9 @@ const IntegrationConfigPage: React.FC = () => {
const [testing, setTesting] = useState(false);
const [config, setConfig] = useState(null);
const [verified, setVerified] = useState(null);
- const [form] = Form.useForm();
+ const [form] = Form.useForm();
- // ── Sync Users Tab ──
+ // ── Manual organization sync ──
const [syncRootDeptId, setSyncRootDeptId] = useState(undefined);
const [orgTree, setOrgTree] = useState([]);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -137,9 +140,10 @@ const IntegrationConfigPage: React.FC = () => {
const handleSave = async () => {
const values = await form.validateFields();
+ const payload = buildDingTalkConfigPayload(values);
setSaving(true);
try {
- await api.post('/integration/config', { type: 'DINGTALK', config: values });
+ await api.post('/integration/config', { type: 'DINGTALK', config: payload });
message.success('配置已保存');
await fetchConfig();
} catch (e: unknown) {
@@ -152,11 +156,12 @@ const IntegrationConfigPage: React.FC = () => {
const handleTest = async () => {
const values = await form.validateFields();
+ const payload = buildDingTalkConfigPayload(values);
setTesting(true);
try {
const res = await api.post<{ success: boolean; message: string }>('/integration/config/test', {
type: 'DINGTALK',
- config: values,
+ config: payload,
});
setVerified(res.success);
message.success(res.message);
@@ -345,12 +350,8 @@ const IntegrationConfigPage: React.FC = () => {
}
};
- const syncTabItems = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
- ? [
- {
- key: 'sync-users',
- label: '同步用户',
- children: (
+ const syncPanel = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
+ ? (
- ),
- },
- ]
- : [];
-
- const tabItems = [
- {
- key: 'config',
- label: '配置',
- children: (
-
- {config && (
-
- {config.corpId || '-'}
- {config.agentId || '-'}
-
-
- {config.startEnable ? '已启用' : '未启用'}
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- } loading={saving} onClick={handleSave}>
- 保存配置
-
- } loading={testing} onClick={handleTest}>
- 测试连接
-
-
-
-
- ),
- },
- ...syncTabItems,
- ];
+ )
+ : null;
return (
-
- {verified === true && } color="success">已连接}
- {verified === false && } color="error">未连接}
-
- }>
-
+
+ {verified === true && (
+ } color="success">
+ 已连接
+
+ )}
+ {verified === false && (
+ } color="error">
+ 未连接
+
+ )}
+
+ }
+ >
+
+ {config && (
+
+ {config.corpId || '-'}
+ {config.agentId || '-'}
+ 手动触发
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ loading={saving}
+ onClick={handleSave}
+ >
+ 保存配置
+
+ } loading={testing} onClick={handleTest}>
+ 测试连接
+
+
+
+
+ {syncPanel && (
+ <>
+ 组织用户导入
+ {syncPanel}
+ >
+ )}
+
);
};
diff --git a/apps/admin/src/pages/IntegrationConfig/integration-config-form.integration.test.ts b/apps/admin/src/pages/IntegrationConfig/integration-config-form.integration.test.ts
new file mode 100644
index 0000000..5219ac7
--- /dev/null
+++ b/apps/admin/src/pages/IntegrationConfig/integration-config-form.integration.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import {
+ buildDingTalkConfigPayload,
+ isAppSecretRequired,
+} from './integration-config-form';
+
+describe('DingTalk integration config form', () => {
+ it('requires AppSecret only for the first configuration', () => {
+ expect(isAppSecretRequired(false)).toBe(true);
+ expect(isAppSecretRequired(true)).toBe(false);
+ });
+
+ it('builds a manual-sync config without the retired startEnable flag', () => {
+ expect(
+ buildDingTalkConfigPayload({
+ corpId: 'ding-corp',
+ agentId: 'app-key',
+ appSecret: '',
+ }),
+ ).toEqual({
+ corpId: 'ding-corp',
+ agentId: 'app-key',
+ appSecret: undefined,
+ });
+ });
+});
diff --git a/apps/admin/src/pages/IntegrationConfig/integration-config-form.ts b/apps/admin/src/pages/IntegrationConfig/integration-config-form.ts
new file mode 100644
index 0000000..4e18c1a
--- /dev/null
+++ b/apps/admin/src/pages/IntegrationConfig/integration-config-form.ts
@@ -0,0 +1,13 @@
+export interface DingTalkConfigFormValues {
+ agentId: string;
+ appSecret?: string;
+ corpId: string;
+}
+
+export const isAppSecretRequired = (hasSavedConfig: boolean) => !hasSavedConfig;
+
+export const buildDingTalkConfigPayload = (values: DingTalkConfigFormValues) => ({
+ corpId: values.corpId.trim(),
+ agentId: values.agentId.trim(),
+ appSecret: values.appSecret?.trim() || undefined,
+});
diff --git a/apps/server/src/integration/config/dto/config.dto.ts b/apps/server/src/integration/config/dto/config.dto.ts
index e0e35ee..d67e4cf 100644
--- a/apps/server/src/integration/config/dto/config.dto.ts
+++ b/apps/server/src/integration/config/dto/config.dto.ts
@@ -1,18 +1,16 @@
/** 钉钉配置 */
export interface DingTalkThirdConfig {
agentId: string; // AppKey
- appSecret: string; // AppSecret
+ appSecret?: string; // AppSecret;更新已有配置时可留空保留旧值
corpId: string; // CorpId
- startEnable: boolean; // 是否启用同步
appId?: string; // 内部应用ID,用于消息推送(可选)
}
/** 企微配置 */
export interface WeComThirdConfig {
agentId: string;
- appSecret: string;
+ appSecret?: string;
corpId: string;
- startEnable: boolean;
}
/** 对外返回的配置(脱敏后,不含 appSecret) */
diff --git a/apps/server/src/integration/config/integration-config.service.spec.ts b/apps/server/src/integration/config/integration-config.service.spec.ts
new file mode 100644
index 0000000..339524a
--- /dev/null
+++ b/apps/server/src/integration/config/integration-config.service.spec.ts
@@ -0,0 +1,49 @@
+import { IntegrationConfigService } from './integration-config.service';
+
+describe('IntegrationConfigService.testConnection', () => {
+ const originalFetch = global.fetch;
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ jest.restoreAllMocks();
+ });
+
+ it('uses the saved AppSecret when testing an existing configuration with a blank secret', async () => {
+ const configRepo = {
+ findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }),
+ };
+ const detailRepo = {
+ findOne: jest.fn().mockResolvedValue({
+ configId: 1,
+ type: 'DINGTALK_SYNC',
+ content: JSON.stringify({
+ config: {
+ corpId: 'ding-corp',
+ agentId: 'saved-key',
+ appSecret: 'saved-secret',
+ },
+ }),
+ }),
+ };
+ global.fetch = jest.fn().mockResolvedValue({
+ json: jest.fn().mockResolvedValue({ accessToken: 'token' }),
+ }) as never;
+
+ const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
+
+ await expect(
+ service.testConnection('DINGTALK', {
+ corpId: 'ding-corp',
+ agentId: 'saved-key',
+ appSecret: '',
+ }),
+ ).resolves.toBe(true);
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://api.dingtalk.com/v1.0/oauth2/accessToken',
+ expect.objectContaining({
+ body: JSON.stringify({ appKey: 'saved-key', appSecret: 'saved-secret' }),
+ }),
+ );
+ });
+});
diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts
index 56825a2..3216230 100644
--- a/apps/server/src/integration/config/integration-config.service.ts
+++ b/apps/server/src/integration/config/integration-config.service.ts
@@ -125,7 +125,12 @@ export class IntegrationConfigService {
config: DingTalkThirdConfig | WeComThirdConfig,
): Promise {
try {
- const token = await this.getTokenForTest(type, config as unknown as Record);
+ const finalConfig = { ...config } as Record;
+ if (!finalConfig.appSecret) {
+ const savedConfig = await this.getRawConfig(type);
+ if (savedConfig?.appSecret) finalConfig.appSecret = savedConfig.appSecret;
+ }
+ const token = await this.getTokenForTest(type, finalConfig);
return !!token;
} catch (e) {
this.logger.error(`连接测试失败: ${(e as Error).message}`);
From 04ef5c42d90cae50cbb3ba5838170b51e1a8fa13 Mon Sep 17 00:00:00 2001
From: wangziqi
Date: Mon, 13 Jul 2026 14:02:00 +0800
Subject: [PATCH 08/13] refactor(deposits): remove inline installment creation
---
.../src/deposits/deposits.controller.ts | 4 ++--
apps/server/src/deposits/deposits.service.ts | 13 +------------
.../src/deposits/dto/deposit.dto.spec.ts | 19 +++++++++++++++++++
apps/server/src/deposits/dto/deposit.dto.ts | 19 +------------------
4 files changed, 23 insertions(+), 32 deletions(-)
create mode 100644 apps/server/src/deposits/dto/deposit.dto.spec.ts
diff --git a/apps/server/src/deposits/deposits.controller.ts b/apps/server/src/deposits/deposits.controller.ts
index 6e0a588..78657bd 100644
--- a/apps/server/src/deposits/deposits.controller.ts
+++ b/apps/server/src/deposits/deposits.controller.ts
@@ -16,7 +16,7 @@ import { Student } from '../entities/student.entity';
import { DepositsService } from './deposits.service';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity';
-import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
+import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
@@ -61,7 +61,7 @@ export class DepositsController {
@Post()
@RequirePermission('deposit:create')
- async create(@Body() dto: CreateDepositDto | CreateDepositWithInstallmentsDto, @Request() req: any) {
+ async create(@Body() dto: CreateDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto, req.user?.id);
await this.logService.log({
diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts
index 0f93d3a..b1a8238 100644
--- a/apps/server/src/deposits/deposits.service.ts
+++ b/apps/server/src/deposits/deposits.service.ts
@@ -5,7 +5,7 @@ import { Deposit } from '../entities/deposit.entity';
import { Student } from '../entities/student.entity';
import { DepositInstallment } from '../entities/deposit-installment.entity';
-import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto';
+import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
@Injectable()
export class DepositsService {
@@ -55,17 +55,6 @@ export class DepositsService {
recordedBy: userId,
});
- if (dto instanceof CreateDepositWithInstallmentsDto && dto.installments?.length) {
- deposit.installments = dto.installments.map((i) => {
- const inst = this.installmentRepo.create({
- amount: i.amount,
- dueDate: i.dueDate,
- status: 'pending',
- });
- return inst;
- });
- }
-
return this.repo.save(deposit);
}
diff --git a/apps/server/src/deposits/dto/deposit.dto.spec.ts b/apps/server/src/deposits/dto/deposit.dto.spec.ts
new file mode 100644
index 0000000..6de3925
--- /dev/null
+++ b/apps/server/src/deposits/dto/deposit.dto.spec.ts
@@ -0,0 +1,19 @@
+import 'reflect-metadata';
+import { validate } from 'class-validator';
+import { CreateDepositDto } from './deposit.dto';
+
+describe('CreateDepositDto boundaries', () => {
+ it('removes inline installments because they are managed after deposit creation', async () => {
+ const dto = Object.assign(new CreateDepositDto(), {
+ studentId: 1,
+ amount: 500,
+ paidDate: '2026-07-13',
+ installments: [{ amount: 250, dueDate: '2026-08-01' }],
+ });
+
+ await validate(dto, { whitelist: true });
+
+ expect(dto).toMatchObject({ studentId: 1, amount: 500, paidDate: '2026-07-13' });
+ expect(dto).not.toHaveProperty('installments');
+ });
+});
diff --git a/apps/server/src/deposits/dto/deposit.dto.ts b/apps/server/src/deposits/dto/deposit.dto.ts
index 1b140b6..74ea313 100644
--- a/apps/server/src/deposits/dto/deposit.dto.ts
+++ b/apps/server/src/deposits/dto/deposit.dto.ts
@@ -1,5 +1,4 @@
-import { IsInt, IsNumber, IsString, IsOptional, ValidateNested } from 'class-validator';
-import { Type } from 'class-transformer';
+import { IsInt, IsNumber, IsString, IsOptional } from 'class-validator';
export class CreateDepositDto {
@IsInt()
@@ -16,16 +15,6 @@ export class CreateDepositDto {
notes?: string;
}
-export class CreateInstallmentDto {
- @IsNumber()
- amount: number;
-
- @IsString()
- dueDate: string;
-}
-
-
-
export class RefundDepositDto {
@IsString()
refundDate: string;
@@ -42,9 +31,3 @@ export class RefundDepositDto {
@IsString()
notes?: string;
}
-export class CreateDepositWithInstallmentsDto extends CreateDepositDto {
- @IsOptional()
- @ValidateNested({ each: true })
- @Type(() => CreateInstallmentDto)
- installments?: CreateInstallmentDto[];
-}
From 7b08560aef20b8911df93ab06033265babdc64ec Mon Sep 17 00:00:00 2001
From: wangziqi
Date: Mon, 13 Jul 2026 14:05:24 +0800
Subject: [PATCH 09/13] fix(deposits): show student numbers in lookup
---
...deposit-student-option.integration.test.ts | 20 +++++++++++++++++++
.../pages/Deposits/deposit-student-option.ts | 10 ++++++++++
apps/admin/src/pages/Deposits/index.tsx | 7 +++----
3 files changed, 33 insertions(+), 4 deletions(-)
create mode 100644 apps/admin/src/pages/Deposits/deposit-student-option.integration.test.ts
create mode 100644 apps/admin/src/pages/Deposits/deposit-student-option.ts
diff --git a/apps/admin/src/pages/Deposits/deposit-student-option.integration.test.ts b/apps/admin/src/pages/Deposits/deposit-student-option.integration.test.ts
new file mode 100644
index 0000000..b876ebe
--- /dev/null
+++ b/apps/admin/src/pages/Deposits/deposit-student-option.integration.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest';
+import { buildDepositStudentOption } from './deposit-student-option';
+
+describe('deposit student option', () => {
+ it('uses the student number as the non-sensitive identifier', () => {
+ expect(
+ buildDepositStudentOption({ id: 23, name: '张三', studentNo: 'S2026001' }),
+ ).toEqual({
+ value: 23,
+ label: '张三 (S2026001)',
+ });
+ });
+
+ it('falls back to the internal id when the student number is missing', () => {
+ expect(buildDepositStudentOption({ id: 23, name: '张三', studentNo: null })).toEqual({
+ value: 23,
+ label: '张三 (#23)',
+ });
+ });
+});
diff --git a/apps/admin/src/pages/Deposits/deposit-student-option.ts b/apps/admin/src/pages/Deposits/deposit-student-option.ts
new file mode 100644
index 0000000..732d6b2
--- /dev/null
+++ b/apps/admin/src/pages/Deposits/deposit-student-option.ts
@@ -0,0 +1,10 @@
+export interface DepositStudentLookup {
+ id: number;
+ name: string;
+ studentNo?: string | null;
+}
+
+export const buildDepositStudentOption = (student: DepositStudentLookup) => ({
+ value: student.id,
+ label: `${student.name} (${student.studentNo || `#${student.id}`})`,
+});
diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx
index 16c29c0..3ddc854 100644
--- a/apps/admin/src/pages/Deposits/index.tsx
+++ b/apps/admin/src/pages/Deposits/index.tsx
@@ -19,6 +19,7 @@ import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
+import { buildDepositStudentOption } from './deposit-student-option';
const statusMap: Record = {
paid: { text: '已缴', color: 'green' },
@@ -90,10 +91,8 @@ const DepositsPage: React.FC = () => {
const studentOptions = useMemo(
() =>
students
- .map((s: any) => ({
- value: s.id,
- label: s.studentNo ? `${s.name} (${s.studentNo})` : s.name,
- })),
+ .filter((s: any) => s.status === 'active')
+ .map(buildDepositStudentOption),
[students],
);
From 1f32d1285b06f6f39577914fed87908e2648bfb6 Mon Sep 17 00:00:00 2001
From: wangziqi
Date: Mon, 13 Jul 2026 14:19:58 +0800
Subject: [PATCH 10/13] refactor(deposits): remove refund approval remnants
---
apps/admin/src/pages/Deposits/index.tsx | 23 +---
.../server/src/dashboard/dashboard.service.ts | 3 +-
...database-migrations.deposit-refund.spec.ts | 101 ++++++++++++++++++
.../database/database-migrations.service.ts | 39 +++++++
.../src/deposits/deposits.refund.spec.ts | 38 +++++++
apps/server/src/deposits/deposits.service.ts | 5 +-
apps/server/src/entities/deposit.entity.ts | 17 +--
7 files changed, 186 insertions(+), 40 deletions(-)
create mode 100644 apps/server/src/database/database-migrations.deposit-refund.spec.ts
create mode 100644 apps/server/src/deposits/deposits.refund.spec.ts
diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx
index 3ddc854..1d50ca3 100644
--- a/apps/admin/src/pages/Deposits/index.tsx
+++ b/apps/admin/src/pages/Deposits/index.tsx
@@ -28,13 +28,6 @@ const statusMap: Record = {
deducted: { text: '已全扣', color: 'red' },
};
-const refundStatusMap: Record = {
- pending: { text: '历史退款处理中', color: 'orange' },
- head_teacher_approved: { text: '历史退款处理中', color: 'blue' },
- finance_approved: { text: '已退款', color: 'green' },
- refunded: { text: '已退款', color: 'green' },
-};
-
const installmentStatusMap: Record = {
pending: { text: '待缴', color: 'orange' },
paid: { text: '已缴', color: 'green' },
@@ -187,12 +180,6 @@ const DepositsPage: React.FC = () => {
dataIndex: 'status',
render: (s: string) => {statusMap[s]?.text || s},
},
- {
- title: '退款状态',
- dataIndex: 'refundStatus',
- render: (s: string) =>
- s ? {refundStatusMap[s]?.text || s} : '-',
- },
{
title: '退还金额',
dataIndex: 'refundAmount',
@@ -220,7 +207,7 @@ const DepositsPage: React.FC = () => {
>
详情
- {record.status === 'paid' && !record.refundStatus && (
+ {record.status === 'paid' && (
<>
{
{statusMap[detailModal.status]?.text || detailModal.status}
- {detailModal.refundStatus && (
-
- 退款状态:{' '}
-
- {refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus}
-
-
- )}
{detailModal.notes && 备注: {detailModal.notes}
}
diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts
index 96871af..aa7a0b4 100644
--- a/apps/server/src/dashboard/dashboard.service.ts
+++ b/apps/server/src/dashboard/dashboard.service.ts
@@ -119,8 +119,7 @@ export class DashboardService {
const pendingQb = this.depositRepo
.createQueryBuilder('d')
.select('SUM(d.amount)', 'total')
- .where('d.status = :paid', { paid: 'paid' })
- .andWhere('d.refundStatus IS NULL');
+ .where('d.status = :paid', { paid: 'paid' });
const pendingResult = await pendingQb.getRawOne();
const pendingDeposits = parseFloat(pendingResult?.total || '0');
diff --git a/apps/server/src/database/database-migrations.deposit-refund.spec.ts b/apps/server/src/database/database-migrations.deposit-refund.spec.ts
new file mode 100644
index 0000000..1b1ac7c
--- /dev/null
+++ b/apps/server/src/database/database-migrations.deposit-refund.spec.ts
@@ -0,0 +1,101 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { getDataSourceToken } from '@nestjs/typeorm';
+import { DatabaseMigrationsService } from './database-migrations.service';
+
+function createRunner(columns: string[]) {
+ return {
+ connect: jest.fn(),
+ release: jest.fn(),
+ query: jest.fn().mockResolvedValue([]),
+ getTables: jest.fn().mockResolvedValue(columns.length ? [{ name: 'deposits' }] : []),
+ getTable: jest.fn().mockResolvedValue({
+ name: 'deposits',
+ columns: columns.map((name) => ({ name })),
+ }),
+ renameColumn: jest.fn().mockResolvedValue(undefined),
+ dropColumn: jest.fn().mockResolvedValue(undefined),
+ };
+}
+
+async function createService(runner: ReturnType) {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ DatabaseMigrationsService,
+ {
+ provide: getDataSourceToken(),
+ useValue: {
+ options: { type: 'better-sqlite3' },
+ createQueryRunner: jest.fn().mockReturnValue(runner),
+ },
+ },
+ ],
+ }).compile();
+ return module.get(DatabaseMigrationsService) as DatabaseMigrationsService & {
+ cleanupDepositRefundColumns(): Promise;
+ };
+}
+
+describe('DatabaseMigrationsService — deposit refund cleanup', () => {
+ it('renames refund audit fields and drops approval-flow remnants', async () => {
+ const runner = createRunner([
+ 'id',
+ 'refund_status',
+ 'refund_requested_at',
+ 'refund_approved_by',
+ 'refund_approved_at',
+ 'refund_rejected_reason',
+ ]);
+ const service = await createService(runner);
+
+ await service.cleanupDepositRefundColumns();
+
+ expect(runner.renameColumn).toHaveBeenCalledWith(
+ 'deposits',
+ 'refund_approved_by',
+ 'refunded_by',
+ );
+ expect(runner.renameColumn).toHaveBeenCalledWith(
+ 'deposits',
+ 'refund_approved_at',
+ 'refunded_at',
+ );
+ expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_status');
+ expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_requested_at');
+ expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_rejected_reason');
+ expect(runner.release).toHaveBeenCalled();
+ });
+
+ it('merges legacy audit values before dropping duplicate legacy columns', async () => {
+ const runner = createRunner([
+ 'id',
+ 'refund_approved_by',
+ 'refund_approved_at',
+ 'refunded_by',
+ 'refunded_at',
+ ]);
+ const service = await createService(runner);
+
+ await service.cleanupDepositRefundColumns();
+
+ expect(runner.query).toHaveBeenCalledWith(
+ 'UPDATE deposits SET refunded_by = COALESCE(refunded_by, refund_approved_by)',
+ );
+ expect(runner.query).toHaveBeenCalledWith(
+ 'UPDATE deposits SET refunded_at = COALESCE(refunded_at, refund_approved_at)',
+ );
+ expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_approved_by');
+ expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_approved_at');
+ expect(runner.renameColumn).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when the deposits table is absent', async () => {
+ const runner = createRunner([]);
+ const service = await createService(runner);
+
+ await service.cleanupDepositRefundColumns();
+
+ expect(runner.renameColumn).not.toHaveBeenCalled();
+ expect(runner.dropColumn).not.toHaveBeenCalled();
+ expect(runner.release).toHaveBeenCalled();
+ });
+});
diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts
index b40d799..0254339 100644
--- a/apps/server/src/database/database-migrations.service.ts
+++ b/apps/server/src/database/database-migrations.service.ts
@@ -15,6 +15,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
await this.normalizeClassDates();
await this.protectAttendanceHistory();
await this.removeUnusedClassroomColumns();
+ await this.cleanupDepositRefundColumns();
}
private async removeUnusedClassroomColumns(): Promise {
@@ -36,6 +37,44 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
}
}
+ private async cleanupDepositRefundColumns(): Promise {
+ const runner = this.dataSource.createQueryRunner();
+ await runner.connect();
+ try {
+ const tables = await runner.getTables(['deposits']);
+ if (tables.length === 0) return;
+
+ const table = await runner.getTable('deposits');
+ const columnNames = new Set(table?.columns.map((column) => column.name) ?? []);
+ for (const [legacyName, currentName] of [
+ ['refund_approved_by', 'refunded_by'],
+ ['refund_approved_at', 'refunded_at'],
+ ] as const) {
+ if (!columnNames.has(legacyName)) continue;
+
+ if (columnNames.has(currentName)) {
+ await runner.query(
+ `UPDATE deposits SET ${currentName} = COALESCE(${currentName}, ${legacyName})`,
+ );
+ await runner.dropColumn('deposits', legacyName);
+ } else {
+ await runner.renameColumn('deposits', legacyName, currentName);
+ columnNames.add(currentName);
+ }
+ columnNames.delete(legacyName);
+ }
+
+ for (const columnName of ['refund_status', 'refund_requested_at', 'refund_rejected_reason']) {
+ if (columnNames.has(columnName)) {
+ await runner.dropColumn('deposits', columnName);
+ columnNames.delete(columnName);
+ }
+ }
+ } finally {
+ await runner.release();
+ }
+ }
+
private async ensureAiConfigTable(): Promise {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
diff --git a/apps/server/src/deposits/deposits.refund.spec.ts b/apps/server/src/deposits/deposits.refund.spec.ts
new file mode 100644
index 0000000..8400678
--- /dev/null
+++ b/apps/server/src/deposits/deposits.refund.spec.ts
@@ -0,0 +1,38 @@
+import { DepositsService } from './deposits.service';
+import { Deposit } from '../entities/deposit.entity';
+
+describe('DepositsService — direct refund', () => {
+ it('stores the refund result on the main status and renamed audit fields', async () => {
+ const deposit = {
+ id: 1,
+ amount: 500,
+ status: 'paid',
+ } as Deposit;
+ const repo = {
+ findOne: jest.fn().mockResolvedValue(deposit),
+ save: jest.fn().mockImplementation(async (value: Deposit) => value),
+ };
+ const service = new DepositsService(repo as never, {} as never, {} as never);
+
+ const result = await service.refund(
+ 1,
+ {
+ refundDate: '2026-07-13',
+ deductionAmount: 100,
+ deductionReason: '物品损坏',
+ },
+ 42,
+ );
+
+ expect(result).toMatchObject({
+ refundDate: '2026-07-13',
+ refundAmount: 400,
+ deductionAmount: 100,
+ deductionReason: '物品损坏',
+ status: 'partial_refund',
+ refundedBy: 42,
+ });
+ expect(result.refundedAt).toBeInstanceOf(Date);
+ expect(repo.save).toHaveBeenCalledWith(deposit);
+ });
+});
diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts
index b1a8238..b6597f9 100644
--- a/apps/server/src/deposits/deposits.service.ts
+++ b/apps/server/src/deposits/deposits.service.ts
@@ -103,9 +103,8 @@ export class DepositsService {
deposit.status =
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
if (dto.notes) deposit.notes = dto.notes;
- deposit.refundStatus = 'refunded';
- deposit.refundApprovedBy = userId ?? null as unknown as number;
- deposit.refundApprovedAt = new Date();
+ deposit.refundedBy = userId ?? null;
+ deposit.refundedAt = new Date();
return this.repo.save(deposit);
}
diff --git a/apps/server/src/entities/deposit.entity.ts b/apps/server/src/entities/deposit.entity.ts
index 258db25..f657010 100644
--- a/apps/server/src/entities/deposit.entity.ts
+++ b/apps/server/src/entities/deposit.entity.ts
@@ -46,20 +46,11 @@ export class Deposit {
@Column({ name: 'recorded_by', nullable: true })
recordedBy: number;
- @Column({ name: 'refund_status', length: 30, nullable: true })
- refundStatus: string; // pending | head_teacher_approved | finance_approved | refunded
+ @Column({ name: 'refunded_by', type: 'integer', nullable: true })
+ refundedBy: number | null;
- @Column({ name: 'refund_requested_at', type: 'datetime', nullable: true })
- refundRequestedAt: Date;
-
- @Column({ name: 'refund_approved_by', type: 'integer', nullable: true })
- refundApprovedBy: number;
-
- @Column({ name: 'refund_approved_at', type: 'datetime', nullable: true })
- refundApprovedAt: Date;
-
- @Column({ name: 'refund_rejected_reason', length: 500, nullable: true })
- refundRejectedReason: string;
+ @Column({ name: 'refunded_at', type: 'datetime', nullable: true })
+ refundedAt: Date | null;
@OneToMany(() => DepositInstallment, (i) => i.deposit, { cascade: true, eager: true })
installments: DepositInstallment[];
From 0533c30ecec02ce7748dec714ea2ad3f18f115d9 Mon Sep 17 00:00:00 2001
From: wangziqi
Date: Mon, 13 Jul 2026 14:38:34 +0800
Subject: [PATCH 11/13] refactor(classes): preserve student membership history
---
apps/admin/src/pages/Classes/detail.tsx | 13 ++-
.../classes.batch-import-membership.spec.ts | 47 ++++++++
.../src/classes/classes.membership.spec.ts | 95 +++++++++++++++
apps/server/src/classes/classes.service.ts | 110 +++++++++++++-----
.../database-migrations.class-student.spec.ts | 56 +++++++++
.../database/database-migrations.service.ts | 17 +++
.../src/entities/class-student.entity.ts | 7 +-
7 files changed, 303 insertions(+), 42 deletions(-)
create mode 100644 apps/server/src/classes/classes.batch-import-membership.spec.ts
create mode 100644 apps/server/src/classes/classes.membership.spec.ts
create mode 100644 apps/server/src/database/database-migrations.class-student.spec.ts
diff --git a/apps/admin/src/pages/Classes/detail.tsx b/apps/admin/src/pages/Classes/detail.tsx
index 435b46b..d2fbaa7 100644
--- a/apps/admin/src/pages/Classes/detail.tsx
+++ b/apps/admin/src/pages/Classes/detail.tsx
@@ -19,6 +19,7 @@ interface ClassStudent {
studentName: string;
studentNo: string;
joinDate: string;
+ leaveDate: string | null;
status: string;
}
@@ -305,6 +306,7 @@ const ClassDetailPage: React.FC = () => {
{ title: '姓名', dataIndex: 'studentName' },
{ title: '学号', dataIndex: 'studentNo' },
{ title: '加入日期', dataIndex: 'joinDate' },
+ { title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' },
{
title: '状态',
dataIndex: 'status',
@@ -316,11 +318,12 @@ const ClassDetailPage: React.FC = () => {
},
{
title: '操作',
- render: (_: unknown, r: ClassStudent) => (
- handleRemoveStudent(r.studentId)}>
- 移除
-
- ),
+ render: (_: unknown, r: ClassStudent) =>
+ r.status === 'active' ? (
+ handleRemoveStudent(r.studentId)}>
+ 移除
+
+ ) : null,
},
];
diff --git a/apps/server/src/classes/classes.batch-import-membership.spec.ts b/apps/server/src/classes/classes.batch-import-membership.spec.ts
new file mode 100644
index 0000000..fea8627
--- /dev/null
+++ b/apps/server/src/classes/classes.batch-import-membership.spec.ts
@@ -0,0 +1,47 @@
+import { ClassesService } from './classes.service';
+import { ClassStudent } from '../entities';
+
+describe('ClassesService — DingTalk class import membership lifecycle', () => {
+ it('reactivates left memberships and skips active memberships', async () => {
+ const left = {
+ classId: 3,
+ studentId: 8,
+ status: 'left',
+ joinDate: '2026-01-01',
+ leaveDate: '2026-02-01',
+ } as ClassStudent;
+ const active = { classId: 3, studentId: 9, status: 'active' } as ClassStudent;
+ const classStudentRepo = {
+ find: jest.fn().mockResolvedValue([left, active]),
+ create: jest.fn().mockImplementation((value: Partial) => value),
+ save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value),
+ };
+ const service = new ClassesService(
+ { findOne: jest.fn().mockResolvedValue({ id: 3 }) } as never,
+ classStudentRepo as never,
+ {} as never,
+ {} as never,
+ {} as never,
+ {} as never,
+ { create: jest.fn(), save: jest.fn() } as never,
+ {
+ find: jest.fn().mockResolvedValue([
+ { dingUserId: 'd8', studentId: 8 },
+ { dingUserId: 'd9', studentId: 9 },
+ ]),
+ create: jest.fn(),
+ save: jest.fn(),
+ } as never,
+ );
+
+ const result = await service.batchImportStudents(3, [
+ { dingUserId: 'd8', name: '学生8' },
+ { dingUserId: 'd9', name: '学生9' },
+ ]);
+
+ expect(result).toEqual({ imported: 1, skipped: 1 });
+ expect(left).toMatchObject({ status: 'active', leaveDate: null });
+ expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ expect(classStudentRepo.save).toHaveBeenCalledWith([left]);
+ });
+});
diff --git a/apps/server/src/classes/classes.membership.spec.ts b/apps/server/src/classes/classes.membership.spec.ts
new file mode 100644
index 0000000..9dc2167
--- /dev/null
+++ b/apps/server/src/classes/classes.membership.spec.ts
@@ -0,0 +1,95 @@
+import { BadRequestException, NotFoundException } from '@nestjs/common';
+import { ClassesService } from './classes.service';
+import { ClassStudent } from '../entities';
+
+function createService(
+ classStudentRepo: Record,
+ classRepo: Record = { findOne: jest.fn().mockResolvedValue({ id: 3 }) },
+ studentRepo: Record = {
+ find: jest.fn().mockResolvedValue([{ id: 8 }, { id: 9 }, { id: 10 }]),
+ },
+) {
+ return new ClassesService(
+ classRepo as never,
+ classStudentRepo as never,
+ {} as never,
+ {} as never,
+ {} as never,
+ {} as never,
+ studentRepo as never,
+ {} as never,
+ );
+}
+
+describe('ClassesService — student membership lifecycle', () => {
+ it('marks an active membership as left instead of deleting it', async () => {
+ const membership = {
+ classId: 3,
+ studentId: 8,
+ status: 'active',
+ leaveDate: null,
+ } as ClassStudent;
+ const repo = {
+ findOne: jest.fn().mockResolvedValue(membership),
+ save: jest.fn().mockImplementation(async (value: ClassStudent) => value),
+ };
+ const service = createService(repo);
+
+ await service.removeStudent(3, 8);
+
+ expect(membership.status).toBe('left');
+ expect(membership.leaveDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ expect(repo.save).toHaveBeenCalledWith(membership);
+ });
+
+ it('rejects removing an already-left membership', async () => {
+ const repo = {
+ findOne: jest.fn().mockResolvedValue({ status: 'left' }),
+ save: jest.fn(),
+ };
+ const service = createService(repo);
+
+ await expect(service.removeStudent(3, 8)).rejects.toBeInstanceOf(BadRequestException);
+ expect(repo.save).not.toHaveBeenCalled();
+ });
+
+ it('rejects removing a student without a membership', async () => {
+ const repo = {
+ findOne: jest.fn().mockResolvedValue(null),
+ save: jest.fn(),
+ };
+ const service = createService(repo);
+
+ await expect(service.removeStudent(3, 8)).rejects.toBeInstanceOf(NotFoundException);
+ });
+
+ it('reactivates left memberships, creates new ones, and skips active ones', async () => {
+ const left = {
+ id: 1,
+ classId: 3,
+ studentId: 8,
+ status: 'left',
+ joinDate: '2026-01-01',
+ leaveDate: '2026-02-01',
+ } as ClassStudent;
+ const active = { id: 2, classId: 3, studentId: 9, status: 'active' } as ClassStudent;
+ const repo = {
+ find: jest.fn().mockResolvedValue([left, active]),
+ create: jest.fn().mockImplementation((value: Partial) => value),
+ save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value),
+ };
+ const service = createService(repo);
+
+ const result = await service.addStudents(3, [8, 9, 10, 10]);
+
+ expect(result).toEqual({ added: 2, skipped: 1 });
+ expect(left).toMatchObject({ status: 'active', leaveDate: null });
+ expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ expect(repo.create).toHaveBeenCalledWith(
+ expect.objectContaining({ classId: 3, studentId: 10, status: 'active' }),
+ );
+ expect(repo.save).toHaveBeenCalledWith(
+ expect.arrayContaining([left, expect.objectContaining({ studentId: 10 })]),
+ );
+ });
+});
diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts
index d61a45f..36451a6 100644
--- a/apps/server/src/classes/classes.service.ts
+++ b/apps/server/src/classes/classes.service.ts
@@ -227,34 +227,45 @@ export class ClassesService {
}
// 3. Fetch existing class-student links in one query
- const allStudentIds = Array.from(dingToStudentId.values());
- const alreadyInClass = new Set();
- if (allStudentIds.length > 0) {
- const existingClassStudents = await this.classStudentRepo.find({
- where: { classId, studentId: In(allStudentIds) },
- });
- for (const cs of existingClassStudents) {
- alreadyInClass.add(cs.studentId);
+ const allStudentIds = Array.from(new Set(dingToStudentId.values()));
+ const existingClassStudents =
+ allStudentIds.length > 0
+ ? await this.classStudentRepo.find({
+ where: { classId, studentId: In(allStudentIds) },
+ })
+ : [];
+ const existingByStudentId = new Map(
+ existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]),
+ );
+ const today = new Date().toISOString().slice(0, 10);
+ let skipped = 0;
+ const memberships = allStudentIds.flatMap((studentId) => {
+ const existing = existingByStudentId.get(studentId);
+ if (existing?.status === 'active') {
+ skipped++;
+ return [];
}
- }
-
- // 4. Batch insert new class-student records
- const newClassStudents = allStudentIds
- .filter((sid) => !alreadyInClass.has(sid))
- .map((studentId) =>
+ if (existing) {
+ existing.status = 'active';
+ existing.joinDate = today;
+ existing.leaveDate = null;
+ return [existing];
+ }
+ return [
this.classStudentRepo.create({
classId,
studentId,
status: 'active',
- joinDate: new Date().toISOString().slice(0, 10),
+ joinDate: today,
}),
- );
+ ];
+ });
- if (newClassStudents.length > 0) {
- await this.classStudentRepo.save(newClassStudents);
+ if (memberships.length > 0) {
+ await this.classStudentRepo.save(memberships);
}
- return { imported: newClassStudents.length, skipped: alreadyInClass.size };
+ return { imported: memberships.length, skipped };
}
async update(id: number, dto: UpdateClassDto) {
const cls = await this.classRepo.findOne({ where: { id } });
@@ -315,26 +326,61 @@ export class ClassesService {
}
async addStudents(classId: number, studentIds: number[]) {
+ const uniqueStudentIds = [...new Set(studentIds)];
+ if (uniqueStudentIds.length === 0) return { added: 0, skipped: 0 };
+
+ const cls = await this.classRepo.findOne({ where: { id: classId } });
+ if (!cls) throw new NotFoundException('班级不存在');
+
+ const students = await this.studentRepo.find({ where: { id: In(uniqueStudentIds) } });
+ if (students.length !== uniqueStudentIds.length) {
+ throw new NotFoundException('部分学生不存在');
+ }
+
const existing = await this.classStudentRepo.find({
- where: { classId, studentId: In(studentIds) },
+ where: { classId, studentId: In(uniqueStudentIds) },
});
- const existingIds = new Set(existing.map((e) => e.studentId));
- const newIds = studentIds.filter((id) => !existingIds.has(id));
-
- const entries = newIds.map((sid) =>
- this.classStudentRepo.create({
- classId,
- studentId: sid,
- joinDate: new Date().toISOString().split('T')[0],
- }),
+ const existingByStudentId = new Map(
+ existing.map((classStudent) => [classStudent.studentId, classStudent]),
);
- if (entries.length) await this.classStudentRepo.save(entries);
+ const today = new Date().toISOString().split('T')[0];
+ let skipped = 0;
+ const memberships = uniqueStudentIds.flatMap((studentId) => {
+ const current = existingByStudentId.get(studentId);
+ if (current?.status === 'active') {
+ skipped++;
+ return [];
+ }
+ if (current) {
+ current.status = 'active';
+ current.joinDate = today;
+ current.leaveDate = null;
+ return [current];
+ }
+ return [
+ this.classStudentRepo.create({
+ classId,
+ studentId,
+ status: 'active',
+ joinDate: today,
+ }),
+ ];
+ });
+ if (memberships.length) await this.classStudentRepo.save(memberships);
- return { added: entries.length, skipped: studentIds.length - entries.length };
+ return { added: memberships.length, skipped };
}
async removeStudent(classId: number, studentId: number) {
- await this.classStudentRepo.delete({ classId, studentId });
+ const membership = await this.classStudentRepo.findOne({
+ where: { classId, studentId },
+ });
+ if (!membership) throw new NotFoundException('学生不在该班级');
+ if (membership.status !== 'active') throw new BadRequestException('学生已离班');
+
+ membership.status = 'left';
+ membership.leaveDate = new Date().toISOString().split('T')[0];
+ await this.classStudentRepo.save(membership);
return { success: true };
}
diff --git a/apps/server/src/database/database-migrations.class-student.spec.ts b/apps/server/src/database/database-migrations.class-student.spec.ts
new file mode 100644
index 0000000..6088762
--- /dev/null
+++ b/apps/server/src/database/database-migrations.class-student.spec.ts
@@ -0,0 +1,56 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { getDataSourceToken } from '@nestjs/typeorm';
+import { DatabaseMigrationsService } from './database-migrations.service';
+
+function createRunner(tableExists: boolean, columns: string[] = []) {
+ return {
+ connect: jest.fn(),
+ release: jest.fn(),
+ getTables: jest.fn().mockResolvedValue(tableExists ? [{ name: 'class_student' }] : []),
+ getTable: jest.fn().mockResolvedValue({
+ name: 'class_student',
+ columns: columns.map((name) => ({ name })),
+ }),
+ dropColumn: jest.fn().mockResolvedValue(undefined),
+ };
+}
+
+async function createService(runner: ReturnType) {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ DatabaseMigrationsService,
+ {
+ provide: getDataSourceToken(),
+ useValue: {
+ options: { type: 'better-sqlite3' },
+ createQueryRunner: jest.fn().mockReturnValue(runner),
+ },
+ },
+ ],
+ }).compile();
+ return module.get(DatabaseMigrationsService) as DatabaseMigrationsService & {
+ removeUnusedClassStudentColumns(): Promise;
+ };
+}
+
+describe('DatabaseMigrationsService — class student cleanup', () => {
+ it('drops the unused enrollment_id column', async () => {
+ const runner = createRunner(true, ['id', 'enrollment_id']);
+ const service = await createService(runner);
+
+ await service.removeUnusedClassStudentColumns();
+
+ expect(runner.dropColumn).toHaveBeenCalledWith('class_student', 'enrollment_id');
+ expect(runner.release).toHaveBeenCalled();
+ });
+
+ it('does nothing when the table is absent', async () => {
+ const runner = createRunner(false);
+ const service = await createService(runner);
+
+ await service.removeUnusedClassStudentColumns();
+
+ expect(runner.dropColumn).not.toHaveBeenCalled();
+ expect(runner.release).toHaveBeenCalled();
+ });
+});
diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts
index 0254339..d9c8587 100644
--- a/apps/server/src/database/database-migrations.service.ts
+++ b/apps/server/src/database/database-migrations.service.ts
@@ -16,6 +16,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
await this.protectAttendanceHistory();
await this.removeUnusedClassroomColumns();
await this.cleanupDepositRefundColumns();
+ await this.removeUnusedClassStudentColumns();
}
private async removeUnusedClassroomColumns(): Promise {
@@ -75,6 +76,22 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
}
}
+ private async removeUnusedClassStudentColumns(): Promise {
+ const runner = this.dataSource.createQueryRunner();
+ await runner.connect();
+ try {
+ const tables = await runner.getTables(['class_student']);
+ if (tables.length === 0) return;
+
+ const table = await runner.getTable('class_student');
+ if (table?.columns.some((column) => column.name === 'enrollment_id')) {
+ await runner.dropColumn('class_student', 'enrollment_id');
+ }
+ } finally {
+ await runner.release();
+ }
+ }
+
private async ensureAiConfigTable(): Promise {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
diff --git a/apps/server/src/entities/class-student.entity.ts b/apps/server/src/entities/class-student.entity.ts
index 4b9906c..7a6ca73 100644
--- a/apps/server/src/entities/class-student.entity.ts
+++ b/apps/server/src/entities/class-student.entity.ts
@@ -30,14 +30,11 @@ export class ClassStudent {
@JoinColumn({ name: 'student_id' })
student: Student;
- @Column({ name: 'enrollment_id', type: 'integer', nullable: true })
- enrollmentId: number;
-
@Column({ name: 'join_date', type: 'date', nullable: true })
- joinDate: string;
+ joinDate: string | null;
@Column({ name: 'leave_date', type: 'date', nullable: true })
- leaveDate: string;
+ leaveDate: string | null;
@Column({ name: 'status', length: 10, default: 'active' })
status: string;
From aa1ed7db567b9e2d3b5dc0c984ec3fca21a46769 Mon Sep 17 00:00:00 2001
From: wangziqi
Date: Mon, 13 Jul 2026 15:12:36 +0800
Subject: [PATCH 12/13] refactor: resolve remaining field audit issues
---
.../src/pages/ClassroomRentals/index.tsx | 87 +++++--
apps/admin/src/pages/Classrooms/index.tsx | 20 +-
apps/admin/src/pages/Rooms/index.tsx | 24 +-
apps/server/package.json | 1 +
apps/server/src/archive/archive.controller.ts | 20 +-
apps/server/src/archive/archive.service.ts | 38 ++-
.../src/archive/dto/archive.dto.spec.ts | 41 +++-
apps/server/src/archive/dto/archive.dto.ts | 7 +
.../classroom-rentals.controller.ts | 36 +++
.../classroom-rentals.service.spec.ts | 70 ++++--
.../classroom-rentals.service.ts | 96 ++++++--
.../src/classroom-rentals/dto/rental.dto.ts | 6 +-
.../src/classrooms/classrooms.service.ts | 217 ++++++++++++++----
.../src/classrooms/classrooms.status.spec.ts | 39 ++++
.../src/classrooms/dto/classroom.dto.ts | 8 +-
.../server/src/dashboard/dashboard.service.ts | 12 +-
...tabase-migrations.classroom-status.spec.ts | 36 +++
.../database/database-migrations.service.ts | 47 ++--
.../src/entities/classroom-rental.entity.ts | 10 +-
apps/server/src/entities/classroom.entity.ts | 14 +-
apps/server/src/entities/index.ts | 4 +-
apps/server/src/entities/room.entity.ts | 13 +-
.../integration/config/dto/config.dto.spec.ts | 48 ++++
.../src/integration/config/dto/config.dto.ts | 72 ++++--
.../config/integration-config.controller.ts | 6 +-
.../config/integration-config.service.ts | 18 +-
.../src/occupancies/occupancies.service.ts | 33 +--
apps/server/src/rooms/dto/room.dto.spec.ts | 21 ++
apps/server/src/rooms/dto/room.dto.ts | 11 +-
apps/server/src/rooms/rooms.controller.ts | 22 +-
apps/server/src/rooms/rooms.gender.spec.ts | 43 ++++
apps/server/src/rooms/rooms.service.ts | 58 +++--
.../server/src/schedules/schedules.service.ts | 17 +-
package-lock.json | 21 ++
34 files changed, 953 insertions(+), 263 deletions(-)
create mode 100644 apps/server/src/classrooms/classrooms.status.spec.ts
create mode 100644 apps/server/src/database/database-migrations.classroom-status.spec.ts
create mode 100644 apps/server/src/integration/config/dto/config.dto.spec.ts
create mode 100644 apps/server/src/rooms/dto/room.dto.spec.ts
create mode 100644 apps/server/src/rooms/rooms.gender.spec.ts
diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx
index 08d7451..5892fad 100644
--- a/apps/admin/src/pages/ClassroomRentals/index.tsx
+++ b/apps/admin/src/pages/ClassroomRentals/index.tsx
@@ -15,7 +15,7 @@ import {
Tooltip,
Empty,
} from 'antd';
-import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
+import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined, StopOutlined, CheckOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
@@ -39,6 +39,7 @@ const ClassroomRentalsPage: React.FC = () => {
const [editing, setEditing] = useState(null);
const [form] = Form.useForm();
const [filterMonth, setFilterMonth] = useState(null);
+ const [filterStatus, setFilterStatus] = useState();
const [searchText, setSearchText] = useState('');
const [saving, setSaving] = useState(false);
const [unavailableDates, setUnavailableDates] = useState>(new Set());
@@ -48,20 +49,22 @@ const ClassroomRentalsPage: React.FC = () => {
const selectedClassroomId = Form.useWatch('classroomId', form);
const filteredData = useMemo(() => {
- if (!searchText) return data;
- const s = searchText.toLowerCase();
return data.filter((r: any) => {
+ if (filterStatus && r.effectiveStatus !== filterStatus) return false;
+ if (!searchText) return true;
+ const s = searchText.toLowerCase();
const matchClassroom = r.classroom?.name?.toLowerCase().includes(s);
const matchOrganization = r.lesseeOrganization?.name?.toLowerCase().includes(s);
return matchClassroom || matchOrganization;
});
- }, [data, searchText]);
+ }, [data, searchText, filterStatus]);
const fetchData = async () => {
setLoading(true);
try {
const params: any = {};
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
+ params.includeEnded = true;
const res: any = await api.get('/classroom-rentals', { params });
setData(res);
} catch (e: any) {
@@ -215,6 +218,16 @@ const ClassroomRentalsPage: React.FC = () => {
}
};
+ const handleRentalAction = async (id: number, action: 'cancel' | 'end') => {
+ try {
+ await api.put(`/classroom-rentals/${id}/${action}`);
+ message.success(action === 'cancel' ? '租赁已取消' : '租赁已结束');
+ fetchData();
+ } catch (e: any) {
+ message.error(e?.message || '操作失败');
+ }
+ };
+
const handleDownloadContract = async (id: number, filename?: string) => {
try {
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
@@ -308,6 +321,19 @@ const ClassroomRentalsPage: React.FC = () => {
width: 100,
render: (v: any) => (v ? `¥${v}` : '-'),
},
+ {
+ title: '状态',
+ dataIndex: 'effectiveStatus',
+ width: 90,
+ render: (status: string) => {
+ const config: Record = {
+ active: { text: '进行中', color: 'green' },
+ ended: { text: '已结束', color: 'default' },
+ cancelled: { text: '已取消', color: 'red' },
+ };
+ return {config[status]?.text || status};
+ },
+ },
{
title: '合同',
width: 120,
@@ -364,21 +390,30 @@ const ClassroomRentalsPage: React.FC = () => {
width: 150,
render: (_: any, record: any) => (
- openEdit(record)}
- >
- 编辑
-
- handleDelete(record.id)}
- >
-
- 删除
-
-
+ {record.effectiveStatus === 'active' && (
+ <>
+ openEdit(record)}>
+ 编辑
+
+ handleRentalAction(record.id, 'cancel')}>
+ }>
+ 取消
+
+
+ {!dayjs(record.startDate).isAfter(dayjs(), 'day') && (
+ handleRentalAction(record.id, 'end')}>
+ }>
+ 结束
+
+
+ )}
+ >
+ )}
+ {record.effectiveStatus !== 'active' && (
+ handleDelete(record.id)}>
+ 删除
+
+ )}
),
},
@@ -415,6 +450,18 @@ const ClassroomRentalsPage: React.FC = () => {
allowClear
format="YYYY-MM"
/>
+
{
optionFilterProp="label"
placeholder="选择教室"
onChange={handleClassroomChange}
- options={classrooms.map((c) => ({
+ options={classrooms.filter((c) => c.status === 'available').map((c) => ({
value: c.id,
label: `${c.building ? c.building + ' · ' : ''}${c.name}(${c.roomType})`,
}))}
diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx
index e3e9fe8..360e52f 100644
--- a/apps/admin/src/pages/Classrooms/index.tsx
+++ b/apps/admin/src/pages/Classrooms/index.tsx
@@ -61,7 +61,7 @@ const ClassroomsPage: React.FC = () => {
const filteredData = useMemo(() => {
let result = data;
if (searchText) { const s = searchText.toLowerCase(); result = result.filter((d: Record) => (typeof d.name === 'string' && d.name.toLowerCase().includes(s)) || (typeof d.building === 'string' && d.building.toLowerCase().includes(s))); }
- if (filterStatus) result = result.filter((d: Record) => d.status === filterStatus);
+ if (filterStatus) result = result.filter((d: Record) => d.effectiveStatus === filterStatus);
return result;
}, [data, searchText, filterStatus]);
@@ -157,11 +157,11 @@ const ClassroomsPage: React.FC = () => {
{
title: '状态', width: 100,
dataIndex: 'status',
- render: (s: string, record: { currentUsage?: CurrentUsage | null }) => {
- const effectiveStatus = record.currentUsage ? 'in_use' : s;
+ render: (_s: string, record: { effectiveStatus?: string; status: string; currentUsage?: CurrentUsage | null }) => {
+ const effectiveStatus = record.effectiveStatus || record.status;
return (
- {statusMap[effectiveStatus]?.text || s}
+ {statusMap[effectiveStatus]?.text || effectiveStatus}
);
},
@@ -228,7 +228,7 @@ const ClassroomsPage: React.FC = () => {
if (!e.target.value) setSearchText('');
}}
/>
-
+