文件
hyb-oa/server/src/routes/attendanceCorrections.js
T
wangsli a837912638
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
feat: 完成错误原因合同与付款就绪提示(09-05-error-explainability-payment-readiness)
2026-09-05 19:00:35 +08:00

575 行
19 KiB
JavaScript

const { buildBusinessValidationError } = require("../utils/businessValidationError");
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 parseInput(schema, value) {
const parsed = schema.safeParse(value ?? {});
if (!parsed.success) {
throw buildBusinessValidationError("attendance_correction.invalid_input", parsed.error.flatten(), 400);
}
return parsed.data;
}
function parseUuid(value, key = "id") {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildBusinessValidationError("attendance_correction.invalid_uuid", { [key]: "invalid_uuid" }, 400);
}
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 buildBusinessValidationError("attendance_correction.datetime_invalid", { datetime: "invalid" }, 400);
}
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 buildBusinessValidationError("attendance_correction.feature_flag_attendance_unified_v1_disabled", { 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 buildBusinessValidationError("attendance_correction.workflow_missing_definition", { 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 buildBusinessValidationError("attendance_correction.workflow_no_actionable_approver", { 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 buildBusinessValidationError("attendance_correction.status_invalid_cancel_status", { 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;