R59 A1-A6:极简考勤打卡
- 新增 AttendanceRecord 与 AttendancePolicy 模型 - 上下班打卡、今日状态、月度统计端点 - 考勤管理页面(我的/团队/设置三 Tab) - 移动端快速打卡入口 - 管理员补卡与审计日志 - 单测覆盖打卡幂等、迟到计算、工时计算、权限边界
这个提交包含在:
@@ -0,0 +1,430 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { currentDateString } = require("../utils/date");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const {
|
||||
buildAttendanceMetrics,
|
||||
buildAttendanceValidationError,
|
||||
buildMyMonthAttendance,
|
||||
buildTeamAttendance,
|
||||
detectAttendanceSource,
|
||||
getAttendancePolicy,
|
||||
getExemptionForDate,
|
||||
loadAttendanceExemptions,
|
||||
loadAttendanceRecord,
|
||||
mapAttendanceRecord,
|
||||
parseTimeToMinutes,
|
||||
upsertAttendancePolicy,
|
||||
} = require("../services/attendance");
|
||||
|
||||
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().datetime());
|
||||
|
||||
const CLOCK_BODY_SCHEMA = z
|
||||
.object({
|
||||
note: z.string().trim().max(300).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MONTH_QUERY_SCHEMA = z.object({
|
||||
year: z.coerce.number().int().min(2000).max(2100).optional(),
|
||||
month: z.coerce.number().int().min(1).max(12).optional(),
|
||||
});
|
||||
|
||||
const TEAM_QUERY_SCHEMA = z.object({
|
||||
date: DATE_SCHEMA.optional(),
|
||||
departmentId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const POLICY_BODY_SCHEMA = z
|
||||
.object({
|
||||
workStartTime: z.string().trim(),
|
||||
workEndTime: z.string().trim(),
|
||||
lunchStartTime: z.string().trim(),
|
||||
lunchEndTime: z.string().trim(),
|
||||
lateToleranceMinutes: z.coerce.number().int().min(0).max(600),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
try {
|
||||
parseTimeToMinutes(value.workStartTime, "workStartTime");
|
||||
parseTimeToMinutes(value.workEndTime, "workEndTime");
|
||||
parseTimeToMinutes(value.lunchStartTime, "lunchStartTime");
|
||||
parseTimeToMinutes(value.lunchEndTime, "lunchEndTime");
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
for (const [key, reason] of Object.entries(error.details ?? {})) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [key],
|
||||
message: String(reason),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const ADMIN_BACKFILL_BODY_SCHEMA = z
|
||||
.object({
|
||||
employeeId: z.string().uuid(),
|
||||
workDate: DATE_SCHEMA,
|
||||
clockInAt: DATE_TIME_SCHEMA.nullable().optional(),
|
||||
clockOutAt: DATE_TIME_SCHEMA.nullable().optional(),
|
||||
note: z.string().trim().max(300).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function parseInput(schema, value) {
|
||||
const parsed = schema.safeParse(value ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildAttendanceValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function ensureFeatureEnabled(fastify, request) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.attendance,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
throw buildAttendanceValidationError(
|
||||
{ featureFlag: "attendance_v1_disabled", reason: gate.reason },
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployeeForAttendance(prisma, employeeId) {
|
||||
return prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
legal_entity_id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveTodayState(fastify, employeeId) {
|
||||
const workDate = currentDateString(fastify.config.appTimezone);
|
||||
const employee = await loadEmployeeForAttendance(fastify.prisma, employeeId);
|
||||
if (!employee || employee.status !== "active") {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
const [record, exemptions] = await Promise.all([
|
||||
loadAttendanceRecord(fastify.prisma, employeeId, workDate),
|
||||
loadAttendanceExemptions(fastify.prisma, {
|
||||
employees: [{ id: employee.id, legal_entity_id: employee.legal_entity_id }],
|
||||
dateStrings: [workDate],
|
||||
}),
|
||||
]);
|
||||
|
||||
const exemption = getExemptionForDate(exemptions, employeeId, workDate);
|
||||
return {
|
||||
employee,
|
||||
workDate,
|
||||
record,
|
||||
exemption,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalDateTime(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw buildAttendanceValidationError({ datetime: "invalid" });
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
async function attendanceRoutes(fastify) {
|
||||
fastify.post("/attendance/clock-in", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const body = parseInput(CLOCK_BODY_SCHEMA, request.body);
|
||||
const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id);
|
||||
|
||||
if (record?.clock_in_at) {
|
||||
return sendData(reply, request, { item: mapAttendanceRecord(record) });
|
||||
}
|
||||
|
||||
if (exemption) {
|
||||
throw buildAttendanceValidationError(
|
||||
{ attendance: "exempt_day", exemptionReason: exemption.exemptionReason },
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const policy = await getAttendancePolicy(fastify.prisma);
|
||||
const clockInAt = new Date();
|
||||
const clockOutAt =
|
||||
record?.clock_out_at && record.clock_out_at > clockInAt ? record.clock_out_at : null;
|
||||
const nextNote = body.note || record?.note || null;
|
||||
const metrics = buildAttendanceMetrics({
|
||||
clockInAt,
|
||||
clockOutAt,
|
||||
workDate,
|
||||
timeZone: fastify.config.appTimezone,
|
||||
policy,
|
||||
});
|
||||
|
||||
const saved = record
|
||||
? await fastify.prisma.attendanceRecord.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
clock_in_at: clockInAt,
|
||||
clock_out_at: clockOutAt,
|
||||
work_minutes: metrics.workMinutes,
|
||||
is_late: metrics.isLate,
|
||||
late_minutes: metrics.lateMinutes,
|
||||
is_early_leave: metrics.isEarlyLeave,
|
||||
early_minutes: metrics.earlyMinutes,
|
||||
note: nextNote,
|
||||
source: detectAttendanceSource(request),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
})
|
||||
: await fastify.prisma.attendanceRecord.create({
|
||||
data: {
|
||||
employee_id: request.employee.id,
|
||||
work_date: new Date(`${workDate}T00:00:00.000Z`),
|
||||
clock_in_at: clockInAt,
|
||||
clock_out_at: null,
|
||||
work_minutes: null,
|
||||
is_late: metrics.isLate,
|
||||
late_minutes: metrics.lateMinutes,
|
||||
is_early_leave: false,
|
||||
early_minutes: 0,
|
||||
note: body.note || null,
|
||||
source: detectAttendanceSource(request),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, { item: mapAttendanceRecord(saved) });
|
||||
});
|
||||
|
||||
fastify.post("/attendance/clock-out", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const body = parseInput(CLOCK_BODY_SCHEMA, request.body);
|
||||
const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id);
|
||||
|
||||
if (!record?.clock_in_at) {
|
||||
if (exemption) {
|
||||
throw buildAttendanceValidationError(
|
||||
{ attendance: "exempt_day", exemptionReason: exemption.exemptionReason },
|
||||
409,
|
||||
);
|
||||
}
|
||||
throw buildAttendanceValidationError({ attendance: "missing_clock_in" });
|
||||
}
|
||||
|
||||
const clockOutAt = new Date();
|
||||
const policy = await getAttendancePolicy(fastify.prisma);
|
||||
const nextNote = body.note || record.note || null;
|
||||
const metrics = buildAttendanceMetrics({
|
||||
clockInAt: record.clock_in_at,
|
||||
clockOutAt,
|
||||
workDate,
|
||||
timeZone: fastify.config.appTimezone,
|
||||
policy,
|
||||
});
|
||||
|
||||
const saved = await fastify.prisma.attendanceRecord.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
clock_out_at: clockOutAt,
|
||||
work_minutes: metrics.workMinutes,
|
||||
is_early_leave: metrics.isEarlyLeave,
|
||||
early_minutes: metrics.earlyMinutes,
|
||||
is_late: metrics.isLate,
|
||||
late_minutes: metrics.lateMinutes,
|
||||
note: nextNote,
|
||||
source: detectAttendanceSource(request),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, { item: mapAttendanceRecord(saved) });
|
||||
});
|
||||
|
||||
fastify.get("/attendance/me/today", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id);
|
||||
return sendData(reply, request, {
|
||||
workDate,
|
||||
needClockIn: !exemption,
|
||||
exemptionReason: exemption?.exemptionReason ?? null,
|
||||
item: mapAttendanceRecord(record),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get("/attendance/me/month", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const query = parseInput(MONTH_QUERY_SCHEMA, request.query);
|
||||
const now = new Date();
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: fastify.config.appTimezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
}).formatToParts(now);
|
||||
const partMap = {};
|
||||
for (const part of parts) {
|
||||
if (part.type !== "literal") {
|
||||
partMap[part.type] = part.value;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await buildMyMonthAttendance(fastify.prisma, {
|
||||
employeeId: request.employee.id,
|
||||
timeZone: fastify.config.appTimezone,
|
||||
year: query.year ?? Number(partMap.year),
|
||||
month: query.month ?? Number(partMap.month),
|
||||
});
|
||||
return sendData(reply, request, payload);
|
||||
});
|
||||
|
||||
fastify.get(
|
||||
"/attendance/team",
|
||||
{ preHandler: fastify.requirePermission("system.users") },
|
||||
async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const query = parseInput(TEAM_QUERY_SCHEMA, request.query);
|
||||
const dateString = query.date ?? currentDateString(fastify.config.appTimezone);
|
||||
const items = await buildTeamAttendance(fastify.prisma, {
|
||||
dateString,
|
||||
departmentId: query.departmentId,
|
||||
timeZone: fastify.config.appTimezone,
|
||||
});
|
||||
return sendData(reply, request, {
|
||||
date: dateString,
|
||||
items,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get("/attendance/policy", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const item = await getAttendancePolicy(fastify.prisma);
|
||||
return sendData(reply, request, { item });
|
||||
});
|
||||
|
||||
fastify.put(
|
||||
"/attendance/policy",
|
||||
{ preHandler: fastify.requirePermission("system.settings") },
|
||||
async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const body = parseInput(POLICY_BODY_SCHEMA, request.body);
|
||||
const item = await upsertAttendancePolicy(fastify.prisma, request.employee.id, body);
|
||||
return sendData(reply, request, { item });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/attendance/admin/backfill",
|
||||
{ preHandler: fastify.requirePermission("system.users") },
|
||||
async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const body = parseInput(ADMIN_BACKFILL_BODY_SCHEMA, request.body);
|
||||
const employee = await loadEmployeeForAttendance(fastify.prisma, body.employeeId);
|
||||
if (!employee || employee.status !== "active") {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
const before = await loadAttendanceRecord(fastify.prisma, body.employeeId, body.workDate);
|
||||
const nextClockInAt = body.clockInAt !== undefined ? parseOptionalDateTime(body.clockInAt) : before?.clock_in_at ?? null;
|
||||
const nextClockOutAt = body.clockOutAt !== undefined ? parseOptionalDateTime(body.clockOutAt) : before?.clock_out_at ?? null;
|
||||
|
||||
if (!nextClockInAt && !nextClockOutAt) {
|
||||
throw buildAttendanceValidationError({ attendance: "missing_backfill_values" });
|
||||
}
|
||||
|
||||
if (nextClockInAt && nextClockOutAt && nextClockOutAt < nextClockInAt) {
|
||||
throw buildAttendanceValidationError({ clockOutAt: "must_be_after_or_equal_clockInAt" });
|
||||
}
|
||||
|
||||
const policy = await getAttendancePolicy(fastify.prisma);
|
||||
const nextNote = body.note || before?.note || null;
|
||||
const metrics = buildAttendanceMetrics({
|
||||
clockInAt: nextClockInAt,
|
||||
clockOutAt: nextClockOutAt,
|
||||
workDate: body.workDate,
|
||||
timeZone: fastify.config.appTimezone,
|
||||
policy,
|
||||
});
|
||||
|
||||
const saved = before
|
||||
? await fastify.prisma.attendanceRecord.update({
|
||||
where: { id: before.id },
|
||||
data: {
|
||||
clock_in_at: nextClockInAt,
|
||||
clock_out_at: nextClockOutAt,
|
||||
work_minutes: metrics.workMinutes,
|
||||
is_late: metrics.isLate,
|
||||
late_minutes: metrics.lateMinutes,
|
||||
is_early_leave: metrics.isEarlyLeave,
|
||||
early_minutes: metrics.earlyMinutes,
|
||||
note: nextNote,
|
||||
source: "admin",
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
})
|
||||
: await fastify.prisma.attendanceRecord.create({
|
||||
data: {
|
||||
employee_id: body.employeeId,
|
||||
work_date: new Date(`${body.workDate}T00:00:00.000Z`),
|
||||
clock_in_at: nextClockInAt,
|
||||
clock_out_at: nextClockOutAt,
|
||||
work_minutes: metrics.workMinutes,
|
||||
is_late: metrics.isLate,
|
||||
late_minutes: metrics.lateMinutes,
|
||||
is_early_leave: metrics.isEarlyLeave,
|
||||
early_minutes: metrics.earlyMinutes,
|
||||
note: nextNote,
|
||||
source: "admin",
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "ATTENDANCE_ADMIN_BACKFILLED",
|
||||
targetType: "attendance_record",
|
||||
targetId: saved.id,
|
||||
extra: {
|
||||
employeeId: body.employeeId,
|
||||
workDate: body.workDate,
|
||||
note: nextNote,
|
||||
before: mapAttendanceRecord(before),
|
||||
after: mapAttendanceRecord(saved),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, { item: mapAttendanceRecord(saved) });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
attendanceRoutes.__testables = {
|
||||
ensureFeatureEnabled,
|
||||
parseOptionalDateTime,
|
||||
resolveTodayState,
|
||||
};
|
||||
|
||||
module.exports = attendanceRoutes;
|
||||
@@ -231,6 +231,20 @@ const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({
|
||||
routes: ["/api/presence/team", "/api/portal/home"],
|
||||
},
|
||||
],
|
||||
attendance_v1: [
|
||||
{
|
||||
label: "极简考勤",
|
||||
routes: [
|
||||
"/api/attendance/clock-in",
|
||||
"/api/attendance/clock-out",
|
||||
"/api/attendance/me/today",
|
||||
"/api/attendance/me/month",
|
||||
"/api/attendance/team",
|
||||
"/api/attendance/policy",
|
||||
"/api/attendance/admin/backfill",
|
||||
],
|
||||
},
|
||||
],
|
||||
weekly_report_v1: [
|
||||
{
|
||||
label: "周报工作台",
|
||||
|
||||
在新工单中引用
屏蔽一个用户