feat: 拆分学号/身份证字段 + 考勤教师展示 + 代码优化

- 入住导入模板:学号和身份证号拆为独立字段,前后端对齐
- 排课查询关联教师,考勤归档页展示教师姓名
- 抽查时段增加 IsIn 校验
- 抽取 withPessimisticWriteLock 去重悲观锁查询
- import 增加文件空 buffer 校验
- 测试 mock 补全,适配事务 manager
- MySQL init.sql VALUES() 语法兼容修复
This commit is contained in:
2026-07-20 11:54:01 +08:00
parent 375c7ec60b
commit c7ba2799b9
12 changed files with 772 additions and 365 deletions

View File

@@ -45,6 +45,7 @@ const createService = () => {
{} as never,
sessionRepo as never,
attendanceDeviceRepo as never,
{} as never,
dataSource as unknown as DataSource,
);
return { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo, attendanceDeviceRepo, dataSource };

View File

@@ -7,6 +7,7 @@ import { AttendanceService } from './attendance.service';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { AttendanceDevice } from '../entities/attendance-device.entity';
import { AttendancePeriodConfig } from '../entities/attendance-period-config.entity';
import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity';
import { Class } from '../entities/class.entity';
import { Student } from '../entities/student.entity';
@@ -59,6 +60,7 @@ describe('AttendanceService — batchCreate', () => {
{ provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } },
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
{ provide: getRepositoryToken(AttendanceDevice), useValue: mockAttendanceDeviceRepo },
{ provide: getRepositoryToken(AttendancePeriodConfig), useValue: {} },
{ provide: getDataSourceToken(), useValue: { transaction: jest.fn() } },
],
}).compile();
@@ -140,6 +142,7 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
{} as never,
{} as never,
{} as never,
{} as never,
);
beforeEach(() => {
@@ -216,6 +219,7 @@ describe('AttendanceService — DingTalk raw query', () => {
{} as never,
{} as never,
{} as never,
{} as never,
);
await expect(
@@ -280,6 +284,7 @@ describe('AttendanceService — attendance device display mappings', () => {
{} as never,
attendanceDeviceRepo as never,
{} as never,
{} as never,
);
return { service, qb, attendanceDeviceRepo };
}
@@ -389,6 +394,7 @@ describe('AttendanceService — session serialization', () => {
{} as never,
{} as never,
{ find: jest.fn().mockResolvedValue([]) } as never,
{} as never,
dataSourceMock as never,
);
}

View File

@@ -12,6 +12,7 @@ import {
ClassSchedule,
ClassStudent,
ClassTeacher,
TeacherRoleType,
ScheduleType,
StudentDingMapping,
} from '../entities';
@@ -970,8 +971,12 @@ export class AttendanceService {
async getScheduleOptionsForAttendance(classId: number, date: string) {
const weekDay = this.getWeekDayForDate(date);
return this.scheduleRepo
const { entities, raw } = await this.scheduleRepo
.createQueryBuilder('cs')
.leftJoin('cs.teacher', 'teacher')
.addSelect('cs.id', 'scheduleIdForTeacherMap')
.addSelect('teacher.username', 'teacherUsername')
.addSelect('teacher.name', 'teacherName')
.where('cs.classId = :classId', { classId })
.andWhere('cs.weekDay = :weekDay', { weekDay })
.andWhere('cs.startDate <= :date', { date })
@@ -979,7 +984,25 @@ export class AttendanceService {
.andWhere('cs.status = :status', { status: 'active' })
.orderBy('cs.startTime', 'ASC')
.addOrderBy('cs.subject', 'ASC')
.getMany();
.getRawAndEntities();
const teacherByScheduleId = new Map(
raw.map((row) => [
Number(row.scheduleIdForTeacherMap),
{
teacherName: row.teacherName || null,
teacherUsername: row.teacherUsername || null,
},
]),
);
return entities.map((schedule, index) => {
const teacher = teacherByScheduleId.get(schedule.id) ?? {
teacherName: raw[index]?.teacherName || null,
teacherUsername: raw[index]?.teacherUsername || null,
};
return { ...schedule, ...teacher };
});
}
private async buildCalendar(classId: number, weekStart: string) {
@@ -1095,9 +1118,51 @@ export class AttendanceService {
if (classIds.length === 0) return [];
const where = { id: In(classIds) };
const classes = await this.classRepo.find({ where });
const [classes, teachers] = await Promise.all([
this.classRepo.find({ where }),
this.classTeacherRepo.find({
where: {
classId: In(classIds),
roleType: In([
TeacherRoleType.HEAD_TEACHER,
TeacherRoleType.LIFE_TEACHER,
TeacherRoleType.SUBJECT_TEACHER,
]),
},
relations: ['user'],
order: { roleType: 'ASC', id: 'ASC' },
}),
]);
const nameMap = new Map(classes.map((c) => [c.id, c.name]));
return classIds.map((id) => ({ classId: id, className: nameMap.get(id) || `班级${id}` }));
const teacherMap = new Map<
number,
Array<{
userId: number;
username: string | null;
name: string | null;
roleType: string;
subject: string | null;
}>
>();
for (const teacher of teachers) {
const user = teacher.user as { username?: string | null; name?: string | null } | undefined;
const items = teacherMap.get(teacher.classId) ?? [];
items.push({
userId: teacher.userId,
username: user?.username || null,
name: user?.name || null,
roleType: teacher.roleType,
subject: teacher.subject || null,
});
teacherMap.set(teacher.classId, items);
}
return classIds.map((id) => ({
classId: id,
className: nameMap.get(id) || `班级${id}`,
teachers: teacherMap.get(id) ?? [],
}));
}
// ── DingAttendance raw records ──

View File

@@ -64,6 +64,7 @@ export class AttendanceRecordItem {
attendanceDate: string;
@IsString()
@IsIn(['morning_reading', 'morning', 'afternoon', 'evening_study', 'night_check'])
@IsNotEmpty()
session: string;