ATTV1 实现考勤统一入口与企微同步
这个提交包含在:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { hasPermission } = require("../constants/permissions");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { parseWorkflowSteps, resolveActionableStep } = require("../utils/workflow");
|
||||
|
||||
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 UUID_SCHEMA = z.string().uuid();
|
||||
|
||||
const CORRECTION_BODY_SCHEMA = z
|
||||
.object({
|
||||
kind: z.enum(["missed_punch", "out", "overtime"]),
|
||||
workDate: DATE_SCHEMA,
|
||||
missedPunchAt: DATE_TIME_SCHEMA.optional(),
|
||||
startAt: DATE_TIME_SCHEMA.optional(),
|
||||
endAt: DATE_TIME_SCHEMA.optional(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
locationSummary: z.string().trim().max(200).optional(),
|
||||
compensationPolicy: z.string().trim().max(200).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.kind === "missed_punch" && !value.missedPunchAt) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["missedPunchAt"],
|
||||
message: "required",
|
||||
});
|
||||
}
|
||||
if ((value.kind === "out" || value.kind === "overtime") && (!value.startAt || !value.endAt)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["startAt"],
|
||||
message: "start_and_end_required",
|
||||
});
|
||||
}
|
||||
if (value.startAt && value.endAt) {
|
||||
const start = new Date(value.startAt);
|
||||
const end = new Date(value.endAt);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["endAt"],
|
||||
message: "must_be_after_or_equal_startAt",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const LIST_QUERY_SCHEMA = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
employeeId: UUID_SCHEMA.optional(),
|
||||
status: z.enum(["reviewing", "approved", "rejected", "cancelled"]).optional(),
|
||||
kind: z.enum(["missed_punch", "out", "overtime"]).optional(),
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
function parseInput(schema, value) {
|
||||
const parsed = schema.safeParse(value ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseUuid(value, key = "id") {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError({ [key]: "invalid_uuid" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseDateOnly(value) {
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function parseOptionalDateTime(value) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw buildValidationError({ datetime: "invalid" });
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function formatDateOnly(value) {
|
||||
return value?.toISOString?.().slice(0, 10) ?? null;
|
||||
}
|
||||
|
||||
function parseMonthRange(month) {
|
||||
if (!month) return null;
|
||||
const [year, monthNo] = month.split("-").map(Number);
|
||||
const startDate = new Date(Date.UTC(year, monthNo - 1, 1));
|
||||
const endDate = new Date(Date.UTC(year, monthNo, 0));
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
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 buildValidationError(
|
||||
{ featureFlag: "attendance_unified_v1_disabled", reason: gate.reason },
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapEmployee(employee) {
|
||||
if (!employee) return null;
|
||||
return {
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no,
|
||||
name: employee.name,
|
||||
departmentId: employee.department_id ?? null,
|
||||
departmentName: employee.department?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapCorrection(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
status: record.status,
|
||||
workDate: formatDateOnly(record.work_date),
|
||||
missedPunchAt: record.missed_punch_at?.toISOString?.() ?? null,
|
||||
startAt: record.start_at?.toISOString?.() ?? null,
|
||||
endAt: record.end_at?.toISOString?.() ?? null,
|
||||
reason: record.reason,
|
||||
locationSummary: record.location_summary ?? null,
|
||||
compensationPolicy: record.compensation_policy ?? null,
|
||||
owner: mapEmployee(record.owner),
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
workflow: record.workflow_instances?.[0]
|
||||
? {
|
||||
id: record.workflow_instances[0].id,
|
||||
status: record.workflow_instances[0].current_status,
|
||||
currentStepId: record.workflow_instances[0].current_step_id,
|
||||
}
|
||||
: 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 createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) {
|
||||
for (const skipped of skippedSteps) {
|
||||
await tx.workflowHistory.create({
|
||||
data: {
|
||||
instance_id: workflowInstanceId,
|
||||
actor_id: actorId,
|
||||
action: "skip",
|
||||
payload: {
|
||||
step_id: skipped.step.id,
|
||||
reason: skipped.reason,
|
||||
owner_id: ownerId,
|
||||
depth_checked: skipped.depthChecked,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function canReadCorrection(request, correction) {
|
||||
if (correction.owner_id === request.employee.id) {
|
||||
return true;
|
||||
}
|
||||
if (hasGlobalAttendanceScope(request)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(request.employee.permissionCodes ?? [], "attendance.view_all") &&
|
||||
correction.owner?.direct_manager_id === request.employee.id
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCorrectionForRead(tx, correctionId, request) {
|
||||
const item = await tx.attendanceCorrection.findUnique({
|
||||
where: { id: correctionId },
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
direct_manager_id: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
workflow_instances: {
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
current_status: true,
|
||||
current_step_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!item) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (!canReadCorrection(request, item)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async function attendanceCorrectionsRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/attendance-corrections",
|
||||
{
|
||||
schema: { tags: ["Attendance"], summary: "Submit Attendance Correction" },
|
||||
preHandler: fastify.requireEntityPermission("attendance_correction", "create"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
await ensureUnifiedFeatureEnabled(fastify, request);
|
||||
const body = parseInput(CORRECTION_BODY_SCHEMA, request.body);
|
||||
const actor = await fastify.prisma.employee.findUnique({
|
||||
where: { id: request.employee.id },
|
||||
select: {
|
||||
id: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
if (!actor || actor.status !== "active") {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
let createdId = null;
|
||||
let workflowInstanceId = null;
|
||||
let workflowTaskId = null;
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const correction = await tx.attendanceCorrection.create({
|
||||
data: {
|
||||
owner_id: request.employee.id,
|
||||
department_id: actor.department_id,
|
||||
kind: body.kind,
|
||||
status: "reviewing",
|
||||
work_date: parseDateOnly(body.workDate),
|
||||
missed_punch_at: parseOptionalDateTime(body.missedPunchAt),
|
||||
start_at: parseOptionalDateTime(body.startAt),
|
||||
end_at: parseOptionalDateTime(body.endAt),
|
||||
reason: body.reason,
|
||||
location_summary: body.locationSummary || null,
|
||||
compensation_policy: body.compensationPolicy || null,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
createdId = correction.id;
|
||||
|
||||
const workflowDefinition = await tx.workflowDefinition.findFirst({
|
||||
where: { entity_type: "attendance_correction", is_active: true },
|
||||
orderBy: { version: "desc" },
|
||||
select: { id: true, definition_json: true },
|
||||
});
|
||||
if (!workflowDefinition) {
|
||||
throw buildValidationError({ workflow: "missing_definition" }, 409);
|
||||
}
|
||||
const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json);
|
||||
const { actionable, skipped } = await resolveActionableStep({
|
||||
tx,
|
||||
steps: workflowSteps,
|
||||
startIndex: 0,
|
||||
ownerId: request.employee.id,
|
||||
logger: fastify.log,
|
||||
blockOnUnresolvedAssignee: true,
|
||||
});
|
||||
if (!actionable) {
|
||||
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
|
||||
}
|
||||
|
||||
const workflowInstance = await tx.workflowInstance.create({
|
||||
data: {
|
||||
workflow_id: workflowDefinition.id,
|
||||
entity_type: "attendance_correction",
|
||||
entity_id: correction.id,
|
||||
attendance_correction_id: correction.id,
|
||||
submitter_id: request.employee.id,
|
||||
current_status: "pending",
|
||||
current_step_id: actionable.step.id,
|
||||
context_data: {
|
||||
owner_id: request.employee.id,
|
||||
department_id: actor.department_id,
|
||||
correction_kind: body.kind,
|
||||
work_date: body.workDate,
|
||||
workflow_steps_snapshot: workflowSteps,
|
||||
},
|
||||
context_department_id: actor.department_id,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
workflowInstanceId = workflowInstance.id;
|
||||
|
||||
await createSkipHistoryRecords(
|
||||
tx,
|
||||
workflowInstance.id,
|
||||
request.employee.id,
|
||||
request.employee.id,
|
||||
skipped,
|
||||
);
|
||||
|
||||
const workflowTask = await tx.workflowTask.create({
|
||||
data: {
|
||||
instance_id: workflowInstance.id,
|
||||
step_id: actionable.step.id,
|
||||
step_name: actionable.step.name ?? actionable.step.id,
|
||||
assignee_id: actionable.assigneeId,
|
||||
assignee_role: actionable.assigneeRole,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
workflowTaskId = workflowTask.id;
|
||||
|
||||
await tx.workflowHistory.create({
|
||||
data: {
|
||||
instance_id: workflowInstance.id,
|
||||
task_id: workflowTask.id,
|
||||
actor_id: request.employee.id,
|
||||
action: "start",
|
||||
payload: {
|
||||
step_id: actionable.step.id,
|
||||
skipped_step_count: skipped.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "ATTENDANCE_CORRECTION_SUBMITTED",
|
||||
targetType: "attendance_correction",
|
||||
targetId: correction.id,
|
||||
extra: {
|
||||
kind: body.kind,
|
||||
workDate: body.workDate,
|
||||
workflowInstanceId: workflowInstance.id,
|
||||
workflowTaskId: workflowTask.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const item = await loadCorrectionForRead(fastify.prisma, createdId, request);
|
||||
return sendData(reply, request, {
|
||||
item: mapCorrection(item),
|
||||
workflowInstanceId,
|
||||
currentTaskId: workflowTaskId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/attendance-corrections",
|
||||
{
|
||||
schema: { tags: ["Attendance"], summary: "List Attendance Corrections" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
await ensureUnifiedFeatureEnabled(fastify, request);
|
||||
fastify.assertEntityPermission(request.employee, "attendance_correction", "view_own");
|
||||
const query = parseInput(LIST_QUERY_SCHEMA, request.query);
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const canViewTeam = hasPermission(request.employee.permissionCodes ?? [], "attendance.view_all");
|
||||
const canViewGlobal = hasGlobalAttendanceScope(request);
|
||||
if (query.employeeId && query.employeeId !== request.employee.id && !canViewTeam && !canViewGlobal) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
if (query.employeeId && query.employeeId !== request.employee.id && !canViewGlobal) {
|
||||
const target = await fastify.prisma.employee.findUnique({
|
||||
where: { id: query.employeeId },
|
||||
select: { id: true, status: true, direct_manager_id: true },
|
||||
});
|
||||
if (!target || target.status !== "active") {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (target.direct_manager_id !== request.employee.id) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
}
|
||||
const monthRange = parseMonthRange(query.month);
|
||||
const ownerScopeWhere = query.employeeId
|
||||
? { owner_id: query.employeeId }
|
||||
: canViewGlobal
|
||||
? {}
|
||||
: canViewTeam
|
||||
? { OR: [{ owner_id: request.employee.id }, { owner: { direct_manager_id: request.employee.id } }] }
|
||||
: { owner_id: request.employee.id };
|
||||
const where = {
|
||||
...ownerScopeWhere,
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.kind ? { kind: query.kind } : {}),
|
||||
...(monthRange
|
||||
? {
|
||||
work_date: {
|
||||
gte: monthRange.startDate,
|
||||
lte: monthRange.endDate,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
fastify.prisma.attendanceCorrection.findMany({
|
||||
where,
|
||||
orderBy: [{ work_date: "desc" }, { created_at: "desc" }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
workflow_instances: {
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
current_status: true,
|
||||
current_step_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
fastify.prisma.attendanceCorrection.count({ where }),
|
||||
]);
|
||||
|
||||
return sendData(reply, request, {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
items: items.map(mapCorrection),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/attendance-corrections/:id",
|
||||
{
|
||||
schema: { tags: ["Attendance"], summary: "Get Attendance Correction" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
await ensureUnifiedFeatureEnabled(fastify, request);
|
||||
fastify.assertEntityPermission(request.employee, "attendance_correction", "view_own");
|
||||
const correctionId = parseUuid(request.params?.id);
|
||||
const item = await loadCorrectionForRead(fastify.prisma, correctionId, request);
|
||||
return sendData(reply, request, { item: mapCorrection(item) });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/attendance-corrections/:id/cancel",
|
||||
{
|
||||
schema: { tags: ["Attendance"], summary: "Cancel Attendance Correction" },
|
||||
preHandler: fastify.requireEntityPermission("attendance_correction", "submit"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
await ensureUnifiedFeatureEnabled(fastify, request);
|
||||
const correctionId = parseUuid(request.params?.id);
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const correction = await tx.attendanceCorrection.findUnique({
|
||||
where: { id: correctionId },
|
||||
select: { id: true, owner_id: true, status: true },
|
||||
});
|
||||
if (!correction) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (correction.owner_id !== request.employee.id) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
if (correction.status !== "reviewing") {
|
||||
throw buildValidationError({ status: "invalid_cancel_status" }, 409);
|
||||
}
|
||||
|
||||
const workflowInstance = await tx.workflowInstance.findFirst({
|
||||
where: {
|
||||
entity_type: "attendance_correction",
|
||||
entity_id: correctionId,
|
||||
current_status: "pending",
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await tx.attendanceCorrection.update({
|
||||
where: { id: correctionId },
|
||||
data: {
|
||||
status: "cancelled",
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
if (workflowInstance) {
|
||||
await tx.workflowTask.updateMany({
|
||||
where: { instance_id: workflowInstance.id, status: "pending" },
|
||||
data: {
|
||||
status: "cancelled",
|
||||
completed_at: new Date(),
|
||||
completed_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.workflowInstance.update({
|
||||
where: { id: workflowInstance.id },
|
||||
data: {
|
||||
current_status: "cancelled",
|
||||
completed_at: new Date(),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.workflowHistory.create({
|
||||
data: {
|
||||
instance_id: workflowInstance.id,
|
||||
actor_id: request.employee.id,
|
||||
action: "cancel",
|
||||
payload: { source: "attendance_correction_cancel" },
|
||||
},
|
||||
});
|
||||
}
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "ATTENDANCE_CORRECTION_CANCELLED",
|
||||
targetType: "attendance_correction",
|
||||
targetId: correctionId,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const item = await loadCorrectionForRead(fastify.prisma, correctionId, request);
|
||||
return sendData(reply, request, { item: mapCorrection(item) });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
attendanceCorrectionsRoutes.__testables = {
|
||||
ensureUnifiedFeatureEnabled,
|
||||
mapCorrection,
|
||||
parseMonthRange,
|
||||
};
|
||||
|
||||
module.exports = attendanceCorrectionsRoutes;
|
||||
@@ -245,6 +245,17 @@ const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({
|
||||
],
|
||||
},
|
||||
],
|
||||
attendance_unified_v1: [
|
||||
{
|
||||
label: "企微考勤统一汇总",
|
||||
routes: [
|
||||
"/api/attendance/wecom/sync",
|
||||
"/api/attendance/wecom/sync-runs",
|
||||
"/api/attendance/monthly",
|
||||
"/api/attendance-corrections",
|
||||
],
|
||||
},
|
||||
],
|
||||
weekly_report_v1: [
|
||||
{
|
||||
label: "周报工作台",
|
||||
|
||||
+142
-2
@@ -56,7 +56,7 @@ const REMIND_TASK_BODY_SCHEMA = z.object({
|
||||
|
||||
const TASK_LIST_QUERY_SCHEMA = z.object({
|
||||
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
|
||||
entityType: z.enum(["reimbursement", "leave", "purchase_request", "travel_request"]).optional(),
|
||||
entityType: z.enum(["reimbursement", "leave", "attendance_correction", "purchase_request", "travel_request"]).optional(),
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
@@ -373,6 +373,7 @@ function mapTaskSummary(task, allowedActions = []) {
|
||||
function fallbackBusinessTitle(entityType) {
|
||||
if (entityType === "reimbursement") return "报销单";
|
||||
if (entityType === "leave") return "请假申请";
|
||||
if (entityType === "attendance_correction") return "考勤修正";
|
||||
if (entityType === "purchase_request") return "采购申请";
|
||||
if (entityType === "travel_request") return "差旅申请";
|
||||
return "业务单据";
|
||||
@@ -421,6 +422,29 @@ function buildTaskBusinessSummary(task) {
|
||||
};
|
||||
}
|
||||
|
||||
if (workflowInstance.entity_type === "attendance_correction" && workflowInstance.attendance_correction) {
|
||||
const correction = workflowInstance.attendance_correction;
|
||||
const kindLabel =
|
||||
correction.kind === "missed_punch"
|
||||
? "补卡"
|
||||
: correction.kind === "out"
|
||||
? "外出"
|
||||
: correction.kind === "overtime"
|
||||
? "加班"
|
||||
: "考勤修正";
|
||||
return {
|
||||
...fallback,
|
||||
reference: correction.work_date?.toISOString?.().slice(0, 10) ?? workflowInstance.entity_id,
|
||||
title: `${kindLabel}:${correction.reason ?? "考勤修正"}`,
|
||||
applicantName: correction.owner?.name ?? null,
|
||||
duration:
|
||||
correction.start_at && correction.end_at
|
||||
? `${correction.start_at.toISOString().slice(11, 16)}-${correction.end_at.toISOString().slice(11, 16)}`
|
||||
: null,
|
||||
businessStatus: correction.status ?? fallback.businessStatus,
|
||||
};
|
||||
}
|
||||
|
||||
if (workflowInstance.entity_type === "purchase_request" && workflowInstance.purchase_request) {
|
||||
const purchaseRequest = workflowInstance.purchase_request;
|
||||
const amount = toMoneyString(purchaseRequest.total_amount);
|
||||
@@ -546,7 +570,10 @@ function listAllowedActionsForTask(request, task, actionable) {
|
||||
const approvePermission = resolveEntityPermission(task.workflow_instance?.entity_type, "approve");
|
||||
const canApprove = approvePermission && hasPermission(request.employee.permissionCodes, approvePermission);
|
||||
if (canApprove) {
|
||||
actions.push("approve", "reject", "return");
|
||||
actions.push("approve", "reject");
|
||||
if (task.workflow_instance?.entity_type !== "attendance_correction") {
|
||||
actions.push("return");
|
||||
}
|
||||
}
|
||||
|
||||
if (canApprove && hasPermission(request.employee.permissionCodes, "expense.transfer")) {
|
||||
@@ -714,6 +741,28 @@ async function loadLeaveNotifyContext(tx, leaveRequestId) {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAttendanceCorrectionNotifyContext(tx, correctionId) {
|
||||
return tx.attendanceCorrection.findUnique({
|
||||
where: { id: correctionId },
|
||||
select: {
|
||||
id: true,
|
||||
owner_id: true,
|
||||
kind: true,
|
||||
status: true,
|
||||
work_date: true,
|
||||
start_at: true,
|
||||
end_at: true,
|
||||
reason: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPurchaseNotifyContext(tx, purchaseRequestId) {
|
||||
return tx.purchaseRequest.findUnique({
|
||||
where: { id: purchaseRequestId },
|
||||
@@ -771,6 +820,14 @@ async function loadWorkflowNotifyContext(tx, entityType, entityId) {
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedEntityType === 'attendance_correction') {
|
||||
const record = await loadAttendanceCorrectionNotifyContext(tx, entityId);
|
||||
return {
|
||||
record,
|
||||
recipientEmployeeIds: uniq([record?.owner_id]),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedEntityType === 'purchase_request') {
|
||||
const record = await loadPurchaseNotifyContext(tx, entityId);
|
||||
return {
|
||||
@@ -839,6 +896,10 @@ function collectWorkflowApplicantIds(workflowInstance, record) {
|
||||
return uniq([record?.owner_id, workflowInstance.submitter_id]);
|
||||
}
|
||||
|
||||
if (entityType === "attendance_correction") {
|
||||
return uniq([record?.owner_id, workflowInstance.submitter_id]);
|
||||
}
|
||||
|
||||
if (entityType === "purchase_request") {
|
||||
return uniq([record?.applicant_id, record?.submitter_id, workflowInstance.submitter_id]);
|
||||
}
|
||||
@@ -895,6 +956,7 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) {
|
||||
if (outcome === 'approved') {
|
||||
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementApproved;
|
||||
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveApproved;
|
||||
if (normalizedEntityType === 'attendance_correction') return NOTIFICATION_TYPES.attendanceCorrectionApproved;
|
||||
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestApproved;
|
||||
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestApproved;
|
||||
}
|
||||
@@ -902,6 +964,7 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) {
|
||||
if (outcome === 'rejected') {
|
||||
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementRejected;
|
||||
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveRejected;
|
||||
if (normalizedEntityType === 'attendance_correction') return NOTIFICATION_TYPES.attendanceCorrectionRejected;
|
||||
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestRejected;
|
||||
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestRejected;
|
||||
}
|
||||
@@ -1547,6 +1610,14 @@ async function executeApproveWorkflowTask({
|
||||
createdBy: request.employee.id,
|
||||
});
|
||||
}
|
||||
} else if (workflowInstance.entity_type === "attendance_correction") {
|
||||
await tx.attendanceCorrection.update({
|
||||
where: { id: workflowInstance.entity_id },
|
||||
data: {
|
||||
status: "approved",
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
} else if (workflowInstance.entity_type === "purchase_request") {
|
||||
await tx.purchaseRequest.update({
|
||||
where: { id: workflowInstance.entity_id },
|
||||
@@ -1633,6 +1704,7 @@ async function executeApproveWorkflowTask({
|
||||
workflowInstanceId: workflowInstance.id,
|
||||
nextTaskId: null,
|
||||
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null,
|
||||
attendanceCorrectionStatus: workflowInstance.entity_type === "attendance_correction" ? "approved" : null,
|
||||
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "approved" : null,
|
||||
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "approved" : null,
|
||||
};
|
||||
@@ -1736,6 +1808,14 @@ async function executeRejectWorkflowTask({
|
||||
createdBy: request.employee.id,
|
||||
});
|
||||
}
|
||||
} else if (workflowInstance.entity_type === "attendance_correction") {
|
||||
await tx.attendanceCorrection.update({
|
||||
where: { id: workflowInstance.entity_id },
|
||||
data: {
|
||||
status: "rejected",
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
} else if (workflowInstance.entity_type === "purchase_request") {
|
||||
await tx.purchaseRequest.update({
|
||||
where: { id: workflowInstance.entity_id },
|
||||
@@ -1807,6 +1887,7 @@ async function executeRejectWorkflowTask({
|
||||
outcome: "rejected",
|
||||
workflowInstanceId: workflowInstance.id,
|
||||
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null,
|
||||
attendanceCorrectionStatus: workflowInstance.entity_type === "attendance_correction" ? "rejected" : null,
|
||||
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "rejected" : null,
|
||||
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "rejected" : null,
|
||||
};
|
||||
@@ -1978,6 +2059,21 @@ async function workflowRoutes(fastify) {
|
||||
},
|
||||
},
|
||||
},
|
||||
attendance_correction: {
|
||||
select: {
|
||||
status: true,
|
||||
kind: true,
|
||||
work_date: true,
|
||||
start_at: true,
|
||||
end_at: true,
|
||||
reason: true,
|
||||
owner: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
purchase_request: {
|
||||
select: {
|
||||
request_no: true,
|
||||
@@ -2130,6 +2226,27 @@ async function workflowRoutes(fastify) {
|
||||
},
|
||||
},
|
||||
},
|
||||
attendance_correction: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
kind: true,
|
||||
work_date: true,
|
||||
missed_punch_at: true,
|
||||
start_at: true,
|
||||
end_at: true,
|
||||
reason: true,
|
||||
location_summary: true,
|
||||
compensation_policy: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
purchase_request: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -2372,6 +2489,29 @@ async function workflowRoutes(fastify) {
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
attendanceCorrection: task.workflow_instance?.attendance_correction
|
||||
? {
|
||||
id: task.workflow_instance.attendance_correction.id,
|
||||
status: task.workflow_instance.attendance_correction.status,
|
||||
kind: task.workflow_instance.attendance_correction.kind,
|
||||
workDate: formatDateOnly(task.workflow_instance.attendance_correction.work_date),
|
||||
missedPunchAt:
|
||||
task.workflow_instance.attendance_correction.missed_punch_at?.toISOString?.() ?? null,
|
||||
startAt: task.workflow_instance.attendance_correction.start_at?.toISOString?.() ?? null,
|
||||
endAt: task.workflow_instance.attendance_correction.end_at?.toISOString?.() ?? null,
|
||||
reason: task.workflow_instance.attendance_correction.reason ?? null,
|
||||
locationSummary: task.workflow_instance.attendance_correction.location_summary ?? null,
|
||||
compensationPolicy:
|
||||
task.workflow_instance.attendance_correction.compensation_policy ?? null,
|
||||
owner: task.workflow_instance.attendance_correction.owner
|
||||
? {
|
||||
id: task.workflow_instance.attendance_correction.owner.id,
|
||||
employeeNo: task.workflow_instance.attendance_correction.owner.employee_no,
|
||||
name: task.workflow_instance.attendance_correction.owner.name,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
purchaseRequest: task.workflow_instance?.purchase_request
|
||||
? {
|
||||
id: task.workflow_instance.purchase_request.id,
|
||||
|
||||
@@ -6,7 +6,7 @@ const { buildAuditLogData } = require("../utils/audit");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "leave", "purchase_request", "travel_request"]);
|
||||
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "leave", "attendance_correction", "purchase_request", "travel_request"]);
|
||||
const ASSIGNEE_RULE_SCHEMA = z.union([
|
||||
z.object({ type: z.literal("department_manager") }).strict(),
|
||||
z.object({ type: z.literal("role"), value: z.string().trim().min(1).max(32) }).strict(),
|
||||
@@ -92,7 +92,7 @@ async function workflowDefinitionsRoutes(fastify) {
|
||||
fastify.get("/workflow-definitions", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const items = await fastify.prisma.workflowDefinition.findMany({
|
||||
where: { entity_type: { in: ["leave", "purchase_request", "travel_request"] } },
|
||||
where: { entity_type: { in: ["leave", "attendance_correction", "purchase_request", "travel_request"] } },
|
||||
orderBy: [{ entity_type: "asc" }, { version: "desc" }],
|
||||
});
|
||||
const reimbursement = await buildReimbursementProjection(fastify.prisma);
|
||||
|
||||
在新工单中引用
屏蔽一个用户