feat(core-edu): 完整实现 core-edu 教学核心服务
包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
This commit is contained in:
116
services/core-edu/src/attendance/attendance.service.ts
Normal file
116
services/core-edu/src/attendance/attendance.service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { db } from "../config/database.js";
|
||||
import { attendanceRepository } from "./attendance.repository.js";
|
||||
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
|
||||
import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js";
|
||||
import { isValidAttendanceStatus } from "./attendance-status.js";
|
||||
import {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
ConflictError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
import type { Attendance } from "./attendance.schema.js";
|
||||
|
||||
export interface RecordAttendanceInput {
|
||||
scheduleId: string;
|
||||
studentId: string;
|
||||
status: string;
|
||||
remark?: string;
|
||||
recordedBy: string;
|
||||
schoolId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AttendanceService {
|
||||
async recordAttendance(
|
||||
input: RecordAttendanceInput,
|
||||
userId?: string,
|
||||
): Promise<{ id: string }> {
|
||||
if (
|
||||
!input.scheduleId ||
|
||||
!input.studentId ||
|
||||
!input.recordedBy ||
|
||||
!input.schoolId
|
||||
) {
|
||||
throw new ValidationError(
|
||||
"scheduleId, studentId, recordedBy, schoolId are required",
|
||||
);
|
||||
}
|
||||
if (!isValidAttendanceStatus(input.status)) {
|
||||
throw new ValidationError(
|
||||
`Invalid attendance status: ${input.status}. Must be one of: present, absent, late, leave`,
|
||||
);
|
||||
}
|
||||
|
||||
// Idempotency: check existing record
|
||||
const existing = await attendanceRepository.findByScheduleAndStudent(
|
||||
input.scheduleId,
|
||||
input.studentId,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictError(
|
||||
`Attendance already recorded for student ${input.studentId} in schedule ${input.scheduleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const event = buildEvent({
|
||||
aggregateId: id,
|
||||
eventType: "attendance.recorded",
|
||||
payload: {
|
||||
attendanceId: id,
|
||||
scheduleId: input.scheduleId,
|
||||
studentId: input.studentId,
|
||||
status: input.status,
|
||||
},
|
||||
userId,
|
||||
});
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await attendanceRepository.create(
|
||||
{
|
||||
id,
|
||||
scheduleId: input.scheduleId,
|
||||
studentId: input.studentId,
|
||||
status: input.status,
|
||||
remark: input.remark,
|
||||
recordedBy: input.recordedBy,
|
||||
schoolId: input.schoolId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await outboxRepository.create(
|
||||
{
|
||||
id: randomUUID(),
|
||||
eventId: event.event_id,
|
||||
aggregateId: id,
|
||||
aggregateType: "attendance",
|
||||
eventType: "attendance.recorded",
|
||||
occurredAt: new Date(event.occurred_at),
|
||||
payload: serializeEvent(event),
|
||||
status: "pending",
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
async getAttendance(id: string): Promise<Attendance> {
|
||||
const record = await attendanceRepository.findById(id);
|
||||
if (!record) {
|
||||
throw new NotFoundError(`Attendance ${id} not found`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async listByStudent(studentId: string): Promise<Attendance[]> {
|
||||
return attendanceRepository.findByStudentId(studentId);
|
||||
}
|
||||
|
||||
async listByClass(classId: string): Promise<Attendance[]> {
|
||||
return attendanceRepository.findByClassId(classId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user