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