费用提交

This commit is contained in:
2026-10-27 18:27:56 +08:00
parent 423e40e6aa
commit f418f9a05f
56 changed files with 649 additions and 7898 deletions
+7 -3
View File
@@ -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;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。 `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-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#发布配套)。文件准备不代表已执行或部署。 `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 或部署。 车辆与司机档案照片复用:先执行 `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 | 表数 | | 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:| |---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 | | 国内 | `头程数据库统一增量-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` 拼写。 - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
@@ -108,7 +112,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 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、回填或删除测试存量。 编号规则见 `../../../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)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。 本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
@@ -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');
@@ -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;
@@ -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;
+2 -261
View File
@@ -1,5 +1,5 @@
-- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 -- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
-- 海外费用资金、仓租四表、审核人名称快照、采购退款请求幂等字段及唯一索引均在本文件最终建表定义中维护。 -- 海外费用资金、仓租四表、审核人名称快照及简化采购六表均在本文件最终建表定义中维护。
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 -- 国内 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) UNIQUE KEY uk_ll_outbound_pda_complete_key (customer_id,completion_idempotency_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务'; ) 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 ( CREATE TABLE ll_overseas_procurement_file (
id bigint NOT NULL COMMENT '主键ID', 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) UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; ) 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库存流水,不回填历史采购数量。 -- 采购实收、差异、装箱和两段整批发运事实。不产生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; 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; USE baiyue_lastleg;
-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。 -- 费用链目标更正快照与关联;资金流水仍唯一归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; USE baiyue_lastleg;
-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。 -- 单采购订单内的更正说明、阻断异常与关闭证据。不生成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; USE baiyue_lastleg;
@@ -5156,4 +4897,4 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
PRIMARY KEY (order_id,command_key) PRIMARY KEY (order_id,command_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计'; ) 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;
@@ -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 仅完成静态范围及防护契约检查。
@@ -26,6 +26,8 @@
## 验收范围 ## 验收范围
2026-10-27 旧采购模型已与新流程彻底分离:只保留主单、审核流水、采购附件和三张批次表。旧购买、装箱、面单、两段运输及采购专属资金接口退役,详情不再查询旧表;费用统一沿用共用海外费用审核链。[21 张旧采购表清理范围与脚本](procurement-table-cleanup.md)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。
- 创建采购单后为待审核,审核后直接收货中。 - 创建采购单后为待审核,审核后直接收货中。
- 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。 - 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。
- 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。 - 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。
@@ -72,7 +72,6 @@ public interface OverseasFeeBusinessMapper {
return switch (type) { return switch (type) {
case "OVERSEAS_INBOUND" -> new String[]{"ll_inbound_plan", "plan_no", "NULL", "NULL"}; 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 "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 "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 "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"}; case "LASTLEG_ORDER" -> new String[]{"ll_order", "order_no", "t.overseas_warehouse_id", "t.order_type"};
@@ -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<ProcurementChargeService.Snapshot> 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<java.util.List<ProcurementAdjustmentService.Result>> 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<ProcurementChargeService.Snapshot> 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<OverseasProcurementFee> 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<ProcurementChargeService.Snapshot> 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<ProcurementChargeService.Snapshot> 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<ProcurementChargeService.Snapshot> 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<ProcurementAdjustmentService.Result> 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<ProcurementAdjustmentService.Result> 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<OverseasProcurementFee> 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));
}
}
@@ -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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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));
}
}
@@ -15,7 +15,7 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Set; import java.util.Set;
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; import static com.baiyue.lastleg.procurement.controller.ProcurementPlatformIdentity.platform;
@RestController @RequiredArgsConstructor @RestController @RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") @RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
@@ -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<Outcome> 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<Outcome> 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<Outcome> 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<Outcome> 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<Outcome> 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<Outcome> 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());
}
}
@@ -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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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));
}
}
@@ -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());
}
}
@@ -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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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,"运输段与操作入口不一致");}
}
@@ -10,7 +10,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; 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 @RestController @RequiredArgsConstructor
@@ -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) { }
}
@@ -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) { }
}
@@ -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) { }
}
@@ -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) { }
}
@@ -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) { }
}
@@ -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;
}
@@ -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<OverseasProcurementFee> {
/** 调用方持有订单锁时,读取当前退款版本上界。 */
@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);
}
@@ -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);
}
@@ -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); }
}
@@ -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<OverseasProcurementFee>()
.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; }
}
@@ -9,9 +9,7 @@ import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile; 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.OverseasProcurementFileMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway; import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
@@ -56,10 +54,8 @@ public class OverseasProcurementService {
@Resource private OverseasProcurementOrderMapper orders; @Resource private OverseasProcurementOrderMapper orders;
@Resource private OverseasProcurementStatusHistoryMapper histories; @Resource private OverseasProcurementStatusHistoryMapper histories;
@Resource private ProcurementWarehouseDirectory warehouseDirectory; @Resource private ProcurementWarehouseDirectory warehouseDirectory;
@Resource private com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository directionRepository;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private OverseasProcurementFileMapper procurementFiles; @Resource private OverseasProcurementFileMapper procurementFiles;
@Resource private OverseasProcurementFeeMapper procurementFees;
@Resource private ProcurementFileGateway fileGateway; @Resource private ProcurementFileGateway fileGateway;
/** 创建客户采购订单,首次请求分配 CG 日流水。 */ /** 创建客户采购订单,首次请求分配 CG 日流水。 */
@@ -165,7 +161,7 @@ public class OverseasProcurementService {
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(), new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(), value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList(), files(order), fees(order)); value.getCreatedAt())).toList(), files(order), List.of());
} }
@Transactional @Transactional
@@ -202,16 +198,6 @@ public class OverseasProcurementService {
return new OverseasProcurementResponses.FileInfo(value.getId(), value.getCategory(), value.getFileId(), value.getFileName(), return new OverseasProcurementResponses.FileInfo(value.getId(), value.getCategory(), value.getFileId(), value.getFileName(),
value.getContentType(), Objects.requireNonNullElse(value.getSizeBytes(), 0L), value.getCreatedAt()); value.getContentType(), Objects.requireNonNullElse(value.getSizeBytes(), 0L), value.getCreatedAt());
} }
private List<OverseasProcurementResponses.Fee> fees(OverseasProcurementOrder order) {
return procurementFees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>()
.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 @Transactional
@@ -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<String> 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<OverseasProcurementFee> 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<Result> 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();
}
}
@@ -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<Fee> 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<Long> 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<String> 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());
}
}
@@ -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<Map<String,Object>> notes,
List<Remaining> remaining,List<Map<String,Object>> closures,List<Map<String,Object>> 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<String,Object>();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<String,Remaining> 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> 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<Remaining>();
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<Remaining> 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<String> 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<String,Object> noteView(Map<String,Object> 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);}
}
@@ -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); }
}
@@ -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; }
}
@@ -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<OverseasProcurementFee>()
.eq(OverseasProcurementFee::getOrderId, orderId).eq(OverseasProcurementFee::getPurpose, purpose.name())
.eq(OverseasProcurementFee::getFeeItemCode, code).eq(OverseasProcurementFee::getActive, true));
}
public List<OverseasProcurementFee> all(long orderId) {
return fees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>().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<OverseasProcurementFee> refunds = fees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>()
.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<OverseasProcurementFee> 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<Long> 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<OverseasProcurementFee> 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<OverseasProcurementFee>();
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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<OverseasProcurementFee>().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<OverseasProcurementFee>()
.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<OverseasProcurementFile>()
.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); }
}
@@ -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<String,Object> 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<String> categories) {
var file=files.selectOne(new LambdaQueryWrapper<OverseasProcurementFile>().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);}
}
@@ -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<Source> sources) { }
public record Snapshot(String orderNo,long version,String status,List<ReceiptRow> receipts,
List<Map<String,Object>> dispositions,Integer packingVersion,String packingStatus,List<CartonRow> 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<Long,Long> available=new HashMap<>(),used=new HashMap<>();
for(var receipt:received(order.getId(),Leg.OVERSEAS,null)) available.put(receipt.id(),receipt.goodQuantity());
Set<String> 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<Long> 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<Long,Long> 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<Long,List<Source>> 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<String,Object> 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<ReceiptRow> received(long orderId,Leg leg,Long source){return received(orderId,leg,source,false);}
private List<ReceiptRow> 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<Object> 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);}
}
@@ -1,30 +1,28 @@
package com.baiyue.lastleg.procurement.service; package com.baiyue.lastleg.procurement.service;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; 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.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 lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; 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 @Service @RequiredArgsConstructor
public class ProcurementQueryService { public class ProcurementQueryService {
@jakarta.annotation.Resource private com.baiyue.lastleg.financefee.service.OverseasManualFeeService manualFees; private final ProcurementWorkflowRepository repository;
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 OverseasProcurementService orders; 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, 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, 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, String plannedWarehouseName,String actualWarehouseCode,String actualWarehouseName,String status,long version,
LocalDateTime createdAt,LocalDateTime updatedAt,String procurementDirection) { } LocalDateTime createdAt,LocalDateTime updatedAt,String procurementDirection) { }
public record Budget(BigDecimal totalAmount,String currency,LocalDateTime authorizedAt) { } 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, public record CustomerFee(String feeNo,String purpose,String name,Integer version,boolean active,String determination,
BigDecimal amount,String currency,BigDecimal originalAmount,String originalCurrency, BigDecimal amount,String currency,BigDecimal originalAmount,String originalCurrency,
BigDecimal exchangeRate,String exchangeRateMonth,LocalDate businessDate,String parentFeeNo, BigDecimal exchangeRate,String exchangeRateMonth,LocalDate businessDate,String parentFeeNo,
String walletStatus,boolean sourceLinked) { 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()), public static CustomerFee fromManual(OverseasFeeSource fee) {
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(), return new CustomerFee(String.valueOf(fee.getSourceFeeId()),fee.getFeeItemCode(),fee.getFeeItemName(),
fee.getSourceVersion(),"ACTIVE".equals(fee.getSourceStatus()),fee.getReviewStatus(),fee.getAmount(), fee.getSourceVersion(),"ACTIVE".equals(fee.getSourceStatus()),fee.getReviewStatus(),fee.getAmount(),
fee.getCurrency(),fee.getOriginalAmount(),fee.getOriginalCurrency(),fee.getExchangeRate(), fee.getCurrency(),fee.getOriginalAmount(),fee.getOriginalCurrency(),fee.getExchangeRate(),
fee.getExchangeRateMonth(), fee.getBusinessDate(), null, fee.getWalletStatus(), fee.getExchangeRateMonth(),fee.getBusinessDate(),null,fee.getWalletStatus(),"PUBLISHED".equals(fee.getPublicationStatus()));
"PUBLISHED".equals(fee.getPublicationStatus()));
} }
} }
// 保留原客户端响应容器,但旧区块恒为空;不查询旧事实,也不把新批次伪装为旧装箱及面单。
public record CustomerView(Header order,Budget budget,boolean purchased,boolean purchaseConfirmed,boolean purchaseNeedsAttention, public record CustomerView(Header order,Budget budget,boolean purchased,boolean purchaseConfirmed,boolean purchaseNeedsAttention,
List<Progress> progress,List<Box> boxes,List<Shipment> shipments,List<CustomerFee> fees,List<String> exceptionSummaries) { } List<Object> progress,List<Object> boxes,List<Object> shipments,List<CustomerFee> fees,List<String> exceptionSummaries) { }
public record Action(String key,String label,String permission,boolean allowed,String reason) { } public record PlatformView(CustomerView customerView,Map<String,Object> purchaseFacts,Map<String,Object> physical,
public record PlatformView(CustomerView customerView,Map<String,Object> purchaseFacts,ProcurementPhysicalService.Snapshot physical, Map<String,Object> transport,List<Object> fees,List<Object> feeCorrections,Map<String,Object> controls,
ProcurementTransportService.Snapshot transport,List<OverseasProcurementFee> fees,
List<ProcurementAdjustmentService.Result> feeCorrections,ProcurementControlService.Snapshot controls,
List<OverseasProcurementResponses.FileInfo> files,List<OverseasProcurementResponses.History> reviewHistory, List<OverseasProcurementResponses.FileInfo> files,List<OverseasProcurementResponses.History> reviewHistory,
List<Map<String,Object>> operations,List<Action> actions,String internalRemark) { } List<Object> operations,List<Object> actions,String internalRemark) { }
/** 客户详情按当前客户与完整单号定位。 */
@Transactional(readOnly=true) @Transactional(readOnly=true)
public CustomerView customer(String customer,String orderNo) { public CustomerView customer(String customer,String orderNo) { return customer(requireOrder(customer,orderNo)); }
var order=lifecycle.requireOrder(customer,orderNo);return customer(order);
} /** 采购仓取主单审核结果,预算取客户下单值;批次和库存从 workflow 读取。 */
/** 组装客户采购投影;新增费用与旧费用使用同一公开白名单。 */
private CustomerView customer(OverseasProcurementOrder order) { private CustomerView customer(OverseasProcurementOrder order) {
var flow=lifecycle.flow(order.getId());var fees=finance.all(order.getId());Map<Long,String> 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(),
var header=new Header(order.getId(),order.getOrderNo(),order.getCustomerId(),order.getCustomerName(),order.getPurchaseCategory(),order.getProductName(),order.getProductSpecification(),order.getProductUrl(), order.getProductSpecification(),order.getProductUrl(),order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(),
order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(),order.getReceivingAddress(),order.getContactName(),order.getContactPhone(),order.getEstimatedArrivalAt(),order.getPurchaseWarehouseName(), 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())); null,order.getPurchaseWarehouseName(),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", var budget=new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null);
(rs,n)->new Budget(rs.getBigDecimal(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime()),order.getId()); var fees=manualFees.procurementFees(order.getCustomerId(),order.getId()).stream()
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> 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.<Map<String,Object>>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<CustomerFee> 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()) .filter(f->"RECEIVABLE".equals(f.getDirection()) && "OVERSEAS_PROCUREMENT".equals(f.getBusinessType())
&& order.getCustomerId().equals(f.getCustomerId()) && order.getId().equals(f.getBusinessId())) && order.getCustomerId().equals(f.getCustomerId()) && order.getId().equals(f.getBusinessId()))
.map(CustomerFee::fromManual).toList()); .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, return new CustomerView(header,budget,false,false,false,List.of(),List.of(),List.of(),fees,List.of());
List.copyOf(customerFees),List.copyOf(summaries));
} }
/** 平台旧区块为空,正式作业全部使用七状态批次接口。 */
@Transactional(readOnly=true) @Transactional(readOnly=true)
public PlatformView platform(String customer,String orderNo,Set<String> permissions) { public PlatformView platform(String customer,String orderNo,Set<String> permissions) {
var order=lifecycle.requireOrder(customer,orderNo);var base=orders.detail(customer,orderNo);var flow=lifecycle.flow(order.getId()); var order=requireOrder(customer,orderNo);var base=orders.detail(customer,orderNo);
var physicalView=physical.read(customer,orderNo);var transportView=transport.read(customer,orderNo); return new PlatformView(customer(order),Map.of(),
return new PlatformView(customer(order),flow,physicalView,transportView,finance.all(order.getId()),adjustments.history(customer,orderNo),controls.read(customer,orderNo),base.files(),base.histories(), Map.of("dispositions",List.of(),"receipts",List.of(),"cartons",List.of()),
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()); 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<Action> actions(OverseasProcurementOrder order,Map<String,Object> flow,ProcurementPhysicalService.Snapshot physicalView,ProcurementTransportService.Snapshot transportView,Set<String> permissions) { /** 先验证客户归属,再读取费用和内部附件。 */
if (ProcurementWorkflowService.STATUSES.contains(order.getStatus())) return List.of(); private OverseasProcurementOrder requireOrder(String customer,String orderNo) {
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; var order=repository.order(customer,orderNo,false);
String stopped=closed?"订单已结束":null; if(order==null) throw bad("采购单不存在或不属于当前客户");
String blockers=reason(()->lifecycle.requireNoBlockers(order)); return order;
var sample=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT");boolean paid=sample!=null && finance.sourceLinked(sample);
List<Action> 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 static void action(List<Action> result,Set<String> 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;}
} }
@@ -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<Source> 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<LabelRow> 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<Shipment> shipments,List<Cargo> overseasCargo,List<Cargo> 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<String,Object> 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> 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<Cargo> result=new ArrayList<>();
for(var row:rows) {
long id=((Number)row.get("id")).longValue();
List<Source> 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> cargo,List<Long> ids) {
Set<Long> 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>T deserialize(String value,Class<T> 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);}}
}
@@ -47,11 +47,12 @@ public class ProcurementWarehouseDirectory {
} }
return warehouse; 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); 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); required(id, direction);
var result=client.activeWarehouse(id,token); var result=client.activeWarehouse(id,token);
if(result==null || !result.successful() || result.data()==null || result.data().warehouse()==null) throw unavailable(); 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); 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()) 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(" ")); .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() { private static ResponseStatusException unavailable() {
return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"采购收货仓目录暂不可用,请稍后重试"); return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"采购收货仓目录暂不可用,请稍后重试");
@@ -13,8 +13,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.*; import java.util.*;
import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.bad; import static com.baiyue.lastleg.procurement.service.ProcurementErrors.bad;
import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.conflict; import static com.baiyue.lastleg.procurement.service.ProcurementErrors.conflict;
/** 采购仅保留审核、分批收货、客户确认与分批出库;库存始终属于采购单。 */ /** 采购仅保留审核、分批收货、客户确认与分批出库;库存始终属于采购单。 */
@Service @RequiredArgsConstructor @Service @RequiredArgsConstructor
@@ -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')) CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION'))
) ENGINE=InnoDB COMMENT='海外采购签收文件及费用凭证'; ) 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; EXECUTE return_sku_index_stmt;
DEALLOCATE PREPARE 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库存流水,不回填历史采购数量。 -- 采购实收、差异、装箱和两段整批发运事实。不产生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,不回填旧费用。 -- 费用链目标更正快照与关联;资金流水仍唯一归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库存或退货运输任务。 -- 单采购订单内的更正说明、阻断异常与关闭证据。不生成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 ( CREATE TABLE IF NOT EXISTS ll_appointment_impact (
File diff suppressed because it is too large Load Diff
@@ -63,7 +63,7 @@ paths:
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds: /api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds:
post: post:
summary: 从采购库存创建出库单 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: parameters:
- $ref: '#/components/parameters/OrderNo' - $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId' - $ref: '#/components/parameters/CustomerId'
@@ -23,8 +23,10 @@ class OverseasProcurementSchemaContractTest {
assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL")); assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL"));
assertTrue(sql.contains("review_request_hash CHAR(64) NULL")); assertTrue(sql.contains("review_request_hash CHAR(64) NULL"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_details")); 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_workflow_receipt"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment_item")); 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("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'"));
assertTrue(sql.contains("change_detail JSON NULL")); assertTrue(sql.contains("change_detail JSON NULL"));
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound"));
@@ -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<String> 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<String> 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<String>();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:");
}
}
@@ -50,10 +50,7 @@ class ProcurementRequestBoundaryTest {
var json=new ObjectMapper().findAndRegisterModules(); var json=new ObjectMapper().findAndRegisterModules();
try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) { try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) {
var validator=factory.getValidator(); var validator=factory.getValidator();
for(var type:List.of(com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command.class, for(var type:List.of(OverseasProcurementRequests.Review.class, OverseasProcurementRequests.Update.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)) {
assertThat(validator.validate(json.readValue("{}",type))).anyMatch(v->"version".equals(v.getPropertyPath().toString())); assertThat(validator.validate(json.readValue("{}",type))).anyMatch(v->"version".equals(v.getPropertyPath().toString()));
} }
} }
@@ -67,10 +64,10 @@ class ProcurementRequestBoundaryTest {
} }
@Test void customerProjectionExcludesInternalFilesAndFinancialDiagnostics() throws Exception { @Test void customerProjectionExcludesInternalFilesAndFinancialDiagnostics() throws Exception {
var fee=new com.baiyue.lastleg.procurement.entity.OverseasProcurementFee(); var fee=new com.baiyue.lastleg.financefee.entity.OverseasFeeSource();
fee.setFeeNo("PF-1");fee.setFeeItemName("关税");fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED"); fee.setSourceFeeId(1L);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"); 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.from(fee,null,false)); var output=new ObjectMapper().writeValueAsString(com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerFee.fromManual(fee));
assertThat(output).contains("PF-1","FREEZE_FAILED").doesNotContain("private.pdf","voucherFileId","internal note","database error","operator","private-ledger"); assertThat(output).contains("FREEZE_FAILED").doesNotContain("private.pdf","voucherFileId","internal note","database error","operator","private-ledger");
} }
} }
@@ -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");}
}
@@ -7,7 +7,6 @@ import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper; 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.gateway.ProcurementFileGateway;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService; import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
@@ -43,10 +42,8 @@ class OverseasProcurementServiceTest {
@Mock OverseasProcurementOrderMapper orders; @Mock OverseasProcurementOrderMapper orders;
@Mock OverseasProcurementStatusHistoryMapper histories; @Mock OverseasProcurementStatusHistoryMapper histories;
@Mock ProcurementWarehouseDirectory warehouseDirectory; @Mock ProcurementWarehouseDirectory warehouseDirectory;
@Mock com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository directionRepository;
@Mock com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator; @Mock com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator;
@Mock OverseasProcurementFileMapper procurementFiles; @Mock OverseasProcurementFileMapper procurementFiles;
@Mock OverseasProcurementFeeMapper procurementFees;
@Mock ProcurementFileGateway fileGateway; @Mock ProcurementFileGateway fileGateway;
@Mock WalletCommandOutboxService walletCommands; @Mock WalletCommandOutboxService walletCommands;
@Mock OverseasFeeSourceService feeSources; @Mock OverseasFeeSourceService feeSources;
@@ -58,15 +55,12 @@ class OverseasProcurementServiceTest {
ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory); ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
ReflectionTestUtils.setField(service, "directionRepository", directionRepository);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles); ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles);
ReflectionTestUtils.setField(service, "procurementFees", procurementFees);
ReflectionTestUtils.setField(service, "fileGateway", fileGateway); ReflectionTestUtils.setField(service, "fileGateway", fileGateway);
lenient().when(orderNoGenerator.next(anyString())).thenReturn("ACME202609050001"); lenient().when(orderNoGenerator.next(anyString())).thenReturn("ACME202609050001");
lenient().when(procurementFiles.selectList(any())).thenReturn(List.of()); lenient().when(procurementFiles.selectList(any())).thenReturn(List.of());
lenient().when(procurementFees.selectList(any())).thenReturn(List.of());
} }
@Test @Test
@@ -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<java.util.concurrent.Future<Boolean>>();
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<java.util.concurrent.Future<Boolean>>();
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)));}
}
@@ -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<Resolution> 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<Resolution> lines){return tx.execute(s->service.close("9","PO-1","7",request(lines)));}
}
@@ -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();
}
}
@@ -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)); }
}
@@ -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<Carton> 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")));}
}
@@ -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<OverseasProcurementFee>();
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");
}
}
@@ -1,106 +1,65 @@
package com.baiyue.lastleg.procurement.service; package com.baiyue.lastleg.procurement.service;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasManualFeeService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.*;
import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*; import java.util.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** 仅创建六张正式采购表,真实 Mapper 验证详情、审核、文件和客户费用不会访问任何旧表。 */
class ProcurementQueryServiceTest { class ProcurementQueryServiceTest {
final ProcurementControlPersistenceTest fixture=new ProcurementControlPersistenceTest(); ProcurementWorkflowPersistenceTest fixture;
ProcurementQueryService query;ProcurementTransportService transport;OverseasProcurementService base; ProcurementQueryService query;
ObjectMapper json=new ObjectMapper().findAndRegisterModules(); OverseasProcurementService orders;
OverseasManualFeeService fees;
ProcurementWarehouseDirectory warehouses;
@BeforeEach void setup() throws Exception { @BeforeEach void setup() throws Exception {
fixture.setup();var jdbc=fixture.jdbc; fixture=new ProcurementWorkflowPersistenceTest();fixture.setup();
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); orders=new OverseasProcurementService();
for(String name:List.of("ll_procurement_budget_authorization","ll_procurement_manifest","ll_procurement_carton_source","ll_procurement_label_set","ll_procurement_label_item")) { ReflectionTestUtils.setField(orders,"orders",fixture.orders);
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[^;]*;",");")); 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);
} }
var physical=new ProcurementPhysicalService(fixture.lifecycle,fixture.warehouses,fixture.finance,jdbc); @Test void currentDetailsReadOnlySixTablesAndReturnNoLegacyActionsOrPhysicalFacts() {
org.springframework.test.util.ReflectionTestUtils.setField(physical, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET budget_unit_price=15,purchase_warehouse_name='洛杉矶仓',remark='internal note' WHERE id=11");
transport=new ProcurementTransportService(fixture.lifecycle,physical,fixture.finance,fixture.warehouses,jdbc,json); var platform=query.platform("9","CG-1",Set.of("platform:overseas-business:purchase:buy"));
org.springframework.test.util.ReflectionTestUtils.setField(transport, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); assertThat(platform.customerView().budget().totalAmount()).isEqualByComparingTo("150");
base=mock(OverseasProcurementService.class); assertThat(platform.customerView().order().actualWarehouseName()).isEqualTo("洛杉矶仓");
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())); assertThat(platform.customerView().boxes()).isEmpty();assertThat(platform.customerView().shipments()).isEmpty();
query=new ProcurementQueryService(fixture.lifecycle,physical,transport,fixture.finance,mock(ProcurementAdjustmentService.class),fixture.service,base,jdbc); assertThat(platform.actions()).isEmpty();assertThat(platform.operations()).isEmpty();
org.springframework.test.util.ReflectionTestUtils.setField(query, "manualFees", mock(com.baiyue.lastleg.financefee.service.OverseasManualFeeService.class)); assertThat(platform.files()).hasSize(3);assertThat(platform.internalRemark()).isEqualTo("internal note");
assertThat(orders.detail("9","CG-1").fees()).isEmpty();
} }
@Test void customerResponseContainsStructuredFactsAndOnlyApprovedPublicSummaries()throws Exception { @Test void reviewAndRemarkUpdatesNoLongerRequireLegacyFeeOrDirectionTables() {
fixture.jdbc.update("UPDATE ll_procurement_flow SET purchase_issue='private purchase diagnosis' WHERE order_id=11"); fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET status='PENDING_REVIEW' WHERE 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')"); when(warehouses.required(91)).thenReturn(new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(91L,"LAX","洛杉矶仓","US","ACTIVE"));
fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'BOX-A',20,30,40,5.125,10)"); var result=fixture.tx.execute(s->orders.review("9","CG-1","7",new OverseasProcurementRequests.Review(0L,"APPROVE","review",null,91L)));
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')"); assertThat(result.summary().status()).isEqualTo("RECEIVING");assertThat(result.histories()).hasSize(1);
fixture.jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(401,201,10,'Express','TRACK-1','{\"privateFile\":1001}')"); assertThat(query.customer("9","CG-1").order().actualWarehouseName()).isEqualTo("洛杉矶仓");
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 platformCombinesDataAndAuthoritativeShippingBlockReasonsWithoutGrantingOtherActions() { @Test void customerCannotReadAnotherCustomersOrderOrInternalDiagnostics() throws Exception {
var permissions=Set.of("platform:overseas-business:purchase:receive-overseas","platform:overseas-business:purchase:ship-customer"); assertThatThrownBy(()->query.customer("10","CG-1")).hasMessageContaining("当前客户");
var result=query.platform("9","PO-1",permissions); verifyNoInteractions(fees);
assertThat(result.internalRemark()).isEqualTo("internal remark");assertThat(result.files()).hasSize(1); var fee=fee("9",11L,"RECEIVABLE");fee.setWalletErrorMessage("private error");fee.setReviewReason("private reason");
assertThat(result.controls().remaining()).hasSize(1); when(fees.procurementFees("9",11L)).thenReturn(List.of(fee,fee("10",11L,"RECEIVABLE"),fee("9",12L,"RECEIVABLE"),fee("9",11L,"PAYABLE")));
assertThat(result.actions()).filteredOn(a->a.key().equals("RECEIVE_OVERSEAS")).singleElement().satisfies(a->assertThat(a.allowed()).isTrue()); var result=query.customer("9","CG-1");
assertThat(result.actions()).filteredOn(a->a.key().equals("SHIP_CUSTOMER")).singleElement().satisfies(a->{assertThat(a.allowed()).isFalse();assertThat(a.reason()).contains("样品货款");}); assertThat(result.fees()).singleElement().satisfies(row->{assertThat(row.walletStatus()).isEqualTo("FREEZE_PENDING");assertThat(row.sourceLinked()).isFalse();});
assertThat(result.actions()).filteredOn(a->a.key().equals("FINANCE_ADJUST")).singleElement().satisfies(a->assertThat(a.reason()).isEqualTo("无此操作权限")); assertThat(new ObjectMapper().findAndRegisterModules().writeValueAsString(result)).doesNotContain("private", "walletErrorMessage", "reviewReason", "internalRemark");
} }
@Test void anotherCustomerCannotLoadEvenPublicProjection() { private OverseasFeeSource fee(String customer,long order,String direction) {
assertThatThrownBy(()->query.customer("10","PO-1")).hasMessageContaining("当前客户");verifyNoInteractions(fixture.finance,base); 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 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");
}
@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("登记国内实收"));
}
} }
@@ -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<Future<Boolean>>();
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<Label> labels){return tx.execute(s->service.saveLabels("9","PO-1","7",new Labels(version(),"labels-"+version(),leg,1,leg==Leg.OVERSEAS?domestic:customer,null,labels,"checked labels")));}
private void review(BigDecimal amount){tx.execute(s->service.reviewEstimate("9","PO-1","7",new Estimate(version(),"estimate-"+version(),labelVersion(Leg.OVERSEAS),amount,LocalDate.now(),"quoted")));}
private void apply(){tx.execute(s->service.applyLabels("9","PO-1","7",new ApplyLabels(version(),"apply-"+version(),labelVersion(Leg.OVERSEAS),List.of(201L,202L),start.plusMinutes(30),"attached")));}
private Ship shipRequest(Leg leg,String key){return new Ship(version(),key,leg,labelVersion(leg),List.of(201L,202L),start.plusMinutes(leg==Leg.OVERSEAS?60:100),"actual handoff");}
private ProcurementTransportService.Snapshot ship(Leg leg,String key){return tx.execute(s->service.ship("9","PO-1","7",shipRequest(leg,key)));}
@Test void exportUsesDomesticSenderAndOverseasDestinationThenEndsAfterLocalHandoff() {
var sender = new Address("Domestic warehouse","13800000000","Shenzhen domestic origin");
var receiver = new Address("Overseas warehouse","+1 1234567890","Los Angeles overseas receiving");
var finalReceiver = new Address("Overseas customer","+1 9876543210","San Diego customer destination");
jdbc.update("UPDATE ll_overseas_procurement_order SET procurement_direction=?,contact_name=?,contact_phone=?,receiving_address=? WHERE id=11",
ProcurementDirection.EXPORT,finalReceiver.name(),finalReceiver.phone(),finalReceiver.address());
when(warehouses.sender(91,ProcurementDirection.EXPORT)).thenReturn(sender);
tx.execute(s -> service.saveLabels("9","PO-1","7",new Labels(version(),"export-labels",Leg.OVERSEAS,1,receiver,null,List.of(label(201),label(202)),"export manifest")));
review(BigDecimal.ZERO); apply();
assertThat(ship(Leg.OVERSEAS,"export-international").status()).isEqualTo("INTERNATIONAL_TRANSIT");
String snapshot = jdbc.queryForObject("SELECT address_snapshot FROM ll_procurement_shipment WHERE leg='OVERSEAS'",String.class);
assertThat(snapshot).contains(sender.address(),receiver.address()).doesNotContain(warehouse.address());
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,6,0,0,?,'7'),(103,11,'9','PR-3','DOMESTIC',202,4,0,0,?,'7')",start.plusMinutes(65),start.plusMinutes(65));
jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=? WHERE order_id=11",start.plusMinutes(70));
tx.execute(s -> service.saveLabels("9","PO-1","7",new Labels(version(),"export-local-labels",Leg.DOMESTIC,1,finalReceiver,null,List.of(label(201),label(202)),"overseas delivery")));
assertThatThrownBy(() -> ship(Leg.DOMESTIC,"before-charges")).hasMessageContaining("费用尚未确认");
jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=? WHERE order_id=11",start.plusMinutes(80));
var request = shipRequest(Leg.DOMESTIC,"export-local");
assertThat(tx.execute(s -> service.ship("9","PO-1","7",request)).status()).isEqualTo("SHIPPED_TO_CUSTOMER");
tx.execute(s -> service.ship("9","PO-1","7",request));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isEqualTo(2);
String local = jdbc.queryForObject("SELECT address_snapshot FROM ll_procurement_shipment WHERE leg='DOMESTIC'",String.class);
assertThat(local).contains(receiver.address(),finalReceiver.address());
verify(warehouses,never()).sender(91);
verify(finance).requireShipmentReady(11);
}
}
@@ -31,20 +31,23 @@ class ProcurementWorkflowPersistenceTest {
ProcurementWorkflowService service; ProcurementWorkflowService service;
OverseasProcurementOrderMapper orders; OverseasProcurementOrderMapper orders;
OverseasProcurementFileMapper files; OverseasProcurementFileMapper files;
OverseasProcurementStatusHistoryMapper histories;
ProcurementWorkflowRepository repository;
OverseasFeeSourceService fees; OverseasFeeSourceService fees;
@BeforeEach void setup() throws Exception { @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",""); 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)); jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); 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_workflow_receipt","ll_procurement_workflow_outbound","ll_procurement_workflow_command")) { for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_overseas_procurement_status_history","ll_procurement_workflow_receipt","ll_procurement_workflow_outbound","ll_procurement_workflow_command")) {
var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema); 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[^;]*;",");")); 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);config.addMapper(ProcurementWorkflowMapper.class); var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);config.addMapper(OverseasProcurementStatusHistoryMapper.class);config.addMapper(ProcurementWorkflowMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var session=new SqlSessionTemplate(factory.getObject());orders=session.getMapper(OverseasProcurementOrderMapper.class);files=session.getMapper(OverseasProcurementFileMapper.class); var session=new SqlSessionTemplate(factory.getObject());orders=session.getMapper(OverseasProcurementOrderMapper.class);files=session.getMapper(OverseasProcurementFileMapper.class);
var repository=new ProcurementWorkflowRepository(session.getMapper(ProcurementWorkflowMapper.class),orders,files); histories=session.getMapper(OverseasProcurementStatusHistoryMapper.class);
repository=new ProcurementWorkflowRepository(session.getMapper(ProcurementWorkflowMapper.class),orders,files);
var numbers=mock(LastlegDocumentNumberGenerator.class);var sequence=new AtomicInteger();when(numbers.next("CK")).thenAnswer(i->"CK-"+sequence.incrementAndGet()); var numbers=mock(LastlegDocumentNumberGenerator.class);var sequence=new AtomicInteger();when(numbers.next("CK")).thenAnswer(i->"CK-"+sequence.incrementAndGet());
fees=mock(OverseasFeeSourceService.class); fees=mock(OverseasFeeSourceService.class);
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED"))); when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED")));