ATTV1 实现考勤统一入口与企微同步

这个提交包含在:
2026-05-17 16:56:24 +08:00
父节点 d50b7db143
当前提交 916cc3b7f7
共修改 51 个文件,包含 3323 行新增和 1762 行删除
+641
查看文件
@@ -4,7 +4,12 @@ const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { currentDateString } = require("../utils/date");
const { sendData } = require("../utils/reply");
const { hasPermission } = require("../constants/permissions");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
fetchWecomCheckinData,
normalizeCheckinRecord,
} = require("../services/integrations/wecomCheckin");
const {
buildAttendanceMetrics,
buildAttendanceValidationError,
@@ -80,6 +85,47 @@ const ADMIN_BACKFILL_BODY_SCHEMA = z
})
.strict();
const UNIFIED_MONTH_QUERY_SCHEMA = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
employeeId: z.string().uuid().optional(),
});
const WECOM_SYNC_BODY_SCHEMA = z
.object({
startDate: DATE_SCHEMA,
endDate: DATE_SCHEMA,
employeeIds: z.array(z.string().uuid()).max(50).optional(),
})
.strict()
.superRefine((value, ctx) => {
const start = parseDateOnly(value.startDate);
const end = parseDateOnly(value.endDate);
if (!start || !end) {
return;
}
if (end < start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["endDate"],
message: "must_be_after_or_equal_startDate",
});
return;
}
const days = Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
if (days > 31) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["endDate"],
message: "range_too_large",
});
}
});
const SYNC_RUN_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
function parseInput(schema, value) {
const parsed = schema.safeParse(value ?? {});
if (!parsed.success) {
@@ -104,6 +150,22 @@ async function ensureFeatureEnabled(fastify, request) {
}
}
async function ensureUnifiedFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.attendanceUnified,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildAttendanceValidationError(
{ featureFlag: "attendance_unified_v1_disabled", reason: gate.reason },
409,
);
}
}
async function loadEmployeeForAttendance(prisma, employeeId) {
return prisma.employee.findUnique({
where: { id: employeeId },
@@ -152,6 +214,255 @@ function parseOptionalDateTime(value) {
return date;
}
function parseDateOnly(value) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null;
}
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatDateOnlyUtc(value) {
if (!value) return null;
return new Date(value).toISOString().slice(0, 10);
}
function addDays(value, days) {
const next = new Date(value);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function listDateStrings(startDate, endDate) {
const items = [];
for (let cursor = new Date(startDate); cursor <= endDate; cursor = addDays(cursor, 1)) {
items.push(formatDateOnlyUtc(cursor));
}
return items;
}
function parseMonthRange(monthValue, timeZone) {
const now = new Date();
const fallback = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
}).format(now);
const month = monthValue || fallback;
const [year, monthNo] = month.split("-").map(Number);
if (!Number.isInteger(year) || !Number.isInteger(monthNo) || monthNo < 1 || monthNo > 12) {
throw buildAttendanceValidationError({ month: "invalid" });
}
const startDate = new Date(Date.UTC(year, monthNo - 1, 1));
const endDate = new Date(Date.UTC(year, monthNo, 0));
return { month, year, monthNo, startDate, endDate };
}
function isWorkday(dateString) {
const day = new Date(`${dateString}T00:00:00.000Z`).getUTCDay();
return day !== 0 && day !== 6;
}
function groupBy(items, keyFn) {
const map = new Map();
for (const item of items) {
const key = keyFn(item);
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(item);
}
return map;
}
function mapEmployeeSummary(employee) {
if (!employee) return null;
return {
id: employee.id,
employeeNo: employee.employee_no,
name: employee.name,
title: employee.title ?? null,
departmentId: employee.department_id ?? null,
departmentName: employee.department?.name ?? null,
};
}
function mapWecomCheckinRecord(record) {
return {
id: record.id,
checkinAt: record.checkin_time?.toISOString?.() ?? null,
checkinDate: formatDateOnlyUtc(record.checkin_date),
checkinType: record.checkin_type,
result: record.result ?? null,
exceptionType: record.exception_type ?? null,
sourceType: record.source_type ?? null,
};
}
function mapAttendanceCorrection(record) {
return {
id: record.id,
kind: record.kind,
status: record.status,
workDate: formatDateOnlyUtc(record.work_date),
startAt: record.start_at?.toISOString?.() ?? null,
endAt: record.end_at?.toISOString?.() ?? null,
missedPunchAt: record.missed_punch_at?.toISOString?.() ?? null,
reason: record.reason,
locationSummary: record.location_summary ?? null,
compensationPolicy: record.compensation_policy ?? null,
owner: mapEmployeeSummary(record.owner),
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
function correctionKindLabel(kind) {
if (kind === "missed_punch") return "补卡";
if (kind === "out") return "外出";
if (kind === "overtime") return "加班";
return "考勤修正";
}
function resolveDailyAttendanceStatus({ dateString, checkins, corrections, leaveRequests }) {
const approvedCorrections = corrections.filter((item) => item.status === "approved");
if (leaveRequests.length > 0) {
return {
status: "leave",
explanation: "OA 已批准请假",
};
}
if (approvedCorrections.length > 0) {
return {
status: approvedCorrections.some((item) => item.kind === "overtime") ? "overtime" : "corrected",
explanation: approvedCorrections.map((item) => correctionKindLabel(item.kind)).join("、"),
};
}
if (!isWorkday(dateString)) {
return {
status: "weekend",
explanation: "周末",
};
}
if (checkins.length > 0) {
const hasException = checkins.some((item) => item.exception_type || item.result);
return {
status: hasException ? "exception" : "normal",
explanation: hasException ? "企微打卡存在异常摘要" : "企微打卡已同步",
};
}
return {
status: "missing",
explanation: "未同步到企微打卡摘要",
};
}
function mapSyncRun(record) {
return {
id: record.id,
startDate: formatDateOnlyUtc(record.start_date),
endDate: formatDateOnlyUtc(record.end_date),
status: record.status,
employeeCount: record.employee_count,
recordCount: record.record_count,
errorCode: record.error_code ?? null,
errorMessage: record.error_message ?? null,
triggeredBy: record.trigger
? {
id: record.trigger.id,
employeeNo: record.trigger.employee_no,
name: record.trigger.name,
}
: null,
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
function hasGlobalAttendanceScope(request) {
const roleCodes = request.employee.roleCodes ?? [];
const permissionCodes = request.employee.permissionCodes ?? [];
return (
roleCodes.includes("admin") ||
roleCodes.includes("hr") ||
hasPermission(permissionCodes, "*")
);
}
async function loadMonthlyEmployees(fastify, request, employeeId) {
const permissionCodes = request.employee.permissionCodes ?? [];
const canViewTeam = hasPermission(permissionCodes, "attendance.view_all");
const canViewGlobal = hasGlobalAttendanceScope(request);
if (employeeId) {
const isSelf = employeeId === request.employee.id;
if (!isSelf && !canViewTeam && !canViewGlobal) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
const employee = await fastify.prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
employee_no: true,
name: true,
title: true,
status: true,
department_id: true,
direct_manager_id: true,
department: { select: { id: true, name: true } },
},
});
if (!employee || employee.status !== "active") {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (!isSelf && !canViewGlobal && employee.direct_manager_id !== request.employee.id) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
return [employee];
}
if (!canViewTeam && !canViewGlobal) {
const employee = await fastify.prisma.employee.findUnique({
where: { id: request.employee.id },
select: {
id: true,
employee_no: true,
name: true,
title: true,
status: true,
department_id: true,
direct_manager_id: true,
department: { select: { id: true, name: true } },
},
});
return employee ? [employee] : [];
}
const teamScopeWhere = canViewGlobal
? {}
: {
OR: [
{ id: request.employee.id },
{ direct_manager_id: request.employee.id },
],
};
return fastify.prisma.employee.findMany({
where: { status: "active", ...teamScopeWhere },
orderBy: [{ department_id: "asc" }, { employee_no: "asc" }],
take: 200,
select: {
id: true,
employee_no: true,
name: true,
title: true,
status: true,
department_id: true,
direct_manager_id: true,
department: { select: { id: true, name: true } },
},
});
}
async function attendanceRoutes(fastify) {
fastify.post("/attendance/clock-in", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
@@ -419,10 +730,340 @@ async function attendanceRoutes(fastify) {
return sendData(reply, request, { item: mapAttendanceRecord(saved) });
},
);
fastify.post(
"/attendance/wecom/sync",
{ preHandler: fastify.authenticate },
async (request, reply) => {
await ensureUnifiedFeatureEnabled(fastify, request);
fastify.assertEntityPermission(request.employee, "attendance", "sync");
const body = parseInput(WECOM_SYNC_BODY_SCHEMA, request.body);
const startDate = parseDateOnly(body.startDate);
const endDate = parseDateOnly(body.endDate);
const employeeWhere = {
status: "active",
...(body.employeeIds?.length ? { id: { in: Array.from(new Set(body.employeeIds)) } } : {}),
};
const employees = await fastify.prisma.employee.findMany({
where: employeeWhere,
select: {
id: true,
employee_no: true,
name: true,
wecom_identity_binding: {
select: {
external_user_id: true,
},
},
},
});
const externalUserIds = employees
.map((employee) => employee.wecom_identity_binding?.external_user_id)
.filter(Boolean);
const employeeByExternalUserId = new Map(
employees
.filter((employee) => employee.wecom_identity_binding?.external_user_id)
.map((employee) => [employee.wecom_identity_binding.external_user_id, employee]),
);
const syncRun = await fastify.prisma.wecomCheckinSyncRun.create({
data: {
start_date: startDate,
end_date: endDate,
status: "running",
employee_count: employees.length,
record_count: 0,
triggered_by: request.employee.id,
},
});
let result;
let normalizedRecords = [];
try {
result = await fetchWecomCheckinData({
config: fastify.config,
startDate: body.startDate,
endDate: body.endDate,
externalUserIds,
});
normalizedRecords = result.records
.map((raw) =>
normalizeCheckinRecord(raw, {
employeeByExternalUserId,
timeZone: fastify.config.appTimezone,
}),
)
.filter(Boolean);
if (result.ok && normalizedRecords.length > 0) {
await fastify.prisma.wecomCheckinRecord.createMany({
data: normalizedRecords.map((record) => ({
sync_run_id: syncRun.id,
employee_id: record.employeeId,
external_user_id: record.externalUserId,
checkin_time: record.checkinTime,
checkin_date: record.checkinDate,
checkin_type: record.checkinType,
exception_type: record.exceptionType,
source_type: record.sourceType,
result: record.result,
raw_hash: record.rawHash,
})),
skipDuplicates: true,
});
}
} catch (error) {
result = {
ok: false,
skipped: false,
code: "wecom_checkin_unhandled_error",
message: error instanceof Error ? error.message.slice(0, 500) : "unknown_error",
};
}
const status = result.skipped ? "skipped" : result.ok ? "success" : "failed";
const updatedRun = await fastify.prisma.wecomCheckinSyncRun.update({
where: { id: syncRun.id },
data: {
status,
employee_count: employees.length,
record_count: normalizedRecords.length,
error_code: result.ok && !result.skipped ? null : result.code,
error_message: result.ok && !result.skipped ? null : result.message ?? null,
},
include: {
trigger: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "WECOM_CHECKIN_SYNC_RUN",
targetType: "wecom_checkin_sync_run",
targetId: updatedRun.id,
extra: {
startDate: body.startDate,
endDate: body.endDate,
status,
employeeCount: employees.length,
boundUserCount: externalUserIds.length,
recordCount: normalizedRecords.length,
resultCode: result.code,
},
}),
});
return sendData(reply, request, {
item: mapSyncRun(updatedRun),
skipped: Boolean(result.skipped),
code: result.code,
employeeCount: employees.length,
boundUserCount: externalUserIds.length,
recordCount: normalizedRecords.length,
});
},
);
fastify.get(
"/attendance/wecom/sync-runs",
{ preHandler: fastify.authenticate },
async (request, reply) => {
await ensureUnifiedFeatureEnabled(fastify, request);
fastify.assertEntityPermission(request.employee, "attendance", "sync");
const query = parseInput(SYNC_RUN_QUERY_SCHEMA, request.query);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = {};
const [items, total] = await Promise.all([
fastify.prisma.wecomCheckinSyncRun.findMany({
where,
orderBy: { created_at: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
trigger: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
}),
fastify.prisma.wecomCheckinSyncRun.count({ where }),
]);
return sendData(reply, request, {
page,
pageSize,
total,
items: items.map(mapSyncRun),
});
},
);
fastify.get(
"/attendance/monthly",
{ preHandler: fastify.authenticate },
async (request, reply) => {
await ensureUnifiedFeatureEnabled(fastify, request);
fastify.assertEntityPermission(request.employee, "attendance", "view_own");
const query = parseInput(UNIFIED_MONTH_QUERY_SCHEMA, request.query);
const { month, startDate, endDate } = parseMonthRange(query.month, fastify.config.appTimezone);
const employees = await loadMonthlyEmployees(fastify, request, query.employeeId);
const employeeIds = employees.map((employee) => employee.id);
const dates = listDateStrings(startDate, endDate);
if (!employeeIds.length) {
return sendData(reply, request, {
month,
summary: {
employeeCount: 0,
workDays: dates.filter(isWorkday).length,
checkinDays: 0,
missingDays: 0,
approvedCorrectionCount: 0,
},
employees: [],
});
}
const [checkins, corrections, leaveRequests] = await Promise.all([
fastify.prisma.wecomCheckinRecord.findMany({
where: {
employee_id: { in: employeeIds },
checkin_date: { gte: startDate, lte: endDate },
},
orderBy: [{ checkin_date: "asc" }, { checkin_time: "asc" }],
}),
fastify.prisma.attendanceCorrection.findMany({
where: {
owner_id: { in: employeeIds },
work_date: { gte: startDate, lte: endDate },
},
orderBy: [{ work_date: "asc" }, { created_at: "asc" }],
include: {
owner: {
select: {
id: true,
employee_no: true,
name: true,
title: true,
department_id: true,
department: { select: { id: true, name: true } },
},
},
},
}),
fastify.prisma.leaveRequest.findMany({
where: {
owner_id: { in: employeeIds },
status: "approved",
start_date: { lte: endDate },
end_date: { gte: startDate },
},
select: {
id: true,
owner_id: true,
start_date: true,
end_date: true,
requested_days: true,
reason: true,
leave_type: { select: { name: true } },
},
}),
]);
const checkinsByEmployeeDate = groupBy(
checkins,
(item) => `${item.employee_id}|${formatDateOnlyUtc(item.checkin_date)}`,
);
const correctionsByEmployeeDate = groupBy(
corrections,
(item) => `${item.owner_id}|${formatDateOnlyUtc(item.work_date)}`,
);
const leavesByEmployeeDate = new Map();
for (const leave of leaveRequests) {
const leaveStart = leave.start_date > startDate ? leave.start_date : startDate;
const leaveEnd = leave.end_date < endDate ? leave.end_date : endDate;
for (const dateString of listDateStrings(leaveStart, leaveEnd)) {
const key = `${leave.owner_id}|${dateString}`;
if (!leavesByEmployeeDate.has(key)) {
leavesByEmployeeDate.set(key, []);
}
leavesByEmployeeDate.get(key).push({
id: leave.id,
leaveTypeName: leave.leave_type?.name ?? "请假",
requestedDays: Number(leave.requested_days),
reason: leave.reason ?? null,
});
}
}
let checkinDays = 0;
let missingDays = 0;
let approvedCorrectionCount = 0;
const employeeItems = employees.map((employee) => {
const days = dates.map((dateString) => {
const key = `${employee.id}|${dateString}`;
const dayCheckins = checkinsByEmployeeDate.get(key) ?? [];
const dayCorrections = correctionsByEmployeeDate.get(key) ?? [];
const dayLeaves = leavesByEmployeeDate.get(key) ?? [];
const { status, explanation } = resolveDailyAttendanceStatus({
dateString,
checkins: dayCheckins,
corrections: dayCorrections,
leaveRequests: dayLeaves,
});
if (dayCheckins.length > 0) {
checkinDays += 1;
}
if (status === "missing") {
missingDays += 1;
}
approvedCorrectionCount += dayCorrections.filter((item) => item.status === "approved").length;
return {
date: dateString,
status,
explanation,
firstCheckinAt: dayCheckins[0]?.checkin_time?.toISOString?.() ?? null,
lastCheckinAt: dayCheckins[dayCheckins.length - 1]?.checkin_time?.toISOString?.() ?? null,
checkins: dayCheckins.map(mapWecomCheckinRecord),
corrections: dayCorrections.map(mapAttendanceCorrection),
leaveRequests: dayLeaves,
};
});
return {
employee: mapEmployeeSummary(employee),
days,
};
});
return sendData(reply, request, {
month,
summary: {
employeeCount: employees.length,
workDays: dates.filter(isWorkday).length,
checkinDays,
missingDays,
approvedCorrectionCount,
},
employees: employeeItems,
});
},
);
}
attendanceRoutes.__testables = {
ensureFeatureEnabled,
ensureUnifiedFeatureEnabled,
parseMonthRange,
parseOptionalDateTime,
resolveTodayState,
};