From f418f9a05fca9fdc0264a435ffe09eef1be62b5e Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 27 Oct 2026 18:27:56 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=B9=E7=94=A8=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 10 +- ...rseas-procurement-legacy-tables-retire.sql | 102 + ...rseas-procurement-table-cleanup-review.sql | 38 + .../overseas-procurement-test-data-reset.sql | 65 + deploy/sql/海外区域统一增量-MySQL8.sql | 263 +- .../docs/procurement-table-cleanup.md | 66 + lastleg-service/docs/procurement-workflow.md | 2 + .../mapper/OverseasFeeBusinessMapper.java | 1 - .../ProcurementChargeController.java | 77 - .../ProcurementControlController.java | 45 - .../controller/ProcurementFileController.java | 2 +- .../ProcurementLifecycleController.java | 64 - .../ProcurementPhysicalController.java | 82 - .../ProcurementPlatformIdentity.java | 17 + .../ProcurementTransportController.java | 59 - .../ProcurementWorkflowController.java | 2 +- .../dto/ProcurementChargeRequests.java | 35 - .../dto/ProcurementControlRequests.java | 25 - .../dto/ProcurementLifecycleRequests.java | 23 - .../dto/ProcurementPhysicalRequests.java | 37 - .../dto/ProcurementTransportRequests.java | 38 - .../entity/OverseasProcurementFee.java | 53 - .../mapper/OverseasProcurementFeeMapper.java | 13 - .../mapper/ProcurementDirectionMapper.java | 14 - .../ProcurementDirectionRepository.java | 14 - .../ProcurementFeeNumberRepository.java | 27 - .../service/OverseasProcurementService.java | 16 +- .../service/ProcurementAdjustmentService.java | 114 - .../service/ProcurementChargeService.java | 113 - .../service/ProcurementControlService.java | 148 - .../service/ProcurementErrors.java | 13 + .../service/ProcurementFeePurpose.java | 14 - .../service/ProcurementFinanceService.java | 533 --- .../service/ProcurementLifecycleService.java | 252 -- .../service/ProcurementPhysicalService.java | 334 -- .../service/ProcurementQueryService.java | 161 +- .../service/ProcurementTransportService.java | 279 -- .../ProcurementWarehouseDirectory.java | 7 +- .../service/ProcurementWorkflowService.java | 4 +- .../src/main/resources/db/lastleg.sql | 245 -- .../openapi/procurement-fulfillment-v1.yaml | 3432 +---------------- .../openapi/procurement-workflow-v1.yaml | 2 +- ...OverseasProcurementSchemaContractTest.java | 6 +- ...ocurementLegacyRetirementContractTest.java | 50 + .../ProcurementRequestBoundaryTest.java | 15 +- ...ProcurementLifecycleAuthorizationTest.java | 145 - .../OverseasProcurementServiceTest.java | 6 - .../ProcurementChargePersistenceTest.java | 260 -- .../ProcurementControlPersistenceTest.java | 130 - .../ProcurementDirectionPersistenceTest.java | 31 - .../ProcurementFinancePersistenceTest.java | 287 -- .../ProcurementPhysicalPersistenceTest.java | 238 -- .../ProcurementPurchasePersistenceTest.java | 184 - .../service/ProcurementQueryServiceTest.java | 141 +- .../ProcurementTransportPersistenceTest.java | 204 - .../ProcurementWorkflowPersistenceTest.java | 9 +- 56 files changed, 649 insertions(+), 7898 deletions(-) create mode 100644 deploy/sql/overseas-procurement-legacy-tables-retire.sql create mode 100644 deploy/sql/overseas-procurement-table-cleanup-review.sql create mode 100644 deploy/sql/overseas-procurement-test-data-reset.sql create mode 100644 lastleg-service/docs/procurement-table-cleanup.md delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementChargeController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementControlController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPhysicalController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPlatformIdentity.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementTransportController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementChargeRequests.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementControlRequests.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementLifecycleRequests.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementPhysicalRequests.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementTransportRequests.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementDirectionMapper.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementDirectionRepository.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementFeeNumberRepository.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementErrors.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFeePurpose.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementLegacyRetirementContractTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementDirectionPersistenceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index b36b94d0b..f4f31a11a 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,9 +1,13 @@ # 单测试环境数据库空库初始化 +`overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。 + `finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。 `overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。 +`overseas-procurement-table-cleanup-review.sql` 为海外采购 27 表的只读核查,元数据行数为估算,另输出可手工执行的精确 COUNT。2026-10-27 已解除 21 张旧采购专属表的生产代码依赖,当前基线只保留六表。`overseas-procurement-legacy-tables-retire.sql` 为单独审阅执行的 MySQL 8.0+ 精确删表清单:仅 `baiyue_lastleg` 的 21 张旧采购表,不修改共用财务、资金、文件或仓储表。须先备份、停止旧版本写入、部署分离版本并验证,在维护窗口同连接设置脚本指定发布确认变量;六表缺失、旧状态采购或旧资金记录存在时拒绝。前置检查在业务 DROP 前完成,不关闭外键校验,使用一条 DROP TABLE IF EXISTS,可在完整成功后重跑;DDL 隐式提交,失败立即停止,禁止 --force。凭据及实例由执行者选择,不内置或自动连接。本轮未执行数据库或部署;[范围、清单及顺序](../../lastleg-service/docs/procurement-table-cleanup.md)。V14—V21 旧采购迁移仅供追溯,退役后不得重放。 + `overseas-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。 车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。 @@ -85,7 +89,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 238 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 222 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 @@ -108,7 +112,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/238`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/222`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -209,7 +213,7 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test 编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。 -海外费用与仓租首版统一维护在 `头程数据库统一增量-MySQL8.sql` 的 IAM 菜单/管理员授权种子及 `海外区域统一增量-MySQL8.sql` 的最终建表定义中。海外基线包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租快照、审核人名称,以及采购退款请求幂等字段和唯一索引;同步模块源基线、国内权限种子及海外空库 238 表,复用现有 `FEE_TYPE_077 / 仓储费`,不新增费用项目种子。发布须同时更新 Firstleg、Lastleg、Admin 与海外 PDA;规则、配置及资金验收见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。仅整理文件,未执行 SQL 或部署。 +海外费用与仓租首版统一维护在 `头程数据库统一增量-MySQL8.sql` 的 IAM 菜单/管理员授权种子及 `海外区域统一增量-MySQL8.sql` 的最终建表定义中。海外基线包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租快照及审核人名称;采购旧费用与退款专属表已在简化流程退役,不再进入空库初始化。同步模块源基线、国内权限种子及海外空库 222 表,复用现有 `FEE_TYPE_077 / 仓储费`,不新增费用项目种子。发布须同时更新 Firstleg、Lastleg、Admin 与海外 PDA;规则、配置及资金验收见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。仅整理文件,未执行 SQL 或部署。 本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。 diff --git a/deploy/sql/overseas-procurement-legacy-tables-retire.sql b/deploy/sql/overseas-procurement-legacy-tables-retire.sql new file mode 100644 index 000000000..f7a806dac --- /dev/null +++ b/deploy/sql/overseas-procurement-legacy-tables-retire.sql @@ -0,0 +1,102 @@ +-- 海外采购旧流程专属表退役,MySQL 8.0+,目标仅 baiyue_lastleg。 +-- 必须先停止旧版 Lastleg 写入并部署本次分离版本,验证新流程后在维护窗口执行。 +-- 永久删除下面明确列出的 21 张旧采购表及其记录,DDL 隐式提交;禁止 mysql --force。 +-- 保留主单、审核流水、采购附件、分批收货、出库及幂等六表;不改任何共用财务、资金、文件或仓储表。 +-- 旧资金表有记录或旧状态采购未处理时拒绝,不自动清除、回填或伪造资金完成。 +-- 运行者须在同一连接显式 SET @procurement_retirement_release='procurement-six-tables-20261027'; +-- 文件准备不表示数据库已执行。先审核备份及运行版本,按清单整份执行,遇错停止。 +USE baiyue_lastleg; +DELIMITER $$ +DROP PROCEDURE IF EXISTS retire_procurement_legacy_tables$$ +CREATE PROCEDURE retire_procurement_legacy_tables() +BEGIN + IF DATABASE()<>'baiyue_lastleg' OR COALESCE(@procurement_retirement_release,'')<>'procurement-six-tables-20261027' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Confirm the six-table procurement release and target schema first'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ( + 'll_overseas_procurement_order','ll_overseas_procurement_status_history','ll_overseas_procurement_file', + 'll_procurement_workflow_receipt','ll_procurement_workflow_outbound','ll_procurement_workflow_command'))<>6 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Six active procurement tables must exist before retiring legacy tables'; + END IF; + IF EXISTS(SELECT 1 FROM ll_overseas_procurement_order WHERE status NOT IN + ('PENDING_REVIEW','RECEIVING','WAIT_CUSTOMER_CONFIRM','CONFIRMED','IN_TRANSIT','DELIVERING','COMPLETED','REJECTED','CANCELLED')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement orders remain; resolve them before retirement'; + END IF; + -- 仅只读核对共用资金记录;不删除资金命令或回执,不把已投递当作已结清。 + IF EXISTS(SELECT 1 FROM ll_event_outbox WHERE aggregate_type='PROCUREMENT_FEE') + OR EXISTS(SELECT 1 FROM ll_wallet_event_inbox + WHERE JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.businessType'))='PROCUREMENT_FEE') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement wallet events exist; financial review is required'; + END IF; + IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_fee') THEN + SET @procurement_retirement_financial_rows=0; + SET @procurement_retirement_check_sql='SELECT COUNT(*) INTO @procurement_retirement_financial_rows FROM baiyue_lastleg.ll_overseas_procurement_fee'; + PREPARE procurement_retirement_check FROM @procurement_retirement_check_sql; + EXECUTE procurement_retirement_check; + DEALLOCATE PREPARE procurement_retirement_check; + IF @procurement_retirement_financial_rows<>0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement financial records remain; do not delete financial evidence'; + END IF; + END IF; + IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_procurement_wallet_attempt') THEN + SET @procurement_retirement_financial_rows=0; + SET @procurement_retirement_check_sql='SELECT COUNT(*) INTO @procurement_retirement_financial_rows FROM baiyue_lastleg.ll_procurement_wallet_attempt'; + PREPARE procurement_retirement_check FROM @procurement_retirement_check_sql; + EXECUTE procurement_retirement_check; + DEALLOCATE PREPARE procurement_retirement_check; + IF @procurement_retirement_financial_rows<>0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy procurement financial records remain; do not delete financial evidence'; + END IF; + END IF; + -- 不关闭外键校验;来自其他业务表的外键也会使这条精确删除失败。 + DROP TABLE IF EXISTS + ll_procurement_closure_item, + ll_procurement_closure, + ll_procurement_control_note, + ll_procurement_fee_correction_item, + ll_procurement_fee_correction, + ll_procurement_repack, + ll_procurement_estimate_review, + ll_procurement_label_item, + ll_procurement_label_set, + ll_procurement_shipment_item, + ll_procurement_shipment, + ll_procurement_carton_source, + ll_procurement_carton, + ll_procurement_manifest, + ll_procurement_disposition, + ll_procurement_receipt, + ll_procurement_budget_authorization, + ll_procurement_command, + ll_procurement_flow, + ll_procurement_wallet_attempt, + ll_overseas_procurement_fee; +END$$ +CALL retire_procurement_legacy_tables()$$ +DROP PROCEDURE retire_procurement_legacy_tables$$ +DELIMITER ; + +-- 若上面任一步失败必须停止。剩余旧表数应为 0;此 SELECT 不代替客户端退出码核对。 +SELECT COUNT(*) AS remaining_legacy_procurement_tables +FROM information_schema.tables WHERE table_schema='baiyue_lastleg' AND table_name IN ( +'ll_overseas_procurement_fee', +'ll_procurement_wallet_attempt', +'ll_procurement_flow', +'ll_procurement_command', +'ll_procurement_budget_authorization', +'ll_procurement_receipt', +'ll_procurement_disposition', +'ll_procurement_manifest', +'ll_procurement_carton', +'ll_procurement_carton_source', +'ll_procurement_shipment', +'ll_procurement_shipment_item', +'ll_procurement_label_set', +'ll_procurement_label_item', +'ll_procurement_estimate_review', +'ll_procurement_repack', +'ll_procurement_fee_correction', +'ll_procurement_fee_correction_item', +'ll_procurement_control_note', +'ll_procurement_closure', +'ll_procurement_closure_item'); diff --git a/deploy/sql/overseas-procurement-table-cleanup-review.sql b/deploy/sql/overseas-procurement-table-cleanup-review.sql new file mode 100644 index 000000000..706da9357 --- /dev/null +++ b/deploy/sql/overseas-procurement-table-cleanup-review.sql @@ -0,0 +1,38 @@ +-- 海外采购退役表只读核查;2026-10-27 分离版本。MySQL 8.0+,仅查询,不自动执行删除。 +-- 6 张当前业务表保留;21 张旧采购专属表已从生产代码和初始化基线移除。 +-- 只有先部署分离版本并通过 legacy-tables-retire.sql 前置校验后,才可执行精确删表清单。 +-- 元数据行数是估算;actual_count_sql 供逐表只读核对,不自动执行。 +WITH inventory AS ( + SELECT 'll_overseas_procurement_order' AS table_name, 'KEEP' AS disposition + UNION ALL SELECT 'll_overseas_procurement_status_history' AS table_name, 'KEEP' AS disposition + UNION ALL SELECT 'll_overseas_procurement_file' AS table_name, 'KEEP' AS disposition + UNION ALL SELECT 'll_procurement_workflow_receipt' AS table_name, 'KEEP' AS disposition + UNION ALL SELECT 'll_procurement_workflow_outbound' AS table_name, 'KEEP' AS disposition + UNION ALL SELECT 'll_procurement_workflow_command' AS table_name, 'KEEP' AS disposition + UNION ALL SELECT 'll_overseas_procurement_fee' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_wallet_attempt' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_flow' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_command' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_budget_authorization' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_receipt' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_disposition' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_manifest' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_carton' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_carton_source' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_shipment' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_shipment_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_label_set' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_label_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_estimate_review' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_repack' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_fee_correction' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_fee_correction_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_control_note' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_closure' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition + UNION ALL SELECT 'll_procurement_closure_item' AS table_name, 'RETIRED_RELEASE_REQUIRED' AS disposition +) +SELECT i.table_name,i.disposition,CASE WHEN t.table_name IS NULL THEN 'MISSING' ELSE 'EXISTS' END AS database_state, + t.table_rows AS table_rows_estimate, + CASE WHEN t.table_name IS NOT NULL THEN CONCAT('SELECT COUNT(*) AS actual_rows FROM baiyue_lastleg.',i.table_name,';') END AS actual_count_sql +FROM inventory i LEFT JOIN information_schema.tables t ON t.table_schema='baiyue_lastleg' AND t.table_name=i.table_name +ORDER BY i.disposition,i.table_name; diff --git a/deploy/sql/overseas-procurement-test-data-reset.sql b/deploy/sql/overseas-procurement-test-data-reset.sql new file mode 100644 index 000000000..f835f402f --- /dev/null +++ b/deploy/sql/overseas-procurement-test-data-reset.sql @@ -0,0 +1,65 @@ +-- 仅用于用户确认可丢弃数据的海外采购测试环境,MySQL 8.0+。 +-- 清空六张当前采购表的数据,删除二十一张旧采购表及其数据。 +-- 仅作用于 baiyue_lastleg 的采购专属表;不修改共用费用、余额、文件对象及库存。 +-- 共用表中已有的采购关联记录不会自动删除或退款;本脚本不用于财务结算。 +-- 先更新分离后的后端并停止采购写入,整份执行,遇错停止,不使用 --force。 +-- 无存储过程、无 DELIMITER、无需设置会话确认变量;DDL 隐式提交。 +USE baiyue_lastleg; +START TRANSACTION; +DELETE FROM ll_procurement_workflow_command; +DELETE FROM ll_procurement_workflow_outbound; +DELETE FROM ll_procurement_workflow_receipt; +DELETE FROM ll_overseas_procurement_file; +DELETE FROM ll_overseas_procurement_status_history; +DELETE FROM ll_overseas_procurement_order; +COMMIT; + +DROP TABLE IF EXISTS + ll_procurement_closure_item, + ll_procurement_closure, + ll_procurement_control_note, + ll_procurement_fee_correction_item, + ll_procurement_fee_correction, + ll_procurement_repack, + ll_procurement_estimate_review, + ll_procurement_label_item, + ll_procurement_label_set, + ll_procurement_shipment_item, + ll_procurement_shipment, + ll_procurement_carton_source, + ll_procurement_carton, + ll_procurement_manifest, + ll_procurement_disposition, + ll_procurement_receipt, + ll_procurement_budget_authorization, + ll_procurement_command, + ll_procurement_flow, + ll_procurement_wallet_attempt, + ll_overseas_procurement_fee; + +SELECT 'procurement_test_reset_complete' AS result, + (SELECT COUNT(*) FROM ll_overseas_procurement_order) AS procurement_orders, + (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_lastleg' AND table_name IN ( + 'll_overseas_procurement_fee', + 'll_procurement_wallet_attempt', + 'll_procurement_flow', + 'll_procurement_command', + 'll_procurement_budget_authorization', + 'll_procurement_receipt', + 'll_procurement_disposition', + 'll_procurement_manifest', + 'll_procurement_carton', + 'll_procurement_carton_source', + 'll_procurement_shipment', + 'll_procurement_shipment_item', + 'll_procurement_label_set', + 'll_procurement_label_item', + 'll_procurement_estimate_review', + 'll_procurement_repack', + 'll_procurement_fee_correction', + 'll_procurement_fee_correction_item', + 'll_procurement_control_note', + 'll_procurement_closure', + 'll_procurement_closure_item' + )) AS remaining_legacy_procurement_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index b01093437..1b376435e 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1,5 +1,5 @@ -- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 --- 海外费用资金、仓租四表、审核人名称快照、采购退款请求幂等字段及唯一索引均在本文件最终建表定义中维护。 +-- 海外费用资金、仓租四表、审核人名称快照及简化采购六表均在本文件最终建表定义中维护。 -- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 @@ -2060,53 +2060,6 @@ CREATE TABLE ll_outbound_pda_task ( UNIQUE KEY uk_ll_outbound_pda_complete_key (customer_id,completion_idempotency_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务'; -CREATE TABLE ll_overseas_procurement_fee ( - purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定', - confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作', - fee_version INT NULL COMMENT '同用途费用确认版本', - active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本', - determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用', - original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD', - original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额', - exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照', - exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份', - business_date DATE NULL COMMENT '实际费用业务日期', - wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录', - wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估', - wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', - wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用', - settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件', - source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID', - parent_fee_id BIGINT NULL COMMENT '原费用或预估引用', - idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号', - request_hash CHAR(64) NULL COMMENT '确认请求摘要', - id bigint NOT NULL COMMENT '主键ID', - order_id bigint NOT NULL COMMENT '采购订单ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', - fee_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用业务号', - fee_item_code varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项编码', - fee_item_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项名称', - amount decimal(20,2) NOT NULL COMMENT 'CNY金额', - currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种', - voucher_file_id bigint NULL COMMENT '凭证文件ID', - voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NULL COMMENT '凭证文件名', - remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '说明', - wallet_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金状态', - wallet_error_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码', - wallet_error_message varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误说明', - wallet_command_event_id varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前资金指令事件ID', - created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登记人', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')), - UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version), - UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key), - PRIMARY KEY (id), - UNIQUE KEY uk_ll_procurement_fee_no (fee_no), - KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at), - CONSTRAINT chk_ll_procurement_fee_amount CHECK ((amount >= 0)), - CONSTRAINT chk_ll_procurement_fee_currency CHECK ((currency = _utf8mb4'CNY')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购费用明细及资金生命周期'; CREATE TABLE ll_overseas_procurement_file ( id bigint NOT NULL COMMENT '主键ID', @@ -3977,245 +3930,33 @@ CREATE TABLE ll_return_carton_count_session ( UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) ) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; -CREATE TABLE ll_procurement_wallet_attempt ( - id BIGINT NOT NULL COMMENT '请求尝试ID', - order_id BIGINT NOT NULL COMMENT '采购订单ID', - customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID', - attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号', - operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款', - business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号', - amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空', - command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用', - status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败', - result_hash CHAR(64) NULL COMMENT '原业务回执摘要', - wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', - wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用', - error_code VARCHAR(64) NULL COMMENT '业务失败码', - error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间', - completed_at DATETIME(3) NULL COMMENT '最终结果时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id), - UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no), - KEY idx_procurement_attempt_order(customer_id,order_id,status), - CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果'; -- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。 -CREATE TABLE ll_procurement_flow ( - order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次', - customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - purchased_at DATETIME(3) NULL COMMENT '真实购买时间', - purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品', - purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格', - purchased_quantity BIGINT NULL COMMENT '实际购买件数', - purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额', - purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD', - purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用', - purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明', - purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功', - actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID', - actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照', - actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照', - overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间', - packing_version INT NULL COMMENT '当前确认装箱清单版本', - overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间', - domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间', - charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间', - customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间', - domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人', - domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话', - domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址', - cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间', - closed_at DATETIME(3) NULL COMMENT '异常关闭时间', - control_reason VARCHAR(1000) NULL COMMENT '取消或关闭原因', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id), - CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0), - CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实'; -CREATE TABLE ll_procurement_command ( - id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', - customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作', - idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要', - operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据', - detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key), - KEY idx_procurement_command_customer(customer_id,order_id,created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计'; -CREATE TABLE ll_procurement_budget_authorization ( - id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', - customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种', - authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种', - voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据', - reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间', - PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id), - CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0), - CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史'; -- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。 -CREATE TABLE ll_procurement_receipt ( - id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', - source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓', - good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量', - received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实', - supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active), - CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0), - CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实'; -CREATE TABLE ll_procurement_disposition ( - id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源', - receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货', - quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货', - active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实', - voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据', - created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', - PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind), - CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款'; -CREATE TABLE ll_procurement_manifest ( - id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代', - confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version), - CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本'; -CREATE TABLE ll_procurement_carton ( - id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本', - carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米', - width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米', - gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id), - CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数'; -CREATE TABLE ll_procurement_carton_source ( - carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数', - PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量'; -CREATE TABLE ll_procurement_shipment ( - id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户', - manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间', - address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no), - CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄'; -CREATE TABLE ll_procurement_shipment_item ( - shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数', - carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照', - label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照', - PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实'; USE baiyue_lastleg; -- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。 -CREATE TABLE ll_procurement_label_set ( - id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本', - manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要', - sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照', - created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version), - CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照'; -CREATE TABLE ll_procurement_label_item ( - label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID', - file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始', - carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号', - applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人', - PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实'; -CREATE TABLE ll_procurement_estimate_review ( - id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本', - fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员', - reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联'; -CREATE TABLE ll_procurement_repack ( - id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源', - length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米', - height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克', - voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据', - created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', - PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id), - CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史'; USE baiyue_lastleg; -- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。 -CREATE TABLE ll_procurement_fee_correction ( - id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根', - idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要', - target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种', - exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份', - target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY', - difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录', - voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据', - created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key), - KEY idx_procurement_correction_root(order_id,root_fee_id,id), - CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照'; -CREATE TABLE ll_procurement_fee_correction_item ( - correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID', - PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联'; USE baiyue_lastleg; -- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。 -CREATE TABLE ll_procurement_control_note ( - id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型', - target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约', - voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明', - client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容', - original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照', - created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', - resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间', - resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常'; -CREATE TABLE ll_procurement_closure ( - id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证', - financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实', - reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要', - created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实'; -CREATE TABLE ll_procurement_closure_item ( - id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID', - source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空', - quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置', - destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明', - PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id), - CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; USE baiyue_lastleg; @@ -5156,4 +4897,4 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command ( PRIMARY KEY (order_id,command_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 243 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 222 AS initialized_tables; diff --git a/lastleg-service/docs/procurement-table-cleanup.md b/lastleg-service/docs/procurement-table-cleanup.md new file mode 100644 index 000000000..b80eb6719 --- /dev/null +++ b/lastleg-service/docs/procurement-table-cleanup.md @@ -0,0 +1,66 @@ +# 海外采购旧流程表退役 + +2026-10-27 已完成代码分离。只处理海外采购专属模型和业务表,不删除或更新共用财务、资金、File、仓储库存或其他海外业务表。尚未执行目标数据库,也未部署运行服务。 + +采购专属表从 27 张收敛为 6 张;21 张旧表已从生产 Java 和两份初始化基线解除引用。原只读核查发现的阻塞现已在本次修改中处理,不能在旧版服务上直接执行删表脚本。 + +## 保留六表 + +| 表 | 用途 | +|---|---| +| ll_overseas_procurement_order | 采购主单、方向、审核仓库与当前状态 | +| ll_overseas_procurement_status_history | 创建、审核及备注修改流水 | +| ll_overseas_procurement_file | 收货、签收附件归属 | +| ll_procurement_workflow_receipt | 分批实收及照片 | +| ll_procurement_workflow_outbound | 分批出库、物流和签收 | +| ll_procurement_workflow_command | 批次操作幂等及审计 | + +## 精确退役清单(21 张) + +- `ll_overseas_procurement_fee` +- `ll_procurement_wallet_attempt` +- `ll_procurement_flow` +- `ll_procurement_command` +- `ll_procurement_budget_authorization` +- `ll_procurement_receipt` +- `ll_procurement_disposition` +- `ll_procurement_manifest` +- `ll_procurement_carton` +- `ll_procurement_carton_source` +- `ll_procurement_shipment` +- `ll_procurement_shipment_item` +- `ll_procurement_label_set` +- `ll_procurement_label_item` +- `ll_procurement_estimate_review` +- `ll_procurement_repack` +- `ll_procurement_fee_correction` +- `ll_procurement_fee_correction_item` +- `ll_procurement_control_note` +- `ll_procurement_closure` +- `ll_procurement_closure_item` + +## 代码分离 + +- ProcurementQueryService 只读取主单、附件、审核流水及共用人工海外费用,不再执行旧预算、装箱、面单、运输、异常和采购费用查询。客户投影仍过滤客户、主单、业务类型和应收方向,内部说明不公开。 +- 创建、审核、编辑响应不再访问旧采购费用表;仓库直接取审核主单。原响应中的旧区块保留空容器,避免当前前端适配器访问缺失数组,背后没有旧业务表依赖。 +- 旧购买、预算核验、物理装箱、运输面单、费用核定及异常处置 Controller/Service/DTO 已删除。新批次接口和独立上传、客户确认、文件权限保持不变。 +- 旧采购专属 WalletResultHandler、费用 Mapper/Entity、方向事实 Mapper 和专属退款编号 Repository 已移除;共用资金消费者、Outbox/Inbox 和共用海外费用审核链不修改。 +- 共用费用业务白名单仅移除已退役 PROCUREMENT_FEE 子业务实体查表项,OVERSEAS_PROCUREMENT 主单继续沿原共用应收、冻结、审核和实扣链。 +- 旧履约 OpenAPI 仅保留当前文件接口与详情投影;七状态流程独立契约继续保留。旧实现测试随旧功能退役;现行分批数量、库存、客户隔离、幂等、费用门禁与权限测试保留,并新增仅六表可运行的真实 Mapper 详情/审核测试和退役范围合同。 + +## 脚本与执行顺序 + +1. [只读核查 SQL](../../deploy/sql/overseas-procurement-table-cleanup-review.sql):查看 27 张表存在状态及行数估算,支持重复执行,不需要业务写权限。 +2. 完成快照/备份,停止旧服务写入并部署本次 Lastleg 分离版本;在维护窗口验证现行主流程。必须先部署、再删除旧表。不能把脚本存在视为已发布。 +3. 审阅 [精确删表 SQL](../../deploy/sql/overseas-procurement-legacy-tables-retire.sql)。在指定 MySQL 8.0+ 实例的同一连接设置脚本声明的发布确认变量,然后整份执行,禁止遇错继续。脚本只对 baiyue_lastleg 的 21 张清单表执行一条 DROP TABLE IF EXISTS;DDL 隐式提交,不承诺事务回滚。 +4. 脚本先验证六张当前表存在、无旧状态采购、旧资金表无任何记录,并只读检查共用资金 Outbox/Inbox 中是否仍有 PROCUREMENT_FEE 证据。有旧资金记录就停止,不自动删除、迁移或伪造结清,也不修改共用表。非空旧物理/运输/异常表会随 DROP 永久删除,须在执行前核对备份及范围。 +5. 前置检查在首个业务 DROP 前完成;不关闭外键校验,有外部引用则失败。完整成功后可重复执行,已删除表按 IF EXISTS 跳过。失败时停止、保留原错误并核查现状;最终剩余旧表数应为 0,同时确认客户端退出码为 0。 + +只读脚本不证明数据为空,删除脚本也不能自动识别运行中的二进制版本。V14—V21 旧采购迁移仅用于历史追溯,不得在本次退役后重放;当前 Lastleg 基线与海外空库基线已删除这 21 张定义。不得用本脚本删除六张现用表或任何共用费用、余额、文件对象及库存数据。 + +## 本次验证 + +- 后端现行采购及退役契约 55 项、管理端相关 67 项、Gateway 路由 5 项和脚本文档契约 4 项通过;Mapper 扫描通过。 +- 两份初始化 SQL 与修改前逐表比较:均只移除这 21 张表,其余表定义完全一致;生产 Java 中没有这 21 张旧表名引用。 +- Lastleg 架构检查 15 项中 14 项通过,无新增跨业务依赖;车队检查仍因 dispatch 访问 fleet 持久化 Mapper 失败,该问题不在本次采购修改范围。 +- 未执行目标 MySQL、未部署、未进行线上页面验收;SQL 仅完成静态范围及防护契约检查。 diff --git a/lastleg-service/docs/procurement-workflow.md b/lastleg-service/docs/procurement-workflow.md index 86a6b551f..f9f257721 100644 --- a/lastleg-service/docs/procurement-workflow.md +++ b/lastleg-service/docs/procurement-workflow.md @@ -26,6 +26,8 @@ ## 验收范围 +2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。 + - 创建采购单后为待审核,审核后直接收货中。 - 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。 - 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeBusinessMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeBusinessMapper.java index a4b50b30d..0272cbfea 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeBusinessMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeBusinessMapper.java @@ -72,7 +72,6 @@ public interface OverseasFeeBusinessMapper { return switch (type) { case "OVERSEAS_INBOUND" -> new String[]{"ll_inbound_plan", "plan_no", "NULL", "NULL"}; case "OVERSEAS_PROCUREMENT" -> new String[]{"ll_overseas_procurement_order", "order_no", "NULL", "NULL"}; - case "PROCUREMENT_FEE" -> new String[]{"ll_overseas_procurement_fee", "fee_no", "NULL", "NULL"}; case "US_TRUCK" -> new String[]{"ll_us_truck_order", "order_no", "NULL", "NULL"}; case "LOCAL_LOOSEBOARD" -> new String[]{"ll_local_looseboard_order", "order_no", "NULL", "NULL"}; case "LASTLEG_ORDER" -> new String[]{"ll_order", "order_no", "t.overseas_warehouse_id", "t.order_type"}; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementChargeController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementChargeController.java deleted file mode 100644 index 1234d76b9..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementChargeController.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.baiyue.lastleg.procurement.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.*; -import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; -import com.baiyue.lastleg.procurement.service.ProcurementChargeService; -import com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService; -import com.baiyue.lastleg.security.OverseasPermissions; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; -import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; - -@RestController @RequiredArgsConstructor -@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") -public class ProcurementChargeController { - private final ProcurementChargeService service; - private final ProcurementAdjustmentService adjustments; - @GetMapping("/{orderNo}/charges") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") - public ApiResponse read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { - platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo)); - } - @GetMapping("/{orderNo}/fee-corrections") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") - public ApiResponse> corrections(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { - platform(user,customerId);return ApiResponse.success(adjustments.history(customerId,orderNo)); - } - @PostMapping("/{orderNo}/charges/confirm") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_CHARGE+"')") - public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Confirm request) { - return ApiResponse.success(service.confirm(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/fees/additional") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')") - public ApiResponse additional(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Additional request) { - return ApiResponse.success(adjustments.additional(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/fees/{feeId}/retry") - @PreAuthorize("isAuthenticated()") - public ApiResponse retry(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Command request) { - String operator=platform(user,customerId);String permission=service.retryPermission(customerId,orderNo,feeId); - if(user.permissions()==null || !user.permissions().contains(permission))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该费用用途的重试权限"); - return ApiResponse.success(service.retry(customerId,orderNo,operator,feeId,request)); - } - @PostMapping("/{orderNo}/fees/{feeId}/terminate") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')") - public ApiResponse terminate(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Command request) { - return ApiResponse.success(service.terminate(customerId,orderNo,platform(user,customerId),feeId,request)); - } - @PostMapping("/{orderNo}/fees/{feeId}/release-estimate") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')") - public ApiResponse releaseEstimate(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Command request) { - return ApiResponse.success(service.releaseEstimate(customerId,orderNo,platform(user,customerId),feeId,request)); - } - @PostMapping("/{orderNo}/fees/{feeId}/correct-target") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')") - public ApiResponse correct(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Correct request) { - return ApiResponse.success(adjustments.correct(customerId,orderNo,platform(user,customerId),feeId,request)); - } - @PostMapping("/{orderNo}/fees/{feeId}/replace-failed") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')") - public ApiResponse replaceFailed(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Correct request) { - return ApiResponse.success(adjustments.replaceFailed(customerId,orderNo,platform(user,customerId),feeId,request)); - } - @PostMapping("/{orderNo}/fees/{feeId}/refund") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')") - public ApiResponse refund(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Refund request) { - return ApiResponse.success(adjustments.refund(customerId,orderNo,platform(user,customerId),feeId,request)); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementControlController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementControlController.java deleted file mode 100644 index fb203bbe2..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementControlController.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.baiyue.lastleg.procurement.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.procurement.dto.ProcurementControlRequests.*; -import com.baiyue.lastleg.procurement.service.ProcurementControlService; -import com.baiyue.lastleg.procurement.service.ProcurementControlService.Snapshot; -import com.baiyue.lastleg.security.OverseasPermissions; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; -import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; - -@RestController @RequiredArgsConstructor -@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") -public class ProcurementControlController { - private final ProcurementControlService service; - @GetMapping("/{orderNo}/controls") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") - public ApiResponse read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { - platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo)); - } - @PostMapping("/{orderNo}/control-notes") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") - public ApiResponse note(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Note request) { - return ApiResponse.success(service.note(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/control-notes/{noteId}/resolve") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") - public ApiResponse resolve(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long noteId,@Valid @RequestBody Resolve request) { - return ApiResponse.success(service.resolve(customerId,orderNo,platform(user,customerId),noteId,request)); - } - @PostMapping("/{orderNo}/actual-warehouse/correct") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") - public ApiResponse warehouse(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody WarehouseCorrection request) { - return ApiResponse.success(service.correctWarehouse(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/exception-close") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") - public ApiResponse close(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Close request) { - return ApiResponse.success(service.close(customerId,orderNo,platform(user,customerId),request)); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java index d4f3383cd..010772c6c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java @@ -15,7 +15,7 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; import java.util.Set; -import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; +import static com.baiyue.lastleg.procurement.controller.ProcurementPlatformIdentity.platform; @RestController @RequiredArgsConstructor @RequestMapping("/api/platform/v1/last-leg/overseas-procurements") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleController.java deleted file mode 100644 index b7ec2fd45..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleController.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.baiyue.lastleg.procurement.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*; -import com.baiyue.lastleg.procurement.service.ProcurementLifecycleService; -import com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.Outcome; -import com.baiyue.lastleg.security.OverseasPermissions; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") -public class ProcurementLifecycleController { - private final ProcurementLifecycleService service; - - @GetMapping("/{orderNo}/fulfillment") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") - public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { - platform(user,customerId); return ApiResponse.success(service.read(customerId,orderNo)); - } - @PostMapping("/{orderNo}/purchase") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_BUY+"')") - public ApiResponse purchase(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId, - @PathVariable String orderNo,@Valid @RequestBody Purchase request) { - return ApiResponse.success(service.recordPurchase(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/purchase/confirm") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_BUY+"')") - public ApiResponse confirmPurchase(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId, - @PathVariable String orderNo,@Valid @RequestBody Command request) { - return ApiResponse.success(service.confirmPurchase(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/budget-authorizations") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_REVIEW+"')") - public ApiResponse budget(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId, - @PathVariable String orderNo,@Valid @RequestBody Budget request) { - return ApiResponse.success(service.authorizeBudget(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/planned-warehouse") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_REVIEW+"')") - public ApiResponse warehouse(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId, - @PathVariable String orderNo,@Valid @RequestBody Warehouse request) { - return ApiResponse.success(service.selectWarehouse(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/cancel") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_CANCEL+"')") - public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId, - @PathVariable String orderNo,@Valid @RequestBody Command request) { - return ApiResponse.success(service.cancel(customerId,orderNo,platform(user,customerId),request)); - } - static String platform(AuthenticatedUser user,String customerId) { - if(user==null || user.tenantId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if(user.tenantId()!=0 || !"PLATFORM".equals(user.appCode())) throw new ResponseStatusException(HttpStatus.FORBIDDEN); - if(customerId==null || !customerId.matches("[1-9][0-9]*")) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请指定采购订单客户"); - return user.userId()==null?user.username():String.valueOf(user.userId()); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPhysicalController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPhysicalController.java deleted file mode 100644 index 355e94602..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPhysicalController.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.baiyue.lastleg.procurement.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command; -import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.*; -import com.baiyue.lastleg.procurement.service.ProcurementPhysicalService; -import com.baiyue.lastleg.procurement.service.ProcurementPhysicalService.Snapshot; -import com.baiyue.lastleg.security.OverseasPermissions; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; -import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; - -@RestController @RequiredArgsConstructor -@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") -public class ProcurementPhysicalController { - private final ProcurementPhysicalService service; - @GetMapping("/{orderNo}/physical") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") - public ApiResponse read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { - platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo)); - } - @PostMapping("/{orderNo}/overseas-receipts") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')") - public ApiResponse overseas(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) { - String operator=platform(user,customerId);leg(request.leg(),Leg.OVERSEAS);return ApiResponse.success(service.receive(customerId,orderNo,operator,request)); - } - @PostMapping("/{orderNo}/domestic-receipts") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC+"')") - public ApiResponse domestic(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) { - String operator=platform(user,customerId);leg(request.leg(),Leg.DOMESTIC);return ApiResponse.success(service.receive(customerId,orderNo,operator,request)); - } - @PostMapping("/{orderNo}/overseas-receipts/complete") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')") - public ApiResponse completeOverseas(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody FinishReceipt request) { - String operator=platform(user,customerId);leg(request.leg(),Leg.OVERSEAS);return ApiResponse.success(service.finishReceipt(customerId,orderNo,operator,request)); - } - @PostMapping("/{orderNo}/domestic-receipts/complete") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC+"')") - public ApiResponse completeDomestic(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody FinishReceipt request) { - String operator=platform(user,customerId);leg(request.leg(),Leg.DOMESTIC);return ApiResponse.success(service.finishReceipt(customerId,orderNo,operator,request)); - } - @PostMapping("/{orderNo}/dispositions") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") - public ApiResponse dispose(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Disposition request) { - return ApiResponse.success(service.dispose(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/packing") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_PACK+"')") - public ApiResponse packing(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Packing request) { - return ApiResponse.success(service.savePacking(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/packing/confirm") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_PACK+"')") - public ApiResponse confirmPacking(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody ConfirmPacking request) { - return ApiResponse.success(service.confirmPacking(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/packing/withdraw") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") - public ApiResponse withdraw(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Command request) { - return ApiResponse.success(service.withdrawPacking(customerId,orderNo,platform(user,customerId),request)); - } - private static void leg(Leg actual,Leg expected){if(actual!=expected)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"收货段别与操作入口不一致");} - - @PostMapping("/{orderNo}/receipts/{receiptId}/correct") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") - public ApiResponse correct(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo, - @PathVariable long receiptId,@Valid @RequestBody Correction request) { - return ApiResponse.success(service.correctReceipt(customerId,orderNo,platform(user,customerId),receiptId,request)); - } - @PostMapping("/{orderNo}/dispositions/{dispositionId}/revoke") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") - public ApiResponse revoke(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo, - @PathVariable long dispositionId,@Valid @RequestBody RevokeDisposition request) { - return ApiResponse.success(service.revokeDisposition(customerId,orderNo,platform(user,customerId),dispositionId,request)); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPlatformIdentity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPlatformIdentity.java new file mode 100644 index 000000000..8ed49a510 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPlatformIdentity.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** 采购平台操作统一验证平台主体和明确客户范围。 */ +final class ProcurementPlatformIdentity { + private ProcurementPlatformIdentity() { } + /** 客户身份不能借平台操作权限越权,客户编号不能为空或平台作用域。 */ + static String platform(AuthenticatedUser user, String customerId) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.tenantId() != 0 || !"PLATFORM".equals(user.appCode())) throw new ResponseStatusException(HttpStatus.FORBIDDEN); + if (customerId == null || !customerId.matches("[1-9][0-9]*")) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请指定采购订单客户"); + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementTransportController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementTransportController.java deleted file mode 100644 index 0110c7f85..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementTransportController.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.baiyue.lastleg.procurement.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg; -import com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.*; -import com.baiyue.lastleg.procurement.service.ProcurementTransportService; -import com.baiyue.lastleg.procurement.service.ProcurementTransportService.Snapshot; -import com.baiyue.lastleg.security.OverseasPermissions; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; -import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; - -@RestController @RequiredArgsConstructor -@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") -public class ProcurementTransportController { - private final ProcurementTransportService service; - @GetMapping("/{orderNo}/transport") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") - public ApiResponse read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { - platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo)); - } - @PostMapping("/{orderNo}/labels") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LABEL+"')") - public ApiResponse labels(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Labels request) { - return ApiResponse.success(service.saveLabels(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/international-estimate") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_ESTIMATE+"')") - public ApiResponse estimate(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Estimate request) { - return ApiResponse.success(service.reviewEstimate(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/labels/apply") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_APPLY_LABEL+"')") - public ApiResponse apply(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody ApplyLabels request) { - return ApiResponse.success(service.applyLabels(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/domestic-repack") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC+"')") - public ApiResponse repack(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Repack request) { - return ApiResponse.success(service.repack(customerId,orderNo,platform(user,customerId),request)); - } - @PostMapping("/{orderNo}/overseas-shipment") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_OVERSEAS+"')") - public ApiResponse overseas(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Ship request) { - String operator=platform(user,customerId);leg(request.leg(),Leg.OVERSEAS);return ApiResponse.success(service.ship(customerId,orderNo,operator,request)); - } - @PostMapping("/{orderNo}/customer-shipment") - @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')") - public ApiResponse customer(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Ship request) { - String operator=platform(user,customerId);leg(request.leg(),Leg.DOMESTIC);return ApiResponse.success(service.ship(customerId,orderNo,operator,request)); - } - private static void leg(Leg actual,Leg expected){if(actual!=expected)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"运输段与操作入口不一致");} -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java index 2db0e1f53..4649c137d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementWorkflowController.java @@ -10,7 +10,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; +import static com.baiyue.lastleg.procurement.controller.ProcurementPlatformIdentity.platform; /** 平台简化采购作业,所有写入沿用正式收货及出库权限。 */ @RestController @RequiredArgsConstructor diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementChargeRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementChargeRequests.java deleted file mode 100644 index ca91847fa..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementChargeRequests.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.baiyue.lastleg.procurement.dto; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.List; - -public final class ProcurementChargeRequests { - private ProcurementChargeRequests() { } - public record Amount(@NotBlank @Pattern(regexp="POSITIVE|ZERO|NOT_APPLICABLE") String determination, - @NotNull @DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal amount, - @NotBlank @Pattern(regexp="CNY|USD") String currency,@NotNull LocalDate businessDate, - @Positive Long voucherFileId,@NotBlank @Size(max=500) String reason) { } - /** Existing immutable fee OR a newly determined amount; never both. */ - public record Selection(@Positive Long feeId,@Valid Amount value) { } - public record Other(@NotBlank @Pattern(regexp="[A-Z][A-Z0-9_]{0,31}") String code, - @NotBlank @Size(max=128) String name,@NotNull @Valid Selection selection) { } - public record Confirm(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @NotNull @Valid Selection internationalFreight,@NotNull @Valid Selection duty, - @NotNull @Valid Selection domesticFreight, - @NotBlank @Pattern(regexp="NOT_APPLICABLE|ITEMIZED") String otherDetermination, - @NotNull List<@NotNull @Valid Other> otherFees,@NotBlank @Size(max=1000) String reason) { } - public record Additional(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @NotBlank @Size(max=128) String name, - @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=16,fraction=2) BigDecimal amount, - @NotBlank @Pattern(regexp="CNY|USD") String currency,@NotNull @PastOrPresent LocalDate businessDate, - @Positive long voucherFileId,@NotBlank @Size(max=500) String reason) { } - public record Correct(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @NotNull @DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal targetOriginalAmount, - @Positive long voucherFileId,@NotBlank @Size(max=500) String reason) { } - public record Refund(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=16,fraction=2) BigDecimal amount, - @Positive long voucherFileId,@NotBlank @Size(max=500) String reason) { } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementControlRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementControlRequests.java deleted file mode 100644 index 1c7a9dd4b..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementControlRequests.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.baiyue.lastleg.procurement.dto; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import java.util.List; - -public final class ProcurementControlRequests { - private ProcurementControlRequests() { } - public record Note(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @NotBlank @Pattern(regexp="PURCHASE|WAREHOUSE|RECEIPT|SHIPMENT") String targetType,@Positive Long targetId, - boolean blocking,@Positive long voucherFileId,@NotBlank @Size(max=1000) String reason, - @Size(max=500) String clientSummary) { } - public record Resolve(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { } - public record WarehouseCorrection(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @Positive long warehouseId,@Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { } - public record Resolution(@NotBlank @Pattern(regexp="PURCHASE_REMAINDER|TRANSIT_REMAINDER|GOOD|DAMAGED|EXTRA") String kind, - @Positive Long sourceId,@Positive long quantity, - @NotBlank @Pattern(regexp="SUPPLIER_RETAINED|RETURNED_TO_SUPPLIER|RETURNED_TO_SENDER|LOSS_CONFIRMED|CUSTOMER_AUTHORIZED_RETENTION|DISPOSED") String decision, - @NotBlank @Size(max=500) String destination,@Positive long voucherFileId) { } - public record Close(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @NotNull List<@NotNull @Valid Resolution> resolutions,@Positive long customerVoucherFileId, - @NotBlank @Size(max=1000) String financialConclusion,@NotBlank @Size(max=1000) String reason, - @NotBlank @Size(max=500) String clientSummary) { } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementLifecycleRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementLifecycleRequests.java deleted file mode 100644 index 87dc28d75..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementLifecycleRequests.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.baiyue.lastleg.procurement.dto; - -import jakarta.validation.constraints.*; -import java.math.BigDecimal; -import java.time.LocalDateTime; - -public final class ProcurementLifecycleRequests { - private ProcurementLifecycleRequests() { } - public record Command(@NotNull @Min(0) Long version, @NotBlank @Size(max=128) String idempotencyKey, - @NotBlank @Size(max=1000) String reason) { } - public record Purchase(@NotNull @Min(0) Long version, @NotBlank @Size(max=128) String idempotencyKey, - @NotBlank @Size(max=255) String productName, @Size(max=500) String specification, - @Positive long quantity, @NotNull @DecimalMin("0.00") @Digits(integer=16,fraction=2) BigDecimal amount, - @NotBlank @Pattern(regexp="CNY|USD") String currency, - @NotNull @PastOrPresent LocalDateTime purchasedAt, @NotNull LocalDateTime estimatedArrivalAt, - @Positive long voucherFileId, @NotBlank @Size(max=1000) String reason) { } - public record Budget(@NotNull @Min(0) Long version, @NotBlank @Size(max=128) String idempotencyKey, - @NotNull @DecimalMin("0.00") @Digits(integer=16,fraction=2) BigDecimal amount, - @NotBlank @Pattern(regexp="CNY|USD") String currency, @Positive long voucherFileId, - @NotBlank @Size(max=1000) String reason) { } - public record Warehouse(@NotNull @Min(0) Long version, @NotBlank @Size(max=128) String idempotencyKey, - @Positive long warehouseId, @NotBlank @Size(max=1000) String reason) { } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementPhysicalRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementPhysicalRequests.java deleted file mode 100644 index 4f84eb561..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementPhysicalRequests.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.baiyue.lastleg.procurement.dto; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; - -public final class ProcurementPhysicalRequests { - private ProcurementPhysicalRequests() { } - public enum Leg { OVERSEAS, DOMESTIC } - public record Receipt(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,@NotNull Leg leg, - @Positive Long warehouseId,@Positive Long sourceCartonId, - @Min(0) long goodQuantity,@Min(0) long damagedQuantity,@Min(0) long extraQuantity, - @NotNull @PastOrPresent LocalDateTime receivedAt,@NotBlank @Size(max=1000) String reason) { } - public record FinishReceipt(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,@NotNull Leg leg, - @NotBlank @Size(max=1000) String reason) { } - public record Disposition(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,@NotNull Leg leg, - @Positive Long sourceCartonId,@Positive Long receiptId, - @NotBlank @Pattern(regexp="SHORTAGE|DAMAGED|EXTRA") String kind, - @Positive long quantity,@NotBlank @Pattern(regexp="ACCEPT_SHORTAGE|DISPOSE_DAMAGED|RETURN_EXTRA") String decision, - @Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { } - public record Source(@Positive long receiptId,@Positive long quantity) { } - public record Correction(@NotNull @Valid Receipt receipt,@Positive long voucherFileId) { } - public record RevokeDisposition(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { } - public record Carton(@NotBlank @Size(max=64) String cartonNo, - @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal lengthCm, - @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal widthCm, - @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal heightCm, - @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=9,fraction=3) BigDecimal grossWeightKg, - @NotEmpty List<@NotNull @Valid Source> sources) { } - public record Packing(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @NotEmpty List<@NotNull @Valid Carton> cartons,@NotBlank @Size(max=1000) String reason) { } - public record ConfirmPacking(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @Min(1) int packingVersion,@NotBlank @Size(max=1000) String reason) { } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementTransportRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementTransportRequests.java deleted file mode 100644 index 6ca574939..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementTransportRequests.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.baiyue.lastleg.procurement.dto; - -import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg; -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -public final class ProcurementTransportRequests { - private ProcurementTransportRequests() { } - public record Address(@NotBlank @Size(max=128) String name, - @NotBlank @Pattern(regexp="[+0-9 ()-]{5,64}") String phone, - @NotBlank @Size(max=1000) String address) { } - public record Label(@Positive long cartonId,@Positive long fileId,@Min(1) int page, - @NotBlank @Size(max=128) String carrier,@NotBlank @Size(max=128) String trackingNo) { } - public record Labels(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @NotNull Leg leg,@Min(1) int packingVersion,@NotNull @Valid Address receiver, - @Positive Long addressVoucherFileId,@NotEmpty List<@NotNull @Valid Label> labels, - @NotBlank @Size(max=1000) String reason) { } - public record Estimate(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @Min(1) int labelVersion,@NotNull @DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal amount, - @NotNull LocalDate businessDate,@NotBlank @Size(max=500) String reason) { } - public record ApplyLabels(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @Min(1) int labelVersion,@NotEmpty List<@NotNull @Positive Long> cartonIds, - @NotNull @PastOrPresent LocalDateTime appliedAt,@NotBlank @Size(max=1000) String reason) { } - public record Ship(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @NotNull Leg leg,@Min(1) int labelVersion,@NotEmpty List<@NotNull @Positive Long> cartonIds, - @NotNull @PastOrPresent LocalDateTime shippedAt,@NotBlank @Size(max=1000) String reason) { } - public record Repack(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, - @Positive long cartonId, - @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal lengthCm, - @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal widthCm, - @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal heightCm, - @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=9,fraction=3) BigDecimal grossWeightKg, - @Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java deleted file mode 100644 index 520b2fcff..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.baiyue.lastleg.procurement.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.time.LocalDate; - -@Data -@TableName("ll_overseas_procurement_fee") -public class OverseasProcurementFee { - @TableId(type = IdType.INPUT) private Long id; - private Long orderId; - private String customerId; - private String feeNo; - private String purpose; - private String confirmationOrigin; - private Integer feeVersion; - private Boolean active; - private String determination; - private String originalCurrency; - private BigDecimal originalAmount; - private BigDecimal exchangeRate; - private String exchangeRateMonth; - private LocalDate businessDate; - private Long walletBusinessId; - private String walletBusinessNo; - private String walletHoldId; - private String walletLedgerId; - private String settledCommandEventId; - private Long sourceFeeId; - private Long parentFeeId; - private String requestHash; - private String idempotencyKey; - private String feeItemCode; - private String feeItemName; - private BigDecimal amount; - private String currency; - private Long voucherFileId; - private String voucherFileName; - private String remark; - private String walletStatus; - @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) - private String walletErrorCode; - @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) - private String walletErrorMessage; - private String walletCommandEventId; - private String createdBy; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java deleted file mode 100644 index 40193e3fa..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.baiyue.lastleg.procurement.mapper; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; -import org.apache.ibatis.annotations.Mapper; -@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper { - /** 调用方持有订单锁时,读取当前退款版本上界。 */ - @org.apache.ibatis.annotations.Select("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=#{orderId} AND purpose='REFUND'") - int maxRefundVersion(@org.apache.ibatis.annotations.Param("orderId") long orderId); - @org.apache.ibatis.annotations.Select("SELECT * FROM ll_overseas_procurement_fee WHERE id=#{id} AND order_id=#{orderId} AND customer_id=#{customerId} FOR UPDATE") - OverseasProcurementFee lock(@org.apache.ibatis.annotations.Param("id") long id, - @org.apache.ibatis.annotations.Param("orderId") long orderId, - @org.apache.ibatis.annotations.Param("customerId") String customerId); -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementDirectionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementDirectionMapper.java deleted file mode 100644 index 1018cc61c..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/ProcurementDirectionMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.baiyue.lastleg.procurement.mapper; - -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - -/** 方向变更只读门禁,购买与收货事实仍归原履约模型。 */ -@Mapper -public interface ProcurementDirectionMapper { - /** 已发生的购买或起运仓事实禁止通过审核切换方向。 */ - @Select("SELECT EXISTS(SELECT 1 FROM ll_procurement_flow WHERE order_id=#{orderId} AND " - + "(purchased_at IS NOT NULL OR actual_warehouse_id IS NOT NULL OR overseas_shipped_at IS NOT NULL))") - boolean hasStarted(@Param("orderId") long orderId); -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementDirectionRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementDirectionRepository.java deleted file mode 100644 index a375d947d..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementDirectionRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.baiyue.lastleg.procurement.repository; - -import com.baiyue.lastleg.procurement.mapper.ProcurementDirectionMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -/** 采购方向审核的数据访问边界。 */ -@Repository -@RequiredArgsConstructor -public class ProcurementDirectionRepository { - private final ProcurementDirectionMapper mapper; - /** 在订单行锁内读取履约事实,避免改向重解释原始货物来源。 */ - public boolean hasStarted(long orderId) { return mapper.hasStarted(orderId); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementFeeNumberRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementFeeNumberRepository.java deleted file mode 100644 index 52e24df0d..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementFeeNumberRepository.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.baiyue.lastleg.procurement.repository; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; -import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper; -import org.springframework.stereotype.Repository; - -/** 采购退款的请求身份独立于展示单号,跨日重试仍定位原费用。 */ -@Repository -public class ProcurementFeeNumberRepository { - private final OverseasProcurementFeeMapper mapper; - - /** 使用采购费用所属服务的 Mapper。 */ - public ProcurementFeeNumberRepository(OverseasProcurementFeeMapper mapper) { this.mapper = mapper; } - - /** 上层已持有订单锁,同客户同订单的退款请求只对应一个费用。 */ - public OverseasProcurementFee findRefund(String customerId, long orderId, String key) { - return mapper.selectOne(new LambdaQueryWrapper() - .eq(OverseasProcurementFee::getCustomerId, customerId) - .eq(OverseasProcurementFee::getOrderId, orderId) - .eq(OverseasProcurementFee::getPurpose, "REFUND") - .eq(OverseasProcurementFee::getIdempotencyKey, key)); - } - - /** 版本沿用同订单退款序列,业务编号不承担费用版本含义。 */ - public int nextRefundVersion(long orderId) { return mapper.maxRefundVersion(orderId) + 1; } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index e997178bb..60c9e0c3b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -9,9 +9,7 @@ import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper; -import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper; import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; @@ -56,10 +54,8 @@ public class OverseasProcurementService { @Resource private OverseasProcurementOrderMapper orders; @Resource private OverseasProcurementStatusHistoryMapper histories; @Resource private ProcurementWarehouseDirectory warehouseDirectory; - @Resource private com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository directionRepository; @Resource private ObjectMapper objectMapper; @Resource private OverseasProcurementFileMapper procurementFiles; - @Resource private OverseasProcurementFeeMapper procurementFees; @Resource private ProcurementFileGateway fileGateway; /** 创建客户采购订单,首次请求分配 CG 日流水。 */ @@ -165,7 +161,7 @@ public class OverseasProcurementService { new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getChangeDetail(), value.getRemark(), - value.getCreatedAt())).toList(), files(order), fees(order)); + value.getCreatedAt())).toList(), files(order), List.of()); } @Transactional @@ -202,16 +198,6 @@ public class OverseasProcurementService { return new OverseasProcurementResponses.FileInfo(value.getId(), value.getCategory(), value.getFileId(), value.getFileName(), value.getContentType(), Objects.requireNonNullElse(value.getSizeBytes(), 0L), value.getCreatedAt()); } - private List fees(OverseasProcurementOrder order) { - return procurementFees.selectList(new LambdaQueryWrapper() - .eq(OverseasProcurementFee::getOrderId, order.getId()).orderByAsc(OverseasProcurementFee::getCreatedAt)) - .stream().map(this::fee).toList(); - } - private OverseasProcurementResponses.Fee fee(OverseasProcurementFee value) { - return new OverseasProcurementResponses.Fee(value.getId(), value.getFeeNo(), value.getFeeItemCode(), value.getFeeItemName(), - value.getAmount(), value.getCurrency(), value.getVoucherFileId(), value.getVoucherFileName(), value.getRemark(), - value.getWalletStatus(), value.getWalletErrorMessage(), value.getCreatedAt()); - } /** 审核通过后直接进入分批收货,不再要求旧购买及装箱节点。 */ @Transactional diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java deleted file mode 100644 index de6cbda02..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Correct; -import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Additional; -import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Refund; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; -import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.*; -import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*; - -@Service @RequiredArgsConstructor -public class ProcurementAdjustmentService { - @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; - - private final ProcurementLifecycleService lifecycle; - private final ProcurementFinanceService finance; - private final JdbcTemplate jdbc; - public record Result(String correctionNo,String rootFeeNo,BigDecimal targetOriginalAmount,String originalCurrency, - BigDecimal exchangeRate,String exchangeRateMonth,BigDecimal targetAmount,BigDecimal previousNetAmount, - BigDecimal differenceAmount,List feeNos) { } - private record Identity(long rootFeeId,Correct request) { } - /** 更正原采购费用并以 PG 日流水标识本次更正记录。 */ - @Transactional - public Result correct(String customer,String orderNo,String operator,long rootFeeId,Correct request) { - var order=lifecycle.lock(customer,orderNo);var identity=new Identity(rootFeeId,request); - if(lifecycle.replay(order,"CORRECT_FEE_TARGET",request.idempotencyKey(),identity))return result(order.getId(),request.idempotencyKey()); - version(order,request.version());var root=finance.required(order,rootFeeId); - if(Set.of("INTERNATIONAL_ESTIMATE","ADJUSTMENT","REFUND").contains(root.getPurpose()))throw bad("目标更正须选择原实际费用,不能选择预估或差额单"); - var chain=finance.readyChain(order,root); - if(request.targetOriginalAmount()==null || request.targetOriginalAmount().signum()<0 || request.targetOriginalAmount().scale()>2)throw bad("正确原币总额须为非负两位小数"); - lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER")); - if(root.getExchangeRate()==null || root.getExchangeRate().signum()<=0)throw conflict("原费用汇率快照缺失,不能重新取汇率替代"); - BigDecimal net=chain.stream().filter(f->"SETTLED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add) - .subtract(chain.stream().filter(f->"REFUNDED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)); - BigDecimal target=request.targetOriginalAmount().multiply(root.getExchangeRate()).setScale(2,RoundingMode.HALF_UP),delta=target.subtract(net); - if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围"); - long id=IdWorker.getId();String no=documentNumbers.next("PG"); - // The node command carries the exact request hash; this table stores the computed immutable monetary facts. - jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - id,order.getId(),customer,no,rootFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),root.getOriginalCurrency(),root.getExchangeRate(),root.getExchangeRateMonth(),target,net,delta,request.voucherFileId(),request.reason(),operator); - List created=delta.signum()>0?List.of(finance.supplement(customer,orderNo,operator,rootFeeId,delta,request.voucherFileId(),request.reason(),no)): - delta.signum()<0?finance.reduceChain(customer,orderNo,operator,rootFeeId,delta.negate(),request.voucherFileId(),request.reason(),no):List.of(); - for(var fee:created)jdbc.update("INSERT INTO ll_procurement_fee_correction_item(correction_id,fee_id) VALUES(?,?)",id,fee.getId()); - lifecycle.complete(order,operator,"CORRECT_FEE_TARGET",request.idempotencyKey(),request.reason(),identity); - return result(order.getId(),request.idempotencyKey()); - } - /** 替换失败费用,保留原费用链并分配新的 PG 更正记录号。 */ - @Transactional - public Result replaceFailed(String customer,String orderNo,String operator,long originalFeeId,Correct request) { - var order=lifecycle.lock(customer,orderNo);var identity=new Identity(originalFeeId,request); - if(lifecycle.replay(order,"REPLACE_FAILED_FEE",request.idempotencyKey(),identity))return result(order.getId(),request.idempotencyKey()); - version(order,request.version());var original=finance.requireFailedReplacement(order,originalFeeId); - if(request.targetOriginalAmount()==null || request.targetOriginalAmount().signum()<0 || request.targetOriginalAmount().scale()>2)throw bad("正确原币总额须为非负两位小数"); - lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER")); - BigDecimal target=request.targetOriginalAmount().multiply(original.getExchangeRate()).setScale(2,RoundingMode.HALF_UP); - if("SAMPLE_PAYMENT".equals(original.getPurpose()))lifecycle.requireReplacementBudget(order,request.targetOriginalAmount(),original.getOriginalCurrency(),original.getBusinessDate(),target); - if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围"); - long id=IdWorker.getId();String no=documentNumbers.next("PG"); - jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - id,order.getId(),customer,no,originalFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),original.getOriginalCurrency(),original.getExchangeRate(),original.getExchangeRateMonth(),target,BigDecimal.ZERO,target,request.voucherFileId(),request.reason(),operator); - var fee=finance.replaceFailed(customer,orderNo,operator,originalFeeId,request.targetOriginalAmount(),request.voucherFileId(),request.reason()); - jdbc.update("INSERT INTO ll_procurement_fee_correction_item(correction_id,fee_id) VALUES(?,?)",id,fee.getId()); - if(!Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED","REJECTED","CANCELLED").contains(order.getStatus())) { - jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=?",order.getId()); - if(lifecycle.flow(order.getId()).get("domestic_received_at")!=null)order.setStatus("WAIT_CHARGES"); - } - lifecycle.complete(order,operator,"REPLACE_FAILED_FEE",request.idempotencyKey(),request.reason(),identity); - return result(order.getId(),request.idempotencyKey()); - } - - @Transactional - public OverseasProcurementFee additional(String customer,String orderNo,String operator,Additional request) { - var order=lifecycle.lock(customer,orderNo);String code="ADD_"+lifecycle.requestHash(List.of(customer,orderNo,request.idempotencyKey())).substring(0,24).toUpperCase(); - if(lifecycle.replay(order,"ADD_ACTUAL_FEE",request.idempotencyKey(),request))return finance.all(order.getId()).stream().filter(f->code.equals(f.getFeeItemCode())).min(Comparator.comparing(OverseasProcurementFee::getFeeVersion)).orElseThrow(()->conflict("原新增费用记录缺失")); - version(order,request.version()); - if(lifecycle.flow(order.getId()).get("purchased_at")==null)throw conflict("独立新增费用须关联已发生购买的采购订单"); - if(request.businessDate()==null || request.businessDate().isAfter(java.time.LocalDate.now()))throw bad("独立费用业务日期须为已发生日期"); - lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER")); - var fee=finance.confirmAdditional(customer,orderNo,operator,new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.OTHER,code,request.name(),"POSITIVE",request.amount(),request.currency(),request.businessDate(),request.voucherFileId(),request.reason())); - if(!Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED","REJECTED","CANCELLED").contains(order.getStatus())) { - jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=?",order.getId()); - if(lifecycle.flow(order.getId()).get("domestic_received_at")!=null)order.setStatus("WAIT_CHARGES"); - } - lifecycle.complete(order,operator,"ADD_ACTUAL_FEE",request.idempotencyKey(),request.reason(),request);return fee; - } - - private record RefundIdentity(long originalFeeId,Refund request) { } - @Transactional - public OverseasProcurementFee refund(String customer,String orderNo,String operator,long originalFeeId,Refund request) { - var order=lifecycle.lock(customer,orderNo);var identity=new RefundIdentity(originalFeeId,request); - if(lifecycle.replay(order,"REFUND_FEE",request.idempotencyKey(),identity))return finance.refund(customer,orderNo,operator,originalFeeId,request.amount(),request.voucherFileId(),request.reason(),request.idempotencyKey()); - version(order,request.version()); - var fee=finance.refund(customer,orderNo,operator,originalFeeId,request.amount(),request.voucherFileId(),request.reason(),request.idempotencyKey()); - lifecycle.complete(order,operator,"REFUND_FEE",request.idempotencyKey(),request.reason(),identity);return fee; - } - private Result result(long orderId,String key) { - var row=jdbc.queryForMap("SELECT c.*,f.fee_no AS root_fee_no FROM ll_procurement_fee_correction c JOIN ll_overseas_procurement_fee f ON f.id=c.root_fee_id WHERE c.order_id=? AND c.idempotency_key=?",orderId,key); - var numbers=jdbc.queryForList("SELECT f.fee_no FROM ll_procurement_fee_correction_item i JOIN ll_overseas_procurement_fee f ON f.id=i.fee_id WHERE i.correction_id=? ORDER BY f.id",String.class,row.get("id")); - return new Result((String)row.get("correction_no"),(String)row.get("root_fee_no"),(BigDecimal)row.get("target_original_amount"),(String)row.get("original_currency"), - (BigDecimal)row.get("exchange_rate"),(String)row.get("exchange_rate_month"),(BigDecimal)row.get("target_amount"),(BigDecimal)row.get("previous_net_amount"),(BigDecimal)row.get("difference_amount"),numbers); - } - @Transactional(readOnly=true) - public List history(String customer,String orderNo) { - var order=lifecycle.requireOrder(customer,orderNo); - return jdbc.queryForList("SELECT idempotency_key FROM ll_procurement_fee_correction WHERE order_id=? ORDER BY id DESC",String.class,order.getId()) - .stream().map(key->result(order.getId(),key)).toList(); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java deleted file mode 100644 index 3187f1acd..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.*; -import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; -import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.*; -import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*; - -@Service @RequiredArgsConstructor -public class ProcurementChargeService { - private final ProcurementLifecycleService lifecycle; - private final ProcurementFinanceService finance; - private final JdbcTemplate jdbc; - - public record Fee(OverseasProcurementFee detail,boolean sourceLinked) { } - public record Snapshot(String orderNo,long version,String status,LocalDateTime confirmedAt,List fees) { } - - @Transactional - public Snapshot confirm(String customer,String orderNo,String operator,Confirm request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"CONFIRM_CHARGES",request.idempotencyKey(),request))return snapshot(order); - version(order,request.version());open(order);lifecycle.requireNoBlockers(order); - if(lifecycle.flow(order.getId()).get("domestic_received_at")==null)throw conflict("须国内收货完成且差异处置明确后确认费用"); - if(request.otherFees()==null || !Set.of("NOT_APPLICABLE","ITEMIZED").contains(request.otherDetermination()))throw bad("其他费用须明确核定,不能将未知当作零元"); - if("NOT_APPLICABLE".equals(request.otherDetermination())!=request.otherFees().isEmpty())throw bad("其他费用不适用时不登记收费行,适用时须逐项核定"); - Integer confirmed=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command WHERE order_id=? AND action='CONFIRM_CHARGES'",Integer.class,order.getId()); - if(confirmed!=null && confirmed>0 && (Arrays.asList(request.internationalFreight(),request.duty(),request.domesticFreight()).stream().anyMatch(item->item!=null && item.value()!=null) - || request.otherFees().stream().anyMatch(item->item!=null && item.selection()!=null && item.selection().value()!=null)))throw conflict("首次核费后只可引用已有费用版本,新增或替代费用须使用财务操作"); - Set selected=new HashSet<>(); - selected.add(select(order,operator,ProcurementFeePurpose.INTERNATIONAL_FREIGHT,null,null,request.internationalFreight()).getId()); - selected.add(select(order,operator,ProcurementFeePurpose.DUTY,null,null,request.duty()).getId()); - selected.add(select(order,operator,ProcurementFeePurpose.DOMESTIC_FREIGHT,null,null,request.domesticFreight()).getId()); - Set codes=new HashSet<>(); - for(var item:request.otherFees()) { - if(item==null || item.code()==null || !codes.add(item.code()))throw bad("其他费用编码不能重复"); - selected.add(select(order,operator,ProcurementFeePurpose.OTHER,item.code(),item.name(),item.selection()).getId()); - } - for(var fee:finance.all(order.getId())) { - if(Boolean.TRUE.equals(fee.getActive()) && Set.of("INTERNATIONAL_FREIGHT","DUTY","DOMESTIC_FREIGHT","OTHER").contains(fee.getPurpose()) && !selected.contains(fee.getId())) { - throw conflict("存在未列入本次核对的有效实际费用,不能通过省略行隐藏原收费"); - } - } - jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP(3) WHERE order_id=?",order.getId()); - order.setStatus("WAIT_CUSTOMER_SHIPMENT"); - lifecycle.complete(order,operator,"CONFIRM_CHARGES",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - - private OverseasProcurementFee select(OverseasProcurementOrder order,String operator,ProcurementFeePurpose purpose,String code,String name,Selection selection) { - if(selection==null || (selection.feeId()==null)==(selection.value()==null))throw bad("每项费用须选择原已核定版本或填写新的核定结果,不能同时选择"); - if(selection.feeId()!=null) { - var fee=finance.required(order,selection.feeId()); - if(!Boolean.TRUE.equals(fee.getActive()) || !purpose.name().equals(fee.getPurpose()) - || (purpose==ProcurementFeePurpose.OTHER && !Objects.equals(code,fee.getFeeItemCode())))throw bad("引用费用不是本单该用途的有效版本"); - return fee; - } - var value=selection.value(); - var request=new ProcurementFinanceService.Confirmation(purpose,code,name,value.determination(),value.amount(),value.currency(),value.businessDate(),value.voucherFileId(),value.reason()); - return purpose==ProcurementFeePurpose.INTERNATIONAL_FREIGHT?finance.confirmInternational(order.getCustomerId(),order.getOrderNo(),operator,request) - :finance.confirm(order.getCustomerId(),order.getOrderNo(),operator,request); - } - - private record FeeCommand(long feeId,Command command) { } - @Transactional - public Snapshot retry(String customer,String orderNo,String operator,long feeId,Command request) { - var order=lifecycle.lock(customer,orderNo);var identity=new FeeCommand(feeId,request); - if(lifecycle.replay(order,"RETRY_FEE",request.idempotencyKey(),identity))return snapshot(order); - version(order,request.version());finance.retry(customer,orderNo,feeId); - lifecycle.complete(order,operator,"RETRY_FEE",request.idempotencyKey(),request.reason(),identity);return snapshot(order); - } - @Transactional - public Snapshot terminate(String customer,String orderNo,String operator,long feeId,Command request) { - var order=lifecycle.lock(customer,orderNo);var identity=new FeeCommand(feeId,request); - if(lifecycle.replay(order,"TERMINATE_FEE",request.idempotencyKey(),identity))return snapshot(order); - version(order,request.version());finance.terminateFailed(customer,orderNo,feeId); - // The terminated determination must be explicitly replaced or acknowledged before any further handoff. - if(!Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED","REJECTED","CANCELLED").contains(order.getStatus())) { - jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=?",order.getId()); - if(lifecycle.flow(order.getId()).get("domestic_received_at")!=null)order.setStatus("WAIT_CHARGES"); - } - lifecycle.complete(order,operator,"TERMINATE_FEE",request.idempotencyKey(),request.reason(),identity);return snapshot(order); - } - public Snapshot read(String customer,String orderNo){return snapshot(lifecycle.requireOrder(customer,orderNo));} - @Transactional - public Snapshot releaseEstimate(String customer,String orderNo,String operator,long feeId,Command request) { - var order=lifecycle.lock(customer,orderNo);var identity=new FeeCommand(feeId,request); - if(lifecycle.replay(order,"RELEASE_ESTIMATE",request.idempotencyKey(),identity))return snapshot(order); - version(order,request.version());finance.releaseEstimate(customer,orderNo,feeId); - lifecycle.complete(order,operator,"RELEASE_ESTIMATE",request.idempotencyKey(),request.reason(),identity);return snapshot(order); - } - public String retryPermission(String customer,String orderNo,long feeId) { - var order=lifecycle.requireOrder(customer,orderNo); - var fee=finance.all(order.getId()).stream().filter(f->f.getId()==feeId).findFirst().orElseThrow(()->bad("费用不属于本采购订单")); - if("FINANCIAL".equals(fee.getConfirmationOrigin()))return "platform:overseas-business:purchase:finance-adjust"; - return "platform:overseas-business:purchase:"+switch(ProcurementFeePurpose.valueOf(fee.getPurpose())) { - case SAMPLE_PAYMENT->"buy"; - case INTERNATIONAL_ESTIMATE->"estimate"; - case ADJUSTMENT,REFUND->"finance-adjust"; - default->"charge"; - }; - } - private Snapshot snapshot(OverseasProcurementOrder order) { - Object at=lifecycle.flow(order.getId()).get("charges_confirmed_at"); - return new Snapshot(order.getOrderNo(),order.getVersion(),order.getStatus(),(java.time.LocalDateTime)at, - finance.all(order.getId()).stream().map(f->new Fee(f,finance.sourceLinked(f))).toList()); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java deleted file mode 100644 index d27546287..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.lastleg.procurement.dto.ProcurementControlRequests.*; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.util.*; -import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*; - -@Service @RequiredArgsConstructor -public class ProcurementControlService { - @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; - - private final ProcurementLifecycleService lifecycle; - private final ProcurementFinanceService finance; - private final ProcurementWarehouseDirectory warehouses; - private final JdbcTemplate jdbc; - private final ObjectMapper json; - public record Remaining(String kind,Long sourceId,String businessNo,long quantity) { } - public record Snapshot(String orderNo,long version,String status,List> notes, - List remaining,List> closures,List> closureItems) { } - @Transactional - public Snapshot note(String customer,String orderNo,String operator,Note request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"CONTROL_NOTE",request.idempotencyKey(),request))return snapshot(order); - version(order,request.version());validateTarget(order,request.targetType(),request.targetId());proof(order,request.voucherFileId()); - insertNote(order,operator,request.targetType(),request.targetId(),request.blocking(),request.voucherFileId(),request.reason(),request.clientSummary(),null,null); - lifecycle.complete(order,operator,"CONTROL_NOTE",request.idempotencyKey(),request.reason(),request);return snapshot(order); - } - private record ResolveIdentity(long noteId,Resolve request) { } - @Transactional - public Snapshot resolve(String customer,String orderNo,String operator,long noteId,Resolve request) { - var order=lifecycle.lock(customer,orderNo);var identity=new ResolveIdentity(noteId,request); - if(lifecycle.replay(order,"RESOLVE_CONTROL",request.idempotencyKey(),identity))return snapshot(order); - version(order,request.version());proof(order,request.voucherFileId()); - if(jdbc.update("UPDATE ll_procurement_control_note SET resolved_by=?,resolved_at=CURRENT_TIMESTAMP(3),resolution_reason=?,resolution_voucher_id=? WHERE id=? AND order_id=? AND blocking=1 AND resolved_at IS NULL", - operator,request.reason(),request.voucherFileId(),noteId,order.getId())!=1)throw conflict("本单待处理阻断异常不存在或已处理"); - lifecycle.complete(order,operator,"RESOLVE_CONTROL",request.idempotencyKey(),request.reason(),identity);return snapshot(order); - } - @Transactional - public Snapshot correctWarehouse(String customer,String orderNo,String operator,WarehouseCorrection request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"CORRECT_ACTUAL_WAREHOUSE",request.idempotencyKey(),request))return snapshot(order); - version(order,request.version());proof(order,request.voucherFileId());var flow=lifecycle.flow(order.getId()); - if(flow.get("actual_warehouse_id")==null)throw conflict("尚无实际收货仓,请修改计划仓"); - var warehouse=(ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.required(request.warehouseId(), order.getProcurementDirection()) : warehouses.required(request.warehouseId())); - long old=((Number)flow.get("actual_warehouse_id")).longValue();if(old==request.warehouseId())throw bad("更正仓库与原仓库相同"); - Integer downstream=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_carton WHERE order_id=?",Integer.class,order.getId()); - boolean appendOnly=downstream>0 || flow.get("overseas_shipped_at")!=null || Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED").contains(order.getStatus()); - var before=new LinkedHashMap();before.put("warehouseId",old);before.put("code",flow.get("actual_warehouse_code"));before.put("name",flow.get("actual_warehouse_name")); - insertNote(order,operator,"WAREHOUSE",old,appendOnly,request.voucherFileId(),request.reason(),null,before,warehouse); - if(!appendOnly)jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=?,actual_warehouse_code=?,actual_warehouse_name=? WHERE order_id=?",warehouse.warehouseId(),warehouse.warehouseCode(),warehouse.warehouseName(),order.getId()); - lifecycle.complete(order,operator,"CORRECT_ACTUAL_WAREHOUSE",request.idempotencyKey(),request.reason(),request);return snapshot(order); - } - @Transactional - public Snapshot close(String customer,String orderNo,String operator,Close request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"EXCEPTION_CLOSE",request.idempotencyKey(),request))return snapshot(order); - version(order,request.version());open(order);lifecycle.requireNoBlockers(order); - if(lifecycle.flow(order.getId()).get("purchased_at")==null)throw conflict("购买前请取消订单,不使用购买后异常关闭"); - proof(order,request.customerVoucherFileId());finance.requireShipmentReady(order.getId()); - Map expected=new LinkedHashMap<>();for(var source:remaining(order))expected.put(key(source.kind(),source.sourceId()),source); - if(request.resolutions()==null)throw bad("请逐来源核对剩余货物去向"); - for(var resolution:request.resolutions()) { - var source=expected.remove(key(resolution.kind(),resolution.sourceId())); - if(source==null || source.quantity()!=resolution.quantity())throw bad("最终处置必须恰好覆盖本单每份剩余来源数量,不能重复或跨单关联"); - if(!decisions(resolution.kind()).contains(resolution.decision()))throw bad("处置方式与货物来源不匹配"); - if(resolution.destination()==null || resolution.destination().isBlank())throw bad("请填写货物实际去向及接收方"); - proof(order,resolution.voucherFileId()); - } - if(!expected.isEmpty())throw conflict("仍有货物去向未明确,不能关闭"); - if(request.financialConclusion()==null || request.financialConclusion().isBlank() || request.clientSummary()==null || request.clientSummary().isBlank())throw bad("请填写财务处理结论及对客关闭摘要"); - long id=IdWorker.getId(); - jdbc.update("INSERT INTO ll_procurement_closure(id,order_id,customer_id,closure_no,customer_voucher_file_id,financial_conclusion,reason,client_summary,created_by) VALUES(?,?,?,?,?,?,?,?,?)", - id,order.getId(),customer,number("PX"),request.customerVoucherFileId(),request.financialConclusion(),request.reason(),request.clientSummary(),operator); - for(var r:request.resolutions())jdbc.update("INSERT INTO ll_procurement_closure_item(id,closure_id,source_kind,source_id,quantity,decision,destination,voucher_file_id) VALUES(?,?,?,?,?,?,?,?)", - IdWorker.getId(),id,r.kind(),r.sourceId(),r.quantity(),r.decision(),r.destination(),r.voucherFileId()); - jdbc.update("UPDATE ll_procurement_flow SET closed_at=CURRENT_TIMESTAMP(3),control_reason=? WHERE order_id=?",request.reason(),order.getId()); - order.setStatus("EXCEPTION_CLOSED");lifecycle.complete(order,operator,"EXCEPTION_CLOSE",request.idempotencyKey(),request.reason(),request);return snapshot(order); - } - List remaining(OverseasProcurementOrder order) { - var flow=lifecycle.flow(order.getId());if(flow.get("purchased_at")==null || flow.get("closed_at")!=null || flow.get("customer_shipped_at")!=null)return List.of(); - var result=new ArrayList(); - var receipts=jdbc.queryForList("SELECT * FROM ll_procurement_receipt WHERE order_id=? AND active=1 ORDER BY id",order.getId()); - long overseasReceived=0; - for(var row:receipts) { - long receiptId=((Number)row.get("id")).longValue();String leg=(String)row.get("leg");String no=(String)row.get("receipt_no"); - long good=((Number)row.get("good_quantity")).longValue(),damage=((Number)row.get("damaged_quantity")).longValue(),extra=((Number)row.get("extra_quantity")).longValue(); - if("OVERSEAS".equals(leg))overseasReceived=Math.addExact(overseasReceived,Math.addExact(good,damage)); - if(!"OVERSEAS".equals(leg) || flow.get("overseas_shipped_at")==null)add(result,"GOOD",receiptId,no,good); - add(result,"DAMAGED",receiptId,no,damage-resolved(order.getId(),leg,"DAMAGED",receiptId,null)); - add(result,"EXTRA",receiptId,no,extra-resolved(order.getId(),leg,"EXTRA",receiptId,null)); - } - add(result,"PURCHASE_REMAINDER",null,order.getOrderNo(),((Number)flow.get("purchased_quantity")).longValue()-overseasReceived-resolved(order.getId(),"OVERSEAS","SHORTAGE",null,null)); - if(flow.get("overseas_shipped_at")!=null) { - var shipped=jdbc.queryForList("SELECT i.carton_id,i.quantity,c.carton_no FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id JOIN ll_procurement_carton c ON c.id=i.carton_id WHERE s.order_id=? AND s.leg='OVERSEAS'",order.getId()); - for(var row:shipped) { - long carton=((Number)row.get("carton_id")).longValue(); - long received=receipts.stream().filter(r->"DOMESTIC".equals(r.get("leg")) && ((Number)r.get("source_carton_id")).longValue()==carton) - .mapToLong(r->Math.addExact(((Number)r.get("good_quantity")).longValue(),((Number)r.get("damaged_quantity")).longValue())).reduce(0,Math::addExact); - add(result,"TRANSIT_REMAINDER",carton,(String)row.get("carton_no"),((Number)row.get("quantity")).longValue()-received-resolved(order.getId(),"DOMESTIC","SHORTAGE",null,carton)); - } - } - return result; - } - private long resolved(long orderId,String leg,String kind,Long receipt,Long carton) { - if(receipt!=null)return jdbc.queryForObject("SELECT COALESCE(SUM(quantity),0) FROM ll_procurement_disposition WHERE order_id=? AND leg=? AND kind=? AND receipt_id=? AND active=1",Long.class,orderId,leg,kind,receipt); - return carton==null?jdbc.queryForObject("SELECT COALESCE(SUM(quantity),0) FROM ll_procurement_disposition WHERE order_id=? AND leg=? AND kind=? AND source_carton_id IS NULL AND active=1",Long.class,orderId,leg,kind) - :jdbc.queryForObject("SELECT COALESCE(SUM(quantity),0) FROM ll_procurement_disposition WHERE order_id=? AND leg=? AND kind=? AND source_carton_id=? AND active=1",Long.class,orderId,leg,kind,carton); - } - private void validateTarget(OverseasProcurementOrder order,String type,Long id) { - if("PURCHASE".equals(type)) {if(id!=null || lifecycle.flow(order.getId()).get("purchased_at")==null)throw bad("购买说明须关联本单已记录购买");return;} - if("WAREHOUSE".equals(type)) {Object warehouse=lifecycle.flow(order.getId()).get("actual_warehouse_id");if(id==null || !(warehouse instanceof Number n) || n.longValue()!=id)throw bad("仓库说明须关联本单实际仓");return;} - String table=switch(type){case "RECEIPT"->"ll_procurement_receipt";case "SHIPMENT"->"ll_procurement_shipment";default->throw bad("说明对象类型不正确");}; - if(id==null || jdbc.queryForObject("SELECT COUNT(*) FROM "+table+" WHERE order_id=? AND id=?",Integer.class,order.getId(),id)!=1)throw bad("说明对象不属于本采购订单"); - } - private void insertNote(OverseasProcurementOrder order,String operator,String type,Long target,boolean blocking,long voucher,String reason,String clientSummary,Object original,Object corrected) { - jdbc.update("INSERT INTO ll_procurement_control_note(id,order_id,customer_id,note_no,target_type,target_id,blocking,voucher_file_id,reason,client_summary,original_value_json,corrected_value_json,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?)", - IdWorker.getId(),order.getId(),order.getCustomerId(),number("PN"),type,target,blocking,voucher,reason,clientSummary,serialize(original),serialize(corrected),operator); - } - private void proof(OverseasProcurementOrder order,long id){lifecycle.requireFile(order,id,Set.of("EXCEPTION"));} - private static String key(String kind,Long id){return kind+":"+id;} - private static void add(List result,String kind,Long source,String no,long quantity){if(quantity<0)throw conflict("货物来源与处置数量不一致,不能关闭");if(quantity>0)result.add(new Remaining(kind,source,no,quantity));} - private static Set decisions(String kind){return switch(kind){case "PURCHASE_REMAINDER"->Set.of("SUPPLIER_RETAINED","RETURNED_TO_SUPPLIER","LOSS_CONFIRMED");case "TRANSIT_REMAINDER"->Set.of("LOSS_CONFIRMED","RETURNED_TO_SENDER");case "GOOD"->Set.of("RETURNED_TO_SUPPLIER","CUSTOMER_AUTHORIZED_RETENTION","DISPOSED");case "DAMAGED"->Set.of("DISPOSED","RETURNED_TO_SUPPLIER");case "EXTRA"->Set.of("RETURNED_TO_SUPPLIER");default->Set.of();};} - @Transactional(readOnly=true) - public Snapshot read(String customer,String orderNo){return snapshot(lifecycle.requireOrder(customer,orderNo));} - private Snapshot snapshot(OverseasProcurementOrder order){return new Snapshot(order.getOrderNo(),order.getVersion(),order.getStatus(),jdbc.queryForList("SELECT * FROM ll_procurement_control_note WHERE order_id=? ORDER BY id",order.getId()).stream().map(this::noteView).toList(),remaining(order),jdbc.queryForList("SELECT * FROM ll_procurement_closure WHERE order_id=?",order.getId()),jdbc.queryForList("SELECT i.* FROM ll_procurement_closure c JOIN ll_procurement_closure_item i ON i.closure_id=c.id WHERE c.order_id=? ORDER BY i.id",order.getId()));} - private Map noteView(Map row) { - var result=new LinkedHashMap<>(row); - for(String field:List.of("original_value_json","corrected_value_json")) { - Object value=row.get(field); - if(value!=null)try { - var node=value instanceof byte[] bytes?json.readTree(bytes):json.readTree(String.valueOf(value)); - if(node.isNull()){result.put(field,null);continue;} - if(!node.isObject())throw new IllegalStateException("采购事实更正快照必须为对象"); - result.put(field,node); - }catch(java.io.IOException exception){throw new IllegalStateException("采购事实更正快照读取失败",exception);} - } - return result; - } - private String serialize(Object value){if(value==null)return null;try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} - /** 说明和关闭记录使用各自独立的日流水。 */ - private String number(String prefix){return documentNumbers.next(prefix);} -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementErrors.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementErrors.java new file mode 100644 index 000000000..c051a7b4c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementErrors.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.procurement.service; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** 简化采购独立错误语义,不依赖旧样品履约服务。 */ +public final class ProcurementErrors { + private ProcurementErrors() { } + /** 请求字段或客户归属不符合要求。 */ + public static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + /** 当前版本、库存或流程状态不允许操作。 */ + public static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFeePurpose.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFeePurpose.java deleted file mode 100644 index 71ad9f5f0..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFeePurpose.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -/** Fee semantics are fixed by the business node, never inferred from editable display names. */ -public enum ProcurementFeePurpose { - SAMPLE_PAYMENT("样品货款"), INTERNATIONAL_ESTIMATE("预估国际运费"), - INTERNATIONAL_FREIGHT("实际国际运费"), DUTY("关税"), DOMESTIC_FREIGHT("国内运费"), - OTHER("其他费用"), ADJUSTMENT("追加费用"), REFUND("退款"); - - private final String label; - ProcurementFeePurpose(String label) { this.label = label; } - public String label() { return label; } - public boolean estimate() { return this == INTERNATIONAL_ESTIMATE; } - public boolean immediateCharge() { return this != INTERNATIONAL_ESTIMATE && this != REFUND; } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java deleted file mode 100644 index 6c75ccd80..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java +++ /dev/null @@ -1,533 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.common.event.WalletCommandCompletedV1; -import com.baiyue.common.event.WalletSettlementReference; -import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; -import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; -import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; -import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper; -import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper; -import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; -import com.baiyue.lastleg.wallet.WalletCommandOutboxService; -import com.baiyue.lastleg.wallet.WalletResultHandler; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; - -import java.math.BigDecimal; -import java.security.MessageDigest; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.HexFormat; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.UUID; - -/** Procurement-specific orchestration. Called by authorized fulfillment commands while the order is locked. */ -@Service -@RequiredArgsConstructor -public class ProcurementFinanceService implements WalletResultHandler { - @jakarta.annotation.Resource private com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository feeNumbers; - @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; - - private final OverseasProcurementFeeMapper fees; - private final OverseasProcurementFileMapper files; - private final OverseasProcurementOrderMapper orders; - private final OverseasFeeCurrencyConverter converter; - private final OverseasFeeSourceService sources; - private final WalletCommandOutboxService commands; - private final JdbcTemplate jdbc; - private final ObjectMapper json; - - public record Confirmation(ProcurementFeePurpose purpose, String feeItemCode, String feeItemName, - String determination, BigDecimal originalAmount, String originalCurrency, - LocalDate businessDate, Long voucherFileId, String reason) { } - - @Transactional - public OverseasProcurementFee confirm(String customerId, String orderNo, String operatorId, Confirmation request) { - OverseasProcurementOrder order = lockOrder(customerId, orderNo); - return confirmLocked(order, operatorId, request, null); - } - - /** The estimate remains its own immutable fact; this actual fee consumes that same reservation. */ - @Transactional - public OverseasProcurementFee confirmInternational(String customerId, String orderNo, String operatorId, - Confirmation request) { - OverseasProcurementOrder order = lockOrder(customerId, orderNo); - if (request.purpose() != ProcurementFeePurpose.INTERNATIONAL_FREIGHT) throw bad("实际国际运费用途不正确"); - var existing = current(order.getId(), ProcurementFeePurpose.INTERNATIONAL_FREIGHT, "INTERNATIONAL_FREIGHT"); - if (existing != null) { - if (!Objects.equals(existing.getRequestHash(), hash(request))) throw conflict("国际运费已确认,请通过受控更正处理"); - return existing; - } - OverseasProcurementFee estimate = current(order.getId(), ProcurementFeePurpose.INTERNATIONAL_ESTIMATE, "INTERNATIONAL_ESTIMATE"); - if (estimate == null || !Set.of("FROZEN", "ZERO", "RELEASED").contains(estimate.getWalletStatus())) { - throw conflict("国际运费预估尚未完成冻结或零元确认"); - } - return confirmLocked(order, operatorId, request, estimate); - } - - private OverseasProcurementFee confirmLocked(OverseasProcurementOrder order, String operatorId, - Confirmation request, OverseasProcurementFee estimate) { - validate(request); - if (request.purpose() == ProcurementFeePurpose.REFUND || request.purpose() == ProcurementFeePurpose.ADJUSTMENT) { - throw bad("财务调整必须关联原费用,不得使用普通核费入口"); - } - if (request.purpose() == ProcurementFeePurpose.INTERNATIONAL_FREIGHT && estimate == null) { - throw bad("实际国际运费必须核对原预估"); - } - String code = request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemCode().trim() : request.purpose().name(); - String requestHash = hash(request); - OverseasProcurementFee existing = current(order.getId(), request.purpose(), code); - if (existing != null) { - if (!Objects.equals(existing.getRequestHash(), requestHash)) throw conflict("费用已确认,请通过受控更正处理"); - return existing; - } - if(!request.purpose().estimate()) { - Integer previous=jdbc.queryForObject("SELECT COUNT(*) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose=? AND fee_item_code=?",Integer.class,order.getId(),request.purpose().name(),code); - if(previous!=null && previous>0)throw conflict("该实际费用已有历史版本,请使用财务权限重新核定已终止费用"); - } - return createFee(order,operatorId,request,estimate,converter.convert(request.originalCurrency(),request.originalAmount(),request.businessDate()),"BUSINESS"); - } - - // Both Lastleg and Finance fee sources store DECIMAL(20,6), before any wallet command. - static void requireSourceCapacity(BigDecimal original,BigDecimal settlement) { - if(settlement.precision()-settlement.scale()>14)throw bad("折算人民币金额超出支持范围"); - if(original.precision()-original.scale()>14)throw bad("原币金额超出费用来源支持范围"); - } - - private OverseasProcurementFee createFee(OverseasProcurementOrder order,String operatorId,Confirmation request, - OverseasProcurementFee estimate,OverseasFeeCurrencyConverter.Conversion conversion,String origin) { - validate(request); - String code=request.purpose()==ProcurementFeePurpose.OTHER?request.feeItemCode().trim():request.purpose().name(); - String requestHash=hash(request); - boolean positive="POSITIVE".equals(request.determination()); - requireSourceCapacity(conversion.originalAmount(),conversion.settlementAmount()); - if (positive && conversion.settlementAmount().signum() <= 0) throw bad("折算后的实际金额必须大于零"); - OverseasProcurementFile voucher = voucher(order, request, positive); - Integer maxVersion = jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose=? AND fee_item_code=?", - Integer.class, order.getId(), request.purpose().name(), code); - LocalDateTime now = LocalDateTime.now(); - OverseasProcurementFee fee = new OverseasProcurementFee(); - fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(order.getCustomerId()); - fee.setFeeNo(documentNumbers.next("PF")); - fee.setPurpose(request.purpose().name()); fee.setConfirmationOrigin(origin); fee.setFeeVersion(maxVersion == null ? 1 : maxVersion + 1); - fee.setActive(true); fee.setDetermination(request.determination()); fee.setFeeItemCode(code); - fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : ProcurementDirection.display(request.purpose().label(), order.getProcurementDirection())); - fee.setOriginalCurrency(conversion.originalCurrency()); fee.setOriginalAmount(conversion.originalAmount()); - fee.setExchangeRate(conversion.exchangeRate()); fee.setExchangeRateMonth(conversion.exchangeRateMonth()); - fee.setAmount(conversion.settlementAmount()); fee.setCurrency("CNY"); fee.setBusinessDate(request.businessDate()); - fee.setWalletBusinessId(estimate != null && "FROZEN".equals(estimate.getWalletStatus()) ? estimate.getId() : fee.getId()); - fee.setWalletBusinessNo(estimate != null && "FROZEN".equals(estimate.getWalletStatus()) ? estimate.getFeeNo() : fee.getFeeNo()); - fee.setParentFeeId(estimate == null ? null : estimate.getId()); - fee.setVoucherFileId(voucher == null ? null : voucher.getFileId()); - fee.setVoucherFileName(voucher == null ? null : voucher.getFileName()); - fee.setRemark(request.reason()); fee.setRequestHash(requestHash); fee.setCreatedBy(operatorId); - fee.setCreatedAt(now); fee.setUpdatedAt(now); - fee.setWalletStatus(positive ? "PENDING" : request.determination()); - fees.insert(fee); - if (estimate != null && "FROZEN".equals(estimate.getWalletStatus())) { - fee.setWalletHoldId(estimate.getWalletHoldId()); - issue(fee, positive ? "SETTLE_ACTUAL" : "RELEASE", positive ? fee.getAmount() : null); - } else if (positive) { - issue(fee, "FREEZE", fee.getAmount()); - } - return fee; - } - - /** Only the latest definitively terminated, never-consumed actual version can be replaced. */ - public OverseasProcurementFee requireFailedReplacement(OverseasProcurementOrder order,long feeId) { - var original=required(order,feeId); - if(Set.of("INTERNATIONAL_ESTIMATE","ADJUSTMENT","REFUND").contains(original.getPurpose()) - || Boolean.TRUE.equals(original.getActive()) || !Set.of("TERMINATED","RELEASED").contains(original.getWalletStatus()) - || original.getSettledCommandEventId()!=null || original.getSourceFeeId()!=null)throw conflict("须先终止未成功实扣的原实际费用,并完成有效冻结释放"); - var attempts=jdbc.queryForList("SELECT operation,status FROM ll_procurement_wallet_attempt WHERE fee_id=? ORDER BY attempt_no DESC",feeId); - if(attempts.isEmpty())throw conflict("原费用缺少可核验的失败及终止记录"); - var last=attempts.get(0); - boolean terminated="TERMINATED".equals(original.getWalletStatus()) && "FREEZE".equals(last.get("operation")) && "FAILED".equals(last.get("status")); - boolean released="RELEASED".equals(original.getWalletStatus()) && "RELEASE".equals(last.get("operation")) && "SUCCEEDED".equals(last.get("status")); - if((!terminated && !released) || attempts.stream().anyMatch(row->Set.of("SETTLE","SETTLE_ACTUAL").contains(row.get("operation")) && "SUCCEEDED".equals(row.get("status"))))throw conflict("原费用未明确结束或已经实扣,不能创建替代费用"); - Integer pending=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE order_id=? AND business_no=? AND status='PENDING'",Integer.class,order.getId(),original.getWalletBusinessNo()); - if(pending!=null && pending>0)throw conflict("原费用有关资金请求仍未决"); - Integer latest=jdbc.queryForObject("SELECT MAX(fee_version) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose=? AND fee_item_code=?",Integer.class,order.getId(),original.getPurpose(),original.getFeeItemCode()); - if(!Objects.equals(latest,original.getFeeVersion()) || current(order.getId(),ProcurementFeePurpose.valueOf(original.getPurpose()),original.getFeeItemCode())!=null)throw conflict("已有后续费用版本,请处理最新版本,不能重复替代旧费用"); - if(original.getExchangeRate()==null || original.getExchangeRate().signum()<=0 || original.getExchangeRateMonth()==null || original.getBusinessDate()==null)throw conflict("原费用汇率或业务日期快照缺失"); - return original; - } - @Transactional - public OverseasProcurementFee replaceFailed(String customer,String orderNo,String operator,long originalFeeId,BigDecimal target,long voucherId,String reason) { - var order=lockOrder(customer,orderNo);var original=requireFailedReplacement(order,originalFeeId); - if(target==null || target.signum()<0 || target.scale()>2)throw bad("替代费用原币总额须为非负两位小数"); - var purpose=ProcurementFeePurpose.valueOf(original.getPurpose()); - var request=new Confirmation(purpose,original.getFeeItemCode(),original.getFeeItemName(),target.signum()>0?"POSITIVE":"ZERO",target, - original.getOriginalCurrency(),original.getBusinessDate(),voucherId,reason); - var conversion=new OverseasFeeCurrencyConverter.Conversion(original.getOriginalCurrency(),target,original.getExchangeRate(),original.getExchangeRateMonth(),target.multiply(original.getExchangeRate()).setScale(2,java.math.RoundingMode.HALF_UP)); - // The original reservation has already ended; the replacement owns a new wallet business identity. - var replacement=createFee(order,operator,request,null,conversion,"FINANCIAL"); - replacement.setParentFeeId(original.getParentFeeId());fees.updateById(replacement); - return replacement; - } - - @Transactional - public OverseasProcurementFee confirmAdditional(String customer,String orderNo,String operator,Confirmation request) { - var order=lockOrder(customer,orderNo);validate(request); - if(request.purpose()!=ProcurementFeePurpose.OTHER || !"POSITIVE".equals(request.determination()))throw bad("独立新增费用须为正额其他费用"); - Integer previous=jdbc.queryForObject("SELECT COUNT(*) FROM ll_overseas_procurement_fee WHERE order_id=? AND fee_item_code=?",Integer.class,order.getId(),request.feeItemCode()); - if(previous!=null && previous>0)throw conflict("独立费用编号已使用,不能重复新增"); - return createFee(order,operator,request,null,converter.convert(request.originalCurrency(),request.originalAmount(),request.businessDate()),"FINANCIAL"); - } - - public OverseasProcurementFee current(long orderId, ProcurementFeePurpose purpose, String code) { - return fees.selectOne(new LambdaQueryWrapper() - .eq(OverseasProcurementFee::getOrderId, orderId).eq(OverseasProcurementFee::getPurpose, purpose.name()) - .eq(OverseasProcurementFee::getFeeItemCode, code).eq(OverseasProcurementFee::getActive, true)); - } - - public List all(long orderId) { - return fees.selectList(new LambdaQueryWrapper().eq(OverseasProcurementFee::getOrderId,orderId).orderByAsc(OverseasProcurementFee::getId)); - } - public OverseasProcurementFee required(OverseasProcurementOrder order,long feeId) { - var fee=fees.lock(feeId,order.getId(),order.getCustomerId()); - if(fee==null)throw bad("费用不属于本采购订单");return fee; - } - - @Transactional - public OverseasProcurementFee refund(String customerId, String orderNo, String operatorId, long originalFeeId, - BigDecimal amount, long voucherFileId, String reason, String idempotencyKey) { - var order = lockOrder(customerId, orderNo); - return refundLocked(order,operatorId,originalFeeId,amount,voucherFileId,reason,idempotencyKey,true); - } - /** 订单锁内先恢复同幂等键退款,首次请求校验后才分配独立 PF 业务号。 */ - private OverseasProcurementFee refundLocked(OverseasProcurementOrder order,String operatorId,long originalFeeId, - BigDecimal amount,long voucherFileId,String reason,String idempotencyKey,boolean checkChain) { - String customerId=order.getCustomerId(),orderNo=order.getOrderNo(); - if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128) throw bad("退款幂等键无效"); - var request = new Confirmation(ProcurementFeePurpose.REFUND, "REFUND", "退款", "POSITIVE", amount, - "CNY", LocalDate.now(), voucherFileId, reason); - validate(request); - String requestHash = hash(List.of(originalFeeId, amount, voucherFileId, reason)); - var existing = feeNumbers.findRefund(customerId, order.getId(), idempotencyKey); - if (existing != null) { - if (!requestHash.equals(existing.getRequestHash())) throw conflict("退款幂等键对应不同原扣款或金额"); - return existing; - } - var original = fees.lock(originalFeeId, order.getId(), customerId); - if (original == null || !"SETTLED".equals(original.getWalletStatus()) || original.getWalletLedgerId() == null - || ProcurementFeePurpose.INTERNATIONAL_ESTIMATE.name().equals(original.getPurpose())) { - throw conflict("退款必须关联本单已成功实扣的实际费用"); - } - if(checkChain)readyChain(order,original); - List refunds = fees.selectList(new LambdaQueryWrapper() - .eq(OverseasProcurementFee::getParentFeeId, originalFeeId).eq(OverseasProcurementFee::getPurpose, "REFUND")); - if (refunds.stream().anyMatch(f -> !Set.of("REFUNDED", "TERMINATED").contains(f.getWalletStatus()))) { - throw conflict("原费用存在未完成退款,须先处理原请求"); - } - BigDecimal already = refunds.stream().filter(f -> "REFUNDED".equals(f.getWalletStatus())) - .map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add); - if (already.add(amount).compareTo(original.getAmount()) > 0) throw bad("退款超过原扣款可退余额"); - var voucher = voucher(order, request, true); - var fee = new OverseasProcurementFee(); - String number = documentNumbers.next("PF"); - fee.setIdempotencyKey(idempotencyKey); - fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(customerId); fee.setFeeNo(number); - fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("REFUND"); fee.setFeeItemCode("REFUND"); fee.setFeeItemName("关联费用退款"); - fee.setFeeVersion(feeNumbers.nextRefundVersion(order.getId())); fee.setActive(true); fee.setDetermination("POSITIVE"); - fee.setAmount(amount); fee.setCurrency("CNY"); fee.setOriginalAmount(amount); fee.setOriginalCurrency("CNY"); - fee.setExchangeRate(BigDecimal.ONE); fee.setExchangeRateMonth(request.businessDate().toString().substring(0, 7)); - fee.setBusinessDate(request.businessDate()); fee.setParentFeeId(originalFeeId); fee.setWalletBusinessId(fee.getId()); - fee.setWalletBusinessNo(number); fee.setVoucherFileId(voucher.getFileId()); fee.setVoucherFileName(voucher.getFileName()); - fee.setRemark(reason); fee.setRequestHash(requestHash); fee.setCreatedBy(operatorId); fee.setWalletStatus("PENDING"); - fee.setCreatedAt(LocalDateTime.now()); fee.setUpdatedAt(fee.getCreatedAt()); fees.insert(fee); - issue(fee, "REFUND", amount); - return fee; - } - - /** Includes the base fee, all positive corrections and refunds against each actual consumption. */ - public List readyChain(OverseasProcurementOrder order,OverseasProcurementFee selected) { - var root="ADJUSTMENT".equals(selected.getPurpose())?required(order,selected.getParentFeeId()):selected; - if(Set.of("REFUND","ADJUSTMENT","INTERNATIONAL_ESTIMATE").contains(root.getPurpose()) - || !Boolean.TRUE.equals(root.getActive()) || !sourceLinked(root))throw conflict("原费用尚未实扣及关联完成,不能更正费用链"); - var all=all(order.getId()); - Set originals=new java.util.HashSet<>();originals.add(root.getId()); - all.stream().filter(f->"ADJUSTMENT".equals(f.getPurpose()) && Objects.equals(f.getParentFeeId(),root.getId())).forEach(f->originals.add(f.getId())); - var chain=all.stream().filter(f->originals.contains(f.getId()) || ("REFUND".equals(f.getPurpose()) && originals.contains(f.getParentFeeId()))).toList(); - if(chain.stream().anyMatch(f->Boolean.TRUE.equals(f.getActive()) && !sourceLinked(f)))throw conflict("原费用链存在未完成的调整或退款,须先完成或终止旧请求"); - return chain; - } - - /** The correction target is converted with the original rate; this new charge is only the exact CNY difference. */ - @Transactional - public OverseasProcurementFee supplement(String customer,String orderNo,String operator,long rootId,BigDecimal delta,long voucherId,String reason,String correctionNo) { - var order=lockOrder(customer,orderNo);var root=required(order,rootId);readyChain(order,root); - if(delta==null || delta.signum()<=0 || delta.scale()>2)throw bad("追加费用仅允许正额CNY差额"); - requireSourceCapacity(delta,delta); - var proof=voucher(order,new Confirmation(ProcurementFeePurpose.ADJUSTMENT,"ADJUSTMENT","追加费用","POSITIVE",delta,"CNY",root.getBusinessDate(),voucherId,reason),true); - var fee=new OverseasProcurementFee();fee.setId(IdWorker.getId());fee.setOrderId(order.getId());fee.setCustomerId(customer); - fee.setFeeNo(documentNumbers.next("PF"));fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("ADJUSTMENT"); - fee.setFeeItemCode("ADJ_"+root.getId());fee.setFeeItemName("原费用正额调整"); - fee.setFeeVersion(jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0)+1 FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='ADJUSTMENT' AND fee_item_code=?",Integer.class,order.getId(),fee.getFeeItemCode())); - fee.setActive(true);fee.setDetermination("POSITIVE");fee.setAmount(delta);fee.setCurrency("CNY");fee.setOriginalCurrency("CNY");fee.setOriginalAmount(delta); - fee.setExchangeRate(BigDecimal.ONE.setScale(6));fee.setExchangeRateMonth(root.getExchangeRateMonth());fee.setBusinessDate(root.getBusinessDate()); - fee.setWalletBusinessId(fee.getId());fee.setWalletBusinessNo(fee.getFeeNo());fee.setParentFeeId(rootId);fee.setVoucherFileId(proof.getFileId());fee.setVoucherFileName(proof.getFileName()); - fee.setRemark(reason);fee.setRequestHash(hash(List.of(correctionNo,rootId,delta,voucherId,reason)));fee.setCreatedBy(operator);fee.setCreatedAt(LocalDateTime.now());fee.setUpdatedAt(fee.getCreatedAt());fee.setWalletStatus("PENDING"); - fees.insert(fee);issue(fee,"FREEZE",delta);return fee; - } - - @Transactional - public List reduceChain(String customer,String orderNo,String operator,long rootId,BigDecimal amount,long voucherId,String reason,String correctionNo) { - var order=lockOrder(customer,orderNo);var root=required(order,rootId);var chain=readyChain(order,root); - if(amount==null || amount.signum()<=0 || amount.scale()>2)throw bad("减少费用须填写正额CNY退款差额"); - BigDecimal remaining=amount;var created=new java.util.ArrayList(); - for(var original:chain) { - if(!"SETTLED".equals(original.getWalletStatus()) || "REFUND".equals(original.getPurpose()))continue; - BigDecimal returned=chain.stream().filter(f->"REFUNDED".equals(f.getWalletStatus()) && Objects.equals(f.getParentFeeId(),original.getId())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add); - BigDecimal available=original.getAmount().subtract(returned),part=available.min(remaining); - if(part.signum()>0) { - created.add(refundLocked(order,operator,original.getId(),part,voucherId,reason,correctionNo+":"+original.getId(),false));remaining=remaining.subtract(part); - } - if(remaining.signum()==0)break; - } - if(remaining.signum()>0)throw bad("减少金额超过费用链净实扣");return created; - } - - /** Called only after Finance has reported a definite failure. Unknown outcomes keep their original request. */ - @Transactional - public OverseasProcurementFee retry(String customerId, String orderNo, long feeId) { - OverseasProcurementOrder order = lockOrder(customerId, orderNo); - OverseasProcurementFee fee = fees.lock(feeId, order.getId(), customerId); - if (fee == null || !Boolean.TRUE.equals(fee.getActive())) throw bad("有效费用不存在"); - List> attempts = jdbc.queryForList("SELECT operation,amount,status FROM ll_procurement_wallet_attempt WHERE fee_id=? ORDER BY attempt_no DESC LIMIT 1 FOR UPDATE", feeId); - if (attempts.isEmpty() || !"FAILED".equals(attempts.get(0).get("status"))) throw conflict("资金结果未明确失败,不能发起新尝试"); - Map last = attempts.get(0); - issue(fee, String.valueOf(last.get("operation")), (BigDecimal) last.get("amount")); - return fee; - } - - @Transactional - public void releaseEstimate(String customerId, String orderNo, long feeId) { - var order = lockOrder(customerId, orderNo); - var fee = fees.lock(feeId, order.getId(), customerId); - if (fee == null || !Boolean.TRUE.equals(fee.getActive()) || !"INTERNATIONAL_ESTIMATE".equals(fee.getPurpose())) { - throw bad("当前预估费用不存在"); - } - Integer pending = jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE order_id=? AND business_no=? AND status='PENDING'", - Integer.class, order.getId(), fee.getWalletBusinessNo()); - if (pending != null && pending > 0) throw conflict("冻结有关资金请求未决,不能释放"); - if ("ZERO".equals(fee.getWalletStatus())) { - fee.setActive(false); fee.setWalletStatus("RELEASED"); fees.updateById(fee); - } else if ("FROZEN".equals(fee.getWalletStatus())) { - issue(fee, "RELEASE", null); - } else { - throw conflict("预估不处于可释放状态"); - } - } - - @Transactional - public void terminateFailed(String customerId, String orderNo, long feeId) { - var order = lockOrder(customerId, orderNo); - var fee = fees.lock(feeId, order.getId(), customerId); - if (fee == null || !Boolean.TRUE.equals(fee.getActive())) throw bad("有效费用不存在"); - var attempts = jdbc.queryForList("SELECT status,operation FROM ll_procurement_wallet_attempt WHERE fee_id=? ORDER BY attempt_no DESC LIMIT 1 FOR UPDATE", feeId); - if (attempts.isEmpty() || !"FAILED".equals(attempts.get(0).get("status"))) throw conflict("只能终止已明确失败的资金版本"); - String operation = String.valueOf(attempts.get(0).get("operation")); - if (Set.of("FREEZE", "REFUND").contains(operation)) { - fee.setActive(false); fee.setWalletStatus("TERMINATED"); fees.updateById(fee); - } else { - issue(fee, "RELEASE", null); - } - } - - private void issue(OverseasProcurementFee fee, String operation, BigDecimal amount) { - Long previous = jdbc.queryForObject("SELECT COALESCE(MAX(attempt_no),0) FROM ll_procurement_wallet_attempt WHERE fee_id=?", Long.class, fee.getId()); - long attempt = previous == null ? 1 : previous + 1; - // A shared reservation can be settled by a different fee version, so the aggregate key includes the fee identity. - long attemptId = IdWorker.getId(); - long aggregateVersion = attemptId; - String event; - if ("REFUND".equals(operation)) { - var original = fees.lock(fee.getParentFeeId(), fee.getOrderId(), fee.getCustomerId()); - if (original == null || original.getWalletLedgerId() == null) throw conflict("原退款扣款引用缺失"); - event = commands.publishRefund(Long.parseLong(fee.getCustomerId()), "PROCUREMENT_FEE", fee.getWalletBusinessNo(), - amount, original.getWalletLedgerId(), fee.getRemark(), aggregateVersion); - } else { - event = commands.publish(operation, Long.parseLong(fee.getCustomerId()), "PROCUREMENT_FEE", fee.getWalletBusinessNo(), - amount, fee.getFeeItemName() + ":" + fee.getFeeNo(), aggregateVersion); - } - jdbc.update("INSERT INTO ll_procurement_wallet_attempt(id,order_id,customer_id,fee_id,attempt_no,operation,business_no,amount,command_event_id,status) VALUES(?,?,?,?,?,?,?,?,?,'PENDING')", - attemptId, fee.getOrderId(), fee.getCustomerId(), fee.getId(), attempt, operation, fee.getWalletBusinessNo(), amount, event); - fee.setWalletStatus(operation + "_PENDING"); fee.setWalletCommandEventId(event); fee.setUpdatedAt(LocalDateTime.now()); - fees.updateById(fee); - } - - @Override public boolean supports(String businessType) { return "PROCUREMENT_FEE".equals(businessType); } - - @Override - @Transactional - public void handle(WalletCommandCompletedV1 result) { - List> found = jdbc.queryForList("SELECT order_id,fee_id FROM ll_procurement_wallet_attempt WHERE command_event_id=? AND customer_id=?", - result.commandEventId(), String.valueOf(result.customerId())); - if (found.size() != 1) throw conflict("资金结果不属于已登记的采购请求"); - long orderId = ((Number) found.get(0).get("order_id")).longValue(); - var order = orders.selectById(orderId); - if (order == null) throw bad("采购订单不存在"); - order = lockOrder(String.valueOf(result.customerId()), order.getOrderNo()); - long feeId = ((Number) found.get(0).get("fee_id")).longValue(); - var fee = fees.lock(feeId, orderId, String.valueOf(result.customerId())); - if (fee == null) throw conflict("资金结果对应的采购费用不存在"); - Map attempt = jdbc.queryForMap("SELECT * FROM ll_procurement_wallet_attempt WHERE command_event_id=? FOR UPDATE", result.commandEventId()); - String resultHash = hash(result); - if (!"PENDING".equals(attempt.get("status"))) { - if (!resultHash.equals(attempt.get("result_hash"))) throw conflict("同一资金请求存在不同最终结果"); - return; - } - if (!result.operation().equals(attempt.get("operation")) || !result.businessNo().equals(attempt.get("business_no")) - || !Set.of("SUCCEEDED", "FAILED").contains(result.result())) throw conflict("资金结果与原请求不匹配"); - boolean success = "SUCCEEDED".equals(result.result()); - if (success) { - if (result.walletLedgerId() == null || (!"REFUND".equals(result.operation()) && result.walletHoldId() == null)) throw conflict("成功资金结果缺少权威引用"); - String expectedStatus = switch (result.operation()) { - case "FREEZE" -> "FROZEN"; - case "SETTLE", "SETTLE_ACTUAL" -> "SETTLED"; - case "RELEASE" -> "RELEASED"; - case "REFUND" -> "REFUNDED"; - default -> throw conflict("采购资金操作不受支持"); - }; - if (!expectedStatus.equals(result.holdStatus())) throw conflict("资金结果状态与操作不匹配"); - BigDecimal expected = (BigDecimal) attempt.get("amount"); - if ("SETTLE".equals(result.operation())) expected = fee.getAmount(); - if (expected != null && (result.amount() == null || expected.compareTo(result.amount()) != 0)) { - throw conflict("资金结果金额与原请求不匹配"); - } - } - jdbc.update("UPDATE ll_procurement_wallet_attempt SET status=?,result_hash=?,wallet_hold_id=?,wallet_ledger_id=?,error_code=?,error_message=?,completed_at=CURRENT_TIMESTAMP(3) WHERE command_event_id=? AND status='PENDING'", - result.result(), resultHash, result.walletHoldId(), result.walletLedgerId(), result.errorCode(), result.errorMessage(), result.commandEventId()); - // Terminal results always remain on their original attempt. Never apply an old result to a newer fee request. - if (!Objects.equals(fee.getWalletCommandEventId(), result.commandEventId())) return; - fee.setWalletStatus(success ? switch (result.operation()) { - case "FREEZE" -> "FROZEN"; - case "SETTLE", "SETTLE_ACTUAL" -> "SETTLED"; - case "RELEASE" -> "RELEASED"; - case "REFUND" -> "REFUNDED"; - default -> throw conflict("采购资金操作不受支持"); - } : result.operation() + "_FAILED"); - fee.setWalletErrorCode(success ? null : result.errorCode()); fee.setWalletErrorMessage(success ? null : result.errorMessage()); - if (success) { fee.setWalletHoldId(result.walletHoldId()); fee.setWalletLedgerId(result.walletLedgerId()); } - fee.setUpdatedAt(LocalDateTime.now()); fees.updateById(fee); - if (!success) return; - if ("RELEASE".equals(result.operation()) && ("INTERNATIONAL_ESTIMATE".equals(fee.getPurpose()) - || "POSITIVE".equals(fee.getDetermination()))) { - fee.setActive(false); fees.updateById(fee); - } - if ("REFUND".equals(result.operation())) { - fee.setSettledCommandEventId(result.commandEventId()); fees.updateById(fee); return; - } - if ("FREEZE".equals(result.operation()) && ProcurementFeePurpose.valueOf(fee.getPurpose()).immediateCharge()) { - issue(fee, "SETTLE", null); - } else if (Set.of("SETTLE", "SETTLE_ACTUAL").contains(result.operation())) { - fee.setSettledCommandEventId(result.commandEventId()); - publishSource(order, fee); - fees.updateById(fee); - } - if (fee.getParentFeeId() != null && Set.of("SETTLE_ACTUAL", "RELEASE").contains(result.operation())) { - var estimate = fees.lock(fee.getParentFeeId(), orderId, fee.getCustomerId()); - if (estimate != null && ProcurementFeePurpose.INTERNATIONAL_ESTIMATE.name().equals(estimate.getPurpose())) { - estimate.setWalletStatus(fee.getWalletStatus()); estimate.setUpdatedAt(LocalDateTime.now()); fees.updateById(estimate); - } - } - } - - private void publishSource(OverseasProcurementOrder order, OverseasProcurementFee fee) { - var request = new OverseasFeeSourceDtos.SaveRequest(fee.getCustomerId(), order.getCustomerName(), - "OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getWalletBusinessId(), fee.getWalletBusinessNo(), - order.getId(), null, null, null, null, null, null, fee.getFeeItemCode(), fee.getFeeItemName(), null, "RECEIVABLE", - null, null, null, null, null, null, null, List.of(), fee.getBusinessDate(), fee.getOriginalCurrency(), - fee.getOriginalAmount(), BigDecimal.ONE, "ITEM", fee.getOriginalAmount(), null, null); - var source = sources.publishSettled(fee.getCreatedBy(), "PROCUREMENT:" + fee.getFeeNo(), request, - new OverseasFeeCurrencyConverter.Conversion(fee.getOriginalCurrency(), fee.getOriginalAmount(), fee.getExchangeRate(), - fee.getExchangeRateMonth(), fee.getAmount()), - new WalletSettlementReference(fee.getSettledCommandEventId(), fee.getWalletHoldId(), fee.getWalletLedgerId())); - fee.setSourceFeeId(source.getSourceFeeId()); - } - - /** 余额实扣并登记本地费用来源后即可履约,无需等待财务账单推送。 */ - public boolean sourceLinked(OverseasProcurementFee fee) { - if(fee==null || fee.getWalletStatus()==null)return false; - if (Set.of("ZERO", "NOT_APPLICABLE", "REFUNDED").contains(fee.getWalletStatus())) return true; - if ("RELEASED".equals(fee.getWalletStatus()) && fee.getAmount().signum() == 0) return true; - return "SETTLED".equals(fee.getWalletStatus()) && fee.getSourceFeeId() != null - && fee.getWalletLedgerId() != null && fee.getSettledCommandEventId() != null; - } - - /** Called under the procurement order lock before the final physical handoff or exception closure. */ - public void requireShipmentReady(long orderId) { - Integer pending=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE order_id=? AND status='PENDING'",Integer.class,orderId); - if(pending!=null && pending>0)throw conflict("采购存在未决资金请求,不能交寄或关闭"); - var all=fees.selectList(new LambdaQueryWrapper().eq(OverseasProcurementFee::getOrderId,orderId)); - for(var fee:all) { - if("INTERNATIONAL_ESTIMATE".equals(fee.getPurpose())) { - if(!Set.of("ZERO","RELEASED","SETTLED","TERMINATED").contains(fee.getWalletStatus()))throw conflict("预估冻结尚未结清或释放"); - } else if(Boolean.TRUE.equals(fee.getActive()) || "SETTLED".equals(fee.getWalletStatus())) { - if(!sourceLinked(fee))throw conflict("仍有费用扣款、退款、调整或费用来源关联尚未完成"); - } - } - } - - public void requireAdjustmentsComplete(long orderId) { - var adjustments=fees.selectList(new LambdaQueryWrapper() - .eq(OverseasProcurementFee::getOrderId,orderId).eq(OverseasProcurementFee::getActive,true) - .eq(OverseasProcurementFee::getConfirmationOrigin,"FINANCIAL")); - if(adjustments.stream().anyMatch(f->!sourceLinked(f)))throw conflict("存在未完成的财务调整或退款,不能继续交寄"); - } - - private OverseasProcurementFile voucher(OverseasProcurementOrder order, Confirmation request, boolean positive) { - if (request.voucherFileId() == null) { - if (positive && !request.purpose().estimate()) throw bad("正额实际费用必须关联本单凭证"); - return null; - } - var file = files.selectOne(new LambdaQueryWrapper() - .eq(OverseasProcurementFile::getOrderId, order.getId()).eq(OverseasProcurementFile::getCustomerId, order.getCustomerId()) - .eq(OverseasProcurementFile::getFileId, request.voucherFileId())); - if (file == null || !Set.of("VOUCHER", "PURCHASE").contains(file.getCategory())) throw bad("费用凭证不属于当前采购订单或分类不正确"); - return file; - } - - private static void validate(Confirmation request) { - if (request == null || request.purpose() == null || request.businessDate() == null || request.reason() == null - || request.reason().isBlank() || request.reason().length() > 500 || request.originalAmount() == null - || request.originalAmount().signum() < 0 || request.originalAmount().scale() > 2 - || request.originalCurrency() == null || !Set.of("CNY", "USD").contains(request.originalCurrency()) - || request.determination() == null || !Set.of("POSITIVE", "ZERO", "NOT_APPLICABLE").contains(request.determination())) { - throw bad("费用核定信息不完整或精度不正确"); - } - if ("POSITIVE".equals(request.determination()) != (request.originalAmount().signum() > 0)) throw bad("费用核定情况与金额不一致"); - if (request.purpose().estimate() && !"CNY".equals(request.originalCurrency())) throw bad("预估国际运费仅支持CNY"); - if (Set.of(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE, ProcurementFeePurpose.SAMPLE_PAYMENT).contains(request.purpose()) - && "NOT_APPLICABLE".equals(request.determination())) throw bad("货款和预估须明确正额或零元,不能标为不适用"); - if (request.purpose() == ProcurementFeePurpose.OTHER && (request.feeItemCode() == null - || !request.feeItemCode().matches("[A-Z][A-Z0-9_]{0,31}") || request.feeItemName() == null - || request.feeItemName().isBlank() || request.feeItemName().length() > 128)) throw bad("其他费用必须填写编码和名称"); - } - - private OverseasProcurementOrder lockOrder(String customerId, String orderNo) { - var order = orders.lockNo(customerId, orderNo); - if (order == null) throw bad("采购订单不存在或不属于当前客户"); - return order; - } - private String hash(Object value) { - try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value))); } - catch (Exception exception) { throw new IllegalStateException("采购请求摘要生成失败", exception); } - } - private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } - private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java deleted file mode 100644 index 973283fc9..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java +++ /dev/null @@ -1,252 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; -import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; -import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper; -import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; -import java.math.BigDecimal; -import java.security.MessageDigest; -import java.time.LocalDateTime; -import java.util.HexFormat; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; - -/** One procurement order, one physical batch. Warehouse and wallet remain authoritative outside this aggregate. */ -@Service -@RequiredArgsConstructor -public class ProcurementLifecycleService { - // Read SQL DATETIME/TIMESTAMP through JDBC 4.2, independently of each driver's default getObject type. - static final org.springframework.jdbc.core.ColumnMapRowMapper FACT_ROW = new org.springframework.jdbc.core.ColumnMapRowMapper() { - @Override protected Object getColumnValue(java.sql.ResultSet rs,int index) throws java.sql.SQLException { - if(rs.getMetaData().getColumnType(index)==java.sql.Types.TIMESTAMP) return rs.getObject(index,LocalDateTime.class); - return super.getColumnValue(rs,index); - } - }; - private final OverseasProcurementOrderMapper orders; - private final OverseasProcurementFileMapper files; - private final ProcurementWarehouseDirectory warehouses; - private final ProcurementFinanceService finance; - private final OverseasFeeCurrencyConverter converter; - private final JdbcTemplate jdbc; - private final ObjectMapper json; - - public record PurchaseStatus(boolean recorded, boolean confirmed, String issue, String feeNo, - String walletStatus, boolean sourceLinked) { } - public record Outcome(String orderNo, long version, String status, PurchaseStatus purchase) { } - - @Transactional - public Outcome recordPurchase(String customerId,String orderNo,String operator,Purchase request) { - var order=lock(customerId,orderNo); - if(replay(order,"PURCHASE",request.idempotencyKey(),request)) return outcome(order); - version(order,request.version()); open(order); - ensureFlow(order); - var flow=flow(order.getId()); - if(flow.get("purchased_at")!=null) throw conflict("本单已登记购买事实,不能再次购买或覆盖原记录"); - if(request.purchasedAt()==null || request.purchasedAt().isAfter(LocalDateTime.now()) - || request.estimatedArrivalAt()==null || request.estimatedArrivalAt().isBefore(request.purchasedAt()) - || request.quantity()<=0 || request.productName()==null || request.productName().isBlank() - || request.amount()==null || request.amount().signum()<0 || request.amount().scale()>2 - || request.currency()==null || !Set.of("CNY","USD").contains(request.currency())) throw bad("实际购买信息不完整"); - requireFile(order,request.voucherFileId(),Set.of("VOUCHER","PURCHASE")); - jdbc.update("UPDATE ll_procurement_flow SET purchased_at=?,purchased_product_name=?,purchased_specification=?,purchased_quantity=?,purchase_amount=?,purchase_currency=?,purchase_voucher_id=? WHERE order_id=?", - request.purchasedAt(),request.productName().trim(),trim(request.specification()),request.quantity(),request.amount(),request.currency(),request.voucherFileId(),order.getId()); - order.setEstimatedArrivalAt(request.estimatedArrivalAt()); - confirmRecordedPurchase(order,operator); - if(order.getReviewedAt()!=null && !"REJECTED".equals(order.getStatus())) order.setStatus("WAIT_OVERSEAS_RECEIPT"); - complete(order,operator,"PURCHASE",request.idempotencyKey(),request.reason(),request); - return outcome(order); - } - - @Transactional - public Outcome confirmPurchase(String customerId,String orderNo,String operator,Command request) { - var order=lock(customerId,orderNo); - if(replay(order,"CONFIRM_PURCHASE",request.idempotencyKey(),request)) return outcome(order); - version(order,request.version());open(order); - if(flow(order.getId()).get("purchased_at")==null) throw conflict("请先登记真实购买事实"); - confirmRecordedPurchase(order,operator); - if(order.getReviewedAt()!=null && Set.of("PENDING_REVIEW","WAIT_PURCHASE").contains(order.getStatus())) order.setStatus("WAIT_OVERSEAS_RECEIPT"); - complete(order,operator,"CONFIRM_PURCHASE",request.idempotencyKey(),request.reason(),request); - return outcome(order); - } - - @Transactional - public Outcome authorizeBudget(String customerId,String orderNo,String operator,Budget request) { - var order=lock(customerId,orderNo); - if(replay(order,"AUTHORIZE_BUDGET",request.idempotencyKey(),request)) return outcome(order); - version(order,request.version());open(order); - if(request.amount()==null || request.amount().signum()<0 || request.amount().scale()>2 - || !Set.of("CNY","USD").contains(request.currency())) throw bad("预算金额或币种无效"); - if(finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")!=null) { - throw conflict("货款已经核定,不能通过修改预算改变原费用"); - } - requireFile(order,request.voucherFileId(),Set.of("VOUCHER","BUDGET")); - var previous=budget(order); - jdbc.update("INSERT INTO ll_procurement_budget_authorization(id,order_id,customer_id,previous_amount,previous_currency,authorized_amount,currency,voucher_file_id,reason,reviewed_by) VALUES(?,?,?,?,?,?,?,?,?,?)", - IdWorker.getId(),order.getId(),customerId,previous.amount(),previous.currency(),request.amount(),request.currency(),request.voucherFileId(),request.reason(),operator); - complete(order,operator,"AUTHORIZE_BUDGET",request.idempotencyKey(),request.reason(),request); - return outcome(order); - } - - @Transactional - public Outcome selectWarehouse(String customerId,String orderNo,String operator,Warehouse request) { - var order=lock(customerId,orderNo); - if(replay(order,"PLAN_WAREHOUSE",request.idempotencyKey(),request)) return outcome(order); - version(order,request.version());open(order);ensureFlow(order); - if(flow(order.getId()).get("actual_warehouse_id")!=null) throw conflict("首次实收已冻结实际仓库,请使用事实更正操作"); - var warehouse=(ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.required(request.warehouseId(), order.getProcurementDirection()) : warehouses.required(request.warehouseId())); - order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId()));order.setPurchaseWarehouseName(warehouse.warehouseName()); - complete(order,operator,"PLAN_WAREHOUSE",request.idempotencyKey(),request.reason(),request); - return outcome(order); - } - - @Transactional - public Outcome cancel(String customerId,String orderNo,String operator,Command request) { - var order=lock(customerId,orderNo); - if(replay(order,"CANCEL",request.idempotencyKey(),request)) return outcome(order); - version(order,request.version());open(order);ensureFlow(order); - if(flow(order.getId()).get("purchased_at")!=null) throw conflict("购买后不能取消原单,请处理实物及资金后异常关闭"); - order.setStatus("CANCELLED"); - jdbc.update("UPDATE ll_procurement_flow SET cancelled_at=CURRENT_TIMESTAMP(3),control_reason=? WHERE order_id=?",request.reason(),order.getId()); - complete(order,operator,"CANCEL",request.idempotencyKey(),request.reason(),request); - return outcome(order); - } - - private void confirmRecordedPurchase(OverseasProcurementOrder order,String operator) { - var flow=flow(order.getId()); - if(flow.get("purchase_confirmed_at")!=null) return; - String issue=null; - if(order.getReviewedAt()==null || "REJECTED".equals(order.getStatus())) issue="采购订单尚未审核通过"; - if(!Objects.equals(trim(order.getProductName()),flow.get("purchased_product_name")) - || !Objects.equals(trim(order.getProductSpecification()),flow.get("purchased_specification")) - || ((Number)flow.get("purchased_quantity")).longValue()!=order.getPurchaseQuantity()) { - issue="实际购买商品、规格或数量与审核内容不一致,须异常处置,不能通过提高预算改写"; - } - var budget=budget(order); - if(budget.amount()==null) issue="客户货款预算尚未明确,请取得凭证并完成预算核验"; - var businessDate=((LocalDateTime)flow.get("purchased_at")).toLocalDate(); - if(issue==null) { - try { - var payment=converter.convert(String.valueOf(flow.get("purchase_currency")),(BigDecimal)flow.get("purchase_amount"),businessDate); - var limit=converter.convert(budget.currency(),budget.amount(),businessDate); - try { ProcurementFinanceService.requireSourceCapacity(payment.originalAmount(),payment.settlementAmount()); } - catch(ResponseStatusException capacity) { issue=capacity.getReason()+",已保留真实购买事实"; } - if(issue==null && payment.settlementAmount().compareTo(limit.settlementAmount())>0) issue="实际货款超过客户已授权预算,请先取得凭证并完成预算核验"; - } catch(ResponseStatusException exception) { - if(exception.getStatusCode().is4xxClientError()) issue="业务月份汇率尚未核定,已保留真实购买事实"; - else throw exception; - } - } - if(issue!=null) { - jdbc.update("UPDATE ll_procurement_flow SET purchase_issue=? WHERE order_id=?",issue,order.getId()); - return; - } - BigDecimal amount=(BigDecimal)flow.get("purchase_amount"); - finance.confirm(order.getCustomerId(),order.getOrderNo(),operator,new ProcurementFinanceService.Confirmation( - ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","样品货款",amount.signum()==0?"ZERO":"POSITIVE",amount, - String.valueOf(flow.get("purchase_currency")),businessDate,((Number)flow.get("purchase_voucher_id")).longValue(),"购买完成核定样品货款")); - jdbc.update("UPDATE ll_procurement_flow SET purchase_confirmed_at=CURRENT_TIMESTAMP(3),purchase_issue=NULL WHERE order_id=?",order.getId()); - } - - void requireReplacementBudget(OverseasProcurementOrder order,BigDecimal target,String currency, - java.time.LocalDate businessDate,BigDecimal settlementAmount) { - var facts=flow(order.getId()); - if(facts.get("purchased_at")==null || facts.get("purchase_confirmed_at")==null)throw conflict("替代货款须关联本单已核定的真实购买事实"); - var limit=budget(order); - if(limit.amount()==null)throw conflict("客户预算尚未明确,请先取得凭证并核验预算"); - boolean within=currency.equals(limit.currency())?target.compareTo(limit.amount())<=0 - :settlementAmount.compareTo("CNY".equals(limit.currency())?limit.amount():converter.convert(limit.currency(),limit.amount(),businessDate).settlementAmount())<=0; - if(!within)throw conflict("替代货款超过客户已授权预算,请先取得凭证并完成预算核验"); - } - - private record BudgetLimit(BigDecimal amount,String currency) { } - private BudgetLimit budget(OverseasProcurementOrder order) { - var rows=jdbc.query("SELECT authorized_amount,currency FROM ll_procurement_budget_authorization WHERE order_id=? ORDER BY id DESC LIMIT 1", - (rs,n)->new BudgetLimit(rs.getBigDecimal(1),rs.getString(2)),order.getId()); - if(!rows.isEmpty()) return rows.get(0); - if(order.getBudgetUnitPrice()==null) return new BudgetLimit(null,order.getCurrency()); - return new BudgetLimit(order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency()); - } - public PurchaseStatus purchaseStatus(OverseasProcurementOrder order) { - var flow=flow(order.getId()); - var fee=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT"); - return new PurchaseStatus(flow.get("purchased_at")!=null,flow.get("purchase_confirmed_at")!=null, - (String)flow.get("purchase_issue"),fee==null?null:fee.getFeeNo(),fee==null?null:fee.getWalletStatus(),fee!=null && finance.sourceLinked(fee)); - } - public Outcome read(String customerId,String orderNo) { - return outcome(requireOrder(customerId,orderNo)); - } - OverseasProcurementOrder requireOrder(String customerId,String orderNo) { - var order=orders.findNo(customerId,orderNo); - if(order==null) throw bad("采购订单不存在或不属于当前客户"); - return order; - } - Outcome outcome(OverseasProcurementOrder order) { - return new Outcome(order.getOrderNo(),order.getVersion(),order.getStatus(),purchaseStatus(order)); - } - Map flow(long orderId) { - var rows=jdbc.query("SELECT * FROM ll_procurement_flow WHERE order_id=?",FACT_ROW,orderId); - return rows.isEmpty()?Map.of():rows.get(0); - } - void ensureFlow(OverseasProcurementOrder order) { - if(flow(order.getId()).isEmpty()) jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id) VALUES(?,?)",order.getId(),order.getCustomerId()); - } - void requireNoBlockers(OverseasProcurementOrder order) { - Integer count=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_control_note WHERE order_id=? AND blocking=1 AND resolved_at IS NULL",Integer.class,order.getId()); - if(count!=null && count>0)throw conflict("存在未处理的采购阻断异常,请先完成有凭证的异常处理"); - } - void requireFile(OverseasProcurementOrder order,long id,Set categories) { - var file=files.selectOne(new LambdaQueryWrapper().eq(OverseasProcurementFile::getOrderId,order.getId()) - .eq(OverseasProcurementFile::getCustomerId,order.getCustomerId()).eq(OverseasProcurementFile::getFileId,id)); - if(file==null || !categories.contains(file.getCategory())) throw bad("凭证不属于本采购订单或文件用途不正确"); - } - OverseasProcurementOrder lock(String customerId,String orderNo) { - var order=orders.lockNo(customerId,orderNo); - if(order==null) throw bad("采购订单不存在或不属于当前客户"); - return order; - } - /** 新采购禁止通过旧履约命令绕过客户确认及库存占用。 */ - boolean replay(OverseasProcurementOrder order,String action,String key,Object request) { - if(ProcurementWorkflowService.STATUSES.contains(order.getStatus())) throw conflict("请使用简化采购收货、客户确认和出库流程"); - if(key==null || key.isBlank() || key.length()>128) throw bad("操作幂等键无效"); - var previous=jdbc.queryForList("SELECT request_hash FROM ll_procurement_command WHERE order_id=? AND action=? AND idempotency_key=?",String.class,order.getId(),action,key); - if(previous.isEmpty()) return false; - if(!previous.get(0).equals(hash(request))) throw conflict("相同操作幂等键不能用于不同内容"); - return true; - } - void complete(OverseasProcurementOrder order,String operator,String action,String key,String reason,Object request) { - if(reason==null || reason.isBlank() || reason.length()>1000) throw bad("请填写操作依据"); - jdbc.update("INSERT INTO ll_procurement_command(id,order_id,customer_id,action,idempotency_key,request_hash,operator_id,reason,detail_json) VALUES(?,?,?,?,?,?,?,?,CAST(? AS JSON))", - IdWorker.getId(),order.getId(),order.getCustomerId(),action,key,hash(request),operator,reason,serialize(request)); - order.setVersion(order.getVersion()+1);order.setUpdatedAt(LocalDateTime.now());orders.updateById(order); - } - static void version(OverseasProcurementOrder order,long version) { - if(order.getVersion()!=version) throw conflict("订单已被修改,请刷新后重试"); - } - static void open(OverseasProcurementOrder order) { - if(Set.of("REJECTED","CANCELLED","EXCEPTION_CLOSED","SHIPPED_TO_CUSTOMER").contains(order.getStatus())) throw conflict("订单已结束,不能执行该履约操作"); - } - String requestHash(Object value) {return hash(value);} - private String hash(Object value) { - try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(serialize(value).getBytes(java.nio.charset.StandardCharsets.UTF_8))); } - catch(Exception exception) {throw new IllegalStateException(exception);} - } - private String serialize(Object value) { - try {return json.writeValueAsString(value);}catch(Exception exception){throw new IllegalStateException(exception);} - } - private static String trim(String value){return value==null || value.isBlank()?null:value.trim();} - static ResponseStatusException bad(String message){return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);} - static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java deleted file mode 100644 index 33f1be50a..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java +++ /dev/null @@ -1,334 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command; -import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.*; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; -import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.math.BigDecimal; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.LocalDateTime; -import java.util.*; -import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*; - -/** Physical procurement ledger only. Never writes WMS balances, pallets or stock movements. */ -@Service -@RequiredArgsConstructor -public class ProcurementPhysicalService { - @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; - - private final ProcurementLifecycleService lifecycle; - private final ProcurementWarehouseDirectory warehouses; - private final ProcurementFinanceService finance; - private final JdbcTemplate jdbc; - - public record ReceiptRow(long id,String receiptNo,String leg,Long sourceCartonId,long goodQuantity,long damagedQuantity, - long extraQuantity,LocalDateTime receivedAt,boolean active) { } - public record CartonRow(long id,String cartonNo,BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm, - BigDecimal grossWeightKg,long quantity,List sources) { } - public record Snapshot(String orderNo,long version,String status,List receipts, - List> dispositions,Integer packingVersion,String packingStatus,List cartons) { } - - @Transactional - public Snapshot receive(String customer,String orderNo,String operator,Receipt request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"RECEIVE",request.idempotencyKey(),request)) return snapshot(order); - version(order,request.version());open(order); - insertReceipt(order,operator,request,null); - lifecycle.complete(order,operator,"RECEIVE",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - - private record CorrectionIdentity(long receiptId,Correction correction) { } - @Transactional - public Snapshot correctReceipt(String customer,String orderNo,String operator,long receiptId,Correction correction) { - var order=lifecycle.lock(customer,orderNo);var request=correction.receipt();var identity=new CorrectionIdentity(receiptId,correction); - if(lifecycle.replay(order,"CORRECT_RECEIPT",request.idempotencyKey(),identity))return snapshot(order); - version(order,request.version());open(order);requireCorrectable(order,request.leg()); - var rows=jdbc.query("SELECT * FROM ll_procurement_receipt WHERE id=? AND order_id=? AND customer_id=? AND active=1",this::receiptRow,receiptId,order.getId(),customer); - if(rows.size()!=1)throw bad("有效实收记录不存在或不属于当前订单"); - var old=rows.get(0); - if(!request.leg().name().equals(old.leg()) || !Objects.equals(request.sourceCartonId(),old.sourceCartonId()))throw bad("更正不得改变实收的运输段和货物来源"); - lifecycle.requireFile(order,correction.voucherFileId(),Set.of("VOUCHER","EXCEPTION")); - jdbc.update("UPDATE ll_procurement_receipt SET active=0 WHERE id=?",receiptId); - jdbc.update("UPDATE ll_procurement_disposition SET active=0 WHERE order_id=? AND receipt_id=?",order.getId(),receiptId); - invalidateShortage(order.getId(),request.leg(),request.sourceCartonId()); - reopenReceipt(order,request.leg()); - insertReceipt(order,operator,request,receiptId); - lifecycle.complete(order,operator,"CORRECT_RECEIPT",request.idempotencyKey(),request.reason(),identity); - return snapshot(order); - } - - private record RevokeIdentity(long dispositionId,RevokeDisposition request) { } - @Transactional - public Snapshot revokeDisposition(String customer,String orderNo,String operator,long dispositionId,RevokeDisposition request) { - var order=lifecycle.lock(customer,orderNo);var identity=new RevokeIdentity(dispositionId,request); - if(lifecycle.replay(order,"REVOKE_DISPOSITION",request.idempotencyKey(),identity))return snapshot(order); - version(order,request.version());open(order); - var rows=jdbc.queryForList("SELECT leg FROM ll_procurement_disposition WHERE id=? AND order_id=? AND active=1",dispositionId,order.getId()); - if(rows.size()!=1)throw bad("有效差异处置不存在"); - Leg leg=Leg.valueOf(String.valueOf(rows.get(0).get("leg")));requireCorrectable(order,leg); - lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER","EXCEPTION")); - jdbc.update("UPDATE ll_procurement_disposition SET active=0 WHERE id=?",dispositionId);reopenReceipt(order,leg); - lifecycle.complete(order,operator,"REVOKE_DISPOSITION",request.idempotencyKey(),request.reason(),identity); - return snapshot(order); - } - - private void requireCorrectable(OverseasProcurementOrder order,Leg leg) { - var flow=lifecycle.flow(order.getId()); - if(flow.get(leg==Leg.OVERSEAS?"overseas_shipped_at":"customer_shipped_at")!=null)throw conflict("本段已实际交寄,只能追加更正说明,不能改写数量"); - if(leg==Leg.OVERSEAS && flow.get("packing_version")!=null)throw conflict("须先撤回尚未交寄的确认清单,再更正海外实收"); - } - private void reopenReceipt(OverseasProcurementOrder order,Leg leg) { - if(leg==Leg.OVERSEAS) { - jdbc.update("UPDATE ll_procurement_flow SET overseas_received_at=NULL,packing_version=NULL WHERE order_id=?",order.getId()); - jdbc.update("UPDATE ll_procurement_manifest SET status='SUPERSEDED' WHERE order_id=? AND status<>'SUPERSEDED'",order.getId()); - order.setStatus("OVERSEAS_RECEIVING"); - } else { - jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=NULL,charges_confirmed_at=NULL WHERE order_id=?",order.getId()); - order.setStatus("DOMESTIC_RECEIVING"); - } - } - private void invalidateShortage(long orderId,Leg leg,Long source) { - if(source==null)jdbc.update("UPDATE ll_procurement_disposition SET active=0 WHERE order_id=? AND leg=? AND kind='SHORTAGE' AND source_carton_id IS NULL",orderId,leg.name()); - else jdbc.update("UPDATE ll_procurement_disposition SET active=0 WHERE order_id=? AND leg=? AND kind='SHORTAGE' AND source_carton_id=?",orderId,leg.name(),source); - } - - private void insertReceipt(OverseasProcurementOrder order,String operator,Receipt request,Long supersedes) { - if(request.leg()==null || request.receivedAt()==null || request.receivedAt().isAfter(LocalDateTime.now()) - || request.goodQuantity()<0 || request.damagedQuantity()<0 || request.extraQuantity()<0 - || add(add(request.goodQuantity(),request.damagedQuantity()),request.extraQuantity())<=0) throw bad("实收数量或时间无效"); - var flow=lifecycle.flow(order.getId()); - String completed=request.leg()==Leg.OVERSEAS?"overseas_received_at":"domestic_received_at"; - if(flow.get(completed)!=null) throw conflict("本段收货已完成,请使用受控更正操作"); - long expected=expected(order,request.leg(),request.sourceCartonId()); - LocalDateTime originAt; - if(request.leg()==Leg.OVERSEAS) { - if(request.warehouseId()==null || request.sourceCartonId()!=null) throw bad("海外实收须选择实际收货仓,不能指定国内来源箱"); - var warehouse=(ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.required(request.warehouseId(), order.getProcurementDirection()) : warehouses.required(request.warehouseId())); - if(flow.get("actual_warehouse_id")!=null && ((Number)flow.get("actual_warehouse_id")).longValue()!=request.warehouseId()) throw conflict("本单实际收货仓已冻结,不支持转仓"); - jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=?,actual_warehouse_code=?,actual_warehouse_name=? WHERE order_id=?", - warehouse.warehouseId(),warehouse.warehouseCode(),warehouse.warehouseName(),order.getId()); - originAt=((LocalDateTime)flow.get("purchased_at")); - } else { - if(request.warehouseId()!=null) throw bad("国内实收不应指定海外仓"); - originAt=((LocalDateTime)flow.get("overseas_shipped_at")); - } - if(request.receivedAt().isBefore(originAt)) throw bad("实收时间不能早于来源购买或国际交寄时间"); - long counted=received(order.getId(),request.leg(),request.sourceCartonId()).stream() - .mapToLong(r->add(r.goodQuantity(),r.damagedQuantity())).reduce(0,ProcurementPhysicalService::add); - if(add(counted,add(request.goodQuantity(),request.damagedQuantity()))>expected) throw bad("正常及破损实收超过来源数量,多到货物必须单独记录为多货"); - jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,warehouse_id,good_quantity,damaged_quantity,extra_quantity,received_at,active,supersedes_id,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?,?)", - IdWorker.getId(),order.getId(),order.getCustomerId(),number("PR"),request.leg().name(),request.sourceCartonId(),request.warehouseId(), - request.goodQuantity(),request.damagedQuantity(),request.extraQuantity(),request.receivedAt(),supersedes,operator); - order.setStatus(request.leg()==Leg.OVERSEAS?"OVERSEAS_RECEIVING":"DOMESTIC_RECEIVING"); - } - - @Transactional - public Snapshot dispose(String customer,String orderNo,String operator,Disposition request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"DISPOSE",request.idempotencyKey(),request)) return snapshot(order); - version(order,request.version());open(order); - var flow=lifecycle.flow(order.getId()); - if(flow.get(request.leg()==Leg.OVERSEAS?"overseas_received_at":"domestic_received_at")!=null) throw conflict("已完成的收货需先受控更正,不能直接改差异结果"); - long target=expected(order,request.leg(),request.sourceCartonId()); - long available; - if("SHORTAGE".equals(request.kind())) { - if(request.receiptId()!=null || !"ACCEPT_SHORTAGE".equals(request.decision())) throw bad("短少须按来源确认,不关联单条实收"); - long counted=received(order.getId(),request.leg(),request.sourceCartonId()).stream() - .mapToLong(r->add(r.goodQuantity(),r.damagedQuantity())).reduce(0,ProcurementPhysicalService::add); - available=target-counted-resolved(order.getId(),request.leg(),request.sourceCartonId(),null,"SHORTAGE"); - } else { - if(request.receiptId()==null) throw bad("破损或多货须关联实际收货记录"); - var receipt=received(order.getId(),request.leg(),request.sourceCartonId()).stream().filter(r->r.id()==request.receiptId()).findFirst() - .orElseThrow(()->bad("实收记录不属于当前来源")); - if("DAMAGED".equals(request.kind()) && "DISPOSE_DAMAGED".equals(request.decision())) available=receipt.damagedQuantity(); - else if("EXTRA".equals(request.kind()) && "RETURN_EXTRA".equals(request.decision())) available=receipt.extraQuantity(); - else throw bad("差异类型和处置动作不匹配"); - available-=resolved(order.getId(),request.leg(),request.sourceCartonId(),request.receiptId(),request.kind()); - } - if(request.quantity()<=0 || request.quantity()>available) throw bad("处置数量超过未处理差异"); - lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER","EXCEPTION")); - jdbc.update("INSERT INTO ll_procurement_disposition(id,order_id,customer_id,leg,source_carton_id,receipt_id,kind,quantity,decision,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", - IdWorker.getId(),order.getId(),customer,request.leg().name(),request.sourceCartonId(),request.receiptId(),request.kind(),request.quantity(),request.decision(),request.voucherFileId(),request.reason(),operator); - lifecycle.complete(order,operator,"DISPOSE",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - - @Transactional - public Snapshot finishReceipt(String customer,String orderNo,String operator,FinishReceipt request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"FINISH_RECEIPT",request.idempotencyKey(),request)) return snapshot(order); - version(order,request.version());open(order); - var flow=lifecycle.flow(order.getId()); - String field=request.leg()==Leg.OVERSEAS?"overseas_received_at":"domestic_received_at"; - if(flow.get(field)!=null) throw conflict("本段收货已完成"); - if(request.leg()==Leg.OVERSEAS) validateReceiptSource(order,Leg.OVERSEAS,null); - else { - var sourceIds=jdbc.queryForList("SELECT i.carton_id FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.order_id=? AND s.leg='OVERSEAS'",Long.class,order.getId()); - if(sourceIds.isEmpty()) throw conflict("尚无国际交寄事实"); - for(long sourceId:sourceIds) validateReceiptSource(order,Leg.DOMESTIC,sourceId); - } - jdbc.update("UPDATE ll_procurement_flow SET "+field+"=CURRENT_TIMESTAMP(3) WHERE order_id=?",order.getId()); - long good=goodTotal(order.getId(),request.leg()); - if(request.leg()==Leg.OVERSEAS) { - order.setInboundQuantity(good); - order.setStatus(good>0?"WAIT_PACKING":"OVERSEAS_RECEIVING"); - } else order.setStatus(good>0?"WAIT_CHARGES":"DOMESTIC_RECEIVING"); - lifecycle.complete(order,operator,"FINISH_RECEIPT",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - - private void validateReceiptSource(OverseasProcurementOrder order,Leg leg,Long source) { - long target=expected(order,leg,source),counted=0; - for(var receipt:received(order.getId(),leg,source)) { - counted=add(counted,add(receipt.goodQuantity(),receipt.damagedQuantity())); - if(resolved(order.getId(),leg,source,receipt.id(),"DAMAGED")!=receipt.damagedQuantity() - || resolved(order.getId(),leg,source,receipt.id(),"EXTRA")!=receipt.extraQuantity()) throw conflict("存在未处理的破损或多到货物"); - } - if(add(counted,resolved(order.getId(),leg,source,null,"SHORTAGE"))!=target) throw conflict("货物未收齐或短少处置与来源数量不一致"); - } - - @Transactional - public Snapshot savePacking(String customer,String orderNo,String operator,Packing request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"SAVE_PACKING",request.idempotencyKey(),request)) return snapshot(order); - version(order,request.version());open(order); - if(lifecycle.flow(order.getId()).get("overseas_shipped_at")!=null) throw conflict("国际段已实际交寄,不能改写原装箱事实"); - if(request.cartons()==null || request.cartons().isEmpty()) throw bad("装箱清单不能为空"); - Map available=new HashMap<>(),used=new HashMap<>(); - for(var receipt:received(order.getId(),Leg.OVERSEAS,null)) available.put(receipt.id(),receipt.goodQuantity()); - Set names=new HashSet<>(); - for(var carton:request.cartons()) { - if(carton.cartonNo()==null || carton.cartonNo().isBlank() || carton.cartonNo().length()>64 || !names.add(carton.cartonNo().trim())) throw bad("箱号为空、重复或过长"); - measure(carton.lengthCm(),2);measure(carton.widthCm(),2);measure(carton.heightCm(),2);measure(carton.grossWeightKg(),3); - if(carton.sources()==null || carton.sources().isEmpty()) throw bad("每箱必须有明确实收货物来源"); - Set unique=new HashSet<>(); - for(var source:carton.sources()) { - if(source.quantity()<=0 || !unique.add(source.receiptId()) || !available.containsKey(source.receiptId())) throw bad("箱内来源无效、重复或不属于本单有效实收"); - used.merge(source.receiptId(),source.quantity(),ProcurementPhysicalService::add); - if(used.get(source.receiptId())>available.get(source.receiptId())) throw bad("装箱数量超过来源合格实收,不能重复分配"); - } - } - Integer previous=jdbc.queryForObject("SELECT COALESCE(MAX(version),0) FROM ll_procurement_manifest WHERE order_id=?",Integer.class,order.getId()); - int packingVersion=previous==null?1:previous+1;long manifestId=IdWorker.getId(); - jdbc.update("UPDATE ll_procurement_manifest SET status='SUPERSEDED' WHERE order_id=? AND status<>'SUPERSEDED'",order.getId()); - jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(?,?,?,?,'DRAFT',?)",manifestId,order.getId(),customer,packingVersion,operator); - for(var carton:request.cartons()) { - long cartonId=IdWorker.getId(),quantity=carton.sources().stream().mapToLong(Source::quantity).reduce(0,ProcurementPhysicalService::add); - jdbc.update("INSERT INTO ll_procurement_carton(id,order_id,manifest_id,carton_no,length_cm,width_cm,height_cm,gross_weight_kg,quantity) VALUES(?,?,?,?,?,?,?,?,?)", - cartonId,order.getId(),manifestId,carton.cartonNo().trim(),carton.lengthCm(),carton.widthCm(),carton.heightCm(),carton.grossWeightKg(),quantity); - for(var source:carton.sources()) jdbc.update("INSERT INTO ll_procurement_carton_source(carton_id,receipt_id,quantity) VALUES(?,?,?)",cartonId,source.receiptId(),source.quantity()); - } - jdbc.update("UPDATE ll_procurement_flow SET packing_version=NULL WHERE order_id=?",order.getId()); - if(lifecycle.flow(order.getId()).get("overseas_received_at")!=null) order.setStatus("WAIT_PACKING"); - lifecycle.complete(order,operator,"SAVE_PACKING",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - - @Transactional - public Snapshot confirmPacking(String customer,String orderNo,String operator,ConfirmPacking request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"CONFIRM_PACKING",request.idempotencyKey(),request)) return snapshot(order); - version(order,request.version());open(order);lifecycle.requireNoBlockers(order); - var flow=lifecycle.flow(order.getId()); - if(flow.get("overseas_received_at")==null || flow.get("overseas_shipped_at")!=null) throw conflict("须海外收货完成且尚未国际交寄"); - var payment=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT"); - if(payment==null || !finance.sourceLinked(payment)) throw conflict("样品货款扣费及来源关联未完成,不能确认装箱"); - validateReceiptSource(order,Leg.OVERSEAS,null); - var manifest=currentManifest(order.getId()); - if(manifest==null || ((Number)manifest.get("version")).intValue()!=request.packingVersion() || !"DRAFT".equals(manifest.get("status"))) throw conflict("装箱草稿版本已变化"); - long manifestId=((Number)manifest.get("id")).longValue(); - Map assigned=new HashMap<>(); - jdbc.query("SELECT s.receipt_id,SUM(s.quantity) AS quantity FROM ll_procurement_carton c JOIN ll_procurement_carton_source s ON s.carton_id=c.id WHERE c.manifest_id=? GROUP BY s.receipt_id", - (org.springframework.jdbc.core.RowCallbackHandler)rs->assigned.put(rs.getLong("receipt_id"),rs.getLong("quantity")),manifestId); - long total=0; - for(var receipt:received(order.getId(),Leg.OVERSEAS,null)) { - if(assigned.getOrDefault(receipt.id(),0L)!=receipt.goodQuantity()) throw conflict("确认装箱须覆盖本批全部合格实收,且每份来源数量一致"); - assigned.remove(receipt.id());total=add(total,receipt.goodQuantity()); - } - if(!assigned.isEmpty() || total<=0) throw conflict("清单包含失效来源或没有可发货物"); - jdbc.update("UPDATE ll_procurement_manifest SET status='CONFIRMED',confirmed_at=CURRENT_TIMESTAMP(3) WHERE id=?",manifestId); - jdbc.update("UPDATE ll_procurement_flow SET packing_version=? WHERE order_id=?",request.packingVersion(),order.getId()); - order.setStatus("WAIT_LABELS"); - lifecycle.complete(order,operator,"CONFIRM_PACKING",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - - @Transactional - public Snapshot withdrawPacking(String customer,String orderNo,String operator,Command request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"WITHDRAW_PACKING",request.idempotencyKey(),request)) return snapshot(order); - version(order,request.version());open(order); - if(lifecycle.flow(order.getId()).get("overseas_shipped_at")!=null) throw conflict("已交寄装箱事实不能撤回"); - jdbc.update("UPDATE ll_procurement_manifest SET status='SUPERSEDED' WHERE order_id=? AND status<>'SUPERSEDED'",order.getId()); - jdbc.update("UPDATE ll_procurement_flow SET packing_version=NULL WHERE order_id=?",order.getId()); - order.setStatus(lifecycle.flow(order.getId()).get("overseas_received_at")==null?"OVERSEAS_RECEIVING":"WAIT_PACKING"); - lifecycle.complete(order,operator,"WITHDRAW_PACKING",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - - public Snapshot read(String customer,String orderNo) { - var outcome=lifecycle.read(customer,orderNo); - // Read the same authorized aggregate without obtaining a write lock. - var rows=jdbc.queryForList("SELECT id FROM ll_overseas_procurement_order WHERE customer_id=? AND order_no=?",Long.class,customer,orderNo); - if(rows.size()!=1) throw bad("采购订单不存在"); - return snapshot(rows.get(0),outcome.orderNo(),outcome.version(),outcome.status()); - } - private Snapshot snapshot(OverseasProcurementOrder order){return snapshot(order.getId(),order.getOrderNo(),order.getVersion(),order.getStatus());} - private Snapshot snapshot(long orderId,String orderNo,long version,String status) { - var receipts=jdbc.query("SELECT * FROM ll_procurement_receipt WHERE order_id=? ORDER BY created_at,id",this::receiptRow,orderId); - var dispositions=jdbc.queryForList("SELECT id,leg,source_carton_id,receipt_id,kind,quantity,decision,reason,active,created_at FROM ll_procurement_disposition WHERE order_id=? ORDER BY id",orderId); - var manifests=jdbc.queryForList("SELECT * FROM ll_procurement_manifest WHERE order_id=? ORDER BY version DESC LIMIT 1",orderId); - if(manifests.isEmpty()) return new Snapshot(orderNo,version,status,receipts,dispositions,null,null,List.of()); - var manifest=manifests.get(0);long manifestId=((Number)manifest.get("id")).longValue(); - Map> sources=new HashMap<>(); - jdbc.query("SELECT s.carton_id,s.receipt_id,s.quantity FROM ll_procurement_carton_source s JOIN ll_procurement_carton c ON c.id=s.carton_id WHERE c.manifest_id=? ORDER BY s.receipt_id", - (org.springframework.jdbc.core.RowCallbackHandler)rs->sources.computeIfAbsent(rs.getLong(1),x->new ArrayList<>()).add(new Source(rs.getLong(2),rs.getLong(3))),manifestId); - var cartons=jdbc.query("SELECT * FROM ll_procurement_carton WHERE manifest_id=? ORDER BY carton_no",(rs,n)->new CartonRow(rs.getLong("id"),rs.getString("carton_no"), - rs.getBigDecimal("length_cm"),rs.getBigDecimal("width_cm"),rs.getBigDecimal("height_cm"),rs.getBigDecimal("gross_weight_kg"),rs.getLong("quantity"),sources.getOrDefault(rs.getLong("id"),List.of())),manifestId); - return new Snapshot(orderNo,version,status,receipts,dispositions,((Number)manifest.get("version")).intValue(),String.valueOf(manifest.get("status")),cartons); - } - Map currentManifest(long orderId) { - var rows=jdbc.query("SELECT * FROM ll_procurement_manifest WHERE order_id=? AND status<>'SUPERSEDED' ORDER BY version DESC LIMIT 1",ProcurementLifecycleService.FACT_ROW,orderId); - return rows.isEmpty()?null:rows.get(0); - } - long goodTotal(long orderId,Leg leg){return received(orderId,leg,null,true).stream().mapToLong(ReceiptRow::goodQuantity).reduce(0,ProcurementPhysicalService::add);} - List received(long orderId,Leg leg,Long source){return received(orderId,leg,source,false);} - private List received(long orderId,Leg leg,Long source,boolean allSources) { - String sql="SELECT * FROM ll_procurement_receipt WHERE order_id=? AND leg=? AND active=1"; - if(allSources) return jdbc.query(sql,this::receiptRow,orderId,leg.name()); - return source==null?jdbc.query(sql+" AND source_carton_id IS NULL",this::receiptRow,orderId,leg.name()) - :jdbc.query(sql+" AND source_carton_id=?",this::receiptRow,orderId,leg.name(),source); - } - private ReceiptRow receiptRow(ResultSet rs,int index)throws SQLException { - Number source=(Number)rs.getObject("source_carton_id"); - return new ReceiptRow(rs.getLong("id"),rs.getString("receipt_no"),rs.getString("leg"),source==null?null:source.longValue(), - rs.getLong("good_quantity"),rs.getLong("damaged_quantity"),rs.getLong("extra_quantity"),rs.getTimestamp("received_at").toLocalDateTime(),rs.getBoolean("active")); - } - private long resolved(long orderId,Leg leg,Long source,Long receipt,String kind) { - String sql="SELECT COALESCE(SUM(quantity),0) FROM ll_procurement_disposition WHERE order_id=? AND leg=? AND kind=? AND active=1 AND "; - List args=new ArrayList<>(List.of(orderId,leg.name(),kind)); - sql+=source==null?"source_carton_id IS NULL":"source_carton_id=?";if(source!=null)args.add(source); - sql+=receipt==null?" AND receipt_id IS NULL":" AND receipt_id=?";if(receipt!=null)args.add(receipt); - Long value=jdbc.queryForObject(sql,Long.class,args.toArray());return value==null?0:value; - } - private long expected(OverseasProcurementOrder order,Leg leg,Long source) { - var flow=lifecycle.flow(order.getId()); - if(leg==Leg.OVERSEAS) { - if(source!=null || flow.get("purchased_at")==null) throw conflict("海外实收必须关联本单真实购买"); - return ((Number)flow.get("purchased_quantity")).longValue(); - } - if(source==null || flow.get("overseas_shipped_at")==null) throw conflict("国内实收必须关联本单原国际交寄箱"); - var quantities=jdbc.queryForList("SELECT i.quantity FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.order_id=? AND s.customer_id=? AND s.leg='OVERSEAS' AND i.carton_id=?",Long.class,order.getId(),order.getCustomerId(),source); - if(quantities.size()!=1) throw bad("来源箱不属于本单国际交寄");return quantities.get(0); - } - private static void measure(BigDecimal value,int scale){if(value==null || value.signum()<=0 || value.scale()>scale)throw bad("箱规须为正数且最多两位小数,毛重须为正数且最多三位小数");} - private static long add(long a,long b){try{return Math.addExact(a,b);}catch(ArithmeticException e){throw bad("数量超过可处理范围");}} - /** 实收记录使用独立 PR 日流水。 */ - private String number(String prefix){return documentNumbers.next(prefix);} -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java index d1c63b033..cfe729351 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java @@ -1,30 +1,28 @@ package com.baiyue.lastleg.procurement.service; import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; -import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import com.baiyue.lastleg.procurement.repository.ProcurementWorkflowRepository; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.service.OverseasManualFeeService; import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; + import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.*; +import java.util.List; +import java.util.Map; +import java.util.Set; +import static com.baiyue.lastleg.procurement.service.ProcurementErrors.bad; -/** Browser detail aggregation; the customer result is a separate allowlist, never a filtered platform map. */ +/** 简化采购详情只依赖主单、附件、审核流水和共用费用,不查询旧采购履约表。 */ @Service @RequiredArgsConstructor public class ProcurementQueryService { - @jakarta.annotation.Resource private com.baiyue.lastleg.financefee.service.OverseasManualFeeService manualFees; - private final ProcurementLifecycleService lifecycle; - private final ProcurementPhysicalService physical; - private final ProcurementTransportService transport; - private final ProcurementFinanceService finance; - private final ProcurementAdjustmentService adjustments; - private final ProcurementControlService controls; + private final ProcurementWorkflowRepository repository; private final OverseasProcurementService orders; - private final JdbcTemplate jdbc; + private final OverseasManualFeeService manualFees; public record Header(Long id,String orderNo,String customerId,String customerName,String purchaseCategory,String productName, String productSpecification,String productUrl,long purchaseQuantity,BigDecimal budgetUnitPrice,String currency, @@ -32,120 +30,59 @@ public class ProcurementQueryService { String plannedWarehouseName,String actualWarehouseCode,String actualWarehouseName,String status,long version, LocalDateTime createdAt,LocalDateTime updatedAt,String procurementDirection) { } public record Budget(BigDecimal totalAmount,String currency,LocalDateTime authorizedAt) { } - public record Progress(String key,String title,LocalDateTime completedAt) { } - public record Box(String cartonNo,long quantity,BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm,BigDecimal grossWeightKg) { } - public record Shipment(String shipmentNo,String leg,LocalDateTime shippedAt,String cartonNo,long quantity,String carrier,String trackingNo) { } public record CustomerFee(String feeNo,String purpose,String name,Integer version,boolean active,String determination, BigDecimal amount,String currency,BigDecimal originalAmount,String originalCurrency, BigDecimal exchangeRate,String exchangeRateMonth,LocalDate businessDate,String parentFeeNo, String walletStatus,boolean sourceLinked) { - public static CustomerFee from(OverseasProcurementFee fee,String parentNo,boolean linked) { - return new CustomerFee(fee.getFeeNo(),fee.getPurpose(),fee.getFeeItemName(),fee.getFeeVersion(),Boolean.TRUE.equals(fee.getActive()), - fee.getDetermination(),fee.getAmount(),fee.getCurrency(),fee.getOriginalAmount(),fee.getOriginalCurrency(),fee.getExchangeRate(),fee.getExchangeRateMonth(),fee.getBusinessDate(),parentNo,fee.getWalletStatus(),linked); - } - /** 客户仅看到本采购单应收结果,不透出内部审核说明、资金凭证或供应商资料。 */ - public static CustomerFee fromManual(com.baiyue.lastleg.financefee.entity.OverseasFeeSource fee) { - return new CustomerFee(String.valueOf(fee.getSourceFeeId()), fee.getFeeItemCode(), fee.getFeeItemName(), - fee.getSourceVersion(), "ACTIVE".equals(fee.getSourceStatus()), fee.getReviewStatus(), fee.getAmount(), - fee.getCurrency(), fee.getOriginalAmount(), fee.getOriginalCurrency(), fee.getExchangeRate(), - fee.getExchangeRateMonth(), fee.getBusinessDate(), null, fee.getWalletStatus(), - "PUBLISHED".equals(fee.getPublicationStatus())); + /** 客户仅看到本单应收与资金结果,不公开审核说明、资金凭证或供应商资料。 */ + public static CustomerFee fromManual(OverseasFeeSource fee) { + return new CustomerFee(String.valueOf(fee.getSourceFeeId()),fee.getFeeItemCode(),fee.getFeeItemName(), + fee.getSourceVersion(),"ACTIVE".equals(fee.getSourceStatus()),fee.getReviewStatus(),fee.getAmount(), + fee.getCurrency(),fee.getOriginalAmount(),fee.getOriginalCurrency(),fee.getExchangeRate(), + fee.getExchangeRateMonth(),fee.getBusinessDate(),null,fee.getWalletStatus(),"PUBLISHED".equals(fee.getPublicationStatus())); } } + // 保留原客户端响应容器,但旧区块恒为空;不查询旧事实,也不把新批次伪装为旧装箱及面单。 public record CustomerView(Header order,Budget budget,boolean purchased,boolean purchaseConfirmed,boolean purchaseNeedsAttention, - List progress,List boxes,List shipments,List fees,List exceptionSummaries) { } - public record Action(String key,String label,String permission,boolean allowed,String reason) { } - public record PlatformView(CustomerView customerView,Map purchaseFacts,ProcurementPhysicalService.Snapshot physical, - ProcurementTransportService.Snapshot transport,List fees, - List feeCorrections,ProcurementControlService.Snapshot controls, + List progress,List boxes,List shipments,List fees,List exceptionSummaries) { } + public record PlatformView(CustomerView customerView,Map purchaseFacts,Map physical, + Map transport,List fees,List feeCorrections,Map controls, List files,List reviewHistory, - List> operations,List actions,String internalRemark) { } + List operations,List actions,String internalRemark) { } + /** 客户详情按当前客户与完整单号定位。 */ @Transactional(readOnly=true) - public CustomerView customer(String customer,String orderNo) { - var order=lifecycle.requireOrder(customer,orderNo);return customer(order); - } - /** 组装客户采购投影;新增费用与旧费用使用同一公开白名单。 */ + public CustomerView customer(String customer,String orderNo) { return customer(requireOrder(customer,orderNo)); } + + /** 采购仓取主单审核结果,预算取客户下单值;批次和库存从 workflow 读取。 */ private CustomerView customer(OverseasProcurementOrder order) { - var flow=lifecycle.flow(order.getId());var fees=finance.all(order.getId());Map feeNos=new HashMap<>();fees.forEach(f->feeNos.put(f.getId(),f.getFeeNo())); - var header=new Header(order.getId(),order.getOrderNo(),order.getCustomerId(),order.getCustomerName(),order.getPurchaseCategory(),order.getProductName(),order.getProductSpecification(),order.getProductUrl(), - order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(),order.getReceivingAddress(),order.getContactName(),order.getContactPhone(),order.getEstimatedArrivalAt(),order.getPurchaseWarehouseName(), - (String)flow.get("actual_warehouse_code"),(String)flow.get("actual_warehouse_name"),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt(),ProcurementDirection.normalize(order.getProcurementDirection())); - var budgets=jdbc.query("SELECT authorized_amount,currency,reviewed_at FROM ll_procurement_budget_authorization WHERE order_id=? ORDER BY id DESC LIMIT 1", - (rs,n)->new Budget(rs.getBigDecimal(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime()),order.getId()); - var budget=budgets.isEmpty()?new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null):budgets.get(0); - List progress=new ArrayList<>();progress.add(new Progress("CREATED","客户下单",order.getCreatedAt()));progress.add(new Progress("REVIEWED","采购审核",order.getReviewedAt())); - for(var step:List.of(new String[]{"purchased_at","购买完成"},new String[]{"overseas_received_at","海外收货完成"},new String[]{"overseas_shipped_at","海外实际交寄"}, - new String[]{"domestic_received_at","国内收货完成"},new String[]{"charges_confirmed_at","国内费用核定"},new String[]{"customer_shipped_at","已寄客户"},new String[]{"closed_at","异常关闭"})) { - progress.add(new Progress(step[0],ProcurementDirection.display(step[1],order.getProcurementDirection()),date(flow.get(step[0])))); - } - var manifest=physical.currentManifest(order.getId()); - boolean confirmed=manifest!=null && "CONFIRMED".equals(manifest.get("status")); - progress.add(4,new Progress("packing_confirmed","装箱确认",confirmed?date(manifest.get("confirmed_at")):null)); - var labelDates=confirmed?jdbc.query("SELECT l.id,l.created_at FROM ll_procurement_label_set l WHERE l.order_id=? AND l.leg='OVERSEAS' AND l.manifest_id=? ORDER BY l.version DESC LIMIT 1",ProcurementLifecycleService.FACT_ROW,order.getId(),manifest.get("id")):List.>of(); - progress.add(5,new Progress("international_labels","国际面单上传",labelDates.isEmpty()?null:date(labelDates.get(0).get("created_at")))); - LocalDateTime applied=null; - if(!labelDates.isEmpty()) { - var states=jdbc.query("SELECT COUNT(*) AS total,SUM(CASE WHEN applied_at IS NULL THEN 1 ELSE 0 END) AS missing,MAX(applied_at) AS applied_at FROM ll_procurement_label_item WHERE label_set_id=?",ProcurementLifecycleService.FACT_ROW,labelDates.get(0).get("id")).get(0); - if(((Number)states.get("total")).longValue()>0 && ((Number)states.get("missing")).longValue()==0)applied=date(states.get("applied_at")); - } - progress.add(6,new Progress("labels_applied","全部箱贴单",applied)); - var boxes=jdbc.query("SELECT c.carton_no,c.quantity,c.length_cm,c.width_cm,c.height_cm,c.gross_weight_kg FROM ll_procurement_manifest m JOIN ll_procurement_carton c ON c.manifest_id=m.id WHERE m.order_id=? AND m.status='CONFIRMED' ORDER BY c.carton_no", - (rs,n)->new Box(rs.getString(1),rs.getLong(2),rs.getBigDecimal(3),rs.getBigDecimal(4),rs.getBigDecimal(5),rs.getBigDecimal(6)),order.getId()); - var shipments=jdbc.query("SELECT s.shipment_no,s.leg,s.shipped_at,c.carton_no,i.quantity,i.carrier,i.tracking_no FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id JOIN ll_procurement_carton c ON c.id=i.carton_id WHERE s.order_id=? ORDER BY s.id,c.carton_no", - (rs,n)->new Shipment(rs.getString(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime(),rs.getString(4),rs.getLong(5),rs.getString(6),rs.getString(7)),order.getId()); - var summaries=new ArrayList<>(jdbc.queryForList("SELECT client_summary FROM ll_procurement_control_note WHERE order_id=? AND client_summary IS NOT NULL AND TRIM(client_summary)<>'' ORDER BY id",String.class,order.getId())); - summaries.addAll(jdbc.queryForList("SELECT client_summary FROM ll_procurement_closure WHERE order_id=?",String.class,order.getId())); - List customerFees = new ArrayList<>(fees.stream() - .map(f->CustomerFee.from(f,feeNos.get(f.getParentFeeId()),finance.sourceLinked(f))).toList()); - customerFees.addAll(manualFees.procurementFees(order.getCustomerId(), order.getId()).stream() - .filter(f -> "RECEIVABLE".equals(f.getDirection()) && "OVERSEAS_PROCUREMENT".equals(f.getBusinessType()) + var header=new Header(order.getId(),order.getOrderNo(),order.getCustomerId(),order.getCustomerName(),order.getPurchaseCategory(),order.getProductName(), + order.getProductSpecification(),order.getProductUrl(),order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(), + order.getReceivingAddress(),order.getContactName(),order.getContactPhone(),order.getEstimatedArrivalAt(),order.getPurchaseWarehouseName(), + null,order.getPurchaseWarehouseName(),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt(),ProcurementDirection.normalize(order.getProcurementDirection())); + var budget=new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null); + var fees=manualFees.procurementFees(order.getCustomerId(),order.getId()).stream() + .filter(f->"RECEIVABLE".equals(f.getDirection()) && "OVERSEAS_PROCUREMENT".equals(f.getBusinessType()) && order.getCustomerId().equals(f.getCustomerId()) && order.getId().equals(f.getBusinessId())) - .map(CustomerFee::fromManual).toList()); - return new CustomerView(header,budget,flow.get("purchased_at")!=null,flow.get("purchase_confirmed_at")!=null,flow.get("purchase_issue")!=null,List.copyOf(progress),boxes,shipments, - List.copyOf(customerFees),List.copyOf(summaries)); + .map(CustomerFee::fromManual).toList(); + return new CustomerView(header,budget,false,false,false,List.of(),List.of(),List.of(),fees,List.of()); } + + /** 平台旧区块为空,正式作业全部使用七状态批次接口。 */ @Transactional(readOnly=true) public PlatformView platform(String customer,String orderNo,Set permissions) { - var order=lifecycle.requireOrder(customer,orderNo);var base=orders.detail(customer,orderNo);var flow=lifecycle.flow(order.getId()); - var physicalView=physical.read(customer,orderNo);var transportView=transport.read(customer,orderNo); - return new PlatformView(customer(order),flow,physicalView,transportView,finance.all(order.getId()),adjustments.history(customer,orderNo),controls.read(customer,orderNo),base.files(),base.histories(), - jdbc.queryForList("SELECT action,operator_id,reason,detail_json,created_at FROM ll_procurement_command WHERE order_id=? ORDER BY id DESC",order.getId()),actions(order,flow,physicalView,transportView,permissions),base.remark()); + var order=requireOrder(customer,orderNo);var base=orders.detail(customer,orderNo); + return new PlatformView(customer(order),Map.of(), + Map.of("dispositions",List.of(),"receipts",List.of(),"cartons",List.of()), + Map.of("overseasCargo",List.of(),"domesticCargo",List.of(),"shipments",List.of()),List.of(),List.of(), + Map.of("notes",List.of(),"remaining",List.of(),"closures",List.of(),"closureItems",List.of()), + base.files(),base.histories(),List.of(),List.of(),base.remark()); } - /** 新采购通过批次命令校验资格,旧样品动作不再对七主状态开放。 */ - private List actions(OverseasProcurementOrder order,Map flow,ProcurementPhysicalService.Snapshot physicalView,ProcurementTransportService.Snapshot transportView,Set permissions) { - if (ProcurementWorkflowService.STATUSES.contains(order.getStatus())) return List.of(); - boolean closed=Set.of("REJECTED","CANCELLED","EXCEPTION_CLOSED","SHIPPED_TO_CUSTOMER").contains(order.getStatus()),purchased=flow.get("purchased_at")!=null,international=flow.get("overseas_shipped_at")!=null; - String stopped=closed?"订单已结束":null; - String blockers=reason(()->lifecycle.requireNoBlockers(order)); - var sample=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT");boolean paid=sample!=null && finance.sourceLinked(sample); - List result=new ArrayList<>(); - action(result,permissions,"REVIEW","审核采购","review",closed?stopped:!"PENDING_REVIEW".equals(order.getStatus())?"订单不处于待审核":null); - action(result,permissions,"PURCHASE","登记购买完成","buy",closed?stopped:purchased?"已经记录购买事实":order.getReviewedAt()==null?"须审核通过":null); - action(result,permissions,"CONFIRM_PURCHASE","重新核定原购买","buy",closed?stopped:!purchased?"尚无真实购买事实":flow.get("purchase_confirmed_at")!=null?"原购买已核定":null); - action(result,permissions,"AUTHORIZE_BUDGET","核验预算上限","review",closed?stopped:sample!=null?"货款已核定,不能改原费用":null); - action(result,permissions,"PLAN_WAREHOUSE","修改计划仓","review",closed?stopped:flow.get("actual_warehouse_id")!=null?"实际仓已冻结,请使用受控更正":null); - action(result,permissions,"RECEIVE_OVERSEAS","登记海外实收","receive-overseas",closed?stopped:!purchased?"尚无购买来源":flow.get("overseas_received_at")!=null?"海外收货已完成":null); - action(result,permissions,"FINISH_OVERSEAS","确认海外收货完成","receive-overseas",closed?stopped:!purchased?"尚无购买来源":flow.get("overseas_received_at")!=null?"海外收货已完成":null); - action(result,permissions,"PACK","保存装箱草稿","pack",closed?stopped:international?"国际段已交寄":!purchased?"尚无购买来源":null); - action(result,permissions,"CONFIRM_PACKING","确认装箱","pack",closed?stopped:international?"国际段已交寄":blockers!=null?blockers:flow.get("overseas_received_at")==null?"须完成海外收货":!paid?"样品货款或来源关联未完成":!"DRAFT".equals(physicalView.packingStatus())?"须有当前装箱草稿":null); - action(result,permissions,"LABELS","维护两段面单","label",closed?stopped:!"CONFIRMED".equals(physicalView.packingStatus())?"须确认海外装箱":international && flow.get("domestic_received_at")==null?"须完成国内收货后制作寄客户面单":null); - action(result,permissions,"ESTIMATE","核对预估运费","estimate",closed?stopped:international?"国际段已交寄":blockers!=null?blockers:!paid?"样品货款或来源关联未完成":transportView.overseasLabels()==null?"请先维护国际面单":null); - action(result,permissions,"APPLY_LABELS","确认逐箱贴单","apply-label",closed?stopped:international?"国际段已交寄":transportView.overseasLabels()==null?"请先维护国际面单":null); - action(result,permissions,"SHIP_OVERSEAS","确认海外交寄","ship-overseas",transport.shipmentBlockReason(order,Leg.OVERSEAS)); - action(result,permissions,"RECEIVE_DOMESTIC","登记国内实收","receive-domestic",closed?stopped:!international?"尚无国际交寄来源":flow.get("domestic_received_at")!=null?"国内收货已完成":null); - action(result,permissions,"FINISH_DOMESTIC","确认国内收货完成","receive-domestic",closed?stopped:!international?"尚无国际交寄来源":flow.get("domestic_received_at")!=null?"国内收货已完成":null); - action(result,permissions,"REPACK","国内一对一换包装","receive-domestic",closed?stopped:flow.get("domestic_received_at")==null?"须完成国内收货":null); - action(result,permissions,"CONFIRM_CHARGES","确认国内费用","charge",closed?stopped:blockers!=null?blockers:flow.get("domestic_received_at")==null?"须完成国内收货":null); - action(result,permissions,"SHIP_CUSTOMER","确认寄客户","ship-customer",transport.shipmentBlockReason(order,Leg.DOMESTIC)); - action(result,permissions,"MANAGE","事实更正及异常处置","manage",null); - action(result,permissions,"FINANCE_ADJUST","费用更正与退款","finance-adjust",null); - action(result,permissions,"CANCEL","购买前取消","cancel",closed?stopped:purchased?"购买后不能取消,请处理实物资金后异常关闭":null); - action(result,permissions,"EXCEPTION_CLOSE","异常关闭","manage",closed?stopped:!purchased?"购买前请取消订单":blockers!=null?blockers:reason(()->finance.requireShipmentReady(order.getId()))); - return result.stream().map(a -> new Action(a.key(), ProcurementDirection.display(a.label(), order.getProcurementDirection()), - a.permission(), a.allowed(), ProcurementDirection.display(a.reason(), order.getProcurementDirection()))).toList(); + + /** 先验证客户归属,再读取费用和内部附件。 */ + private OverseasProcurementOrder requireOrder(String customer,String orderNo) { + var order=repository.order(customer,orderNo,false); + if(order==null) throw bad("采购单不存在或不属于当前客户"); + return order; } - private static void action(List result,Set permissions,String key,String label,String suffix,String block){String code="platform:overseas-business:purchase:"+suffix;String reason=permissions==null || !permissions.contains(code)?"无此操作权限":block;result.add(new Action(key,label,code,reason==null,reason));} - private static String reason(Runnable action){try{action.run();return null;}catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();}} - private static LocalDateTime date(Object value){return (LocalDateTime)value;} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java deleted file mode 100644 index 815bb76e2..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java +++ /dev/null @@ -1,279 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg; -import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Source; -import com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.*; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.math.BigDecimal; -import java.security.MessageDigest; -import java.time.LocalDateTime; -import java.util.*; -import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*; - -/** Every shipment is a single immutable consumption of this order's entire eligible batch. */ -@Service @RequiredArgsConstructor -public class ProcurementTransportService { - @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; - - private final ProcurementLifecycleService lifecycle; - private final ProcurementPhysicalService physical; - private final ProcurementFinanceService finance; - private final ProcurementWarehouseDirectory warehouses; - private final JdbcTemplate jdbc; - private final ObjectMapper json; - - public record Cargo(long cartonId,String cartonNo,long quantity,BigDecimal lengthCm,BigDecimal widthCm, - BigDecimal heightCm,BigDecimal grossWeightKg,Long packagingRecordId,List sources) { } - public record LabelRow(long cartonId,long fileId,int page,String carrier,String trackingNo,LocalDateTime appliedAt) { } - public record LabelSet(long id,int version,long manifestId,String cargoHash,Address sender,Address receiver,List labels) { } - public record Shipment(long id,String shipmentNo,String leg,LocalDateTime shippedAt) { } - public record Snapshot(String orderNo,long version,String status,LabelSet overseasLabels,LabelSet domesticLabels, - List shipments,List overseasCargo,List domesticCargo) { } - - @Transactional - public Snapshot saveLabels(String customer,String orderNo,String operator,Labels request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"LABELS",request.idempotencyKey(),request))return snapshot(order); - version(order,request.version());open(order);notShipped(order,request.leg()); - var manifest=manifest(order,request.leg()); - if(((Number)manifest.get("version")).intValue()!=request.packingVersion())throw conflict("清单版本已变化,请重新核对"); - var cargo=cargo(order,request.leg(),((Number)manifest.get("id")).longValue()); - if(cargo.isEmpty())throw conflict("没有可寄货物,不能制作面单"); - if(request.labels()==null || request.labels().isEmpty())throw bad("请关联本批全部箱的面单"); - exactCartons(cargo,request.labels().stream().map(Label::cartonId).toList()); - address(request.receiver()); - Address sender; - if(request.leg()==Leg.OVERSEAS) { - var id=(Number)lifecycle.flow(order.getId()).get("actual_warehouse_id"); - if(id==null)throw conflict("尚无实际海外收货仓"); - sender=ProcurementDirection.EXPORT.equals(order.getProcurementDirection()) ? warehouses.sender(id.longValue(), order.getProcurementDirection()) : warehouses.sender(id.longValue()); - } else { - var original=internationalAddresses(order); - sender=original.receiver(); - var customerAddress=new Address(order.getContactName(),order.getContactPhone(),order.getReceivingAddress()); - if(!customerAddress.equals(request.receiver())) { - if(request.addressVoucherFileId()==null)throw bad("最终收件信息变更须关联本单客户确认凭证"); - lifecycle.requireFile(order,request.addressVoucherFileId(),Set.of("EXCEPTION")); - } - } - address(sender); - for(var label:request.labels()) { - lifecycle.requireFile(order,label.fileId(),Set.of("LABEL")); - var types=jdbc.queryForList("SELECT content_type FROM ll_overseas_procurement_file WHERE order_id=? AND file_id=? AND category='LABEL'",String.class,order.getId(),label.fileId()); - if(types.size()!=1 || !Set.of("application/pdf","image/jpeg","image/png").contains(types.get(0)) - || label.page()<1 || (!"application/pdf".equals(types.get(0)) && label.page()!=1))throw bad("面单类型或对应页码不正确"); - text(label.carrier(),128,"承运商");text(label.trackingNo(),128,"运单号"); - } - int next=jdbc.queryForObject("SELECT COALESCE(MAX(version),0)+1 FROM ll_procurement_label_set WHERE order_id=? AND leg=?",Integer.class,order.getId(),request.leg().name()); - long id=IdWorker.getId(); - jdbc.update("INSERT INTO ll_procurement_label_set(id,order_id,customer_id,leg,version,manifest_id,cargo_hash,sender_snapshot,receiver_snapshot,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?)", - id,order.getId(),customer,request.leg().name(),next,manifest.get("id"),hash(cargo),serialize(sender),serialize(request.receiver()),operator); - for(var label:request.labels())jdbc.update("INSERT INTO ll_procurement_label_item(label_set_id,carton_id,file_id,page_no,carrier,tracking_no) VALUES(?,?,?,?,?,?)", - id,label.cartonId(),label.fileId(),label.page(),label.carrier().trim(),label.trackingNo().trim()); - if(request.leg()==Leg.OVERSEAS)order.setStatus("WAIT_LABELS"); - lifecycle.complete(order,operator,"LABELS",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - - /** Different prices release first; callers retry after the authoritative release result to create the replacement. */ - @Transactional - public Snapshot reviewEstimate(String customer,String orderNo,String operator,Estimate request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"ESTIMATE",request.idempotencyKey(),request))return snapshot(order); - version(order,request.version());open(order);notShipped(order,Leg.OVERSEAS);samplePaid(order);lifecycle.requireNoBlockers(order); - var labels=validLabels(order,Leg.OVERSEAS,request.labelVersion()); - if(request.amount()==null || request.amount().signum()<0 || request.amount().scale()>2 || request.businessDate()==null)throw bad("请填写准确的CNY预估金额和业务日期"); - var existing=finance.current(order.getId(),ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE"); - if(existing!=null) { - if(!Set.of("FROZEN","ZERO").contains(existing.getWalletStatus()))throw conflict("原预估资金未完成,请先处理原请求"); - if(existing.getAmount().compareTo(request.amount())!=0) { - finance.releaseEstimate(customer,orderNo,existing.getId()); - order.setStatus("WAIT_LABELS"); - lifecycle.complete(order,operator,"ESTIMATE",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - } else existing=finance.confirm(customer,orderNo,operator,new ProcurementFinanceService.Confirmation( - ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","预估国际运费",request.amount().signum()>0?"POSITIVE":"ZERO", - request.amount(),"CNY",request.businessDate(),null,request.reason())); - Integer count=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_estimate_review WHERE label_set_id=? AND fee_id=?",Integer.class,labels.id(),existing.getId()); - if(count==0)jdbc.update("INSERT INTO ll_procurement_estimate_review(id,order_id,label_set_id,fee_id,reviewed_by) VALUES(?,?,?,?,?)",IdWorker.getId(),order.getId(),labels.id(),existing.getId(),operator); - order.setStatus("WAIT_OVERSEAS_SHIPMENT"); - lifecycle.complete(order,operator,"ESTIMATE",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - - @Transactional - public Snapshot applyLabels(String customer,String orderNo,String operator,ApplyLabels request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"APPLY_LABELS",request.idempotencyKey(),request))return snapshot(order); - version(order,request.version());open(order);notShipped(order,Leg.OVERSEAS); - var labels=validLabels(order,Leg.OVERSEAS,request.labelVersion()); - if(request.appliedAt()==null || request.appliedAt().isAfter(LocalDateTime.now()))throw bad("请填写真实贴单时间"); - var flow=lifecycle.flow(order.getId()); - if(request.appliedAt().isBefore(((LocalDateTime)flow.get("overseas_received_at"))))throw bad("贴单时间不能早于海外收货完成"); - if(request.cartonIds()==null || request.cartonIds().isEmpty() || new HashSet<>(request.cartonIds()).size()!=request.cartonIds().size())throw bad("请选择不重复的实际贴单箱"); - for(long carton:request.cartonIds()) { - var label=labels.labels().stream().filter(l->l.cartonId()==carton).findFirst().orElseThrow(()->bad("贴单箱不属于当前面单")); - if(label.appliedAt()!=null)throw conflict("该箱已确认贴单,不能重复登记"); - jdbc.update("UPDATE ll_procurement_label_item SET applied_at=?,applied_by=? WHERE label_set_id=? AND carton_id=? AND applied_at IS NULL",request.appliedAt(),operator,labels.id(),carton); - } - lifecycle.complete(order,operator,"APPLY_LABELS",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - - @Transactional - public Snapshot repack(String customer,String orderNo,String operator,Repack request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"REPACK",request.idempotencyKey(),request))return snapshot(order); - version(order,request.version());open(order);notShipped(order,Leg.DOMESTIC); - var manifest=manifest(order,Leg.DOMESTIC); - if(cargo(order,Leg.DOMESTIC,((Number)manifest.get("id")).longValue()).stream().noneMatch(c->c.cartonId()==request.cartonId()))throw bad("换包装箱不属于本单国内可寄原箱"); - measure(request.lengthCm(),2);measure(request.widthCm(),2);measure(request.heightCm(),2);measure(request.grossWeightKg(),3); - lifecycle.requireFile(order,request.voucherFileId(),Set.of("EXCEPTION","RECEIPT")); - jdbc.update("INSERT INTO ll_procurement_repack(id,order_id,carton_id,length_cm,width_cm,height_cm,gross_weight_kg,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?)", - IdWorker.getId(),order.getId(),request.cartonId(),request.lengthCm(),request.widthCm(),request.heightCm(),request.grossWeightKg(),request.voucherFileId(),request.reason(),operator); - // New measurements require the domestic operator to reconfirm all current cost determinations. - jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=?",order.getId());order.setStatus("WAIT_CHARGES"); - lifecycle.complete(order,operator,"REPACK",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - - /** 确认采购交寄并记录 PJ 日流水,保留原单关联与重放结果。 */ - @Transactional - public Snapshot ship(String customer,String orderNo,String operator,Ship request) { - var order=lifecycle.lock(customer,orderNo); - if(lifecycle.replay(order,"SHIP",request.idempotencyKey(),request))return snapshot(order); - version(order,request.version());var labels=requireShipmentReady(order,request.leg(),request.labelVersion()); - var cargo=cargo(order,request.leg(),labels.manifestId());exactCartons(cargo,request.cartonIds()); - if(request.shippedAt()==null || request.shippedAt().isAfter(LocalDateTime.now()))throw bad("请填写真实交承运方时间"); - var flow=lifecycle.flow(order.getId()); - if(request.leg()==Leg.OVERSEAS) { - if(labels.labels().stream().anyMatch(l->request.shippedAt().isBefore(l.appliedAt())))throw bad("交寄时间不能早于贴单时间"); - } else { - if(request.shippedAt().isBefore(((LocalDateTime)flow.get("domestic_received_at"))))throw bad("交寄时间不能早于国内收货完成"); - } - long id=IdWorker.getId();String number=documentNumbers.next("PJ"); - jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),?)", - id,order.getId(),customer,number,request.leg().name(),labels.manifestId(),request.shippedAt(),serialize(new Addresses(labels.sender(),labels.receiver())),operator); - for(var carton:cargo) { - var label=labels.labels().stream().filter(l->l.cartonId()==carton.cartonId()).findFirst().orElseThrow(); - jdbc.update("INSERT INTO ll_procurement_shipment_item(shipment_id,carton_id,quantity,carrier,tracking_no,label_snapshot) VALUES(?,?,?,?,?,CAST(? AS JSON))", - id,carton.cartonId(),carton.quantity(),label.carrier(),label.trackingNo(),serialize(new ShipmentItem(carton,label,labels.version()))); - } - String field=request.leg()==Leg.OVERSEAS?"overseas_shipped_at":"customer_shipped_at"; - jdbc.update("UPDATE ll_procurement_flow SET "+field+"=? WHERE order_id=?",request.shippedAt(),order.getId()); - order.setStatus(request.leg()==Leg.OVERSEAS?"INTERNATIONAL_TRANSIT":"SHIPPED_TO_CUSTOMER"); - lifecycle.complete(order,operator,"SHIP",request.idempotencyKey(),request.reason(),request); - return snapshot(order); - } - - private LabelSet requireShipmentReady(OverseasProcurementOrder order,Leg leg,int labelVersion) { - open(order);notShipped(order,leg);samplePaid(order);lifecycle.requireNoBlockers(order);finance.requireAdjustmentsComplete(order.getId()); - var labels=validLabels(order,leg,labelVersion); - if(leg==Leg.OVERSEAS) { - var estimate=finance.current(order.getId(),ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE"); - if(estimate==null || !Set.of("FROZEN","ZERO").contains(estimate.getWalletStatus()))throw conflict("预估国际运费尚未完成冻结或零元核定"); - Integer reviewed=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_estimate_review WHERE label_set_id=? AND fee_id=?",Integer.class,labels.id(),estimate.getId()); - if(reviewed!=1)throw conflict("当前面单和清单尚未核对有效预估"); - if(labels.labels().stream().anyMatch(l->l.appliedAt()==null))throw conflict("本批全部箱须完成贴单确认"); - } else { - if(lifecycle.flow(order.getId()).get("charges_confirmed_at")==null)throw conflict("国内整单费用尚未确认"); - finance.requireShipmentReady(order.getId()); - } - return labels; - } - String shipmentBlockReason(OverseasProcurementOrder order,Leg leg) { - try { - var labels=labels(order.getId(),leg); - requireShipmentReady(order,leg,labels==null?0:labels.version());return null; - } catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();} - } - - public record Addresses(Address sender,Address receiver) { } - public record ShipmentItem(Cargo cargo,LabelRow label,int labelVersion) { } - private Addresses internationalAddresses(OverseasProcurementOrder order) { - var values=jdbc.queryForList("SELECT address_snapshot FROM ll_procurement_shipment WHERE order_id=? AND leg='OVERSEAS'",String.class,order.getId()); - if(values.size()!=1)throw conflict("原国际交寄地址快照缺失"); - return deserialize(values.get(0),Addresses.class); - } - private void samplePaid(OverseasProcurementOrder order) { - var fee=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT"); - if(fee==null || !finance.sourceLinked(fee))throw conflict("样品货款或费用来源关联尚未完成"); - if(lifecycle.flow(order.getId()).get("purchase_issue")!=null)throw conflict("真实购买异常尚未处理"); - } - private void notShipped(OverseasProcurementOrder order,Leg leg) { - if(leg==null)throw bad("运输段不能为空"); - if(lifecycle.flow(order.getId()).get(leg==Leg.OVERSEAS?"overseas_shipped_at":"customer_shipped_at")!=null)throw conflict("本段已实际交寄,不能重复发出或覆盖历史"); - } - private Map manifest(OverseasProcurementOrder order,Leg leg) { - var flow=lifecycle.flow(order.getId()); - if(leg==Leg.DOMESTIC && flow.get("domestic_received_at")==null)throw conflict("须国内收货完成后核对本批可寄货物"); - var manifest=physical.currentManifest(order.getId()); - if(flow.get("overseas_received_at")==null || flow.get("packing_version")==null || manifest==null || !"CONFIRMED".equals(manifest.get("status")))throw conflict("须使用本单已确认的装箱清单"); - return manifest; - } - List cargo(OverseasProcurementOrder order,Leg leg,long manifestId) { - var rows=jdbc.queryForList("SELECT * FROM ll_procurement_carton WHERE order_id=? AND manifest_id=? ORDER BY id",order.getId(),manifestId); - List result=new ArrayList<>(); - for(var row:rows) { - long id=((Number)row.get("id")).longValue(); - List sources; - if(leg==Leg.OVERSEAS)sources=jdbc.query("SELECT receipt_id,quantity FROM ll_procurement_carton_source WHERE carton_id=? ORDER BY receipt_id",(rs,n)->new Source(rs.getLong(1),rs.getLong(2)),id); - else sources=physical.received(order.getId(),Leg.DOMESTIC,id).stream().filter(r->r.goodQuantity()>0) - .sorted(Comparator.comparingLong(ProcurementPhysicalService.ReceiptRow::id)).map(r->new Source(r.id(),r.goodQuantity())).toList(); - long quantity=sources.stream().mapToLong(Source::quantity).reduce(0,Math::addExact); - if(quantity<=0)continue; - var measurements=row;Long packagingRecordId=null; - if(leg==Leg.DOMESTIC) { - var replacements=jdbc.queryForList("SELECT * FROM ll_procurement_repack WHERE order_id=? AND carton_id=? ORDER BY id DESC LIMIT 1",order.getId(),id); - if(!replacements.isEmpty()) {measurements=replacements.get(0);packagingRecordId=((Number)measurements.get("id")).longValue();} - } - result.add(new Cargo(id,String.valueOf(row.get("carton_no")),quantity,(BigDecimal)measurements.get("length_cm"), - (BigDecimal)measurements.get("width_cm"),(BigDecimal)measurements.get("height_cm"),(BigDecimal)measurements.get("gross_weight_kg"),packagingRecordId,sources)); - } - return result; - } - private LabelSet validLabels(OverseasProcurementOrder order,Leg leg,int requestedVersion) { - var manifest=manifest(order,leg);var labels=labels(order.getId(),leg); - if(labels==null || labels.version()!=requestedVersion || labels.manifestId()!=((Number)manifest.get("id")).longValue())throw conflict("面单或清单版本已变化,请重新制单核对"); - var cargo=cargo(order,leg,labels.manifestId()); - if(!labels.cargoHash().equals(hash(cargo)))throw conflict("实收或包装已变化,旧面单不能用于交寄,请重新制单"); - exactCartons(cargo,labels.labels().stream().map(LabelRow::cartonId).toList());return labels; - } - private static void exactCartons(List cargo,List ids) { - Set expected=new HashSet<>(cargo.stream().map(Cargo::cartonId).toList()); - if(cargo.isEmpty() || ids==null || new HashSet<>(ids).size()!=ids.size() || !expected.equals(new HashSet<>(ids)))throw bad("必须覆盖本批全部可寄原箱,不能重复、拆箱、合箱或关联其他订单箱"); - } - @Transactional(readOnly=true) - public Snapshot read(String customer,String orderNo) { - return snapshot(lifecycle.requireOrder(customer,orderNo)); - } - private Snapshot snapshot(OverseasProcurementOrder order) { - var shipments=jdbc.query("SELECT id,shipment_no,leg,shipped_at FROM ll_procurement_shipment WHERE order_id=? ORDER BY id", - (rs,n)->new Shipment(rs.getLong(1),rs.getString(2),rs.getString(3),rs.getTimestamp(4).toLocalDateTime()),order.getId()); - var manifest=physical.currentManifest(order.getId()); - Long manifestId=manifest==null?null:((Number)manifest.get("id")).longValue(); - return new Snapshot(order.getOrderNo(),order.getVersion(),order.getStatus(),labels(order.getId(),Leg.OVERSEAS),labels(order.getId(),Leg.DOMESTIC),shipments, - manifestId==null?List.of():cargo(order,Leg.OVERSEAS,manifestId),manifestId==null?List.of():cargo(order,Leg.DOMESTIC,manifestId)); - } - private LabelSet labels(long orderId,Leg leg) { - var rows=jdbc.queryForList("SELECT * FROM ll_procurement_label_set WHERE order_id=? AND leg=? ORDER BY version DESC LIMIT 1",orderId,leg.name()); - if(rows.isEmpty())return null;var row=rows.get(0);long id=((Number)row.get("id")).longValue(); - var items=jdbc.query("SELECT * FROM ll_procurement_label_item WHERE label_set_id=? ORDER BY carton_id",(rs,n)->new LabelRow(rs.getLong("carton_id"),rs.getLong("file_id"),rs.getInt("page_no"),rs.getString("carrier"),rs.getString("tracking_no"),rs.getTimestamp("applied_at")==null?null:rs.getTimestamp("applied_at").toLocalDateTime()),id); - return new LabelSet(id,((Number)row.get("version")).intValue(),((Number)row.get("manifest_id")).longValue(),String.valueOf(row.get("cargo_hash")), - deserialize(jsonText(row.get("sender_snapshot")),Address.class),deserialize(jsonText(row.get("receiver_snapshot")),Address.class),items); - } - private static String jsonText(Object value){return value instanceof byte[] b?new String(b,java.nio.charset.StandardCharsets.UTF_8):String.valueOf(value);} - private static void address(Address value){if(value==null)throw bad("收寄资料不完整");text(value.name(),128,"收寄姓名");text(value.address(),1000,"收寄地址");if(value.phone()==null || !value.phone().matches("[+0-9 ()-]{5,64}"))throw bad("请填写有效快递联系电话");} - private static void text(String value,int max,String name){if(value==null || value.isBlank() || value.length()>max)throw bad(name+"不能为空或超过长度限制");} - private static void measure(BigDecimal value,int scale){if(value==null || value.signum()<=0 || value.scale()>scale)throw bad("换包装箱规及重量须符合正数与精度要求");} - private String serialize(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} - private T deserialize(String value,Class type){try{return json.readValue(value,type);}catch(Exception e){throw new IllegalStateException("采购运输快照无效",e);}} - private String hash(Object value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(serialize(value).getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java index 07f3e2656..7561fa7d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java @@ -47,11 +47,12 @@ public class ProcurementWarehouseDirectory { } return warehouse; } - public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id) { + public record Sender(String name, String phone, String address) { } + public Sender sender(long id) { return sender(id, ProcurementDirection.IMPORT); } /** 从起运仓主档生成发件信息,两种方向均校验作业地址。 */ - public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id, String direction) { + public Sender sender(long id, String direction) { required(id, direction); var result=client.activeWarehouse(id,token); if(result==null || !result.successful() || result.data()==null || result.data().warehouse()==null) throw unavailable(); @@ -69,7 +70,7 @@ public class ProcurementWarehouseDirectory { var a=operating.get(0); String address=java.util.stream.Stream.of(a.countryCode(),a.stateProvince(),a.city(),a.district(),a.addressLine1(),a.addressLine2(),a.postalCode()) .filter(v->v!=null && !v.isBlank()).collect(java.util.stream.Collectors.joining(" ")); - return new com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address(warehouse.contactName(),warehouse.contactPhone(),address); + return new Sender(warehouse.contactName(),warehouse.contactPhone(),address); } private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"采购收货仓目录暂不可用,请稍后重试"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java index 1812af3ca..7e03c0ce6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java @@ -13,8 +13,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.security.MessageDigest; import java.util.*; -import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.bad; -import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.conflict; +import static com.baiyue.lastleg.procurement.service.ProcurementErrors.bad; +import static com.baiyue.lastleg.procurement.service.ProcurementErrors.conflict; /** 采购仅保留审核、分批收货、客户确认与分批出库;库存始终属于采购单。 */ @Service @RequiredArgsConstructor diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 335836035..9efa9318b 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -3220,39 +3220,6 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file ( CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION')) ) ENGINE=InnoDB COMMENT='海外采购签收文件及费用凭证'; -CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee ( - purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定', - confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作', - fee_version INT NULL COMMENT '同用途费用确认版本', - active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本', - determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用', - original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD', - original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额', - exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照', - exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份', - business_date DATE NULL COMMENT '实际费用业务日期', - wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录', - wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估', - wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', - wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用', - settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件', - source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID', - parent_fee_id BIGINT NULL COMMENT '原费用或预估引用', - idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号', - request_hash CHAR(64) NULL COMMENT '确认请求摘要', - id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', - fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称', - amount DECIMAL(20,2) NOT NULL COMMENT 'CNY金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', voucher_file_id BIGINT NULL COMMENT '凭证文件ID', - voucher_file_name VARCHAR(255) NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明', - wallet_status VARCHAR(32) NOT NULL COMMENT '资金状态', wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明', - wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', created_by VARCHAR(128) NULL COMMENT '登记人', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')), - UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version), - UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key), - PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at), - CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>=0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY') -) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期'; @@ -3605,242 +3572,30 @@ PREPARE return_sku_index_stmt FROM @return_sku_index_ddl; EXECUTE return_sku_index_stmt; DEALLOCATE PREPARE return_sku_index_stmt; -CREATE TABLE IF NOT EXISTS ll_procurement_wallet_attempt ( - id BIGINT NOT NULL COMMENT '请求尝试ID', - order_id BIGINT NOT NULL COMMENT '采购订单ID', - customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID', - attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号', - operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款', - business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号', - amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空', - command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用', - status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败', - result_hash CHAR(64) NULL COMMENT '原业务回执摘要', - wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', - wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用', - error_code VARCHAR(64) NULL COMMENT '业务失败码', - error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间', - completed_at DATETIME(3) NULL COMMENT '最终结果时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id), - UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no), - KEY idx_procurement_attempt_order(customer_id,order_id,status), - CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果'; -- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。 -CREATE TABLE IF NOT EXISTS ll_procurement_flow ( - order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次', - customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - purchased_at DATETIME(3) NULL COMMENT '真实购买时间', - purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品', - purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格', - purchased_quantity BIGINT NULL COMMENT '实际购买件数', - purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额', - purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD', - purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用', - purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明', - purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功', - actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID', - actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照', - actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照', - overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间', - packing_version INT NULL COMMENT '当前确认装箱清单版本', - overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间', - domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间', - charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间', - customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间', - domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人', - domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话', - domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址', - cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间', - closed_at DATETIME(3) NULL COMMENT '异常关闭时间', - control_reason VARCHAR(1000) NULL COMMENT '取消或关闭原因', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id), - CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0), - CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实'; -CREATE TABLE IF NOT EXISTS ll_procurement_command ( - id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', - customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作', - idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要', - operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据', - detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key), - KEY idx_procurement_command_customer(customer_id,order_id,created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计'; -CREATE TABLE IF NOT EXISTS ll_procurement_budget_authorization ( - id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', - customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种', - authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种', - voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据', - reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间', - PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id), - CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0), - CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史'; -- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。 -CREATE TABLE IF NOT EXISTS ll_procurement_receipt ( - id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', - source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓', - good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量', - received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实', - supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active), - CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0), - CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实'; -CREATE TABLE IF NOT EXISTS ll_procurement_disposition ( - id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源', - receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货', - quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货', - active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实', - voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据', - created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', - PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind), - CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款'; -CREATE TABLE IF NOT EXISTS ll_procurement_manifest ( - id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代', - confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version), - CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本'; -CREATE TABLE IF NOT EXISTS ll_procurement_carton ( - id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本', - carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米', - width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米', - gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id), - CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数'; -CREATE TABLE IF NOT EXISTS ll_procurement_carton_source ( - carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数', - PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量'; -CREATE TABLE IF NOT EXISTS ll_procurement_shipment ( - id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户', - manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间', - address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no), - CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄'; -CREATE TABLE IF NOT EXISTS ll_procurement_shipment_item ( - shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数', - carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照', - label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照', - PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实'; -- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。 -CREATE TABLE IF NOT EXISTS ll_procurement_label_set ( - id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本', - manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要', - sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照', - created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version), - CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照'; -CREATE TABLE IF NOT EXISTS ll_procurement_label_item ( - label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID', - file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始', - carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号', - applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人', - PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实'; -CREATE TABLE IF NOT EXISTS ll_procurement_estimate_review ( - id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本', - fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员', - reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联'; -CREATE TABLE IF NOT EXISTS ll_procurement_repack ( - id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源', - length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米', - height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克', - voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据', - created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', - PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id), - CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史'; -- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。 -CREATE TABLE IF NOT EXISTS ll_procurement_fee_correction ( - id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根', - idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要', - target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种', - exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份', - target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY', - difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录', - voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据', - created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key), - KEY idx_procurement_correction_root(order_id,root_fee_id,id), - CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照'; -CREATE TABLE IF NOT EXISTS ll_procurement_fee_correction_item ( - correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID', - PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联'; -- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。 -CREATE TABLE IF NOT EXISTS ll_procurement_control_note ( - id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型', - target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约', - voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明', - client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容', - original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照', - created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', - resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间', - resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常'; -CREATE TABLE IF NOT EXISTS ll_procurement_closure ( - id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', - closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证', - financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实', - reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要', - created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实'; -CREATE TABLE IF NOT EXISTS ll_procurement_closure_item ( - id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID', - source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空', - quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置', - destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明', - PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id), - CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; CREATE TABLE IF NOT EXISTS ll_appointment_impact ( diff --git a/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml b/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml index 2cfa18852..8b22a56b7 100644 --- a/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml @@ -1,833 +1,41 @@ openapi: 3.0.3 info: - title: 采购样品履约节点 API + title: 海外采购详情与附件 API version: 1.0.0 - description: 客户范围、节点资格、版本与幂等由服务端校验;购买事实与资金结果分开保存。采购双方向复用两段技术编码:OVERSEAS 为起运段,DOMESTIC 为目的地段;按 procurementDirection 解释实际区域。 + description: 七状态采购仅使用主单、审核流水、采购附件及三个批次表;作业接口见 procurement-workflow-v1.yaml。旧购买、预算核验、装箱、面单、两段运输及专属费用接口已退役。 security: -- bearerAuth: [] + - bearerAuth: [] paths: - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fulfillment: - get: - summary: 读取采购履约与货款结果 - description: 权限 platform:overseas-business:purchase:list;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 统一业务响应,须检查 code;成功不等于资金已扣款 - content: - application/json: - schema: - $ref: '#/components/schemas/Response' - '401': - description: 未认证 - '403': - description: 主体或动作权限不允许 - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/purchase: - post: - summary: 登记真实购买并核定样品货款 - description: 权限 platform:overseas-business:purchase:buy;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Purchase' - responses: - '200': - description: 统一业务响应,须检查 code;成功不等于资金已扣款 - content: - application/json: - schema: - $ref: '#/components/schemas/Response' - '401': - description: 未认证 - '403': - description: 主体或动作权限不允许 - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/purchase/confirm: - post: - summary: 补齐授权或汇率后核定原购买 - description: 权限 platform:overseas-business:purchase:buy;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Command' - responses: - '200': - description: 统一业务响应,须检查 code;成功不等于资金已扣款 - content: - application/json: - schema: - $ref: '#/components/schemas/Response' - '401': - description: 未认证 - '403': - description: 主体或动作权限不允许 - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/budget-authorizations: - post: - summary: 核验客户预算上限授权 - description: 权限 platform:overseas-business:purchase:review;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Budget' - responses: - '200': - description: 统一业务响应,须检查 code;成功不等于资金已扣款 - content: - application/json: - schema: - $ref: '#/components/schemas/Response' - '401': - description: 未认证 - '403': - description: 主体或动作权限不允许 - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/planned-warehouse: - post: - summary: 首次实收前选择计划采购仓 - description: 权限 platform:overseas-business:purchase:review;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Warehouse' - responses: - '200': - description: 统一业务响应,须检查 code;成功不等于资金已扣款 - content: - application/json: - schema: - $ref: '#/components/schemas/Response' - '401': - description: 未认证 - '403': - description: 主体或动作权限不允许 - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/cancel: - post: - summary: 购买前取消采购订单 - description: 权限 platform:overseas-business:purchase:cancel;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Command' - responses: - '200': - description: 统一业务响应,须检查 code;成功不等于资金已扣款 - content: - application/json: - schema: - $ref: '#/components/schemas/Response' - '401': - description: 未认证 - '403': - description: 主体或动作权限不允许 - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/physical: - get: - summary: 查询采购实收与当前装箱版本 - description: 权限 platform:overseas-business:purchase:list;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 检查业务code;返回最新采购实收及装箱快照 - content: - application/json: - schema: - $ref: '#/components/schemas/PhysicalResponse' - '403': - description: 无动作权限 - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/overseas-receipts: - post: - summary: 登记海外分次实收 - description: 权限 platform:overseas-business:purchase:receive-overseas;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 检查业务code;返回最新采购实收及装箱快照 - content: - application/json: - schema: - $ref: '#/components/schemas/PhysicalResponse' - '403': - description: 无动作权限 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Receipt' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/domestic-receipts: - post: - summary: 按原交寄箱登记国内实收 - description: 权限 platform:overseas-business:purchase:receive-domestic;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 检查业务code;返回最新采购实收及装箱快照 - content: - application/json: - schema: - $ref: '#/components/schemas/PhysicalResponse' - '403': - description: 无动作权限 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Receipt' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/overseas-receipts/complete: - post: - summary: 确认海外收货完成 - description: 权限 platform:overseas-business:purchase:receive-overseas;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 检查业务code;返回最新采购实收及装箱快照 - content: - application/json: - schema: - $ref: '#/components/schemas/PhysicalResponse' - '403': - description: 无动作权限 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/FinishReceipt' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/domestic-receipts/complete: - post: - summary: 确认国内收货完成 - description: 权限 platform:overseas-business:purchase:receive-domestic;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 检查业务code;返回最新采购实收及装箱快照 - content: - application/json: - schema: - $ref: '#/components/schemas/PhysicalResponse' - '403': - description: 无动作权限 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/FinishReceipt' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/dispositions: - post: - summary: 确认单内差异处置 - description: 权限 platform:overseas-business:purchase:manage;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 检查业务code;返回最新采购实收及装箱快照 - content: - application/json: - schema: - $ref: '#/components/schemas/PhysicalResponse' - '403': - description: 无动作权限 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Disposition' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/packing: - post: - summary: 保存新的装箱清单版本 - description: 权限 platform:overseas-business:purchase:pack;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 检查业务code;返回最新采购实收及装箱快照 - content: - application/json: - schema: - $ref: '#/components/schemas/PhysicalResponse' - '403': - description: 无动作权限 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Packing' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/packing/confirm: - post: - summary: 确认整批装箱 - description: 权限 platform:overseas-business:purchase:pack;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 检查业务code;返回最新采购实收及装箱快照 - content: - application/json: - schema: - $ref: '#/components/schemas/PhysicalResponse' - '403': - description: 无动作权限 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ConfirmPacking' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/packing/withdraw: - post: - summary: 撤回尚未交寄的确认清单 - description: 权限 platform:overseas-business:purchase:manage;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 检查业务code;返回最新采购实收及装箱快照 - content: - application/json: - schema: - $ref: '#/components/schemas/PhysicalResponse' - '403': - description: 无动作权限 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Command' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/receipts/{receiptId}/correct: - post: - summary: 保留原记录并受控更正实收 - description: 权限 platform:overseas-business:purchase:manage;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - - name: receiptId - in: path - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 检查业务code;返回最新采购实收及装箱快照 - content: - application/json: - schema: - $ref: '#/components/schemas/PhysicalResponse' - '403': - description: 无动作权限 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Correction' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/dispositions/{dispositionId}/revoke: - post: - summary: 凭证支持的差异处置更正 - description: 权限 platform:overseas-business:purchase:manage;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - - name: dispositionId - in: path - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 检查业务code;返回最新采购实收及装箱快照 - content: - application/json: - schema: - $ref: '#/components/schemas/PhysicalResponse' - '403': - description: 无动作权限 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RevokeDisposition' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/transport: - get: - summary: 读取两段面单及实际交寄事实 - description: 权限 platform:overseas-business:purchase:list。平台内部资料,不直接对客输出。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code;资金处理中不代表已完成 - content: - application/json: - schema: - $ref: '#/components/schemas/TransportResponse' - '400': - description: 字段、来源或数量不合法 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、重复发运或节点尚未满足 - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/labels: - post: - summary: 保存本段全批面单新版本 - description: 权限 platform:overseas-business:purchase:label。清单、文件、页码与全部可寄原箱关联;国际寄件取实际仓主档。国内改最终收件信息须有本单EXCEPTION客户确认凭证。新版本不沿用旧贴单。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code;资金处理中不代表已完成 - content: - application/json: - schema: - $ref: '#/components/schemas/TransportResponse' - '400': - description: 字段、来源或数量不合法 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、重复发运或节点尚未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Labels' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/international-estimate: - post: - summary: 核对国际预估并申请冻结或释放 - description: 权限 platform:overseas-business:purchase:estimate。仅CNY。同金额可显式沿用原FROZEN/ZERO;变价先申请释放,等待成功后以新请求确认新金额,未决不得交寄。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code;资金处理中不代表已完成 - content: - application/json: - schema: - $ref: '#/components/schemas/TransportResponse' - '400': - description: 字段、来源或数量不合法 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、重复发运或节点尚未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Estimate' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/labels/apply: - post: - summary: 逐箱登记真实贴单 - description: 权限 platform:overseas-business:purchase:apply-label。仅海外段;可以分次贴单,不能重复记录相同版本相同箱。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code;资金处理中不代表已完成 - content: - application/json: - schema: - $ref: '#/components/schemas/TransportResponse' - '400': - description: 字段、来源或数量不合法 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、重复发运或节点尚未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ApplyLabels' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/domestic-repack: - post: - summary: 国内原箱一对一换包装 - description: 权限 platform:overseas-business:purchase:receive-domestic。不改变来源及件数,不拆箱合箱;使旧国内面单失效,并要求重新核对国内费用。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code;资金处理中不代表已完成 - content: - application/json: - schema: - $ref: '#/components/schemas/TransportResponse' - '400': - description: 字段、来源或数量不合法 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、重复发运或节点尚未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Repack' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/overseas-shipment: - post: - summary: 确认整批海外实际交寄 - description: 权限 platform:overseas-business:purchase:ship-overseas。leg固定OVERSEAS;全部箱、当前面单、逐箱贴单、样品实扣关联、唯一有效预估均须通过。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code;资金处理中不代表已完成 - content: - application/json: - schema: - $ref: '#/components/schemas/TransportResponse' - '400': - description: 字段、来源或数量不合法 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、重复发运或节点尚未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Ship' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/customer-shipment: - post: - summary: 确认整批国内寄客户 - description: 权限 platform:overseas-business:purchase:ship-customer。leg固定DOMESTIC;核费及全部资金完成才可交寄。每单每段最多一次,重复不同请求不能再发货。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code;资金处理中不代表已完成 - content: - application/json: - schema: - $ref: '#/components/schemas/TransportResponse' - '400': - description: 字段、来源或数量不合法 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、重复发运或节点尚未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Ship' /api/platform/v1/last-leg/overseas-procurements/{orderNo}/files/{category}: post: summary: 上传采购内部附件 description: 按类别独立授权:PURCHASE=buy;BUDGET=review;LABEL=label;EXCEPTION=manage;RECEIPT=receive-overseas或receive-domestic;VOUCHER=charge或finance-adjust。新增四类只允许PDF/JPG/PNG,最大10MiB。 parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - - name: category - in: path - required: true - schema: - type: string - enum: - - RECEIPT - - VOUCHER - - LABEL - - PURCHASE - - BUDGET - - EXCEPTION + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: category + in: path + required: true + schema: + type: string + enum: + - RECEIPT + - VOUCHER + - LABEL + - PURCHASE + - BUDGET + - EXCEPTION requestBody: required: true content: @@ -835,631 +43,52 @@ paths: schema: type: object required: - - file + - file properties: file: type: string format: binary responses: - '200': + "200": description: 本单附件元数据业务响应 - '403': + "403": description: 主体或类别权限不足 /api/platform/v1/last-leg/overseas-procurements/{orderNo}/files/{fileId}/content: get: summary: 读取本采购单内部文件 description: 权限 platform:overseas-business:purchase:list;校验客户、订单、分类与文件归属。附件下载,Cache-Control no-store。客户入口不开放这些内部文件。 parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - - name: fileId - in: path - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: fileId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ responses: - '200': + "200": description: 文件内容 content: application/octet-stream: schema: type: string format: binary - '400': + "400": description: 文件不属于本单 - '403': + "403": description: 无平台采购只读权限 - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/charges: - get: - summary: 读取核费与资金处理结果 - description: 权限 platform:overseas-business:purchase:list。仅平台使用,内部费用及诊断字段不得直接透传客户。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/ChargesResponse' - '400': - description: 参数或本单费用归属错误 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、原请求未决或节点未满足 - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fee-corrections: - get: - summary: 读取费用链更正历史 - description: 权限 platform:overseas-business:purchase:list。不可变目标、原汇率、确认时净额及产生的逐笔差额或退款业务号。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/FeeCorrectionsResponse' - '400': - description: 参数或本单费用归属错误 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、原请求未决或节点未满足 - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/charges/confirm: - post: - summary: 确认国内整单费用 - description: 权限 platform:overseas-business:purchase:charge。须国内实收完成。三项固定费用明确核定,其他不适用不产生虚构收费行;重核引用原feeId不再次扣款,不可省略已有有效实际费用。确认不等于资金成功。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/ChargesResponse' - '400': - description: 参数或本单费用归属错误 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、原请求未决或节点未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ConfirmCharges' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/retry: - post: - summary: 重试已明确失败的费用请求 - description: 样品货款要求buy,预估要求estimate,实际国际运费/关税/国内运费/其他要求charge,调整/退款要求finance-adjust;未知结果拒绝新请求,已冻结仅重试结算。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - - name: feeId - in: path - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/ChargesResponse' - '400': - description: 参数或本单费用归属错误 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、原请求未决或节点未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Command' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/terminate: - post: - summary: 终止明确失败的费用版本 - description: 权限 platform:overseas-business:purchase:finance-adjust。终止旧版本重试资格;有效冻结必须释放成功后才可替代,重新核对整单费用。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - - name: feeId - in: path - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/ChargesResponse' - '400': - description: 参数或本单费用归属错误 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、原请求未决或节点未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Command' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/correct-target: - post: - summary: 按正确原币总额更正费用链 - description: 权限 platform:overseas-business:purchase:finance-adjust。选择原实际费用根。沿用原汇率计算正确CNY总额,以成功原扣+成功差额-成功退款求净额;正差额新扣、负差额逐笔关联退款,未决旧调整阻断。CNY差额单自身汇率为1,目标原币汇率保留在更正快照,不因分差反推原币。已寄客户不回退履约。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - - name: feeId - in: path - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/FeeCorrectionResponse' - '400': - description: 参数或本单费用归属错误 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、原请求未决或节点未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CorrectFeeTarget' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/additional: - post: - summary: 登记独立新发生费用 - description: 权限 platform:overseas-business:purchase:finance-adjust。仅已发生购买订单;按新费用真实业务日冻结汇率,以独立费用号发起扣款,核定入口为 FINANCIAL。保留原费用;普通核费首次确认后只引用已有版本。未交寄订单需重新核费,已交寄事实不回退。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/FeeVersionResponse' - '400': - description: 参数或本单费用归属错误 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、原请求未决或节点未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AdditionalFee' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/replace-failed: - post: - summary: 重新核定已终止且未实扣的实际费用 - description: 权限 platform:overseas-business:purchase:finance-adjust。仅允许最新原实际费用已明确失败、终止重试且有效冻结已释放后替代;拒绝已实扣、未决或已有后续版本。沿用原币、业务日和汇率快照,使用新资金业务身份;货款仍核验客户预算。以更正记录关联旧版本与新实际费用,零元不发起资金请求。原购买与履约事实不回退。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - - name: feeId - in: path - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/FeeCorrectionResponse' - '400': - description: 参数或本单费用归属错误 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、原请求未决或节点未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CorrectFeeTarget' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/refund: - post: - summary: 关联原实扣退回客户钱包 - description: 权限 platform:overseas-business:purchase:finance-adjust。amount为正额CNY。仅本单已结算消费可退;整链有未完成调整/退款时不接受新请求,重放原请求仍返回原退款。累计金额由Finance原子封顶,不走外部支付。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - - name: feeId - in: path - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 业务结果,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/FeeVersionResponse' - '400': - description: 参数或本单费用归属错误 - '403': - description: 主体或动作权限不足 - '409': - description: 版本冲突、原请求未决或节点未满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RefundFee' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/controls: - get: - summary: 读取异常、更正与剩余货物来源 - description: 权限 platform:overseas-business:purchase:list。内部记录,剩余来源用于逐项填写最终去向。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 统一业务响应,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/ControlResponse' - '400': - description: 来源、证据或数量不匹配 - '403': - description: 无当前操作权限 - '409': - description: 版本、实物或资金前置不满足 - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/control-notes: - post: - summary: 追加事实更正说明或阻断异常 - description: 权限 platform:overseas-business:purchase:manage。校验本单事实和EXCEPTION凭证;已交寄允许追加说明,不改原货物、运单或状态。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 统一业务响应,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/ControlResponse' - '400': - description: 来源、证据或数量不匹配 - '403': - description: 无当前操作权限 - '409': - description: 版本、实物或资金前置不满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ControlNote' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/control-notes/{noteId}/resolve: - post: - summary: 有凭证解除采购阻断异常 - description: 权限 platform:overseas-business:purchase:manage。不覆盖原说明,记录处理人和证据;不改变资金结果。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - - name: noteId - in: path - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 统一业务响应,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/ControlResponse' - '400': - description: 来源、证据或数量不匹配 - '403': - description: 无当前操作权限 - '409': - description: 版本、实物或资金前置不满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ResolveControl' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/actual-warehouse/correct: - post: - summary: 受控更正实际收货仓 - description: 权限 platform:overseas-business:purchase:manage。未有后续箱明细时更新有效实际仓并留原快照及原实收;已有后续实物或已结束时仅追加阻断说明,不重归属货物。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 统一业务响应,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/ControlResponse' - '400': - description: 来源、证据或数量不匹配 - '403': - description: 无当前操作权限 - '409': - description: 版本、实物或资金前置不满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CorrectWarehouse' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/exception-close: - post: - summary: 实物资金收口后异常关闭 - description: 权限 platform:overseas-business:purchase:manage。购买后逐来源核平剩余货物最终去向,并提供客户停止履约证据、财务结论及对客摘要。全部已知资金与费用来源处理结束且无阻断异常才可关闭;不退款、不生成退货或零数量发运。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 统一业务响应,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/ControlResponse' - '400': - description: 来源、证据或数量不匹配 - '403': - description: 无当前操作权限 - '409': - description: 版本、实物或资金前置不满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ExceptionClose' - /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/release-estimate: - post: - summary: 释放不再使用的预估冻结 - description: 权限 platform:overseas-business:purchase:finance-adjust。只作用于本单有效预估;未决或已实际结算不允许释放。供异常处理使用,不能将实际消费当冻结释放。 - parameters: - - name: orderNo - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 64 - - name: customerId - in: query - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - - name: feeId - in: path - required: true - schema: - type: string - pattern: ^[1-9][0-9]*$ - responses: - '200': - description: 统一业务响应,须检查code - content: - application/json: - schema: - $ref: '#/components/schemas/ChargesResponse' - '400': - description: 来源、证据或数量不匹配 - '403': - description: 无当前操作权限 - '409': - description: 版本、实物或资金前置不满足 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Command' components: securitySchemes: bearerAuth: @@ -1467,1517 +96,80 @@ components: scheme: bearer bearerFormat: JWT schemas: - Command: + ProcurementCustomerResponse: type: object required: - - version - - idempotencyKey - - reason - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 1000 - Purchase: - allOf: - - $ref: '#/components/schemas/Command' - - type: object - required: - - productName - - quantity - - amount - - currency - - purchasedAt - - estimatedArrivalAt - - voucherFileId - properties: - productName: - type: string - minLength: 1 - maxLength: 255 - specification: - type: string - nullable: true - maxLength: 500 - quantity: - type: integer - format: int64 - minimum: 1 - amount: - type: number - minimum: 0 - multipleOf: 0.01 - currency: - type: string - enum: - - CNY - - USD - purchasedAt: - type: string - format: date-time - description: 真实购买时间,不得在未来 - estimatedArrivalAt: - type: string - format: date-time - voucherFileId: - type: string - description: 必须属于本订单的购买或费用凭证 - Budget: - allOf: - - $ref: '#/components/schemas/Command' - - type: object - required: - - amount - - currency - - voucherFileId - properties: - amount: - type: number - minimum: 0 - multipleOf: 0.01 - description: 新授权货款总上限 - currency: - type: string - enum: - - CNY - - USD - voucherFileId: - type: string - description: 本订单客户书面或聊天确认凭证 - Warehouse: - allOf: - - $ref: '#/components/schemas/Command' - - type: object - required: - - warehouseId - properties: - warehouseId: - type: string - description: 国内权威目录的有效公共海外收货仓引用 - Response: - type: object + - code + - message + - data properties: code: type: string message: type: string data: - type: object - properties: - orderNo: - type: string - version: - type: integer - format: int64 - status: - type: string - purchase: - type: object - properties: - recorded: - type: boolean - confirmed: - type: boolean - description: 货款已核定,不代表已扣款 - issue: - type: string - nullable: true - feeNo: - type: string - nullable: true - walletStatus: - type: string - nullable: true - sourceLinked: - type: boolean - description: 已实扣费用成功入池关联或明确免费 - Receipt: - allOf: - - $ref: '#/components/schemas/Command' - - type: object - required: - - leg - - receivedAt - properties: - leg: - type: string - enum: - - OVERSEAS - - DOMESTIC - warehouseId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - description: 海外段必填的实际公共收货仓,国内为空 - sourceCartonId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - description: 国内段必填的本单原国际交寄箱,海外为空 - goodQuantity: - type: integer - format: int64 - minimum: 0 - damagedQuantity: - type: integer - format: int64 - minimum: 0 - extraQuantity: - type: integer - format: int64 - minimum: 0 - receivedAt: - type: string - format: date-time - FinishReceipt: - allOf: - - $ref: '#/components/schemas/Command' - - type: object - required: - - leg - properties: - leg: - type: string - enum: - - OVERSEAS - - DOMESTIC - Disposition: - allOf: - - $ref: '#/components/schemas/Command' - - type: object - required: - - leg - - kind - - quantity - - decision - - voucherFileId - properties: - leg: - type: string - enum: - - OVERSEAS - - DOMESTIC - sourceCartonId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - receiptId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - kind: - type: string - enum: - - SHORTAGE - - DAMAGED - - EXTRA - quantity: - type: integer - format: int64 - minimum: 1 - decision: - type: string - enum: - - ACCEPT_SHORTAGE - - DISPOSE_DAMAGED - - RETURN_EXTRA - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - Source: + $ref: "#/components/schemas/ProcurementCustomerView" + ProcurementCustomerView: type: object required: - - receiptId - - quantity + - order + - budget + - purchased + - purchaseConfirmed + - purchaseNeedsAttention + - progress + - boxes + - shipments + - fees + - exceptionSummaries properties: - receiptId: - type: string - pattern: ^[1-9][0-9]*$ - quantity: - type: integer - format: int64 - minimum: 1 - Carton: - type: object - required: - - cartonNo - - lengthCm - - widthCm - - heightCm - - grossWeightKg - - sources - properties: - cartonNo: - type: string - minLength: 1 - maxLength: 64 - lengthCm: - type: number - minimum: 0 - exclusiveMinimum: true - multipleOf: 0.01 - widthCm: - type: number - minimum: 0 - exclusiveMinimum: true - multipleOf: 0.01 - heightCm: - type: number - minimum: 0 - exclusiveMinimum: true - multipleOf: 0.01 - grossWeightKg: - type: number - minimum: 0 - exclusiveMinimum: true - multipleOf: 0.001 - sources: - type: array - minItems: 1 - items: - $ref: '#/components/schemas/Source' - Packing: - allOf: - - $ref: '#/components/schemas/Command' - - type: object - required: - - cartons - properties: - cartons: - type: array - minItems: 1 - items: - $ref: '#/components/schemas/Carton' - ConfirmPacking: - allOf: - - $ref: '#/components/schemas/Command' - - type: object - required: - - packingVersion - properties: - packingVersion: - type: integer - minimum: 1 - Correction: - type: object - required: - - receipt - - voucherFileId - properties: - receipt: - $ref: '#/components/schemas/Receipt' - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - RevokeDisposition: - allOf: - - $ref: '#/components/schemas/Command' - - type: object - required: - - voucherFileId - properties: - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - PhysicalResponse: - type: object - properties: - code: - type: string - message: - type: string - data: - type: object - properties: - orderNo: - type: string - version: - type: integer - format: int64 - minimum: 0 - status: - type: string - packingVersion: - type: integer - nullable: true - packingStatus: - type: string - nullable: true - enum: - - DRAFT - - CONFIRMED - - SUPERSEDED - receipts: - type: array - items: - type: object - properties: - id: - type: string - pattern: ^[1-9][0-9]*$ - receiptNo: - type: string - leg: - type: string - enum: - - OVERSEAS - - DOMESTIC - sourceCartonId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - goodQuantity: - type: integer - format: int64 - minimum: 0 - damagedQuantity: - type: integer - format: int64 - minimum: 0 - extraQuantity: - type: integer - format: int64 - minimum: 0 - receivedAt: - type: string - format: date-time - active: - type: boolean - dispositions: - type: array - description: 平台处置历史,active=0为已撤销记录;不直接对客输出内部依据 - items: - type: object - cartons: - type: array - items: - allOf: - - $ref: '#/components/schemas/Carton' - - type: object - properties: - id: - type: string - pattern: ^[1-9][0-9]*$ - quantity: - type: integer - format: int64 - minimum: 1 - PostalAddress: - type: object - required: - - name - - phone - - address - properties: - name: - type: string - minLength: 1 - maxLength: 128 - phone: - type: string - pattern: ^[+0-9 ()-]{5,64}$ - address: - type: string - minLength: 1 - maxLength: 1000 - Label: - type: object - required: - - cartonId - - fileId - - page - - carrier - - trackingNo - properties: - cartonId: - type: string - pattern: ^[1-9][0-9]*$ - fileId: - type: string - pattern: ^[1-9][0-9]*$ - page: - type: integer - minimum: 1 - carrier: - type: string - minLength: 1 - maxLength: 128 - trackingNo: - type: string - minLength: 1 - maxLength: 128 - Labels: - type: object - required: - - version - - idempotencyKey - - reason - - leg - - packingVersion - - receiver - - labels - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 1000 - leg: - type: string - enum: - - OVERSEAS - - DOMESTIC - packingVersion: - type: integer - minimum: 1 - receiver: - $ref: '#/components/schemas/PostalAddress' - addressVoucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - labels: - type: array - minItems: 1 - items: - $ref: '#/components/schemas/Label' - Estimate: - type: object - required: - - version - - idempotencyKey - - reason - - labelVersion - - amount - - businessDate - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 1000 - labelVersion: - type: integer - minimum: 1 - amount: - type: number - minimum: 0 - multipleOf: 0.01 - businessDate: - type: string - format: date - ApplyLabels: - type: object - required: - - version - - idempotencyKey - - reason - - labelVersion - - cartonIds - - appliedAt - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 1000 - labelVersion: - type: integer - minimum: 1 - cartonIds: - type: array - minItems: 1 - items: - type: string - pattern: ^[1-9][0-9]*$ - uniqueItems: true - appliedAt: - type: string - format: date-time - Ship: - type: object - required: - - version - - idempotencyKey - - reason - - leg - - labelVersion - - cartonIds - - shippedAt - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 1000 - leg: - type: string - enum: - - OVERSEAS - - DOMESTIC - labelVersion: - type: integer - minimum: 1 - cartonIds: - type: array - minItems: 1 - items: - type: string - pattern: ^[1-9][0-9]*$ - uniqueItems: true - shippedAt: - type: string - format: date-time - Repack: - type: object - required: - - version - - idempotencyKey - - reason - - cartonId - - lengthCm - - widthCm - - heightCm - - grossWeightKg - - voucherFileId - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 1000 - cartonId: - type: string - pattern: ^[1-9][0-9]*$ - lengthCm: - type: number - exclusiveMinimum: true - minimum: 0 - multipleOf: 0.01 - widthCm: - type: number - exclusiveMinimum: true - minimum: 0 - multipleOf: 0.01 - heightCm: - type: number - exclusiveMinimum: true - minimum: 0 - multipleOf: 0.01 - grossWeightKg: - type: number - exclusiveMinimum: true - minimum: 0 - multipleOf: 0.001 - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - LabelRow: - allOf: - - $ref: '#/components/schemas/Label' - - type: object - required: - - appliedAt - properties: - appliedAt: - type: string - format: date-time - nullable: true - LabelSet: - type: object - required: - - id - - version - - manifestId - - cargoHash - - sender - - receiver - - labels - properties: - id: - type: string - pattern: ^[1-9][0-9]*$ - version: - type: integer - manifestId: - type: string - pattern: ^[1-9][0-9]*$ - cargoHash: - type: string - sender: - $ref: '#/components/schemas/PostalAddress' - receiver: - $ref: '#/components/schemas/PostalAddress' - labels: - type: array - minItems: 1 - items: - $ref: '#/components/schemas/LabelRow' - TransportCargo: - type: object - required: [cartonId, cartonNo, quantity, lengthCm, widthCm, heightCm, grossWeightKg, packagingRecordId, sources] - properties: - cartonId: - type: string - pattern: '^[1-9][0-9]*$' - cartonNo: - type: string - quantity: - type: integer - format: int64 - minimum: 1 - lengthCm: - type: number - minimum: 0 - exclusiveMinimum: true - multipleOf: 0.01 - widthCm: - type: number - minimum: 0 - exclusiveMinimum: true - multipleOf: 0.01 - heightCm: - type: number - minimum: 0 - exclusiveMinimum: true - multipleOf: 0.01 - grossWeightKg: - type: number - minimum: 0 - exclusiveMinimum: true - multipleOf: 0.001 - packagingRecordId: - type: string - nullable: true - sources: - type: array - items: - $ref: '#/components/schemas/Source' - TransportResponse: - type: object - required: - - code - - message - - data - properties: - code: - type: string - message: - type: string - data: - type: object - required: - - orderNo - - version - - status - - overseasLabels - - domesticLabels - - overseasCargo - - domesticCargo - - shipments - properties: - orderNo: - type: string - minLength: 1 - maxLength: 64 - version: - type: integer - format: int64 - minimum: 0 - status: - type: string - minLength: 1 - maxLength: 128 - overseasCargo: - type: array - items: - $ref: '#/components/schemas/TransportCargo' - domesticCargo: - type: array - description: 国内有效合格实收及最新一对一换包装箱规,不包含已处置的破损或多货 - items: - $ref: '#/components/schemas/TransportCargo' - overseasLabels: - allOf: - - $ref: '#/components/schemas/LabelSet' - nullable: true - domesticLabels: - allOf: - - $ref: '#/components/schemas/LabelSet' - nullable: true - shipments: - type: array - items: - type: object - required: - - id - - shipmentNo - - leg - - shippedAt - properties: - id: - type: string - pattern: '^[1-9][0-9]*$' - shipmentNo: - type: string - minLength: 1 - maxLength: 64 - leg: - type: string - enum: - - OVERSEAS - - DOMESTIC - shippedAt: - type: string - format: date-time - ChargeAmount: - type: object - required: - - determination - - amount - - currency - - businessDate - - reason - properties: - determination: - type: string - enum: - - POSITIVE - - ZERO - - NOT_APPLICABLE - amount: - type: number - minimum: 0 - multipleOf: 0.01 - currency: - type: string - enum: - - CNY - - USD - businessDate: - type: string - format: date - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - reason: - type: string - minLength: 1 - maxLength: 500 - ChargeSelection: - type: object - properties: - feeId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - value: - allOf: - - $ref: '#/components/schemas/ChargeAmount' - nullable: true - description: feeId与value恰有一个。引用本单同用途有效费用用于重新核对,不产生新扣款;新核定用value,未知不能当零元。 - oneOf: - - required: - - feeId - properties: - value: - type: object - nullable: true - enum: - - null - feeId: - type: string - pattern: ^[1-9][0-9]*$ - - required: - - value - properties: - feeId: - type: string - nullable: true - enum: - - null - value: - $ref: '#/components/schemas/ChargeAmount' - OtherCharge: - type: object - required: - - code - - name - - selection - properties: - code: - type: string - pattern: ^[A-Z][A-Z0-9_]{0,31}$ - name: - type: string - minLength: 1 - maxLength: 128 - selection: - $ref: '#/components/schemas/ChargeSelection' - ConfirmCharges: - type: object - required: - - version - - idempotencyKey - - reason - - internationalFreight - - duty - - domesticFreight - - otherDetermination - - otherFees - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 1000 - internationalFreight: - $ref: '#/components/schemas/ChargeSelection' - duty: - $ref: '#/components/schemas/ChargeSelection' - domesticFreight: - $ref: '#/components/schemas/ChargeSelection' - otherDetermination: - type: string - enum: - - NOT_APPLICABLE - - ITEMIZED - otherFees: - type: array - items: - $ref: '#/components/schemas/OtherCharge' - AdditionalFee: - type: object - additionalProperties: false - required: [version, idempotencyKey, name, amount, currency, businessDate, voucherFileId, reason] - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - name: - type: string - minLength: 1 - maxLength: 128 - amount: - type: number - minimum: 0 - exclusiveMinimum: true - multipleOf: 0.01 - description: 正额原币金额,原币及折算 CNY 均须在费用来源容量内(最多14位整数)。 - currency: - type: string - enum: [CNY, USD] - businessDate: - type: string - format: date - description: 已发生费用的实际业务日期,不允许未来日期。 - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - reason: - type: string - minLength: 1 - maxLength: 500 - CorrectFeeTarget: - type: object - required: - - version - - idempotencyKey - - reason - - targetOriginalAmount - - voucherFileId - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 500 - targetOriginalAmount: - type: number - minimum: 0 - multipleOf: 0.01 - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - RefundFee: - type: object - required: - - version - - idempotencyKey - - reason - - amount - - voucherFileId - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 500 - amount: - type: number - minimum: 0 - multipleOf: 0.01 - exclusiveMinimum: true - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - ProcurementFeeVersion: - type: object - required: - - id - - feeNo - - purpose - - amount - - walletStatus - properties: - id: - type: string - pattern: ^[1-9][0-9]*$ - feeNo: - type: string - minLength: 1 - maxLength: 64 - purpose: - type: string - enum: - - SAMPLE_PAYMENT - - INTERNATIONAL_ESTIMATE - - INTERNATIONAL_FREIGHT - - DUTY - - DOMESTIC_FREIGHT - - OTHER - - ADJUSTMENT - - REFUND - confirmationOrigin: - type: string - enum: [BUSINESS, FINANCIAL] - description: 核定入口决定重试权限;客户响应不包含此字段。 - feeVersion: - type: integer - active: + order: + $ref: "#/components/schemas/ProcurementHeader" + budget: + $ref: "#/components/schemas/ProcurementBudget" + purchased: type: boolean - determination: - type: string - feeItemCode: - type: string - minLength: 1 - maxLength: 64 - feeItemName: - type: string - minLength: 1 - maxLength: 128 - amount: - type: number - minimum: 0 - multipleOf: 0.01 - currency: - type: string - enum: - - CNY - originalCurrency: - type: string - enum: - - CNY - - USD - originalAmount: - type: number - minimum: 0 - multipleOf: 0.01 - exchangeRate: - type: number - exchangeRateMonth: - type: string - businessDate: - type: string - format: date - parentFeeId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - walletStatus: - type: string - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - remark: - type: string - nullable: true - ChargesSnapshot: - type: object - required: - - orderNo - - version - - status - - confirmedAt - - fees - properties: - orderNo: - type: string - minLength: 1 - maxLength: 64 - version: - type: integer - format: int64 - minimum: 0 - status: - type: string - minLength: 1 - maxLength: 128 - confirmedAt: - type: string - format: date-time - nullable: true + purchaseConfirmed: + type: boolean + purchaseNeedsAttention: + type: boolean + progress: + type: array + maxItems: 0 + items: + type: object + boxes: + type: array + maxItems: 0 + items: + type: object + shipments: + type: array + maxItems: 0 + items: + type: object fees: type: array items: - type: object - required: - - detail - - sourceLinked - properties: - detail: - $ref: '#/components/schemas/ProcurementFeeVersion' - sourceLinked: - type: boolean - FeeCorrectionResult: - type: object - required: - - correctionNo - - rootFeeNo - - targetOriginalAmount - - originalCurrency - - exchangeRate - - exchangeRateMonth - - targetAmount - - previousNetAmount - - differenceAmount - - feeNos - properties: - correctionNo: - type: string - minLength: 1 - maxLength: 64 - rootFeeNo: - type: string - minLength: 1 - maxLength: 64 - targetOriginalAmount: - type: number - minimum: 0 - multipleOf: 0.01 - originalCurrency: - type: string - exchangeRate: - type: number - exchangeRateMonth: - type: string - targetAmount: - type: number - minimum: 0 - multipleOf: 0.01 - previousNetAmount: - type: number - minimum: 0 - multipleOf: 0.01 - differenceAmount: - type: number - multipleOf: 0.01 - feeNos: + $ref: "#/components/schemas/CustomerFee" + exceptionSummaries: type: array items: type: string - minLength: 1 - maxLength: 64 - ChargesResponse: - type: object - required: - - code - - message - - data - properties: - code: - type: string - message: - type: string - data: - $ref: '#/components/schemas/ChargesSnapshot' - FeeCorrectionResponse: - type: object - required: - - code - - message - - data - properties: - code: - type: string - message: - type: string - data: - $ref: '#/components/schemas/FeeCorrectionResult' - FeeCorrectionsResponse: - type: object - required: - - code - - message - - data - properties: - code: - type: string - message: - type: string - data: - type: array - items: - $ref: '#/components/schemas/FeeCorrectionResult' - FeeVersionResponse: - type: object - required: - - code - - message - - data - properties: - code: - type: string - message: - type: string - data: - $ref: '#/components/schemas/ProcurementFeeVersion' - ControlNote: - type: object - required: - - version - - idempotencyKey - - reason - - targetType - - blocking - - voucherFileId - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 1000 - targetType: - type: string - enum: - - PURCHASE - - WAREHOUSE - - RECEIPT - - SHIPMENT - targetId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - blocking: - type: boolean - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - clientSummary: - type: string - minLength: 1 - maxLength: 500 - nullable: true - ResolveControl: - type: object - required: - - version - - idempotencyKey - - reason - - voucherFileId - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 1000 - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - CorrectWarehouse: - type: object - required: - - version - - idempotencyKey - - reason - - warehouseId - - voucherFileId - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 1000 - warehouseId: - type: string - pattern: ^[1-9][0-9]*$ - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - CloseResolution: - type: object - required: - - kind - - quantity - - decision - - destination - - voucherFileId - properties: - kind: - type: string - enum: - - PURCHASE_REMAINDER - - TRANSIT_REMAINDER - - GOOD - - DAMAGED - - EXTRA - sourceId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - quantity: - type: integer - format: int64 - minimum: 1 - decision: - type: string - enum: - - SUPPLIER_RETAINED - - RETURNED_TO_SUPPLIER - - RETURNED_TO_SENDER - - LOSS_CONFIRMED - - CUSTOMER_AUTHORIZED_RETENTION - - DISPOSED - destination: - type: string - minLength: 1 - maxLength: 500 - voucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - ExceptionClose: - type: object - required: - - version - - idempotencyKey - - reason - - resolutions - - customerVoucherFileId - - financialConclusion - - clientSummary - properties: - version: - type: integer - format: int64 - minimum: 0 - idempotencyKey: - type: string - minLength: 1 - maxLength: 128 - reason: - type: string - minLength: 1 - maxLength: 1000 - resolutions: - type: array - items: - $ref: '#/components/schemas/CloseResolution' - customerVoucherFileId: - type: string - pattern: ^[1-9][0-9]*$ - financialConclusion: - type: string - minLength: 1 - maxLength: 1000 - clientSummary: - type: string - minLength: 1 - maxLength: 500 - ControlRemaining: - type: object - required: - - kind - - sourceId - - businessNo - - quantity - properties: - kind: - type: string - enum: - - PURCHASE_REMAINDER - - TRANSIT_REMAINDER - - GOOD - - DAMAGED - - EXTRA - sourceId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - businessNo: - type: string - minLength: 1 - maxLength: 64 - quantity: - type: integer - format: int64 - minimum: 1 - ControlSnapshot: - type: object - required: - - orderNo - - version - - status - - notes - - remaining - - closures - - closureItems - properties: - orderNo: - type: string - minLength: 1 - maxLength: 64 - version: - type: integer - format: int64 - minimum: 0 - status: - type: string - minLength: 1 - maxLength: 128 - notes: - type: array - items: - type: object - description: 平台内部更正事实,含原始凭证和处理说明;字段为表结构snake_case,不对客透传 - remaining: - type: array - items: - $ref: '#/components/schemas/ControlRemaining' - closures: - type: array - items: - type: object - closureItems: - type: array - items: - type: object - ControlResponse: - type: object - required: - - code - - message - - data - properties: - code: - type: string - message: - type: string - data: - $ref: '#/components/schemas/ControlSnapshot' + additionalProperties: false + description: 客户专属投影;费用来自共用海外费用链,旧进度、装箱、两段运输和异常区块为空。实收和出库批次通过 workflow 读取。 ProcurementHeader: type: object required: - - id - - orderNo - - productName - - purchaseQuantity - - status - - version + - id + - orderNo + - productName + - purchaseQuantity + - status + - version properties: procurementDirection: - $ref: 'overseas-business-v1.yaml#/components/schemas/ProcurementDirection' + $ref: overseas-business-v1.yaml#/components/schemas/ProcurementDirection orderNo: type: string nullable: true @@ -3051,9 +243,9 @@ components: ProcurementBudget: type: object required: - - totalAmount - - currency - - authorizedAt + - totalAmount + - currency + - authorizedAt properties: totalAmount: type: number @@ -3064,107 +256,25 @@ components: type: string format: date-time nullable: true - ProcurementProgress: - type: object - required: - - key - - title - - completedAt - properties: - key: - type: string - minLength: 1 - maxLength: 128 - title: - type: string - minLength: 1 - maxLength: 128 - completedAt: - type: string - format: date-time - nullable: true - CustomerBox: - type: object - required: - - cartonNo - - quantity - - lengthCm - - widthCm - - heightCm - - grossWeightKg - properties: - cartonNo: - type: string - minLength: 1 - maxLength: 64 - quantity: - type: integer - format: int64 - lengthCm: - type: number - widthCm: - type: number - heightCm: - type: number - grossWeightKg: - type: number - CustomerShipment: - type: object - required: - - shipmentNo - - leg - - shippedAt - - cartonNo - - quantity - - carrier - - trackingNo - properties: - shipmentNo: - type: string - minLength: 1 - maxLength: 64 - leg: - type: string - enum: - - OVERSEAS - - DOMESTIC - shippedAt: - type: string - format: date-time - cartonNo: - type: string - minLength: 1 - maxLength: 64 - quantity: - type: integer - format: int64 - carrier: - type: string - minLength: 1 - maxLength: 128 - trackingNo: - type: string - minLength: 1 - maxLength: 128 CustomerFee: type: object required: - - feeNo - - purpose - - name - - determination - - currency - - originalCurrency - - exchangeRateMonth - - parentFeeNo - - walletStatus - - version - - active - - amount - - originalAmount - - exchangeRate - - businessDate - - sourceLinked + - feeNo + - purpose + - name + - determination + - currency + - originalCurrency + - exchangeRateMonth + - parentFeeNo + - walletStatus + - version + - active + - amount + - originalAmount + - exchangeRate + - businessDate + - sourceLinked properties: feeNo: type: string @@ -3214,251 +324,65 @@ components: type: boolean additionalProperties: false description: 客户费用白名单:不含凭证、备注、资金命令/冻结/流水引用和原始诊断 - ProcurementCustomerView: + ProcurementPlatformResponse: type: object required: - - order - - budget - - purchased - - purchaseConfirmed - - purchaseNeedsAttention - - progress - - boxes - - shipments - - fees - - exceptionSummaries + - code + - message + - data properties: - order: - $ref: '#/components/schemas/ProcurementHeader' - budget: - $ref: '#/components/schemas/ProcurementBudget' - purchased: - type: boolean - purchaseConfirmed: - type: boolean - purchaseNeedsAttention: - type: boolean - progress: - type: array - items: - $ref: '#/components/schemas/ProcurementProgress' - boxes: - type: array - items: - $ref: '#/components/schemas/CustomerBox' - shipments: - type: array - items: - $ref: '#/components/schemas/CustomerShipment' - fees: - type: array - items: - $ref: '#/components/schemas/CustomerFee' - exceptionSummaries: - type: array - items: - type: string - additionalProperties: false - description: 客户仅获取此独立投影;boxes只包含已确认海外清单。内部采购异常只提供需要处理标记,异常摘要来自人工明确填写的对客字段。 - ProcurementAction: - type: object - required: - - key - - label - - permission - - allowed - - reason - properties: - key: + code: type: string - minLength: 1 - maxLength: 128 - label: + message: type: string - minLength: 1 - maxLength: 128 - permission: - type: string - minLength: 1 - maxLength: 128 - allowed: - type: boolean - reason: - type: string - nullable: true + data: + $ref: "#/components/schemas/ProcurementPlatformView" ProcurementPlatformView: type: object required: - - customerView - - purchaseFacts - - physical - - transport - - fees - - feeCorrections - - controls - - files - - reviewHistory - - operations - - actions - - internalRemark + - customerView + - purchaseFacts + - physical + - transport + - fees + - feeCorrections + - controls + - files + - reviewHistory + - operations + - actions + - internalRemark properties: customerView: - $ref: '#/components/schemas/ProcurementCustomerView' + $ref: "#/components/schemas/ProcurementCustomerView" purchaseFacts: type: object - description: 平台内部履约事实,字段snake_case + maxProperties: 0 physical: type: object properties: - orderNo: - type: string - version: - type: integer - format: int64 - minimum: 0 - status: - type: string - packingVersion: - type: integer - nullable: true - packingStatus: - type: string - nullable: true - enum: - - DRAFT - - CONFIRMED - - SUPERSEDED - receipts: + dispositions: &a1 type: array + maxItems: 0 items: type: object - properties: - id: - type: string - pattern: ^[1-9][0-9]*$ - receiptNo: - type: string - leg: - type: string - enum: - - OVERSEAS - - DOMESTIC - sourceCartonId: - type: string - pattern: ^[1-9][0-9]*$ - nullable: true - goodQuantity: - type: integer - format: int64 - minimum: 0 - damagedQuantity: - type: integer - format: int64 - minimum: 0 - extraQuantity: - type: integer - format: int64 - minimum: 0 - receivedAt: - type: string - format: date-time - active: - type: boolean - dispositions: - type: array - description: 平台处置历史,active=0为已撤销记录;不直接对客输出内部依据 - items: - type: object - cartons: - type: array - items: - allOf: - - $ref: '#/components/schemas/Carton' - - type: object - properties: - id: - type: string - pattern: ^[1-9][0-9]*$ - quantity: - type: integer - format: int64 - minimum: 1 + receipts: *a1 + cartons: *a1 transport: type: object - required: - - orderNo - - version - - status - - overseasLabels - - domesticLabels - - overseasCargo - - domesticCargo - - shipments properties: - orderNo: - type: string - minLength: 1 - maxLength: 64 - version: - type: integer - format: int64 - minimum: 0 - status: - type: string - minLength: 1 - maxLength: 128 - overseasCargo: - type: array - items: - $ref: '#/components/schemas/TransportCargo' - domesticCargo: - type: array - description: 国内有效合格实收及最新一对一换包装箱规,不包含已处置的破损或多货 - items: - $ref: '#/components/schemas/TransportCargo' - overseasLabels: - allOf: - - $ref: '#/components/schemas/LabelSet' - nullable: true - domesticLabels: - allOf: - - $ref: '#/components/schemas/LabelSet' - nullable: true - shipments: - type: array - items: - type: object - required: - - id - - shipmentNo - - leg - - shippedAt - properties: - id: - type: string - pattern: '^[1-9][0-9]*$' - shipmentNo: - type: string - minLength: 1 - maxLength: 64 - leg: - type: string - enum: - - OVERSEAS - - DOMESTIC - shippedAt: - type: string - format: date-time - fees: - type: array - items: - $ref: '#/components/schemas/ProcurementFeeVersion' - feeCorrections: - type: array - items: - $ref: '#/components/schemas/FeeCorrectionResult' + overseasCargo: *a1 + domesticCargo: *a1 + shipments: *a1 + fees: *a1 + feeCorrections: *a1 controls: - $ref: '#/components/schemas/ControlSnapshot' + type: object + properties: + notes: *a1 + remaining: *a1 + closures: *a1 + closureItems: *a1 files: type: array items: @@ -3467,40 +391,8 @@ components: type: array items: type: object - operations: - type: array - items: - type: object - actions: - type: array - items: - $ref: '#/components/schemas/ProcurementAction' + operations: *a1 + actions: *a1 internalRemark: type: string nullable: true - ProcurementCustomerResponse: - type: object - required: - - code - - message - - data - properties: - code: - type: string - message: - type: string - data: - $ref: '#/components/schemas/ProcurementCustomerView' - ProcurementPlatformResponse: - type: object - required: - - code - - message - - data - properties: - code: - type: string - message: - type: string - data: - $ref: '#/components/schemas/ProcurementPlatformView' diff --git a/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml b/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml index 860f1703d..a5f94417b 100644 --- a/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/procurement-workflow-v1.yaml @@ -63,7 +63,7 @@ paths: /api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds: post: summary: 从采购库存创建出库单 - description: 权限 platform:overseas-business:purchase:ship-customer;仅平台主体,必须指定目标客户 customerId。仅 CONFIRMED、IN_TRANSIT 或 DELIVERING;数量不得超过 availableQuantity,物流跟踪号必填。创建后占用数量增加,不实际扣库存。 + description: 权限 platform:overseas-business:purchase:ship-customer;仅平台主体,必须指定目标客户 customerId。仅 CONFIRMED、IN_TRANSIT 或 DELIVERING;数量不得超过 availableQuantity,物流跟踪号必填。必须存在本采购单未作废应收费用且全部有效并审核通过;缺少费用、待审核或驳回时拒绝出库。创建后占用数量增加,不实际扣库存。 parameters: - $ref: '#/components/parameters/OrderNo' - $ref: '#/components/parameters/CustomerId' diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java index b541da683..8fa737f61 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java @@ -23,8 +23,10 @@ class OverseasProcurementSchemaContractTest { assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL")); assertTrue(sql.contains("review_request_hash CHAR(64) NULL")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_details")); - assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment")); - assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment_item")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_workflow_receipt")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_workflow_outbound")); + org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment")); + org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee")); assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'")); assertTrue(sql.contains("change_detail JSON NULL")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementLegacyRetirementContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementLegacyRetirementContractTest.java new file mode 100644 index 000000000..e4a004cbb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementLegacyRetirementContractTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.procurement; + +import org.junit.jupiter.api.Test; +import java.nio.file.*; +import java.util.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; + +/** 防止旧采购表引用回流,以及删表清单误伤共用财务或新流程数据。 */ +class ProcurementLegacyRetirementContractTest { + private final Set retained=Set.of("ll_overseas_procurement_order","ll_overseas_procurement_status_history","ll_overseas_procurement_file", + "ll_procurement_workflow_receipt","ll_procurement_workflow_outbound","ll_procurement_workflow_command"); + private String script() throws Exception { return Files.readString(Path.of("../deploy/sql/overseas-procurement-legacy-tables-retire.sql")); } + private Set dropped(String sql) { + var match=Pattern.compile("DROP TABLE IF EXISTS\\s+([^;]+);",Pattern.CASE_INSENSITIVE).matcher(sql); + assertThat(match.find()).isTrue(); + var result=new HashSet();for(String table:match.group(1).split(","))result.add(table.trim()); + assertThat(match.find()).isFalse();return result; + } + @Test void onlyTwentyOneRetiredProcurementTablesCanBeDroppedAndNoLiveSourceReferencesThem() throws Exception { + var retired=dropped(script());assertThat(retired).hasSize(21).doesNotContainAnyElementsOf(retained); + assertThat(retired).allMatch(t->t.equals("ll_overseas_procurement_fee") || t.startsWith("ll_procurement_") && !t.startsWith("ll_procurement_workflow_")); + try(var files=Files.walk(Path.of("src/main/java"))) { + for(var file:files.filter(f->f.toString().endsWith(".java")).toList()) { + var source=Files.readString(file); + for(String table:retired)assertThat(Pattern.compile("\\b"+table+"\\b").matcher(source).find()).as(file+" must not reference "+table).isFalse(); + } + } + for(String file:List.of("src/main/resources/db/lastleg.sql","../deploy/sql/海外区域统一增量-MySQL8.sql")) { + var sql=Files.readString(Path.of(file)); + for(String table:retired) assertThat(sql).doesNotContain("CREATE TABLE "+table+" (","CREATE TABLE IF NOT EXISTS "+table+" ("); + for(String table:retained) assertThat(sql).containsPattern("CREATE TABLE (?:IF NOT EXISTS )?"+table+" \\("); + } + } + @Test void destructiveScriptChecksFinancialEvidenceAndContainsNoSharedTableMutation() throws Exception { + var sql=script(); + assertThat(sql.indexOf("SIGNAL SQLSTATE")).isLessThan(sql.indexOf("DROP TABLE IF EXISTS")); + assertThat(sql).contains("procurement-six-tables-20261027","@procurement_retirement_financial_rows<>0","aggregate_type='PROCUREMENT_FEE'","$.payload.businessType","<>6"); + assertThat(sql).doesNotContain("FOREIGN_KEY_CHECKS=0","TRUNCATE TABLE","DELETE FROM","UPDATE ll_","ALTER TABLE"); + for(String shared:List.of("ll_finance_fee_source","ll_finance_fee_funding","ll_event_outbox","ll_wallet_event_inbox","ll_inventory"))assertThat(dropped(sql)).doesNotContain(shared); + } + @Test void retiredOperationControllersAndOpenApiPathsAreAbsent() throws Exception { + for(String name:List.of("Lifecycle","Physical","Transport","Charge","Control")) { + assertThat(Files.exists(Path.of("src/main/java/com/baiyue/lastleg/procurement/controller/Procurement"+name+"Controller.java"))).isFalse(); + } + var spec=Files.readString(Path.of("src/main/resources/openapi/procurement-fulfillment-v1.yaml")); + for(String suffix:List.of("/purchase:","/packing:","/labels:","/charges:","/exception-close:"))assertThat(spec).doesNotContain(suffix); + assertThat(spec).contains("/files/{category}:","/files/{fileId}/content:","ProcurementCustomerResponse:","ProcurementPlatformResponse:"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java index 7dd703a09..8371204aa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java @@ -50,10 +50,7 @@ class ProcurementRequestBoundaryTest { var json=new ObjectMapper().findAndRegisterModules(); try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) { var validator=factory.getValidator(); - for(var type:List.of(com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command.class, - com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Purchase.class, - com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Budget.class, - com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Warehouse.class)) { + for(var type:List.of(OverseasProcurementRequests.Review.class, OverseasProcurementRequests.Update.class)) { assertThat(validator.validate(json.readValue("{}",type))).anyMatch(v->"version".equals(v.getPropertyPath().toString())); } } @@ -67,10 +64,10 @@ class ProcurementRequestBoundaryTest { } @Test void customerProjectionExcludesInternalFilesAndFinancialDiagnostics() throws Exception { - var fee=new com.baiyue.lastleg.procurement.entity.OverseasProcurementFee(); - fee.setFeeNo("PF-1");fee.setFeeItemName("关税");fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED"); - fee.setVoucherFileId(2L);fee.setVoucherFileName("private.pdf");fee.setWalletErrorMessage("database error");fee.setRemark("internal note");fee.setCreatedBy("operator");fee.setWalletLedgerId("private-ledger"); - var output=new ObjectMapper().writeValueAsString(com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerFee.from(fee,null,false)); - assertThat(output).contains("PF-1","FREEZE_FAILED").doesNotContain("private.pdf","voucherFileId","internal note","database error","operator","private-ledger"); + var fee=new com.baiyue.lastleg.financefee.entity.OverseasFeeSource(); + fee.setSourceFeeId(1L);fee.setFeeItemName("关税");fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED"); + fee.setPaymentVoucherFileIds(List.of("private.pdf"));fee.setWalletErrorMessage("database error");fee.setReviewReason("internal note");fee.setCreatedBy("operator"); + var output=new ObjectMapper().writeValueAsString(com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerFee.fromManual(fee)); + assertThat(output).contains("FREEZE_FAILED").doesNotContain("private.pdf","voucherFileId","internal note","database error","operator","private-ledger"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java deleted file mode 100644 index ef06232a8..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.baiyue.lastleg.procurement.controller; - -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*; -import com.baiyue.lastleg.procurement.service.ProcurementLifecycleService; -import com.baiyue.lastleg.procurement.service.ProcurementPhysicalService; -import com.baiyue.lastleg.procurement.service.OverseasProcurementService; -import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.springframework.context.annotation.*; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.server.ResponseStatusException; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Set; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; - -class ProcurementLifecycleAuthorizationTest { - private final AnnotationConfigApplicationContext context=new AnnotationConfigApplicationContext(Config.class); - private final ProcurementLifecycleController controller=context.getBean(ProcurementLifecycleController.class); - private final ProcurementLifecycleService service=context.getBean(ProcurementLifecycleService.class); - @Configuration @EnableMethodSecurity(proxyTargetClass=true) - static class Config { - /** 为控制器权限测试中的服务替身补齐编号依赖。 */ - @Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() { - return com.baiyue.lastleg.service.TestDocumentNumbers.documents(); - } - @Bean ProcurementLifecycleService service(){return mock(ProcurementLifecycleService.class);} - @Bean ProcurementLifecycleController controller(ProcurementLifecycleService service){return new ProcurementLifecycleController(service);} - @Bean ProcurementPhysicalService physicalService(){return mock(ProcurementPhysicalService.class);} - @Bean ProcurementPhysicalController physicalController(ProcurementPhysicalService service){return new ProcurementPhysicalController(service);} - // Register the mock directly: its legacy @Resource fields must not be autowired in this controller-only test. - @Bean static org.springframework.beans.factory.config.BeanFactoryPostProcessor orderMock(){ - return factory -> factory.registerSingleton("orders",mock(OverseasProcurementService.class)); - } - @Bean ProcurementFileController files(OverseasProcurementService service){return new ProcurementFileController(service);} - @Bean com.baiyue.lastleg.procurement.service.ProcurementTransportService transportService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementTransportService.class);} - @Bean ProcurementTransportController transport(com.baiyue.lastleg.procurement.service.ProcurementTransportService service){return new ProcurementTransportController(service);} - @Bean com.baiyue.lastleg.procurement.service.ProcurementChargeService chargeService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementChargeService.class);} - @Bean com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService adjustmentService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService.class);} - @Bean ProcurementChargeController charges(com.baiyue.lastleg.procurement.service.ProcurementChargeService service,com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService adjustments){return new ProcurementChargeController(service,adjustments);} - @Bean com.baiyue.lastleg.procurement.service.ProcurementControlService controlService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementControlService.class);} - @Bean ProcurementControlController controls(com.baiyue.lastleg.procurement.service.ProcurementControlService service){return new ProcurementControlController(service);} - } - @AfterEach void cleanup(){SecurityContextHolder.clearContext();context.close();} - @Test void listPermissionDoesNotGrantBuyingOrCancelling() { - var user=login(0,"PLATFORM","list");controller.detail(user,"9","PO-1"); - verify(service).read("9","PO-1"); - assertThatThrownBy(()->controller.purchase(user,"9","PO-1",purchase())).isInstanceOf(AccessDeniedException.class); - assertThatThrownBy(()->controller.cancel(user,"9","PO-1",new Command(0L,"key","reason"))).isInstanceOf(AccessDeniedException.class); - verifyNoMoreInteractions(service); - } - @Test void buyerCannotAuthorizeItsOwnBudgetWithoutReviewPermission() { - var user=login(0,"PLATFORM","buy");var purchase=purchase();controller.purchase(user,"9","PO-1",purchase); - verify(service).recordPurchase("9","PO-1","7",purchase); - assertThatThrownBy(()->controller.budget(user,"9","PO-1",new Budget(0L,"budget",BigDecimal.TEN,"CNY",1,"reason"))) - .isInstanceOf(AccessDeniedException.class); - verifyNoMoreInteractions(service); - } - @Test void customerIdentityCannotUsePlatformActionEvenWithInjectedAuthority() { - var user=login(9,"MERCHANT","buy"); - assertThatThrownBy(()->controller.purchase(user,"9","PO-1",purchase())).isInstanceOf(ResponseStatusException.class); - verifyNoInteractions(service); - } - @Test void overseasReceiverCannotPerformDomesticReceivingOrApproveDiscrepancy() { - var physical=context.getBean(ProcurementPhysicalController.class);var implementation=context.getBean(ProcurementPhysicalService.class); - var user=login(0,"PLATFORM","receive-overseas"); - var receipt=new ProcurementPhysicalRequests.Receipt(0L,"r",ProcurementPhysicalRequests.Leg.OVERSEAS,91L,null,1,0,0,LocalDateTime.now().minusHours(1),"actual"); - physical.overseas(user,"9","PO-1",receipt);verify(implementation).receive("9","PO-1","7",receipt); - assertThatThrownBy(()->physical.domestic(user,"9","PO-1",receipt)).isInstanceOf(AccessDeniedException.class); - var decision=new ProcurementPhysicalRequests.Disposition(0L,"d",ProcurementPhysicalRequests.Leg.OVERSEAS,null,null,"SHORTAGE",1,"ACCEPT_SHORTAGE",1,"confirmed"); - assertThatThrownBy(()->physical.dispose(user,"9","PO-1",decision)).isInstanceOf(AccessDeniedException.class); - verifyNoMoreInteractions(implementation); - } - @Test void labelUploadDoesNotGrantFinancialVoucherOrBudgetUpload() { - var files=context.getBean(ProcurementFileController.class);var orders=context.getBean(OverseasProcurementService.class); - var user=login(0,"PLATFORM","label");var file=new org.springframework.mock.web.MockMultipartFile("file","label.pdf","application/pdf","%PDF-1.4".getBytes()); - files.upload(user,"9","PO-1","LABEL",file);verify(orders).uploadFile("9","PO-1","7","LABEL",file); - assertThatThrownBy(()->files.upload(user,"9","PO-1","VOUCHER",file)).isInstanceOf(ResponseStatusException.class); - assertThatThrownBy(()->files.upload(user,"9","PO-1","BUDGET",file)).isInstanceOf(ResponseStatusException.class); - verifyNoMoreInteractions(orders); - } - @Test void overseasShippingDoesNotGrantDomesticHandoffOrEstimatePermission() { - var transport=context.getBean(ProcurementTransportController.class); - var service=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementTransportService.class); - var user=login(0,"PLATFORM","ship-overseas"); - var request=new com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Ship(0L,"ship",ProcurementPhysicalRequests.Leg.OVERSEAS,1,java.util.List.of(1L),LocalDateTime.now(),"actual"); - transport.overseas(user,"9","PO-1",request);verify(service).ship("9","PO-1","7",request); - assertThatThrownBy(()->transport.customer(user,"9","PO-1",request)).isInstanceOf(AccessDeniedException.class); - assertThatThrownBy(()->transport.estimate(user,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class); - verifyNoMoreInteractions(service); - } - @Test void costConfirmationDoesNotGrantFinancialCorrectionAndRetryMatchesFeePurpose() { - var charges=context.getBean(ProcurementChargeController.class); - var service=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementChargeService.class); - var user=login(0,"PLATFORM","charge"); - assertThatThrownBy(()->charges.additional(user,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class); - assertThatThrownBy(()->charges.correct(user,"9","PO-1",1,null)).isInstanceOf(AccessDeniedException.class); - assertThatThrownBy(()->charges.refund(user,"9","PO-1",1,null)).isInstanceOf(AccessDeniedException.class); - assertThatThrownBy(()->charges.replaceFailed(user,"9","PO-1",1,null)).isInstanceOf(AccessDeniedException.class); - when(service.retryPermission("9","PO-1",1)).thenReturn("platform:overseas-business:purchase:finance-adjust"); - var command=new Command(0L,"retry","retry confirmed failure"); - assertThatThrownBy(()->charges.retry(user,"9","PO-1",1,command)).isInstanceOf(ResponseStatusException.class); - verify(service,never()).retry(anyString(),anyString(),anyString(),anyLong(),any()); - when(service.retryPermission("9","PO-1",2)).thenReturn("platform:overseas-business:purchase:charge"); - charges.retry(user,"9","PO-1",2,command);verify(service).retry("9","PO-1","7",2,command); - } - @Test void financeMayReplaceFailedFeeButCustomerIdentityStillCannotUseThePlatformBoundary() { - var charges=context.getBean(ProcurementChargeController.class);var adjustments=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService.class); - var request=new com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Correct(0L,"replacement",BigDecimal.TEN,1,"replacement proof"); - var user=login(0,"PLATFORM","finance-adjust");charges.replaceFailed(user,"9","PO-1",1,request);verify(adjustments).replaceFailed("9","PO-1","7",1,request); - var customer=login(9,"MERCHANT","finance-adjust");assertThatThrownBy(()->charges.replaceFailed(customer,"9","PO-1",1,request)).isInstanceOf(ResponseStatusException.class);verifyNoMoreInteractions(adjustments); - } - @Test void financialAdditionalUsesPlatformOnlyFinancialBoundary() { - var charges=context.getBean(ProcurementChargeController.class); - var adjustments=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService.class); - var request=new com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Additional(0L,"additional","Storage",BigDecimal.TEN,"CNY",java.time.LocalDate.now(),1,"invoice"); - var user=login(0,"PLATFORM","finance-adjust");charges.additional(user,"9","PO-1",request); - verify(adjustments).additional("9","PO-1","7",request); - var customer=login(9,"MERCHANT","finance-adjust"); - assertThatThrownBy(()->charges.additional(customer,"9","PO-1",request)).isInstanceOf(ResponseStatusException.class); - verifyNoMoreInteractions(adjustments); - } - @Test void receivingPermissionCannotCloseOrderAndManagerCannotReleaseFunds() { - var controls=context.getBean(ProcurementControlController.class); - var receiver=login(0,"PLATFORM","receive-overseas"); - assertThatThrownBy(()->controls.close(receiver,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class); - assertThatThrownBy(()->controls.warehouse(receiver,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class); - var manager=login(0,"PLATFORM","manage");var charges=context.getBean(ProcurementChargeController.class); - assertThatThrownBy(()->charges.releaseEstimate(manager,"9","PO-1",1,new Command(0L,"release","close order"))).isInstanceOf(AccessDeniedException.class); - } - private AuthenticatedUser login(long customer,String app,String suffix) { - String permission="platform:overseas-business:purchase:"+suffix; - var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission)); - SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",java.util.List.of(new SimpleGrantedAuthority(permission)))); - return user; - } - private Purchase purchase(){return new Purchase(0L,"purchase","Sample",null,1,BigDecimal.TEN,"CNY",LocalDateTime.now().minusDays(1),LocalDateTime.now().plusDays(1),1,"actual purchase");} -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index 6d51e0f4d..2da56d2cf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -7,7 +7,6 @@ import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper; -import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper; import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway; import com.baiyue.lastleg.wallet.WalletCommandOutboxService; import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; @@ -43,10 +42,8 @@ class OverseasProcurementServiceTest { @Mock OverseasProcurementOrderMapper orders; @Mock OverseasProcurementStatusHistoryMapper histories; @Mock ProcurementWarehouseDirectory warehouseDirectory; - @Mock com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository directionRepository; @Mock com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator; @Mock OverseasProcurementFileMapper procurementFiles; - @Mock OverseasProcurementFeeMapper procurementFees; @Mock ProcurementFileGateway fileGateway; @Mock WalletCommandOutboxService walletCommands; @Mock OverseasFeeSourceService feeSources; @@ -58,15 +55,12 @@ class OverseasProcurementServiceTest { ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory); - ReflectionTestUtils.setField(service, "directionRepository", directionRepository); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles); - ReflectionTestUtils.setField(service, "procurementFees", procurementFees); ReflectionTestUtils.setField(service, "fileGateway", fileGateway); lenient().when(orderNoGenerator.next(anyString())).thenReturn("ACME202609050001"); lenient().when(procurementFiles.selectList(any())).thenReturn(List.of()); - lenient().when(procurementFees.selectList(any())).thenReturn(List.of()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java deleted file mode 100644 index 14927f83a..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java +++ /dev/null @@ -1,260 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.baiyue.common.event.WalletCommandCompletedV1; -import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; -import com.baiyue.lastleg.financefee.service.*; -import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.*; -import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command; -import com.baiyue.lastleg.procurement.entity.*; -import com.baiyue.lastleg.procurement.mapper.*; -import com.baiyue.lastleg.wallet.WalletCommandOutboxService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mybatis.spring.SqlSessionTemplate; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.DataSourceTransactionManager; -import org.springframework.jdbc.datasource.DriverManagerDataSource; -import org.springframework.transaction.support.TransactionTemplate; -import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.*; -import java.util.*; -import java.util.concurrent.atomic.AtomicLong; -import java.util.regex.Pattern; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -class ProcurementChargePersistenceTest { - JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;OverseasProcurementFeeMapper fees; - ProcurementFinanceService finance;ProcurementLifecycleService lifecycle;ProcurementChargeService charges;ProcurementAdjustmentService adjustments; - OverseasFeeCurrencyConverter converter;WalletCommandOutboxService commands;long estimateId; - @BeforeEach void setup()throws Exception { - var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa",""); - jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); - String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); - for(String name:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_control_note","ll_procurement_flow","ll_procurement_command", - "ll_overseas_procurement_fee","ll_procurement_wallet_attempt","ll_procurement_fee_correction","ll_procurement_fee_correction_item","ll_procurement_budget_authorization")) { - var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema); - assertThat(m.find()).as(name).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); - } - jdbc.execute("CREATE TABLE ll_event_outbox(aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),status VARCHAR(32))"); - var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);config.addMapper(OverseasProcurementFeeMapper.class); - var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject()); - orders=session.getMapper(OverseasProcurementOrderMapper.class);fees=session.getMapper(OverseasProcurementFeeMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class); - var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample"); - order.setProductName("Sample");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY");order.setContactName("buyer");order.setContactPhone("13800000000"); - order.setStatus("WAIT_CHARGES");order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(10L);orders.insert(order); - jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,domestic_received_at) VALUES(11,'9',?)",LocalDateTime.now().minusHours(1)); - jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','VOUCHER',1001,'evidence.pdf','application/pdf',10,'7')"); - converter=mock(OverseasFeeCurrencyConverter.class);when(converter.convert(anyString(),any(),any())).thenAnswer(i->{String currency=i.getArgument(0);BigDecimal amount=i.getArgument(1);BigDecimal rate="USD".equals(currency)?new BigDecimal("7.123456"):BigDecimal.ONE;return new OverseasFeeCurrencyConverter.Conversion(currency,amount,rate,"2026-09",amount.multiply(rate).setScale(2,java.math.RoundingMode.HALF_UP));}); - var sources=mock(OverseasFeeSourceService.class);AtomicLong ids=new AtomicLong(900); - when(sources.publishSettled(anyString(),anyString(),any(),any(),any())).thenAnswer(i->{var s=new OverseasFeeSource();s.setSourceFeeId(ids.incrementAndGet());return s;}); - commands=mock(WalletCommandOutboxService.class);AtomicLong events=new AtomicLong(); - when(commands.publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet()); - when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet()); - var json=new ObjectMapper().findAndRegisterModules();finance=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,json); - org.springframework.test.util.ReflectionTestUtils.setField(finance, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); - org.springframework.test.util.ReflectionTestUtils.setField(finance, "feeNumbers", new com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository(fees)); - lifecycle=new ProcurementLifecycleService(orders,files,mock(ProcurementWarehouseDirectory.class),finance,converter,jdbc,json); - charges=new ProcurementChargeService(lifecycle,finance,jdbc);adjustments=new ProcurementAdjustmentService(lifecycle,finance,jdbc); - org.springframework.test.util.ReflectionTestUtils.setField(adjustments, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); - tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","货款","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"free"))); - estimateId=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","预估","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"zero estimate"))).getId(); - } - @Test void allCategoriesAreExplicitAndReconfirmationReferencesExistingMoney() { - var request=request("100","5");var first=tx.execute(s->charges.confirm("9","PO-1","7",request)); - assertThat(first.confirmedAt()).isNotNull();assertThat(first.status()).isEqualTo("WAIT_CUSTOMER_SHIPMENT"); - assertThat(finance.all(11)).hasSize(5);assertThat(finance.all(11)).noneMatch(f->"OTHER".equals(f.getPurpose())); - assertThatThrownBy(()->finance.requireShipmentReady(11)).hasMessageContaining("未决资金"); - var international=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);var domestic=current(ProcurementFeePurpose.DOMESTIC_FREIGHT);settle(international);settle(domestic); - finance.requireShipmentReady(11);int count=countAttempts(); - var again=new Confirm(version(),"reconfirm",new Selection(international.getId(),null),new Selection(current(ProcurementFeePurpose.DUTY).getId(),null),new Selection(domestic.getId(),null),"NOT_APPLICABLE",List.of(),"same expenses after repack"); - tx.execute(s->charges.confirm("9","PO-1","7",again));assertThat(countAttempts()).isEqualTo(count); - tx.execute(s->charges.confirm("9","PO-1","7",request));assertThat(countAttempts()).isEqualTo(count); - } - @Test void missingDeterminationWrongPurposeAndUnreceivedOrdersCannotConfirm() { - var bad=new Confirm(0L,"missing",null,selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"missing international"); - assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",bad))).hasMessageContaining("每项费用"); - var wrong=new Confirm(0L,"wrong",new Selection(estimateId,null),selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"estimate is not actual"); - assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",wrong))).hasMessageContaining("该用途"); - jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=NULL WHERE order_id=11"); - assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")))).hasMessageContaining("国内收货完成"); - assertThat(countAttempts()).isZero(); - } - @Test void omittedExistingOtherChargeCannotDisappearFromReconfirmation() { - var other=new Other("HANDLING","Handling",selection("10")); - tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(0L,"first",selection("0"),selection("0"),selection("0"),"ITEMIZED",List.of(other),"actual"))); - assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(version(),"omit-other",new Selection(current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT).getId(),null),new Selection(current(ProcurementFeePurpose.DUTY).getId(),null),new Selection(current(ProcurementFeePurpose.DOMESTIC_FREIGHT).getId(),null),"NOT_APPLICABLE",List.of(),"omit")))).hasMessageContaining("未列入"); - assertThat(finance.all(11)).hasSize(6); - } - @Test void additionalExpenseUsesOwnBusinessDateFinancialPermissionAndImmutableReplay() { - jdbc.update("UPDATE ll_procurement_flow SET purchased_at=? WHERE order_id=11",LocalDateTime.now().minusDays(2)); - tx.execute(s->charges.confirm("9","PO-1","7",request("0","0"))); - var request=new Additional(version(),"additional","Storage",new BigDecimal("3.25"),"USD",LocalDate.of(2026,9,2),1001,"new expense"); - var fee=tx.execute(s->adjustments.additional("9","PO-1","7",request)); - assertThat(fee.getConfirmationOrigin()).isEqualTo("FINANCIAL"); - assertThat(fee.getBusinessDate()).isEqualTo(LocalDate.of(2026,9,2)); - assertThat(fee.getAmount()).isEqualByComparingTo("23.15"); - assertThat(charges.retryPermission("9","PO-1",fee.getId())).endsWith(":finance-adjust"); - assertThat(lifecycle.flow(11).get("charges_confirmed_at")).isNull(); - assertThat(orders.selectById(11).getStatus()).isEqualTo("WAIT_CHARGES"); - int attempts=countAttempts(); - assertThat(tx.execute(s->adjustments.additional("9","PO-1","7",request)).getId()).isEqualTo(fee.getId()); - assertThat(countAttempts()).isEqualTo(attempts); - assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",request("0","0")))).hasMessageContaining("首次核费后"); - settle(fee);finance.requireShipmentReady(11); - } - @Test void additionalExpenseRequiresPurchaseAndCannotEmitOutOfRangeMoney() { - var request=new Additional(version(),"additional","Storage",new BigDecimal("3.25"),"USD",LocalDate.of(2026,9,2),1001,"new expense"); - assertThatThrownBy(()->tx.execute(s->adjustments.additional("9","PO-1","7",request))).hasMessageContaining("已发生购买"); - jdbc.update("UPDATE ll_procurement_flow SET purchased_at=? WHERE order_id=11",LocalDateTime.now().minusDays(2)); - var excessive=new Additional(version(),"large","Storage",new BigDecimal("99999999999999.99"),"USD",LocalDate.of(2026,9,2),1001,"large"); - assertThatThrownBy(()->tx.execute(s->adjustments.additional("9","PO-1","7",excessive))).hasMessageContaining("超出支持范围"); - assertThat(countAttempts()).isZero();assertThat(finance.all(11)).hasSize(2); - } - @Test void target100To120To130To110ChargesOnlyDifferencesAndKeepsOriginal() { - tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root); - var first=correct(root,"120","first");assertThat(first.differenceAmount()).isEqualByComparingTo("20");settle(byNo(first.feeNos().get(0))); - var second=correct(root,"130","second");assertThat(second.differenceAmount()).isEqualByComparingTo("10");settle(byNo(second.feeNos().get(0))); - var third=correct(root,"110","third");assertThat(third.differenceAmount()).isEqualByComparingTo("-20"); - var refund=byNo(third.feeNos().get(0));assertThat(refund.getPurpose()).isEqualTo("REFUND");assertThat(refund.getAmount()).isEqualByComparingTo("20"); - assertThatThrownBy(()->correct(root,"115","pending")).hasMessageContaining("未完成");complete(refund,"REFUND"); - assertThat(finance.readyChain(orders.findNo("9","PO-1"),fees.selectById(root.getId())).stream().filter(f->"SETTLED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("130"); - assertThat(fees.selectById(root.getId()).getAmount()).isEqualByComparingTo("100"); - var zero=correct(root,"0","all-refund");assertThat(zero.previousNetAmount()).isEqualByComparingTo("110");assertThat(zero.feeNos()).hasSize(3); - assertThat(zero.feeNos().stream().map(this::byNo).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("110"); - } - @Test void failedOldTargetMustBeTerminatedBeforeAnotherTargetAndCannotRetryLater() { - tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root); - var adjustment=byNo(correct(root,"120","first").feeNos().get(0)); - tx.executeWithoutResult(s->finance.handle(new WalletCommandCompletedV1(adjustment.getWalletCommandEventId(),"FREEZE",9,"PROCUREMENT_FEE",adjustment.getFeeNo(),"FAILED",null,adjustment.getAmount(),"INSUFFICIENT","insufficient",Instant.now(),null,null))); - assertThatThrownBy(()->correct(root,"130","blocked")).hasMessageContaining("未完成"); - tx.execute(s->charges.terminate("9","PO-1","7",adjustment.getId(),new Command(version(),"terminate","replace failed target"))); - assertThatThrownBy(()->tx.execute(s->finance.retry("9","PO-1",adjustment.getId()))).hasMessageContaining("有效费用"); - assertThat(correct(root,"130","new").differenceAmount()).isEqualByComparingTo("30"); - } - @Test void originalUsdSnapshotIsUsedForTargetWithoutReloadingMonthlyRate() { - var usd=new Selection(null,new Amount("POSITIVE",new BigDecimal("10"),"USD",LocalDate.of(2026,9,1),1001L,"original USD")); - tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(0L,"usd",usd,selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"original"))); - var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);clearInvocations(converter); - var corrected=correct(root,"11","usd-correction");assertThat(corrected.exchangeRate()).isEqualByComparingTo("7.123456");assertThat(corrected.exchangeRateMonth()).isEqualTo("2026-09"); - assertThat(corrected.targetAmount()).isEqualByComparingTo("78.36");assertThat(corrected.differenceAmount()).isEqualByComparingTo("7.13"); - var delta=byNo(corrected.feeNos().get(0));assertThat(delta.getOriginalCurrency()).isEqualTo("CNY");assertThat(delta.getAmount()).isEqualByComparingTo("7.13");verifyNoInteractions(converter); - } - @Test void correctionReplayReturnsOriginalTargetAndDoesNotRegressCompletedFulfillment() { - tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root); - jdbc.update("UPDATE ll_overseas_procurement_order SET status='SHIPPED_TO_CUSTOMER' WHERE id=11"); - var request=new Correct(version(),"same",new BigDecimal("120"),1001,"correction"); - var original=tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),request));int attempts=countAttempts(); - var replay=tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),request));assertThat(replay).isEqualTo(original); - assertThat(countAttempts()).isEqualTo(attempts);assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("SHIPPED_TO_CUSTOMER"); - assertThatThrownBy(()->tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),new Correct(version(),"same",new BigDecimal("130"),1001,"correction")))).hasMessageContaining("相同操作幂等键"); - } - @Test void terminatedUnpaidActualCanBeReplacedWithFrozenFxAndNewWalletIdentity() { - var usd=new Selection(null,new Amount("POSITIVE",new BigDecimal("10"),"USD",LocalDate.of(2026,9,1),1001L,"USD original")); - tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(version(),"original-usd",usd,selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"all"))); - var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);var failure=failed(original,"FREEZE");tx.executeWithoutResult(s->finance.handle(failure)); - assertThatThrownBy(()->replace(original,"11","too-soon")).hasMessageContaining("先终止"); - tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate-unpaid","correct actual"))); - assertThatThrownBy(()->tx.execute(s->finance.confirmInternational("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"INTERNATIONAL_FREIGHT","actual","POSITIVE",new BigDecimal("11"),"USD",LocalDate.of(2026,9,1),1001L,"ordinary cannot replace")))).hasMessageContaining("财务权限"); - assertThatThrownBy(()->replace(original,"9999999999999999.00","too-large")).hasMessageContaining("人民币金额超出支持范围"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isZero(); - clearInvocations(converter);var request=new Correct(version(),"replace-unpaid",new BigDecimal("11"),1001,"correct actual amount"); - var result=tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),request));var replacement=byNo(result.feeNos().get(0)); - assertThat(result.previousNetAmount()).isZero();assertThat(result.targetAmount()).isEqualByComparingTo("78.36"); - assertThat(replacement.getFeeVersion()).isEqualTo(original.getFeeVersion()+1);assertThat(replacement.getExchangeRate()).isEqualByComparingTo(original.getExchangeRate()); - assertThat(replacement.getExchangeRateMonth()).isEqualTo("2026-09");assertThat(replacement.getWalletBusinessNo()).isNotEqualTo(original.getWalletBusinessNo()); - assertThat(replacement.getWalletStatus()).isEqualTo("FREEZE_PENDING");assertThat(replacement.getPurpose()).isEqualTo("INTERNATIONAL_FREIGHT"); - assertThat(charges.retryPermission("9","PO-1",replacement.getId())).isEqualTo("platform:overseas-business:purchase:finance-adjust"); - assertThat(fees.selectById(original.getId()).getWalletStatus()).isEqualTo("TERMINATED");assertThat(fees.selectById(original.getId()).getAmount()).isEqualByComparingTo("71.23"); - int count=countAttempts();var replay=tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),request));assertThat(replay).isEqualTo(result);assertThat(countAttempts()).isEqualTo(count); - tx.executeWithoutResult(s->finance.handle(failure));assertThat(fees.selectById(replacement.getId()).getWalletStatus()).isEqualTo("FREEZE_PENDING"); - assertThatThrownBy(()->replace(original,"12","duplicate-old")).hasMessageContaining("后续费用版本"); - assertThatThrownBy(()->tx.execute(s->finance.retry("9","PO-1",original.getId()))).hasMessageContaining("有效费用");verifyNoInteractions(converter); - } - @Test void replacementWaitsForReleaseOfTheOriginalInternationalReservation() { - tx.executeWithoutResult(s->finance.releaseEstimate("9","PO-1",estimateId)); - var estimate=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","estimate","POSITIVE",new BigDecimal("100"),"CNY",LocalDate.of(2026,9,1),null,"estimate"))); - complete(estimate,"FREEZE");tx.execute(s->charges.confirm("9","PO-1","7",request("180","0")));var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT); - tx.executeWithoutResult(s->finance.handle(failed(original,"SETTLE_ACTUAL"))); - tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate-held","end failed settlement"))); - assertThat(fees.selectById(original.getId()).getWalletStatus()).isEqualTo("RELEASE_PENDING"); - assertThatThrownBy(()->replace(original,"120","before-release")).hasMessageContaining("释放"); - var releasing=fees.selectById(original.getId());tx.executeWithoutResult(s->finance.handle(new WalletCommandCompletedV1(releasing.getWalletCommandEventId(),"RELEASE",9,"PROCUREMENT_FEE",releasing.getWalletBusinessNo(),"SUCCEEDED","RELEASED",new BigDecimal("100"),null,null,Instant.now(),"original-hold","release-ledger"))); - var replacement=byNo(replace(original,"120","after-release").feeNos().get(0)); - assertThat(replacement.getWalletStatus()).isEqualTo("FREEZE_PENDING");assertThat(replacement.getWalletBusinessNo()).isEqualTo(replacement.getFeeNo()); - assertThat(replacement.getParentFeeId()).isEqualTo(estimate.getId());assertThat(replacement.getAmount()).isEqualByComparingTo("120"); - assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull(); - assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("WAIT_CHARGES"); - } - @Test void zeroReplacementDoesNotCreateMoneyAndReplacementRollbackLeavesNoVersionOrAudit() { - tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT); - tx.executeWithoutResult(s->finance.handle(failed(original,"FREEZE")));tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate","end failed"))); - doThrow(new IllegalStateException("outbox unavailable")).when(commands).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); - assertThatThrownBy(()->replace(original,"90","rollback")).hasMessageContaining("outbox unavailable"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isZero();assertThat(finance.current(11,ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"INTERNATIONAL_FREIGHT")).isNull(); - int attempts=countAttempts();var result=replace(original,"0","waive");var replacement=byNo(result.feeNos().get(0)); - assertThat(replacement.getWalletStatus()).isEqualTo("ZERO");assertThat(countAttempts()).isEqualTo(attempts);assertThat(finance.sourceLinked(replacement)).isTrue(); - } - @Test void failedSampleReplacementStillRespectsCustomerBudgetAndPreservesPurchaseFacts() { - jdbc.update("DELETE FROM ll_overseas_procurement_fee WHERE purpose='SAMPLE_PAYMENT'"); - jdbc.update("UPDATE ll_procurement_flow SET purchased_at=?,purchase_confirmed_at=?,purchase_amount=10,purchase_currency='USD' WHERE order_id=11",LocalDateTime.of(2026,9,1,10,0),LocalDateTime.of(2026,9,1,10,0)); - var original=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","sample","POSITIVE",new BigDecimal("10"),"USD",LocalDate.of(2026,9,1),1001L,"actual sample"))); - tx.executeWithoutResult(s->finance.handle(failed(original,"FREEZE")));tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate-sample","correct price"))); - assertThatThrownBy(()->replace(original,"20","over-budget")).hasMessageContaining("超过客户已授权预算"); - tx.execute(s->lifecycle.authorizeBudget("9","PO-1","7",new com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Budget(version(),"new-budget",new BigDecimal("200"),"CNY",1001,"customer authorization"))); - var replacement=byNo(replace(original,"20","within-budget").feeNos().get(0));assertThat(replacement.getPurpose()).isEqualTo("SAMPLE_PAYMENT");assertThat(replacement.getAmount()).isEqualByComparingTo("142.47"); - assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("10"); - assertThat(jdbc.queryForObject("SELECT purchase_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull(); - } - @Test void concurrentReplacementsCannotBothChargeForTheSameTerminatedVersion()throws Exception { - tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT); - tx.executeWithoutResult(s->finance.handle(failed(original,"FREEZE")));tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"stop","end original"))); - var executor=java.util.concurrent.Executors.newFixedThreadPool(2);var gate=new java.util.concurrent.CountDownLatch(1);long version=version(); - try { - var futures=new ArrayList>(); - for(String key:List.of("replace-a","replace-b"))futures.add(executor.submit(()->{gate.await();try{tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),new Correct(version,key,new BigDecimal("90"),1001,"verified replacement")));return true;}catch(org.springframework.web.server.ResponseStatusException error){assertThat(error.getStatusCode().value()).isEqualTo(409);return false;}})); - gate.countDown();int succeeded=0;for(var future:futures)if(future.get(10,java.util.concurrent.TimeUnit.SECONDS))succeeded++; - assertThat(succeeded).isEqualTo(1);assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isEqualTo(1); - assertThat(finance.all(11).stream().filter(f->"INTERNATIONAL_FREIGHT".equals(f.getPurpose()) && Boolean.TRUE.equals(f.getActive())).count()).isEqualTo(1); - }finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();} - } - @Test void financialTerminationAfterHandoffPreservesOriginalChargeConfirmationAndFulfillment() { - tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root); - var originalConfirmed=jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class); - jdbc.update("UPDATE ll_overseas_procurement_order SET status='SHIPPED_TO_CUSTOMER' WHERE id=11"); - var adjustment=byNo(correct(root,"120","after-handoff").feeNos().get(0));tx.executeWithoutResult(s->finance.handle(failed(adjustment,"FREEZE"))); - tx.execute(s->charges.terminate("9","PO-1","7",adjustment.getId(),new Command(version(),"terminate-after-handoff","end failed correction"))); - assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("SHIPPED_TO_CUSTOMER"); - assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isEqualTo(originalConfirmed); - } - private WalletCommandCompletedV1 failed(OverseasProcurementFee fee,String operation){return new WalletCommandCompletedV1(fee.getWalletCommandEventId(),operation,9,"PROCUREMENT_FEE",fee.getWalletBusinessNo(),"FAILED",null,fee.getAmount(),"FAILED","definitive failure",Instant.now(),null,null);} - private ProcurementAdjustmentService.Result replace(OverseasProcurementFee original,String target,String key){return tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),new Correct(version(),key,new BigDecimal(target),1001,"replacement verified")));} - - private ProcurementAdjustmentService.Result correct(OverseasProcurementFee root,String target,String key){return tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),new Correct(version(),key,new BigDecimal(target),1001,"verified target")));} - @Test void concurrentTargetsCannotBothSupplementTheSameOldNetAmount()throws Exception { - tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root); - var executor=java.util.concurrent.Executors.newFixedThreadPool(2);var gate=new java.util.concurrent.CountDownLatch(1);long version=version(); - try { - var futures=new ArrayList>(); - for(String target:List.of("120","130"))futures.add(executor.submit(()->{gate.await();try{tx.execute(s->adjustments.correct("9","PO-1",target,root.getId(),new Correct(version,"parallel-"+target,new BigDecimal(target),1001,"correct target")));return true;} - catch(org.springframework.web.server.ResponseStatusException e){assertThat(e.getStatusCode().value()).isEqualTo(409);return false;}})); - gate.countDown();int succeeded=0;for(var result:futures)if(result.get(10,java.util.concurrent.TimeUnit.SECONDS))succeeded++; - assertThat(succeeded).isEqualTo(1);assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isEqualTo(1); - assertThat(finance.all(11).stream().filter(f->"ADJUSTMENT".equals(f.getPurpose())).count()).isEqualTo(1); - }finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();} - } - private Selection selection(String amount){return new Selection(null,new Amount(new BigDecimal(amount).signum()>0?"POSITIVE":"ZERO",new BigDecimal(amount),"CNY",LocalDate.of(2026,9,1),new BigDecimal(amount).signum()>0?1001L:null,"actual cost"));} - private Confirm request(String international,String domestic){return new Confirm(version(),"charges-"+version(),selection(international),selection("0"),selection(domestic),"NOT_APPLICABLE",List.of(),"all determined");} - private long version(){return orders.findNo("9","PO-1").getVersion();} - private int countAttempts(){return jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt",Integer.class);} - private OverseasProcurementFee current(ProcurementFeePurpose purpose){return finance.current(11,purpose,purpose.name());} - private OverseasProcurementFee byNo(String no){return finance.all(11).stream().filter(f->no.equals(f.getFeeNo())).findFirst().orElseThrow();} - private void settle(OverseasProcurementFee fee){complete(fee,"FREEZE");complete(fees.selectById(fee.getId()),"SETTLE");var saved=fees.selectById(fee.getId());jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE',?,'PUBLISHED')",String.valueOf(saved.getSourceFeeId()));} - private void complete(OverseasProcurementFee fee,String operation){String status="REFUND".equals(operation)?"REFUNDED":"FREEZE".equals(operation)?"FROZEN":"SETTLED";tx.executeWithoutResult(s->finance.handle(new WalletCommandCompletedV1(fee.getWalletCommandEventId(),operation,9,"PROCUREMENT_FEE",fee.getWalletBusinessNo(),"SUCCEEDED",status,fee.getAmount(),null,null,Instant.now(),"REFUND".equals(operation)?null:"hold-"+fee.getId(),"ledger-"+fee.getId()+"-"+operation)));} -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java deleted file mode 100644 index 5cee6c842..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java +++ /dev/null @@ -1,130 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse; -import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; -import com.baiyue.lastleg.procurement.dto.ProcurementControlRequests.*; -import com.baiyue.lastleg.procurement.entity.*; -import com.baiyue.lastleg.procurement.mapper.*; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mybatis.spring.SqlSessionTemplate; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.DataSourceTransactionManager; -import org.springframework.jdbc.datasource.DriverManagerDataSource; -import org.springframework.transaction.support.TransactionTemplate; -import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDateTime; -import java.util.*; -import java.util.regex.Pattern; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; -import static org.mockito.ArgumentMatchers.*; - -class ProcurementControlPersistenceTest { - JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;ProcurementFinanceService finance; - ProcurementControlService service;ProcurementLifecycleService lifecycle;ProcurementWarehouseDirectory warehouses; - @BeforeEach void setup()throws Exception { - var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa",""); - jdbc=new JdbcTemplate(ds){ - @Override public int update(String sql,Object... args){return super.update(sql.replaceAll("(?i)CAST\\(\\? AS JSON\\)","? FORMAT JSON"),args);} - };tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); - String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); - for(String name:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_flow","ll_procurement_command","ll_procurement_receipt","ll_procurement_disposition", - "ll_procurement_carton","ll_procurement_shipment","ll_procurement_shipment_item","ll_procurement_control_note","ll_procurement_closure","ll_procurement_closure_item")) { - var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema); - assertThat(m.find()).as(name).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); - } - var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class); - var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject()); - orders=session.getMapper(OverseasProcurementOrderMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class); - var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample"); - order.setProductName("Sample");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY");order.setContactName("buyer");order.setContactPhone("13800000000"); - order.setStatus("WAIT_OVERSEAS_RECEIPT");order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(0L);orders.insert(order); - jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,purchased_at,purchased_quantity,purchase_amount,purchase_currency) VALUES(11,'9',?,10,100,'CNY')",LocalDateTime.now().minusDays(1)); - jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','EXCEPTION',1001,'proof.pdf','application/pdf',10,'7')"); - finance=mock(ProcurementFinanceService.class);warehouses=mock(ProcurementWarehouseDirectory.class); - when(warehouses.required(92)).thenReturn(new ReceivingWarehouse(92L,"NEW","Correct receiving warehouse","US","ACTIVE")); - var json=new ObjectMapper().findAndRegisterModules();lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json); - service=new ProcurementControlService(lifecycle,finance,warehouses,jdbc,json); - org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); - } - @Test void purchasedButUnreceivedGoodsCanCloseOnlyWithAllSupplierDispositionAndCustomerEvidence() { - assertThat(service.read("9","PO-1").remaining()).containsExactly(new ProcurementControlService.Remaining("PURCHASE_REMAINDER",null,"PO-1",10)); - assertThatThrownBy(()->close(List.of())).hasMessageContaining("货物去向未明确"); - var request=request(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED"))); - var result=tx.execute(s->service.close("9","PO-1","7",request));assertThat(result.status()).isEqualTo("EXCEPTION_CLOSED");assertThat(result.remaining()).isEmpty(); - tx.execute(s->service.close("9","PO-1","7",request));assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_closure",Integer.class)).isEqualTo(1); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isZero();verify(finance,times(2)).requireShipmentReady(11);verifyNoMoreInteractions(finance); - } - @Test void partialReceiptsRequireExactGoodDamagedExtraAndUnreceivedSources() { - receipt(101,"OVERSEAS",null,6,2,1); - assertThat(service.read("9","PO-1").remaining()).hasSize(4); - assertThatThrownBy(()->close(List.of(resolution("GOOD",101L,5,"RETURNED_TO_SUPPLIER")))).hasMessageContaining("恰好覆盖"); - var all=List.of(resolution("GOOD",101L,6,"RETURNED_TO_SUPPLIER"),resolution("DAMAGED",101L,2,"DISPOSED"),resolution("EXTRA",101L,1,"RETURNED_TO_SUPPLIER"),resolution("PURCHASE_REMAINDER",null,2,"SUPPLIER_RETAINED")); - assertThat(close(all).status()).isEqualTo("EXCEPTION_CLOSED"); - assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_closure_item",Long.class)).isEqualTo(11L); - assertThat(jdbc.queryForObject("SELECT good_quantity FROM ll_procurement_receipt",Long.class)).isEqualTo(6L); - } - @Test void internationalShipmentIsNotRecountedAndDomesticRemainingKeepsOriginalCartonSources() { - receipt(101,"OVERSEAS",null,10,0,0);international();receipt(102,"DOMESTIC",201L,3,1,1); - var remaining=service.read("9","PO-1").remaining();assertThat(remaining).hasSize(5);assertThat(remaining).noneMatch(r->r.sourceId()!=null && r.sourceId()==101); - assertThat(remaining).contains(new ProcurementControlService.Remaining("TRANSIT_REMAINDER",201L,"C1",2),new ProcurementControlService.Remaining("TRANSIT_REMAINDER",202L,"C2",4)); - close(List.of(resolution("GOOD",102L,3,"RETURNED_TO_SUPPLIER"),resolution("DAMAGED",102L,1,"DISPOSED"),resolution("EXTRA",102L,1,"RETURNED_TO_SUPPLIER"),resolution("TRANSIT_REMAINDER",201L,2,"LOSS_CONFIRMED"),resolution("TRANSIT_REMAINDER",202L,4,"RETURNED_TO_SENDER"))); - assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L); - assertThat(jdbc.queryForObject("SELECT overseas_shipped_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull(); - } - @Test void unresolvedMoneyCannotBeErasedByClosing() { - doThrow(ProcurementLifecycleService.conflict("资金未决")).when(finance).requireShipmentReady(11); - assertThatThrownBy(()->close(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED")))).hasMessageContaining("资金未决"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_closure",Integer.class)).isZero();assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("WAIT_OVERSEAS_RECEIPT"); - } - @Test void evidenceBackedBlockerMustBeResolvedBeforePlannedOperationsAndClosure() { - tx.execute(s->service.note("9","PO-1","7",new Note(version(),"note","PURCHASE",null,true,1001,"internal evidence","Waiting for agreed handling"))); - assertThatThrownBy(()->lifecycle.requireNoBlockers(orders.findNo("9","PO-1"))).hasMessageContaining("阻断异常"); - assertThatThrownBy(()->close(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED")))).hasMessageContaining("阻断异常"); - long id=jdbc.queryForObject("SELECT id FROM ll_procurement_control_note",Long.class); - tx.execute(s->service.resolve("9","PO-1","7",id,new Resolve(version(),"resolve",1001,"customer confirms supplier retention"))); - lifecycle.requireNoBlockers(orders.findNo("9","PO-1"));assertThat(close(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED"))).status()).isEqualTo("EXCEPTION_CLOSED"); - assertThat(jdbc.queryForObject("SELECT reason FROM ll_procurement_control_note",String.class)).isEqualTo("internal evidence"); - } - @Test void warehouseCorrectionBeforePackingChangesEffectiveWarehouseAndPreservesOriginalReceipt() { - receipt(101,"OVERSEAS",null,10,0,0);warehouse(); - tx.execute(s->service.correctWarehouse("9","PO-1","7",new WarehouseCorrection(version(),"warehouse",92,1001,"typed wrong warehouse"))); - assertThat(jdbc.queryForObject("SELECT actual_warehouse_id FROM ll_procurement_flow",Long.class)).isEqualTo(92L); - assertThat(jdbc.queryForObject("SELECT warehouse_id FROM ll_procurement_receipt",Long.class)).isEqualTo(91L); - assertThat(jdbc.queryForObject("SELECT original_value_json FROM ll_procurement_control_note",String.class)).contains("91"); - var snapshot=service.read("9","PO-1"); - assertThat(snapshot.notes().get(0).get("original_value_json")).isInstanceOf(com.fasterxml.jackson.databind.JsonNode.class); - assertThat(((com.fasterxml.jackson.databind.JsonNode)snapshot.notes().get(0).get("original_value_json")).get("warehouseId").asLong()).isEqualTo(91); - - assertThat(jdbc.queryForObject("SELECT blocking FROM ll_procurement_control_note",Boolean.class)).isFalse(); - } - @Test void warehouseAfterDownstreamAndPostShipmentCorrectionsOnlyAppendFacts() { - receipt(101,"OVERSEAS",null,10,0,0);warehouse();international(); - tx.execute(s->service.correctWarehouse("9","PO-1","7",new WarehouseCorrection(version(),"warehouse",92,1001,"observed later"))); - assertThat(jdbc.queryForObject("SELECT actual_warehouse_id FROM ll_procurement_flow",Long.class)).isEqualTo(91L); - assertThat(jdbc.queryForObject("SELECT blocking FROM ll_procurement_control_note",Boolean.class)).isTrue(); - jdbc.update("UPDATE ll_overseas_procurement_order SET status='SHIPPED_TO_CUSTOMER' WHERE id=11"); - tx.execute(s->service.note("9","PO-1","7",new Note(version(),"after","SHIPMENT",301L,false,1001,"post handoff correction","Shipment correction recorded"))); - assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("SHIPPED_TO_CUSTOMER");assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L); - } - @Test void wrongOrderProofOrFactAndDuplicateRemainingSourceAreRejected() { - assertThatThrownBy(()->service.read("10","PO-1")).hasMessageContaining("当前客户"); - assertThatThrownBy(()->tx.execute(s->service.note("9","PO-1","7",new Note(version(),"other","SHIPMENT",999L,false,1001,"other fact",null)))).hasMessageContaining("不属于"); - var line=resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED");assertThatThrownBy(()->close(List.of(line,line))).hasMessageContaining("恰好覆盖"); - assertThatThrownBy(()->tx.execute(s->service.close("9","PO-1","7",new Close(version(),"other-proof",List.of(line),999,"no further charges","closed","Agreement")))).hasMessageContaining("凭证不属于"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_closure",Integer.class)).isZero(); - } - private void warehouse(){jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=91,actual_warehouse_code='OLD',actual_warehouse_name='Original warehouse' WHERE order_id=11");} - private void international(){jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2));jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,401,'C1',20,30,40,6,6),(202,11,401,'C2',20,30,40,4,4)");jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(301,11,'9','PS-1','OVERSEAS',401,?,'{}','7')",LocalDateTime.now().minusHours(2));jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(301,201,6,'carrier','T1','{}'),(301,202,4,'carrier','T2','{}')");} - private void receipt(long id,String leg,Long carton,long good,long damaged,long extra){jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,warehouse_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(?,11,'9',?,?,?,91,?,?,?,?, '7')",id,"PR-"+id,leg,carton,good,damaged,extra,LocalDateTime.now().minusHours(1));} - private long version(){return orders.findNo("9","PO-1").getVersion();} - private Resolution resolution(String kind,Long source,long quantity,String decision){return new Resolution(kind,source,quantity,decision,"verified actual destination",1001);} - private Close request(List lines){return new Close(version(),"close-"+version(),lines,1001,"known fees resolved; no further customer charges","agreed stop","Procurement closed by agreement");} - private ProcurementControlService.Snapshot close(List lines){return tx.execute(s->service.close("9","PO-1","7",request(lines)));} -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementDirectionPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementDirectionPersistenceTest.java deleted file mode 100644 index 6ab56a0e0..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementDirectionPersistenceTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.baiyue.lastleg.procurement.mapper.ProcurementDirectionMapper; -import com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository; -import org.junit.jupiter.api.Test; -import org.mybatis.spring.SqlSessionTemplate; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.DriverManagerDataSource; -import java.util.UUID; -import static org.assertj.core.api.Assertions.assertThat; - -class ProcurementDirectionPersistenceTest { - @Test void directionGateReadsOnlyThisOrdersRecordedFacts() throws Exception { - var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); - var jdbc = new JdbcTemplate(ds); - jdbc.execute("CREATE TABLE ll_procurement_flow(order_id BIGINT PRIMARY KEY,purchased_at TIMESTAMP,actual_warehouse_id BIGINT,overseas_shipped_at TIMESTAMP)"); - var config = new MybatisConfiguration(); config.addMapper(ProcurementDirectionMapper.class); - var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config); - var repository = new ProcurementDirectionRepository(new SqlSessionTemplate(factory.getObject()).getMapper(ProcurementDirectionMapper.class)); - assertThat(repository.hasStarted(1)).isFalse(); - jdbc.update("INSERT INTO ll_procurement_flow(order_id) VALUES(1)"); - assertThat(repository.hasStarted(1)).isFalse(); - jdbc.update("UPDATE ll_procurement_flow SET purchased_at=CURRENT_TIMESTAMP WHERE order_id=1"); - assertThat(repository.hasStarted(1)).isTrue(); - assertThat(repository.hasStarted(2)).isFalse(); - jdbc.update("UPDATE ll_procurement_flow SET purchased_at=NULL,actual_warehouse_id=91 WHERE order_id=1"); - assertThat(repository.hasStarted(1)).isTrue(); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java deleted file mode 100644 index 4ba1cf4f8..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java +++ /dev/null @@ -1,287 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.baiyue.common.event.WalletCommandCompletedV1; -import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; -import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; -import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; -import com.baiyue.lastleg.procurement.entity.*; -import com.baiyue.lastleg.procurement.mapper.*; -import com.baiyue.lastleg.wallet.WalletCommandOutboxService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mybatis.spring.SqlSessionTemplate; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.DataSourceTransactionManager; -import org.springframework.jdbc.datasource.DriverManagerDataSource; -import org.springframework.transaction.support.TransactionTemplate; -import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.time.LocalDate; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.regex.Pattern; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -/** Real fee rows, attempt rows, locking and rollback; only external boundaries are mocked. */ -class ProcurementFinancePersistenceTest { - JdbcTemplate jdbc; - TransactionTemplate tx; - OverseasProcurementFeeMapper fees; - OverseasProcurementOrder order; - OverseasFeeSourceService sources; - WalletCommandOutboxService commands; - ProcurementFinanceService service; - @BeforeEach void setup() throws Exception { - var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", ""); - jdbc = new JdbcTemplate(ds); tx = new TransactionTemplate(new DataSourceTransactionManager(ds)); - String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); - for (String name : new String[]{"ll_overseas_procurement_fee", "ll_procurement_wallet_attempt"}) { - var match = Pattern.compile("CREATE TABLE IF NOT EXISTS " + name + " \\(.*?\\) ENGINE=InnoDB[^;]*;", Pattern.DOTALL).matcher(schema); - assertThat(match.find()).as("source schema contains %s",name).isTrue(); - jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;", ");")); - } - jdbc.execute("CREATE TABLE ll_event_outbox(aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),status VARCHAR(32))"); - var config=new MybatisConfiguration(); config.addMapper(OverseasProcurementFeeMapper.class); - var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); - fees=new SqlSessionTemplate(factory.getObject()).getMapper(OverseasProcurementFeeMapper.class); - var orders=mock(OverseasProcurementOrderMapper.class); var files=mock(OverseasProcurementFileMapper.class); - order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test"); - order.setOrderNo("PO-1");order.setPurchaseQuantity(10L);order.setInboundQuantity(10L);order.setStatus("WAIT_CHARGES"); - when(orders.lockNo("9","PO-1")).thenReturn(order);when(orders.selectById(11L)).thenReturn(order); - var voucher=new OverseasProcurementFile();voucher.setFileId(1L);voucher.setFileName("voucher.pdf");voucher.setCategory("VOUCHER"); - when(files.selectOne(any())).thenReturn(voucher); - var converter=mock(OverseasFeeCurrencyConverter.class); - when(converter.convert(eq("CNY"),any(),any())).thenAnswer(i -> new OverseasFeeCurrencyConverter.Conversion("CNY",i.getArgument(1),BigDecimal.ONE,"2026-10",i.getArgument(1))); - sources=mock(OverseasFeeSourceService.class); - var source=new OverseasFeeSource();source.setSourceFeeId(901L); - when(sources.publishSettled(anyString(),anyString(),any(),any(),any())).thenReturn(source); - commands=mock(WalletCommandOutboxService.class);AtomicInteger sequence=new AtomicInteger(); - when(commands.publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong())) - .thenAnswer(i -> "command-"+sequence.incrementAndGet()); - when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong())) - .thenAnswer(i -> "command-"+sequence.incrementAndGet()); - service=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,new ObjectMapper().findAndRegisterModules()); - org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); - org.springframework.test.util.ReflectionTestUtils.setField(service, "feeNumbers", new com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository(fees)); - } - - @Test void actualFeePublishesOnlyAfterSuccessfulConsumption() { - var fee=confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"POSITIVE","100"); - var frozen=result(fee,"FREEZE","SUCCEEDED","FROZEN","100");handle(frozen); - verifyNoInteractions(sources); - var pending=fees.selectById(fee.getId());assertThat(pending.getWalletStatus()).isEqualTo("SETTLE_PENDING"); - var settled=result(pending,"SETTLE","SUCCEEDED","SETTLED","100");handle(settled);handle(settled); - var saved=fees.selectById(fee.getId());assertThat(saved.getWalletStatus()).isEqualTo("SETTLED"); - assertThat(saved.getSourceFeeId()).isEqualTo(901L); - verify(sources).publishSettled(eq("7"),anyString(),any(),any(),argThat(ref -> ref.commandEventId().equals(settled.commandEventId()))); - assertThat(service.sourceLinked(saved)).isTrue(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isZero(); - } - - @Test void fullInboundNeverSettlesEstimateAndActualUsesOriginalHold() { - var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100"); - handle(result(estimate,"FREEZE","SUCCEEDED","FROZEN","100")); - assertThat(fees.selectById(estimate.getId()).getWalletStatus()).isEqualTo("FROZEN"); - verify(commands,never()).publish(eq("SETTLE"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); - var request=request(ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"POSITIVE","60"); - var actual=tx.execute(s -> service.confirmInternational("9","PO-1","7",request)); - assertThat(actual.getWalletBusinessNo()).isEqualTo(estimate.getFeeNo()); - handle(result(actual,"SETTLE_ACTUAL","SUCCEEDED","SETTLED","60")); - assertThat(fees.selectById(estimate.getId()).getAmount()).isEqualByComparingTo("100"); - assertThat(fees.selectById(actual.getId()).getAmount()).isEqualByComparingTo("60"); - assertThat(tx.execute(s -> service.confirmInternational("9","PO-1","7",request)).getId()).isEqualTo(actual.getId()); - verify(sources).publishSettled(anyString(),anyString(),argThat(r -> r.businessNo().equals(estimate.getFeeNo()) && r.amount().compareTo(new BigDecimal("60"))==0),any(),any()); - } - - @Test void zeroActualReleasesEstimateWithoutCreatingAnActualReceivable() { - var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100"); - handle(result(estimate,"FREEZE","SUCCEEDED","FROZEN","100")); - var actual=tx.execute(s -> service.confirmInternational("9","PO-1","7",request(ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"ZERO","0"))); - handle(result(actual,"RELEASE","SUCCEEDED","RELEASED","100")); - assertThat(fees.selectById(estimate.getId()).getWalletStatus()).isEqualTo("RELEASED"); - assertThat(service.sourceLinked(fees.selectById(actual.getId()))).isTrue(); - verifyNoInteractions(sources); - } - - @Test void unknownRequestCannotRetryAndOldFailedResultCannotOverwriteNewAttempt() { - var fee=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10"); - assertThatThrownBy(() -> tx.execute(s -> service.retry("9","PO-1",fee.getId()))).hasMessageContaining("未明确失败"); - var failed=result(fee,"FREEZE","FAILED",null,"10");handle(failed); - var retry=tx.execute(s -> service.retry("9","PO-1",fee.getId())); - assertThat(retry.getWalletCommandEventId()).isNotEqualTo(failed.commandEventId()); - handle(failed); - assertThat(fees.selectById(fee.getId()).getWalletStatus()).isEqualTo("FREEZE_PENDING"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE fee_id=?",Integer.class,fee.getId())).isEqualTo(2); - } - - @Test void pendingOriginalRejectsReplacementAndLateSuccessKeepsOriginalAmountAndLedger() { - var original=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10"); - var originalFreeze=result(original,"FREEZE","SUCCEEDED","FROZEN","10"); - assertPendingVersionCannotBeReplaced(original); - // Replaying a timed-out confirmation returns the original command, never a new attempt. - assertThat(confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10").getWalletCommandEventId()) - .isEqualTo(originalFreeze.commandEventId()); - handle(originalFreeze); - var settling=fees.selectById(original.getId()); - assertPendingVersionCannotBeReplaced(settling); - assertThat(confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10").getWalletCommandEventId()) - .isEqualTo(settling.getWalletCommandEventId()); - var lateSuccess=result(settling,"SETTLE","SUCCEEDED","SETTLED","10"); - handle(lateSuccess); - // A delayed freeze receipt after settlement must not regress the settled projection. - handle(originalFreeze); - handle(lateSuccess); - var saved=fees.selectById(original.getId()); - assertThat(saved.getFeeVersion()).isEqualTo(1); - assertThat(saved.getOriginalAmount()).isEqualByComparingTo("10"); - assertThat(saved.getAmount()).isEqualByComparingTo("10"); - assertThat(saved.getWalletStatus()).isEqualTo("SETTLED"); - assertThat(saved.getSettledCommandEventId()).isEqualTo(lateSuccess.commandEventId()); - assertThat(saved.getWalletLedgerId()).isEqualTo(lateSuccess.walletLedgerId()); - assertThat(saved.getSourceFeeId()).isEqualTo(901L); - assertThat(service.all(11)).hasSize(1); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE fee_id=? AND status='SUCCEEDED'",Integer.class,original.getId())).isEqualTo(2); - assertThat(jdbc.queryForObject("SELECT fee_id FROM ll_procurement_wallet_attempt WHERE command_event_id=?",Long.class,lateSuccess.commandEventId())).isEqualTo(original.getId()); - verify(commands,times(2)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); - verify(commands,never()).publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong()); - verify(sources).publishSettled(anyString(),anyString(),argThat(r -> r.amount().compareTo(new BigDecimal("10"))==0),any(), - argThat(ref -> ref.commandEventId().equals(lateSuccess.commandEventId()))); - } - - private void assertPendingVersionCannotBeReplaced(OverseasProcurementFee original) { - assertThatThrownBy(() -> confirm(ProcurementFeePurpose.DUTY,"POSITIVE","20")).hasMessageContaining("已确认"); - assertThatThrownBy(() -> tx.execute(s -> service.replaceFailed("9","PO-1","7",original.getId(),new BigDecimal("20"),1,"corrected target"))) - .hasMessageContaining("须先终止"); - assertThatThrownBy(() -> tx.execute(s -> service.retry("9","PO-1",original.getId()))).hasMessageContaining("未明确失败"); - assertThatThrownBy(() -> tx.executeWithoutResult(s -> service.terminateFailed("9","PO-1",original.getId()))).hasMessageContaining("明确失败"); - assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",original.getId(),BigDecimal.ONE,1,"corrected target","pending-refund"))) - .hasMessageContaining("已成功实扣"); - assertThat(fees.selectById(original.getId()).getWalletCommandEventId()).isEqualTo(original.getWalletCommandEventId()); - } - - @Test void pendingEstimateCannotBeReleasedOrReplacedBeforeLateFreezeResult() { - var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","10"); - assertThatThrownBy(() -> tx.executeWithoutResult(s -> service.releaseEstimate("9","PO-1",estimate.getId()))) - .hasMessageContaining("未决"); - assertThatThrownBy(() -> confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","20")).hasMessageContaining("已确认"); - assertThat(confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","10").getId()).isEqualTo(estimate.getId()); - var lateFreeze=result(estimate,"FREEZE","SUCCEEDED","FROZEN","10"); - handle(lateFreeze);handle(lateFreeze); - assertThat(fees.selectById(estimate.getId()).getWalletStatus()).isEqualTo("FROZEN"); - assertThat(fees.selectById(estimate.getId()).getAmount()).isEqualByComparingTo("10"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt",Integer.class)).isEqualTo(1); - verify(commands).publish(eq("FREEZE"),anyLong(),anyString(),eq(estimate.getFeeNo()),eq(new BigDecimal("10")),anyString(),anyLong()); - verifyNoMoreInteractions(commands); - verifyNoInteractions(sources); - } - - @Test void sourceFailureRollsBackResultProjectionAndRetriesWithoutAnotherDebit() { - var fee=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10"); - handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","10")); - var pending=fees.selectById(fee.getId());var result=result(pending,"SETTLE","SUCCEEDED","SETTLED","10"); - when(sources.publishSettled(anyString(),anyString(),any(),any(),any())).thenThrow(new IllegalStateException("source unavailable")); - assertThatThrownBy(() -> handle(result)).hasMessage("source unavailable"); - assertThat(fees.selectById(fee.getId()).getWalletStatus()).isEqualTo("SETTLE_PENDING"); - assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_wallet_attempt WHERE command_event_id=?",String.class,result.commandEventId())).isEqualTo("PENDING"); - verify(commands,times(2)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); - - var recoveredSource=new OverseasFeeSource();recoveredSource.setSourceFeeId(901L); - doReturn(recoveredSource).when(sources).publishSettled(anyString(),anyString(),any(),any(),any()); - handle(result);handle(result); - var recovered=fees.selectById(fee.getId()); - assertThat(recovered.getWalletStatus()).isEqualTo("SETTLED"); - assertThat(recovered.getSourceFeeId()).isEqualTo(901L); - assertThat(recovered.getSettledCommandEventId()).isEqualTo(result.commandEventId()); - assertThat(recovered.getWalletLedgerId()).isEqualTo(result.walletLedgerId()); - assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_wallet_attempt WHERE command_event_id=?",String.class,result.commandEventId())).isEqualTo("SUCCEEDED"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE fee_id=?",Integer.class,fee.getId())).isEqualTo(2); - verify(sources,times(2)).publishSettled(anyString(),anyString(),any(),any(),argThat(ref->ref.commandEventId().equals(result.commandEventId()))); - verify(commands,times(2)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); - assertThat(service.sourceLinked(recovered)).isTrue(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isZero(); - service.requireShipmentReady(11); - } - - @Test void freeSampleDoesNotPublishZeroMoneyAndCannotBeChangedByReplay() { - var fee=confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"ZERO","0"); - assertThat(fee.getWalletStatus()).isEqualTo("ZERO");assertThat(service.sourceLinked(fee)).isTrue(); - assertThatThrownBy(() -> confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"POSITIVE","1")).hasMessageContaining("已确认"); - verifyNoInteractions(commands,sources); - } - - @Test void repriceWaitsForReleaseAndCreatesANewImmutableVersion() { - var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100"); - handle(result(estimate,"FREEZE","SUCCEEDED","FROZEN","100")); - tx.executeWithoutResult(s -> service.releaseEstimate("9","PO-1",estimate.getId())); - assertThatThrownBy(() -> confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","80")).hasMessageContaining("已确认"); - handle(result(fees.selectById(estimate.getId()),"RELEASE","SUCCEEDED","RELEASED","100")); - var revised=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","80"); - assertThat(revised.getFeeVersion()).isEqualTo(2); - assertThat(fees.selectById(estimate.getId()).getAmount()).isEqualByComparingTo("100"); - assertThat(fees.selectById(estimate.getId()).getActive()).isFalse(); - } - - @Test void refundUsesOriginalConsumptionAndIsIdempotentByBusinessRequest() { - var fee=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","100"); - handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100")); - handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100")); - jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')"); - var refund=tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key")); - assertThat(refund.getFeeNo()).matches("PF[0-9]{12}"); - var replay = tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key")); - assertThat(replay.getId()).isEqualTo(refund.getId()); - assertThat(replay.getFeeNo()).isEqualTo(refund.getFeeNo()); - assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("21"),1,"correction","refund-key"))) - .hasMessageContaining("退款幂等键对应不同"); - verify(commands, times(1)).publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong()); - assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","another-key"))) - .hasMessageContaining("未完成的调整或退款"); - handle(new WalletCommandCompletedV1(refund.getWalletCommandEventId(),"REFUND",9,"PROCUREMENT_FEE",refund.getFeeNo(), - "SUCCEEDED","REFUNDED",new BigDecimal("20"),null,null,Instant.parse("2026-10-09T10:00:00Z"),null,"33")); - assertThat(fees.selectById(refund.getId()).getWalletStatus()).isEqualTo("REFUNDED"); - assertThat(fees.selectById(fee.getId()).getWalletStatus()).isEqualTo("SETTLED"); - verify(commands).publishRefund(eq(9L),eq("PROCUREMENT_FEE"),eq(refund.getFeeNo()),any(),eq("32"),eq("correction"),anyLong()); - assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("81"),1,"correction","too-much"))) - .hasMessageContaining("可退余额"); - } - - @Test void finalHandoffWaitsForConsumptionSourceAndAnyPendingRefund() { - var fee=confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"POSITIVE","100"); - assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("未决资金"); - handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100")); - handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100")); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isZero(); - service.requireShipmentReady(11); - tx.execute(s->service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","pending-refund")); - assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("未决资金"); - assertThatThrownBy(()->service.requireAdjustmentsComplete(11)).hasMessageContaining("财务调整或退款"); - } - - @Test void unusedFrozenEstimateMustBeReleasedBeforeFinalHandoff() { - var fee=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100"); - handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100")); - assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("尚未结清或释放"); - tx.executeWithoutResult(s->service.releaseEstimate("9","PO-1",fee.getId())); - handle(result(fees.selectById(fee.getId()),"RELEASE","SUCCEEDED","RELEASED","100")); - service.requireShipmentReady(11); - } - - private OverseasProcurementFee confirm(ProcurementFeePurpose purpose,String determination,String amount) { - return tx.execute(s -> service.confirm("9","PO-1","7",request(purpose,determination,amount))); - } - private ProcurementFinanceService.Confirmation request(ProcurementFeePurpose purpose,String determination,String amount) { - return new ProcurementFinanceService.Confirmation(purpose,purpose.name(),purpose.label(),determination,new BigDecimal(amount),"CNY",LocalDate.of(2026,10,9),1L,"confirmed basis"); - } - private WalletCommandCompletedV1 result(OverseasProcurementFee fee,String operation,String outcome,String state,String amount) { - return new WalletCommandCompletedV1(fee.getWalletCommandEventId(),operation,9,"PROCUREMENT_FEE",fee.getWalletBusinessNo(),outcome,state,new BigDecimal(amount), - "FAILED".equals(outcome)?"FIN-WALLET-INSUFFICIENT":null,null,Instant.parse("2026-10-09T10:00:00Z"),"SUCCEEDED".equals(outcome)?"31":null,"SUCCEEDED".equals(outcome)?"32":null); - } - private void handle(WalletCommandCompletedV1 result) { tx.executeWithoutResult(s -> service.handle(result)); } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java deleted file mode 100644 index 8caa1277d..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java +++ /dev/null @@ -1,238 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse; -import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; -import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command; -import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.*; -import com.baiyue.lastleg.procurement.entity.*; -import com.baiyue.lastleg.procurement.mapper.*; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mybatis.spring.SqlSessionTemplate; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.DataSourceTransactionManager; -import org.springframework.jdbc.datasource.DriverManagerDataSource; -import org.springframework.transaction.support.TransactionTemplate; -import org.springframework.web.server.ResponseStatusException; -import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.*; -import java.util.regex.Pattern; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -class ProcurementPhysicalPersistenceTest { - JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders; - ProcurementPhysicalService service;ProcurementFinanceService finance;OverseasProcurementFee payment; - @BeforeEach void setup()throws Exception { - var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa",""); - jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); - String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); - for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_control_note","ll_procurement_flow","ll_procurement_command", - "ll_procurement_budget_authorization","ll_procurement_receipt","ll_procurement_disposition","ll_procurement_manifest","ll_procurement_carton","ll_procurement_carton_source","ll_procurement_shipment","ll_procurement_shipment_item")) { - var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema); - assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); - } - var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class); - var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject()); - orders=session.getMapper(OverseasProcurementOrderMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class); - var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample"); - order.setProductName("Sample");order.setProductSpecification("A");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY"); - order.setContactName("buyer");order.setContactPhone("13800000000");order.setStatus("WAIT_OVERSEAS_RECEIPT");order.setReviewedAt(LocalDateTime.now().minusDays(3)); - order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(0L);orders.insert(order); - jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,purchased_at,purchased_quantity,purchase_confirmed_at) VALUES(11,'9',?,10,?)",LocalDateTime.now().minusDays(2),LocalDateTime.now().minusDays(2)); - jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','VOUCHER',1001,'evidence.pdf','application/pdf',10,'7')"); - var warehouses=mock(ProcurementWarehouseDirectory.class);when(warehouses.required(91)).thenReturn(new ReceivingWarehouse(91L,"LAX","receiving","US","ACTIVE")); - finance=mock(ProcurementFinanceService.class);payment=new OverseasProcurementFee();payment.setWalletStatus("SETTLED"); - when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true); - var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,new ObjectMapper().findAndRegisterModules()); - service=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc); - org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); - } - - @Test void splitReceiptsAndCartonSourcesRemainConservedWithoutWms() { - var first=receive(Leg.OVERSEAS,null,6,0,0,"r1");long source1=first.receipts().get(0).id(); - var second=receive(Leg.OVERSEAS,null,4,0,0,"r2");long source2=second.receipts().get(1).id(); - finish(Leg.OVERSEAS); - var draft=pack(List.of(carton("C1",new Source(source1,5)),carton("C2",new Source(source1,1),new Source(source2,4)))); - var confirmed=tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",draft.packingVersion(),"checked"))); - assertThat(confirmed.status()).isEqualTo("WAIT_LABELS");assertThat(confirmed.packingStatus()).isEqualTo("CONFIRMED"); - assertThat(confirmed.cartons()).extracting(ProcurementPhysicalService.CartonRow::quantity).containsExactly(5L,5L); - assertThat(confirmed.cartons().get(1).sources()).hasSize(2); - assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isEqualTo(10L); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables WHERE table_name='ll_inventory_balance'",Integer.class)).isZero(); - } - - @Test void unresolvedDamageExtraAndShortageBlockCompletionUntilEvidenceExists() { - var receipt=receive(Leg.OVERSEAS,null,6,2,1,"r1").receipts().get(0); - assertThatThrownBy(()->finish(Leg.OVERSEAS)).hasMessageContaining("破损或多到"); - dispose(receipt.id(),"DAMAGED",2,"DISPOSE_DAMAGED");dispose(receipt.id(),"EXTRA",1,"RETURN_EXTRA"); - assertThatThrownBy(()->finish(Leg.OVERSEAS)).hasMessageContaining("未收齐"); - dispose(null,"SHORTAGE",2,"ACCEPT_SHORTAGE"); - assertThat(finish(Leg.OVERSEAS).status()).isEqualTo("WAIT_PACKING"); - assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isEqualTo(6L); - verify(finance,never()).refund(anyString(),anyString(),anyString(),anyLong(),any(),anyLong(),anyString(),anyString()); - } - - @Test void overReceiptAndOtherOrderEvidenceAreRejectedWithoutCounting() { - var receipt=receive(Leg.OVERSEAS,null,8,2,0,"r1").receipts().get(0); - assertThatThrownBy(()->receive(Leg.OVERSEAS,null,1,0,0,"r2")).hasMessageContaining("超过来源"); - assertThatThrownBy(()->tx.execute(s->service.dispose("9","PO-1","7",new Disposition(version(),"dispose",Leg.OVERSEAS,null,receipt.id(),"DAMAGED",2,"DISPOSE_DAMAGED",999,"wrong file")))) - .hasMessageContaining("凭证不属于"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_receipt",Integer.class)).isEqualTo(1); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_disposition",Integer.class)).isZero(); - } - - @Test void draftsAreAllowedBeforePaymentButConfirmationWaitsForMoneyAndAllSources() { - when(finance.sourceLinked(payment)).thenReturn(false); - var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS); - var draft=pack(List.of(carton("C1",new Source(receipt.id(),10)))); - assertThatThrownBy(()->tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",draft.packingVersion(),"checked")))) - .hasMessageContaining("样品货款"); - when(finance.sourceLinked(payment)).thenReturn(true); - tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",draft.packingVersion(),"checked"))); - assertThat(service.read("9","PO-1").packingStatus()).isEqualTo("CONFIRMED"); - } - - @Test void quantityDoubleAllocationAndExcessPrecisionNeverPersist() { - var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0); - assertThatThrownBy(()->pack(List.of(carton("C1",new Source(receipt.id(),6)),carton("C2",new Source(receipt.id(),6))))).hasMessageContaining("重复分配"); - var bad=new Carton("C1",new BigDecimal("1.001"),BigDecimal.TEN,BigDecimal.TEN,BigDecimal.ONE,List.of(new Source(receipt.id(),10))); - assertThatThrownBy(()->pack(List.of(bad))).hasMessageContaining("最多两位"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_manifest",Integer.class)).isZero(); - } - - @Test void nonPositiveMeasurementsAndOverPreciseWeightCannotPersistPacking() { - var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0); - var source=List.of(new Source(receipt.id(),10)); - for(BigDecimal invalid:List.of(BigDecimal.ZERO,BigDecimal.ONE.negate())) { - for(int axis=0;axis<4;axis++) { - BigDecimal[] values={BigDecimal.TEN,BigDecimal.TEN,BigDecimal.TEN,BigDecimal.ONE};values[axis]=invalid; - var carton=new Carton("C1",values[0],values[1],values[2],values[3],source); - assertThatThrownBy(()->pack(List.of(carton))).hasMessageContaining("正数"); - } - } - assertThatThrownBy(()->pack(List.of(new Carton("C1",BigDecimal.TEN,BigDecimal.TEN,BigDecimal.TEN,new BigDecimal("1.0001"),source)))) - .hasMessageContaining("最多三位"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_manifest",Integer.class)).isZero(); - } - - @Test void foreignReceiptAndIncompleteAllocationCannotBecomeConfirmedPacking() { - var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS); - assertThatThrownBy(()->pack(List.of(carton("C1",new Source(999,10))))).hasMessageContaining("不属于本单"); - var draft=pack(List.of(carton("C1",new Source(receipt.id(),9)))); - assertThatThrownBy(()->tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"incomplete",draft.packingVersion(),"checked")))) - .hasMessageContaining("全部"); - assertThat(service.read("9","PO-1").packingStatus()).isEqualTo("DRAFT"); - } - - @Test void repackingCreatesANewVersionAndCannotRewriteAnActualDispatch() { - var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS); - var first=pack(List.of(carton("C1",new Source(receipt.id(),10)))); - tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",first.packingVersion(),"checked"))); - var second=pack(List.of(carton("C1",new Source(receipt.id(),5)),carton("C2",new Source(receipt.id(),5)))); - assertThat(second.packingVersion()).isEqualTo(2);assertThat(second.packingStatus()).isEqualTo("DRAFT"); - assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_manifest WHERE version=1",String.class)).isEqualTo("SUPERSEDED"); - assertThat(jdbc.queryForObject("SELECT packing_version FROM ll_procurement_flow WHERE order_id=11",Integer.class)).isNull(); - jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now()); - assertThatThrownBy(()->pack(List.of(carton("C1",new Source(receipt.id(),10))))).hasMessageContaining("已实际交寄"); - } - - @Test void domesticReceiptsMustMatchEachOriginalShippedCarton() { - jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2)); - jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(201,11,'9','PS-1','OVERSEAS',301,?, '{}','7')",LocalDateTime.now().minusHours(2)); - jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(201,101,6,'carrier','T1','{}'),(201,102,4,'carrier','T2','{}')"); - assertThatThrownBy(()->receive(Leg.DOMESTIC,999L,1,0,0,"wrong")).hasMessageContaining("不属于本单"); - receive(Leg.DOMESTIC,101L,4,0,0,"d1");assertThatThrownBy(()->finish(Leg.DOMESTIC)).hasMessageContaining("未收齐"); - receive(Leg.DOMESTIC,101L,2,0,0,"d2");receive(Leg.DOMESTIC,102L,4,0,0,"d3"); - assertThat(finish(Leg.DOMESTIC).status()).isEqualTo("WAIT_CHARGES"); - assertThat(service.read("9","PO-1").receipts()).hasSize(3); - } - - @Test void concurrentReceiversCannotCountTheSameWholePurchaseTwice()throws Exception { - ExecutorService executor=Executors.newFixedThreadPool(2);CountDownLatch start=new CountDownLatch(1); - try { - var one=executor.submit(()->attemptReceipt(start,"one"));var two=executor.submit(()->attemptReceipt(start,"two"));start.countDown(); - assertThat((one.get(10,TimeUnit.SECONDS)?1:0)+(two.get(10,TimeUnit.SECONDS)?1:0)).isEqualTo(1); - assertThat(jdbc.queryForObject("SELECT SUM(good_quantity) FROM ll_procurement_receipt",Long.class)).isEqualTo(10L); - }finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,TimeUnit.SECONDS)).isTrue();} - } - - @Test void overseasCorrectionRequiresWithdrawalAndKeepsOriginalReceiptAndPacking() { - var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS); - var packing=pack(List.of(carton("C1",new Source(receipt.id(),10)))); - tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",packing.packingVersion(),"checked"))); - assertThatThrownBy(()->correct(receipt.id(),Leg.OVERSEAS,null,9,1)).hasMessageContaining("须先撤回"); - tx.execute(s->service.withdrawPacking("9","PO-1","7",new Command(version(),"withdraw","record correction"))); - var corrected=correct(receipt.id(),Leg.OVERSEAS,null,9,1); - assertThat(corrected.receipts()).hasSize(2);assertThat(corrected.receipts().get(0).active()).isFalse(); - assertThat(corrected.receipts().get(0).goodQuantity()).isEqualTo(10); - assertThat(corrected.receipts().get(1).goodQuantity()).isEqualTo(9); - assertThat(jdbc.queryForObject("SELECT quantity FROM ll_procurement_carton",Long.class)).isEqualTo(10L); - assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_manifest",String.class)).isEqualTo("SUPERSEDED"); - assertThatThrownBy(()->finish(Leg.OVERSEAS)).hasMessageContaining("破损"); - } - - @Test void domesticCorrectionDoesNotUndoInternationalShipmentAndReopensCharges() { - jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2)); - jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(201,11,'9','PS-1','OVERSEAS',301,?, '{}','7')",LocalDateTime.now().minusHours(2)); - jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(201,101,10,'carrier','T1','{}')"); - var receipt=receive(Leg.DOMESTIC,101L,10,0,0,"d1").receipts().get(0);finish(Leg.DOMESTIC); - jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11"); - correct(receipt.id(),Leg.DOMESTIC,101L,8,2); - assertThat(jdbc.queryForObject("SELECT overseas_shipped_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull(); - assertThat(jdbc.queryForObject("SELECT domestic_received_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull(); - assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull(); - assertThat(jdbc.queryForObject("SELECT quantity FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L); - assertThat(jdbc.queryForObject("SELECT SUM(good_quantity) FROM ll_procurement_receipt WHERE active=1",Long.class)).isEqualTo(8L); - verifyNoInteractions(finance); - } - @Test void overseasShipmentRejectsReceiptCorrectionWithoutChangingOriginalFacts() { - var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0); - jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=CURRENT_TIMESTAMP WHERE order_id=11"); - long before=version(); - assertThatThrownBy(()->correct(receipt.id(),Leg.OVERSEAS,null,8,2)).hasMessageContaining("本段已实际交寄"); - assertThat(service.read("9","PO-1").receipts()).containsExactly(receipt); - assertThat(version()).isEqualTo(before); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command WHERE action='CORRECT_RECEIPT'",Integer.class)).isZero(); - } - - @Test void customerShipmentRejectsDomesticCorrectionWithoutReopeningReceiptOrCharges() { - jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2)); - jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(201,11,'9','PS-1','OVERSEAS',301,?, '{}','7')",LocalDateTime.now().minusHours(2)); - jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(201,101,10,'carrier','T1','{}')"); - var receipt=receive(Leg.DOMESTIC,101L,10,0,0,"d1").receipts().get(0);finish(Leg.DOMESTIC); - jdbc.update("UPDATE ll_procurement_flow SET customer_shipped_at=CURRENT_TIMESTAMP,charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11"); - long before=version(); - assertThatThrownBy(()->correct(receipt.id(),Leg.DOMESTIC,101L,8,2)).hasMessageContaining("本段已实际交寄"); - assertThat(service.read("9","PO-1").receipts()).containsExactly(receipt); - assertThat(version()).isEqualTo(before); - assertThat(jdbc.queryForObject("SELECT domestic_received_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull(); - assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull(); - assertThat(jdbc.queryForObject("SELECT quantity FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L); - } - - private ProcurementPhysicalService.Snapshot correct(long id,Leg leg,Long source,long good,long damaged) { - return tx.execute(s->service.correctReceipt("9","PO-1","7",id,new Correction(new Receipt(version(),"correction",leg, - leg==Leg.OVERSEAS?91L:null,source,good,damaged,0,LocalDateTime.now().minusHours(1),"evidence-backed correction"),1001))); - } - private boolean attemptReceipt(CountDownLatch start,String key)throws Exception { - if(!start.await(5,TimeUnit.SECONDS))throw new AssertionError("timeout"); - try{tx.execute(s->service.receive("9","PO-1","7",new Receipt(0L,key,Leg.OVERSEAS,91L,null,10,0,0,LocalDateTime.now().minusHours(1),"actual")));return true;} - catch(ResponseStatusException e){assertThat(e.getStatusCode().value()).isEqualTo(409);return false;} - } - private long version(){return orders.findNo("9","PO-1").getVersion();} - private ProcurementPhysicalService.Snapshot receive(Leg leg,Long source,long good,long damaged,long extra,String key){return tx.execute(s->service.receive("9","PO-1","7",new Receipt(version(),key,leg,leg==Leg.OVERSEAS?91L:null,source,good,damaged,extra,LocalDateTime.now().minusHours(1),"actual receipt")));} - private ProcurementPhysicalService.Snapshot finish(Leg leg){return tx.execute(s->service.finishReceipt("9","PO-1","7",new FinishReceipt(version(),"finish-"+leg+"-"+version(),leg,"verified")));} - private ProcurementPhysicalService.Snapshot pack(List cartons){return tx.execute(s->service.savePacking("9","PO-1","7",new Packing(version(),"packing-"+version(),cartons,"actual packing")));} - private Carton carton(String no,Source... sources){return new Carton(no,new BigDecimal("20.00"),new BigDecimal("30.00"),new BigDecimal("40.00"),new BigDecimal("5.125"),List.of(sources));} - private void dispose(Long receipt,String kind,long quantity,String decision){tx.execute(s->service.dispose("9","PO-1","7",new Disposition(version(),"disposition-"+kind,Leg.OVERSEAS,null,receipt,kind,quantity,decision,1001,"customer and physical proof")));} -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java deleted file mode 100644 index 86d040df2..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java +++ /dev/null @@ -1,184 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse; -import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; -import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*; -import com.baiyue.lastleg.procurement.entity.*; -import com.baiyue.lastleg.procurement.mapper.*; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mybatis.spring.SqlSessionTemplate; -import org.springframework.http.HttpStatus; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.DataSourceTransactionManager; -import org.springframework.jdbc.datasource.DriverManagerDataSource; -import org.springframework.transaction.support.TransactionTemplate; -import org.springframework.web.server.ResponseStatusException; -import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDateTime; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicReference; -import java.util.regex.Pattern; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -class ProcurementPurchasePersistenceTest { - JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders; - ProcurementLifecycleService service;ProcurementFinanceService finance; - OverseasFeeCurrencyConverter converter;ProcurementWarehouseDirectory warehouses; - @BeforeEach void setup() throws Exception { - var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa",""); - jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); - String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); - for(String table:new String[]{"ll_overseas_procurement_order","ll_procurement_flow","ll_procurement_command","ll_procurement_budget_authorization"}) { - var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema); - assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); - } - var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class); - var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); - orders=new SqlSessionTemplate(factory.getObject()).getMapper(OverseasProcurementOrderMapper.class); - var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1"); - order.setPurchaseCategory("sample");order.setProductName("Sample");order.setProductSpecification("A");order.setPurchaseQuantity(10L); - order.setBudgetUnitPrice(new BigDecimal("10"));order.setCurrency("CNY");order.setContactName("buyer");order.setContactPhone("13800000000"); - order.setStatus("WAIT_PURCHASE");order.setReviewedAt(LocalDateTime.now().minusDays(3));order.setVersion(0L); - order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(0L);orders.insert(order); - var files=mock(OverseasProcurementFileMapper.class);var file=new OverseasProcurementFile();file.setCategory("VOUCHER"); - when(files.selectOne(any())).thenReturn(file); - finance=mock(ProcurementFinanceService.class);var payment=new AtomicReference(); - when(finance.current(eq(11L),eq(ProcurementFeePurpose.SAMPLE_PAYMENT),eq("SAMPLE_PAYMENT"))).thenAnswer(i->payment.get()); - when(finance.confirm(anyString(),anyString(),anyString(),any())).thenAnswer(i->{ - var fee=new OverseasProcurementFee();fee.setFeeNo("PF-1");fee.setWalletStatus("FREEZE_PENDING");payment.set(fee);return fee; - }); - converter=mock(OverseasFeeCurrencyConverter.class); - when(converter.convert(anyString(),any(),any())).thenAnswer(i->new OverseasFeeCurrencyConverter.Conversion(i.getArgument(0),i.getArgument(1),BigDecimal.ONE,"2026-10",i.getArgument(1))); - warehouses=mock(ProcurementWarehouseDirectory.class); - service=new ProcurementLifecycleService(orders,files,warehouses,finance,converter,jdbc,new ObjectMapper().findAndRegisterModules()); - } - - @Test void purchaseRecordsFactAndChargesOnceWithoutWaitingForInbound() { - Purchase request=purchase("80","Sample"); - var result=tx.execute(s->service.recordPurchase("9","PO-1","7",request)); - assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isTrue(); - assertThat(result.status()).isEqualTo("WAIT_OVERSEAS_RECEIPT");assertThat(result.version()).isEqualTo(1); - assertThat(result.purchase().walletStatus()).isEqualTo("FREEZE_PENDING"); - assertThat(service.flow(11).get("purchased_at")).isInstanceOf(LocalDateTime.class); - assertThat(service.flow(11).get("purchase_confirmed_at")).isInstanceOf(LocalDateTime.class); - tx.execute(s->service.recordPurchase("9","PO-1","7",request)); - verify(finance).confirm(eq("9"),eq("PO-1"),eq("7"),argThat(r->r.originalAmount().compareTo(new BigDecimal("80"))==0)); - assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isZero(); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command",Integer.class)).isEqualTo(1); - } - - @Test void overBudgetFactSurvivesUntilAuthorizedAndCanThenConfirm() { - var result=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("120","Sample"))); - assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isFalse(); - assertThat(result.purchase().issue()).contains("超过");verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); - tx.execute(s->service.authorizeBudget("9","PO-1","8",new Budget(1L,"budget-1",new BigDecimal("130"),"CNY",1,"customer approval"))); - var confirmed=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(2L,"confirm-1","authorized"))); - assertThat(confirmed.purchase().confirmed()).isTrue();assertThat(confirmed.purchase().issue()).isNull(); - assertThat(jdbc.queryForObject("SELECT previous_amount FROM ll_procurement_budget_authorization",BigDecimal.class)).isEqualByComparingTo("100"); - assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("120"); - } - - @Test void missingRateKeepsActualPurchaseAndDoesNotInventCharge() { - when(converter.convert(anyString(),any(),any())).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST,"rate missing")); - var result=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); - assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isFalse(); - assertThat(result.purchase().issue()).contains("汇率");verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); - } - - @Test void amountBeyondSourceCapacityKeepsPurchaseFactWithoutCharging() { - jdbc.update("UPDATE ll_overseas_procurement_order SET budget_unit_price=9999999999999999 WHERE id=11"); - var request=purchase("100000000000000.00","Sample"); - var result=tx.execute(s->service.recordPurchase("9","PO-1","7",request)); - assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isFalse(); - assertThat(result.purchase().issue()).contains("超出", "已保留真实购买事实"); - assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("100000000000000.00"); - tx.execute(s->service.recordPurchase("9","PO-1","7",request)); - verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command",Integer.class)).isEqualTo(1); - } - - @Test void wrongGoodsCannotBeAuthorizedByIncreasingBudget() { - var result=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Wrong item"))); - assertThat(result.purchase().issue()).contains("商品"); - tx.execute(s->service.authorizeBudget("9","PO-1","8",new Budget(1L,"budget-1",new BigDecimal("200"),"CNY",1,"customer approval"))); - var confirmed=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(2L,"confirm-1","authorized"))); - assertThat(confirmed.purchase().confirmed()).isFalse();verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); - } - - @Test void unreviewedPurchaseFactCannotConfirmOrCharge() { - jdbc.update("UPDATE ll_overseas_procurement_order SET reviewed_at=NULL,status='PENDING_REVIEW' WHERE id=11"); - assertThatThrownBy(()->tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")))) - .isInstanceOf(ResponseStatusException.class).hasMessageContaining("简化采购"); - assertThat(service.flow(11)).isEmpty(); - assertThat(orders.findNo("9","PO-1").getVersion()).isZero(); - verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); - } - - @Test void missingBudgetCannotConfirmOrInventACharge() { - jdbc.update("UPDATE ll_overseas_procurement_order SET budget_unit_price=NULL WHERE id=11"); - var recorded=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); - assertThat(recorded.purchase().recorded()).isTrue(); - assertThat(recorded.purchase().confirmed()).isFalse(); - assertThat(recorded.purchase().issue()).contains("预算尚未明确"); - assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("80"); - verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); - } - - @Test void anotherPurchaseKeyCannotAppendOrOverwriteRecordedPurchase() { - tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); - var another=new Purchase(1L,"purchase-2","Sample","A",10,new BigDecimal("90"),"CNY",LocalDateTime.now().minusDays(1),LocalDateTime.now().plusDays(3),1,"additional purchase"); - assertThatThrownBy(()->tx.execute(s->service.recordPurchase("9","PO-1","7",another))).hasMessageContaining("不能再次购买"); - assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("80"); - verify(finance,times(1)).confirm(anyString(),anyString(),anyString(),any()); - } - - @Test void cancellationCannotErasePurchasedFacts() { - tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); - assertThatThrownBy(()->tx.execute(s->service.cancel("9","PO-1","7",new Command(1L,"cancel","reason")))).hasMessageContaining("购买后不能取消"); - assertThat(service.read("9","PO-1").purchase().recorded()).isTrue(); - } - - @Test void beforePurchaseCancellationIsIdempotentAndPreventsNewPurchases() { - var request=new Command(0L,"cancel","customer cancellation"); - tx.execute(s->service.cancel("9","PO-1","7",request));tx.execute(s->service.cancel("9","PO-1","7",request)); - assertThat(service.read("9","PO-1").status()).isEqualTo("CANCELLED"); - assertThatThrownBy(()->tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")))).isInstanceOf(ResponseStatusException.class); - verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); - } - - @Test void warehouseSelectionUsesAuthorityAndCannotChangeAfterFirstReceipt() { - when(warehouses.required(91)).thenReturn(new ReceivingWarehouse(91L,"LAX","receiving","US","ACTIVE")); - tx.execute(s->service.selectWarehouse("9","PO-1","7",new Warehouse(0L,"warehouse",91,"plan"))); - assertThat(orders.findNo("9","PO-1").getPurchaseWarehouseName()).isEqualTo("receiving"); - jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=91 WHERE order_id=11"); - assertThatThrownBy(()->tx.execute(s->service.selectWarehouse("9","PO-1","7",new Warehouse(1L,"warehouse-2",92,"change")))).hasMessageContaining("首次实收"); - verify(warehouses,never()).required(92); - } - - @Test void otherCustomerCannotRecordOrReadThisOrder() { - assertThatThrownBy(()->tx.execute(s->service.recordPurchase("10","PO-1","7",purchase("80","Sample")))).hasMessageContaining("当前客户"); - assertThatThrownBy(()->service.read("10","PO-1")).hasMessageContaining("当前客户"); - verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); - } - - @Test void reconfirmingRecordedPurchaseDoesNotRegressPhysicalProgress() { - tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); - jdbc.update("UPDATE ll_overseas_procurement_order SET status='INTERNATIONAL_TRANSIT' WHERE id=11"); - var result=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(1L,"confirm-again","checked"))); - assertThat(result.status()).isEqualTo("INTERNATIONAL_TRANSIT"); - verify(finance,times(1)).confirm(anyString(),anyString(),anyString(),any()); - } - - private Purchase purchase(String amount,String name) { - return new Purchase(0L,"purchase-1",name,"A",10,new BigDecimal(amount),"CNY",LocalDateTime.now().minusDays(1).withNano(0), - LocalDateTime.now().plusDays(3).withNano(0),1,"supplier purchase"); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java index 696a860ba..9deb5b7e7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java @@ -1,106 +1,65 @@ package com.baiyue.lastleg.procurement.service; -import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.service.OverseasManualFeeService; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; +import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.*; -import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +/** 仅创建六张正式采购表,真实 Mapper 验证详情、审核、文件和客户费用不会访问任何旧表。 */ class ProcurementQueryServiceTest { - final ProcurementControlPersistenceTest fixture=new ProcurementControlPersistenceTest(); - ProcurementQueryService query;ProcurementTransportService transport;OverseasProcurementService base; - ObjectMapper json=new ObjectMapper().findAndRegisterModules(); - @BeforeEach void setup()throws Exception { - fixture.setup();var jdbc=fixture.jdbc; - String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); - for(String name:List.of("ll_procurement_budget_authorization","ll_procurement_manifest","ll_procurement_carton_source","ll_procurement_label_set","ll_procurement_label_item")) { - var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);assertThat(m.find()).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); - } - var physical=new ProcurementPhysicalService(fixture.lifecycle,fixture.warehouses,fixture.finance,jdbc); - org.springframework.test.util.ReflectionTestUtils.setField(physical, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); - transport=new ProcurementTransportService(fixture.lifecycle,physical,fixture.finance,fixture.warehouses,jdbc,json); - org.springframework.test.util.ReflectionTestUtils.setField(transport, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); - base=mock(OverseasProcurementService.class); - when(base.detail("9","PO-1")).thenReturn(new OverseasProcurementResponses.Detail(null,null,null,null,null,null,"internal remark","operator","reviewer","internal review",null,List.of(),List.of(new OverseasProcurementResponses.FileInfo(1L,"EXCEPTION",1001L,"private.pdf","application/pdf",10,null)),List.of())); - query=new ProcurementQueryService(fixture.lifecycle,physical,transport,fixture.finance,mock(ProcurementAdjustmentService.class),fixture.service,base,jdbc); - org.springframework.test.util.ReflectionTestUtils.setField(query, "manualFees", mock(com.baiyue.lastleg.financefee.service.OverseasManualFeeService.class)); + ProcurementWorkflowPersistenceTest fixture; + ProcurementQueryService query; + OverseasProcurementService orders; + OverseasManualFeeService fees; + ProcurementWarehouseDirectory warehouses; + @BeforeEach void setup() throws Exception { + fixture=new ProcurementWorkflowPersistenceTest();fixture.setup(); + orders=new OverseasProcurementService(); + ReflectionTestUtils.setField(orders,"orders",fixture.orders); + ReflectionTestUtils.setField(orders,"histories",fixture.histories); + ReflectionTestUtils.setField(orders,"procurementFiles",fixture.files); + ReflectionTestUtils.setField(orders,"objectMapper",new ObjectMapper().findAndRegisterModules()); + warehouses=mock(ProcurementWarehouseDirectory.class); + ReflectionTestUtils.setField(orders,"warehouseDirectory",warehouses); + fees=mock(OverseasManualFeeService.class); + when(fees.procurementFees("9",11L)).thenReturn(List.of()); + query=new ProcurementQueryService(fixture.repository,orders,fees); } - @Test void customerResponseContainsStructuredFactsAndOnlyApprovedPublicSummaries()throws Exception { - fixture.jdbc.update("UPDATE ll_procurement_flow SET purchase_issue='private purchase diagnosis' WHERE order_id=11"); - fixture.jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,confirmed_at,created_by) VALUES(301,11,'9',1,'CONFIRMED',CURRENT_TIMESTAMP,'7')"); - fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'BOX-A',20,30,40,5.125,10)"); - fixture.jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(401,11,'9','PS-1','OVERSEAS',301,CURRENT_TIMESTAMP,'{}','7')"); - fixture.jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(401,201,10,'Express','TRACK-1','{\"privateFile\":1001}')"); - fixture.jdbc.update("INSERT INTO ll_procurement_control_note(id,order_id,customer_id,note_no,target_type,blocking,voucher_file_id,reason,client_summary,created_by) VALUES(501,11,'9','PN-1','PURCHASE',0,1001,'private evidence','Customer-approved summary','internal-operator')"); - fixture.jdbc.update("INSERT INTO ll_procurement_budget_authorization(id,order_id,customer_id,previous_amount,previous_currency,authorized_amount,currency,voucher_file_id,reason,reviewed_by) VALUES(601,11,'9',100,'CNY',150,'CNY',1001,'private budget discussion','reviewer')"); - var fee=new OverseasProcurementFee();fee.setId(1L);fee.setFeeNo("PF-1");fee.setPurpose("SAMPLE_PAYMENT");fee.setFeeItemName("样品货款");fee.setFeeVersion(1);fee.setActive(true);fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED");fee.setWalletErrorMessage("private database error");fee.setVoucherFileName("private.pdf");fee.setVoucherFileId(1001L);fee.setWalletLedgerId("private-ledger"); - when(fixture.finance.all(11)).thenReturn(List.of(fee)); - var result=query.customer("9","PO-1");assertThat(result.budget().totalAmount()).isEqualByComparingTo("150");assertThat(result.purchaseNeedsAttention()).isTrue(); - assertThat(result.boxes()).singleElement().satisfies(b->assertThat(b.cartonNo()).isEqualTo("BOX-A")); - assertThat(result.shipments()).singleElement().satisfies(s->assertThat(s.trackingNo()).isEqualTo("TRACK-1")); - var output=json.writeValueAsString(result);assertThat(output).contains("BOX-A","TRACK-1","PF-1","Customer-approved summary") - .doesNotContain("private","voucherFileId","walletLedgerId","createdBy","internal-operator","reason","purchaseFacts"); - verifyNoInteractions(base); + @Test void currentDetailsReadOnlySixTablesAndReturnNoLegacyActionsOrPhysicalFacts() { + fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET budget_unit_price=15,purchase_warehouse_name='洛杉矶仓',remark='internal note' WHERE id=11"); + var platform=query.platform("9","CG-1",Set.of("platform:overseas-business:purchase:buy")); + assertThat(platform.customerView().budget().totalAmount()).isEqualByComparingTo("150"); + assertThat(platform.customerView().order().actualWarehouseName()).isEqualTo("洛杉矶仓"); + assertThat(platform.customerView().boxes()).isEmpty();assertThat(platform.customerView().shipments()).isEmpty(); + assertThat(platform.actions()).isEmpty();assertThat(platform.operations()).isEmpty(); + assertThat(platform.files()).hasSize(3);assertThat(platform.internalRemark()).isEqualTo("internal note"); + assertThat(orders.detail("9","CG-1").fees()).isEmpty(); } - @Test void platformCombinesDataAndAuthoritativeShippingBlockReasonsWithoutGrantingOtherActions() { - var permissions=Set.of("platform:overseas-business:purchase:receive-overseas","platform:overseas-business:purchase:ship-customer"); - var result=query.platform("9","PO-1",permissions); - assertThat(result.internalRemark()).isEqualTo("internal remark");assertThat(result.files()).hasSize(1); - assertThat(result.controls().remaining()).hasSize(1); - assertThat(result.actions()).filteredOn(a->a.key().equals("RECEIVE_OVERSEAS")).singleElement().satisfies(a->assertThat(a.allowed()).isTrue()); - assertThat(result.actions()).filteredOn(a->a.key().equals("SHIP_CUSTOMER")).singleElement().satisfies(a->{assertThat(a.allowed()).isFalse();assertThat(a.reason()).contains("样品货款");}); - assertThat(result.actions()).filteredOn(a->a.key().equals("FINANCE_ADJUST")).singleElement().satisfies(a->assertThat(a.reason()).isEqualTo("无此操作权限")); + @Test void reviewAndRemarkUpdatesNoLongerRequireLegacyFeeOrDirectionTables() { + fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET status='PENDING_REVIEW' WHERE id=11"); + when(warehouses.required(91)).thenReturn(new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(91L,"LAX","洛杉矶仓","US","ACTIVE")); + var result=fixture.tx.execute(s->orders.review("9","CG-1","7",new OverseasProcurementRequests.Review(0L,"APPROVE","review",null,91L))); + assertThat(result.summary().status()).isEqualTo("RECEIVING");assertThat(result.histories()).hasSize(1); + assertThat(query.customer("9","CG-1").order().actualWarehouseName()).isEqualTo("洛杉矶仓"); } - @Test void anotherCustomerCannotLoadEvenPublicProjection() { - assertThatThrownBy(()->query.customer("10","PO-1")).hasMessageContaining("当前客户");verifyNoInteractions(fixture.finance,base); + @Test void customerCannotReadAnotherCustomersOrderOrInternalDiagnostics() throws Exception { + assertThatThrownBy(()->query.customer("10","CG-1")).hasMessageContaining("当前客户"); + verifyNoInteractions(fees); + var fee=fee("9",11L,"RECEIVABLE");fee.setWalletErrorMessage("private error");fee.setReviewReason("private reason"); + when(fees.procurementFees("9",11L)).thenReturn(List.of(fee,fee("10",11L,"RECEIVABLE"),fee("9",12L,"RECEIVABLE"),fee("9",11L,"PAYABLE"))); + var result=query.customer("9","CG-1"); + assertThat(result.fees()).singleElement().satisfies(row->{assertThat(row.walletStatus()).isEqualTo("FREEZE_PENDING");assertThat(row.sourceLinked()).isFalse();}); + assertThat(new ObjectMapper().findAndRegisterModules().writeValueAsString(result)).doesNotContain("private", "walletErrorMessage", "reviewReason", "internalRemark"); } - /** 共用费用只公开当前采购单应收和资金结果,过滤其他客户、单据及内部诊断。 */ - @Test void customerReadsOnlyOwnedManualProcurementFeeResults() throws Exception { - var manual = mock(com.baiyue.lastleg.financefee.service.OverseasManualFeeService.class); - org.springframework.test.util.ReflectionTestUtils.setField(query, "manualFees", manual); - var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource(); - fee.setSourceFeeId(901L); fee.setSourceVersion(1); fee.setCustomerId("9"); fee.setBusinessId(11L); - fee.setBusinessType("OVERSEAS_PROCUREMENT"); fee.setDirection("RECEIVABLE"); fee.setSourceStatus("ACTIVE"); - fee.setFeeItemCode("SERVICE"); fee.setFeeItemName("采购服务费"); fee.setReviewStatus("PENDING"); - fee.setWalletStatus("FREEZE_PENDING"); fee.setWalletErrorMessage("private funding diagnosis"); - fee.setAmount(BigDecimal.TEN); fee.setCurrency("CNY"); fee.setReviewReason("private review reason"); - var other = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource(); - other.setBusinessType("OVERSEAS_PROCUREMENT"); other.setDirection("RECEIVABLE"); - other.setCustomerId("10"); other.setBusinessId(11L); - when(manual.procurementFees("9", 11L)).thenReturn(List.of(fee, other)); - var result = query.customer("9", "PO-1"); - assertThat(result.fees()).singleElement().satisfies(row -> { - assertThat(row.feeNo()).isEqualTo("901"); - assertThat(row.walletStatus()).isEqualTo("FREEZE_PENDING"); - assertThat(row.sourceLinked()).isFalse(); - }); - assertThat(json.writeValueAsString(result)).doesNotContain("private", "walletErrorMessage", "reviewReason"); + private OverseasFeeSource fee(String customer,long order,String direction) { + var fee=new OverseasFeeSource();fee.setSourceFeeId(901L);fee.setSourceVersion(1);fee.setCustomerId(customer);fee.setBusinessId(order); + fee.setBusinessType("OVERSEAS_PROCUREMENT");fee.setDirection(direction);fee.setSourceStatus("ACTIVE");fee.setReviewStatus("PENDING"); + fee.setFeeItemCode("SERVICE");fee.setFeeItemName("采购服务费");fee.setWalletStatus("FREEZE_PENDING");fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");return fee; } - @Test void draftAndSupersededPackingAreNotDisplayedAsConfirmedCustomerBoxes() { - fixture.jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(301,11,'9',1,'SUPERSEDED','7'),(302,11,'9',2,'DRAFT','7')"); - fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'OLD',20,30,40,5,10),(202,11,302,'DRAFT',20,30,40,5,10)"); - assertThat(query.customer("9","PO-1").boxes()).isEmpty(); - } - - /** 新流程不再提示旧购买、装箱和两段核费动作。 */ - @Test void simplifiedWorkflowDoesNotExposeLegacySampleActions() { - fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET status='RECEIVING' WHERE id=11"); - assertThat(query.platform("9", "PO-1", Set.of("platform:overseas-business:purchase:buy")).actions()).isEmpty(); - } - - @Test void exportProjectionExposesDirectionAndLabelsWithoutRewritingHistoricalLegs() { - fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET procurement_direction=? WHERE id=11",ProcurementDirection.EXPORT); - var result=query.platform("9","PO-1",Set.of("platform:overseas-business:purchase:receive-overseas")); - assertThat(result.customerView().order().procurementDirection()).isEqualTo(ProcurementDirection.EXPORT); - assertThat(result.customerView().progress()).extracting(ProcurementQueryService.Progress::title).contains("国内收货完成","海外收货完成","海外费用核定"); - assertThat(result.actions()).filteredOn(a -> a.key().equals("RECEIVE_OVERSEAS")).singleElement().satisfies(a -> assertThat(a.label()).isEqualTo("登记国内实收")); - } - } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java deleted file mode 100644 index 5952dc31b..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java +++ /dev/null @@ -1,204 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg; -import com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.*; -import com.baiyue.lastleg.procurement.entity.*; -import com.baiyue.lastleg.procurement.mapper.*; -import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mybatis.spring.SqlSessionTemplate; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.DataSourceTransactionManager; -import org.springframework.jdbc.datasource.DriverManagerDataSource; -import org.springframework.transaction.support.TransactionTemplate; -import org.springframework.web.server.ResponseStatusException; -import java.math.BigDecimal; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.*; -import java.util.concurrent.*; -import java.util.regex.Pattern; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; -import static org.mockito.ArgumentMatchers.*; - -class ProcurementTransportPersistenceTest { - JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders; - ProcurementWarehouseDirectory warehouses; - ProcurementTransportService service;ProcurementFinanceService finance;OverseasProcurementFee estimate; - final Address warehouse=new Address("Overseas","1234567890","US operating address"); - final Address domestic=new Address("Domestic","13800000000","Shanghai receiving address"); - final Address customer=new Address("Customer","13900000000","Hangzhou customer address"); - final LocalDateTime start=LocalDateTime.now().minusHours(2); - @BeforeEach void setup()throws Exception { - var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa",""); - // H2 CAST(varchar AS JSON) creates a JSON string; FORMAT JSON matches MySQL's object parsing. - jdbc=new JdbcTemplate(ds){@Override public int update(String sql,Object...args){return super.update(sql.replace("CAST(? AS JSON)","? FORMAT JSON"),args);}}; - tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); - String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); - for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_control_note","ll_procurement_flow","ll_procurement_command", - "ll_procurement_receipt","ll_procurement_manifest","ll_procurement_carton","ll_procurement_carton_source","ll_procurement_shipment","ll_procurement_shipment_item", - "ll_procurement_label_set","ll_procurement_label_item","ll_procurement_estimate_review","ll_procurement_repack")) { - var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema); - assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); - } - var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class); - var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject()); - orders=session.getMapper(OverseasProcurementOrderMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class); - var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample"); - order.setProductName("Sample");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY"); - order.setContactName(customer.name());order.setContactPhone(customer.phone());order.setReceivingAddress(customer.address());order.setStatus("WAIT_LABELS");order.setReviewedAt(start.minusDays(1)); - order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(10L);orders.insert(order); - jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,purchased_at,purchased_quantity,purchase_confirmed_at,actual_warehouse_id,overseas_received_at,packing_version) VALUES(11,'9',?,10,?,91,?,1)",start.minusDays(1),start.minusDays(1),start); - jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,warehouse_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(101,11,'9','PR-1','OVERSEAS',91,10,0,0,?,'7')",start); - jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(301,11,'9',1,'CONFIRMED','7')"); - jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'C1',20,30,40,6.125,6),(202,11,301,'C2',20,30,40,4.125,4)"); - jdbc.update("INSERT INTO ll_procurement_carton_source VALUES(201,101,6),(202,101,4)"); - jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','LABEL',1001,'labels.pdf','application/pdf',10,'7'),(502,11,'9','EXCEPTION',1002,'proof.pdf','application/pdf',10,'7')"); - warehouses=mock(ProcurementWarehouseDirectory.class);when(warehouses.sender(91)).thenReturn(warehouse); - finance=mock(ProcurementFinanceService.class);var payment=new OverseasProcurementFee();payment.setWalletStatus("SETTLED"); - when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true); - estimate=new OverseasProcurementFee();estimate.setId(801L);estimate.setAmount(BigDecimal.ZERO);estimate.setWalletStatus("ZERO"); - when(finance.current(11,ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE")).thenReturn(estimate); - var json=new ObjectMapper().findAndRegisterModules(); - var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json); - var physical=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc); - org.springframework.test.util.ReflectionTestUtils.setField(physical, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); - service=new ProcurementTransportService(lifecycle,physical,finance,warehouses,jdbc,json); - org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); - } - @Test void entireBatchShipsOnceWithFrozenAddressLabelsAndSources() { - prepare();var request=shipRequest(Leg.OVERSEAS,"ship"); - var result=tx.execute(s->service.ship("9","PO-1","7",request)); - assertThat(result.status()).isEqualTo("INTERNATIONAL_TRANSIT"); - assertThat(result.shipments()).hasSize(1);assertThat(result.shipments().get(0).id()).isPositive(); - assertThat(tx.execute(s->service.ship("9","PO-1","7",request)).shipments()).hasSize(1); - assertThatThrownBy(()->ship(Leg.OVERSEAS,"another")).hasMessageContaining("本段已实际交寄"); - assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L); - String address=jdbc.queryForObject("SELECT address_snapshot FROM ll_procurement_shipment",String.class); - assertThat(address).contains("US operating address","Shanghai receiving address"); - assertThat(jdbc.queryForList("SELECT label_snapshot FROM ll_procurement_shipment_item",String.class)).allSatisfy(v->assertThat(v).contains("receiptId","fileId","cartonNo")); - verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); - } - @Test void crossOrderFilesWrongPagesMissingCartonsAndUnappliedCartonsCannotShip() { - assertThatThrownBy(()->save(Leg.OVERSEAS,List.of(new Label(201,999,1,"carrier","T1"),label(202)))).hasMessageContaining("凭证不属于"); - assertThatThrownBy(()->save(Leg.OVERSEAS,List.of(label(201)))).hasMessageContaining("全部可寄"); - jdbc.update("UPDATE ll_overseas_procurement_file SET content_type='image/png' WHERE file_id=1001"); - assertThatThrownBy(()->save(Leg.OVERSEAS,List.of(new Label(201,1001,2,"carrier","T1"),label(202)))).hasMessageContaining("页码"); - save(Leg.OVERSEAS,List.of(label(201),label(202)));review(BigDecimal.ZERO); - assertThatThrownBy(()->ship(Leg.OVERSEAS,"before-apply")).hasMessageContaining("贴单"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isZero(); - } - @Test void replacementLabelNeedsNewReviewAndNewAppliedFactsWithoutAnotherHold() { - prepare();save(Leg.OVERSEAS,List.of(label(201),label(202))); - assertThatThrownBy(()->ship(Leg.OVERSEAS,"stale")).hasMessageContaining("尚未核对有效预估"); - review(BigDecimal.ZERO); - assertThatThrownBy(()->ship(Leg.OVERSEAS,"not-applied")).hasMessageContaining("贴单"); - apply();ship(Leg.OVERSEAS,"ship"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_label_set",Integer.class)).isEqualTo(2); - verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); - } - @Test void unresolvedManagementBlockerStopsBothActionEligibilityAndActualHandoff() { - prepare(); - jdbc.update("INSERT INTO ll_procurement_control_note(id,order_id,customer_id,note_no,target_type,blocking,voucher_file_id,reason,created_by) VALUES(701,11,'9','PN-1','PURCHASE',1,1002,'customer handling unresolved','7')"); - assertThat(service.shipmentBlockReason(orders.findNo("9","PO-1"),Leg.OVERSEAS)).contains("阻断异常"); - assertThatThrownBy(()->ship(Leg.OVERSEAS,"blocked")).hasMessageContaining("阻断异常"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isZero(); - jdbc.update("UPDATE ll_procurement_control_note SET resolved_at=CURRENT_TIMESTAMP,resolved_by='7',resolution_reason='resolved',resolution_voucher_id=1002 WHERE id=701"); - assertThat(service.shipmentBlockReason(orders.findNo("9","PO-1"),Leg.OVERSEAS)).isNull(); - assertThat(ship(Leg.OVERSEAS,"after-resolution").shipments()).hasSize(1); - } - @Test void changedPriceReleasesFirstAndPendingMoneyStillBlocksShipping() { - prepare();estimate.setAmount(new BigDecimal("100.00"));estimate.setWalletStatus("FROZEN"); - review(new BigDecimal("120.00"));verify(finance).releaseEstimate("9","PO-1",801L); - verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); - estimate.setWalletStatus("RELEASE_PENDING"); - assertThatThrownBy(()->review(new BigDecimal("120.00"))).hasMessageContaining("原预估资金未完成"); - assertThatThrownBy(()->ship(Leg.OVERSEAS,"pending")).hasMessageContaining("预估国际运费"); - } - @Test void differentRequestsFromTwoOperatorsCannotCreateTwoShipments()throws Exception { - prepare();long version=version();int labels=labelVersion(Leg.OVERSEAS);var executor=Executors.newFixedThreadPool(2);var startGate=new CountDownLatch(1); - try { - var futures=new ArrayList>(); - for(String key:List.of("one","two"))futures.add(executor.submit(()->{startGate.await();try{tx.execute(s->service.ship("9","PO-1",key,new Ship(version,key,Leg.OVERSEAS,labels,List.of(201L,202L),start.plusHours(1),"actual")));return true;}catch(ResponseStatusException e){assertThat(e.getStatusCode().value()).isEqualTo(409);return false;}})); - startGate.countDown();int count=0;for(var future:futures)if(future.get(10,TimeUnit.SECONDS))count++;assertThat(count).isEqualTo(1); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isEqualTo(1); - }finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,TimeUnit.SECONDS)).isTrue();} - } - @Test void domesticRepackInvalidatesOldLabelsWithoutChangingOriginalShipmentOrQuantity() { - domesticReceipts();save(Leg.DOMESTIC,List.of(label(201),label(202))); - tx.execute(s->service.repack("9","PO-1","7",new Repack(version(),"repack",201,new BigDecimal("25.00"),new BigDecimal("30.00"),new BigDecimal("40.00"),new BigDecimal("5.125"),1002,"damaged box"))); - var current=service.read("9","PO-1"); - assertThat(current.overseasCargo()).filteredOn(c->c.cartonId()==201).singleElement().satisfies(c->{ - assertThat(c.lengthCm()).isEqualByComparingTo("20");assertThat(c.quantity()).isEqualTo(6);assertThat(c.packagingRecordId()).isNull(); - }); - assertThat(current.domesticCargo()).filteredOn(c->c.cartonId()==201).singleElement().satisfies(c->{ - assertThat(c.lengthCm()).isEqualByComparingTo("25");assertThat(c.grossWeightKg()).isEqualByComparingTo("5.125"); - assertThat(c.quantity()).isEqualTo(5);assertThat(c.packagingRecordId()).isNotNull();assertThat(c.sources()).containsExactly(new com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Source(102,5)); - }); - assertThatThrownBy(()->ship(Leg.DOMESTIC,"stale-label")).hasMessageContaining("旧面单"); - assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull(); - save(Leg.DOMESTIC,List.of(label(201),label(202)));jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11"); - var result=ship(Leg.DOMESTIC,"domestic");assertThat(result.status()).isEqualTo("SHIPPED_TO_CUSTOMER"); - assertThat(jdbc.queryForObject("SELECT SUM(i.quantity) FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.leg='DOMESTIC'",Long.class)).isEqualTo(9L); - assertThat(jdbc.queryForObject("SELECT SUM(i.quantity) FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.leg='OVERSEAS'",Long.class)).isEqualTo(10L); - verify(finance).requireShipmentReady(11); - } - @Test void domesticHandoffRequiresConfirmedAndSettledChargesAndCustomerChangeEvidence() { - domesticReceipts(); - var badAddress=new Address("Changed","13600000000","Other street"); - assertThatThrownBy(()->tx.execute(s->service.saveLabels("9","PO-1","7",new Labels(version(),"changed-address",Leg.DOMESTIC,1,badAddress,null,List.of(label(201),label(202)),"change")))).hasMessageContaining("确认凭证"); - save(Leg.DOMESTIC,List.of(label(201),label(202))); - jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=11"); - assertThatThrownBy(()->ship(Leg.DOMESTIC,"unknown-charge")).hasMessageContaining("整单费用尚未确认"); - jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11"); - doThrow(ProcurementLifecycleService.conflict("未决资金请求")).when(finance).requireShipmentReady(11); - assertThatThrownBy(()->ship(Leg.DOMESTIC,"funds-pending")).hasMessageContaining("未决资金"); - assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment WHERE leg='DOMESTIC'",Integer.class)).isZero(); - } - private void domesticReceipts(){prepare();ship(Leg.OVERSEAS,"international");jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=?,charges_confirmed_at=? WHERE order_id=11",start.plusMinutes(70),start.plusMinutes(80)); - jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(102,11,'9','PR-2','DOMESTIC',201,5,1,0,?,'7'),(103,11,'9','PR-3','DOMESTIC',202,4,0,0,?,'7')",start.plusMinutes(65),start.plusMinutes(65));} - private void prepare(){save(Leg.OVERSEAS,List.of(label(201),label(202)));review(BigDecimal.ZERO);apply();} - private long version(){return orders.findNo("9","PO-1").getVersion();} - private int labelVersion(Leg leg){return jdbc.queryForObject("SELECT MAX(version) FROM ll_procurement_label_set WHERE order_id=11 AND leg=?",Integer.class,leg.name());} - private Label label(long carton){return new Label(carton,1001,1,"carrier","TRACK-"+carton);} - private ProcurementTransportService.Snapshot save(Leg leg,List