ATTV1 实现考勤统一入口与企微同步

这个提交包含在:
2026-05-17 16:56:24 +08:00
父节点 d50b7db143
当前提交 916cc3b7f7
共修改 51 个文件,包含 3323 行新增和 1762 行删除
+206 -559
查看文件
@@ -3,381 +3,190 @@ const Fastify = require("fastify");
const attendanceRoutes = require("../../../src/routes/attendance");
const { AppError } = require("../../../src/utils/AppError");
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
const MANAGER_ID = "22222222-2222-4222-8222-222222222222";
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const LEGAL_ENTITY_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
function buildEmployeeRow(overrides = {}) {
function buildEmployee(overrides = {}) {
return {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "Alice",
title: "员工",
department_id: DEPARTMENT_ID,
legal_entity_id: LEGAL_ENTITY_ID,
name: "员工一",
title: "工程师",
status: "active",
department: {
id: DEPARTMENT_ID,
name: "行政部",
},
department_id: DEPARTMENT_ID,
direct_manager_id: null,
department: { id: DEPARTMENT_ID, name: "研发部" },
wecom_identity_binding: { external_user_id: "wecom-user-1" },
...overrides,
};
}
function buildAttendanceRecord(overrides = {}) {
function buildFlag(flagKey, overrideMode = "force_on") {
return {
id: "attendance-1",
employee_id: EMPLOYEE_ID,
work_date: new Date("2026-04-09T00:00:00.000Z"),
clock_in_at: null,
clock_out_at: null,
work_minutes: null,
is_late: false,
late_minutes: 0,
is_early_leave: false,
early_minutes: 0,
note: null,
source: "web",
created_at: new Date("2026-04-09T01:00:00.000Z"),
updated_at: new Date("2026-04-09T01:00:00.000Z"),
updated_by: ACTOR_ID,
...overrides,
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 buildPolicy(overrides = {}) {
return {
id: "policy-1",
work_start_time: "09:00",
work_end_time: "18:00",
lunch_start_time: "12:00",
lunch_end_time: "13:00",
late_tolerance_minutes: 0,
enabled: true,
created_at: new Date("2026-04-01T00:00:00.000Z"),
updated_at: new Date("2026-04-01T00:00:00.000Z"),
updated_by: null,
...overrides,
};
}
function buildLeaveRow(overrides = {}) {
return {
owner_id: EMPLOYEE_ID,
start_date: new Date("2026-04-09T00:00:00.000Z"),
end_date: new Date("2026-04-09T00:00:00.000Z"),
reason: "请假",
status: "approved",
...overrides,
};
}
function buildTravelRow(overrides = {}) {
return {
applicant_employee_id: EMPLOYEE_ID,
start_date: new Date("2026-04-09T00:00:00.000Z"),
end_date: new Date("2026-04-09T00:00:00.000Z"),
purpose: "出差",
status: "approved",
...overrides,
};
}
function buildLegalEntityRow(overrides = {}) {
return {
id: LEGAL_ENTITY_ID,
default_holiday_calendar: {
id: "calendar-1",
archived_at: null,
days: [],
},
...overrides,
};
}
function sameDate(left, right) {
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
}
function projectEmployeeForFindUnique(employee, select) {
if (!employee) {
return null;
}
if (!select) {
return employee;
}
const projected = {};
for (const [key, value] of Object.entries(select)) {
if (!value) {
continue;
}
projected[key] = employee[key];
}
return projected;
}
async function buildApp(options = {}) {
function buildApp(options = {}) {
const app = Fastify();
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
error: { code: error.code, message: error.message, details: error.details ?? {} },
});
}
return reply.status(error.statusCode ?? 500).send({
error: { code: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} },
});
});
const actor = options.actor ?? {
id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
permissionCodes: [],
permissionCodes: ["attendance.view_own", "attendance.create", "attendance.submit", "attendance.sync", "attendance.view_all"],
roleCodes: ["hr"],
};
const employees = options.employees ?? [buildEmployeeRow()];
const attendanceRecords = [...(options.attendanceRecords ?? [])];
const attendancePolicies = [...(options.attendancePolicies ?? [buildPolicy()])];
const leaveRows = options.leaveRows ?? [];
const travelRows = options.travelRows ?? [];
const legalEntities = options.legalEntities ?? [buildLegalEntityRow()];
const employeeFindUnique = vi.fn(async ({ where, select }) => {
const employee = employees.find((item) => item.id === where.id);
return projectEmployeeForFindUnique(employee, select);
});
const employeeFindMany = vi.fn(async ({ where, select }) => {
const rows = employees
.filter((item) => !where?.status || item.status === where.status)
.filter((item) => !where?.department_id || item.department_id === where.department_id)
.filter((item) => !where?.id || item.id === where.id)
.map((item) => {
if (!select) {
return item;
}
return {
id: item.id,
employee_no: item.employee_no,
name: item.name,
title: item.title,
department_id: item.department_id,
legal_entity_id: item.legal_entity_id,
department: item.department,
};
});
return rows;
});
const attendanceRecordFindFirst = vi.fn(async ({ where }) =>
attendanceRecords.find((row) => {
if (where?.employee_id && row.employee_id !== where.employee_id) return false;
if (where?.work_date instanceof Date) {
return sameDate(row.work_date, where.work_date);
}
return true;
}) ?? null,
);
const attendanceRecordFindMany = vi.fn(async ({ where }) =>
attendanceRecords.filter((row) => {
if (where?.employee_id?.in && !where.employee_id.in.includes(row.employee_id)) return false;
if (where?.employee_id && typeof where.employee_id === "string" && row.employee_id !== where.employee_id) return false;
if (where?.work_date instanceof Date) {
return sameDate(row.work_date, where.work_date);
}
if (where?.work_date?.gte && row.work_date < where.work_date.gte) return false;
if (where?.work_date?.lte && row.work_date > where.work_date.lte) return false;
return true;
}),
);
const attendanceRecordCreate = vi.fn(async ({ data }) => {
const created = buildAttendanceRecord({
id: `attendance-${attendanceRecords.length + 1}`,
employee_id: data.employee_id,
work_date: data.work_date,
clock_in_at: data.clock_in_at ?? null,
clock_out_at: data.clock_out_at ?? null,
work_minutes: data.work_minutes ?? null,
is_late: data.is_late ?? false,
late_minutes: data.late_minutes ?? 0,
is_early_leave: data.is_early_leave ?? false,
early_minutes: data.early_minutes ?? 0,
note: data.note ?? null,
source: data.source ?? "web",
updated_by: data.updated_by ?? null,
updated_at: new Date(),
});
attendanceRecords.push(created);
return created;
});
const attendanceRecordUpdate = vi.fn(async ({ where, data }) => {
const index = attendanceRecords.findIndex((row) => row.id === where.id);
if (index < 0) {
throw new Error("record_not_found");
const employees = options.employees ?? [buildEmployee()];
const syncRuns = [];
const checkins = [...(options.checkins ?? [])];
const corrections = [...(options.corrections ?? [])];
const leaves = [...(options.leaves ?? [])];
const featureFlagFindUnique = vi.fn(async ({ where }) => {
if (where.flag_key === "attendance_unified_v1") {
return buildFlag("attendance_unified_v1", options.unifiedOverrideMode ?? "force_on");
}
attendanceRecords[index] = {
...attendanceRecords[index],
...data,
updated_at: new Date(),
};
return attendanceRecords[index];
});
const attendancePolicyFindFirst = vi.fn(async () => attendancePolicies[0] ?? null);
const attendancePolicyUpdate = vi.fn(async ({ where, data }) => {
const index = attendancePolicies.findIndex((item) => item.id === where.id);
if (index < 0) {
throw new Error("policy_not_found");
if (where.flag_key === "attendance_v1") {
return buildFlag("attendance_v1", "force_on");
}
attendancePolicies[index] = {
...attendancePolicies[index],
...data,
updated_at: new Date(),
};
return attendancePolicies[index];
return buildFlag(where.flag_key, "force_on");
});
const attendancePolicyCreate = vi.fn(async ({ data }) => {
const created = buildPolicy({
id: `policy-${attendancePolicies.length + 1}`,
...data,
created_at: new Date(),
updated_at: new Date(),
});
attendancePolicies.push(created);
return created;
});
const leaveFindMany = vi.fn(async ({ where }) =>
leaveRows.filter((row) => {
if (where?.owner_id?.in && !where.owner_id.in.includes(row.owner_id)) return false;
if (where?.status && row.status !== where.status) return false;
if (where?.start_date?.lte && row.start_date > where.start_date.lte) return false;
if (where?.end_date?.gte && row.end_date < where.end_date.gte) return false;
return true;
}),
);
const travelFindMany = vi.fn(async ({ where }) =>
travelRows.filter((row) => {
if (where?.applicant_employee_id?.in && !where.applicant_employee_id.in.includes(row.applicant_employee_id)) return false;
if (where?.status && row.status !== where.status) return false;
if (where?.start_date?.lte && row.start_date > where.start_date.lte) return false;
if (where?.end_date?.gte && row.end_date < where.end_date.gte) return false;
return true;
}),
);
const legalEntityFindMany = vi.fn(async ({ where }) =>
legalEntities.filter((item) => where?.id?.in?.includes(item.id)),
);
const auditCreate = vi.fn().mockResolvedValue({});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
if (!(actor.permissionCodes ?? []).includes(permissionCode)) {
app.decorate("assertEntityPermission", (employee, entityType, action) => {
const permission = `${entityType === "attendance_correction" ? "attendance" : entityType}.${action}`;
if (!(employee.permissionCodes ?? []).includes(permission) && !(employee.permissionCodes ?? []).includes("*")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission } });
}
});
app.decorate("requirePermission", (permission) => async (request) => {
await app.authenticate(request);
if (!(actor.permissionCodes ?? []).includes(permission)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("config", {
appTimezone: "Asia/Shanghai",
attendanceEnabled: true,
attendanceUnifiedEnabled: true,
wecomCheckinSyncEnabled: false,
wecomCheckinSecret: "",
leaveHolidayCalendarEnabled: false,
});
app.decorate("prisma", {
featureFlag: { findUnique: featureFlagFindUnique },
employee: {
findUnique: employeeFindUnique,
findMany: employeeFindMany,
findUnique: vi.fn(async ({ where }) => employees.find((item) => item.id === where.id) ?? null),
findMany: vi.fn(async ({ where }) =>
employees.filter((item) => {
if (where?.status && item.status !== where.status) return false;
if (where?.id?.in && !where.id.in.includes(item.id)) return false;
if (where?.OR?.length) {
return where.OR.some((condition) => {
if (condition.id && item.id === condition.id) return true;
if (condition.direct_manager_id && item.direct_manager_id === condition.direct_manager_id) return true;
return false;
});
}
return true;
}),
),
},
attendanceRecord: {
findFirst: attendanceRecordFindFirst,
findMany: attendanceRecordFindMany,
create: attendanceRecordCreate,
update: attendanceRecordUpdate,
findFirst: vi.fn(),
findMany: vi.fn(async () => []),
create: vi.fn(),
update: vi.fn(),
},
attendancePolicy: {
findFirst: attendancePolicyFindFirst,
update: attendancePolicyUpdate,
create: attendancePolicyCreate,
},
leaveRequest: {
findMany: leaveFindMany,
},
travelRequest: {
findMany: travelFindMany,
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
legalEntity: {
findMany: legalEntityFindMany,
findMany: vi.fn(async () => []),
},
auditLog: {
create: auditCreate,
travelRequest: {
findMany: vi.fn(async () => []),
},
featureFlag: {
findUnique: vi.fn().mockResolvedValue({
id: "flag-attendance",
flag_key: "attendance_v1",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-04-01T00:00:00.000Z"),
created_by: "system",
updated_at: new Date("2026-04-01T00:00:00.000Z"),
updated_by: "system",
leaveRequest: {
findMany: vi.fn(async () => leaves),
},
wecomCheckinRecord: {
findMany: vi.fn(async () => checkins),
createMany: vi.fn(async ({ data }) => {
checkins.push(...data);
return { count: data.length };
}),
},
attendanceCorrection: {
findMany: vi.fn(async () => corrections),
},
wecomCheckinSyncRun: {
create: vi.fn(async ({ data }) => {
const row = {
id: `sync-${syncRuns.length + 1}`,
created_at: new Date("2026-05-17T02:00:00.000Z"),
updated_at: new Date("2026-05-17T02:00:00.000Z"),
trigger: buildEmployee(),
...data,
};
syncRuns.push(row);
return row;
}),
update: vi.fn(async ({ where, data }) => {
const index = syncRuns.findIndex((item) => item.id === where.id);
syncRuns[index] = {
...syncRuns[index],
...data,
updated_at: new Date("2026-05-17T02:01:00.000Z"),
trigger: buildEmployee(),
};
return syncRuns[index];
}),
findMany: vi.fn(async () => syncRuns),
count: vi.fn(async () => syncRuns.length),
},
auditLog: {
create: vi.fn(async () => ({})),
},
});
await app.register(attendanceRoutes, { prefix: "/api" });
return {
app,
auditCreate,
attendancePolicies,
attendanceRecords,
employeeFindMany,
attendanceRecordCreate,
attendanceRecordUpdate,
};
app.register(attendanceRoutes, { prefix: "/api" });
return { app, prisma: app.prisma, syncRuns };
}
function countWeekdays(year, month) {
let count = 0;
const date = new Date(Date.UTC(year, month - 1, 1));
while (date.getUTCMonth() === month - 1) {
const day = date.getUTCDay();
if (day !== 0 && day !== 6) {
count += 1;
}
date.setUTCDate(date.getUTCDate() + 1);
}
return count;
}
describe("attendance routes", () => {
describe("attendance unified routes", () => {
let currentApp = null;
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
});
afterEach(async () => {
vi.useRealTimers();
if (currentApp) {
await currentApp.close();
currentApp = null;
}
});
it("keeps clock-in idempotent and does not overwrite the original timestamp", async () => {
vi.setSystemTime(new Date("2026-04-09T03:00:00.000Z"));
const originalClockIn = new Date("2026-04-09T01:02:00.000Z");
const { app, attendanceRecordCreate, attendanceRecordUpdate } = await buildApp({
attendanceRecords: [
buildAttendanceRecord({
clock_in_at: originalClockIn,
updated_at: originalClockIn,
}),
],
});
it("keeps historical OA clock-in API disabled even when legacy flag record is force_on", async () => {
const { app } = buildApp();
currentApp = app;
const response = await app.inject({
@@ -386,291 +195,129 @@ describe("attendance routes", () => {
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item.clockInAt).toBe(originalClockIn.toISOString());
expect(attendanceRecordCreate).not.toHaveBeenCalled();
expect(attendanceRecordUpdate).not.toHaveBeenCalled();
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toMatchObject({
featureFlag: "attendance_v1_disabled",
reason: "env_hard_off",
});
});
it("calculates late minutes with tolerance on clock-in", async () => {
vi.setSystemTime(new Date("2026-04-09T01:20:00.000Z"));
const { app } = await buildApp({
attendancePolicies: [buildPolicy({ late_tolerance_minutes: 15 })],
attendanceRecords: [],
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/clock-in",
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
isLate: true,
lateMinutes: 5,
});
});
it("allows repeated clock-out and recalculates work minutes", async () => {
vi.setSystemTime(new Date("2026-04-09T10:00:00.000Z"));
const { app, attendanceRecords } = await buildApp({
attendanceRecords: [
buildAttendanceRecord({
clock_in_at: new Date("2026-04-09T01:00:00.000Z"),
clock_out_at: new Date("2026-04-09T09:00:00.000Z"),
work_minutes: 420,
}),
it("returns unified monthly summary with WeCom checkin and OA correction explanation", async () => {
const { app } = buildApp({
checkins: [
{
id: "checkin-1",
employee_id: EMPLOYEE_ID,
checkin_time: new Date("2026-05-11T01:00:00.000Z"),
checkin_date: new Date("2026-05-11T00:00:00.000Z"),
checkin_type: "上班打卡",
exception_type: null,
source_type: "app",
result: null,
},
],
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/clock-out",
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item.clockOutAt).toBe("2026-04-09T10:00:00.000Z");
expect(response.json().data.item.workMinutes).toBe(480);
expect(attendanceRecords[0].clock_out_at.toISOString()).toBe("2026-04-09T10:00:00.000Z");
});
it("calculates work minutes with lunch deduction and early leave on clock-out", async () => {
vi.setSystemTime(new Date("2026-04-09T08:30:00.000Z"));
const { app } = await buildApp({
attendanceRecords: [
buildAttendanceRecord({
clock_in_at: new Date("2026-04-09T01:00:00.000Z"),
}),
],
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/clock-out",
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
workMinutes: 390,
isEarlyLeave: true,
earlyMinutes: 90,
});
});
it("marks approved leave day as no-need-clock-in and rejects self clock-in", async () => {
vi.setSystemTime(new Date("2026-04-09T01:00:00.000Z"));
const { app } = await buildApp({
attendanceRecords: [],
leaveRows: [buildLeaveRow()],
});
currentApp = app;
const todayResponse = await app.inject({
method: "GET",
url: "/api/attendance/me/today",
});
expect(todayResponse.statusCode).toBe(200);
expect(todayResponse.json().data).toMatchObject({
workDate: "2026-04-09",
needClockIn: false,
exemptionReason: "leave",
item: null,
});
const clockInResponse = await app.inject({
method: "POST",
url: "/api/attendance/clock-in",
payload: {},
});
expect(clockInResponse.statusCode).toBe(409);
});
it("treats configured legal holidays as no-need-clock-in even when leave holiday flag is off", async () => {
vi.setSystemTime(new Date("2026-05-01T01:00:00.000Z"));
const { app } = await buildApp({
legalEntities: [
buildLegalEntityRow({
default_holiday_calendar: {
id: "calendar-1",
archived_at: null,
days: [{ date: new Date("2026-05-01T00:00:00.000Z"), day_type: "holiday" }],
},
}),
corrections: [
{
id: "correction-1",
owner_id: EMPLOYEE_ID,
kind: "missed_punch",
status: "approved",
work_date: new Date("2026-05-12T00:00:00.000Z"),
missed_punch_at: new Date("2026-05-12T01:00:00.000Z"),
start_at: null,
end_at: null,
reason: "企微缺卡,已说明",
location_summary: null,
compensation_policy: null,
created_at: new Date("2026-05-12T02:00:00.000Z"),
updated_at: new Date("2026-05-12T02:00:00.000Z"),
owner: buildEmployee(),
},
],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/me/today",
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({
workDate: "2026-05-01",
needClockIn: false,
exemptionReason: "holiday",
item: null,
});
});
it("treats adjusted workdays as working days even on weekends", async () => {
vi.setSystemTime(new Date("2026-01-04T01:00:00.000Z"));
const { app } = await buildApp({
legalEntities: [
buildLegalEntityRow({
default_holiday_calendar: {
id: "calendar-1",
archived_at: null,
days: [{ date: new Date("2026-01-04T00:00:00.000Z"), day_type: "adjusted_workday" }],
},
}),
],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/me/today",
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({
workDate: "2026-01-04",
needClockIn: true,
exemptionReason: null,
item: null,
});
});
it("builds month summary without counting approved leave as missed", async () => {
vi.setSystemTime(new Date("2026-04-15T01:00:00.000Z"));
const { app } = await buildApp({
attendanceRecords: [
buildAttendanceRecord({
work_date: new Date("2026-04-01T00:00:00.000Z"),
clock_in_at: new Date("2026-04-01T01:00:00.000Z"),
clock_out_at: new Date("2026-04-01T10:00:00.000Z"),
work_minutes: 480,
}),
],
leaveRows: [
buildLeaveRow({
start_date: new Date("2026-04-02T00:00:00.000Z"),
end_date: new Date("2026-04-02T00:00:00.000Z"),
}),
],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/me/month?year=2026&month=4",
url: "/api/attendance/monthly?month=2026-05",
});
expect(response.statusCode).toBe(200);
const payload = response.json().data;
const leaveItem = payload.items.find((item) => item.workDate === "2026-04-02");
expect(leaveItem).toMatchObject({
attendanceStatus: "leave",
needClockIn: false,
});
expect(payload.summary).toMatchObject({
totalWorkDays: countWeekdays(2026, 4) - 1,
clockedDays: 1,
lateDays: 0,
earlyLeaveDays: 0,
missedDays: countWeekdays(2026, 4) - 2,
totalWorkMinutes: 480,
});
const employee = payload.employees[0];
const checkinDay = employee.days.find((item) => item.date === "2026-05-11");
const correctionDay = employee.days.find((item) => item.date === "2026-05-12");
expect(checkinDay).toMatchObject({ status: "normal", firstCheckinAt: "2026-05-11T01:00:00.000Z" });
expect(correctionDay).toMatchObject({ status: "corrected", explanation: "补卡" });
expect(correctionDay.corrections[0]).toMatchObject({ reason: "企微缺卡,已说明" });
});
it("rejects team attendance query without system.users permission", async () => {
vi.setSystemTime(new Date("2026-04-09T01:00:00.000Z"));
const { app, employeeFindMany } = await buildApp({
it("scopes manager monthly summary to self and direct reports", async () => {
const manager = buildEmployee({
id: MANAGER_ID,
employee_no: "M001",
name: "经理",
direct_manager_id: null,
wecom_identity_binding: { external_user_id: "manager-user" },
});
const directReport = buildEmployee({ direct_manager_id: MANAGER_ID });
const outsider = buildEmployee({
id: "33333333-3333-4333-8333-333333333333",
employee_no: "E999",
name: "其他员工",
direct_manager_id: "44444444-4444-4444-8444-444444444444",
wecom_identity_binding: { external_user_id: "outsider-user" },
});
const { app } = buildApp({
actor: {
id: ACTOR_ID,
id: MANAGER_ID,
department_id: DEPARTMENT_ID,
permissionCodes: [],
permissionCodes: ["attendance.view_own", "attendance.view_all"],
roleCodes: ["manager"],
},
employees: [manager, directReport, outsider],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/team",
url: "/api/attendance/monthly?month=2026-05",
});
expect(response.statusCode).toBe(403);
expect(employeeFindMany).not.toHaveBeenCalled();
expect(response.statusCode).toBe(200);
const names = response.json().data.employees.map((item) => item.employee.name);
expect(names).toEqual(expect.arrayContaining(["经理", "员工一"]));
expect(names).not.toContain("其他员工");
});
it("allows system.users to backfill attendance and writes audit log", async () => {
vi.setSystemTime(new Date("2026-04-09T09:30:00.000Z"));
const { app, auditCreate, attendanceRecords } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: DEPARTMENT_ID,
permissionCodes: ["system.users"],
},
employees: [
buildEmployeeRow(),
buildEmployeeRow({
id: OTHER_EMPLOYEE_ID,
employee_no: "E002",
name: "Bob",
}),
],
});
it("records WeCom sync as skipped when checkin secret or API permission is not configured", async () => {
const { app, prisma } = buildApp();
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/admin/backfill",
url: "/api/attendance/wecom/sync",
payload: {
employeeId: OTHER_EMPLOYEE_ID,
workDate: "2026-04-09",
clockInAt: "2026-04-09T01:00:00.000Z",
clockOutAt: "2026-04-09T10:00:00.000Z",
note: "补卡原因",
startDate: "2026-05-11",
endDate: "2026-05-12",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
employeeId: OTHER_EMPLOYEE_ID,
source: "admin",
workMinutes: 480,
expect(response.json().data).toMatchObject({
skipped: true,
code: "wecom_checkin_sync_disabled",
employeeCount: 1,
boundUserCount: 1,
recordCount: 0,
});
expect(attendanceRecords).toHaveLength(1);
expect(auditCreate).toHaveBeenCalledWith(
expect(prisma.wecomCheckinSyncRun.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "ATTENDANCE_ADMIN_BACKFILLED",
extra: expect.objectContaining({
employeeId: OTHER_EMPLOYEE_ID,
workDate: "2026-04-09",
}),
status: "skipped",
error_code: "wecom_checkin_sync_disabled",
}),
}),
);
});
});