文件
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

529 行
22 KiB
JavaScript

const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const featureFlagState = vi.hoisted(() => ({ enabled: true }));
vi.mock("../../../src/services/featureFlags", () => ({
FEATURE_FLAG_KEYS: { announcements: "announcements_v1" },
evaluateFeatureFlag: vi.fn(async () => ({
enabled: featureFlagState.enabled,
reason: featureFlagState.enabled ? "mock_on" : "mock_off",
})),
}));
const announcementsRoutes = require("../../../src/routes/announcements");
const ADMIN_ID = "00000000-0000-4000-8000-000000000001";
const EMPLOYEE_ID = "00000000-0000-4000-8000-000000000002";
const EMPLOYEE_2_ID = "00000000-0000-4000-8000-000000000003";
const INACTIVE_EMPLOYEE_ID = "00000000-0000-4000-8000-000000000004";
const POLICY_ID = "10000000-0000-4000-8000-000000000001";
const GENERAL_ID = "10000000-0000-4000-8000-000000000002";
const HIDDEN_ID = "10000000-0000-4000-8000-000000000003";
const TEST_FUTURE_OFFSET_MS = 24 * 60 * 60 * 1000;
function buildFutureDate() {
return new Date(Date.now() + TEST_FUTURE_OFFSET_MS);
}
function buildEmployee(overrides = {}) {
return {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "员工一",
status: "active",
permissionCodes: ["expense.view_own"],
...overrides,
};
}
function buildAnnouncement(overrides = {}) {
const now = new Date("2026-05-28T02:00:00.000Z");
return {
id: POLICY_ID,
title: "费用制度签收",
summary: "请确认费用制度 V1。",
link_url: null,
starts_at: null,
ends_at: null,
enabled: true,
priority: 10,
category: "policy",
requires_acknowledgement: true,
acknowledgement_statement: "我已阅读并理解该制度。",
acknowledgement_due_at: buildFutureDate(),
created_at: now,
created_by: ADMIN_ID,
updated_at: now,
updated_by: ADMIN_ID,
...overrides,
};
}
function cloneWithInclude(item, include, acknowledgements) {
const row = { ...item };
if (include?.acknowledgements) {
let rows = acknowledgements.filter((acknowledgement) => acknowledgement.announcement_id === item.id);
if (include.acknowledgements.where?.employee_id) {
rows = rows.filter((acknowledgement) => acknowledgement.employee_id === include.acknowledgements.where.employee_id);
}
row.acknowledgements = rows.map((acknowledgement) => ({
id: acknowledgement.id,
employee_id: acknowledgement.employee_id,
acknowledged_at: acknowledgement.acknowledged_at,
}));
}
return row;
}
function matchAnnouncementWhere(item, where = {}) {
if (where.enabled !== undefined && item.enabled !== where.enabled) {
return false;
}
const startsAtUpperBound = where.OR?.find((clause) => clause.starts_at?.lte)?.starts_at.lte ?? new Date();
const endsAtLowerBound = where.AND?.[0]?.OR?.find((clause) => clause.ends_at?.gte)?.ends_at.gte ?? new Date();
if (where.OR && item.starts_at && item.starts_at > startsAtUpperBound) {
return false;
}
if (where.AND && item.ends_at && item.ends_at < endsAtLowerBound) {
return false;
}
return true;
}
function createPrismaState(options = {}) {
const employees = options.employees ?? [
buildEmployee({ id: EMPLOYEE_ID, employee_no: "E001", name: "员工一" }),
buildEmployee({ id: EMPLOYEE_2_ID, employee_no: "E002", name: "员工二" }),
buildEmployee({ id: INACTIVE_EMPLOYEE_ID, employee_no: "E003", name: "离职员工", status: "archived" }),
];
const announcements = options.announcements ?? [
buildAnnouncement(),
buildAnnouncement({
id: GENERAL_ID,
title: "普通公告",
summary: "无需签收。",
category: "general",
requires_acknowledgement: false,
acknowledgement_statement: null,
acknowledgement_due_at: null,
priority: 20,
}),
buildAnnouncement({
id: HIDDEN_ID,
title: "未来公告",
starts_at: buildFutureDate(),
}),
];
const acknowledgements = options.acknowledgements ?? [];
const auditLogs = [];
const portalAnnouncement = {
findMany: vi.fn(async ({ where, include }) =>
announcements
.filter((item) => matchAnnouncementWhere(item, where))
.sort((left, right) => left.priority - right.priority || right.created_at.getTime() - left.created_at.getTime())
.map((item) => cloneWithInclude(item, include, acknowledgements)),
),
findUnique: vi.fn(async ({ where, include }) => {
const item = announcements.find((announcement) => announcement.id === where.id);
return item ? cloneWithInclude(item, include, acknowledgements) : null;
}),
create: vi.fn(async ({ data }) => {
const created = buildAnnouncement({
...data,
id: `10000000-0000-4000-8000-${String(announcements.length + 10).padStart(12, "0")}`,
link_url: data.link_url ?? null,
starts_at: data.starts_at ?? null,
ends_at: data.ends_at ?? null,
created_at: new Date("2026-05-28T02:00:00.000Z"),
updated_at: new Date("2026-05-28T02:00:00.000Z"),
});
announcements.push(created);
return created;
}),
update: vi.fn(async ({ where, data }) => {
const index = announcements.findIndex((announcement) => announcement.id === where.id);
if (index < 0) {
throw new Error("not_found");
}
announcements[index] = { ...announcements[index], ...data, updated_at: new Date("2026-05-28T03:00:00.000Z") };
return announcements[index];
}),
};
const portalAnnouncementAcknowledgement = {
count: vi.fn(async ({ where }) =>
acknowledgements.filter((acknowledgement) => acknowledgement.announcement_id === where.announcement_id).length,
),
create: vi.fn(async ({ data }) => {
const duplicated = acknowledgements.find(
(acknowledgement) =>
acknowledgement.announcement_id === data.announcement_id &&
acknowledgement.employee_id === data.employee_id,
);
if (duplicated) {
const error = new Error("duplicate_acknowledgement");
error.code = "P2002";
throw error;
}
const created = {
id: `20000000-0000-4000-8000-${String(acknowledgements.length + 1).padStart(12, "0")}`,
acknowledged_at: new Date("2026-05-28T02:30:00.000Z"),
created_at: new Date("2026-05-28T02:30:00.000Z"),
...data,
};
acknowledgements.push(created);
return created;
}),
findUnique: vi.fn(async ({ where }) => {
const compound = where.announcement_id_employee_id;
if (!compound) return null;
return acknowledgements.find(
(acknowledgement) =>
acknowledgement.announcement_id === compound.announcement_id &&
acknowledgement.employee_id === compound.employee_id,
) ?? null;
}),
};
const auditLog = {
create: vi.fn(async ({ data }) => {
auditLogs.push(data);
return { id: `audit-${auditLogs.length}`, ...data };
}),
};
const prisma = {
portalAnnouncement,
portalAnnouncementAcknowledgement,
employee: {
findMany: vi.fn(async ({ where }) => employees.filter((employee) => employee.status === where.status)),
},
auditLog,
featureFlag: {
findUnique: vi.fn(async () => ({
id: "flag-announcements",
flag_key: "announcements_v1",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-05-28T00:00:00.000Z"),
created_by: null,
updated_at: new Date("2026-05-28T00:00:00.000Z"),
updated_by: null,
})),
},
$transaction: vi.fn(async (callback) => callback({ portalAnnouncementAcknowledgement, auditLog })),
};
return { prisma, employees, announcements, acknowledgements, auditLogs };
}
async function buildApp(options = {}) {
const state = createPrismaState(options);
const actor = options.actor ?? buildEmployee({ id: EMPLOYEE_ID, permissionCodes: ["expense.view_own"] });
const app = Fastify({ logger: false });
app.decorate("config", { announcementsEnabled: options.enabled ?? true });
app.decorate("prisma", state.prisma);
app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes(permissionCode));
app.decorate("assertPermission", (employee, permissionCode) => {
if (!app.hasPermission(employee, permissionCode)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { permission: permissionCode },
});
}
});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
await app.authenticate(request);
app.assertPermission(request.employee, permissionCode);
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: { code: error.code, message: error.message, details: error.details },
});
}
return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001", message: error.message, details: {} } });
});
await app.register(announcementsRoutes, { prefix: "/api" });
return { app, state };
}
beforeEach(() => {
featureFlagState.enabled = true;
});
describe("announcements policy acknowledgement routes", () => {
it("rejects policy acknowledgement announcement without statement", async () => {
const { app } = await buildApp({
actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }),
});
const response = await app.inject({
method: "POST",
url: "/api/announcements",
payload: {
title: "制度公告",
summary: "缺少签收声明。",
category: "policy",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details.acknowledgementStatement).toBe("required_for_policy_acknowledgement");
expect(response.json().error.details.reasonCode).toBe("announcement.statement_required");
await app.close();
});
it("returns only current employee acknowledgement status for normal list", async () => {
const { app } = await buildApp();
const response = await app.inject({ method: "GET", url: "/api/announcements" });
expect(response.statusCode).toBe(200);
const item = response.json().data.items.find((announcement) => announcement.id === POLICY_ID);
expect(item.acknowledgement).toMatchObject({ status: "pending", acknowledgedAt: null, overdue: false });
expect(item.acknowledgementSummary).toBeUndefined();
expect(item.pendingEmployees).toBeUndefined();
await app.close();
});
it("creates acknowledgement once and writes audit log idempotently", async () => {
const { app, state } = await buildApp();
const first = await app.inject({
method: "POST",
url: `/api/announcements/${POLICY_ID}/acknowledge`,
payload: { confirmed: true },
});
const second = await app.inject({
method: "POST",
url: `/api/announcements/${POLICY_ID}/acknowledge`,
payload: { confirmed: true },
});
expect(first.statusCode).toBe(201);
expect(second.statusCode).toBe(200);
expect(state.acknowledgements).toHaveLength(1);
expect(state.acknowledgements[0]).toMatchObject({
announcement_id: POLICY_ID,
employee_id: EMPLOYEE_ID,
statement_snapshot: "我已阅读并理解该制度。",
title_snapshot: "费用制度签收",
});
expect(state.auditLogs).toHaveLength(1);
expect(state.auditLogs[0]).toMatchObject({
action: "PORTAL_ANNOUNCEMENT_ACKNOWLEDGED",
target_type: "portal_announcement_acknowledgement",
});
await app.close();
});
it("rejects acknowledgement for general or invisible announcements", async () => {
const { app } = await buildApp();
const general = await app.inject({
method: "POST",
url: `/api/announcements/${GENERAL_ID}/acknowledge`,
payload: { confirmed: true },
});
const hidden = await app.inject({
method: "POST",
url: `/api/announcements/${HIDDEN_ID}/acknowledge`,
payload: { confirmed: true },
});
expect(general.statusCode).toBe(409);
expect(general.json().error.details.acknowledgement).toBe("not_required");
expect(general.json().error.details.reasonCode).toBe("announcement.acknowledgement_not_required");
expect(hidden.statusCode).toBe(404);
await app.close();
});
it("returns admin acknowledgement summary and minimal pending employees only", async () => {
const { app } = await buildApp({
actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }),
acknowledgements: [
{
id: "20000000-0000-4000-8000-000000000099",
announcement_id: POLICY_ID,
employee_id: EMPLOYEE_ID,
actor_id: EMPLOYEE_ID,
acknowledged_at: new Date("2026-05-28T01:00:00.000Z"),
statement_snapshot: "我已阅读并理解该制度。",
title_snapshot: "费用制度签收",
summary_snapshot: "请确认费用制度 V1。",
created_at: new Date("2026-05-28T01:00:00.000Z"),
},
],
});
const response = await app.inject({ method: "GET", url: "/api/announcements?manage=1" });
expect(response.statusCode).toBe(200);
const item = response.json().data.items.find((announcement) => announcement.id === POLICY_ID);
expect(item.acknowledgementSummary).toMatchObject({
required: true,
totalActiveEmployees: 2,
acknowledgedCount: 1,
pendingCount: 1,
overdueCount: 0,
});
expect(item.pendingEmployees).toEqual([
{ id: EMPLOYEE_2_ID, employeeNo: "E002", name: "员工二", status: "pending" },
]);
expect(JSON.stringify(item.pendingEmployees)).not.toMatch(/phone|email|wecom/i);
await app.close();
});
it("denies manage query for employee without system settings permission", async () => {
const { app } = await buildApp();
const response = await app.inject({ method: "GET", url: "/api/announcements?manage=1" });
expect(response.statusCode).toBe(403);
await app.close();
});
it("rejects protected policy content edits after acknowledgement exists", async () => {
const { app } = await buildApp({
actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }),
acknowledgements: [
{
id: "20000000-0000-4000-8000-000000000099",
announcement_id: POLICY_ID,
employee_id: EMPLOYEE_ID,
actor_id: EMPLOYEE_ID,
acknowledged_at: new Date("2026-05-28T01:00:00.000Z"),
statement_snapshot: "我已阅读并理解该制度。",
title_snapshot: "费用制度签收",
summary_snapshot: "请确认费用制度 V1。",
created_at: new Date("2026-05-28T01:00:00.000Z"),
},
],
});
const response = await app.inject({
method: "PATCH",
url: `/api/announcements/${POLICY_ID}`,
payload: { title: "费用制度签收 V2" },
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.acknowledgement).toBe("create_new_version_after_acknowledgement");
expect(response.json().error.details.reasonCode).toBe("announcement.acknowledged_content_locked");
await app.close();
});
});
describe("announcement time and reason contracts", () => {
const admin = buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] });
const fields = ["startsAt", "endsAt", "acknowledgementDueAt"];
const invalid = ["2026-02-29T12:00:00Z", "2024-02-30T08:00:00+08:00", "2026-04-31T23:59", "2026-13-01T00:00:00Z", "2026-01-01Tgarbage", "2026-01-01T25:00:00Z", "2026-01-01T12:60:00Z", "2026-01-01T"];
function expectNoBusinessCalls(state) {
for (const group of [state.prisma.portalAnnouncement, state.prisma.portalAnnouncementAcknowledgement, state.prisma.employee, state.prisma.auditLog]) {
for (const fn of Object.values(group)) expect(fn).not.toHaveBeenCalled();
}
expect(state.prisma.$transaction).not.toHaveBeenCalled();
}
it.each(["POST", "PATCH"].flatMap((method) => fields.flatMap((field) => invalid.map((value) => [method, field, value]))))("rejects %s %s=%s before business access", async (method, field, value) => {
const { app, state } = await buildApp({ actor: admin });
try {
const response = await app.inject({ method, url: method === "POST" ? "/api/announcements" : `/api/announcements/${POLICY_ID}`, payload: { title: "合成公告", summary: "合成内容", [field]: value } });
expect(response.statusCode).toBe(400);
expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: method === "POST" ? "announcement.create_input_invalid" : "announcement.update_input_invalid", fieldErrors: { [field]: ["invalid_calendar_datetime"] } } });
expectNoBusinessCalls(state);
} finally { await app.close(); }
});
it.each(["2024-02-29T23:59:59.123Z", "2024-02-29T23:30:00-12:00", "2024-03-01T00:30:00+08:00", "2024-04-30T12:34", "2024-04-30T12:34:56.789", "2024-04-30T24:00:00Z"])("retains valid offset/local/rollover compatibility: %s", async (value) => {
const { app, state } = await buildApp({ actor: admin });
try {
const response = await app.inject({ method: "POST", url: "/api/announcements", payload: { title: "合成公告", summary: "合成内容", category: "policy", acknowledgementStatement: "合成签收声明", ...Object.fromEntries(fields.map((field) => [field, value])) } });
expect(response.statusCode).toBe(201);
const data = state.prisma.portalAnnouncement.create.mock.calls[0][0].data;
for (const field of ["starts_at", "ends_at", "acknowledgement_due_at"]) expect(data[field]).toEqual(new Date(value));
const patched = await app.inject({ method: "PATCH", url: `/api/announcements/${POLICY_ID}`, payload: Object.fromEntries(fields.map((field) => [field, value])) });
expect(patched.statusCode).toBe(200);
const patchData = state.prisma.portalAnnouncement.update.mock.calls[0][0].data;
for (const field of ["starts_at", "ends_at", "acknowledgement_due_at"]) expect(patchData[field]).toEqual(new Date(value));
} finally { await app.close(); }
});
it("preserves null clearing and omitted timestamps", async () => {
const { app, state } = await buildApp({ actor: admin });
try {
const created = await app.inject({ method: "POST", url: "/api/announcements", payload: { title: "合成公告", summary: "合成内容" } });
expect(created.statusCode).toBe(201);
expect(state.prisma.portalAnnouncement.create.mock.calls[0][0].data).toMatchObject({ starts_at: null, ends_at: null, acknowledgement_due_at: null });
const currentDue = state.announcements.find((item) => item.id === POLICY_ID).acknowledgement_due_at;
const omitted = await app.inject({ method: "PATCH", url: `/api/announcements/${POLICY_ID}`, payload: { priority: 11 } });
expect(omitted.statusCode).toBe(200);
expect(state.prisma.portalAnnouncement.update.mock.calls[0][0].data).not.toHaveProperty("starts_at");
expect(state.prisma.portalAnnouncement.update.mock.calls[0][0].data.acknowledgement_due_at).toEqual(currentDue);
const cleared = await app.inject({ method: "PATCH", url: `/api/announcements/${POLICY_ID}`, payload: { startsAt: null, endsAt: null, acknowledgementDueAt: null } });
expect(cleared.statusCode).toBe(200);
expect(state.prisma.portalAnnouncement.update.mock.calls[1][0].data).toMatchObject({ starts_at: null, ends_at: null, acknowledgement_due_at: null });
} finally { await app.close(); }
});
it.each([
["GET", "/api/announcements?manage=invalid", undefined, "announcement.query_invalid"],
["PATCH", "/api/announcements/invalid", { enabled: false }, "announcement.id_invalid"],
["PATCH", `/api/announcements/${POLICY_ID}`, {}, "announcement.update_input_invalid"],
["POST", `/api/announcements/${POLICY_ID}/acknowledge`, { confirmed: false }, "announcement.acknowledgement_input_invalid"],
])("explains %s %s before reads/writes", async (method, url, payload, reasonCode) => {
const { app, state } = await buildApp({ actor: admin });
try {
const response = await app.inject({ method, url, ...(payload ? { payload } : {}) });
expect(response.statusCode).toBe(400);
expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode } });
expectNoBusinessCalls(state);
} finally { await app.close(); }
});
it("explains the disabled feature before business access", async () => {
const { app, state } = await buildApp({ actor: admin, enabled: false });
try {
const response = await app.inject({ method: "GET", url: "/api/announcements" });
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ reasonCode: "announcement.disabled", featureFlag: "announcements_v1_disabled", reason: "env_hard_off" });
expectNoBusinessCalls(state);
} finally { await app.close(); }
});
it("explains a legacy policy missing its statement without acknowledgement writes", async () => {
const { app, state } = await buildApp({ announcements: [buildAnnouncement({ acknowledgement_statement: null })] });
try {
const response = await app.inject({ method: "POST", url: `/api/announcements/${POLICY_ID}/acknowledge`, payload: { confirmed: true } });
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ reasonCode: "announcement.statement_missing", acknowledgementStatement: "missing_for_policy_acknowledgement" });
expect(state.prisma.$transaction).not.toHaveBeenCalled();
expect(state.prisma.portalAnnouncementAcknowledgement.create).not.toHaveBeenCalled();
expect(state.prisma.auditLog.create).not.toHaveBeenCalled();
} finally { await app.close(); }
});
it("retains permission guards with valid dates", async () => {
const { app, state } = await buildApp();
try {
const response = await app.inject({ method: "POST", url: "/api/announcements", payload: { title: "合成公告", summary: "合成内容", startsAt: "2024-02-29T00:00:00Z" } });
expect(response.statusCode).toBe(403);
expectNoBusinessCalls(state);
} finally { await app.close(); }
});
});