feat: 补齐报销付款待办交接(09-11-reimbursement-payment-handoffs)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-11 09:43:37 +08:00
父节点 f9aee4c932
当前提交 fedcaed1a1
共修改 15 个文件,包含 486 行新增和 15 行删除
+3
查看文件
@@ -7,6 +7,7 @@ const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { getWorkspaceSetting } = require("../services/workspaceSettings");
const { currentDateString, parseDateOnlyString } = require("../utils/date");
const { loadReimbursementPaymentHandoffs } = require("../services/reimbursementPaymentHandoffs");
const PORTAL_RECENT_ACTIVITY_LIMIT = 15;
const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24;
@@ -455,6 +456,7 @@ async function portalRoutes(fastify) {
await ensureFeatureEnabled(fastify, request);
const workspace = await getWorkspaceSetting(fastify.prisma);
const businessHandoffs = await loadReimbursementPaymentHandoffs(fastify, request.employee);
const today = currentDateString(fastify.config.appTimezone);
const currentWeekStart = resolveWeekStart(today);
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
@@ -761,6 +763,7 @@ async function portalRoutes(fastify) {
{ domain: "reimbursement", count: reimbursementsCount, href: "/reimbursements" },
{ domain: "leave", count: leaveCount, href: "/leave-requests" },
],
businessHandoffs,
riskSummary: riskCount > 0 ? { openCount: riskCount, href: "/ap-operations" } : null,
announcements: announcementRows.map((item) => ({
id: item.id,
@@ -0,0 +1,92 @@
const { evaluateFeatureFlag, FEATURE_FLAG_KEYS } = require("./featureFlags");
const { buildPaymentProcessingSemantics } = require("./paymentProcessingProgress");
async function loadReimbursementPaymentHandoffs(fastify, employee) {
return fastify.prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe("SET TRANSACTION READ ONLY");
const items = [];
const canManage = fastify.hasPermission(employee, "payment.batch.manage");
const canReview = fastify.hasPermission(employee, "payment.batch.review")
&& (employee.roleCodes ?? []).some((role) => ["manager", "ceo"].includes(role));
if (fastify.hasPermission(employee, "expense.submit") && fastify.hasPermission(employee, "expense.view_own")) {
const returned = await tx.reimbursementForm.findMany({
where: { deleted_at: null, paid_at: null, status: { in: ["draft", "rejected"] }, OR: [{ owner_id: employee.id }, { submitter_id: employee.id }] },
select: { id: true, form_number: true, workflow_instances: {
orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 1,
select: { workflow_history: { orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 1, select: { action: true } } },
} },
orderBy: [{ updated_at: "asc" }, { id: "asc" }],
});
for (const form of returned) {
if (!["return", "reject"].includes(form.workflow_instances[0]?.workflow_history[0]?.action)) continue;
items.push({ key: `reimbursement-edit:${form.id}`, kind: "reimbursement_edit", reference: form.form_number,
title: "报销退回待修改", nextStep: "修改后重新提交,也可删除后重新填报", ownerLabel: "报销人或代填人",
href: `/reimbursements/${form.id}`, mobileHref: `/mobile/reimbursements/${form.id}` });
}
}
if (!canManage && !canReview) return { total: items.length, items };
const gate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.paymentBatch, strictDepartment: false });
if (!gate.enabled) return { total: items.length, items };
const departmentGates = new Map();
const departmentEnabled = async (departmentId) => {
const key = departmentId ?? null;
if (!departmentGates.has(key)) departmentGates.set(key, evaluateFeatureFlag({
prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.paymentBatch, departmentId: key, strictDepartment: true,
}).then((result) => result.enabled));
return departmentGates.get(key);
};
if (canManage) {
const approved = await tx.reimbursementForm.findMany({
where: { status: "approved", paid_at: null, deleted_at: null },
select: { id: true, form_number: true, department_id: true },
orderBy: [{ updated_at: "asc" }, { id: "asc" }],
});
const reservations = approved.length ? await tx.paymentBatchItem.findMany({
where: { entity_id: { in: approved.map((form) => form.id) }, payment_batch: { entity_type: "reimbursement", status: { not: "cancelled" } } },
select: { entity_id: true },
}) : [];
const reserved = new Set(reservations.map((item) => item.entity_id));
for (const form of approved) {
if (reserved.has(form.id) || !await departmentEnabled(form.department_id)) continue;
items.push({ key: `reimbursement-batch:${form.id}`, kind: "reimbursement_batch", reference: form.form_number,
title: "报销待组建付款批次", nextStep: "核对付款资料后组批,资料缺项由付款页说明", ownerLabel: "财务",
href: `/reimbursements/${form.id}`, mobileHref: `/mobile/reimbursements/${form.id}` });
}
}
const batches = await tx.paymentBatch.findMany({
where: { entity_type: "reimbursement", OR: [
...(canReview ? [{ status: "draft", reviewed_at: null, created_by: { not: employee.id } }] : []),
...(canManage ? [{ status: { in: ["reviewed", "failed"] } }] : []),
] },
select: { id: true, batch_no: true, status: true, created_by: true, payment_order_export_count: true, default_payment_channel: true,
items: { select: { entity_id: true, status: true, payment_channel_override: true } },
_count: { select: { receipt_attachments: { where: { archived_at: null } } } },
},
orderBy: [{ created_at: "asc" }, { id: "asc" }],
});
const sourceIds = [...new Set(batches.flatMap((batch) => batch.items.map((item) => item.entity_id)))];
const sources = sourceIds.length ? await tx.reimbursementForm.findMany({
where: { id: { in: sourceIds }, deleted_at: null }, select: { id: true, department_id: true },
}) : [];
const sourceDepartments = new Map(sources.map((source) => [source.id, source.department_id]));
for (const batch of batches) {
if (!batch.items.length || !batch.created_by) continue;
if (batch.status === "draft" && batch.items.some((item) => item.status !== "pending")) continue;
const readable = await Promise.all(batch.items.map((item) => sourceDepartments.has(item.entity_id) && departmentEnabled(sourceDepartments.get(item.entity_id))));
if (readable.some((enabled) => !enabled)) continue;
const review = batch.status === "draft";
const semantics = buildPaymentProcessingSemantics({ status: batch.status,
requiresBankReceipt: batch.items.some((item) => ["pending", "failed"].includes(item.status)
&& (item.payment_channel_override ?? batch.default_payment_channel ?? "bank") === "bank"),
paymentOrderExportCount: batch.payment_order_export_count, receiptCount: batch._count.receipt_attachments,
});
items.push({ key: `payment-${review ? "review" : "execute"}:${batch.id}`, kind: review ? "payment_review" : "payment_execute", reference: batch.batch_no,
title: review ? "付款批次待复核" : "付款与回单待处理", nextStep: (review ? semantics.review : semantics.payment).detail,
ownerLabel: review ? "非制单复核人" : "财务", href: `/payment-batches?id=${batch.id}`, mobileHref: `/mobile/payment-batches?id=${batch.id}` });
}
const unique = [...new Map(items.map((item) => [item.key, item])).values()];
return { total: unique.length, items: unique };
}, { isolationLevel: "RepeatableRead", maxWait: 5000, timeout: 10000 });
}
module.exports = { loadReimbursementPaymentHandoffs };
+4
查看文件
@@ -177,6 +177,7 @@ async function buildApp(options = {}) {
},
reimbursementForm: {
count: vi.fn(async () => 2),
findMany: vi.fn(async () => []),
},
leaveRequest: {
count: vi.fn(async () => 1),
@@ -192,6 +193,7 @@ async function buildApp(options = {}) {
},
paymentBatch: {
count: vi.fn(async () => options.pendingFinanceBatchCount ?? 0),
findMany: vi.fn(async () => []),
},
paymentBatchItem: {
count: vi.fn(async () => options.failedFinanceItemCount ?? 0),
@@ -293,6 +295,8 @@ async function buildApp(options = {}) {
};
const app = Fastify();
prisma.$executeRawUnsafe = vi.fn(async () => 0);
prisma.$transaction = vi.fn(async (work) => work(prisma));
app.decorate("prisma", prisma);
app.decorate("config", {
appTimezone: "Asia/Shanghai",
@@ -0,0 +1,91 @@
const { loadReimbursementPaymentHandoffs } = require("../../../src/services/reimbursementPaymentHandoffs");
const gates = { global: true, disabled: [] };
function setup({ permissions = [], roles = [], returned = [], approved = [], reservations = [], batches = [], sources = [] } = {}) {
const tx = {
$executeRawUnsafe: vi.fn(async () => 0),
featureFlag: { findUnique: vi.fn(async () => ({ override_mode: gates.global ? "force_on" : "force_off",
rollout_type: gates.disabled.length ? "department_allowlist" : "global", department_ids: gates.disabled.length ? ["free"] : [],
})) },
reimbursementForm: { findMany: vi.fn(async ({ where }) => where.OR ? returned : where.status === "approved" ? approved : sources) },
paymentBatchItem: { findMany: vi.fn(async () => reservations) },
paymentBatch: { findMany: vi.fn(async ({ where }) => batches.filter((batch) => where.OR.some((filter) =>
filter.status === "draft" ? batch.status === "draft" && batch.created_by !== "actor" : filter.status.in.includes(batch.status)))) },
};
const fastify = { config: { paymentBatchEnabled: true }, prisma: { $transaction: vi.fn(async (work) => work(tx)) }, hasPermission: (_, permission) => permissions.includes(permission) };
const employee = { id: "actor", roleCodes: roles };
return { tx, fastify, run: () => loadReimbursementPaymentHandoffs(fastify, employee) };
}
function batch(status = "draft", creator = "maker") {
return { id: "batch", batch_no: "PB-SYN", status, created_by: creator, default_payment_channel: "bank", payment_order_export_count: 0,
items: [{ entity_id: "form", status: "pending" }], _count: { receipt_attachments: 0 } };
}
describe("报销付款四类只读交接", () => {
beforeEach(() => { gates.global = true; gates.disabled = []; });
it("无权限不读取业务来源,事务始终只读", async () => {
const { run, tx, fastify } = setup();
expect(await run()).toEqual({ total: 0, items: [] });
expect(tx.reimbursementForm.findMany).not.toHaveBeenCalled();
expect(tx.paymentBatch.findMany).not.toHaveBeenCalled();
expect(tx.$executeRawUnsafe).toHaveBeenCalledWith("SET TRANSACTION READ ONLY");
expect(fastify.prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: "RepeatableRead", maxWait: 5000, timeout: 10000 });
});
it("只认最新流程退回/驳回,不把新草稿、已撤回或旧退回历史当待修改", async () => {
const returned = ["return", "reject", "withdraw", null].map((action, index) => ({ id: `form-${index}`, form_number: `BX-${index}`,
workflow_instances: action ? [{ workflow_history: [{ action }] }] : [] }));
const { run, tx } = setup({ permissions: ["expense.submit", "expense.view_own"], returned });
const result = await run();
expect(result.total).toBe(2);
expect(result.items.map((item) => item.kind)).toEqual(["reimbursement_edit", "reimbursement_edit"]);
expect(tx.reimbursementForm.findMany.mock.calls[0][0].where).toEqual({ deleted_at: null, paid_at: null, status: { in: ["draft", "rejected"] }, OR: [{ owner_id: "actor" }, { submitter_id: "actor" }] });
});
it("待组批排除活动占用和部门停用,但不读取或解密银行资料", async () => {
gates.disabled = ["disabled"];
const approved = ["free", "reserved", "disabled"].map((id) => ({ id, form_number: id, department_id: id }));
const { run, tx } = setup({ permissions: ["payment.batch.manage"], approved, reservations: [{ entity_id: "reserved" }] });
const result = await run();
expect(result.items.map((item) => item.reference)).toEqual(["free"]);
expect(tx.reimbursementForm.findMany.mock.calls[0][0].where).toEqual({ status: "approved", paid_at: null, deleted_at: null });
expect(tx.paymentBatchItem.findMany.mock.calls[0][0].where.payment_batch.status).toEqual({ not: "cancelled" });
expect(JSON.stringify(tx.reimbursementForm.findMany.mock.calls)).not.toMatch(/bank|total_amount|description/);
});
it.each([[[], false], [["admin"], false], [["manager"], true], [["ceo"], true]])("复核需要真实业务角色 %j,不用通配冒充", async (roles, expected) => {
const { run } = setup({ roles, permissions: ["payment.batch.review"], batches: [batch()], sources: [{ id: "form", department_id: "department" }] });
expect((await run()).total).toBe(expected ? 1 : 0);
});
it("多角色制单人也不能收到自审任务", async () => {
const { run } = setup({ roles: ["ceo", "finance"], permissions: ["payment.batch.review", "payment.batch.manage"], batches: [batch("draft", "actor")] });
expect((await run()).total).toBe(0);
});
it.each(["reviewed", "failed"])("%s 交给财务,不交给单独 CEO;导出不意味着付款", async (status) => {
const data = { batches: [{ ...batch(status), payment_order_export_count: 1 }], sources: [{ id: "form", department_id: "department" }] };
const result = await setup({ ...data, permissions: ["payment.batch.manage"] }).run();
expect(result.total).toBe(1);
expect(result.items[0].kind).toBe("payment_execute");
expect(result.items[0].nextStep).toContain(status === "failed" ? "付款登记失败" : "已生成银行制单");
expect((await setup({ ...data, permissions: ["payment.batch.review"], roles: ["ceo"] }).run()).total).toBe(0);
});
it("批次已取消/完成、来源已删除/缺失、部门停用不继续投影", async () => {
const base = { permissions: ["payment.batch.manage"], batches: [batch("executed"), batch("cancelled")] };
expect((await setup(base).run()).total).toBe(0);
expect((await setup({ ...base, batches: [batch("reviewed")] }).run()).total).toBe(0);
gates.disabled = ["department"];
expect((await setup({ ...base, batches: [batch("reviewed")], sources: [{ id: "form", department_id: "department" }] }).run()).total).toBe(0);
});
it("反复加载不重复,通知设施不可用也不丢待办;全局停用不暴露批次", async () => {
const { run } = setup({ permissions: ["payment.batch.manage"], approved: [{ id: "free", department_id: "department" }] });
expect(await run()).toEqual(await run());
gates.global = false;
expect((await run()).total).toBe(0);
});
});