diff --git a/docs/domain-events.md b/docs/domain-events.md index 3b97a21d..f7a30591 100644 --- a/docs/domain-events.md +++ b/docs/domain-events.md @@ -1,7 +1,7 @@ -# 领域事件清单(P19 / P19+IM-1 / P19+ERP-1) +# 领域事件清单(P19 / P19+IM-1 / P19+ERP-2) **快照日期**:2026-03-08 -**目标**:冻结 `hyb-oa` 当前采购与资产域的标准领域事件名,并补充 `P19+IM-1` 已实际消费的 IM 通知类型清单,以及 `P19+ERP-1` 已实际落地的 ERP 消费映射,作为后续 ERP / IM / AI 适配的事实源。 +**目标**:冻结 `hyb-oa` 当前采购与资产域的标准领域事件名,并补充 `P19+IM-1` 已实际消费的 IM 通知类型清单,以及 `P19+ERP-1 / ERP-2` 已实际落地的 ERP 消费映射,作为后续 ERP / IM / AI 适配的事实源。 ## 1. 使用原则 - 本文档同时维护两类事实: @@ -18,6 +18,7 @@ | `purchase_request.approved` | 采购申请审批通过后 | `purchase_request` | `purchaseRequestId`, `requestNo`, `approvedBy`, `legalEntityId`, `costCenterId`, `supplierId`, `budgetPoolId`, `totalAmount`, `status` | ERP、IM、AI | 对应预算 `confirm` 已完成 | | `purchase_request.rejected` | 采购申请审批驳回后 | `purchase_request` | `purchaseRequestId`, `requestNo`, `actorId`, `reason`, `status` | IM、AI | 若存在预算预留,应已释放 | | `purchase_request.withdrawn` | 申请人撤回采购申请后 | `purchase_request` | `purchaseRequestId`, `requestNo`, `actorId`, `status` | IM、AI、ERP 补偿 | 表示由 `reviewing` 回到 `draft` | +| `purchase_order.archived` | PO/合同归档保存成功后 | `purchase_order_archive` | `purchaseOrderArchiveId`, `purchaseRequestId`, `requestNo`, `poNo`, `contractNo`, `archivedAt`, `totalAmount` | ERP、AI | ERP-2 以此驱动 accrual journal 与 archive summary | | `asset.card.created` | 资产卡片创建成功后 | `asset_card` | `assetId`, `assetNo`, `name`, `categoryId`, `legalEntityId`, `costCenterId`, `purchaseRequestId`, `status` | ERP、IM、AI | 资产域不接 workflow | | `asset.assigned` | 资产领用成功后 | `asset_card` | `assetId`, `assetNo`, `employeeId`, `assignedAt`, `location`, `status` | IM、AI | 对应新增 open assignment | | `asset.returned` | 资产归还成功后 | `asset_card` | `assetId`, `assetNo`, `employeeId`, `returnedAt`, `location`, `status` | IM、AI | 对应关闭 open assignment | @@ -29,6 +30,7 @@ | entityType | 推荐事件前缀 | 当前事实源 | | --- | --- | --- | | `purchase_request` | `purchase_request.*` | `P17A` / `P17B` | +| `purchase_order_archive` | `purchase_order.*` | `P17B` / `P19+ERP-2` | | `asset_card` | `asset.*` | `P18A` / `P18B` | | `inventory_check` | `inventory_check.*` | `P18B` | @@ -55,13 +57,13 @@ - 载荷字段命名应与 HTTP camelCase 输出保持一致,不直接暴露数据库 snake_case 列名。 ## 6. 明确不纳入当前事件主表的事项 -- `purchase_request.archived`:待未来采购归档通知专项再冻结 +- `purchase_order.cancelled`:待未来采购撤销 / 作废专项再冻结 - `inventory_check.started`:当前不是对外关键事实源 - `asset.repaired` / `asset.retired`:待未来维修 / 报废专项后再定义 ## 7. 推荐消费场景 - IM:采购审批结果、报销结果、资产领用归还、盘点完成提醒 -- ERP:采购审批通过摘要、资产卡片新增摘要、盘点完成摘要 +- ERP:采购审批 commitment、PO/合同归档 accrual / summary、资产卡片新增摘要、盘点完成摘要 - AI:审批摘要、异常提示、主数据重复建议、盘点结果总结 ## 8. P19+IM-1 已消费通知类型 @@ -91,9 +93,12 @@ 这些事件名称仍保留在长期蓝图中,但不属于 `P19+IM-1` 的首批协同覆盖范围。 -## 10. P19+ERP-1 已实现消费映射 -- `P19+ERP-1` 没有新增新的领域事件名;当前 ERP 一期真正落地消费的既有业务事实是 `reimbursement.paid`。 -- 运行时不会把 ERP 专有结构写回核心报销表,而是先构建标准化 `ReimbursementJournalSlice`,再通过 `erp.journal.export` 写入 `IntegrationOutbox` 异步派发。 -- `ReimbursementJournalSlice.sourceEventName` 固定为 `reimbursement.paid`,用于标记该凭证切片来源于“报销付款完成”这一既有业务事实。 -- ERP 主数据同步(`LegalEntity` / `CostCenter` / `Supplier`)属于主动 Pull,不作为新的领域事件名纳入本表。 -- `purchase_request.approved`、`asset.card.created`、`inventory_check.completed` 等事件仍只冻结名称与最小载荷;真实 ERP 导出留待后续 `P19+ERP-2 / ERP-3`。 +## 10. P19+ERP-1 / ERP-2 已实现消费映射 +- `P19+ERP-1` 没有新增新的领域事件名;ERP 一期真正落地消费的既有业务事实是 `reimbursement.paid`,运行时会先构建标准化 `ReimbursementJournalSlice`,再通过 `erp.journal.export` 写入 `IntegrationOutbox` 异步派发。 +- `ReimbursementJournalSlice.sourceEventName` 固定为 `reimbursement.paid`,用于标记该凭证切片来源于“报销付款完成”这一既有业务事实;历史 `erp.reimbursement.paid` outbox 仍继续兼容。 +- `P19+ERP-2` 正式冻结新增领域事件名 `purchase_order.archived`,并消费两类采购事实: + - `purchase_request.approved` → `PurchaseCommitmentJournalSlice` → `erp.journal.export` + - `purchase_order.archived` → `PurchaseAccrualJournalSlice` + `purchase_order_archive` summary → `erp.journal.export` / `erp.procurement.export` +- 采购 ERP 成功派发后,运行时统一把外部 ERP 编号回写到 `integration_bindings`,分别对应 `purchase_request_commitment_journal`、`purchase_order_accrual_journal`、`purchase_order_po`、`purchase_order_contract`。 +- ERP 主数据同步(`LegalEntity` / `CostCenter` / `Supplier`)仍属于主动 Pull,不作为新的领域事件名纳入本表;`IntegrationSyncState` 也继续只承载主数据资源级同步状态,而不承载采购单据轨道。 +- `asset.card.created`、`inventory_check.completed` 等事件继续只冻结名称与最小载荷;真实 ERP 导出留待后续 `P19+ERP-3`。 diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md index cf93befe..8cc1c392 100644 --- a/docs/feature-matrix.md +++ b/docs/feature-matrix.md @@ -1,8 +1,8 @@ -# hyb-oa 实现状态矩阵(P5-P19+IM-1+ERP-1) +# hyb-oa 实现状态矩阵(P5-P19+IM-1+ERP-2) **快照日期**:2026-03-08 **范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8` + `P14-0-P14-8` + `P15A-0-P15A-5` + `P15B-0-P15B-6` + `P16A-0-P16A-6` + `P16B-0-P16B-6` + `P16C-0-P16C-6` + `R9-0-R9-3` + `G2-0-G2-6` + `P17A-0-P17A-6` + `P17B-0-P17B-6` + `P18A-0-P18A-6` + `P18B-0-P18B-6` + `P19-0-P19-6` + `P19IM1-0-P19IM1-6` + `P19ERP1-0-P19ERP1-6`,共 206 项。 -**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv` + `issues/2026-02-13_00-11-56-p14-l5-operationalization.csv` + `issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv` + `issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv` + `issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv` + `issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv` + `issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv` + `issues/2026-03-07_18-15-03-r9-g2-p19-roadmap-refresh.csv` + `issues/2026-03-07_18-16-00-g2-l5-cross-domain-bridge.csv` + `issues/2026-03-07_19-33-09-p17a-l5-procurement-core.csv` + `issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv` + `issues/2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv` + `issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv` + `issues/2026-03-07_23-22-00-p19-l4-integration-readiness.csv` + `issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv` + `issues/2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv`(执行合同) +**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv` + `issues/2026-02-13_00-11-56-p14-l5-operationalization.csv` + `issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv` + `issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv` + `issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv` + `issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv` + `issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv` + `issues/2026-03-07_18-15-03-r9-g2-p19-roadmap-refresh.csv` + `issues/2026-03-07_18-16-00-g2-l5-cross-domain-bridge.csv` + `issues/2026-03-07_19-33-09-p17a-l5-procurement-core.csv` + `issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv` + `issues/2026-03-07_22-02-09-p18a-l5-asset-registry-core.csv` + `issues/2026-03-07_22-34-03-p18b-l5-asset-movements-inventory.csv` + `issues/2026-03-07_23-22-00-p19-l4-integration-readiness.csv` + `issues/2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv` + `issues/2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv` + `issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv`(执行合同) **说明**: - 本矩阵用于全局读图(审计/交接/回归覆盖追踪),不替代 `issues/*.csv` 的执行合同职责。 @@ -238,3 +238,12 @@ | P19ERP1-5 | OpenAPI文档与矩阵同步 | N/A | 2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv | 2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv | 2026-03-08 | DONE | validate-feature-matrix | 集成边界、领域事件、Feature Matrix 与校验器同步到 `P5-P19+IM-1+ERP-1`,并纳入 ERP-1 合同事实源 | | P19ERP1-6 | P19ERP1回归与安全收口 | N/A | 2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv | 2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv | 2026-03-08 | DONE | server:test + regression-integrations + regression-all + client:typecheck/build | 主数据 Pull、报销凭证切片、ERP 管理入口、开关阻断与 dead_letter 重入全部通过,且既有主流程零回归 | + +| P19ERP2-0 | P19ERP2执行合同建档 | N/A | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08 | DONE | CSV 校验 | 采购 ERP 双阶段出站执行合同建档,冻结 commitment / accrual / archive summary 三段模型 | +| P19ERP2-1 | 采购ERP出站契约与provider扩展 | N/A | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08 | DONE | server:test | 扩展 `erp.journal.export` / `erp.procurement.export` 契约、payload、dedupe 与采购 binding 回写规则 | +| P19ERP2-2 | 采购审批与归档自动出站 | N/A | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08 | DONE | server:test + regression-integrations | 采购审批自动入队 commitment,首次归档自动入队 accrual + summary,且 gate 关闭时仅 skip 不阻断业务 | +| P19ERP2-3 | 采购ERP轨道查询与重试接口 | N/A | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08 | DONE | server:test + regression-integrations | 新增采购 ERP track / requeue 接口,支持历史补发、失败重试与 archive summary 覆盖同步 | +| P19ERP2-4 | 采购详情ERP轨道挂件 | N/A | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08 | DONE | client:typecheck/build | `PurchaseRequestsPage` 归档区新增 ERP 三段轨道挂件,按权限区分查看与重试能力 | +| P19ERP2-5 | 文档事件与矩阵同步 | N/A | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08 | DONE | validate-feature-matrix | 集成边界、领域事件、Feature Matrix 与校验器同步到 `P5-P19+IM-1+ERP-2`,并纳入 ERP-2 合同事实源 | +| P19ERP2-6 | P19ERP2回归与验收收口 | N/A | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | 2026-03-08 | DONE | server:test + regression-integrations + regression-all + client:typecheck/build | 采购双阶段 journal、archive summary 覆盖同步、详情挂件、权限边界与既有 ERP / IM 主流程回归全部通过 | + diff --git a/docs/integration-boundaries.md b/docs/integration-boundaries.md index 2b8014e0..4b29b9e6 100644 --- a/docs/integration-boundaries.md +++ b/docs/integration-boundaries.md @@ -1,8 +1,8 @@ -# 集成边界冻结(P19 / P19+IM-1 / P19+ERP-1) +# 集成边界冻结(P19 / P19+IM-1 / P19+ERP-2) **快照日期**:2026-03-08 -**当前基线**:`P19+IM-1` 与 `P19+ERP-1` 已完成;当前运行时已落地企微群机器人 + 深链 + Outbox 异步派发的一期协同适配,以及 ERP 主数据 Pull / 报销付款凭证切片的一期桥接。 -**目标**:在保持核心域稳定的前提下,冻结 ERP / IM / AI / MCP 的长期边界,并记录 IM 一期与 ERP 一期的已实现范围、禁入规则和后续扩展位。 +**当前基线**:`P19+IM-1`、`P19+ERP-1` 与 `P19+ERP-2` 已完成;当前运行时已落地企微群机器人 + 深链 + Outbox 异步派发的一期协同适配,以及 ERP 主数据 Pull、报销付款凭证切片、采购双阶段 journal 与归档摘要出站。 +**目标**:在保持核心域稳定的前提下,冻结 ERP / IM / AI / MCP 的长期边界,并记录 IM 一期与 ERP 两期的已实现范围、禁入规则和后续扩展位。 ## 1. 总体原则 @@ -48,7 +48,7 @@ ### 2.4 建议后续专项 - `P19+ERP-1`:主数据同步协议 -- `P19+ERP-2`:采购审批结果导出 / 回写协议 +- `P19+ERP-2`:采购双阶段 journal 与归档摘要出站 - `P19+ERP-3`:资产台账摘要导出协议 ### 2.5 P19+ERP-1 已实现范围 @@ -58,6 +58,14 @@ - 已兼容保留历史 `erp.reimbursement.paid` 派发路径;成功导出后统一回写 `integration_bindings(provider=erp, entity_type=reimbursement_journal)`。 - 已明确 `ERP_SYNC_ENABLED` 与 `erp_sync` feature flag 共同构成 ERP 管理端总闸,任一关闭都必须阻断主数据同步与手动导出。 +### 2.6 P19+ERP-2 已实现范围 +- 已冻结采购双阶段财务语义:`purchase_request.approved` 自动生成 `purchase_request_commitment` journal,`purchase_order.archived` 首次归档自动生成 `purchase_order_accrual` journal。 +- 已新增 `erp.procurement.export` 用于 `purchase_order_archive` 摘要出站;commitment / accrual 仍统一复用 `erp.journal.export`,避免额外拆分出站总线。 +- 已为 `purchase_order_archive` 建立“同一条 outbox 覆盖同步”语义:当 `poNo` / `contractNo` / `archivedFiles` 变化时,仅重置 summary 对应 outbox 并覆盖 payload,不重复创建 accrual journal。 +- 已在 `integration_bindings` 中落地采购映射:`purchase_request_commitment_journal`、`purchase_order_accrual_journal`、`purchase_order_po`、`purchase_order_contract`;成功派发后回写 ERP 外部编号与最近同步时间。 +- 已明确 `IntegrationSyncState` 继续只表示资源级主数据同步状态,不承载采购单据级轨道;采购详情轨道只允许由 `IntegrationOutbox + IntegrationBinding + 当前单据事实` 推导。 +- 已将采购 ERP 轨道挂件固定在采购详情 / 归档区:所有可读用户仅查看阶段状态,`integration.manage` 才能查看 ERP 外部编号并执行手动补发 / 重试。 + ## 3. IM / 协同适配边界 ### 3.1 一期已实现能力(P19+IM-1) diff --git a/issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv b/issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv index c6a1ceac..fe086915 100644 --- a/issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv +++ b/issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv @@ -1,8 +1,8 @@ ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes -P19ERP2-0,P19ERP2执行合同建档,创建并校验采购ERP双阶段出站执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv,manual,TODO,TODO,TODO,issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv,[],P19ERP2固定为采购审批commitment + 归档accrual + 归档摘要出站 -P19ERP2-1,采购ERP出站契约与provider扩展,扩展journal与procurement export provider契约并定义payload/dedupe/binding规则,ERP journal与procurement summary均可通过现有dispatcher/outbox处理且不新增核心表ERP字段,cd server && npm run test,manual,TODO,TODO,TODO,server/src/services/integrations/erp.js | server/src/services/integrations/dispatcher.js | server/src/services/integrations/erpProcurement.js | server/tests,"[""P19ERP2-0""]",默认不新增Prisma表 继续复用IntegrationBinding与IntegrationOutbox -P19ERP2-2,采购审批与归档自动出站,在purchase approval与archive-order落点自动入队commitment/accrual/archive summary并处理gate skip,审批与归档成功后业务事务不受ERP失败影响 且outbox/binding语义正确,cd server && npm run test && ./scripts/regression-integrations.sh,manual,TODO,TODO,TODO,server/src/routes/workflow.js | server/src/routes/purchaseRequests.js | server/src/services/integrations/outbox.js | server/tests,"[""P19ERP2-1""]",自动触发走strictDepartment=true 手动重试走strictDepartment=false -P19ERP2-3,采购ERP轨道查询与重试接口,新增采购详情ERP轨道查询与requeue接口支持历史补发和失败重试,采购可读用户可看轨道 integration.manage可重试且开关关闭时正确阻断,cd server && npm run test && ./scripts/regression-integrations.sh,manual,TODO,TODO,TODO,server/src/routes/integrations.js | server/tests,"[""P19ERP2-2""]",archive summary允许同一条outbox在payload变化时重置覆盖 -P19ERP2-4,采购详情ERP轨道挂件,在PurchaseRequestsPage归档区下增加ERP轨道挂件与重试交互,详情页可展示三段轨道状态且权限边界正确,cd client && npm run typecheck && npm run build,manual,TODO,TODO,TODO,client/src/pages/PurchaseRequestsPage.tsx | client/src/api/purchaseRequests.ts,"[""P19ERP2-3""]",仅详情挂件 不新增ERP页采购面板 -P19ERP2-5,文档事件与矩阵同步,同步采购ERP-2边界 事件映射 Feature Matrix与校验器,矩阵标题更新为P5-P19+IM-1+ERP-2且总数为213,python3 scripts/validate-feature-matrix.py,manual,TODO,TODO,TODO,docs/integration-boundaries.md | docs/domain-events.md | docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv,"[""P19ERP2-4""]",新增purchase_order.archived事件并补采购ERP消费映射 -P19ERP2-6,P19ERP2回归与验收收口,完成采购双阶段出站 详情挂件与回归验证后收口,采购ERP happy path与失败重试通过且报销/请假/采购/资产/IM零回归,cd server && npm run test && ./scripts/regression-integrations.sh && OCR_PROVIDER=mock JOBS_ENABLED=false IM_NOTIFIER_ENABLED=true APP_ORIGIN='http://127.0.0.1:3000' ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,TODO,TODO,TODO,issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | server/scripts/regression-integrations.sh | client/src/pages/PurchaseRequestsPage.tsx,"[""P19ERP2-5""]",重点核对archive summary覆盖同步 成功journal不可强制replay 权限边界与gate阻断 +P19ERP2-0,P19ERP2执行合同建档,创建并校验采购ERP双阶段出站执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv,manual,DONE,DONE,DONE,issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv,[],P19ERP2固定为采购审批commitment + 归档accrual + 归档摘要出站 +P19ERP2-1,采购ERP出站契约与provider扩展,扩展journal与procurement export provider契约并定义payload/dedupe/binding规则,ERP journal与procurement summary均可通过现有dispatcher/outbox处理且不新增核心表ERP字段,cd server && npm run test,manual,DONE,DONE,DONE,server/src/services/integrations/erp.js | server/src/services/integrations/dispatcher.js | server/src/services/integrations/erpProcurement.js | server/tests,"[""P19ERP2-0""]",默认不新增Prisma表 继续复用IntegrationBinding与IntegrationOutbox +P19ERP2-2,采购审批与归档自动出站,在purchase approval与archive-order落点自动入队commitment/accrual/archive summary并处理gate skip,审批与归档成功后业务事务不受ERP失败影响 且outbox/binding语义正确,cd server && npm run test && ./scripts/regression-integrations.sh,manual,DONE,DONE,DONE,server/src/routes/workflow.js | server/src/routes/purchaseRequests.js | server/src/services/integrations/outbox.js | server/tests,"[""P19ERP2-1""]",自动触发走strictDepartment=true 手动重试走strictDepartment=false +P19ERP2-3,采购ERP轨道查询与重试接口,新增采购详情ERP轨道查询与requeue接口支持历史补发和失败重试,采购可读用户可看轨道 integration.manage可重试且开关关闭时正确阻断,cd server && npm run test && ./scripts/regression-integrations.sh,manual,DONE,DONE,DONE,server/src/routes/integrations.js | server/tests,"[""P19ERP2-2""]",archive summary允许同一条outbox在payload变化时重置覆盖 +P19ERP2-4,采购详情ERP轨道挂件,在PurchaseRequestsPage归档区下增加ERP轨道挂件与重试交互,详情页可展示三段轨道状态且权限边界正确,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/PurchaseRequestsPage.tsx | client/src/api/purchaseRequests.ts,"[""P19ERP2-3""]",仅详情挂件 不新增ERP页采购面板 +P19ERP2-5,文档事件与矩阵同步,同步采购ERP-2边界 事件映射 Feature Matrix与校验器,矩阵标题更新为P5-P19+IM-1+ERP-2且总数为213,python3 scripts/validate-feature-matrix.py,manual,DONE,DONE,DONE,docs/integration-boundaries.md | docs/domain-events.md | docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv,"[""P19ERP2-4""]",新增purchase_order.archived事件并补采购ERP消费映射 +P19ERP2-6,P19ERP2回归与验收收口,完成采购双阶段出站 详情挂件与回归验证后收口,采购ERP happy path与失败重试通过且报销/请假/采购/资产/IM零回归,cd server && npm run test && ./scripts/regression-integrations.sh && OCR_PROVIDER=mock JOBS_ENABLED=false IM_NOTIFIER_ENABLED=true APP_ORIGIN='http://127.0.0.1:3000' ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,issues/2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv | server/scripts/regression-integrations.sh | client/src/pages/PurchaseRequestsPage.tsx,"[""P19ERP2-5""]",重点核对archive summary覆盖同步 成功journal不可强制replay 权限边界与gate阻断 diff --git a/scripts/validate-feature-matrix.py b/scripts/validate-feature-matrix.py index f4d9bf13..43a0cf53 100755 --- a/scripts/validate-feature-matrix.py +++ b/scripts/validate-feature-matrix.py @@ -40,6 +40,7 @@ CSV_FACT_SOURCES = [ ROOT / "issues" / "2026-03-07_23-22-00-p19-l4-integration-readiness.csv", ROOT / "issues" / "2026-03-08_12-18-10-p19-im-1-l5-collaboration-adapter.csv", ROOT / "issues" / "2026-03-08_14-10-53-p19-erp-1-l5-masterdata-journal-bridge.csv", + ROOT / "issues" / "2026-03-08_22-46-51-p19-erp-2-l5-procurement-commitment-archive.csv", ] ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z-]*$") diff --git a/server/scripts/regression-integrations.sh b/server/scripts/regression-integrations.sh index 21b5509f..a4c6bd45 100755 --- a/server/scripts/regression-integrations.sh +++ b/server/scripts/regression-integrations.sh @@ -6,6 +6,10 @@ MOCK_ERP_PORT="${MOCK_ERP_PORT:-$((3900 + RANDOM % 500))}" APP_PORT="${APP_PORT:-$((4300 + RANDOM % 500))}" BASE_URL="http://127.0.0.1:${APP_PORT}" DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-integrations}" +EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}" +EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}" +MANAGER_IDENTIFIER="${MANAGER_IDENTIFIER:-manager@hyinda.com}" +MANAGER_PASSWORD="${MANAGER_PASSWORD:-ManagerPassword1234}" FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}" FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}" RUN_SUFFIX="$(date +%s)-$RANDOM" @@ -155,7 +159,11 @@ const server = http.createServer(async (req, res) => { if ( req.method === 'POST' && - (url.pathname === '/erp/journals/reimbursements/paid' || url.pathname === '/erp/reimbursements/paid') + ( + url.pathname === '/erp/journals/export' || + url.pathname === '/erp/journals/reimbursements/paid' || + url.pathname === '/erp/reimbursements/paid' + ) ) { const mode = url.searchParams.get('mode') || 'success'; const chunks = []; @@ -176,6 +184,7 @@ const server = http.createServer(async (req, res) => { ok: true, scenario: 'timeout', externalId: `ERP-TIMEOUT-${Date.now()}`, + externalCode: `ERP-TIMEOUT-${Date.now()}`, reimbursementId: parsedBody?.reimbursementId || parsedBody?.entityId || null, }); }, 1500); @@ -200,14 +209,68 @@ const server = http.createServer(async (req, res) => { }); } + const documentNo = parsedBody?.documentNo || parsedBody?.formNumber || parsedBody?.entityId || Date.now(); return sendJson(res, 200, { ok: true, scenario: 'success', - externalId: `ERP-${parsedBody?.documentNo || parsedBody?.formNumber || parsedBody?.entityId || Date.now()}`, + externalId: `ERP-${documentNo}`, + externalCode: `ERP-${documentNo}`, reimbursementId: parsedBody?.reimbursementId || parsedBody?.entityId || null, }); } + if (req.method === 'POST' && url.pathname === '/erp/procurement/export') { + const mode = url.searchParams.get('mode') || 'success'; + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + + let parsedBody = null; + try { + parsedBody = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + } catch { + parsedBody = null; + } + + if (mode === 'timeout') { + setTimeout(() => { + sendJson(res, 200, { + ok: true, + externalPoId: `ERP-PO-${Date.now()}`, + externalPoNo: `ERP-PO-${parsedBody?.poNo || Date.now()}`, + }); + }, 1500); + return; + } + + if (mode === 'client') { + return sendJson(res, 422, { + ok: false, + code: 'invalid_payload', + message: 'mock procurement client error', + }); + } + + if (mode === 'server') { + return sendJson(res, 503, { + ok: false, + code: 'service_unavailable', + message: 'mock procurement server error', + }); + } + + return sendJson(res, 200, { + ok: true, + externalId: `ERP-PROC-${parsedBody?.poNo || parsedBody?.entityId || Date.now()}`, + externalCode: `ERP-PROC-${parsedBody?.poNo || parsedBody?.entityId || Date.now()}`, + externalPoId: parsedBody?.poNo ? `ERP-PO-${parsedBody.poNo}` : null, + externalPoNo: parsedBody?.poNo ? `ERP-PO-${parsedBody.poNo}` : null, + externalContractId: parsedBody?.contractNo ? `ERP-CT-${parsedBody.contractNo}` : null, + externalContractNo: parsedBody?.contractNo ? `ERP-CT-${parsedBody.contractNo}` : null, + }); + } + sendJson(res, 404, { ok: false, message: 'not_found' }); }); @@ -239,9 +302,11 @@ start_app_server() { ERP_SYNC_ENABLED=true \ ERP_SYNC_PROVIDER=http \ ERP_MASTER_DATA_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/master-data/pull?mode=success" \ - ERP_JOURNAL_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/journals/reimbursements/paid?mode=success" \ + ERP_JOURNAL_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/journals/export?mode=success" \ + ERP_PROCUREMENT_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/procurement/export?mode=success" \ ERP_SYNC_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/reimbursements/paid?mode=success" \ ERP_SYNC_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \ + ERP_PROCUREMENT_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \ ERP_MASTER_DATA_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \ ERP_SYNC_AUTH_TOKEN="" \ node src/index.js >"$TMP_DIR/app.log" 2>&1 & @@ -259,16 +324,45 @@ start_app_server() { exit 1 } -login_finance_mgr() { - local login_body="$TMP_DIR/login.json" +login_user() { + local identifier="$1" + local password="$2" + local suffix="$3" + local out_file="$TMP_DIR/login-${suffix}.json" local login_status - login_status="$(curl -sS -o "$login_body" -w "%{http_code}" \ - -X POST "$BASE_URL/api/auth/login" \ - -H "content-type: application/json" \ - -H "x-device-fingerprint: ${DEVICE_FINGERPRINT}-finance" \ - -d "{\"identifier\":\"$FINANCE_MGR_IDENTIFIER\",\"password\":\"$FINANCE_MGR_PASSWORD\"}")" - assert_status "$login_status" "200" "finance_mgr login" - TOKEN="$(jq -er '.data.accessToken' "$login_body")" + login_status="$( + curl -sS -o "$out_file" -w "%{http_code}" \ + -X POST "$BASE_URL/api/auth/login" \ + -H "content-type: application/json" \ + -H "x-device-fingerprint: ${DEVICE_FINGERPRINT}-${suffix}" \ + -d "$(jq -nc --arg identifier "$identifier" --arg password "$password" '{identifier: $identifier, password: $password}')" + )" + assert_status "$login_status" "200" "login ${identifier}" "$out_file" + jq -er '.data.accessToken' "$out_file" +} + +login_finance_mgr() { + TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" finance)" +} + +api_json_auth() { + local method="$1" + local path="$2" + local token="$3" + local out_file="$4" + local body="${5:-}" + + if [[ -n "$body" ]]; then + curl -sS -o "$out_file" -w "%{http_code}" \ + -X "$method" "$BASE_URL$path" \ + -H "authorization: Bearer $token" \ + -H "content-type: application/json" \ + -d "$body" + else + curl -sS -o "$out_file" -w "%{http_code}" \ + -X "$method" "$BASE_URL$path" \ + -H "authorization: Bearer $token" + fi } set_erp_flag() { @@ -460,6 +554,175 @@ const { createPrismaClient } = require('./src/prismaClient'); NODE } +resolve_procurement_fixture_ids() { + DATABASE_URL="$DATABASE_URL" node - <<'NODE' +require('dotenv/config'); +const { createPrismaClient } = require('./src/prismaClient'); +const { resolveApplicableBudgetPoolByContext } = require('./src/services/budgetPool'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const employee = await prisma.employee.findUnique({ + where: { email: process.env.SEED_EMPLOYEE_EMAIL ?? 'employee@hyinda.com' }, + select: { department_id: true, legal_entity_id: true }, + }); + const costCenter = await prisma.costCenter.findFirst({ + where: { + enabled: true, + archived_at: null, + legal_entity_id: employee.legal_entity_id, + }, + orderBy: { created_at: 'asc' }, + select: { id: true }, + }); + const supplier = await prisma.supplier.findFirst({ + where: { + enabled: true, + archived_at: null, + OR: [{ legal_entity_id: employee.legal_entity_id }, { legal_entity_id: null }], + }, + orderBy: { created_at: 'asc' }, + select: { id: true }, + }); + const documentCategory = await prisma.documentCategory.findFirst({ + where: { + enabled: true, + archived_at: null, + entity_type: 'purchase_request', + }, + orderBy: { created_at: 'asc' }, + select: { id: true }, + }); + const budgetPool = await resolveApplicableBudgetPoolByContext(prisma, { + departmentId: employee.department_id, + legalEntityId: employee.legal_entity_id, + costCenterId: costCenter?.id ?? null, + effectiveDate: new Date(), + }); + + process.stdout.write( + [ + employee.legal_entity_id, + costCenter?.id ?? '', + supplier?.id ?? '', + documentCategory?.id ?? '', + budgetPool?.id ?? '', + ].join('\n'), + ); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +query_outbox_by_key() { + local event_type="$1" + local dedupe_key="$2" + DATABASE_URL="$DATABASE_URL" EVENT_TYPE="$event_type" DEDUPE_KEY="$dedupe_key" node <<'NODE' +require('dotenv/config'); +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const row = await prisma.integrationOutbox.findUnique({ + where: { + event_type_dedupe_key: { + event_type: process.env.EVENT_TYPE, + dedupe_key: process.env.DEDUPE_KEY, + }, + }, + select: { + id: true, + event_type: true, + status: true, + attempts: true, + processed_at: true, + next_retry_at: true, + dead_letter_at: true, + last_error: true, + payload: true, + }, + }); + process.stdout.write(JSON.stringify(row ? { + id: row.id, + eventType: row.event_type, + status: row.status, + attempts: row.attempts, + processedAt: row.processed_at ? row.processed_at.toISOString() : null, + nextRetryAt: row.next_retry_at ? row.next_retry_at.toISOString() : null, + deadLetterAt: row.dead_letter_at ? row.dead_letter_at.toISOString() : null, + lastError: row.last_error, + payload: row.payload, + } : null)); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +query_binding_by_key() { + local entity_type="$1" + local local_id="$2" + DATABASE_URL="$DATABASE_URL" ENTITY_TYPE="$entity_type" LOCAL_ID="$local_id" node <<'NODE' +require('dotenv/config'); +const { createPrismaClient } = require('./src/prismaClient'); + +(async () => { + const { prisma, pool } = createPrismaClient(); + await prisma.$connect(); + try { + const row = await prisma.integrationBinding.findUnique({ + where: { + provider_entity_type_local_id: { + provider: 'erp', + entity_type: process.env.ENTITY_TYPE, + local_id: process.env.LOCAL_ID, + }, + }, + select: { + external_id: true, + external_code: true, + last_synced_at: true, + }, + }); + process.stdout.write(JSON.stringify({ + externalId: row?.external_id ?? null, + externalCode: row?.external_code ?? null, + lastSyncedAt: row?.last_synced_at ? row.last_synced_at.toISOString() : null, + })); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE +} + +assert_outbox_absent_by_key() { + local event_type="$1" + local dedupe_key="$2" + local state_json + state_json="$(query_outbox_by_key "$event_type" "$dedupe_key")" + jq -e '. == null' <<<"$state_json" >/dev/null +} + assert_master_data_seeded() { DATABASE_URL="$DATABASE_URL" LEGAL_ENTITY_CODE="$LEGAL_ENTITY_CODE" COST_CENTER_CODE="$COST_CENTER_CODE" SUPPLIER_CODE="$SUPPLIER_CODE" node <<'NODE' require('dotenv/config'); @@ -497,16 +760,9 @@ NODE } run_outbox_once() { - local mode="$1" - DATABASE_URL="$DATABASE_URL" \ - INTEGRATION_OUTBOX_BATCH_SIZE=500 \ - ERP_SYNC_ENABLED=true \ - ERP_SYNC_PROVIDER=http \ - ERP_JOURNAL_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/journals/reimbursements/paid?mode=$mode" \ - ERP_SYNC_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/reimbursements/paid?mode=$mode" \ - ERP_SYNC_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \ - ERP_SYNC_AUTH_TOKEN="" \ - npm run --silent job:integration-outbox-once >"$TMP_DIR/outbox-once.log" + local journal_mode="$1" + local procurement_mode="${2:-$journal_mode}" + DATABASE_URL="$DATABASE_URL" INTEGRATION_OUTBOX_BATCH_SIZE=500 ERP_SYNC_ENABLED=true ERP_SYNC_PROVIDER=http ERP_JOURNAL_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/journals/export?mode=$journal_mode" ERP_PROCUREMENT_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/procurement/export?mode=$procurement_mode" ERP_SYNC_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/reimbursements/paid?mode=$journal_mode" ERP_SYNC_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" ERP_PROCUREMENT_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" ERP_SYNC_AUTH_TOKEN="" npm run --silent job:integration-outbox-once >"$TMP_DIR/outbox-once.log" } assert_pull_mode() { @@ -553,6 +809,60 @@ api_post() { -d "$body" } +create_and_approve_purchase() { + local employee_token="$1" + local manager_token="$2" + local title_suffix="$3" + local tmp_prefix="$4" + + local create_status + create_status="$(api_json_auth POST "/api/purchase-requests" "$employee_token" "$TMP_DIR/${tmp_prefix}-create.json" '{}')" + assert_status "$create_status" "200" "create purchase ${title_suffix}" "$TMP_DIR/${tmp_prefix}-create.json" + local purchase_id + purchase_id="$(jq -er '.data.purchaseRequest.id' "$TMP_DIR/${tmp_prefix}-create.json")" + + local patch_body + patch_body="$({ + jq -nc \ + --arg title "$title_suffix" \ + --arg le "$LEGAL_ENTITY_ID" \ + --arg cc "$COST_CENTER_ID" \ + --arg sp "$SUPPLIER_ID" \ + --arg dc "$DOCUMENT_CATEGORY_ID" \ + '{title:$title, legalEntityId:$le, costCenterId:$cc, supplierId:$sp, documentCategoryId:$dc, items:[{description:"Laptop", specification:"14 inch", quantity:"2.00", unitPrice:"440.00", isAssetCandidate:true}] }' + })" + local patch_status + patch_status="$(api_json_auth PATCH "/api/purchase-requests/$purchase_id" "$employee_token" "$TMP_DIR/${tmp_prefix}-patch.json" "$patch_body")" + assert_status "$patch_status" "200" "patch purchase ${title_suffix}" "$TMP_DIR/${tmp_prefix}-patch.json" + + local submit_status + submit_status="$(api_json_auth POST "/api/purchase-requests/$purchase_id/submit" "$employee_token" "$TMP_DIR/${tmp_prefix}-submit.json" '{}')" + assert_status "$submit_status" "200" "submit purchase ${title_suffix}" "$TMP_DIR/${tmp_prefix}-submit.json" + local task_id + task_id="$(jq -er '.data.currentTaskId' "$TMP_DIR/${tmp_prefix}-submit.json")" + + local approve_status + approve_status="$(api_json_auth POST "/api/workflow/tasks/$task_id/approve" "$manager_token" "$TMP_DIR/${tmp_prefix}-approve.json" '{"comment":"approve procurement erp regression"}')" + assert_status "$approve_status" "200" "approve purchase ${title_suffix}" "$TMP_DIR/${tmp_prefix}-approve.json" + jq -e '.data.outcome == "approved" and .data.purchaseRequestStatus == "approved"' "$TMP_DIR/${tmp_prefix}-approve.json" >/dev/null + + printf '%s\n%s' "$purchase_id" "$task_id" +} + +archive_purchase_order() { + local finance_token="$1" + local purchase_id="$2" + local po_no="$3" + local contract_no="$4" + local tmp_prefix="$5" + + local archive_status + archive_status="$(api_json_auth POST "/api/purchase-requests/$purchase_id/archive-order" "$finance_token" "$TMP_DIR/${tmp_prefix}-archive.json" "$(jq -nc --arg po "$po_no" --arg contract "$contract_no" '{poNo:$po, contractNo:$contract, archivedFiles:[]}')")" + assert_status "$archive_status" "200" "archive purchase ${purchase_id}" "$TMP_DIR/${tmp_prefix}-archive.json" + jq -e --arg po "$po_no" --arg contract "$contract_no" '.data.archiveOrder.poNo == $po and .data.archiveOrder.contractNo == $contract' "$TMP_DIR/${tmp_prefix}-archive.json" >/dev/null + jq -er '.data.archiveOrder.id' "$TMP_DIR/${tmp_prefix}-archive.json" +} + assert_export_mode_semantics() { local mode="$1" local state_json="$2" @@ -692,7 +1002,95 @@ legacy_outbox_id="$(jq -er '.data.outboxId' "$legacy_body")" legacy_state="$(query_outbox_state "$legacy_outbox_id")" jq -e '.eventType == "erp.journal.export" and .status == "pending"' <<<"$legacy_state" >/dev/null -echo "[9/9] ERP env gate off blocks sync" +echo "[9/13] Procurement approval/archive auto export + bindings" +EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" employee)" +MANAGER_TOKEN="$(login_user "$MANAGER_IDENTIFIER" "$MANAGER_PASSWORD" manager)" +FINANCE_MGR_TOKEN="$TOKEN" +mapfile -t FIXTURE_IDS < <(resolve_procurement_fixture_ids) +LEGAL_ENTITY_ID="${FIXTURE_IDS[0]}" +COST_CENTER_ID="${FIXTURE_IDS[1]}" +SUPPLIER_ID="${FIXTURE_IDS[2]}" +DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}" +BUDGET_POOL_ID="${FIXTURE_IDS[4]}" + +mapfile -t PROCUREMENT_APPROVED < <(create_and_approve_purchase "$EMPLOYEE_TOKEN" "$MANAGER_TOKEN" "procurement erp success" "proc-success") +PURCHASE_ID_APPROVED="${PROCUREMENT_APPROVED[0]}" +COMMITMENT_DEDUPE="purchase_request:${PURCHASE_ID_APPROVED}:commitment" +commitment_state="$(query_outbox_by_key 'erp.journal.export' "$COMMITMENT_DEDUPE")" +jq -e '.status == "pending" and .payload.journalType == "purchase_request_commitment"' <<<"$commitment_state" >/dev/null +run_outbox_once success success +commitment_state_done="$(query_outbox_by_key 'erp.journal.export' "$COMMITMENT_DEDUPE")" +jq -e '.status == "succeeded"' <<<"$commitment_state_done" >/dev/null +commitment_binding="$(query_binding_by_key 'purchase_request_commitment_journal' "$PURCHASE_ID_APPROVED")" +jq -e '.externalCode != null' <<<"$commitment_binding" >/dev/null + +ARCHIVE_ID_APPROVED="$(archive_purchase_order "$FINANCE_MGR_TOKEN" "$PURCHASE_ID_APPROVED" 'PO-P19ERP2-001' 'CT-P19ERP2-001' 'proc-success')" +ACCRUAL_DEDUPE="purchase_order_archive:${ARCHIVE_ID_APPROVED}:accrual" +SUMMARY_DEDUPE="purchase_order_archive:${ARCHIVE_ID_APPROVED}:summary" +accrual_state="$(query_outbox_by_key 'erp.journal.export' "$ACCRUAL_DEDUPE")" +summary_state="$(query_outbox_by_key 'erp.procurement.export' "$SUMMARY_DEDUPE")" +jq -e '.status == "pending" and .payload.journalType == "purchase_order_accrual"' <<<"$accrual_state" >/dev/null +jq -e '.status == "pending" and .payload.exportType == "purchase_order_archive"' <<<"$summary_state" >/dev/null +run_outbox_once success success +accrual_state_done="$(query_outbox_by_key 'erp.journal.export' "$ACCRUAL_DEDUPE")" +summary_state_done="$(query_outbox_by_key 'erp.procurement.export' "$SUMMARY_DEDUPE")" +jq -e '.status == "succeeded"' <<<"$accrual_state_done" >/dev/null +jq -e '.status == "succeeded"' <<<"$summary_state_done" >/dev/null +accrual_binding="$(query_binding_by_key 'purchase_order_accrual_journal' "$ARCHIVE_ID_APPROVED")" +po_binding="$(query_binding_by_key 'purchase_order_po' "$ARCHIVE_ID_APPROVED")" +contract_binding="$(query_binding_by_key 'purchase_order_contract' "$ARCHIVE_ID_APPROVED")" +jq -e '.externalCode != null' <<<"$accrual_binding" >/dev/null +jq -e '.externalCode != null' <<<"$po_binding" >/dev/null +jq -e '.externalCode != null' <<<"$contract_binding" >/dev/null + +echo "[10/13] Archive update only resets summary" +ARCHIVE_UPDATE_STATUS="$(api_json_auth POST "/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$FINANCE_MGR_TOKEN" "$TMP_DIR/proc-success-archive-update.json" '{"poNo":"PO-P19ERP2-001","contractNo":"CT-P19ERP2-002","archivedFiles":[]}')" +assert_status "$ARCHIVE_UPDATE_STATUS" "200" "archive update" "$TMP_DIR/proc-success-archive-update.json" +accrual_state_after_update="$(query_outbox_by_key 'erp.journal.export' "$ACCRUAL_DEDUPE")" +summary_state_after_update="$(query_outbox_by_key 'erp.procurement.export' "$SUMMARY_DEDUPE")" +ACCRUAL_OUTBOX_ID="$(jq -er '.id' <<<"$accrual_state_done")" +SUMMARY_OUTBOX_ID="$(jq -er '.id' <<<"$summary_state_done")" +jq -e --arg id "$ACCRUAL_OUTBOX_ID" '.id == $id and .status == "succeeded"' <<<"$accrual_state_after_update" >/dev/null +jq -e --arg id "$SUMMARY_OUTBOX_ID" '.id == $id and .status == "pending" and .payload.contractNo == "CT-P19ERP2-002"' <<<"$summary_state_after_update" >/dev/null +run_outbox_once success success +contract_binding_updated="$(query_binding_by_key 'purchase_order_contract' "$ARCHIVE_ID_APPROVED")" +jq -e '.externalCode != null' <<<"$contract_binding_updated" >/dev/null + +echo "[11/13] Force-off skips auto export and manual requeue backfills historical单据" +set_erp_flag "force_off" +mapfile -t PROCUREMENT_SKIPPED < <(create_and_approve_purchase "$EMPLOYEE_TOKEN" "$MANAGER_TOKEN" "procurement erp skipped" "proc-skipped") +PURCHASE_ID_SKIPPED="${PROCUREMENT_SKIPPED[0]}" +COMMITMENT_DEDUPE_SKIPPED="purchase_request:${PURCHASE_ID_SKIPPED}:commitment" +assert_outbox_absent_by_key 'erp.journal.export' "$COMMITMENT_DEDUPE_SKIPPED" +ARCHIVE_ID_SKIPPED="$(archive_purchase_order "$FINANCE_MGR_TOKEN" "$PURCHASE_ID_SKIPPED" 'PO-P19ERP2-101' 'CT-P19ERP2-101' 'proc-skipped')" +ACCRUAL_DEDUPE_SKIPPED="purchase_order_archive:${ARCHIVE_ID_SKIPPED}:accrual" +SUMMARY_DEDUPE_SKIPPED="purchase_order_archive:${ARCHIVE_ID_SKIPPED}:summary" +assert_outbox_absent_by_key 'erp.journal.export' "$ACCRUAL_DEDUPE_SKIPPED" +assert_outbox_absent_by_key 'erp.procurement.export' "$SUMMARY_DEDUPE_SKIPPED" +set_erp_flag "force_on" +PROC_REQUEUE_CODE="$(api_json_auth POST "/api/integrations/erp/procurement/purchase-requests/$PURCHASE_ID_SKIPPED/requeue?runNow=0" "$FINANCE_MGR_TOKEN" "$TMP_DIR/proc-requeue.json" '{"targets":["commitment_journal","accrual_journal","archive_summary"]}')" +assert_status "$PROC_REQUEUE_CODE" "200" "procurement requeue" "$TMP_DIR/proc-requeue.json" +jq -e '.data.items | length == 3' "$TMP_DIR/proc-requeue.json" >/dev/null +run_outbox_once success success +jq -e '.status == "succeeded"' <<<"$(query_outbox_by_key 'erp.journal.export' "$COMMITMENT_DEDUPE_SKIPPED")" >/dev/null +jq -e '.status == "succeeded"' <<<"$(query_outbox_by_key 'erp.journal.export' "$ACCRUAL_DEDUPE_SKIPPED")" >/dev/null +jq -e '.status == "succeeded"' <<<"$(query_outbox_by_key 'erp.procurement.export' "$SUMMARY_DEDUPE_SKIPPED")" >/dev/null + +echo "[12/13] Archive summary dead-letter can requeue same outbox" +PROC_SUMMARY_FAIL_STATUS="$(api_json_auth POST "/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$FINANCE_MGR_TOKEN" "$TMP_DIR/proc-summary-fail-archive.json" '{"poNo":"PO-P19ERP2-001","contractNo":"CT-P19ERP2-003","archivedFiles":[]}')" +assert_status "$PROC_SUMMARY_FAIL_STATUS" "200" "archive update for summary failure" "$TMP_DIR/proc-summary-fail-archive.json" +run_outbox_once success client +summary_dead_state="$(query_outbox_by_key 'erp.procurement.export' "$SUMMARY_DEDUPE")" +jq -e '.status == "dead_letter" and .attempts == 1' <<<"$summary_dead_state" >/dev/null +SUMMARY_DEAD_ID="$(jq -er '.id' <<<"$summary_dead_state")" +PROC_SUMMARY_REQUEUE_CODE="$(api_json_auth POST "/api/integrations/erp/procurement/purchase-requests/$PURCHASE_ID_APPROVED/requeue?runNow=0" "$FINANCE_MGR_TOKEN" "$TMP_DIR/proc-summary-requeue.json" '{"targets":["archive_summary"]}')" +assert_status "$PROC_SUMMARY_REQUEUE_CODE" "200" "procurement summary requeue" "$TMP_DIR/proc-summary-requeue.json" +jq -e --arg id "$SUMMARY_DEAD_ID" '.data.items[0].outboxId == $id and .data.items[0].requeued == true' "$TMP_DIR/proc-summary-requeue.json" >/dev/null +run_outbox_once success success +summary_requeue_state="$(query_outbox_by_key 'erp.procurement.export' "$SUMMARY_DEDUPE")" +jq -e --arg id "$SUMMARY_DEAD_ID" '.id == $id and .status == "succeeded"' <<<"$summary_requeue_state" >/dev/null + + echo "[13/13] ERP env gate off blocks sync" if kill -0 "$APP_SERVER_PID" >/dev/null 2>&1; then kill "$APP_SERVER_PID" >/dev/null 2>&1 || true wait "$APP_SERVER_PID" >/dev/null 2>&1 || true @@ -705,8 +1103,10 @@ OCR_PROVIDER=mock \ ERP_SYNC_ENABLED=false \ ERP_SYNC_PROVIDER=http \ ERP_MASTER_DATA_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/master-data/pull?mode=success" \ -ERP_JOURNAL_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/journals/reimbursements/paid?mode=success" \ +ERP_JOURNAL_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/journals/export?mode=success" \ +ERP_PROCUREMENT_EXPORT_ENDPOINT="http://127.0.0.1:$MOCK_ERP_PORT/erp/procurement/export?mode=success" \ ERP_SYNC_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \ +ERP_PROCUREMENT_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \ ERP_MASTER_DATA_TIMEOUT_MS="$ERP_SYNC_TIMEOUT_MS" \ ERP_SYNC_AUTH_TOKEN="" \ node src/index.js >"$TMP_DIR/app-gate-off.log" 2>&1 & diff --git a/server/tests/unit/services/erpJournal.test.js b/server/tests/unit/services/erpJournal.test.js index d8f71942..4a211574 100644 --- a/server/tests/unit/services/erpJournal.test.js +++ b/server/tests/unit/services/erpJournal.test.js @@ -72,7 +72,7 @@ describe('ERP Journal Service', () => { dedupe_key: 'reimbursement:rb-1:paid', }, }, - select: { id: true, status: true }, + select: { id: true, status: true, payload: true }, }); expect(prisma.integrationOutbox.create).toHaveBeenCalled(); expect(result.eventType).toBe('erp.journal.export');