2 Commits
Author SHA1 Message Date
wangziqi 6f9316709f feat(server): 班级花名册与档案报读双向同步
- 新增 ensureEnrollmentsForRoster:班级添加/导入成员时事务内自动补建报读记录
  (course_category=班级class_type, classType=offline, 班名快照, 老师取class_teacher配置,
  起止日期取班级; 幂等: 已有 active 报读指向此班则跳过)
- addStudents 改为事务包裹并联动建报读
- commitRosterImport 导入提交后联动建报读
- 存量数据可用 backfill_roster_enrollments.sql 回填
2026-08-12 12:02:03 +08:00
wangziqi b87acbd704 fix(admin): 筛选教室/班级/角色 Select 按 label 过滤搜索
optionFilterProp 默认按 value(数字 id)过滤,导致搜"3"匹配教室 id=3 显示"209"。
排课页顶部筛选教室/筛选班级与账号管理角色分配统一加 showSearch+optionFilterProp=label,
按显示文本搜索。
2026-08-12 12:02:03 +08:00
6 changed files with 99 additions and 4 deletions
+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) {