@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 = 班级 classType(culture/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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user