ATTV1 实现考勤统一入口与企微同步
这个提交包含在:
@@ -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;
|
||||
在新工单中引用
屏蔽一个用户