fix: 拦截已删除报销进入付款创建(09-04-reimbursement-state-gap-finance-view)
这个提交包含在:
@@ -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": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户