fix: 拦截已删除报销进入付款创建(09-04-reimbursement-state-gap-finance-view)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-12 16:00:25 +08:00
父节点 752ec41396
当前提交 31ab9fbb2b
共修改 5 个文件,包含 61 行新增和 7 行删除
@@ -103,6 +103,17 @@ Wrong: treat a notice count or pending-batch aggregate as the complete finance w
- Creation takes source locks, reloads facts, runs the resolver and only then - Creation takes source locks, reloads facts, runs the resolver and only then
allocates/creates a batch, items, success audit and notifications. Blocked allocates/creates a batch, items, success audit and notifications. Blocked
preflight/create must not emit any of those success side effects. preflight/create must not emit any of those success side effects.
- New-payment candidates and `loadEntitySnapshots` both require
`deleted_at: null`; an approved status alone is not the deletion guard.
A deleted source yields `payment_batch.source_not_found` in readiness and
HTTP 404 on creation, including when marked between preflight and source lock.
Keep deletion-filter awareness in query test doubles and exercise this
defensive fixture even though current deletion only permits draft/rejected.
- Do not mechanically apply creation filters to historical batch detail,
frozen snapshots or legacy evidence reads. Preserve their existing authority
checks and test that retained history remains readable. This does not permit
deleting approved/reserved sources; any such future change must also review
existing-batch export, review and execution paths, not just creation.
- Public blockers contain only reason code and optional source association. - Public blockers contain only reason code and optional source association.
Neither decrypted account numbers nor account snapshots enter readiness DTOs. Neither decrypted account numbers nor account snapshots enter readiness DTOs.
Bank-channel constraints must not silently become constraints on cash or other Bank-channel constraints must not silently become constraints on cash or other
@@ -1,13 +1,29 @@
# 报销状态空隙与财务处理视角:实施计划 # 报销状态空隙与财务处理视角:实施计划
## 2026-09-12 F1 付款创建纵深防御(接续已批准后续规划)
复核七处来源读取后,本轮先封住新付款创建入口:候选列表和readiness/锁后创建共用的loadEntitySnapshots显式要求deleted_at为空。不得向所有查询机械添加过滤:既有批次详情、历史收款人兜底和费用明细属于凭证读取,保留现有授权读取。删除仍只允许draft/rejected且无活动批次,不扩展approved删除,不改审批/复核/付款执行状态机。
- [x] 新回归先失败:构造approved但带删除标记的防御性fixture,候选排除,readiness与锁后创建拒绝且无成功写入/通知;未删除正例和历史批次详情保持。
- [x] 只修改两处创建来源过滤,付款路由/就绪/制单/编号123项通过,语法和Prisma validate退出0;治理结果另记下文。
- [x] 主会话只读检查并记录限制;inline无独立review能力,不冒充独立复核。本增量不自动提交、推送、部署;不改变现已发布752ec413。
执行证据:artifacts/qa-layout/f1-red.log(新两项在旧实现失败,其中锁后变化仍创建201,期望404)、f1-tests.log(4文件123/123)、f1-prisma.log。实际命令:server下显式设置PAYMENT_ORDER_PYTHON_BIN为既有BIFF8测试解释器,执行npm test -- tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/services/paymentBatchReadiness.test.js tests/unit/services/paymentOrderXls.test.js tests/unit/documentSequence.service.test.js;两修改JS文件node --check通过。此处fixture是未来错误状态的防御性验证,不是证明生产存在approved已删除单据。测试使用Fastify注入和Prisma双桩,未执行真实数据库并发或生产请求。
Trellis治理、process-contract baseline、原因码strict及git diff --check均通过;日志为f1-governance.log、f1-process.log、f1-reasons.log。本地复核入口为artifacts/qa-layout/f1-review/README.md;不将“待独立复核”写成阻断产品故障,不让真人承担合成回归。
复核:候选过滤与readiness/锁后创建共用来源读取一致;过滤发生在payee快照构造之前,不解密被排除来源。沿用现有source_not_found/404原因码,无schema、权限、审批或执行状态机变化。七处读取中其余五处未机械过滤,保留历史查询及当前执行不变量;若将来开放已批准/已占用单据删除,必须另审既有批次导出与执行。独立只读review待外部执行,不因主会话自检改成通过。下个发布前置是本增量复核、提交推送授权及新候选部署批准;主线随后接续首批员工准入认证与跨角色验收,生产合成写入仍另批,不重做已完成备注/布局。
## 2026-09-12 备注自解释增量 ## 2026-09-12 备注自解释增量
发布收口:752ec413已提交推送并部署,existing命令退出0且备份合同刷新通过,前后pending=none。公网ready与106资源扫描通过;数据库未重启、环境文件未变。运行镜像四份生成器/模板相关文件摘要与候选一致,并用合成密钥、合成账号在内存生成BIFF8读回,验证“报销款 ”+完整编号、用途及同人多单分笔;不操作真人业务或真实银行。详见artifacts/qa-layout/purpose-remark-release的execution-result、acceptance-result与generator-acceptance回执。本段是发布后记录,不属于752ec413候选树,不新增一次121单测计数,不冒充独立复核或银行导入验证。
用户批准按“报销款+完整报销编号”修改、测试后部署,随后明确批准六文件提交推送、补齐当前版本本地配对备份和独立镜像;不删除旧制品、不异机传输、不操作真实付款。基于7bfc2546,只改生成器备注列并同步付款合同,不增加姓名、截断或未经银行核实的上限。 用户批准按“报销款+完整报销编号”修改、测试后部署,随后明确批准六文件提交推送、补齐当前版本本地配对备份和独立镜像;不删除旧制品、不异机传输、不操作真实付款。基于7bfc2546,只改生成器备注列并同步付款合同,不增加姓名、截断或未经银行核实的上限。
- [x] 新断言先在旧实现失败;修改后单测与真实BIFF8生成/读回及付款路由回归。 - [x] 新断言先在旧实现失败;修改后单测与真实BIFF8生成/读回及付款路由回归。
- [x] 复核差异、语法及治理;已获提交推送授权,实际提交与发布结果另记本轮发布回执。 - [x] 复核差异、语法及治理;已获提交推送授权,实际提交与发布结果另记本轮发布回执。
- [ ] 刷新候选/生产版本/迁移/磁盘/备份/独立镜像预检;退路缺失先报告,不擅自补写或清理。 - [x] 刷新候选/生产版本/迁移/磁盘/备份/独立镜像预检;按用户授权补齐同源本地备份和独立镜像,保留旧制品。
- [ ] existing部署后核公网版本、资源、运行时代码摘要及生成逻辑;不操作真人单据或银行。 - [x] existing部署后核公网版本、资源、运行时代码摘要及生成逻辑;不操作真人单据或银行。
执行回执:旧代码15项中3项按预期失败;运行时修改一行后,使用既有BIFF8解释器,在server执行 `npm test -- tests/unit/services/paymentOrderXls.test.js tests/unit/documentSequence.service.test.js tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/services/paymentBatchReadiness.test.js`,121/121通过、退出0。生成实际BIFF8并读回,备注为“报销款 ”+完整编号,姓名只在收款人列,同人同账号两单仍独立。语法检查、Trellis治理、process-contract baseline及git diff --check通过;主会话复核确认仅一列映射变化,无权限/审批/迁移变化,不冒充独立review。 执行回执:旧代码15项中3项按预期失败;运行时修改一行后,使用既有BIFF8解释器,在server执行 `npm test -- tests/unit/services/paymentOrderXls.test.js tests/unit/documentSequence.service.test.js tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/services/paymentBatchReadiness.test.js`,121/121通过、退出0。生成实际BIFF8并读回,备注为“报销款 ”+完整编号,姓名只在收款人列,同人同账号两单仍独立。语法检查、Trellis治理、process-contract baseline及git diff --check通过;主会话复核确认仅一列映射变化,无权限/审批/迁移变化,不冒充独立review。
@@ -21,6 +21,6 @@
"children": [], "children": [],
"parent": null, "parent": null,
"relatedFiles": [], "relatedFiles": [],
"notes": "09-11复核dcde0d44发现旧请求迟到401污染旧身份缓存,双端甲→乙→甲已合成复现,原包NO_GO保留。沿原批准门户隔离范围接续取消:两页queryFn消费signal,经门户API传给HTTP,不改全局认证、角色或服务端。双端红测后最新专项10/10、联合66/66零重试、新构建/PWA及真实隔离业务链PASS,临时库/合成附件已清理;135单测按未变输入复用。新包postpush-cancellation,独立review仍PENDING_INLINE;需新输入交审、明确提交推送授权、新SHA执行时预检及另批部署/公网自动验收。本轮未提交推送、连接生产或部署,不声称全站认证缓存或真实过期会话已验证。", "notes": "09-12当前生产752ec413:备注自解释、此前布局与门户隔离均已发布,公网和运行镜像合成BIFF8验收通过,详见implement顶部及purpose-remark-release回执;旧未发布表述仅属历史阶段。接续已批准后续规划完成F1付款创建来源显式删除过滤,本地4文件123/123通过、Prisma/语法通过;无真实业务写入,未提交推送或部署本增量,独立只读review待外部执行。现有付款批次历史读取保留,不开放approved删除。后续为本增量复核与授权发布、首批准入认证及跨角色验收;银行导入、完整恢复仍未闭合,不自动归档。",
"meta": {} "meta": {}
} }
+2 -1
查看文件
@@ -546,7 +546,7 @@ function buildPayeeSnapshot(config, owner, options = {}) {
async function loadEntitySnapshots(tx, entityType, entityIds, config, options = {}) { async function loadEntitySnapshots(tx, entityType, entityIds, config, options = {}) {
if (entityType === "reimbursement") { if (entityType === "reimbursement") {
const items = await tx.reimbursementForm.findMany({ const items = await tx.reimbursementForm.findMany({
where: { id: { in: entityIds } }, where: { id: { in: entityIds }, deleted_at: null },
select: { select: {
id: true, id: true,
form_number: true, form_number: true,
@@ -1435,6 +1435,7 @@ async function paymentBatchesRoutes(fastify) {
const where = { const where = {
status: "approved", status: "approved",
paid_at: null, paid_at: null,
deleted_at: null,
...(q ...(q
? { ? {
OR: [ OR: [
@@ -433,10 +433,10 @@ function buildPrisma(options = {}) {
options.candidateCount ?? (state.reservedEntityIds.includes(state.reimbursement.id) ? 0 : 1), options.candidateCount ?? (state.reservedEntityIds.includes(state.reimbursement.id) ? 0 : 1),
), ),
findMany: vi.fn(async ({ select, where } = {}) => { findMany: vi.fn(async ({ select, where } = {}) => {
const reimbursements = [state.reimbursement, ...state.additionalReimbursements].filter((item) => where?.deleted_at !== null || item.deleted_at == null);
if (select?.owner?.select?.employee_no) { if (select?.owner?.select?.employee_no) {
return [state.reimbursement, ...state.additionalReimbursements].filter((item) => item.status === "approved" && !item.paid_at).map((item) => ({ ...item })); return reimbursements.filter((item) => item.status === "approved" && !item.paid_at).map((item) => ({ ...item }));
} }
const reimbursements = [state.reimbursement, ...state.additionalReimbursements];
return reimbursements.filter((item) => !where?.id?.in || where.id.in.includes(item.id)).map((item) => ({ ...item })); return reimbursements.filter((item) => !where?.id?.in || where.id.in.includes(item.id)).map((item) => ({ ...item }));
}), }),
findUnique: vi.fn(async ({ where }) => { findUnique: vi.fn(async ({ where }) => {
@@ -673,6 +673,31 @@ describe("payment batch reimbursement routes", () => {
await app.close(); await app.close();
}); });
it("excludes retained-deleted sources from new payment creation without hiding batch history", async () => {
const { app, tx, state } = await buildApp();
state.reimbursement.deleted_at = new Date("2026-09-12T00:00:00Z");
const headers = { authorization: "Bearer test" };
const candidates = await app.inject({ method: "GET", url: "/api/payment-batches/candidates", headers });
expect(candidates.statusCode).toBe(200);
expect(candidates.json().data.items).toEqual([]);
expect(candidates.json().data.total).toBe(0);
const payload = { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID], defaultPaymentChannel: "bank" };
const readiness = await app.inject({ method: "POST", url: "/api/payment-batches/readiness", headers, payload });
expect(readiness.statusCode).toBe(200);
expect(readiness.json().data).toEqual({ ready: false, blockers: [{ reasonCode: "payment_batch.source_not_found" }] });
const create = await app.inject({ method: "POST", url: "/api/payment-batches", headers, payload });
expect(create.statusCode).toBe(404);
expect(create.json().error.details.reasonCode).toBe("payment_batch.source_not_found");
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled();
const history = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}`, headers });
expect(history.statusCode).toBe(200);
expect(history.json().data.item.id).toBe(BATCH_ID);
await app.close();
});
it("projects synthetic 0014 omissions in candidates and preflight without writes, and recomputes after repair", async () => { it("projects synthetic 0014 omissions in candidates and preflight without writes, and recomputes after repair", async () => {
const { app, tx, state } = await buildApp(); const { app, tx, state } = await buildApp();
const completeOwner = { ...state.reimbursement.owner }; const completeOwner = { ...state.reimbursement.owner };
@@ -697,6 +722,7 @@ describe("payment batch reimbursement routes", () => {
}); });
it.each([ it.each([
["source_not_found", (state) => { state.reimbursement.deleted_at = new Date("2026-09-12T00:00:00Z"); }],
["source_status_invalid", (state) => { state.reimbursement.status = "paid"; }], ["source_status_invalid", (state) => { state.reimbursement.status = "paid"; }],
["active_batch_exists", (state) => { state.reservedEntityIds.push(REIMBURSEMENT_ID); }], ["active_batch_exists", (state) => { state.reservedEntityIds.push(REIMBURSEMENT_ID); }],
["legal_entity_required", (state) => { state.reimbursement.owner.legal_entity_id = null; }], ["legal_entity_required", (state) => { state.reimbursement.owner.legal_entity_id = null; }],
@@ -714,7 +740,7 @@ describe("payment batch reimbursement routes", () => {
expect(preflight.json().data).toEqual({ ready: true, blockers: [] }); expect(preflight.json().data).toEqual({ ready: true, blockers: [] });
tx.$queryRaw.mockImplementationOnce(async () => { change(state); return []; }); tx.$queryRaw.mockImplementationOnce(async () => { change(state); return []; });
const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload }); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload });
expect(response.statusCode).toBe(409); expect(response.statusCode).toBe(reason === "source_not_found" ? 404 : 409);
expect(response.json().error.code).toBe("VALID_001"); expect(response.json().error.code).toBe("VALID_001");
expect(response.json().error.details.reasonCode).toBe(`payment_batch.${reason}`); expect(response.json().error.details.reasonCode).toBe(`payment_batch.${reason}`);
expect(tx.$queryRaw).toHaveBeenCalledTimes(1); expect(tx.$queryRaw).toHaveBeenCalledTimes(1);