ATTV1 实现考勤统一入口与企微同步
这个提交包含在:
@@ -0,0 +1,331 @@
|
||||
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: "直属下属补卡" });
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户