const Fastify = require("fastify"); const attendanceCorrectionsRoutes = require("../../../src/routes/attendanceCorrections"); const { resolveEntityPermission } = require("../../../src/constants/permissions"); const { AppError } = require("../../../src/utils/AppError"); const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; const MANAGER_ID = "22222222-2222-4222-8222-222222222222"; const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; function buildFlag(flagKey, overrideMode = "force_on") { return { id: `flag-${flagKey}`, flag_key: flagKey, override_mode: overrideMode, rollout_type: "global", department_ids: [], note: "", version: 1, created_at: new Date("2026-05-01T00:00:00.000Z"), updated_at: new Date("2026-05-01T00:00:00.000Z"), }; } function buildEmployee(overrides = {}) { return { id: EMPLOYEE_ID, employee_no: "E001", name: "员工一", status: "active", department_id: DEPARTMENT_ID, department: { id: DEPARTMENT_ID, name: "研发部" }, direct_manager_id: MANAGER_ID, ...overrides, }; } function buildApp(options = {}) { const app = Fastify(); const actor = options.actor ?? { id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, permissionCodes: ["attendance.create", "attendance.submit", "attendance.view_own"], roleCodes: ["employee"], }; const corrections = []; const initialCorrections = options.corrections ?? []; const workflowInstances = []; const workflowTasks = []; const workflowHistory = []; const employees = options.employees ?? [ buildEmployee(), buildEmployee({ id: MANAGER_ID, name: "经理", direct_manager_id: null }), ]; corrections.push( ...initialCorrections.map((item) => ({ created_at: new Date("2026-05-17T02:00:00.000Z"), updated_at: new Date("2026-05-17T02:00:00.000Z"), workflow_instances: [], ...item, })), ); const tx = { employee: { findUnique: vi.fn(async ({ where, select }) => { const employee = employees.find((item) => item.id === where.id) ?? null; if (!employee || !select) return employee; return Object.fromEntries(Object.keys(select).map((key) => [key, employee[key]])); }), }, featureFlag: { findUnique: vi.fn(async ({ where }) => buildFlag(where.flag_key, "force_on")), }, workflowDefinition: { findFirst: vi.fn(async () => ({ id: "33333333-3333-4333-8333-333333333333", definition_json: { steps: [ { id: "attendance_correction_approval_manager", name: "直属经理审批", type: "approval", assignee_rule: { type: "department_manager" }, }, ], }, })), }, attendanceCorrection: { create: vi.fn(async ({ data }) => { const row = { id: "44444444-4444-4444-8444-444444444444", created_at: new Date("2026-05-17T02:00:00.000Z"), updated_at: new Date("2026-05-17T02:00:00.000Z"), owner: buildEmployee(), workflow_instances: [], ...data, }; corrections.push(row); return { id: row.id }; }), findUnique: vi.fn(async ({ where }) => { const row = corrections.find((item) => item.id === where.id); if (!row) return null; const owner = employees.find((item) => item.id === row.owner_id) ?? buildEmployee({ id: row.owner_id }); return { ...row, owner, workflow_instances: workflowInstances.filter((item) => item.entity_id === row.id), }; }), findMany: vi.fn(async ({ where }) => corrections .filter((item) => { const owner = employees.find((employee) => employee.id === item.owner_id) ?? null; if (where?.owner_id && item.owner_id !== where.owner_id) return false; if (where?.OR?.length) { const matched = where.OR.some((condition) => { if (condition.owner_id && item.owner_id === condition.owner_id) return true; if (condition.owner?.direct_manager_id && owner?.direct_manager_id === condition.owner.direct_manager_id) return true; return false; }); if (!matched) return false; } if (where?.status && item.status !== where.status) return false; if (where?.kind && item.kind !== where.kind) return false; return true; }) .map((item) => ({ ...item, owner: employees.find((employee) => employee.id === item.owner_id) ?? buildEmployee({ id: item.owner_id }), workflow_instances: [], })), ), count: vi.fn(async ({ where }) => tx.attendanceCorrection.findMany({ where }).then((items) => items.length)), update: vi.fn(async ({ where, data }) => { const index = corrections.findIndex((item) => item.id === where.id); corrections[index] = { ...corrections[index], ...data, updated_at: new Date("2026-05-17T03:00:00.000Z") }; return corrections[index]; }), }, workflowInstance: { create: vi.fn(async ({ data }) => { const row = { id: "55555555-5555-4555-8555-555555555555", current_status: data.current_status, current_step_id: data.current_step_id, created_at: new Date("2026-05-17T02:00:00.000Z"), ...data, }; workflowInstances.push(row); return { id: row.id }; }), findFirst: vi.fn(async ({ where }) => workflowInstances.find( (item) => item.entity_type === where.entity_type && item.entity_id === where.entity_id && item.current_status === where.current_status, ) ?? null, ), update: vi.fn(async ({ where, data }) => { const index = workflowInstances.findIndex((item) => item.id === where.id); workflowInstances[index] = { ...workflowInstances[index], ...data }; return workflowInstances[index]; }), }, workflowTask: { create: vi.fn(async ({ data }) => { const row = { id: "66666666-6666-4666-8666-666666666666", ...data }; workflowTasks.push(row); return { id: row.id }; }), updateMany: vi.fn(async () => ({ count: 1 })), }, workflowHistory: { create: vi.fn(async ({ data }) => { workflowHistory.push(data); return { id: `history-${workflowHistory.length}`, ...data }; }), }, auditLog: { create: vi.fn(async () => ({})), }, }; const prisma = { ...tx, $transaction: vi.fn(async (callback) => callback(tx)), }; app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("assertEntityPermission", (employee, entityType, action) => { const permission = resolveEntityPermission(entityType, action); if (!permission || (!employee.permissionCodes.includes(permission) && !employee.permissionCodes.includes("*"))) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission } }); } }); app.decorate("requireEntityPermission", (entityType, action) => async (request) => { await app.authenticate(request); app.assertEntityPermission(request.employee, entityType, action); }); app.decorate("config", { appTimezone: "Asia/Shanghai", attendanceUnifiedEnabled: true, }); app.decorate("prisma", prisma); app.register(attendanceCorrectionsRoutes, { prefix: "/api" }); return { app, prisma, corrections, workflowInstances, workflowTasks, workflowHistory }; } describe("attendance correction routes", () => { let currentApp = null; afterEach(async () => { if (currentApp) { await currentApp.close(); currentApp = null; } }); it("submits missed punch correction into OA workflow and writes audit", async () => { const { app, prisma, workflowInstances, workflowTasks } = buildApp(); currentApp = app; const response = await app.inject({ method: "POST", url: "/api/attendance-corrections", payload: { kind: "missed_punch", workDate: "2026-05-12", missedPunchAt: "2026-05-12T09:00:00+08:00", reason: "企微缺卡,提交 OA 解释", }, }); expect(response.statusCode).toBe(200); expect(response.json().data.item).toMatchObject({ kind: "missed_punch", status: "reviewing", reason: "企微缺卡,提交 OA 解释", }); expect(workflowInstances[0]).toMatchObject({ entity_type: "attendance_correction", attendance_correction_id: "44444444-4444-4444-8444-444444444444", current_step_id: "attendance_correction_approval_manager", }); expect(workflowTasks[0]).toMatchObject({ assignee_id: MANAGER_ID, step_id: "attendance_correction_approval_manager", }); expect(prisma.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "ATTENDANCE_CORRECTION_SUBMITTED", target_type: "attendance_correction", }), }), ); }); it("rejects correction submission when unified attendance flag is disabled", async () => { const { app, prisma } = buildApp(); currentApp = app; prisma.featureFlag.findUnique.mockResolvedValueOnce(buildFlag("attendance_unified_v1", "force_off")); const response = await app.inject({ method: "POST", url: "/api/attendance-corrections", payload: { kind: "missed_punch", workDate: "2026-05-12", missedPunchAt: "2026-05-12T09:00:00+08:00", reason: "企微缺卡,提交 OA 解释", }, }); expect(response.statusCode).toBe(409); }); it("limits manager correction list to own and direct reports", async () => { const outsiderId = "77777777-7777-4777-8777-777777777777"; const otherManagerId = "88888888-8888-4888-8888-888888888888"; const { app } = buildApp({ actor: { id: MANAGER_ID, department_id: DEPARTMENT_ID, permissionCodes: ["attendance.view_own", "attendance.view_all"], roleCodes: ["manager"], }, employees: [ buildEmployee(), buildEmployee({ id: MANAGER_ID, name: "经理", direct_manager_id: null }), buildEmployee({ id: outsiderId, name: "其他员工", direct_manager_id: otherManagerId }), ], corrections: [ { id: "44444444-4444-4444-8444-444444444444", owner_id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, kind: "missed_punch", status: "reviewing", work_date: new Date("2026-05-12T00:00:00.000Z"), reason: "直属下属补卡", }, { id: "99999999-9999-4999-8999-999999999999", owner_id: outsiderId, department_id: DEPARTMENT_ID, kind: "out", status: "reviewing", work_date: new Date("2026-05-12T00:00:00.000Z"), reason: "非直属员工外出", }, ], }); currentApp = app; const response = await app.inject({ method: "GET", url: "/api/attendance-corrections?month=2026-05", }); expect(response.statusCode).toBe(200); const items = response.json().data.items; expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ reason: "直属下属补卡" }); }); });