feat: 班级花名册与档案报读双向同步 #83

Merged
wangziqi merged 3 commits from feat/roster-enrollment-bidirectional-sync into main 2026-08-12 06:04:14 +00:00
7 changed files with 129 additions and 7 deletions
@@ -70,6 +70,10 @@ export const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> =
async (id: number) => api.delete(`/archive/enrollments/${id}/permanent`),
{ invalidate: [['archive', studentId]] },
);
const archiveEnrollmentMutation = useApiMutation(
async (id: number) => api.delete(`/archive/enrollments/${id}`),
{ invalidate: [['archive', studentId]] },
);
const {
data: classes = [],
@@ -181,6 +185,23 @@ export const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> =
}
};
const handleArchive = (record: EnrollmentRecord) => {
modal.confirm({
title: `归档报读记录(${formatEnrollmentDisplayName(record)})?`,
content: '归档后该生将从对应班级花名册移出(若无其他在读报读),报读记录保留可查。',
okText: '归档',
cancelText: '取消',
onOk: async () => {
try {
await archiveEnrollmentMutation.mutateAsync(record.id);
message.success('已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const handlePurge = (record: EnrollmentRecord) => {
modal.confirm({
title: `永久删除报读记录(${formatEnrollmentDisplayName(record)})?`,
@@ -303,9 +324,15 @@ export const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> =
{
title: '操作',
render: (_: unknown, r: EnrollmentRecord) =>
r.status === 'archived' && canPurgeArchive ? (
<Button size="small" danger type="link" onClick={() => handlePurge(r)}>
r.status === 'archived' ? (
canPurgeArchive && (
<Button size="small" danger type="link" onClick={() => handlePurge(r)}>
</Button>
)
) : hasPermission('student:edit') ? (
<Button size="small" type="link" onClick={() => handleArchive(r)}>
</Button>
) : null,
},
+4
View File
@@ -621,6 +621,8 @@ const SchedulesPage: React.FC = () => {
mode="multiple"
placeholder="筛选教室"
allowClear
showSearch
optionFilterProp="label"
style={{ minWidth: 200 }}
value={filterClassroomIds}
onChange={(v) => setFilterClassroomIds(v)}
@@ -630,6 +632,8 @@ const SchedulesPage: React.FC = () => {
<Select
placeholder="筛选班级"
allowClear
showSearch
optionFilterProp="label"
style={{ minWidth: 160 }}
value={filterClassId}
onChange={(v) => setFilterClassId(v)}
+2
View File
@@ -503,6 +503,8 @@ const UsersPage: React.FC = () => {
<Select
mode="multiple"
placeholder="选择角色"
showSearch
optionFilterProp="label"
options={roles
.filter((r: RoleOption) => r.status !== 0)
.map((r: RoleOption) => ({
@@ -0,0 +1,70 @@
import { EntityManager, In } from 'typeorm';
import { Class, ClassTeacher, StudentEnrollment } from '../entities';
/**
* 班级花名册 → 报读记录 单向补齐(双向同步的「班级侧添加」方向)。
*
* 规则复刻前端「档案添加报读」的自动带出逻辑(EnrollmentsTab)与
* archive.addEnrollment 的快照规则:
* - courseCategory = 班级 classTypeculture/professional/comprehensive
* - classType = 'offline'(与现有报读数据一致;班级无此字段)
* - className = 班级名快照
* - headTeacher = 班级 head_teacher 角色老师名
* - subjectTeacher = 班级 subject_teacher 角色老师名去重拼接
* - startDate/endDate = 班级起止日期
* - status = 'active'
*
* 幂等:该生已有 active 报读指向此班时跳过(与 syncRosterMembership 的
* 「离班守卫」对称,防止重复建报读)。
*
* ponytail: 不处理班级侧移除 → 报读联动;报读是档案数据,花名册移除不应
* 隐式改动报读状态,需要时由用户显式在档案处理。
*/
export async function ensureEnrollmentsForRoster(
manager: EntityManager,
classEntity: Class,
studentIds: number[],
): Promise<number> {
if (studentIds.length === 0) return 0;
const existing = await manager.find(StudentEnrollment, {
where: { classId: classEntity.id, studentId: In(studentIds), status: 'active' },
select: ['studentId'],
});
const existingSet = new Set(existing.map((e) => e.studentId));
const missing = studentIds.filter((id) => !existingSet.has(id));
if (missing.length === 0) return 0;
const teachers = await manager.find(ClassTeacher, {
where: { classId: classEntity.id },
relations: ['user'],
});
const headTeacher =
teachers.find((t) => t.roleType === 'head_teacher')?.user?.name ?? undefined;
const subjectTeacher =
Array.from(
new Set(
teachers
.filter((t) => t.roleType === 'subject_teacher')
.map((t) => t.user?.name)
.filter((n): n is string => Boolean(n)),
),
).join('、') || undefined;
const enrollments = missing.map((studentId) =>
manager.create(StudentEnrollment, {
studentId,
classId: classEntity.id,
className: classEntity.name,
courseCategory: classEntity.classType,
classType: 'offline',
headTeacher,
subjectTeacher,
startDate: classEntity.startDate ?? undefined,
endDate: classEntity.endDate ?? undefined,
status: 'active',
}),
);
await manager.save(StudentEnrollment, enrollments);
return enrollments.length;
}
@@ -13,6 +13,7 @@ import { Classroom } from '../entities/classroom.entity';
import { syncDingTalkStudents } from '../integration/dingtalk-student-sync';
import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto';
import { sanitizeCellText } from './class-roster-import';
import { ensureEnrollmentsForRoster } from './class-roster-enrollment';
import type { ClassRosterCreateRow, ClassRosterImportRow } from './class-roster-import';
import { escapeLike } from '../common/like-escape';
import dayjs from '../common/dayjs';
@@ -791,6 +792,14 @@ export class ClassesQueriesService {
}
}
}
// 双向同步:导入花名册 → 档案自动建报读(幂等,已有 active 报读则跳过)
if (memberships.length) {
await ensureEnrollmentsForRoster(
manager,
classEntity,
memberships.map((m) => m.studentId),
);
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping } from '../entities';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping, StudentEnrollment } from '../entities';
import { ClassesService } from './classes.service';
import { ClassesQueriesService } from './classes-queries.service';
import { ClassesController } from './classes.controller';
@@ -8,7 +8,7 @@ import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule],
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping, StudentEnrollment]), OperationLogsModule, NotificationsModule],
controllers: [ClassesController],
providers: [ClassesService, ClassesQueriesService],
exports: [ClassesService],
+12 -2
View File
@@ -20,6 +20,7 @@ import {
StudentDingMapping,
} from '../entities';
import { ClassesQueriesService, isDuplicateEntryError } from './classes-queries.service';
import { ensureEnrollmentsForRoster } from './class-roster-enrollment';
import { normalizeDateOnly } from '../database/date-normalization';
import dayjs from '../common/dayjs';
import {
@@ -442,9 +443,18 @@ export class ClassesService {
}),
];
});
if (memberships.length) await this.classStudentRepo.save(memberships);
return { added: memberships.length, skipped };
return this.dataSource.transaction(async (manager) => {
let added = 0;
if (memberships.length) {
await manager.save(ClassStudent, memberships);
added = memberships.length;
}
// 双向同步:班级添加成员 → 档案自动建报读(幂等,已有 active 报读则跳过)
const enrolledIds = memberships.map((m) => m.studentId);
const enrollmentsCreated = await ensureEnrollmentsForRoster(manager, cls, enrolledIds);
return { added, skipped, enrollmentsCreated };
});
}
async removeStudent(classId: number, studentId: number) {