费用提交
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
# 单测试环境数据库空库初始化
|
||||
|
||||
`overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。
|
||||
|
||||
`finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。
|
||||
|
||||
`overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。
|
||||
|
||||
`overseas-procurement-table-cleanup-review.sql` 为海外采购 27 表的只读核查,元数据行数为估算,另输出可手工执行的精确 COUNT。2026-10-27 已解除 21 张旧采购专属表的生产代码依赖,当前基线只保留六表。`overseas-procurement-legacy-tables-retire.sql` 为单独审阅执行的 MySQL 8.0+ 精确删表清单:仅 `baiyue_lastleg` 的 21 张旧采购表,不修改共用财务、资金、文件或仓储表。须先备份、停止旧版本写入、部署分离版本并验证,在维护窗口同连接设置脚本指定发布确认变量;六表缺失、旧状态采购或旧资金记录存在时拒绝。前置检查在业务 DROP 前完成,不关闭外键校验,使用一条 DROP TABLE IF EXISTS,可在完整成功后重跑;DDL 隐式提交,失败立即停止,禁止 --force。凭据及实例由执行者选择,不内置或自动连接。本轮未执行数据库或部署;[范围、清单及顺序](../../lastleg-service/docs/procurement-table-cleanup.md)。V14—V21 旧采购迁移仅供追溯,退役后不得重放。
|
||||
|
||||
`overseas-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。
|
||||
|
||||
车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。
|
||||
@@ -85,7 +89,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
|
||||
| 区域 | 完整入口 | 服务 schema | 表数 |
|
||||
|---|---|---|---:|
|
||||
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 |
|
||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 238 |
|
||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 222 |
|
||||
|
||||
|
||||
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
||||
@@ -108,7 +112,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
|
||||
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
||||
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
|
||||
|
||||
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/238`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
||||
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/222`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
||||
|
||||
## 平台管理员密码
|
||||
|
||||
@@ -209,7 +213,7 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
|
||||
|
||||
编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。
|
||||
|
||||
海外费用与仓租首版统一维护在 `头程数据库统一增量-MySQL8.sql` 的 IAM 菜单/管理员授权种子及 `海外区域统一增量-MySQL8.sql` 的最终建表定义中。海外基线包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租快照、审核人名称,以及采购退款请求幂等字段和唯一索引;同步模块源基线、国内权限种子及海外空库 238 表,复用现有 `FEE_TYPE_077 / 仓储费`,不新增费用项目种子。发布须同时更新 Firstleg、Lastleg、Admin 与海外 PDA;规则、配置及资金验收见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。仅整理文件,未执行 SQL 或部署。
|
||||
海外费用与仓租首版统一维护在 `头程数据库统一增量-MySQL8.sql` 的 IAM 菜单/管理员授权种子及 `海外区域统一增量-MySQL8.sql` 的最终建表定义中。海外基线包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租快照及审核人名称;采购旧费用与退款专属表已在简化流程退役,不再进入空库初始化。同步模块源基线、国内权限种子及海外空库 222 表,复用现有 `FEE_TYPE_077 / 仓储费`,不新增费用项目种子。发布须同时更新 Firstleg、Lastleg、Admin 与海外 PDA;规则、配置及资金验收见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。仅整理文件,未执行 SQL 或部署。
|
||||
|
||||
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,5 +1,5 @@
|
||||
-- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
|
||||
-- 海外费用资金、仓租四表、审核人名称快照、采购退款请求幂等字段及唯一索引均在本文件最终建表定义中维护。
|
||||
-- 海外费用资金、仓租四表、审核人名称快照及简化采购六表均在本文件最终建表定义中维护。
|
||||
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
|
||||
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
|
||||
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
|
||||
@@ -2060,53 +2060,6 @@ CREATE TABLE ll_outbound_pda_task (
|
||||
UNIQUE KEY uk_ll_outbound_pda_complete_key (customer_id,completion_idempotency_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务';
|
||||
|
||||
CREATE TABLE ll_overseas_procurement_fee (
|
||||
purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定',
|
||||
confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作',
|
||||
fee_version INT NULL COMMENT '同用途费用确认版本',
|
||||
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本',
|
||||
determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用',
|
||||
original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD',
|
||||
original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额',
|
||||
exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照',
|
||||
exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份',
|
||||
business_date DATE NULL COMMENT '实际费用业务日期',
|
||||
wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录',
|
||||
wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估',
|
||||
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
|
||||
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用',
|
||||
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
|
||||
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
|
||||
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
|
||||
idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号',
|
||||
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
|
||||
id bigint NOT NULL COMMENT '主键ID',
|
||||
order_id bigint NOT NULL COMMENT '采购订单ID',
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
|
||||
fee_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用业务号',
|
||||
fee_item_code varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项编码',
|
||||
fee_item_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项名称',
|
||||
amount decimal(20,2) NOT NULL COMMENT 'CNY金额',
|
||||
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种',
|
||||
voucher_file_id bigint NULL COMMENT '凭证文件ID',
|
||||
voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NULL COMMENT '凭证文件名',
|
||||
remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '说明',
|
||||
wallet_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金状态',
|
||||
wallet_error_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码',
|
||||
wallet_error_message varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误说明',
|
||||
wallet_command_event_id varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前资金指令事件ID',
|
||||
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登记人',
|
||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
|
||||
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
|
||||
UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
|
||||
KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at),
|
||||
CONSTRAINT chk_ll_procurement_fee_amount CHECK ((amount >= 0)),
|
||||
CONSTRAINT chk_ll_procurement_fee_currency CHECK ((currency = _utf8mb4'CNY'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购费用明细及资金生命周期';
|
||||
|
||||
CREATE TABLE ll_overseas_procurement_file (
|
||||
id bigint NOT NULL COMMENT '主键ID',
|
||||
@@ -3977,245 +3930,33 @@ CREATE TABLE ll_return_carton_count_session (
|
||||
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
|
||||
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
|
||||
|
||||
CREATE TABLE ll_procurement_wallet_attempt (
|
||||
id BIGINT NOT NULL COMMENT '请求尝试ID',
|
||||
order_id BIGINT NOT NULL COMMENT '采购订单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID',
|
||||
attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号',
|
||||
operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款',
|
||||
business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号',
|
||||
amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空',
|
||||
command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用',
|
||||
status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败',
|
||||
result_hash CHAR(64) NULL COMMENT '原业务回执摘要',
|
||||
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
|
||||
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用',
|
||||
error_code VARCHAR(64) NULL COMMENT '业务失败码',
|
||||
error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间',
|
||||
completed_at DATETIME(3) NULL COMMENT '最终结果时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id),
|
||||
UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no),
|
||||
KEY idx_procurement_attempt_order(customer_id,order_id,status),
|
||||
CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果';
|
||||
|
||||
-- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。
|
||||
CREATE TABLE ll_procurement_flow (
|
||||
order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
purchased_at DATETIME(3) NULL COMMENT '真实购买时间',
|
||||
purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品',
|
||||
purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格',
|
||||
purchased_quantity BIGINT NULL COMMENT '实际购买件数',
|
||||
purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额',
|
||||
purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD',
|
||||
purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用',
|
||||
purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明',
|
||||
purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功',
|
||||
actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID',
|
||||
actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照',
|
||||
actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照',
|
||||
overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间',
|
||||
packing_version INT NULL COMMENT '当前确认装箱清单版本',
|
||||
overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间',
|
||||
domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间',
|
||||
charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间',
|
||||
customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间',
|
||||
domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人',
|
||||
domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话',
|
||||
domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址',
|
||||
cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间',
|
||||
closed_at DATETIME(3) NULL COMMENT '异常关闭时间',
|
||||
control_reason VARCHAR(1000) NULL COMMENT '取消或关闭原因',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id),
|
||||
CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0),
|
||||
CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实';
|
||||
|
||||
CREATE TABLE ll_procurement_command (
|
||||
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要',
|
||||
operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据',
|
||||
detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key),
|
||||
KEY idx_procurement_command_customer(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计';
|
||||
|
||||
CREATE TABLE ll_procurement_budget_authorization (
|
||||
id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种',
|
||||
authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种',
|
||||
voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据',
|
||||
reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间',
|
||||
PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id),
|
||||
CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0),
|
||||
CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史';
|
||||
|
||||
-- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。
|
||||
CREATE TABLE ll_procurement_receipt (
|
||||
id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内',
|
||||
source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓',
|
||||
good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量',
|
||||
received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实',
|
||||
supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active),
|
||||
CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0),
|
||||
CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实';
|
||||
|
||||
CREATE TABLE ll_procurement_disposition (
|
||||
id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源',
|
||||
receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货',
|
||||
quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货',
|
||||
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实',
|
||||
voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
|
||||
PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind),
|
||||
CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款';
|
||||
|
||||
CREATE TABLE ll_procurement_manifest (
|
||||
id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代',
|
||||
confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version),
|
||||
CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本';
|
||||
|
||||
CREATE TABLE ll_procurement_carton (
|
||||
id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本',
|
||||
carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米',
|
||||
width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米',
|
||||
gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id),
|
||||
CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数';
|
||||
|
||||
CREATE TABLE ll_procurement_carton_source (
|
||||
carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数',
|
||||
PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量';
|
||||
|
||||
CREATE TABLE ll_procurement_shipment (
|
||||
id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户',
|
||||
manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间',
|
||||
address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no),
|
||||
CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄';
|
||||
|
||||
CREATE TABLE ll_procurement_shipment_item (
|
||||
shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数',
|
||||
carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照',
|
||||
label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照',
|
||||
PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实';
|
||||
|
||||
USE baiyue_lastleg;
|
||||
-- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。
|
||||
CREATE TABLE ll_procurement_label_set (
|
||||
id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本',
|
||||
manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要',
|
||||
sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version),
|
||||
CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照';
|
||||
|
||||
CREATE TABLE ll_procurement_label_item (
|
||||
label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID',
|
||||
file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始',
|
||||
carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号',
|
||||
applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人',
|
||||
PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实';
|
||||
|
||||
CREATE TABLE ll_procurement_estimate_review (
|
||||
id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本',
|
||||
fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员',
|
||||
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联';
|
||||
|
||||
CREATE TABLE ll_procurement_repack (
|
||||
id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源',
|
||||
length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米',
|
||||
height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克',
|
||||
voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
|
||||
PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id),
|
||||
CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史';
|
||||
|
||||
USE baiyue_lastleg;
|
||||
-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。
|
||||
CREATE TABLE ll_procurement_fee_correction (
|
||||
id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要',
|
||||
target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种',
|
||||
exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份',
|
||||
target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY',
|
||||
difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录',
|
||||
voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key),
|
||||
KEY idx_procurement_correction_root(order_id,root_fee_id,id),
|
||||
CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照';
|
||||
|
||||
CREATE TABLE ll_procurement_fee_correction_item (
|
||||
correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID',
|
||||
PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
|
||||
|
||||
USE baiyue_lastleg;
|
||||
-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。
|
||||
CREATE TABLE ll_procurement_control_note (
|
||||
id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型',
|
||||
target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约',
|
||||
voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明',
|
||||
client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容',
|
||||
original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
|
||||
resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间',
|
||||
resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
|
||||
|
||||
CREATE TABLE ll_procurement_closure (
|
||||
id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证',
|
||||
financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实',
|
||||
reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实';
|
||||
|
||||
CREATE TABLE ll_procurement_closure_item (
|
||||
id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID',
|
||||
source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空',
|
||||
quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置',
|
||||
destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明',
|
||||
PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
|
||||
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
|
||||
|
||||
USE baiyue_lastleg;
|
||||
|
||||
@@ -5156,4 +4897,4 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
|
||||
PRIMARY KEY (order_id,command_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
|
||||
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 243 AS initialized_tables;
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 222 AS initialized_tables;
|
||||
|
||||
@@ -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)。代码验证与数据库执行分开记录,必须先部署分离版本再执行精确删表脚本。
|
||||
|
||||
- 创建采购单后为待审核,审核后直接收货中。
|
||||
- 两次收货分别保留数量与附件;提交确认后不能追加收货,客户确认前不能创建出库。
|
||||
- 客户只能确认自己的采购单;内部备注与内部资料不进入客户投影。
|
||||
|
||||
-1
@@ -72,7 +72,6 @@ public interface OverseasFeeBusinessMapper {
|
||||
return switch (type) {
|
||||
case "OVERSEAS_INBOUND" -> new String[]{"ll_inbound_plan", "plan_no", "NULL", "NULL"};
|
||||
case "OVERSEAS_PROCUREMENT" -> new String[]{"ll_overseas_procurement_order", "order_no", "NULL", "NULL"};
|
||||
case "PROCUREMENT_FEE" -> new String[]{"ll_overseas_procurement_fee", "fee_no", "NULL", "NULL"};
|
||||
case "US_TRUCK" -> new String[]{"ll_us_truck_order", "order_no", "NULL", "NULL"};
|
||||
case "LOCAL_LOOSEBOARD" -> new String[]{"ll_local_looseboard_order", "order_no", "NULL", "NULL"};
|
||||
case "LASTLEG_ORDER" -> new String[]{"ll_order", "order_no", "t.overseas_warehouse_id", "t.order_type"};
|
||||
|
||||
-77
@@ -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));
|
||||
}
|
||||
}
|
||||
-45
@@ -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));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Set;
|
||||
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform;
|
||||
import static com.baiyue.lastleg.procurement.controller.ProcurementPlatformIdentity.platform;
|
||||
|
||||
@RestController @RequiredArgsConstructor
|
||||
@RequestMapping("/api/platform/v1/last-leg/overseas-procurements")
|
||||
|
||||
-64
@@ -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());
|
||||
}
|
||||
}
|
||||
-82
@@ -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));
|
||||
}
|
||||
}
|
||||
+17
@@ -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());
|
||||
}
|
||||
}
|
||||
-59
@@ -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,"运输段与操作入口不一致");}
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform;
|
||||
import static com.baiyue.lastleg.procurement.controller.ProcurementPlatformIdentity.platform;
|
||||
|
||||
/** 平台简化采购作业,所有写入沿用正式收货及出库权限。 */
|
||||
@RestController @RequiredArgsConstructor
|
||||
|
||||
-35
@@ -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) { }
|
||||
}
|
||||
-25
@@ -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) { }
|
||||
}
|
||||
-23
@@ -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) { }
|
||||
}
|
||||
-37
@@ -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) { }
|
||||
}
|
||||
-38
@@ -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) { }
|
||||
}
|
||||
-53
@@ -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;
|
||||
}
|
||||
-13
@@ -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);
|
||||
}
|
||||
-14
@@ -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);
|
||||
}
|
||||
-14
@@ -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); }
|
||||
}
|
||||
-27
@@ -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; }
|
||||
}
|
||||
+1
-15
@@ -9,9 +9,7 @@ import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
|
||||
import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
|
||||
@@ -56,10 +54,8 @@ public class OverseasProcurementService {
|
||||
@Resource private OverseasProcurementOrderMapper orders;
|
||||
@Resource private OverseasProcurementStatusHistoryMapper histories;
|
||||
@Resource private ProcurementWarehouseDirectory warehouseDirectory;
|
||||
@Resource private com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository directionRepository;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private OverseasProcurementFileMapper procurementFiles;
|
||||
@Resource private OverseasProcurementFeeMapper procurementFees;
|
||||
@Resource private ProcurementFileGateway fileGateway;
|
||||
|
||||
/** 创建客户采购订单,首次请求分配 CG 日流水。 */
|
||||
@@ -165,7 +161,7 @@ public class OverseasProcurementService {
|
||||
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
|
||||
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
|
||||
value.getChangeDetail(), value.getRemark(),
|
||||
value.getCreatedAt())).toList(), files(order), fees(order));
|
||||
value.getCreatedAt())).toList(), files(order), List.of());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -202,16 +198,6 @@ public class OverseasProcurementService {
|
||||
return new OverseasProcurementResponses.FileInfo(value.getId(), value.getCategory(), value.getFileId(), value.getFileName(),
|
||||
value.getContentType(), Objects.requireNonNullElse(value.getSizeBytes(), 0L), value.getCreatedAt());
|
||||
}
|
||||
private List<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
|
||||
|
||||
-114
@@ -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();
|
||||
}
|
||||
}
|
||||
-113
@@ -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());
|
||||
}
|
||||
}
|
||||
-148
@@ -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);}
|
||||
}
|
||||
+13
@@ -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); }
|
||||
}
|
||||
-14
@@ -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; }
|
||||
}
|
||||
-533
@@ -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); }
|
||||
}
|
||||
-252
@@ -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);}
|
||||
}
|
||||
-334
@@ -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);}
|
||||
}
|
||||
+49
-112
@@ -1,30 +1,28 @@
|
||||
package com.baiyue.lastleg.procurement.service;
|
||||
|
||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
|
||||
import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
|
||||
import com.baiyue.lastleg.procurement.repository.ProcurementWorkflowRepository;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasManualFeeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import static com.baiyue.lastleg.procurement.service.ProcurementErrors.bad;
|
||||
|
||||
/** Browser detail aggregation; the customer result is a separate allowlist, never a filtered platform map. */
|
||||
/** 简化采购详情只依赖主单、附件、审核流水和共用费用,不查询旧采购履约表。 */
|
||||
@Service @RequiredArgsConstructor
|
||||
public class ProcurementQueryService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.financefee.service.OverseasManualFeeService manualFees;
|
||||
private final ProcurementLifecycleService lifecycle;
|
||||
private final ProcurementPhysicalService physical;
|
||||
private final ProcurementTransportService transport;
|
||||
private final ProcurementFinanceService finance;
|
||||
private final ProcurementAdjustmentService adjustments;
|
||||
private final ProcurementControlService controls;
|
||||
private final ProcurementWorkflowRepository repository;
|
||||
private final OverseasProcurementService orders;
|
||||
private final JdbcTemplate jdbc;
|
||||
private final OverseasManualFeeService manualFees;
|
||||
|
||||
public record Header(Long id,String orderNo,String customerId,String customerName,String purchaseCategory,String productName,
|
||||
String productSpecification,String productUrl,long purchaseQuantity,BigDecimal budgetUnitPrice,String currency,
|
||||
@@ -32,120 +30,59 @@ public class ProcurementQueryService {
|
||||
String plannedWarehouseName,String actualWarehouseCode,String actualWarehouseName,String status,long version,
|
||||
LocalDateTime createdAt,LocalDateTime updatedAt,String procurementDirection) { }
|
||||
public record Budget(BigDecimal totalAmount,String currency,LocalDateTime authorizedAt) { }
|
||||
public record Progress(String key,String title,LocalDateTime completedAt) { }
|
||||
public record Box(String cartonNo,long quantity,BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm,BigDecimal grossWeightKg) { }
|
||||
public record Shipment(String shipmentNo,String leg,LocalDateTime shippedAt,String cartonNo,long quantity,String carrier,String trackingNo) { }
|
||||
public record CustomerFee(String feeNo,String purpose,String name,Integer version,boolean active,String determination,
|
||||
BigDecimal amount,String currency,BigDecimal originalAmount,String originalCurrency,
|
||||
BigDecimal exchangeRate,String exchangeRateMonth,LocalDate businessDate,String parentFeeNo,
|
||||
String walletStatus,boolean sourceLinked) {
|
||||
public static CustomerFee from(OverseasProcurementFee fee,String parentNo,boolean linked) {
|
||||
return new CustomerFee(fee.getFeeNo(),fee.getPurpose(),fee.getFeeItemName(),fee.getFeeVersion(),Boolean.TRUE.equals(fee.getActive()),
|
||||
fee.getDetermination(),fee.getAmount(),fee.getCurrency(),fee.getOriginalAmount(),fee.getOriginalCurrency(),fee.getExchangeRate(),fee.getExchangeRateMonth(),fee.getBusinessDate(),parentNo,fee.getWalletStatus(),linked);
|
||||
}
|
||||
/** 客户仅看到本采购单应收结果,不透出内部审核说明、资金凭证或供应商资料。 */
|
||||
public static CustomerFee fromManual(com.baiyue.lastleg.financefee.entity.OverseasFeeSource fee) {
|
||||
return new CustomerFee(String.valueOf(fee.getSourceFeeId()), fee.getFeeItemCode(), fee.getFeeItemName(),
|
||||
fee.getSourceVersion(), "ACTIVE".equals(fee.getSourceStatus()), fee.getReviewStatus(), fee.getAmount(),
|
||||
fee.getCurrency(), fee.getOriginalAmount(), fee.getOriginalCurrency(), fee.getExchangeRate(),
|
||||
fee.getExchangeRateMonth(), fee.getBusinessDate(), null, fee.getWalletStatus(),
|
||||
"PUBLISHED".equals(fee.getPublicationStatus()));
|
||||
/** 客户仅看到本单应收与资金结果,不公开审核说明、资金凭证或供应商资料。 */
|
||||
public static CustomerFee fromManual(OverseasFeeSource fee) {
|
||||
return new CustomerFee(String.valueOf(fee.getSourceFeeId()),fee.getFeeItemCode(),fee.getFeeItemName(),
|
||||
fee.getSourceVersion(),"ACTIVE".equals(fee.getSourceStatus()),fee.getReviewStatus(),fee.getAmount(),
|
||||
fee.getCurrency(),fee.getOriginalAmount(),fee.getOriginalCurrency(),fee.getExchangeRate(),
|
||||
fee.getExchangeRateMonth(),fee.getBusinessDate(),null,fee.getWalletStatus(),"PUBLISHED".equals(fee.getPublicationStatus()));
|
||||
}
|
||||
}
|
||||
// 保留原客户端响应容器,但旧区块恒为空;不查询旧事实,也不把新批次伪装为旧装箱及面单。
|
||||
public record CustomerView(Header order,Budget budget,boolean purchased,boolean purchaseConfirmed,boolean purchaseNeedsAttention,
|
||||
List<Progress> progress,List<Box> boxes,List<Shipment> 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,ProcurementPhysicalService.Snapshot physical,
|
||||
ProcurementTransportService.Snapshot transport,List<OverseasProcurementFee> fees,
|
||||
List<ProcurementAdjustmentService.Result> feeCorrections,ProcurementControlService.Snapshot controls,
|
||||
List<Object> progress,List<Object> boxes,List<Object> shipments,List<CustomerFee> fees,List<String> exceptionSummaries) { }
|
||||
public record PlatformView(CustomerView customerView,Map<String,Object> purchaseFacts,Map<String,Object> physical,
|
||||
Map<String,Object> transport,List<Object> fees,List<Object> feeCorrections,Map<String,Object> controls,
|
||||
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)
|
||||
public CustomerView customer(String customer,String orderNo) {
|
||||
var order=lifecycle.requireOrder(customer,orderNo);return customer(order);
|
||||
}
|
||||
/** 组装客户采购投影;新增费用与旧费用使用同一公开白名单。 */
|
||||
public CustomerView customer(String customer,String orderNo) { return customer(requireOrder(customer,orderNo)); }
|
||||
|
||||
/** 采购仓取主单审核结果,预算取客户下单值;批次和库存从 workflow 读取。 */
|
||||
private CustomerView customer(OverseasProcurementOrder order) {
|
||||
var flow=lifecycle.flow(order.getId());var fees=finance.all(order.getId());Map<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(),order.getProductSpecification(),order.getProductUrl(),
|
||||
order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(),order.getReceivingAddress(),order.getContactName(),order.getContactPhone(),order.getEstimatedArrivalAt(),order.getPurchaseWarehouseName(),
|
||||
(String)flow.get("actual_warehouse_code"),(String)flow.get("actual_warehouse_name"),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt(),ProcurementDirection.normalize(order.getProcurementDirection()));
|
||||
var budgets=jdbc.query("SELECT authorized_amount,currency,reviewed_at FROM ll_procurement_budget_authorization WHERE order_id=? ORDER BY id DESC LIMIT 1",
|
||||
(rs,n)->new Budget(rs.getBigDecimal(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime()),order.getId());
|
||||
var budget=budgets.isEmpty()?new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null):budgets.get(0);
|
||||
List<Progress> 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())
|
||||
var header=new Header(order.getId(),order.getOrderNo(),order.getCustomerId(),order.getCustomerName(),order.getPurchaseCategory(),order.getProductName(),
|
||||
order.getProductSpecification(),order.getProductUrl(),order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(),
|
||||
order.getReceivingAddress(),order.getContactName(),order.getContactPhone(),order.getEstimatedArrivalAt(),order.getPurchaseWarehouseName(),
|
||||
null,order.getPurchaseWarehouseName(),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt(),ProcurementDirection.normalize(order.getProcurementDirection()));
|
||||
var budget=new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null);
|
||||
var fees=manualFees.procurementFees(order.getCustomerId(),order.getId()).stream()
|
||||
.filter(f->"RECEIVABLE".equals(f.getDirection()) && "OVERSEAS_PROCUREMENT".equals(f.getBusinessType())
|
||||
&& order.getCustomerId().equals(f.getCustomerId()) && order.getId().equals(f.getBusinessId()))
|
||||
.map(CustomerFee::fromManual).toList());
|
||||
return new CustomerView(header,budget,flow.get("purchased_at")!=null,flow.get("purchase_confirmed_at")!=null,flow.get("purchase_issue")!=null,List.copyOf(progress),boxes,shipments,
|
||||
List.copyOf(customerFees),List.copyOf(summaries));
|
||||
.map(CustomerFee::fromManual).toList();
|
||||
return new CustomerView(header,budget,false,false,false,List.of(),List.of(),List.of(),fees,List.of());
|
||||
}
|
||||
|
||||
/** 平台旧区块为空,正式作业全部使用七状态批次接口。 */
|
||||
@Transactional(readOnly=true)
|
||||
public PlatformView platform(String customer,String orderNo,Set<String> permissions) {
|
||||
var order=lifecycle.requireOrder(customer,orderNo);var base=orders.detail(customer,orderNo);var flow=lifecycle.flow(order.getId());
|
||||
var physicalView=physical.read(customer,orderNo);var transportView=transport.read(customer,orderNo);
|
||||
return new PlatformView(customer(order),flow,physicalView,transportView,finance.all(order.getId()),adjustments.history(customer,orderNo),controls.read(customer,orderNo),base.files(),base.histories(),
|
||||
jdbc.queryForList("SELECT action,operator_id,reason,detail_json,created_at FROM ll_procurement_command WHERE order_id=? ORDER BY id DESC",order.getId()),actions(order,flow,physicalView,transportView,permissions),base.remark());
|
||||
var order=requireOrder(customer,orderNo);var base=orders.detail(customer,orderNo);
|
||||
return new PlatformView(customer(order),Map.of(),
|
||||
Map.of("dispositions",List.of(),"receipts",List.of(),"cartons",List.of()),
|
||||
Map.of("overseasCargo",List.of(),"domesticCargo",List.of(),"shipments",List.of()),List.of(),List.of(),
|
||||
Map.of("notes",List.of(),"remaining",List.of(),"closures",List.of(),"closureItems",List.of()),
|
||||
base.files(),base.histories(),List.of(),List.of(),base.remark());
|
||||
}
|
||||
/** 新采购通过批次命令校验资格,旧样品动作不再对七主状态开放。 */
|
||||
private List<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();
|
||||
boolean closed=Set.of("REJECTED","CANCELLED","EXCEPTION_CLOSED","SHIPPED_TO_CUSTOMER").contains(order.getStatus()),purchased=flow.get("purchased_at")!=null,international=flow.get("overseas_shipped_at")!=null;
|
||||
String stopped=closed?"订单已结束":null;
|
||||
String blockers=reason(()->lifecycle.requireNoBlockers(order));
|
||||
var sample=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT");boolean paid=sample!=null && finance.sourceLinked(sample);
|
||||
List<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 OverseasProcurementOrder requireOrder(String customer,String orderNo) {
|
||||
var order=repository.order(customer,orderNo,false);
|
||||
if(order==null) throw bad("采购单不存在或不属于当前客户");
|
||||
return order;
|
||||
}
|
||||
private static void action(List<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;}
|
||||
}
|
||||
|
||||
-279
@@ -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);}}
|
||||
}
|
||||
+4
-3
@@ -47,11 +47,12 @@ public class ProcurementWarehouseDirectory {
|
||||
}
|
||||
return warehouse;
|
||||
}
|
||||
public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id) {
|
||||
public record Sender(String name, String phone, String address) { }
|
||||
public Sender sender(long id) {
|
||||
return sender(id, ProcurementDirection.IMPORT);
|
||||
}
|
||||
/** 从起运仓主档生成发件信息,两种方向均校验作业地址。 */
|
||||
public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id, String direction) {
|
||||
public Sender sender(long id, String direction) {
|
||||
required(id, direction);
|
||||
var result=client.activeWarehouse(id,token);
|
||||
if(result==null || !result.successful() || result.data()==null || result.data().warehouse()==null) throw unavailable();
|
||||
@@ -69,7 +70,7 @@ public class ProcurementWarehouseDirectory {
|
||||
var a=operating.get(0);
|
||||
String address=java.util.stream.Stream.of(a.countryCode(),a.stateProvince(),a.city(),a.district(),a.addressLine1(),a.addressLine2(),a.postalCode())
|
||||
.filter(v->v!=null && !v.isBlank()).collect(java.util.stream.Collectors.joining(" "));
|
||||
return new com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address(warehouse.contactName(),warehouse.contactPhone(),address);
|
||||
return new Sender(warehouse.contactName(),warehouse.contactPhone(),address);
|
||||
}
|
||||
private static ResponseStatusException unavailable() {
|
||||
return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"采购收货仓目录暂不可用,请稍后重试");
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.LocalDateTime;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.bad;
|
||||
import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.conflict;
|
||||
import static com.baiyue.lastleg.procurement.service.ProcurementErrors.bad;
|
||||
import static com.baiyue.lastleg.procurement.service.ProcurementErrors.conflict;
|
||||
|
||||
/** 采购仅保留审核、分批收货、客户确认与分批出库;库存始终属于采购单。 */
|
||||
@Service @RequiredArgsConstructor
|
||||
|
||||
@@ -3220,39 +3220,6 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file (
|
||||
CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION'))
|
||||
) ENGINE=InnoDB COMMENT='海外采购签收文件及费用凭证';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee (
|
||||
purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定',
|
||||
confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作',
|
||||
fee_version INT NULL COMMENT '同用途费用确认版本',
|
||||
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本',
|
||||
determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用',
|
||||
original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD',
|
||||
original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额',
|
||||
exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照',
|
||||
exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份',
|
||||
business_date DATE NULL COMMENT '实际费用业务日期',
|
||||
wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录',
|
||||
wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估',
|
||||
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
|
||||
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用',
|
||||
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
|
||||
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
|
||||
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
|
||||
idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号',
|
||||
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
|
||||
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID',
|
||||
fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称',
|
||||
amount DECIMAL(20,2) NOT NULL COMMENT 'CNY金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', voucher_file_id BIGINT NULL COMMENT '凭证文件ID',
|
||||
voucher_file_name VARCHAR(255) NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明',
|
||||
wallet_status VARCHAR(32) NOT NULL COMMENT '资金状态', wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明',
|
||||
wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', created_by VARCHAR(128) NULL COMMENT '登记人',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
|
||||
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
|
||||
UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key),
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at),
|
||||
CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>=0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY')
|
||||
) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期';
|
||||
|
||||
|
||||
|
||||
@@ -3605,242 +3572,30 @@ PREPARE return_sku_index_stmt FROM @return_sku_index_ddl;
|
||||
EXECUTE return_sku_index_stmt;
|
||||
DEALLOCATE PREPARE return_sku_index_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_wallet_attempt (
|
||||
id BIGINT NOT NULL COMMENT '请求尝试ID',
|
||||
order_id BIGINT NOT NULL COMMENT '采购订单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID',
|
||||
attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号',
|
||||
operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款',
|
||||
business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号',
|
||||
amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空',
|
||||
command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用',
|
||||
status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败',
|
||||
result_hash CHAR(64) NULL COMMENT '原业务回执摘要',
|
||||
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用',
|
||||
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用',
|
||||
error_code VARCHAR(64) NULL COMMENT '业务失败码',
|
||||
error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间',
|
||||
completed_at DATETIME(3) NULL COMMENT '最终结果时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id),
|
||||
UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no),
|
||||
KEY idx_procurement_attempt_order(customer_id,order_id,status),
|
||||
CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果';
|
||||
|
||||
-- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_flow (
|
||||
order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
purchased_at DATETIME(3) NULL COMMENT '真实购买时间',
|
||||
purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品',
|
||||
purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格',
|
||||
purchased_quantity BIGINT NULL COMMENT '实际购买件数',
|
||||
purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额',
|
||||
purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD',
|
||||
purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用',
|
||||
purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明',
|
||||
purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功',
|
||||
actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID',
|
||||
actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照',
|
||||
actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照',
|
||||
overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间',
|
||||
packing_version INT NULL COMMENT '当前确认装箱清单版本',
|
||||
overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间',
|
||||
domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间',
|
||||
charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间',
|
||||
customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间',
|
||||
domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人',
|
||||
domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话',
|
||||
domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址',
|
||||
cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间',
|
||||
closed_at DATETIME(3) NULL COMMENT '异常关闭时间',
|
||||
control_reason VARCHAR(1000) NULL COMMENT '取消或关闭原因',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id),
|
||||
CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0),
|
||||
CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_command (
|
||||
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要',
|
||||
operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据',
|
||||
detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key),
|
||||
KEY idx_procurement_command_customer(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_budget_authorization (
|
||||
id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种',
|
||||
authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种',
|
||||
voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据',
|
||||
reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间',
|
||||
PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id),
|
||||
CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0),
|
||||
CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史';
|
||||
|
||||
-- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_receipt (
|
||||
id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内',
|
||||
source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓',
|
||||
good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量',
|
||||
received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实',
|
||||
supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active),
|
||||
CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0),
|
||||
CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_disposition (
|
||||
id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源',
|
||||
receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货',
|
||||
quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货',
|
||||
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实',
|
||||
voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
|
||||
PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind),
|
||||
CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_manifest (
|
||||
id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代',
|
||||
confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version),
|
||||
CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_carton (
|
||||
id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本',
|
||||
carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米',
|
||||
width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米',
|
||||
gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id),
|
||||
CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_carton_source (
|
||||
carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数',
|
||||
PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_shipment (
|
||||
id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户',
|
||||
manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间',
|
||||
address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no),
|
||||
CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_shipment_item (
|
||||
shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数',
|
||||
carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照',
|
||||
label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照',
|
||||
PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实';
|
||||
|
||||
-- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_label_set (
|
||||
id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本',
|
||||
manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要',
|
||||
sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version),
|
||||
CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_label_item (
|
||||
label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID',
|
||||
file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始',
|
||||
carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号',
|
||||
applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人',
|
||||
PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_estimate_review (
|
||||
id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本',
|
||||
fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员',
|
||||
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_repack (
|
||||
id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源',
|
||||
length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米',
|
||||
height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克',
|
||||
voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
|
||||
PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id),
|
||||
CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史';
|
||||
|
||||
-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_fee_correction (
|
||||
id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要',
|
||||
target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种',
|
||||
exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份',
|
||||
target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY',
|
||||
difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录',
|
||||
voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key),
|
||||
KEY idx_procurement_correction_root(order_id,root_fee_id,id),
|
||||
CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_fee_correction_item (
|
||||
correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID',
|
||||
PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
|
||||
|
||||
-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_control_note (
|
||||
id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型',
|
||||
target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约',
|
||||
voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明',
|
||||
client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容',
|
||||
original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
|
||||
resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间',
|
||||
resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_closure (
|
||||
id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
|
||||
closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证',
|
||||
financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实',
|
||||
reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_procurement_closure_item (
|
||||
id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID',
|
||||
source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空',
|
||||
quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置',
|
||||
destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明',
|
||||
PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
|
||||
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_appointment_impact (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -63,7 +63,7 @@ paths:
|
||||
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds:
|
||||
post:
|
||||
summary: 从采购库存创建出库单
|
||||
description: 权限 platform:overseas-business:purchase:ship-customer;仅平台主体,必须指定目标客户 customerId。仅 CONFIRMED、IN_TRANSIT 或 DELIVERING;数量不得超过 availableQuantity,物流跟踪号必填。创建后占用数量增加,不实际扣库存。
|
||||
description: 权限 platform:overseas-business:purchase:ship-customer;仅平台主体,必须指定目标客户 customerId。仅 CONFIRMED、IN_TRANSIT 或 DELIVERING;数量不得超过 availableQuantity,物流跟踪号必填。必须存在本采购单未作废应收费用且全部有效并审核通过;缺少费用、待审核或驳回时拒绝出库。创建后占用数量增加,不实际扣库存。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OrderNo'
|
||||
- $ref: '#/components/parameters/CustomerId'
|
||||
|
||||
+4
-2
@@ -23,8 +23,10 @@ class OverseasProcurementSchemaContractTest {
|
||||
assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL"));
|
||||
assertTrue(sql.contains("review_request_hash CHAR(64) NULL"));
|
||||
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_details"));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment"));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment_item"));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_workflow_receipt"));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_workflow_outbound"));
|
||||
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment"));
|
||||
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee"));
|
||||
assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'"));
|
||||
assertTrue(sql.contains("change_detail JSON NULL"));
|
||||
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound"));
|
||||
|
||||
+50
@@ -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:");
|
||||
}
|
||||
}
|
||||
+6
-9
@@ -50,10 +50,7 @@ class ProcurementRequestBoundaryTest {
|
||||
var json=new ObjectMapper().findAndRegisterModules();
|
||||
try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) {
|
||||
var validator=factory.getValidator();
|
||||
for(var type:List.of(com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command.class,
|
||||
com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Purchase.class,
|
||||
com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Budget.class,
|
||||
com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Warehouse.class)) {
|
||||
for(var type:List.of(OverseasProcurementRequests.Review.class, OverseasProcurementRequests.Update.class)) {
|
||||
assertThat(validator.validate(json.readValue("{}",type))).anyMatch(v->"version".equals(v.getPropertyPath().toString()));
|
||||
}
|
||||
}
|
||||
@@ -67,10 +64,10 @@ class ProcurementRequestBoundaryTest {
|
||||
}
|
||||
|
||||
@Test void customerProjectionExcludesInternalFilesAndFinancialDiagnostics() throws Exception {
|
||||
var fee=new com.baiyue.lastleg.procurement.entity.OverseasProcurementFee();
|
||||
fee.setFeeNo("PF-1");fee.setFeeItemName("关税");fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED");
|
||||
fee.setVoucherFileId(2L);fee.setVoucherFileName("private.pdf");fee.setWalletErrorMessage("database error");fee.setRemark("internal note");fee.setCreatedBy("operator");fee.setWalletLedgerId("private-ledger");
|
||||
var output=new ObjectMapper().writeValueAsString(com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerFee.from(fee,null,false));
|
||||
assertThat(output).contains("PF-1","FREEZE_FAILED").doesNotContain("private.pdf","voucherFileId","internal note","database error","operator","private-ledger");
|
||||
var fee=new com.baiyue.lastleg.financefee.entity.OverseasFeeSource();
|
||||
fee.setSourceFeeId(1L);fee.setFeeItemName("关税");fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED");
|
||||
fee.setPaymentVoucherFileIds(List.of("private.pdf"));fee.setWalletErrorMessage("database error");fee.setReviewReason("internal note");fee.setCreatedBy("operator");
|
||||
var output=new ObjectMapper().writeValueAsString(com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerFee.fromManual(fee));
|
||||
assertThat(output).contains("FREEZE_FAILED").doesNotContain("private.pdf","voucherFileId","internal note","database error","operator","private-ledger");
|
||||
}
|
||||
}
|
||||
|
||||
-145
@@ -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");}
|
||||
}
|
||||
-6
@@ -7,7 +7,6 @@ import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper;
|
||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper;
|
||||
import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway;
|
||||
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
@@ -43,10 +42,8 @@ class OverseasProcurementServiceTest {
|
||||
@Mock OverseasProcurementOrderMapper orders;
|
||||
@Mock OverseasProcurementStatusHistoryMapper histories;
|
||||
@Mock ProcurementWarehouseDirectory warehouseDirectory;
|
||||
@Mock com.baiyue.lastleg.procurement.repository.ProcurementDirectionRepository directionRepository;
|
||||
@Mock com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator;
|
||||
@Mock OverseasProcurementFileMapper procurementFiles;
|
||||
@Mock OverseasProcurementFeeMapper procurementFees;
|
||||
@Mock ProcurementFileGateway fileGateway;
|
||||
@Mock WalletCommandOutboxService walletCommands;
|
||||
@Mock OverseasFeeSourceService feeSources;
|
||||
@@ -58,15 +55,12 @@ class OverseasProcurementServiceTest {
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "histories", histories);
|
||||
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
|
||||
ReflectionTestUtils.setField(service, "directionRepository", directionRepository);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
|
||||
ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles);
|
||||
ReflectionTestUtils.setField(service, "procurementFees", procurementFees);
|
||||
ReflectionTestUtils.setField(service, "fileGateway", fileGateway);
|
||||
lenient().when(orderNoGenerator.next(anyString())).thenReturn("ACME202609050001");
|
||||
lenient().when(procurementFiles.selectList(any())).thenReturn(List.of());
|
||||
lenient().when(procurementFees.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
-260
@@ -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)));}
|
||||
}
|
||||
-130
@@ -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)));}
|
||||
}
|
||||
-31
@@ -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();
|
||||
}
|
||||
}
|
||||
-287
@@ -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)); }
|
||||
}
|
||||
-238
@@ -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")));}
|
||||
}
|
||||
-184
@@ -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");
|
||||
}
|
||||
}
|
||||
+50
-91
@@ -1,106 +1,65 @@
|
||||
package com.baiyue.lastleg.procurement.service;
|
||||
|
||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
|
||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee;
|
||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasManualFeeService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 仅创建六张正式采购表,真实 Mapper 验证详情、审核、文件和客户费用不会访问任何旧表。 */
|
||||
class ProcurementQueryServiceTest {
|
||||
final ProcurementControlPersistenceTest fixture=new ProcurementControlPersistenceTest();
|
||||
ProcurementQueryService query;ProcurementTransportService transport;OverseasProcurementService base;
|
||||
ObjectMapper json=new ObjectMapper().findAndRegisterModules();
|
||||
@BeforeEach void setup()throws Exception {
|
||||
fixture.setup();var jdbc=fixture.jdbc;
|
||||
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
for(String name:List.of("ll_procurement_budget_authorization","ll_procurement_manifest","ll_procurement_carton_source","ll_procurement_label_set","ll_procurement_label_item")) {
|
||||
var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);assertThat(m.find()).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
|
||||
}
|
||||
var physical=new ProcurementPhysicalService(fixture.lifecycle,fixture.warehouses,fixture.finance,jdbc);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(physical, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
|
||||
transport=new ProcurementTransportService(fixture.lifecycle,physical,fixture.finance,fixture.warehouses,jdbc,json);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(transport, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
|
||||
base=mock(OverseasProcurementService.class);
|
||||
when(base.detail("9","PO-1")).thenReturn(new OverseasProcurementResponses.Detail(null,null,null,null,null,null,"internal remark","operator","reviewer","internal review",null,List.of(),List.of(new OverseasProcurementResponses.FileInfo(1L,"EXCEPTION",1001L,"private.pdf","application/pdf",10,null)),List.of()));
|
||||
query=new ProcurementQueryService(fixture.lifecycle,physical,transport,fixture.finance,mock(ProcurementAdjustmentService.class),fixture.service,base,jdbc);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(query, "manualFees", mock(com.baiyue.lastleg.financefee.service.OverseasManualFeeService.class));
|
||||
ProcurementWorkflowPersistenceTest fixture;
|
||||
ProcurementQueryService query;
|
||||
OverseasProcurementService orders;
|
||||
OverseasManualFeeService fees;
|
||||
ProcurementWarehouseDirectory warehouses;
|
||||
@BeforeEach void setup() throws Exception {
|
||||
fixture=new ProcurementWorkflowPersistenceTest();fixture.setup();
|
||||
orders=new OverseasProcurementService();
|
||||
ReflectionTestUtils.setField(orders,"orders",fixture.orders);
|
||||
ReflectionTestUtils.setField(orders,"histories",fixture.histories);
|
||||
ReflectionTestUtils.setField(orders,"procurementFiles",fixture.files);
|
||||
ReflectionTestUtils.setField(orders,"objectMapper",new ObjectMapper().findAndRegisterModules());
|
||||
warehouses=mock(ProcurementWarehouseDirectory.class);
|
||||
ReflectionTestUtils.setField(orders,"warehouseDirectory",warehouses);
|
||||
fees=mock(OverseasManualFeeService.class);
|
||||
when(fees.procurementFees("9",11L)).thenReturn(List.of());
|
||||
query=new ProcurementQueryService(fixture.repository,orders,fees);
|
||||
}
|
||||
@Test void customerResponseContainsStructuredFactsAndOnlyApprovedPublicSummaries()throws Exception {
|
||||
fixture.jdbc.update("UPDATE ll_procurement_flow SET purchase_issue='private purchase diagnosis' WHERE order_id=11");
|
||||
fixture.jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,confirmed_at,created_by) VALUES(301,11,'9',1,'CONFIRMED',CURRENT_TIMESTAMP,'7')");
|
||||
fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'BOX-A',20,30,40,5.125,10)");
|
||||
fixture.jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(401,11,'9','PS-1','OVERSEAS',301,CURRENT_TIMESTAMP,'{}','7')");
|
||||
fixture.jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(401,201,10,'Express','TRACK-1','{\"privateFile\":1001}')");
|
||||
fixture.jdbc.update("INSERT INTO ll_procurement_control_note(id,order_id,customer_id,note_no,target_type,blocking,voucher_file_id,reason,client_summary,created_by) VALUES(501,11,'9','PN-1','PURCHASE',0,1001,'private evidence','Customer-approved summary','internal-operator')");
|
||||
fixture.jdbc.update("INSERT INTO ll_procurement_budget_authorization(id,order_id,customer_id,previous_amount,previous_currency,authorized_amount,currency,voucher_file_id,reason,reviewed_by) VALUES(601,11,'9',100,'CNY',150,'CNY',1001,'private budget discussion','reviewer')");
|
||||
var fee=new OverseasProcurementFee();fee.setId(1L);fee.setFeeNo("PF-1");fee.setPurpose("SAMPLE_PAYMENT");fee.setFeeItemName("样品货款");fee.setFeeVersion(1);fee.setActive(true);fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED");fee.setWalletErrorMessage("private database error");fee.setVoucherFileName("private.pdf");fee.setVoucherFileId(1001L);fee.setWalletLedgerId("private-ledger");
|
||||
when(fixture.finance.all(11)).thenReturn(List.of(fee));
|
||||
var result=query.customer("9","PO-1");assertThat(result.budget().totalAmount()).isEqualByComparingTo("150");assertThat(result.purchaseNeedsAttention()).isTrue();
|
||||
assertThat(result.boxes()).singleElement().satisfies(b->assertThat(b.cartonNo()).isEqualTo("BOX-A"));
|
||||
assertThat(result.shipments()).singleElement().satisfies(s->assertThat(s.trackingNo()).isEqualTo("TRACK-1"));
|
||||
var output=json.writeValueAsString(result);assertThat(output).contains("BOX-A","TRACK-1","PF-1","Customer-approved summary")
|
||||
.doesNotContain("private","voucherFileId","walletLedgerId","createdBy","internal-operator","reason","purchaseFacts");
|
||||
verifyNoInteractions(base);
|
||||
@Test void currentDetailsReadOnlySixTablesAndReturnNoLegacyActionsOrPhysicalFacts() {
|
||||
fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET budget_unit_price=15,purchase_warehouse_name='洛杉矶仓',remark='internal note' WHERE id=11");
|
||||
var platform=query.platform("9","CG-1",Set.of("platform:overseas-business:purchase:buy"));
|
||||
assertThat(platform.customerView().budget().totalAmount()).isEqualByComparingTo("150");
|
||||
assertThat(platform.customerView().order().actualWarehouseName()).isEqualTo("洛杉矶仓");
|
||||
assertThat(platform.customerView().boxes()).isEmpty();assertThat(platform.customerView().shipments()).isEmpty();
|
||||
assertThat(platform.actions()).isEmpty();assertThat(platform.operations()).isEmpty();
|
||||
assertThat(platform.files()).hasSize(3);assertThat(platform.internalRemark()).isEqualTo("internal note");
|
||||
assertThat(orders.detail("9","CG-1").fees()).isEmpty();
|
||||
}
|
||||
@Test void platformCombinesDataAndAuthoritativeShippingBlockReasonsWithoutGrantingOtherActions() {
|
||||
var permissions=Set.of("platform:overseas-business:purchase:receive-overseas","platform:overseas-business:purchase:ship-customer");
|
||||
var result=query.platform("9","PO-1",permissions);
|
||||
assertThat(result.internalRemark()).isEqualTo("internal remark");assertThat(result.files()).hasSize(1);
|
||||
assertThat(result.controls().remaining()).hasSize(1);
|
||||
assertThat(result.actions()).filteredOn(a->a.key().equals("RECEIVE_OVERSEAS")).singleElement().satisfies(a->assertThat(a.allowed()).isTrue());
|
||||
assertThat(result.actions()).filteredOn(a->a.key().equals("SHIP_CUSTOMER")).singleElement().satisfies(a->{assertThat(a.allowed()).isFalse();assertThat(a.reason()).contains("样品货款");});
|
||||
assertThat(result.actions()).filteredOn(a->a.key().equals("FINANCE_ADJUST")).singleElement().satisfies(a->assertThat(a.reason()).isEqualTo("无此操作权限"));
|
||||
@Test void reviewAndRemarkUpdatesNoLongerRequireLegacyFeeOrDirectionTables() {
|
||||
fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET status='PENDING_REVIEW' WHERE id=11");
|
||||
when(warehouses.required(91)).thenReturn(new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(91L,"LAX","洛杉矶仓","US","ACTIVE"));
|
||||
var result=fixture.tx.execute(s->orders.review("9","CG-1","7",new OverseasProcurementRequests.Review(0L,"APPROVE","review",null,91L)));
|
||||
assertThat(result.summary().status()).isEqualTo("RECEIVING");assertThat(result.histories()).hasSize(1);
|
||||
assertThat(query.customer("9","CG-1").order().actualWarehouseName()).isEqualTo("洛杉矶仓");
|
||||
}
|
||||
@Test void anotherCustomerCannotLoadEvenPublicProjection() {
|
||||
assertThatThrownBy(()->query.customer("10","PO-1")).hasMessageContaining("当前客户");verifyNoInteractions(fixture.finance,base);
|
||||
@Test void customerCannotReadAnotherCustomersOrderOrInternalDiagnostics() throws Exception {
|
||||
assertThatThrownBy(()->query.customer("10","CG-1")).hasMessageContaining("当前客户");
|
||||
verifyNoInteractions(fees);
|
||||
var fee=fee("9",11L,"RECEIVABLE");fee.setWalletErrorMessage("private error");fee.setReviewReason("private reason");
|
||||
when(fees.procurementFees("9",11L)).thenReturn(List.of(fee,fee("10",11L,"RECEIVABLE"),fee("9",12L,"RECEIVABLE"),fee("9",11L,"PAYABLE")));
|
||||
var result=query.customer("9","CG-1");
|
||||
assertThat(result.fees()).singleElement().satisfies(row->{assertThat(row.walletStatus()).isEqualTo("FREEZE_PENDING");assertThat(row.sourceLinked()).isFalse();});
|
||||
assertThat(new ObjectMapper().findAndRegisterModules().writeValueAsString(result)).doesNotContain("private", "walletErrorMessage", "reviewReason", "internalRemark");
|
||||
}
|
||||
/** 共用费用只公开当前采购单应收和资金结果,过滤其他客户、单据及内部诊断。 */
|
||||
@Test void customerReadsOnlyOwnedManualProcurementFeeResults() throws Exception {
|
||||
var manual = mock(com.baiyue.lastleg.financefee.service.OverseasManualFeeService.class);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(query, "manualFees", manual);
|
||||
var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource();
|
||||
fee.setSourceFeeId(901L); fee.setSourceVersion(1); fee.setCustomerId("9"); fee.setBusinessId(11L);
|
||||
fee.setBusinessType("OVERSEAS_PROCUREMENT"); fee.setDirection("RECEIVABLE"); fee.setSourceStatus("ACTIVE");
|
||||
fee.setFeeItemCode("SERVICE"); fee.setFeeItemName("采购服务费"); fee.setReviewStatus("PENDING");
|
||||
fee.setWalletStatus("FREEZE_PENDING"); fee.setWalletErrorMessage("private funding diagnosis");
|
||||
fee.setAmount(BigDecimal.TEN); fee.setCurrency("CNY"); fee.setReviewReason("private review reason");
|
||||
var other = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource();
|
||||
other.setBusinessType("OVERSEAS_PROCUREMENT"); other.setDirection("RECEIVABLE");
|
||||
other.setCustomerId("10"); other.setBusinessId(11L);
|
||||
when(manual.procurementFees("9", 11L)).thenReturn(List.of(fee, other));
|
||||
var result = query.customer("9", "PO-1");
|
||||
assertThat(result.fees()).singleElement().satisfies(row -> {
|
||||
assertThat(row.feeNo()).isEqualTo("901");
|
||||
assertThat(row.walletStatus()).isEqualTo("FREEZE_PENDING");
|
||||
assertThat(row.sourceLinked()).isFalse();
|
||||
});
|
||||
assertThat(json.writeValueAsString(result)).doesNotContain("private", "walletErrorMessage", "reviewReason");
|
||||
private OverseasFeeSource fee(String customer,long order,String direction) {
|
||||
var fee=new OverseasFeeSource();fee.setSourceFeeId(901L);fee.setSourceVersion(1);fee.setCustomerId(customer);fee.setBusinessId(order);
|
||||
fee.setBusinessType("OVERSEAS_PROCUREMENT");fee.setDirection(direction);fee.setSourceStatus("ACTIVE");fee.setReviewStatus("PENDING");
|
||||
fee.setFeeItemCode("SERVICE");fee.setFeeItemName("采购服务费");fee.setWalletStatus("FREEZE_PENDING");fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");return fee;
|
||||
}
|
||||
@Test void draftAndSupersededPackingAreNotDisplayedAsConfirmedCustomerBoxes() {
|
||||
fixture.jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(301,11,'9',1,'SUPERSEDED','7'),(302,11,'9',2,'DRAFT','7')");
|
||||
fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'OLD',20,30,40,5,10),(202,11,302,'DRAFT',20,30,40,5,10)");
|
||||
assertThat(query.customer("9","PO-1").boxes()).isEmpty();
|
||||
}
|
||||
|
||||
/** 新流程不再提示旧购买、装箱和两段核费动作。 */
|
||||
@Test void simplifiedWorkflowDoesNotExposeLegacySampleActions() {
|
||||
fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET status='RECEIVING' WHERE id=11");
|
||||
assertThat(query.platform("9", "PO-1", Set.of("platform:overseas-business:purchase:buy")).actions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test void exportProjectionExposesDirectionAndLabelsWithoutRewritingHistoricalLegs() {
|
||||
fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET procurement_direction=? WHERE id=11",ProcurementDirection.EXPORT);
|
||||
var result=query.platform("9","PO-1",Set.of("platform:overseas-business:purchase:receive-overseas"));
|
||||
assertThat(result.customerView().order().procurementDirection()).isEqualTo(ProcurementDirection.EXPORT);
|
||||
assertThat(result.customerView().progress()).extracting(ProcurementQueryService.Progress::title).contains("国内收货完成","海外收货完成","海外费用核定");
|
||||
assertThat(result.actions()).filteredOn(a -> a.key().equals("RECEIVE_OVERSEAS")).singleElement().satisfies(a -> assertThat(a.label()).isEqualTo("登记国内实收"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
-204
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+6
-3
@@ -31,20 +31,23 @@ class ProcurementWorkflowPersistenceTest {
|
||||
ProcurementWorkflowService service;
|
||||
OverseasProcurementOrderMapper orders;
|
||||
OverseasProcurementFileMapper files;
|
||||
OverseasProcurementStatusHistoryMapper histories;
|
||||
ProcurementWorkflowRepository repository;
|
||||
OverseasFeeSourceService fees;
|
||||
|
||||
@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: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);
|
||||
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 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());
|
||||
fees=mock(OverseasFeeSourceService.class);
|
||||
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED")));
|
||||
|
||||
Reference in New Issue
Block a user