1072 行
34 KiB
JavaScript
1072 行
34 KiB
JavaScript
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 { hasPermission } = require("../constants/permissions");
|
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
|
const {
|
|
fetchWecomCheckinData,
|
|
normalizeCheckinRecord,
|
|
} = require("../services/integrations/wecomCheckin");
|
|
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();
|
|
|
|
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) {
|
|
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 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 },
|
|
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;
|
|
}
|
|
|
|
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);
|
|
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) });
|
|
},
|
|
);
|
|
|
|
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,
|
|
};
|
|
|
|
module.exports = attendanceRoutes;
|