R59 A1-A6:极简考勤打卡
- 新增 AttendanceRecord 与 AttendancePolicy 模型 - 上下班打卡、今日状态、月度统计端点 - 考勤管理页面(我的/团队/设置三 Tab) - 移动端快速打卡入口 - 管理员补卡与审计日志 - 单测覆盖打卡幂等、迟到计算、工时计算、权限边界
这个提交包含在:
@@ -0,0 +1,45 @@
|
||||
CREATE TABLE "attendance_records" (
|
||||
"id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"work_date" DATE NOT NULL,
|
||||
"clock_in_at" TIMESTAMP(3),
|
||||
"clock_out_at" TIMESTAMP(3),
|
||||
"work_minutes" INTEGER,
|
||||
"is_late" BOOLEAN NOT NULL DEFAULT false,
|
||||
"late_minutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"is_early_leave" BOOLEAN NOT NULL DEFAULT false,
|
||||
"early_minutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"note" TEXT,
|
||||
"source" VARCHAR(16) NOT NULL DEFAULT 'web',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "attendance_records_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "attendance_policies" (
|
||||
"id" UUID NOT NULL,
|
||||
"work_start_time" VARCHAR(5) NOT NULL DEFAULT '09:00',
|
||||
"work_end_time" VARCHAR(5) NOT NULL DEFAULT '18:00',
|
||||
"lunch_start_time" VARCHAR(5) NOT NULL DEFAULT '12:00',
|
||||
"lunch_end_time" VARCHAR(5) NOT NULL DEFAULT '13:00',
|
||||
"late_tolerance_minutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "attendance_policies_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "attendance_records_employee_date_ux"
|
||||
ON "attendance_records"("employee_id", "work_date");
|
||||
|
||||
CREATE INDEX "idx_attendance_records_work_date"
|
||||
ON "attendance_records"("work_date");
|
||||
|
||||
ALTER TABLE "attendance_records"
|
||||
ADD CONSTRAINT "attendance_records_employee_id_fkey"
|
||||
FOREIGN KEY ("employee_id") REFERENCES "employees"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -636,6 +636,7 @@ model Employee {
|
||||
lifecycle_events EmployeeLifecycleEvent[]
|
||||
checklist_tasks EmployeeChecklistTask[]
|
||||
presence_records EmployeePresence[]
|
||||
attendance_records AttendanceRecord[]
|
||||
weekly_reports WeeklyReport[]
|
||||
todo_items_assigned TodoItem[] @relation("TodoAssignee")
|
||||
todo_items_created TodoItem[] @relation("TodoCreator")
|
||||
@@ -1836,6 +1837,47 @@ model EmployeePresence {
|
||||
@@map("employee_presence")
|
||||
}
|
||||
|
||||
model AttendanceRecord {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
work_date DateTime @db.Date
|
||||
clock_in_at DateTime?
|
||||
clock_out_at DateTime?
|
||||
work_minutes Int?
|
||||
is_late Boolean @default(false)
|
||||
late_minutes Int @default(0)
|
||||
is_early_leave Boolean @default(false)
|
||||
early_minutes Int @default(0)
|
||||
note String?
|
||||
source String @default("web") @db.VarChar(16)
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([employee_id, work_date], map: "attendance_records_employee_date_ux")
|
||||
@@index([work_date], map: "idx_attendance_records_work_date")
|
||||
@@map("attendance_records")
|
||||
}
|
||||
|
||||
model AttendancePolicy {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
work_start_time String @default("09:00") @db.VarChar(5)
|
||||
work_end_time String @default("18:00") @db.VarChar(5)
|
||||
lunch_start_time String @default("12:00") @db.VarChar(5)
|
||||
lunch_end_time String @default("13:00") @db.VarChar(5)
|
||||
late_tolerance_minutes Int @default(0)
|
||||
enabled Boolean @default(true)
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
@@map("attendance_policies")
|
||||
}
|
||||
|
||||
model WeeklyReport {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "周报工作台",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const attendanceRoutes = require("../../../src/routes/attendance");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const OTHER_EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const LEGAL_ENTITY_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||
|
||||
function buildEmployeeRow(overrides = {}) {
|
||||
return {
|
||||
id: EMPLOYEE_ID,
|
||||
employee_no: "E001",
|
||||
name: "Alice",
|
||||
title: "员工",
|
||||
department_id: DEPARTMENT_ID,
|
||||
legal_entity_id: LEGAL_ENTITY_ID,
|
||||
status: "active",
|
||||
department: {
|
||||
id: DEPARTMENT_ID,
|
||||
name: "行政部",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttendanceRecord(overrides = {}) {
|
||||
return {
|
||||
id: "attendance-1",
|
||||
employee_id: EMPLOYEE_ID,
|
||||
work_date: new Date("2026-04-09T00:00:00.000Z"),
|
||||
clock_in_at: null,
|
||||
clock_out_at: null,
|
||||
work_minutes: null,
|
||||
is_late: false,
|
||||
late_minutes: 0,
|
||||
is_early_leave: false,
|
||||
early_minutes: 0,
|
||||
note: null,
|
||||
source: "web",
|
||||
created_at: new Date("2026-04-09T01:00:00.000Z"),
|
||||
updated_at: new Date("2026-04-09T01:00:00.000Z"),
|
||||
updated_by: ACTOR_ID,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPolicy(overrides = {}) {
|
||||
return {
|
||||
id: "policy-1",
|
||||
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: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updated_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLeaveRow(overrides = {}) {
|
||||
return {
|
||||
owner_id: EMPLOYEE_ID,
|
||||
start_date: new Date("2026-04-09T00:00:00.000Z"),
|
||||
end_date: new Date("2026-04-09T00:00:00.000Z"),
|
||||
reason: "请假",
|
||||
status: "approved",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTravelRow(overrides = {}) {
|
||||
return {
|
||||
applicant_employee_id: EMPLOYEE_ID,
|
||||
start_date: new Date("2026-04-09T00:00:00.000Z"),
|
||||
end_date: new Date("2026-04-09T00:00:00.000Z"),
|
||||
purpose: "出差",
|
||||
status: "approved",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLegalEntityRow(overrides = {}) {
|
||||
return {
|
||||
id: LEGAL_ENTITY_ID,
|
||||
default_holiday_calendar: {
|
||||
id: "calendar-1",
|
||||
archived_at: null,
|
||||
days: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sameDate(left, right) {
|
||||
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function projectEmployeeForFindUnique(employee, select) {
|
||||
if (!employee) {
|
||||
return null;
|
||||
}
|
||||
if (!select) {
|
||||
return employee;
|
||||
}
|
||||
|
||||
const projected = {};
|
||||
for (const [key, value] of Object.entries(select)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
projected[key] = employee[key];
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const actor = options.actor ?? {
|
||||
id: EMPLOYEE_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: [],
|
||||
};
|
||||
const employees = options.employees ?? [buildEmployeeRow()];
|
||||
const attendanceRecords = [...(options.attendanceRecords ?? [])];
|
||||
const attendancePolicies = [...(options.attendancePolicies ?? [buildPolicy()])];
|
||||
const leaveRows = options.leaveRows ?? [];
|
||||
const travelRows = options.travelRows ?? [];
|
||||
const legalEntities = options.legalEntities ?? [buildLegalEntityRow()];
|
||||
|
||||
const employeeFindUnique = vi.fn(async ({ where, select }) => {
|
||||
const employee = employees.find((item) => item.id === where.id);
|
||||
return projectEmployeeForFindUnique(employee, select);
|
||||
});
|
||||
|
||||
const employeeFindMany = vi.fn(async ({ where, select }) => {
|
||||
const rows = employees
|
||||
.filter((item) => !where?.status || item.status === where.status)
|
||||
.filter((item) => !where?.department_id || item.department_id === where.department_id)
|
||||
.filter((item) => !where?.id || item.id === where.id)
|
||||
.map((item) => {
|
||||
if (!select) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
id: item.id,
|
||||
employee_no: item.employee_no,
|
||||
name: item.name,
|
||||
title: item.title,
|
||||
department_id: item.department_id,
|
||||
legal_entity_id: item.legal_entity_id,
|
||||
department: item.department,
|
||||
};
|
||||
});
|
||||
return rows;
|
||||
});
|
||||
|
||||
const attendanceRecordFindFirst = vi.fn(async ({ where }) =>
|
||||
attendanceRecords.find((row) => {
|
||||
if (where?.employee_id && row.employee_id !== where.employee_id) return false;
|
||||
if (where?.work_date instanceof Date) {
|
||||
return sameDate(row.work_date, where.work_date);
|
||||
}
|
||||
return true;
|
||||
}) ?? null,
|
||||
);
|
||||
|
||||
const attendanceRecordFindMany = vi.fn(async ({ where }) =>
|
||||
attendanceRecords.filter((row) => {
|
||||
if (where?.employee_id?.in && !where.employee_id.in.includes(row.employee_id)) return false;
|
||||
if (where?.employee_id && typeof where.employee_id === "string" && row.employee_id !== where.employee_id) return false;
|
||||
if (where?.work_date instanceof Date) {
|
||||
return sameDate(row.work_date, where.work_date);
|
||||
}
|
||||
if (where?.work_date?.gte && row.work_date < where.work_date.gte) return false;
|
||||
if (where?.work_date?.lte && row.work_date > where.work_date.lte) return false;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
const attendanceRecordCreate = vi.fn(async ({ data }) => {
|
||||
const created = buildAttendanceRecord({
|
||||
id: `attendance-${attendanceRecords.length + 1}`,
|
||||
employee_id: data.employee_id,
|
||||
work_date: data.work_date,
|
||||
clock_in_at: data.clock_in_at ?? null,
|
||||
clock_out_at: data.clock_out_at ?? null,
|
||||
work_minutes: data.work_minutes ?? null,
|
||||
is_late: data.is_late ?? false,
|
||||
late_minutes: data.late_minutes ?? 0,
|
||||
is_early_leave: data.is_early_leave ?? false,
|
||||
early_minutes: data.early_minutes ?? 0,
|
||||
note: data.note ?? null,
|
||||
source: data.source ?? "web",
|
||||
updated_by: data.updated_by ?? null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
attendanceRecords.push(created);
|
||||
return created;
|
||||
});
|
||||
|
||||
const attendanceRecordUpdate = vi.fn(async ({ where, data }) => {
|
||||
const index = attendanceRecords.findIndex((row) => row.id === where.id);
|
||||
if (index < 0) {
|
||||
throw new Error("record_not_found");
|
||||
}
|
||||
attendanceRecords[index] = {
|
||||
...attendanceRecords[index],
|
||||
...data,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
return attendanceRecords[index];
|
||||
});
|
||||
|
||||
const attendancePolicyFindFirst = vi.fn(async () => attendancePolicies[0] ?? null);
|
||||
const attendancePolicyUpdate = vi.fn(async ({ where, data }) => {
|
||||
const index = attendancePolicies.findIndex((item) => item.id === where.id);
|
||||
if (index < 0) {
|
||||
throw new Error("policy_not_found");
|
||||
}
|
||||
attendancePolicies[index] = {
|
||||
...attendancePolicies[index],
|
||||
...data,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
return attendancePolicies[index];
|
||||
});
|
||||
const attendancePolicyCreate = vi.fn(async ({ data }) => {
|
||||
const created = buildPolicy({
|
||||
id: `policy-${attendancePolicies.length + 1}`,
|
||||
...data,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
attendancePolicies.push(created);
|
||||
return created;
|
||||
});
|
||||
|
||||
const leaveFindMany = vi.fn(async ({ where }) =>
|
||||
leaveRows.filter((row) => {
|
||||
if (where?.owner_id?.in && !where.owner_id.in.includes(row.owner_id)) return false;
|
||||
if (where?.status && row.status !== where.status) return false;
|
||||
if (where?.start_date?.lte && row.start_date > where.start_date.lte) return false;
|
||||
if (where?.end_date?.gte && row.end_date < where.end_date.gte) return false;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
const travelFindMany = vi.fn(async ({ where }) =>
|
||||
travelRows.filter((row) => {
|
||||
if (where?.applicant_employee_id?.in && !where.applicant_employee_id.in.includes(row.applicant_employee_id)) return false;
|
||||
if (where?.status && row.status !== where.status) return false;
|
||||
if (where?.start_date?.lte && row.start_date > where.start_date.lte) return false;
|
||||
if (where?.end_date?.gte && row.end_date < where.end_date.gte) return false;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
const legalEntityFindMany = vi.fn(async ({ where }) =>
|
||||
legalEntities.filter((item) => where?.id?.in?.includes(item.id)),
|
||||
);
|
||||
|
||||
const auditCreate = vi.fn().mockResolvedValue({});
|
||||
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
if (!(actor.permissionCodes ?? []).includes(permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("config", {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
attendanceEnabled: true,
|
||||
});
|
||||
app.decorate("prisma", {
|
||||
employee: {
|
||||
findUnique: employeeFindUnique,
|
||||
findMany: employeeFindMany,
|
||||
},
|
||||
attendanceRecord: {
|
||||
findFirst: attendanceRecordFindFirst,
|
||||
findMany: attendanceRecordFindMany,
|
||||
create: attendanceRecordCreate,
|
||||
update: attendanceRecordUpdate,
|
||||
},
|
||||
attendancePolicy: {
|
||||
findFirst: attendancePolicyFindFirst,
|
||||
update: attendancePolicyUpdate,
|
||||
create: attendancePolicyCreate,
|
||||
},
|
||||
leaveRequest: {
|
||||
findMany: leaveFindMany,
|
||||
},
|
||||
travelRequest: {
|
||||
findMany: travelFindMany,
|
||||
},
|
||||
legalEntity: {
|
||||
findMany: legalEntityFindMany,
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "flag-attendance",
|
||||
flag_key: "attendance_v1",
|
||||
override_mode: "force_on",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "",
|
||||
version: 1,
|
||||
created_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||
created_by: "system",
|
||||
updated_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updated_by: "system",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(attendanceRoutes, { prefix: "/api" });
|
||||
|
||||
return {
|
||||
app,
|
||||
auditCreate,
|
||||
attendancePolicies,
|
||||
attendanceRecords,
|
||||
employeeFindMany,
|
||||
attendanceRecordCreate,
|
||||
attendanceRecordUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
function countWeekdays(year, month) {
|
||||
let count = 0;
|
||||
const date = new Date(Date.UTC(year, month - 1, 1));
|
||||
while (date.getUTCMonth() === month - 1) {
|
||||
const day = date.getUTCDay();
|
||||
if (day !== 0 && day !== 6) {
|
||||
count += 1;
|
||||
}
|
||||
date.setUTCDate(date.getUTCDate() + 1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
describe("attendance routes", () => {
|
||||
let currentApp = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
if (currentApp) {
|
||||
await currentApp.close();
|
||||
currentApp = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps clock-in idempotent and does not overwrite the original timestamp", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T03:00:00.000Z"));
|
||||
const originalClockIn = new Date("2026-04-09T01:02:00.000Z");
|
||||
const { app, attendanceRecordCreate, attendanceRecordUpdate } = await buildApp({
|
||||
attendanceRecords: [
|
||||
buildAttendanceRecord({
|
||||
clock_in_at: originalClockIn,
|
||||
updated_at: originalClockIn,
|
||||
}),
|
||||
],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance/clock-in",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item.clockInAt).toBe(originalClockIn.toISOString());
|
||||
expect(attendanceRecordCreate).not.toHaveBeenCalled();
|
||||
expect(attendanceRecordUpdate).not.toHaveBeenCalled();
|
||||
|
||||
});
|
||||
|
||||
it("calculates late minutes with tolerance on clock-in", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T01:20:00.000Z"));
|
||||
const { app } = await buildApp({
|
||||
attendancePolicies: [buildPolicy({ late_tolerance_minutes: 15 })],
|
||||
attendanceRecords: [],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance/clock-in",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item).toMatchObject({
|
||||
isLate: true,
|
||||
lateMinutes: 5,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it("allows repeated clock-out and recalculates work minutes", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T10:00:00.000Z"));
|
||||
const { app, attendanceRecords } = await buildApp({
|
||||
attendanceRecords: [
|
||||
buildAttendanceRecord({
|
||||
clock_in_at: new Date("2026-04-09T01:00:00.000Z"),
|
||||
clock_out_at: new Date("2026-04-09T09:00:00.000Z"),
|
||||
work_minutes: 420,
|
||||
}),
|
||||
],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance/clock-out",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item.clockOutAt).toBe("2026-04-09T10:00:00.000Z");
|
||||
expect(response.json().data.item.workMinutes).toBe(480);
|
||||
expect(attendanceRecords[0].clock_out_at.toISOString()).toBe("2026-04-09T10:00:00.000Z");
|
||||
|
||||
});
|
||||
|
||||
it("calculates work minutes with lunch deduction and early leave on clock-out", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T08:30:00.000Z"));
|
||||
const { app } = await buildApp({
|
||||
attendanceRecords: [
|
||||
buildAttendanceRecord({
|
||||
clock_in_at: new Date("2026-04-09T01:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance/clock-out",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item).toMatchObject({
|
||||
workMinutes: 390,
|
||||
isEarlyLeave: true,
|
||||
earlyMinutes: 90,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it("marks approved leave day as no-need-clock-in and rejects self clock-in", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T01:00:00.000Z"));
|
||||
const { app } = await buildApp({
|
||||
attendanceRecords: [],
|
||||
leaveRows: [buildLeaveRow()],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const todayResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/attendance/me/today",
|
||||
});
|
||||
|
||||
expect(todayResponse.statusCode).toBe(200);
|
||||
expect(todayResponse.json().data).toMatchObject({
|
||||
workDate: "2026-04-09",
|
||||
needClockIn: false,
|
||||
exemptionReason: "leave",
|
||||
item: null,
|
||||
});
|
||||
|
||||
const clockInResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance/clock-in",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(clockInResponse.statusCode).toBe(409);
|
||||
|
||||
});
|
||||
|
||||
it("builds month summary without counting approved leave as missed", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-15T01:00:00.000Z"));
|
||||
const { app } = await buildApp({
|
||||
attendanceRecords: [
|
||||
buildAttendanceRecord({
|
||||
work_date: new Date("2026-04-01T00:00:00.000Z"),
|
||||
clock_in_at: new Date("2026-04-01T01:00:00.000Z"),
|
||||
clock_out_at: new Date("2026-04-01T10:00:00.000Z"),
|
||||
work_minutes: 480,
|
||||
}),
|
||||
],
|
||||
leaveRows: [
|
||||
buildLeaveRow({
|
||||
start_date: new Date("2026-04-02T00:00:00.000Z"),
|
||||
end_date: new Date("2026-04-02T00:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/attendance/me/month?year=2026&month=4",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const payload = response.json().data;
|
||||
const leaveItem = payload.items.find((item) => item.workDate === "2026-04-02");
|
||||
expect(leaveItem).toMatchObject({
|
||||
attendanceStatus: "leave",
|
||||
needClockIn: false,
|
||||
});
|
||||
expect(payload.summary).toMatchObject({
|
||||
totalWorkDays: countWeekdays(2026, 4) - 1,
|
||||
clockedDays: 1,
|
||||
lateDays: 0,
|
||||
earlyLeaveDays: 0,
|
||||
missedDays: countWeekdays(2026, 4) - 2,
|
||||
totalWorkMinutes: 480,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it("rejects team attendance query without system.users permission", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T01:00:00.000Z"));
|
||||
const { app, employeeFindMany } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: [],
|
||||
},
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/attendance/team",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(employeeFindMany).not.toHaveBeenCalled();
|
||||
|
||||
});
|
||||
|
||||
it("allows system.users to backfill attendance and writes audit log", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T09:30:00.000Z"));
|
||||
const { app, auditCreate, attendanceRecords } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: ["system.users"],
|
||||
},
|
||||
employees: [
|
||||
buildEmployeeRow(),
|
||||
buildEmployeeRow({
|
||||
id: OTHER_EMPLOYEE_ID,
|
||||
employee_no: "E002",
|
||||
name: "Bob",
|
||||
}),
|
||||
],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance/admin/backfill",
|
||||
payload: {
|
||||
employeeId: OTHER_EMPLOYEE_ID,
|
||||
workDate: "2026-04-09",
|
||||
clockInAt: "2026-04-09T01:00:00.000Z",
|
||||
clockOutAt: "2026-04-09T10:00:00.000Z",
|
||||
note: "补卡原因",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item).toMatchObject({
|
||||
employeeId: OTHER_EMPLOYEE_ID,
|
||||
source: "admin",
|
||||
workMinutes: 480,
|
||||
});
|
||||
expect(attendanceRecords).toHaveLength(1);
|
||||
expect(auditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "ATTENDANCE_ADMIN_BACKFILLED",
|
||||
extra: expect.objectContaining({
|
||||
employeeId: OTHER_EMPLOYEE_ID,
|
||||
workDate: "2026-04-09",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户