From 31ab9fbb2b898267932734e4fd8f2375c9032de3 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 12 Sep 2026 16:00:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8B=A6=E6=88=AA=E5=B7=B2=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=8A=A5=E9=94=80=E8=BF=9B=E5=85=A5=E4=BB=98=E6=AC=BE?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=EF=BC=8809-04-reimbursement-state-gap-financ?= =?UTF-8?q?e-view=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/backend-fastify-prisma.md | 11 +++++++ .../implement.md | 20 ++++++++++-- .../task.json | 2 +- server/src/routes/paymentBatches.js | 3 +- .../paymentBatches.reimbursement.test.js | 32 +++++++++++++++++-- 5 files changed, 61 insertions(+), 7 deletions(-) diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index b54e77ae..865beae4 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -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 allocates/creates a batch, items, success audit and notifications. Blocked 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. Neither decrypted account numbers nor account snapshots enter readiness DTOs. Bank-channel constraints must not silently become constraints on cash or other diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md index 458e49cf..f76210a4 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md @@ -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 备注自解释增量 +发布收口:752ec413已提交推送并部署,existing命令退出0且备份合同刷新通过,前后pending=none。公网ready与106资源扫描通过;数据库未重启、环境文件未变。运行镜像四份生成器/模板相关文件摘要与候选一致,并用合成密钥、合成账号在内存生成BIFF8读回,验证“报销款 ”+完整编号、用途及同人多单分笔;不操作真人业务或真实银行。详见artifacts/qa-layout/purpose-remark-release的execution-result、acceptance-result与generator-acceptance回执。本段是发布后记录,不属于752ec413候选树,不新增一次121单测计数,不冒充独立复核或银行导入验证。 + 用户批准按“报销款+完整报销编号”修改、测试后部署,随后明确批准六文件提交推送、补齐当前版本本地配对备份和独立镜像;不删除旧制品、不异机传输、不操作真实付款。基于7bfc2546,只改生成器备注列并同步付款合同,不增加姓名、截断或未经银行核实的上限。 - [x] 新断言先在旧实现失败;修改后单测与真实BIFF8生成/读回及付款路由回归。 - [x] 复核差异、语法及治理;已获提交推送授权,实际提交与发布结果另记本轮发布回执。 -- [ ] 刷新候选/生产版本/迁移/磁盘/备份/独立镜像预检;退路缺失先报告,不擅自补写或清理。 -- [ ] existing部署后核公网版本、资源、运行时代码摘要及生成逻辑;不操作真人单据或银行。 +- [x] 刷新候选/生产版本/迁移/磁盘/备份/独立镜像预检;按用户授权补齐同源本地备份和独立镜像,保留旧制品。 +- [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。 diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json index e25b92f6..730f3311 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/task.json @@ -21,6 +21,6 @@ "children": [], "parent": null, "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": {} } diff --git a/server/src/routes/paymentBatches.js b/server/src/routes/paymentBatches.js index 21d1040c..d1f0e4a6 100644 --- a/server/src/routes/paymentBatches.js +++ b/server/src/routes/paymentBatches.js @@ -546,7 +546,7 @@ function buildPayeeSnapshot(config, owner, options = {}) { async function loadEntitySnapshots(tx, entityType, entityIds, config, options = {}) { if (entityType === "reimbursement") { const items = await tx.reimbursementForm.findMany({ - where: { id: { in: entityIds } }, + where: { id: { in: entityIds }, deleted_at: null }, select: { id: true, form_number: true, @@ -1435,6 +1435,7 @@ async function paymentBatchesRoutes(fastify) { const where = { status: "approved", paid_at: null, + deleted_at: null, ...(q ? { OR: [ diff --git a/server/tests/unit/routes/paymentBatches.reimbursement.test.js b/server/tests/unit/routes/paymentBatches.reimbursement.test.js index b6733d6d..1e4ff802 100644 --- a/server/tests/unit/routes/paymentBatches.reimbursement.test.js +++ b/server/tests/unit/routes/paymentBatches.reimbursement.test.js @@ -433,10 +433,10 @@ function buildPrisma(options = {}) { options.candidateCount ?? (state.reservedEntityIds.includes(state.reimbursement.id) ? 0 : 1), ), 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) { - 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 })); }), findUnique: vi.fn(async ({ where }) => { @@ -673,6 +673,31 @@ describe("payment batch reimbursement routes", () => { 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 () => { const { app, tx, state } = await buildApp(); const completeOwner = { ...state.reimbursement.owner }; @@ -697,6 +722,7 @@ describe("payment batch reimbursement routes", () => { }); 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"; }], ["active_batch_exists", (state) => { state.reservedEntityIds.push(REIMBURSEMENT_ID); }], ["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: [] }); tx.$queryRaw.mockImplementationOnce(async () => { change(state); return []; }); 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.details.reasonCode).toBe(`payment_batch.${reason}`); expect(tx.$queryRaw).toHaveBeenCalledTimes(1);