584 行
19 KiB
JavaScript
584 行
19 KiB
JavaScript
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;
|