文件
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

488 行
16 KiB
JavaScript

const Fastify = require("fastify");
const attendanceRoutes = require("../../../src/routes/attendance");
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";
const originalFetch = global.fetch;
function buildEmployee(overrides = {}) {
return {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "员工一",
title: "工程师",
status: "active",
department_id: DEPARTMENT_ID,
direct_manager_id: null,
department: { id: DEPARTMENT_ID, name: "研发部" },
wecom_identity_binding: { external_user_id: "wecom-user-1" },
...overrides,
};
}
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 buildJsonResponse(payload, overrides = {}) {
return {
ok: overrides.ok ?? true,
status: overrides.status ?? 200,
json: async () => payload,
};
}
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: ["attendance.view_own", "attendance.create", "attendance.submit", "attendance.sync", "attendance.view_all"],
roleCodes: ["hr"],
};
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");
}
if (where.flag_key === "attendance_v1") {
return buildFlag("attendance_v1", "force_on");
}
return buildFlag(where.flag_key, "force_on");
});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
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,
...options.config,
});
app.decorate("prisma", {
featureFlag: { findUnique: featureFlagFindUnique },
employee: {
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: vi.fn(),
findMany: vi.fn(async () => []),
create: vi.fn(),
update: vi.fn(),
},
attendancePolicy: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
legalEntity: {
findMany: vi.fn(async () => []),
},
travelRequest: {
findMany: vi.fn(async () => []),
},
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 () => ({})),
},
});
app.register(attendanceRoutes, { prefix: "/api" });
return { app, prisma: app.prisma, syncRuns };
}
describe("attendance unified routes", () => {
let currentApp = null;
afterEach(async () => {
global.fetch = originalFetch;
if (currentApp) {
await currentApp.close();
currentApp = null;
}
});
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({
method: "POST",
url: "/api/attendance/clock-in",
payload: {},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toMatchObject({
featureFlag: "attendance_v1_disabled",
reason: "env_hard_off",
});
});
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,
},
],
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/monthly?month=2026-05",
});
expect(response.statusCode).toBe(200);
const payload = response.json().data;
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("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: MANAGER_ID,
department_id: DEPARTMENT_ID,
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/monthly?month=2026-05",
});
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("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/wecom/sync",
payload: {
startDate: "2026-05-11",
endDate: "2026-05-12",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({
skipped: true,
code: "wecom_checkin_sync_disabled",
employeeCount: 1,
boundUserCount: 1,
recordCount: 0,
});
expect(prisma.wecomCheckinSyncRun.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "skipped",
error_code: "wecom_checkin_sync_disabled",
}),
}),
);
});
it("records WeCom sync as skipped when no authorized app secret is configured", async () => {
const { app, prisma } = buildApp({
config: {
wecomCheckinSyncEnabled: true,
wecomCorpId: "corp-id",
wecomAgentId: "1000001",
wecomCheckinSecret: "",
wecomAgentSecret: "",
},
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/wecom/sync",
payload: {
startDate: "2026-05-11",
endDate: "2026-05-12",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({
skipped: true,
code: "wecom_checkin_secret_missing",
employeeCount: 1,
boundUserCount: 1,
recordCount: 0,
});
expect(prisma.wecomCheckinSyncRun.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "skipped",
error_code: "wecom_checkin_secret_missing",
}),
}),
);
});
it("uses the authorized workbench app secret fallback for WeCom checkin sync", async () => {
global.fetch = vi
.fn()
.mockResolvedValueOnce(buildJsonResponse({ errcode: 0, access_token: "token-from-workbench" }))
.mockResolvedValueOnce(
buildJsonResponse({
errcode: 0,
checkindata: [
{
userid: "wecom-user-1",
checkin_time: 1778461200,
checkin_type: "上班打卡",
exception_type: "",
source_type: "app",
result: "正常",
},
],
}),
);
const { app, prisma } = buildApp({
config: {
wecomCheckinSyncEnabled: true,
wecomCorpId: "corp-id",
wecomAgentId: "1000001",
wecomCheckinSecret: "",
wecomAgentSecret: "workbench-secret",
},
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/wecom/sync",
payload: {
startDate: "2026-05-11",
endDate: "2026-05-12",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({
skipped: false,
code: "wecom_checkin_fetched",
employeeCount: 1,
boundUserCount: 1,
recordCount: 1,
});
expect(String(global.fetch.mock.calls[0][0])).toContain("corpsecret=workbench-secret");
expect(String(global.fetch.mock.calls[1][0])).toContain("/cgi-bin/checkin/getcheckindata");
expect(prisma.wecomCheckinRecord.createMany).toHaveBeenCalledWith(
expect.objectContaining({
data: [
expect.objectContaining({
employee_id: EMPLOYEE_ID,
external_user_id: "wecom-user-1",
checkin_type: "上班打卡",
result: "正常",
}),
],
}),
);
expect(prisma.wecomCheckinSyncRun.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "success",
record_count: 1,
error_code: null,
}),
}),
);
});
});
describe("unified attendance month guards", () => {
it.each(["2026-00", "2026-13", "2026-1", ""])("rejects month=%s before business reads or sync", async (month) => {
const { app, prisma } = buildApp();
const fetchSpy = vi.fn(() => { throw new Error("no external access in month tests"); });
global.fetch = fetchSpy;
try {
const response = await app.inject({ method: "GET", url: `/api/attendance/monthly?month=${month}` });
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("VALID_001");
expect(response.json().error.details.reasonCode).toBe(["2026-00", "2026-13"].includes(month) ? "attendance.month_invalid" : "attendance.month_query_invalid");
if (["2026-00", "2026-13"].includes(month)) expect(response.json().error.details).toEqual({ month: "invalid", reasonCode: "attendance.month_invalid" });
for (const [name, group] of Object.entries(prisma)) {
if (name === "featureFlag") continue;
if (typeof group === "function") expect(group).not.toHaveBeenCalled();
else for (const fn of Object.values(group)) expect(fn).not.toHaveBeenCalled();
}
expect(fetchSpy).not.toHaveBeenCalled();
} finally { global.fetch = originalFetch; await app.close(); }
});
it.each(["2024-01", "2024-02", "2024-12"])("keeps the selected valid month %s", async (month) => {
const { app, prisma } = buildApp();
try {
const response = await app.inject({ method: "GET", url: `/api/attendance/monthly?month=${month}` });
expect(response.statusCode).toBe(200);
expect(response.json().data.month).toBe(month);
expect(prisma.wecomCheckinRecord.findMany).toHaveBeenCalledOnce();
} finally { await app.close(); }
});
it("keeps the omitted month default in the configured timezone", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2024-02-29T17:00:00.000Z"));
const { app } = buildApp({ employees: [] });
try {
const response = await app.inject({ method: "GET", url: "/api/attendance/monthly" });
expect(response.statusCode).toBe(200);
expect(response.json().data.month).toBe("2024-03");
} finally { vi.useRealTimers(); await app.close(); }
});
});