R59 A1-A6:极简考勤打卡

- 新增 AttendanceRecord 与 AttendancePolicy 模型
- 上下班打卡、今日状态、月度统计端点
- 考勤管理页面(我的/团队/设置三 Tab)
- 移动端快速打卡入口
- 管理员补卡与审计日志
- 单测覆盖打卡幂等、迟到计算、工时计算、权限边界
这个提交包含在:
2026-04-11 21:57:27 +08:00
父节点 ba48e7d1d4
当前提交 677aa6cac3
共修改 25 个文件,包含 3081 行新增和 2 行删除
+2
查看文件
@@ -56,6 +56,7 @@ const migrationRunsRoutes = require("./routes/migrationRuns");
const cutoverRoutes = require("./routes/cutover");
const systemWorkspaceRoutes = require("./routes/systemWorkspace");
const presenceRoutes = require("./routes/presence");
const attendanceRoutes = require("./routes/attendance");
const weeklyReportsRoutes = require("./routes/weeklyReports");
const todosRoutes = require("./routes/todos");
const projectsRoutes = require("./routes/projects");
@@ -345,6 +346,7 @@ async function buildApp() {
await app.register(cutoverRoutes, { prefix: "/api" });
await app.register(systemWorkspaceRoutes, { prefix: "/api" });
await app.register(presenceRoutes, { prefix: "/api" });
await app.register(attendanceRoutes, { prefix: "/api" });
await app.register(weeklyReportsRoutes, { prefix: "/api" });
await app.register(todosRoutes, { prefix: "/api" });
await app.register(projectsRoutes, { prefix: "/api" });
+1
查看文件
@@ -120,6 +120,7 @@ function getConfig() {
workflowDefinitionAdminEnabled: process.env.WORKFLOW_DEFINITION_ADMIN_ENABLED === "true",
cutoverConsoleEnabled: process.env.CUTOVER_CONSOLE_ENABLED === "true",
presenceBoardEnabled: process.env.PRESENCE_BOARD_ENABLED === "true",
attendanceEnabled: process.env.ATTENDANCE_ENABLED === "true",
weeklyReportEnabled: process.env.WEEKLY_REPORT_ENABLED === "true",
employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true",
todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true",
+430
查看文件
@@ -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;
+14
查看文件
@@ -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: "周报工作台",
+49
查看文件
@@ -289,6 +289,13 @@ const DEFAULT_FEATURE_FLAGS = [
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "attendance_v1",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "weekly_report_v1",
override_mode: "force_off",
@@ -552,6 +559,45 @@ async function seedFeatureFlags(prisma) {
}
}
async function seedAttendancePolicy(prisma) {
const now = new Date();
const current = await prisma.attendancePolicy.findFirst({
orderBy: { created_at: "asc" },
select: { id: true },
});
if (current) {
await prisma.attendancePolicy.update({
where: { id: current.id },
data: {
work_start_time: "09:00",
work_end_time: "18:00",
lunch_start_time: "12:00",
lunch_end_time: "13:00",
late_tolerance_minutes: 0,
enabled: true,
updated_at: now,
updated_by: null,
},
});
return;
}
await prisma.attendancePolicy.create({
data: {
work_start_time: "09:00",
work_end_time: "18:00",
lunch_start_time: "12:00",
lunch_end_time: "13:00",
late_tolerance_minutes: 0,
enabled: true,
created_at: now,
updated_at: now,
updated_by: null,
},
});
}
async function seedBudgetControlRules(prisma) {
const now = new Date();
@@ -794,6 +840,7 @@ async function main() {
await seedWorkflowDefinition(prisma);
await seedLeaveTypes(prisma);
await seedFeatureFlags(prisma);
await seedAttendancePolicy(prisma);
await seedBudgetControlRules(prisma);
// eslint-disable-next-line no-console
@@ -818,6 +865,8 @@ async function main() {
console.log(`- leave types: ${DEFAULT_LEAVE_TYPES.length} seeded with default policies`);
// eslint-disable-next-line no-console
console.log(`- feature flags: ${DEFAULT_FEATURE_FLAGS.length} seeded`);
// eslint-disable-next-line no-console
console.log("- attendance policy: default 09:00-18:00 seeded");
} finally {
await prisma.$disconnect();
await pool.end();
+1
查看文件
@@ -458,6 +458,7 @@ async function main() {
"announcements_v1",
"global_search_v1",
"presence_board_v1",
"attendance_v1",
"weekly_report_v1",
"employee_lifecycle_v1",
"todo_board_v1",
+1
查看文件
@@ -260,6 +260,7 @@ async function main() {
"announcements_v1",
"global_search_v1",
"presence_board_v1",
"attendance_v1",
"weekly_report_v1",
"employee_lifecycle_v1",
"todo_board_v1",
+846
查看文件
@@ -0,0 +1,846 @@
const { AppError } = require("../utils/AppError");
const { currentDateString, parseDateOnlyString } = require("../utils/date");
const ATTENDANCE_STATUS_VALUES = [
"normal",
"late",
"early_leave",
"late_early_leave",
"pending_clock_out",
"missed",
"leave",
"travel",
"holiday",
"weekend",
];
const EXEMPTION_REASON_VALUES = ["leave", "travel", "holiday", "weekend", "policy_disabled"];
const ATTENDANCE_SOURCE_VALUES = ["web", "mobile", "admin"];
const TIME_VALUE_PATTERN = /^\d{2}:\d{2}$/;
const DEFAULT_ATTENDANCE_POLICY = Object.freeze({
workStartTime: "09:00",
workEndTime: "18:00",
lunchStartTime: "12:00",
lunchEndTime: "13:00",
lateToleranceMinutes: 0,
enabled: true,
});
function buildAttendanceValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function formatDateOnly(value) {
if (!value) {
return null;
}
return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`).toISOString().slice(0, 10);
}
function addUtcDays(date, days) {
const next = new Date(date.getTime());
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function buildDateStrings(dateFrom, dateTo) {
const items = [];
for (let current = new Date(dateFrom.getTime()); current <= dateTo; current = addUtcDays(current, 1)) {
items.push(formatDateOnly(current));
}
return items;
}
function createZonedFormatter(timeZone) {
return new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
}
function getZonedParts(date, timeZone) {
const formatter = createZonedFormatter(timeZone ?? "Asia/Shanghai");
const parts = formatter.formatToParts(date);
const values = {};
for (const part of parts) {
if (part.type !== "literal") {
values[part.type] = part.value;
}
}
return {
year: Number(values.year),
month: Number(values.month),
day: Number(values.day),
hour: Number(values.hour),
minute: Number(values.minute),
second: Number(values.second),
dateString: `${values.year}-${values.month}-${values.day}`,
timeString: `${values.hour}:${values.minute}`,
};
}
function getZonedMinutes(date, timeZone) {
const parts = getZonedParts(date, timeZone);
return parts.hour * 60 + parts.minute;
}
function parseTimeToMinutes(value, fieldName = "time") {
const input = String(value ?? "").trim();
if (!TIME_VALUE_PATTERN.test(input)) {
throw buildAttendanceValidationError({ [fieldName]: "invalid_time" });
}
const [hourValue, minuteValue] = input.split(":").map(Number);
if (
!Number.isInteger(hourValue) ||
!Number.isInteger(minuteValue) ||
hourValue < 0 ||
hourValue > 23 ||
minuteValue < 0 ||
minuteValue > 59
) {
throw buildAttendanceValidationError({ [fieldName]: "invalid_time" });
}
return hourValue * 60 + minuteValue;
}
function getTimeZoneOffsetMs(date, timeZone) {
const parts = getZonedParts(date, timeZone);
const zonedAsUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
return zonedAsUtc - date.getTime();
}
function zonedDateTimeToUtc(dateString, timeString, timeZone) {
const [year, month, day] = String(dateString).split("-").map(Number);
const [hour, minute] = String(timeString).split(":").map(Number);
let date = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0));
for (let index = 0; index < 2; index += 1) {
const offset = getTimeZoneOffsetMs(date, timeZone);
date = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0) - offset);
}
return date;
}
function isWeekendDate(dateValue) {
const date = parseDateOnlyString(dateValue);
const weekday = date.getUTCDay();
return weekday === 0 || weekday === 6;
}
function buildHolidayCalendarDayMap(calendarDays = []) {
const map = new Map();
for (const item of calendarDays) {
const key = formatDateOnly(item.date);
if (key) {
map.set(key, item.day_type ?? item.dayType ?? null);
}
}
return map;
}
function isWorkingDateByCalendar(dateString, calendarDayMap) {
const configuredType = calendarDayMap.get(dateString) ?? null;
if (configuredType === "holiday") {
return false;
}
if (configuredType === "workday" || configuredType === "adjusted_workday") {
return true;
}
return !isWeekendDate(dateString);
}
function detectMobileUserAgent(userAgent) {
const source = String(userAgent ?? "");
return /Android|iPhone|iPad|Mobile|HarmonyOS|MiuiBrowser/i.test(source);
}
function detectAttendanceSource(request) {
if (detectMobileUserAgent(request.headers?.["user-agent"])) {
return "mobile";
}
return "web";
}
function normalizeAttendancePolicy(record) {
if (!record) {
return {
id: null,
...DEFAULT_ATTENDANCE_POLICY,
createdAt: null,
updatedAt: null,
updatedBy: null,
};
}
return {
id: record.id,
workStartTime: record.work_start_time,
workEndTime: record.work_end_time,
lunchStartTime: record.lunch_start_time,
lunchEndTime: record.lunch_end_time,
lateToleranceMinutes: Number(record.late_tolerance_minutes ?? 0),
enabled: Boolean(record.enabled),
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
updatedBy: record.updated_by ?? null,
};
}
async function getAttendancePolicy(prisma) {
const record = await prisma.attendancePolicy.findFirst({
orderBy: { created_at: "asc" },
});
return normalizeAttendancePolicy(record);
}
function validateAttendancePolicyInput(input) {
const workStartMinutes = parseTimeToMinutes(input.workStartTime, "workStartTime");
const workEndMinutes = parseTimeToMinutes(input.workEndTime, "workEndTime");
const lunchStartMinutes = parseTimeToMinutes(input.lunchStartTime, "lunchStartTime");
const lunchEndMinutes = parseTimeToMinutes(input.lunchEndTime, "lunchEndTime");
const lateToleranceMinutes = Number(input.lateToleranceMinutes ?? 0);
if (!Number.isInteger(lateToleranceMinutes) || lateToleranceMinutes < 0 || lateToleranceMinutes > 600) {
throw buildAttendanceValidationError({ lateToleranceMinutes: "invalid" });
}
if (workStartMinutes >= workEndMinutes) {
throw buildAttendanceValidationError({ workTime: "invalid_range" });
}
if (lunchStartMinutes >= lunchEndMinutes) {
throw buildAttendanceValidationError({ lunchTime: "invalid_range" });
}
if (lunchStartMinutes < workStartMinutes || lunchEndMinutes > workEndMinutes) {
throw buildAttendanceValidationError({ lunchTime: "outside_work_range" });
}
return {
workStartTime: input.workStartTime,
workEndTime: input.workEndTime,
lunchStartTime: input.lunchStartTime,
lunchEndTime: input.lunchEndTime,
lateToleranceMinutes,
enabled: input.enabled !== undefined ? Boolean(input.enabled) : true,
};
}
async function upsertAttendancePolicy(prisma, actorId, input) {
const policy = validateAttendancePolicyInput(input);
const current = await prisma.attendancePolicy.findFirst({
orderBy: { created_at: "asc" },
select: { id: true },
});
const data = {
work_start_time: policy.workStartTime,
work_end_time: policy.workEndTime,
lunch_start_time: policy.lunchStartTime,
lunch_end_time: policy.lunchEndTime,
late_tolerance_minutes: policy.lateToleranceMinutes,
enabled: policy.enabled,
updated_by: actorId ?? null,
};
const record = current
? await prisma.attendancePolicy.update({
where: { id: current.id },
data,
})
: await prisma.attendancePolicy.create({
data,
});
return normalizeAttendancePolicy(record);
}
function mapAttendanceRecord(record) {
if (!record) {
return null;
}
return {
id: record.id,
employeeId: record.employee_id,
workDate: formatDateOnly(record.work_date),
clockInAt: record.clock_in_at?.toISOString?.() ?? null,
clockOutAt: record.clock_out_at?.toISOString?.() ?? null,
workMinutes: record.work_minutes ?? null,
isLate: Boolean(record.is_late),
lateMinutes: Number(record.late_minutes ?? 0),
isEarlyLeave: Boolean(record.is_early_leave),
earlyMinutes: Number(record.early_minutes ?? 0),
note: record.note ?? null,
source: record.source ?? "web",
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
function buildEmptyAttendanceRecord(employeeId, workDate) {
return {
id: null,
employeeId,
workDate,
clockInAt: null,
clockOutAt: null,
workMinutes: null,
isLate: false,
lateMinutes: 0,
isEarlyLeave: false,
earlyMinutes: 0,
note: null,
source: null,
updatedAt: null,
};
}
async function loadAttendanceRecord(prisma, employeeId, workDate) {
return prisma.attendanceRecord.findFirst({
where: {
employee_id: employeeId,
work_date: parseDateOnlyString(workDate),
},
});
}
function buildHistoryKey(employeeId, dateString) {
return `${employeeId}:${dateString}`;
}
function buildExemptionMapForEmployees({
employees,
dateStrings,
leaveRows,
travelRows,
holidayCalendarByLegalEntity,
policyEnabled = true,
}) {
const result = new Map();
for (const employee of employees) {
const map = new Map();
result.set(employee.id, map);
if (!policyEnabled) {
for (const dateString of dateStrings) {
map.set(dateString, {
needClockIn: false,
exemptionReason: "policy_disabled",
note: "attendance_policy_disabled",
});
}
continue;
}
const calendarDayMap = holidayCalendarByLegalEntity.get(employee.legal_entity_id ?? "") ?? new Map();
for (const dateString of dateStrings) {
if (isWorkingDateByCalendar(dateString, calendarDayMap)) {
continue;
}
const configuredType = calendarDayMap.get(dateString) ?? null;
map.set(dateString, {
needClockIn: false,
exemptionReason: configuredType === "holiday" ? "holiday" : "weekend",
note: null,
});
}
}
for (const row of travelRows) {
const startDate = formatDateOnly(row.start_date);
const endDate = formatDateOnly(row.end_date);
const employeeMap = result.get(row.applicant_employee_id);
if (!employeeMap) {
continue;
}
for (const dateString of dateStrings) {
if (dateString < startDate || dateString > endDate) {
continue;
}
employeeMap.set(dateString, {
needClockIn: false,
exemptionReason: "travel",
note: row.purpose ?? null,
});
}
}
for (const row of leaveRows) {
const startDate = formatDateOnly(row.start_date);
const endDate = formatDateOnly(row.end_date);
const employeeMap = result.get(row.owner_id);
if (!employeeMap) {
continue;
}
for (const dateString of dateStrings) {
if (dateString < startDate || dateString > endDate) {
continue;
}
employeeMap.set(dateString, {
needClockIn: false,
exemptionReason: "leave",
note: row.reason ?? null,
});
}
}
return result;
}
async function loadAttendanceExemptions(prisma, { employees, dateStrings, policyEnabled = true }) {
if (!employees.length || !dateStrings.length) {
return new Map();
}
const dateFrom = dateStrings[0];
const dateTo = dateStrings[dateStrings.length - 1];
const employeeIds = employees.map((item) => item.id);
const legalEntityIds = Array.from(
new Set(
employees
.map((item) => item.legal_entity_id ?? null)
.filter(Boolean),
),
);
const [leaveRows, travelRows, legalEntities] = await Promise.all([
prisma.leaveRequest.findMany({
where: {
owner_id: { in: employeeIds },
status: "approved",
start_date: { lte: parseDateOnlyString(dateTo) },
end_date: { gte: parseDateOnlyString(dateFrom) },
},
select: {
owner_id: true,
start_date: true,
end_date: true,
reason: true,
},
}),
prisma.travelRequest.findMany({
where: {
applicant_employee_id: { in: employeeIds },
status: "approved",
start_date: { lte: parseDateOnlyString(dateTo) },
end_date: { gte: parseDateOnlyString(dateFrom) },
},
select: {
applicant_employee_id: true,
start_date: true,
end_date: true,
purpose: true,
},
}),
legalEntityIds.length
? prisma.legalEntity.findMany({
where: { id: { in: legalEntityIds } },
select: {
id: true,
default_holiday_calendar: {
select: {
id: true,
archived_at: true,
days: {
where: {
date: {
gte: parseDateOnlyString(dateFrom),
lte: parseDateOnlyString(dateTo),
},
},
select: {
date: true,
day_type: true,
},
},
},
},
},
})
: Promise.resolve([]),
]);
const holidayCalendarByLegalEntity = new Map();
for (const item of legalEntities) {
const calendar = item.default_holiday_calendar;
holidayCalendarByLegalEntity.set(
item.id,
calendar && !calendar.archived_at ? buildHolidayCalendarDayMap(calendar.days) : new Map(),
);
}
return buildExemptionMapForEmployees({
employees,
dateStrings,
leaveRows,
travelRows,
holidayCalendarByLegalEntity,
policyEnabled,
});
}
function getExemptionForDate(exemptionMap, employeeId, dateString) {
return exemptionMap.get(employeeId)?.get(dateString) ?? null;
}
function resolveAttendanceStatus(record, exemption) {
if (exemption && !exemption.needClockIn) {
if (exemption.exemptionReason === "leave") return "leave";
if (exemption.exemptionReason === "travel") return "travel";
if (exemption.exemptionReason === "holiday") return "holiday";
return "weekend";
}
if (!record?.clock_in_at) {
return "missed";
}
if (!record.clock_out_at) {
return "pending_clock_out";
}
if (record.is_late && record.is_early_leave) {
return "late_early_leave";
}
if (record.is_late) {
return "late";
}
if (record.is_early_leave) {
return "early_leave";
}
return "normal";
}
function calculateOverlapMinutes(startAt, endAt, rangeStartAt, rangeEndAt) {
if (!startAt || !endAt || !rangeStartAt || !rangeEndAt) {
return 0;
}
const start = Math.max(startAt.getTime(), rangeStartAt.getTime());
const end = Math.min(endAt.getTime(), rangeEndAt.getTime());
if (end <= start) {
return 0;
}
return Math.floor((end - start) / 60000);
}
function calculateWorkMinutes({ clockInAt, clockOutAt, workDate, timeZone, policy }) {
if (!clockInAt || !clockOutAt || clockOutAt <= clockInAt) {
return 0;
}
const totalMinutes = Math.floor((clockOutAt.getTime() - clockInAt.getTime()) / 60000);
const lunchStartAt = zonedDateTimeToUtc(workDate, policy.lunchStartTime, timeZone);
const lunchEndAt = zonedDateTimeToUtc(workDate, policy.lunchEndTime, timeZone);
const lunchOverlap = calculateOverlapMinutes(clockInAt, clockOutAt, lunchStartAt, lunchEndAt);
return Math.max(0, totalMinutes - lunchOverlap);
}
function calculateLateInfo({ clockInAt, workDate, timeZone, policy }) {
if (!clockInAt) {
return { isLate: false, lateMinutes: 0 };
}
const startAt = zonedDateTimeToUtc(workDate, policy.workStartTime, timeZone);
const toleratedStartAt = new Date(startAt.getTime() + policy.lateToleranceMinutes * 60000);
if (clockInAt <= toleratedStartAt) {
return { isLate: false, lateMinutes: 0 };
}
return {
isLate: true,
lateMinutes: Math.floor((clockInAt.getTime() - toleratedStartAt.getTime()) / 60000),
};
}
function calculateEarlyLeaveInfo({ clockOutAt, workDate, timeZone, policy }) {
if (!clockOutAt) {
return { isEarlyLeave: false, earlyMinutes: 0 };
}
const endAt = zonedDateTimeToUtc(workDate, policy.workEndTime, timeZone);
if (clockOutAt >= endAt) {
return { isEarlyLeave: false, earlyMinutes: 0 };
}
return {
isEarlyLeave: true,
earlyMinutes: Math.floor((endAt.getTime() - clockOutAt.getTime()) / 60000),
};
}
function buildAttendanceMetrics({ clockInAt, clockOutAt, workDate, timeZone, policy }) {
const late = calculateLateInfo({ clockInAt, workDate, timeZone, policy });
const earlyLeave = calculateEarlyLeaveInfo({ clockOutAt, workDate, timeZone, policy });
return {
workMinutes: clockInAt && clockOutAt
? calculateWorkMinutes({ clockInAt, clockOutAt, workDate, timeZone, policy })
: null,
isLate: late.isLate,
lateMinutes: late.lateMinutes,
isEarlyLeave: earlyLeave.isEarlyLeave,
earlyMinutes: earlyLeave.earlyMinutes,
};
}
function summarizeMonthAttendance(items) {
return items.reduce(
(summary, item) => {
if (item.needClockIn) {
summary.totalWorkDays += 1;
}
if (item.clockInAt) {
summary.clockedDays += 1;
}
if (item.attendanceStatus === "late" || item.attendanceStatus === "late_early_leave") {
summary.lateDays += 1;
}
if (item.attendanceStatus === "early_leave" || item.attendanceStatus === "late_early_leave") {
summary.earlyLeaveDays += 1;
}
if (item.attendanceStatus === "missed") {
summary.missedDays += 1;
}
summary.totalWorkMinutes += Number(item.workMinutes ?? 0);
return summary;
},
{
totalWorkDays: 0,
clockedDays: 0,
lateDays: 0,
earlyLeaveDays: 0,
missedDays: 0,
totalWorkMinutes: 0,
},
);
}
function mapAttendanceEmployee(row) {
return {
id: row.id,
employeeNo: row.employee_no,
name: row.name,
title: row.title,
departmentId: row.department_id ?? null,
departmentName: row.department?.name ?? null,
legalEntityId: row.legal_entity_id ?? null,
};
}
function compareAttendanceEmployees(left, right) {
const departmentCompare = (left.departmentName ?? "").localeCompare(right.departmentName ?? "", "zh-CN");
if (departmentCompare !== 0) {
return departmentCompare;
}
return left.employeeNo.localeCompare(right.employeeNo, "en");
}
async function listActiveAttendanceEmployees(prisma, filters = {}) {
const rows = await prisma.employee.findMany({
where: {
status: "active",
...(filters.departmentId ? { department_id: filters.departmentId } : {}),
...(filters.employeeId ? { id: filters.employeeId } : {}),
},
select: {
id: true,
employee_no: true,
name: true,
title: true,
department_id: true,
legal_entity_id: true,
department: { select: { id: true, name: true } },
},
});
return rows.map(mapAttendanceEmployee).sort(compareAttendanceEmployees);
}
function buildMonthRange(year, month) {
const normalizedYear = Number(year);
const normalizedMonth = Number(month);
if (!Number.isInteger(normalizedYear) || normalizedYear < 2000 || normalizedYear > 2100) {
throw buildAttendanceValidationError({ year: "invalid" });
}
if (!Number.isInteger(normalizedMonth) || normalizedMonth < 1 || normalizedMonth > 12) {
throw buildAttendanceValidationError({ month: "invalid" });
}
const dateFrom = parseDateOnlyString(`${normalizedYear}-${String(normalizedMonth).padStart(2, "0")}-01`);
const dateTo = new Date(Date.UTC(normalizedYear, normalizedMonth, 0));
return {
year: normalizedYear,
month: normalizedMonth,
dateFrom,
dateTo,
dateStrings: buildDateStrings(dateFrom, dateTo),
};
}
function resolveMonthInput(query, timeZone) {
const today = getZonedParts(new Date(), timeZone ?? "Asia/Shanghai");
return buildMonthRange(query.year ?? today.year, query.month ?? today.month);
}
async function buildMyMonthAttendance(prisma, { employeeId, timeZone, year, month }) {
const employeeRows = await listActiveAttendanceEmployees(prisma, { employeeId });
const employee = employeeRows[0];
if (!employee) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const policy = await getAttendancePolicy(prisma);
const range = buildMonthRange(year, month);
const [records, exemptions] = await Promise.all([
prisma.attendanceRecord.findMany({
where: {
employee_id: employeeId,
work_date: {
gte: range.dateFrom,
lte: range.dateTo,
},
},
orderBy: { work_date: "asc" },
}),
loadAttendanceExemptions(prisma, {
employees: [{ id: employee.id, legal_entity_id: employee.legalEntityId }],
dateStrings: range.dateStrings,
policyEnabled: policy.enabled,
}),
]);
const recordMap = new Map(records.map((item) => [formatDateOnly(item.work_date), item]));
const items = range.dateStrings.map((dateString) => {
const record = recordMap.get(dateString) ?? null;
const exemption = getExemptionForDate(exemptions, employeeId, dateString);
const mapped = mapAttendanceRecord(record) ?? buildEmptyAttendanceRecord(employeeId, dateString);
return {
workDate: dateString,
attendanceStatus: resolveAttendanceStatus(record, exemption),
needClockIn: exemption ? false : policy.enabled,
exemptionReason: exemption?.exemptionReason ?? null,
...mapped,
};
});
return {
year: range.year,
month: range.month,
summary: summarizeMonthAttendance(items),
items,
};
}
async function buildTeamAttendance(prisma, { dateString, departmentId, timeZone }) {
const employees = await listActiveAttendanceEmployees(prisma, { departmentId });
const policy = await getAttendancePolicy(prisma);
const [records, exemptions] = await Promise.all([
employees.length
? prisma.attendanceRecord.findMany({
where: {
employee_id: { in: employees.map((item) => item.id) },
work_date: parseDateOnlyString(dateString),
},
})
: Promise.resolve([]),
loadAttendanceExemptions(prisma, {
employees: employees.map((item) => ({
id: item.id,
legal_entity_id: item.legalEntityId,
})),
dateStrings: [dateString],
policyEnabled: policy.enabled,
}),
]);
const recordMap = new Map(records.map((item) => [buildHistoryKey(item.employee_id, formatDateOnly(item.work_date)), item]));
return employees.map((employee) => {
const record = recordMap.get(buildHistoryKey(employee.id, dateString)) ?? null;
const mapped = mapAttendanceRecord(record);
const exemption = getExemptionForDate(exemptions, employee.id, dateString);
return {
employeeId: employee.id,
employeeName: employee.name,
employeeNo: employee.employeeNo,
title: employee.title,
departmentId: employee.departmentId,
departmentName: employee.departmentName,
attendanceStatus: resolveAttendanceStatus(record, exemption),
needClockIn: exemption ? false : policy.enabled,
exemptionReason: exemption?.exemptionReason ?? null,
clockInAt: mapped?.clockInAt ?? null,
clockOutAt: mapped?.clockOutAt ?? null,
workMinutes: mapped?.workMinutes ?? null,
note: mapped?.note ?? exemption?.note ?? null,
source: mapped?.source ?? null,
};
});
}
module.exports = {
ATTENDANCE_STATUS_VALUES,
ATTENDANCE_SOURCE_VALUES,
DEFAULT_ATTENDANCE_POLICY,
EXEMPTION_REASON_VALUES,
addUtcDays,
buildAttendanceMetrics,
buildAttendanceValidationError,
buildDateStrings,
buildMonthRange,
buildMyMonthAttendance,
buildTeamAttendance,
calculateEarlyLeaveInfo,
calculateLateInfo,
calculateOverlapMinutes,
calculateWorkMinutes,
currentDateString,
detectAttendanceSource,
formatDateOnly,
getAttendancePolicy,
getExemptionForDate,
getZonedMinutes,
getZonedParts,
isWorkingDateByCalendar,
listActiveAttendanceEmployees,
loadAttendanceExemptions,
loadAttendanceRecord,
mapAttendanceRecord,
buildEmptyAttendanceRecord,
normalizeAttendancePolicy,
parseTimeToMinutes,
resolveAttendanceStatus,
resolveMonthInput,
upsertAttendancePolicy,
validateAttendancePolicyInput,
zonedDateTimeToUtc,
};
@@ -11,6 +11,7 @@ const FEATURE_FLAG_KEYS = {
workflowDefinitionAdmin: "workflow_definition_admin_v1",
cutoverConsole: "cutover_console_v1",
presenceBoard: "presence_board_v1",
attendance: "attendance_v1",
weeklyReport: "weekly_report_v1",
employeeLifecycle: "employee_lifecycle_v1",
todoBoard: "todo_board_v1",
@@ -122,6 +123,10 @@ function resolveEnvGate(config, flagKey) {
return Boolean(config.presenceBoardEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.attendance) {
return Boolean(config.attendanceEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.weeklyReport) {
return Boolean(config.weeklyReportEnabled);
}
+2
查看文件
@@ -10,6 +10,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
portal: { key: "portal", label: "Portal", icon: "home_app_logo", href: "/portal", anyOf: [] },
team: { key: "team", label: "团队", icon: "groups", href: "/team", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.presenceBoard },
leaveRequests: { key: "leaveRequests", label: "请假", icon: "event_available", href: "/leave-requests", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
attendance: { key: "attendance", label: "考勤", icon: "schedule", href: "/attendance", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.attendance },
reimbursements: { key: "reimbursements", label: "报销", icon: "request_quote", href: "/reimbursements", anyOf: ["expense.view_own", "expense.view_all"] },
assets: { key: "assets", label: "资产", icon: "inventory_2", href: "/assets", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] },
weeklyReports: { key: "weeklyReports", label: "周报", icon: "article", href: "/weekly-reports", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.weeklyReport },
@@ -49,6 +50,7 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
portal: "main",
team: "main",
leaveRequests: "main",
attendance: "main",
reimbursements: "main",
assets: "main",
weeklyReports: "main",