Compare commits

...
52 Commits
Author SHA1 Message Date
ouyangxq 97c80574df Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-11-05 14:53:54 +08:00
ouyangxq b063d7866c 优化 2026-11-05 14:53:52 +08:00
liuyihe 5a8918e2e4 提交 2026-11-05 11:55:23 +08:00
liuyihe 94a36b8c4c 提交 2026-11-04 19:26:27 +08:00
ouyangxq 51291b4c27 优化 2026-11-04 18:08:19 +08:00
liuyihe 0993dd446e 提交 2026-11-04 15:55:55 +08:00
ouyangxq fbdf58c23c 补齐海外仓和私人地址正常及短少收货真实验收 2026-11-04 14:23:41 +08:00
ouyangxq 40475388e1 支持私人地址按实际收货生成库存凭证 2026-11-04 13:55:24 +08:00
ouyangxq 22e8939f1f 修复私人地址逐箱收货的目的地匹配 2026-11-04 13:45:59 +08:00
ouyangxq a01598bd21 允许私人地址按独立托盘生成海外入库计划 2026-11-04 13:35:23 +08:00
ouyangxq 0d8e64c42c 支持私人地址装柜后按普通海外收货交接 2026-11-04 12:45:05 +08:00
ouyangxq 8b56af2cae 统一人工报价时间精度以保证原键精确重放 2026-11-04 12:28:47 +08:00
liuyihe 12617e35a0 Merge remote-tracking branch 'origin/test' into test 2026-11-04 12:22:26 +08:00
liuyihe 46bc137e99 提交 2026-11-04 12:21:59 +08:00
ouyangxq 4165dc3403 将国内初始化成功标记归位到全部语句末尾 2026-11-04 12:17:23 +08:00
ouyangxq 6e451a82da 支持私人地址人工头程报价以闭合预装费用前置 2026-11-04 12:10:14 +08:00
ouyangxq 78ecd2e039 同步派送异常退回订单类型的测试断言 2026-11-04 11:26:13 +08:00
liuyihe 3b1e38cf90 Merge remote-tracking branch 'origin/test' into test 2026-11-04 11:13:44 +08:00
liuyihe ba49035942 提交 2026-11-04 11:13:29 +08:00
ouyangxq 91e4fc33ff 补齐Lastleg回归夹具与现行接口合同断言 2026-11-04 11:12:01 +08:00
liuyihe 413e1e83e8 提交 2026-11-04 10:39:00 +08:00
ouyangxq c3a6519ce4 修正Lastleg废弃箱唛字段的初始化归属 2026-11-04 10:24:39 +08:00
ouyangxq 2eb0b65ba5 修复海外散货托盘收货与短少入库闭环 2026-11-04 09:57:14 +08:00
liuyihe b95281bacb 提交 2026-11-03 16:56:14 +08:00
liuyihe 5d6b2812cf 提交 2026-11-03 16:51:30 +08:00
liuyihe 1506576c67 提交 2026-11-03 16:14:51 +08:00
liuyihe 7b5ef9ba64 提交 2026-11-03 11:41:58 +08:00
liuyihe 21a9ce7d27 提交 2026-11-02 17:14:02 +08:00
ouyangxq 035e7e06af 修复费用审核前调整和运输取消及对账明细约束 2026-10-30 18:51:28 +08:00
liuyihe 5825739b92 提交 2026-10-30 18:46:44 +08:00
liuyihe edd4b1cd38 提交 2026-10-30 18:46:18 +08:00
liuyihe 51a93a69af 提交 2026-10-30 17:59:12 +08:00
ouyangxq 872215367b 修复废弃预留重放与预算到期时区 2026-10-30 15:42:39 +08:00
liuyihe ac013c265b 提交 2026-10-30 13:48:20 +08:00
liuyihe e1bfcfc8f5 提交 2026-10-30 11:54:49 +08:00
liuyihe 0bd25de906 提交 2026-10-29 15:49:18 +08:00
liuyihe 849f8dc5f2 提交 2026-10-29 14:29:02 +08:00
liuyihe 1b27d7e6c2 提交 2026-10-29 13:36:38 +08:00
ouyangxq 353c86f4cf 为船期节点记录并返回操作人快照 2026-10-28 16:56:33 +08:00
liuyihe 71a1026e21 提交 2026-10-28 14:44:59 +08:00
liuyihe f3d57869f2 费用提交 2026-10-28 13:57:34 +08:00
liuyihe 7086713fdd Merge remote-tracking branch 'origin/test' into test 2026-10-28 13:42:18 +08:00
liuyihe 66b0935b11 费用提交 2026-10-28 13:42:03 +08:00
ouyangxq 4f2d90b490 完善预约货物资料与可选确认时间并修复排车衔接 2026-10-28 11:57:09 +08:00
liuyihe 96ffc0925a 费用提交 2026-10-28 11:29:38 +08:00
ouyangxq ca61f79b6d 修正外箱快照清空并提供当前计费量只读预览 2026-10-28 11:03:44 +08:00
ouyangxq b2e4f0d246 统一设备与人工测量默认算法和外箱计费快照 2026-10-28 10:30:40 +08:00
ouyangxq 0b13c2cfb7 修正排车照片端口与车次模型依赖合同 2026-10-28 10:26:52 +08:00
liuyihe c20107c278 Merge remote-tracking branch 'origin/test' into test 2026-10-27 18:28:32 +08:00
liuyihe f418f9a05f 费用提交 2026-10-27 18:27:56 +08:00
ouyangxq 705c732ffd Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-10-27 17:44:43 +08:00
ouyangxq db0e72daeb 优化 2026-10-27 17:44:41 +08:00
496 changed files with 16143 additions and 10925 deletions
+1 -1
View File
@@ -55,7 +55,7 @@
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` |
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` | | 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` |
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` |
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` | | 海外业务费用来源跨区入池、人工费用与自动仓租 | `lastleg-service/`(`financefee/`、`storagerent/`)、`common-event-contract/`、`finance/` |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | | 供应商、国内整柜库存与出入库 | `warehouse/` |
+3
View File
@@ -10,6 +10,9 @@ baiyue.file.storage-root: ${FILE_STORAGE_ROOT}
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:} baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
baiyue: baiyue:
file: file:
pda:
# 在目标 Nacos 命名空间填写公网 HTTPS 根地址,不含 /api;真实环境地址不提交仓库。
public-base-url: ""
export: export:
definitions: definitions:
CUSTOMER_LIST: CUSTOMER_LIST:
File diff suppressed because it is too large Load Diff
+27 -6
View File
@@ -1,9 +1,25 @@
# 单测试环境数据库空库初始化 # 单测试环境数据库空库初始化
`20261104-unify-utf8mb4-0900-ai-ci.sql` 是本机 MySQL 8.4.11 五个业务库的排序规则汇总脚本,覆盖 `baiyue_event_bridge`、`baiyue_event_bridge_overseas`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_order`。脚本已在本机执行并核验 209 张表、1,644 个文本字段;其他环境执行前必须核对实际字段定义和唯一索引冲突,不能默认部署。脚本只收敛旧的 Unicode 不区分大小写排序规则,保留有意使用的 `utf8mb4_bin`。
退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 Lastleg 库先执行 [整托计费结构增量](return-storage-rent-whole-pallet.sql) 增加 `ll_storage_rent_lot.charged_days`,再发布匹配的 Lastleg;源基线和海外空库基线已同步。实际出库事务推进已生成费用的截止库龄日,同托同计租日不重复收费。切换期间暂停相关出库、取消未消费预占,并逐单核对旧比例费用;脚本不修改既有费用、库存或资金。本次仅准备代码及脚本,未执行环境 SQL 或部署。
散板收货闭环:`migrations/overseas/baiyue_lastleg/V37__looseboard_receiving_workflow.sql` 为已有 Lastleg 库增加入仓方式、收货仓、联系人、预计到仓、物流号及转运关联字段;新库使用同步后的海外统一基线。IAM 使用 `migrations/cn/baiyue_iam/V7__looseboard_demand_cargo_permission.sql` 补齐“散板需求”菜单及“加入散板货物”按钮,并为既有平台管理员补取货排车创建权限;权限源同步于 `iam/src/main/resources/db/fleet-capacity-offer-cargo-permission.sql` 和国内统一基线。两项已在本机应用并通过两场景HTTP验收,未据此认定TEST或生产已部署;已有库不可重放空库基线,V37执行前须核对九个新增字段尚不存在。
`migrations/overseas/baiyue_lastleg/V35__widen_finance_fee_command_action.sql` 修复海外人工费用资金重试报 S0500:仅将 `baiyue_lastleg.ll_finance_fee_command.action` 扩为 `VARCHAR(32)`,容纳 20 字符的 `MANUAL_FUNDING_RETRY`,保留中文备注、非空及原幂等唯一键。Lastleg 源基线和海外空库基线同步。已有环境先核对目标库及列定义,经授权执行 V35、回查列长度和记录总数,再通过原费用重试入口验证;可重复执行,不修改费用、审核、余额或资金记录,不重放空库基线。DDL 隐式提交,遇错停止;本次仅准备修复文件,尚未执行测试环境 SQL 或部署,文件存在不表示已经执行。
`overseas-fee-menu-finance-parent-repair.sql` 为已有 `baiyue_iam` 的定向权限修复:将菜单 `platform:overseas-fee:list` 改名为“海外费用明细”,父菜单设为 `platform:finance`(财务管理),保留原路由和 PLATFORM_ADMIN 授权;可重复执行,目标菜单缺失或权限身份冲突时停止。
`overseas-procurement-test-data-reset.sql` 为用户已确认可丢弃采购造数后的测试专用重置脚本:仅清空六张当前采购专属表,删除二十一张旧采购表;不会修改共用费用、余额、文件对象、库存或其他业务表,也不处理共用表的历史采购关联记录或退款。先停止采购写入并更新分离后的后端,完整执行文件、遇错停止,禁止 `--force`;DELETE 使用事务,后续 DROP 为隐式提交,不能整体回滚。无需存储过程、DELIMITER 或确认变量;成功返回 `procurement_test_reset_complete`,两个数量均为 0,成功后可重复执行。2026-10-27 已在本地隔离 MySQL 8.0.40 使用真实 27 表定义、三张 APPROVED 单及收货/出库/旧费用记录验证两次执行成功,共用表对照数据不变;未连接用户目标数据库,不表示部署完成。
`finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。 `finance-receivable-active-uniqueness-repair.sql` 为 TEST 应收作废后重新建单的定向约束修复,仅以一条原子 ALTER 将 `baiyue_finance.fin_settlement` 的全历史唯一键替换为有效应收唯一键;作废历史仍保留原对账单关联,同一客户、同一对账单最多一张未作废应收。须先具备 Finance 重开校验版本 `fe89b8a2b875`,再单独授权执行 SQL;不修改业务金额、不清理或回填数据、不重放空库基线。执行前后核对表结构与记录总数,遇错停止,结果不明确先回查;脚本存在不表示已执行。
`finance-reconciliation-line-uniqueness-repair.sql` 修正 TEST 作废客户对账单后原费用无法重新生成对账单的问题:以一条原子 ALTER 将 `baiyue_finance.fin_bill_line` 的费用版本全历史唯一键改为单内唯一键,并保留费用来源普通索引。作废明细和金额快照完整保留;有效费用占用继续由 Finance 生成时的行锁、AVAILABLE → LOCKED 状态校验及作废单重开校验保证。模块基线与统一空库基线同步。先核对上述服务校验及目标表结构,经单独授权执行,再核对结构和记录总数;遇错停止,结果不明确先回查,不盲目重放,不更新业务数据或重放空库基线。脚本存在不表示已执行。
`overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。 `overseas-procurement-workflow.sql` 新增简化采购的分批收货、分批出库与幂等三表,目标 `baiyue_lastleg`。新采购审核后直接分批收货,客户确认后创建出库,签收才扣采购库存;配套发布 Lastleg、Admin 和 Client。源基线与海外空库基线同步,不回填旧采购事实;结构脚本尚未执行。
`overseas-procurement-table-cleanup-review.sql` 为海外采购 27 表的只读核查,元数据行数为估算,另输出可手工执行的精确 COUNT。2026-10-27 已解除 21 张旧采购专属表的生产代码依赖,当前基线只保留六表。`overseas-procurement-legacy-tables-retire.sql` 为单独审阅执行的 MySQL 8.0+ 精确删表清单:仅 `baiyue_lastleg` 的 21 张旧采购表,不修改共用财务、资金、文件或仓储表。须先备份、停止旧版本写入、部署分离版本并验证,在维护窗口同连接设置脚本指定发布确认变量;六表缺失、旧状态采购或旧资金记录存在时拒绝。前置检查在业务 DROP 前完成,不关闭外键校验,使用一条 DROP TABLE IF EXISTS,可在完整成功后重跑;DDL 隐式提交,失败立即停止,禁止 --force。凭据及实例由执行者选择,不内置或自动连接。本轮未执行数据库或部署;[范围、清单及顺序](../../lastleg-service/docs/procurement-table-cleanup.md)。V14—V21 旧采购迁移仅供追溯,退役后不得重放。
`overseas-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。 `overseas-fees-storage-rent-structure-add.sql` 为指定 TEST 海外费用/仓租的定向结构补齐,只增加缺失的 `ll_storage_rent_inbound` 和 `ll_finance_fee_source.reviewer_name`。全部前置检查在首个业务 DDL 前完成;已有结构匹配时跳过,不匹配则停止,不覆盖或回填。随后按单独授权执行已有 `overseas-storage-rent-zero-fee-repair.sql`,补齐自动免费仓租的金额与汇率约束。使用独立连接、遇错停止,不重放空库初始化;DDL 隐式提交,失败先回查,不删除已完成项。两步范围与发布清单见[海外费用数据库定义汇总](20261022-海外费用仓租数据库变更汇总.md)及[发布配套](../../lastleg-service/docs/overseas-fees-storage-rent-release.md#发布配套)。文件准备不代表已执行或部署。
车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。 车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。
@@ -12,6 +28,8 @@
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。 货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
船节点日志在 `fl_carrier_schedule_node_fact.operator_id/operator_name` 保存操作时的账号与用户名快照;人工补录和确认出仓写入,船司自动采集为空。两列定义同步于 Firstleg 船节点模块基线与国内统一空库基线,更新结构后配套发布 Firstleg 与 Admin。既有 TEST 的字段变更须单独授权执行,不重放空库初始化,不回填历史节点。
`overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。 `overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。
`overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。 `overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
@@ -36,7 +54,7 @@
本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。 本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。 `migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_0900_ai_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。 `migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
@@ -56,11 +74,11 @@
`reset-test-fba-appointments.sql` 仅用于明确授权的 TEST 预约模型重建:删除并按当前基线重建 `ll_inbound_fba_appointment`、`ll_inbound_fba_appointment_item`,并首次创建 `ll_fba_appointment_command` 保存同事务幂等结果与历史。执行任何DROP前必须确认命令表尚不存在;若已存在,先核对上次执行结果,本脚本不清空命令历史、不支持盲目重试。它会丢弃旧预约及关联明细,但不修改订单、入仓计划、库存和排车。调用者必须核对测试连接及两张表的具体重置授权;不得作为自动启动脚本或用于生产。主单区分计划日期/时段、系统单号、外部确认号和确认UTC/IANA时区;明细按稳定货源占用,不再依赖入仓计划明细。结构必须与匹配Lastleg和Admin一同发布,失败立即停止,不回填旧数据。权限从IAM种子核对;文件存在不表示已执行。 `reset-test-fba-appointments.sql` 仅用于明确授权的 TEST 预约模型重建:删除并按当前基线重建 `ll_inbound_fba_appointment`、`ll_inbound_fba_appointment_item`,并首次创建 `ll_fba_appointment_command` 保存同事务幂等结果与历史。执行任何DROP前必须确认命令表尚不存在;若已存在,先核对上次执行结果,本脚本不清空命令历史、不支持盲目重试。它会丢弃旧预约及关联明细,但不修改订单、入仓计划、库存和排车。调用者必须核对测试连接及两张表的具体重置授权;不得作为自动启动脚本或用于生产。主单区分计划日期/时段、系统单号、外部确认号和确认UTC/IANA时区;明细按稳定货源占用,不再依赖入仓计划明细。结构必须与匹配Lastleg和Admin一同发布,失败立即停止,不回填旧数据。权限从IAM种子核对;文件存在不表示已执行。
`migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql` 仅将 `ll_inbound_plan.creation_mode` 从旧统一基线的 `VARCHAR(16)` 扩至模块基线已有的 `VARCHAR(32)`,容纳实际来货审核写入的 `WAREHOUSE_TRANSFER`(18字符)。保留 `utf8mb4_unicode_ci`、非空及 `AUTO` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。 `migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql` 仅将 `ll_inbound_plan.creation_mode` 从旧统一基线的 `VARCHAR(16)` 扩至模块基线已有的 `VARCHAR(32)`,容纳实际来货审核写入的 `WAREHOUSE_TRANSFER`(18字符)。保留 `utf8mb4_0900_ai_ci`、非空及 `AUTO` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。
`migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql` 仅放开散板打盘需求及两类本土订单柜型三列为空、默认空,供未提供这些事实的拼车商业订单使用;普通订单输入校验仍由原服务端请求合同执行。对应源及统一空库基线同步最终定义,不回填已生成订单或把托盘数量解释成打盘服务需求。 `migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql` 仅放开散板打盘需求及两类本土订单柜型三列为空、默认空,供未提供这些事实的拼车商业订单使用;普通订单输入校验仍由原服务端请求合同执行。对应源及统一空库基线同步最终定义,不回填已生成订单或把托盘数量解释成打盘服务需求。
内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。 内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_0900_ai_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。
平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。 平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。
@@ -85,7 +103,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
| 区域 | 完整入口 | 服务 schema | 表数 | | 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:| |---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 | | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 238 | | 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 222 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
@@ -108,7 +126,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/238`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/222`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码 ## 平台管理员密码
@@ -209,8 +227,11 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。 编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。
海外费用与仓租首版统一维护在 `头程数据库统一增量-MySQL8.sql` 的 IAM 菜单/管理员授权种子及 `海外区域统一增量-MySQL8.sql` 的最终建表定义中。海外基线包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租快照、审核人名称,以及采购退款请求幂等字段和唯一索引;同步模块源基线、国内权限种子及海外空库 238 表,复用现有 `FEE_TYPE_077 / 仓储费`,不新增费用项目种子。发布须同时更新 Firstleg、Lastleg、Admin 与海外 PDA;规则、配置及资金验收见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。仅整理文件,未执行 SQL 或部署。 海外费用与仓租首版统一维护在 `头程数据库统一增量-MySQL8.sql` 的 IAM 菜单/管理员授权种子及 `海外区域统一增量-MySQL8.sql` 的最终建表定义中。海外基线包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租快照及审核人名称;采购旧费用与退款专属表已在简化流程退役,不再进入空库初始化。同步模块源基线、国内权限种子及海外空库 222 表,复用现有 `FEE_TYPE_077 / 仓储费`,不新增费用项目种子。发布须同时更新 Firstleg、Lastleg、Admin 与海外 PDA;规则、配置及资金验收见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。仅整理文件,未执行 SQL 或部署。
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。 本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。 采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
2026-10-29 客户采购驳回和下单预算冻结:源结构与海外空库入口同步,已有 Lastleg 库使用 `overseas-order-budget-and-procurement-rejection.sql` 增加两列和预算预占表,不向已有库重放空库入口。需要匹配的 Lastleg、Admin、Client;本轮仅交付脚本,未执行数据库变更。
@@ -0,0 +1,10 @@
-- 仅用于单独授权的 TEST:修正作废对账单历史明细阻止原费用重新生成对账单的问题。
-- 固定目标 MySQL 8 / InnoDB;保留全部账单、明细、金额及修订,不更新、删除或回填业务记录。
-- 单内同一费用版本保持唯一;跨单有效占用由 Finance 生成时的费用行锁和状态校验保证。
-- 前置:Finance 已具备生成时 AVAILABLE -> LOCKED 校验,以及作废单重开时的来源重新占用校验。
-- 执行前后核对 SHOW CREATE TABLE 与记录总数;本文件存在不表示已经执行。
-- 使用独立连接并开启遇错停止;一条原子 ALTER 替换索引,结果不明确先回查,不盲目重放。
ALTER TABLE baiyue_finance.fin_bill_line
DROP INDEX uk_fin_bill_line_source,
ADD UNIQUE KEY uk_fin_bill_line_bill_source (bill_id, source_type, source_id, source_version),
ADD KEY idx_fin_bill_line_source (source_type, source_id, source_version);
@@ -4,4 +4,4 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY(business_date) PRIMARY KEY(business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车单号每日独立原子流水';
@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
PRIMARY KEY (business_date) PRIMARY KEY (business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task ( CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
@@ -81,13 +81,13 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
UNIQUE KEY uk_transport_trip (trip_no), UNIQUE KEY uk_transport_trip (trip_no),
UNIQUE KEY uk_transport_source (active_order), UNIQUE KEY uk_transport_source (active_order),
KEY idx_transport_order (customer_id, order_no) KEY idx_transport_order (customer_id, order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create ( CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号', trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要', request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要',
PRIMARY KEY (trip_no) PRIMARY KEY (trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command ( CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
@@ -96,7 +96,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表', evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY (dispatch_no, request_key) PRIMARY KEY (dispatch_no, request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车运输执行命令与凭证账本';
-- 当前承运占用查询还依赖散板来源标识(与V33一致);仅补结构,不创建散板业务记录。 -- 当前承运占用查询还依赖散板来源标识(与V33一致);仅补结构,不创建散板业务记录。
SET @dispatch_transport_pool_ddl = IF( SET @dispatch_transport_pool_ddl = IF(
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing_channel (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_channel_code (channel_code), UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
KEY idx_fl_sailing_channel_status (status) KEY idx_fl_sailing_channel_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS fl_sailing_template ( CREATE TABLE IF NOT EXISTS fl_sailing_template (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing_template (
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday), KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7), CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30) CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS fl_sailing ( CREATE TABLE IF NOT EXISTS fl_sailing (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -100,7 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing (
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status), KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
KEY idx_fl_sailing_route (departure_port_code,destination_port_code), KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED')) CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS fl_sailing_history ( CREATE TABLE IF NOT EXISTS fl_sailing_history (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -114,7 +114,7 @@ CREATE TABLE IF NOT EXISTS fl_sailing_history (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id) KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/* /*
* 船期日历的事实契约。 * 船期日历的事实契约。
@@ -0,0 +1,13 @@
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
@@ -2,6 +2,6 @@
-- Schema: baiyue_lastleg -- Schema: baiyue_lastleg
-- Purpose: 拼车履约客户关联字段与现有订单域排序规则一致 -- Purpose: 拼车履约客户关联字段与现有订单域排序规则一致
-- Preconditions: 外接预订和商业履约桥已存在,客户字段为VARCHAR(128)且非空 -- Preconditions: 外接预订和商业履约桥已存在,客户字段为VARCHAR(128)且非空
-- Postconditions: 两个客户关联字段使用utf8mb4_unicode_ci;不修改客户标识或业务状态 -- Postconditions: 两个客户关联字段使用utf8mb4_0900_ai_ci;不修改客户标识或业务状态
ALTER TABLE ll_fleet_external_booking MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属'; ALTER TABLE ll_fleet_external_booking MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '委托客户及付款归属';
ALTER TABLE ll_fleet_pool_order_link MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围'; ALTER TABLE ll_fleet_pool_order_link MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '商业客户隔离范围';
@@ -1,8 +1,8 @@
-- Region: OVERSEAS -- Region: OVERSEAS
-- Schema: baiyue_lastleg -- Schema: baiyue_lastleg
-- Purpose: 仓储转运来货审核生成入仓计划时允许完整WAREHOUSE_TRANSFER来源值 -- Purpose: 仓储转运来货审核生成入仓计划时允许完整WAREHOUSE_TRANSFER来源值
-- Preconditions: ll_inbound_plan.creation_mode现为VARCHAR(16),utf8mb4_unicode_ci、NOT NULL、DEFAULT AUTO -- Preconditions: ll_inbound_plan.creation_mode现为VARCHAR(16),utf8mb4_0900_ai_ci、NOT NULL、DEFAULT AUTO
-- Postconditions: 仅将本列扩至VARCHAR(32);保留排序规则、非空、默认值和已有业务数据 -- Postconditions: 仅将本列扩至VARCHAR(32);保留排序规则、非空、默认值和已有业务数据
ALTER TABLE ll_inbound_plan ALTER TABLE ll_inbound_plan
MODIFY COLUMN creation_mode VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci MODIFY COLUMN creation_mode VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci
NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运'; NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运';
@@ -45,4 +45,4 @@ CREATE TABLE ll_procurement_wallet_attempt (
UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no), UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no),
KEY idx_procurement_attempt_order(customer_id,order_id,status), KEY idx_procurement_attempt_order(customer_id,order_id,status),
CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED')) CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购费用资金命令尝试与不可变结果';
@@ -31,7 +31,7 @@ CREATE TABLE ll_procurement_flow (
PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id), 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_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0),
CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=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='采购单一批次履约事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购单一批次履约事实';
CREATE TABLE ll_procurement_command ( CREATE TABLE ll_procurement_command (
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
@@ -42,7 +42,7 @@ CREATE TABLE ll_procurement_command (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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), PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key),
KEY idx_procurement_command_customer(customer_id,order_id,created_at) KEY idx_procurement_command_customer(customer_id,order_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购节点幂等与不可变审计';
CREATE TABLE ll_procurement_budget_authorization ( CREATE TABLE ll_procurement_budget_authorization (
id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID',
@@ -54,4 +54,4 @@ CREATE TABLE ll_procurement_budget_authorization (
PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id), 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_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')) 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='采购货款预算授权核验历史'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购货款预算授权核验历史';
@@ -10,7 +10,7 @@ CREATE TABLE ll_procurement_receipt (
PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active), 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_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)) 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='采购分次实收与受控更正事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购分次实收与受控更正事实';
CREATE TABLE ll_procurement_disposition ( 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 '客户隔离键', id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
@@ -22,7 +22,7 @@ CREATE TABLE ll_procurement_disposition (
created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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), 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) CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购单内差异处置,不自动退款';
CREATE TABLE ll_procurement_manifest ( 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 '客户隔离键', id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
@@ -31,7 +31,7 @@ CREATE TABLE ll_procurement_manifest (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间',
PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version), PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version),
CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED')) CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购装箱清单不可变版本';
CREATE TABLE ll_procurement_carton ( CREATE TABLE ll_procurement_carton (
id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本', id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本',
@@ -40,12 +40,12 @@ CREATE TABLE ll_procurement_carton (
gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT 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), 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) 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='采购版本化逐箱实测与件数'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购版本化逐箱实测与件数';
CREATE TABLE ll_procurement_carton_source ( CREATE TABLE ll_procurement_carton_source (
carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数', 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) 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='采购箱内货物的实收来源与数量'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购箱内货物的实收来源与数量';
CREATE TABLE ll_procurement_shipment ( 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 '客户隔离键', id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
@@ -55,11 +55,11 @@ CREATE TABLE ll_procurement_shipment (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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), 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')) CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购两段各一次整批真实交寄';
CREATE TABLE ll_procurement_shipment_item ( 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 '本次实际交寄件数', 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 '运单号快照', carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照',
label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照', label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照',
PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0) 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='采购整批交寄逐箱事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购整批交寄逐箱事实';
@@ -7,7 +7,7 @@ CREATE TABLE ll_procurement_label_set (
created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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), PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version),
CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购两段面单不可变版本和地址快照';
CREATE TABLE ll_procurement_label_item ( CREATE TABLE ll_procurement_label_item (
label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID', label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID',
@@ -15,14 +15,14 @@ CREATE TABLE ll_procurement_label_item (
carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号', 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 '贴单确认人', 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) 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='采购逐箱文件页关联及贴单事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购逐箱文件页关联及贴单事实';
CREATE TABLE ll_procurement_estimate_review ( 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 '已核对国际面单版本', 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 '核价人员', fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员',
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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) PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='面单清单与预估冻结的显式核对关联';
CREATE TABLE ll_procurement_repack ( 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,保持一对一来源', id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源',
@@ -32,9 +32,9 @@ CREATE TABLE ll_procurement_repack (
created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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), 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) 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='采购国内原箱一对一换包装不可变历史'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购国内原箱一对一换包装不可变历史';
ALTER TABLE ll_overseas_procurement_file ALTER TABLE ll_overseas_procurement_file
DROP CHECK chk_ll_procurement_file_category, DROP CHECK chk_ll_procurement_file_category,
MODIFY COLUMN category VARCHAR(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证', MODIFY COLUMN category VARCHAR(16) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证',
ADD CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION')); ADD CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION'));
@@ -12,9 +12,9 @@ CREATE TABLE ll_procurement_fee_correction (
PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key), 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), 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) 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='采购费用链更正目标与原汇率快照'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购费用链更正目标与原汇率快照';
CREATE TABLE ll_procurement_fee_correction_item ( CREATE TABLE ll_procurement_fee_correction_item (
correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID', 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) PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购费用目标更正与逐笔资金事实关联';
@@ -133,15 +133,15 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo ( CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo (
id bigint NOT NULL COMMENT '测量照片引用ID', id bigint NOT NULL COMMENT '测量照片引用ID',
transfer_order_id bigint NOT NULL COMMENT '仓储转运单ID', transfer_order_id bigint NOT NULL COMMENT '仓储转运单ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', customer_id varchar(128) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户租户ID',
file_id bigint NOT NULL COMMENT '文件服务文件ID', file_id bigint NOT NULL COMMENT '文件服务文件ID',
file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件名快照', file_name varchar(255) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '文件名快照',
content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件媒体类型快照', content_type varchar(128) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '文件媒体类型快照',
size_bytes bigint NOT NULL DEFAULT 0 COMMENT '文件字节数快照', size_bytes bigint NOT NULL DEFAULT 0 COMMENT '文件字节数快照',
uploaded_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA上传操作人', uploaded_by varchar(128) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'PDA上传操作人',
uploaded_at datetime(3) NOT NULL COMMENT '上传时间', uploaded_at datetime(3) NOT NULL COMMENT '上传时间',
submitted_at datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间', submitted_at datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_ll_transfer_measurement_photo_file (customer_id,transfer_order_id,file_id), UNIQUE KEY uk_ll_transfer_measurement_photo_file (customer_id,transfer_order_id,file_id),
KEY idx_ll_transfer_measurement_photo_order (customer_id,transfer_order_id,uploaded_at) KEY idx_ll_transfer_measurement_photo_order (customer_id,transfer_order_id,uploaded_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA测量现场照片引用'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运PDA测量现场照片引用';
@@ -10,7 +10,7 @@ CREATE TABLE ll_procurement_control_note (
resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL 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 '本单处理凭证', 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) 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='采购不可覆盖事实的更正说明及阻断异常'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常';
CREATE TABLE ll_procurement_closure ( 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 '客户隔离键', id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
@@ -19,7 +19,7 @@ CREATE TABLE ll_procurement_closure (
reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) 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 '关闭确认时间', 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) 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='采购实物资金收口后的异常关闭事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采购实物资金收口后的异常关闭事实';
CREATE TABLE ll_procurement_closure_item ( CREATE TABLE ll_procurement_closure_item (
id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID', id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID',
@@ -28,4 +28,4 @@ CREATE TABLE ll_procurement_closure_item (
destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT 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), PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
@@ -2,43 +2,43 @@
-- Schema: baiyue_lastleg -- Schema: baiyue_lastleg
-- Purpose: 统一退货进销存 UNION 文本列排序规则,去除查询转换需求 -- Purpose: 统一退货进销存 UNION 文本列排序规则,去除查询转换需求
-- Preconditions: 八张退货表已存在;核对字段长度、可空性、默认值及唯一索引无目标规则冲突 -- Preconditions: 八张退货表已存在;核对字段长度、可空性、默认值及唯一索引无目标规则冲突
-- Postconditions: 指定文本字段及表默认规则统一为 utf8mb4_unicode_ci;不修改数量、状态或业务标识 -- Postconditions: 指定文本字段及表默认规则统一为 utf8mb4_0900_ai_ci;不修改数量、状态或业务标识
-- 不重放空库基线;本文件可重复执行。DDL 非事务,失败停止并核对后续执行范围。 -- 不重放空库基线;本文件可重复执行。DDL 非事务,失败停止并核对后续执行范围。
ALTER TABLE ll_return_order ALTER TABLE ll_return_order
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID'; MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作海外仓ID';
ALTER TABLE ll_return_order_item ALTER TABLE ll_return_order_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名'; MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名';
ALTER TABLE ll_return_receipt ALTER TABLE ll_return_receipt
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号'; MODIFY COLUMN receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '签收单号';
ALTER TABLE ll_return_receipt_item ALTER TABLE ll_return_receipt_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码'; MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'SKU编码';
ALTER TABLE ll_return_inventory_carton ALTER TABLE ll_return_inventory_carton
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '海外仓ID',
MODIFY COLUMN carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码'; MODIFY COLUMN carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '退货箱号和箱唛编码';
ALTER TABLE ll_return_inventory_carton_item ALTER TABLE ll_return_inventory_carton_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名'; MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名';
ALTER TABLE ll_return_inventory_outbound_movement ALTER TABLE ll_return_inventory_outbound_movement
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键', MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '库存所属客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID', MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '实际出库海外仓ID',
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照', MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次出库SKU编码快照',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照', MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号'; MODIFY COLUMN outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次退货出库业务单号';
ALTER TABLE ll_return_sku_conversion ALTER TABLE ll_return_sku_conversion
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作海外仓ID',
MODIFY COLUMN old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU', MODIFY COLUMN old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '换标前SKU',
MODIFY COLUMN new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU', MODIFY COLUMN new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '换标后SKU',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照', MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号'; MODIFY COLUMN relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '换标单号';
@@ -13,4 +13,4 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id), UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no) KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写', number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒', last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
PRIMARY KEY (number_key) PRIMARY KEY (number_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海外业务短单号分配时钟';
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。 -- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米''')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
@@ -25,9 +25,9 @@ PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 测试环境当前缺失、生产已存在的 PDA 废弃作业字段。 -- 测试环境当前缺失、生产已存在的 PDA 废弃作业字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'actual_discard_packages'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages int NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'actual_discard_packages'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages int NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_status'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED'' AFTER actual_discard_packages', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_status'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status varchar(32) COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED'' AFTER actual_discard_packages', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_operator_id'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_operator_id'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_completed_at'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at datetime(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_completed_at'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at datetime(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -35,36 +35,36 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 生产环境当前缺失、测试已存在的换标与退货字段。 -- 生产环境当前缺失、测试已存在的换标与退货字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_unit_fact' AND column_name = 'quantity'), 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity int NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_unit_fact' AND column_name = 'quantity'), 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity int NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'discrepancy_reason'), 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason varchar(500) COLLATE utf8mb4_unicode_ci NULL COMMENT ''手动完结差异原因'' AFTER status', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'discrepancy_reason'), 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason varchar(500) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''手动完结差异原因'' AFTER status', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'manually_completed_at'), 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at datetime(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'manually_completed_at'), 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at datetime(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''本次签收绑定的托盘码'' AFTER carton_mark', 'SELECT 1'); SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''本次签收绑定的托盘码'' AFTER carton_mark', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'delivery_group_key' AND (column_type <> 'varchar(255)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_group_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键''', 'SELECT 1'); SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'delivery_group_key' AND (column_type <> 'varchar(255)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_group_key varchar(255) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_local_looseboard_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_local_looseboard_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1'); SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_local_looseboard_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_local_looseboard_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1'); SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pda_pick' AND column_name = 'inventory_position_id' AND (column_type <> 'bigint' OR is_nullable <> 'NO')), 'ALTER TABLE ll_outbound_pda_pick MODIFY COLUMN inventory_position_id bigint NOT NULL COMMENT ''库存仓位ID''', 'SELECT 1'); SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pda_pick' AND column_name = 'inventory_position_id' AND (column_type <> 'bigint' OR is_nullable <> 'NO')), 'ALTER TABLE ll_outbound_pda_pick MODIFY COLUMN inventory_position_id bigint NOT NULL COMMENT ''库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF( SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'new_sku_code' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')) EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'new_sku_code' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_0900_ai_ci'))
OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'source_warehouse_id' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')), OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'source_warehouse_id' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_0900_ai_ci')),
'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''换标后SKU编码'', MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''原海外中转仓ID''', 'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT ''换标后SKU编码'', MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT ''原海外中转仓ID''',
'SELECT 1' 'SELECT 1'
); );
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_task' AND column_name = 'review_decision' AND (column_type <> 'varchar(16)' OR is_nullable <> 'YES' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_relabel_pda_task MODIFY COLUMN review_decision varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT ''最近复核结论:APPROVE或REWORK''', 'SELECT 1'); SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_task' AND column_name = 'review_decision' AND (column_type <> 'varchar(16)' OR is_nullable <> 'YES' OR collation_name <> 'utf8mb4_0900_ai_ci')), 'ALTER TABLE ll_relabel_pda_task MODIFY COLUMN review_decision varchar(16) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT ''最近复核结论:APPROVE或REWORK''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF( SET @ddl = IF(
(SELECT table_collation FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_measurement_photo') <> 'utf8mb4_unicode_ci', (SELECT table_collation FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_measurement_photo') <> 'utf8mb4_0900_ai_ci',
'ALTER TABLE ll_warehouse_transfer_measurement_photo CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', 'ALTER TABLE ll_warehouse_transfer_measurement_photo CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci',
'SELECT 1' 'SELECT 1'
); );
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -8,11 +8,11 @@ CREATE TABLE ll_dispatch_truck_task (
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留', active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order), PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
KEY idx_transport_order(customer_id,order_no) KEY idx_transport_order(customer_id,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车与本土卡车原单关联';
CREATE TABLE ll_dispatch_transport_create ( CREATE TABLE ll_dispatch_transport_create (
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号', trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要' request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='本土卡车排车创建请求账本';
CREATE TABLE ll_dispatch_transport_command ( CREATE TABLE ll_dispatch_transport_command (
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键', request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
@@ -20,4 +20,4 @@ CREATE TABLE ll_dispatch_transport_command (
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表', evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
PRIMARY KEY(dispatch_no,request_key) PRIMARY KEY(dispatch_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排车运输执行命令与凭证账本';
@@ -0,0 +1,3 @@
-- 海外费用资金重试动作长度修复:兼容 MANUAL_FUNDING_RETRY 等费用幂等动作。
ALTER TABLE baiyue_lastleg.ll_finance_fee_command
MODIFY COLUMN action VARCHAR(32) NOT NULL COMMENT '费用写操作动作:CREATE、SUBMIT、REVIEW、ADJUST、VOID或资金重试';
@@ -0,0 +1,10 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 预算释放查询的客户和订单号关联字段与采购单、卡车单排序规则一致
-- Preconditions: ll_order_budget_reservation 已存在;核对列长度、可空性及索引
-- Postconditions: customer_id、order_no 使用 utf8mb4_0900_ai_ci;不修改任何业务数据
-- DDL 非事务,执行前核对目标库;失败后检查列定义再继续。
ALTER TABLE baiyue_lastleg.ll_order_budget_reservation
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
MODIFY COLUMN customer_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户隔离标识',
MODIFY COLUMN order_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '成功创建的业务单号';
@@ -0,0 +1,10 @@
ALTER TABLE ll_local_looseboard_order
ADD COLUMN delivery_mode VARCHAR(32) NULL COMMENT '入仓方式:送货到仓或仓库上门取货',
ADD COLUMN operating_warehouse_id VARCHAR(128) NULL COMMENT '实际收货海外仓ID',
ADD COLUMN destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或私人地址',
ADD COLUMN destination_postal_code VARCHAR(32) NULL COMMENT '私人目的地邮编',
ADD COLUMN delivery_contact_name VARCHAR(128) NULL COMMENT '送货到仓联系人',
ADD COLUMN delivery_contact_phone VARCHAR(64) NULL COMMENT '送货到仓联系电话',
ADD COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
ADD COLUMN tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号',
ADD COLUMN transfer_no VARCHAR(64) NULL COMMENT '关联的仓储转运收货单号';
@@ -8,5 +8,5 @@ ALTER TABLE ll_outbound_pda_pick
MODIFY COLUMN inventory_position_id bigint NULL COMMENT '库存仓位ID;退货箱直接出库时为空'; MODIFY COLUMN inventory_position_id bigint NULL COMMENT '库存仓位ID;退货箱直接出库时为空';
ALTER TABLE ll_relabel_item ALTER TABLE ll_relabel_item
MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT '换标后SKU编码;批量SKU换标使用子明细JSON时为空', MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT '换标后SKU编码;批量SKU换标使用子明细JSON时为空',
MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT '原海外中转仓ID;预入仓批量SKU换标时为空'; MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_0900_ai_ci NULL COMMENT '原海外中转仓ID;预入仓批量SKU换标时为空';
@@ -27,4 +27,4 @@ CREATE TABLE ll_warehouse_transfer_receipt_command (
UNIQUE KEY uk_ll_wh_receipt_cmd_key (customer_id, idempotency_key), UNIQUE KEY uk_ll_wh_receipt_cmd_key (customer_id, idempotency_key),
KEY idx_ll_wh_receipt_cmd_target (customer_id, transfer_order_id, target_pallet_id), KEY idx_ll_wh_receipt_cmd_target (customer_id, transfer_order_id, target_pallet_id),
CONSTRAINT ck_ll_wh_receipt_cmd_status CHECK (status IN ('PREPARED','RESERVED','COMPLETED')) CONSTRAINT ck_ll_wh_receipt_cmd_status CHECK (status IN ('PREPARED','RESERVED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令';
@@ -0,0 +1,83 @@
-- 海外费用菜单定向修复,MySQL 8.0+,目标只能 baiyue_iam。
-- 将“海外费用管理”改为“海外费用明细”,并挂到“财务管理”下。
-- 权限码、路由和角色授权保持不变;可重复执行。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS repair_overseas_fee_menu_parent;
DELIMITER $$
CREATE PROCEDURE repair_overseas_fee_menu_parent()
BEGIN
DECLARE v_menu_changed INT DEFAULT 0;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
IF (SELECT COUNT(*) FROM iam_permission
WHERE id=5105000 AND tenant_id=0 AND app_code='PLATFORM'
AND permission_type='MENU' AND permission_code='platform:finance'
AND status='ACTIVE' AND deleted=0)<>1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='Finance parent menu platform:finance is missing or inactive';
END IF;
IF EXISTS (SELECT 1 FROM iam_permission
WHERE (id=5117200 AND (permission_code<>'platform:overseas-fee:list'
OR tenant_id<>0 OR app_code<>'PLATFORM' OR permission_type<>'MENU'))
OR (permission_code='platform:overseas-fee:list'
AND (id<>5117200 OR tenant_id<>0 OR app_code<>'PLATFORM'
OR permission_type<>'MENU'))) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='Overseas fee menu permission identity conflict';
END IF;
IF (SELECT COUNT(*) FROM iam_permission
WHERE id=5117200 AND tenant_id=0 AND app_code='PLATFORM'
AND permission_type='MENU' AND permission_code='platform:overseas-fee:list')<>1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT='Overseas fee menu platform:overseas-fee:list is missing';
END IF;
START TRANSACTION;
UPDATE iam_permission
SET parent_id=5105000,
permission_name='海外费用明细',
route_path='/overseas-business/fees',
http_method='GET',
sort_no=60,
status='ACTIVE',
deleted=0,
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=5117200 AND tenant_id=0 AND app_code='PLATFORM'
AND permission_type='MENU' AND permission_code='platform:overseas-fee:list'
AND (parent_id<>5105000 OR permission_name<>'海外费用明细'
OR route_path<>'/overseas-business/fees' OR http_method<>'GET'
OR sort_no<>60 OR status<>'ACTIVE' OR deleted<>0);
SET v_menu_changed=ROW_COUNT();
-- 菜单实际变化时递增已获该菜单权限用户的安全版本,刷新旧授权快照。
IF v_menu_changed>0 THEN
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
SELECT DISTINCT u.auth_user_id,1
FROM iam_user u
JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=0
JOIN iam_role r ON r.id=mr.role_id AND r.tenant_id=0
JOIN iam_role_permission rp ON rp.role_id=r.id
AND rp.permission_id=5117200 AND rp.tenant_id=0
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
AND m.status='ACTIVE' AND m.deleted=0
AND r.app_code='PLATFORM' AND r.status='ACTIVE' AND r.deleted=0
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
END IF;
COMMIT;
END$$
DELIMITER ;
CALL repair_overseas_fee_menu_parent();
DROP PROCEDURE repair_overseas_fee_menu_parent;
SELECT id,parent_id,permission_code,permission_name,route_path,sort_no
FROM iam_permission WHERE id IN (5105000,5117200) ORDER BY id;
@@ -41,7 +41,7 @@ BEGIN
IF inbound_exists>0 THEN IF inbound_exists>0 THEN
IF (SELECT COUNT(*) FROM information_schema.TABLES IF (SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound' WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound'
AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB' AND TABLE_COLLATION='utf8mb4_unicode_ci')<>1 AND TABLE_TYPE='BASE TABLE' AND ENGINE='InnoDB' AND TABLE_COLLATION='utf8mb4_0900_ai_ci')<>1
OR (SELECT COUNT(*) FROM information_schema.COLUMNS OR (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound')<>4 WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_storage_rent_inbound')<>4
OR (SELECT COUNT(*) FROM information_schema.COLUMNS OR (SELECT COUNT(*) FROM information_schema.COLUMNS
@@ -81,7 +81,7 @@ BEGIN
created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时', created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时',
PRIMARY KEY (receipt_id), PRIMARY KEY (receipt_id),
KEY idx_storage_rent_inbound_lot (lot_id) KEY idx_storage_rent_inbound_lot (lot_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程实收计租幂等登记';
END IF; END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
@@ -0,0 +1,29 @@
-- 2026-10-29:采购驳回照片返工及客户下单预估冻结;仅在Lastleg所属库执行。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_rejection_reason'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN customer_rejection_reason VARCHAR(1000) NULL COMMENT ''客户采购结果驳回原因'''));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='rejected_photo_ids'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN rejected_photo_ids TEXT NULL COMMENT ''本轮客户驳回的收货照片主键快照'''));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
KEY idx_order_budget_release(status,release_requested,created_at),
KEY idx_order_budget_order(customer_id,kind,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
@@ -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;
+4 -4
View File
@@ -52,7 +52,7 @@ CREATE TABLE ll_inbound_fba_appointment (
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no), UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code), KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status) KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='FBA预约安排与正式确认唯一主记录';
CREATE TABLE ll_inbound_fba_appointment_item ( CREATE TABLE ll_inbound_fba_appointment_item (
id BIGINT NOT NULL COMMENT '记录主键', id BIGINT NOT NULL COMMENT '记录主键',
@@ -74,7 +74,7 @@ CREATE TABLE ll_inbound_fba_appointment_item (
KEY idx_ll_fba_active_order (customer_id,order_id,active), KEY idx_ll_fba_active_order (customer_id,order_id,active),
KEY idx_ll_fba_item_source_lookup (source_key,customer_id), KEY idx_ll_fba_item_source_lookup (source_key,customer_id),
KEY idx_ll_fba_item_customer_order (customer_id,order_id) KEY idx_ll_fba_item_customer_order (customer_id,order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入仓前后共用的预约真实货源';
CREATE TABLE ll_appointment_cargo_verification ( CREATE TABLE ll_appointment_cargo_verification (
id BIGINT NOT NULL COMMENT '核实记录主键', id BIGINT NOT NULL COMMENT '核实记录主键',
@@ -97,7 +97,7 @@ CREATE TABLE ll_appointment_cargo_verification (
UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key), UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key),
CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0), CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0),
CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0) CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='预约货物核实资料及不可覆盖的版本历史';
CREATE TABLE ll_fba_appointment_command ( CREATE TABLE ll_fba_appointment_command (
command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键', command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键',
@@ -109,4 +109,4 @@ CREATE TABLE ll_fba_appointment_command (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
PRIMARY KEY(command_key), PRIMARY KEY(command_key),
KEY idx_ll_fba_command_appointment(appointment_no,created_at) KEY idx_ll_fba_command_appointment(appointment_no,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约命令与变更历史'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='预约命令与变更历史';
@@ -0,0 +1,13 @@
-- 在 Lastleg 所属库执行;只补充整托计租截止日,不改写既有费用、库存、冻结或资金流水。
-- 发布新服务前暂停仓租出库,取消未消费预占;旧比例费用的处理需按业务单核对后单独办理。
USE baiyue_lastleg;
SET @return_rent_ddl = (
SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'll_storage_rent_lot' AND column_name = 'charged_days'
), 'SELECT 1',
'ALTER TABLE ll_storage_rent_lot ADD COLUMN charged_days BIGINT NOT NULL DEFAULT 0 COMMENT ''退货原托已生成仓租的截止库龄天数,同一天不重复计费'' AFTER original_units')
);
PREPARE return_rent_statement FROM @return_rent_ddl;
EXECUTE return_rent_statement;
DEALLOCATE PREPARE return_rent_statement;
@@ -3093,7 +3093,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
seal_no varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码', seal_no varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码',
remark varchar(1000) DEFAULT NULL COMMENT '证据备注', remark varchar(1000) DEFAULT NULL COMMENT '证据备注',
status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废', status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,保证每柜每类型仅一条有效事实', active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,同类允许多张有效照片',
operation_key varchar(128) NOT NULL COMMENT '任务范围内幂等操作键', operation_key varchar(128) NOT NULL COMMENT '任务范围内幂等操作键',
request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要', request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
void_reason varchar(500) DEFAULT NULL COMMENT '作废原因', void_reason varchar(500) DEFAULT NULL COMMENT '作废原因',
@@ -3109,7 +3109,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key), UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag), KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
KEY idx_cabinet_evidence_container (container_booking_id,status), KEY idx_cabinet_evidence_container (container_booking_id,status),
KEY idx_cabinet_evidence_seal (seal_no,active_flag), KEY idx_cabinet_evidence_seal (seal_no,active_flag),
CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id),
@@ -3853,6 +3853,8 @@ CREATE TABLE fl_carrier_schedule_node_fact (
location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称', location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称',
source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用', source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用',
raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
operator_id bigint unsigned DEFAULT NULL COMMENT '节点操作人账号ID;自动采集时为空',
operator_name varchar(128) DEFAULT NULL COMMENT '节点操作人用户名快照;自动采集时为空',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key), UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
@@ -4142,7 +4144,7 @@ CREATE TABLE fl_direct_delivery_unloading (
PRIMARY KEY (container_booking_id,inbound_id), PRIMARY KEY (container_booking_id,inbound_id),
UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key), UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key),
CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id) CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='直送目的仓真实卸货凭据';
CREATE TABLE fl_terminal_drayage ( CREATE TABLE fl_terminal_drayage (
@@ -4763,7 +4765,8 @@ CREATE TABLE fin_bill_line (
currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', currency_code char(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_line_source (source_type,source_id,source_version), UNIQUE KEY uk_fin_bill_line_bill_source (bill_id,source_type,source_id,source_version),
KEY idx_fin_bill_line_source (source_type,source_id,source_version),
KEY idx_fin_bill_line_bill (tenant_id,bill_id), KEY idx_fin_bill_line_bill (tenant_id,bill_id),
CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)), CONSTRAINT chk_fin_bill_line_amount CHECK ((amount >= 0)),
CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')), CONSTRAINT chk_fin_bill_line_currency CHECK ((currency_code = _utf8mb4'CNY')),
@@ -8067,7 +8070,7 @@ CREATE TABLE fl_sailing_channel (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_channel_code (channel_code), UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
KEY idx_fl_sailing_channel_status (status) KEY idx_fl_sailing_channel_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing_template ( CREATE TABLE fl_sailing_template (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -8103,7 +8106,7 @@ CREATE TABLE fl_sailing_template (
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday), KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7), CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30) CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing ( CREATE TABLE fl_sailing (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -8140,7 +8143,7 @@ CREATE TABLE fl_sailing (
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status), KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
KEY idx_fl_sailing_route (departure_port_code,destination_port_code), KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED')) CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing_history ( CREATE TABLE fl_sailing_history (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -8154,7 +8157,7 @@ CREATE TABLE fl_sailing_history (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id) KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
USE baiyue_iam; USE baiyue_iam;
@@ -8450,7 +8453,7 @@ CREATE TABLE ord_document_sequence (
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key), PRIMARY KEY (sequence_key),
CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0) CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务单据每日独立原子流水';
USE baiyue_warehouse; USE baiyue_warehouse;
CREATE TABLE wh_document_sequence ( CREATE TABLE wh_document_sequence (
@@ -8458,10 +8461,24 @@ CREATE TABLE wh_document_sequence (
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key), PRIMARY KEY (sequence_key),
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0) CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务单据每日独立原子流水';
-- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。 -- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。
USE baiyue_iam; USE baiyue_iam;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3));
USE baiyue_iam;
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables; SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
File diff suppressed because it is too large Load Diff
View File
@@ -14,6 +14,8 @@ enum BusinessFileCategory {
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -82,7 +84,8 @@ enum BusinessFileCategory {
&& tenantId != 0) { && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台"); throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
} }
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE) if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE
|| this == OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE || this == OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE)
&& tenantId != 0) { && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台"); throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台");
} }
@@ -174,7 +174,8 @@ public class GenericXlsxExportRunner {
case STRING -> cell.setCellValue(safeString(String.valueOf(value), column.key(), warnedTruncatedColumns)); case STRING -> cell.setCellValue(safeString(String.valueOf(value), column.key(), warnedTruncatedColumns));
case INTEGER -> cell.setCellValue(new BigDecimal(String.valueOf(value)).longValueExact()); case INTEGER -> cell.setCellValue(new BigDecimal(String.valueOf(value)).longValueExact());
case DECIMAL -> cell.setCellValue(new BigDecimal(String.valueOf(value)).doubleValue()); case DECIMAL -> cell.setCellValue(new BigDecimal(String.valueOf(value)).doubleValue());
case BOOLEAN -> cell.setCellValue(value instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(value))); // 面向业务人员的是否字段统一显示中文,Provider 仍传输布尔类型。
case BOOLEAN -> cell.setCellValue((value instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(value))) ? "是" : "否");
case DATE -> { cell.setCellValue(LocalDate.parse(String.valueOf(value))); cell.setCellStyle(styles.date); } case DATE -> { cell.setCellValue(LocalDate.parse(String.valueOf(value))); cell.setCellStyle(styles.date); }
case DATETIME -> { cell.setCellValue(LocalDateTime.parse(String.valueOf(value))); cell.setCellStyle(styles.dateTime); } case DATETIME -> { cell.setCellValue(LocalDateTime.parse(String.valueOf(value))); cell.setCellStyle(styles.dateTime); }
} }
@@ -48,7 +48,7 @@ class GenericXlsxExportRunnerTest {
} }
@Test void writesRealTypedWorkbookAndCarriesSnapshotAcrossBatches() throws Exception { @Test void writesRealTypedWorkbookAndCarriesSnapshotAcrossBatches() throws Exception {
List<ExportColumn> columns=List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18),new ExportColumn("integer","整数",ExportColumnType.INTEGER,12),new ExportColumn("decimal","金额",ExportColumnType.DECIMAL,12),new ExportColumn("flag","布尔",ExportColumnType.BOOLEAN,10),new ExportColumn("date","日期",ExportColumnType.DATE,12),new ExportColumn("time","时间",ExportColumnType.DATETIME,20)); List<ExportColumn> columns=List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18),new ExportColumn("integer","整数",ExportColumnType.INTEGER,12),new ExportColumn("decimal","金额",ExportColumnType.DECIMAL,12),new ExportColumn("flag","布尔",ExportColumnType.BOOLEAN,10),new ExportColumn("date","日期",ExportColumnType.DATE,12),new ExportColumn("time","时间",ExportColumnType.DATETIME,20));
List<Map<String,Object>> firstRows=IntStream.range(0,100).mapToObj(index -> Map.<String,Object>of("text",index==0?"=SUM(1,2)":"row-"+index,"integer",12,"decimal","3.50","flag",true,"date","2026-08-03","time","2026-08-03T21:00:00")).toList(); List<Map<String,Object>> firstRows=IntStream.range(0,100).mapToObj(index -> Map.<String,Object>of("text",index==0?"=SUM(1,2)":"row-"+index,"integer",12,"decimal","3.50","flag",index % 2 == 0,"date","2026-08-03","time","2026-08-03T21:00:00")).toList();
var first=new ExportBatchResponse(1,"sha256:test","snapshot-9",columns,firstRows,"10",true,101L); var first=new ExportBatchResponse(1,"sha256:test","snapshot-9",columns,firstRows,"10",true,101L);
var second=new ExportBatchResponse(1,"sha256:test","snapshot-9",null,List.of(Map.of("text","\n=SUM(1,2)","integer",13,"decimal",4.5,"flag",false,"date","2026-08-04","time","2026-08-04T21:00:00")),null,false,101L); var second=new ExportBatchResponse(1,"sha256:test","snapshot-9",null,List.of(Map.of("text","\n=SUM(1,2)","integer",13,"decimal",4.5,"flag",false,"date","2026-08-04","time","2026-08-04T21:00:00")),null,false,101L);
when(provider.batch(anyString(),eq("CUSTOMER_LIST"),any(),any())).thenReturn(ApiResponse.success(first),ApiResponse.success(second)); when(provider.batch(anyString(),eq("CUSTOMER_LIST"),any(),any())).thenReturn(ApiResponse.success(first),ApiResponse.success(second));
@@ -58,7 +58,7 @@ class GenericXlsxExportRunnerTest {
assertEquals(101,result.rows()); assertEquals(101,result.rows());
try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())){ try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())){
var row=workbook.getSheetAt(0).getRow(1);assertEquals("'=SUM(1,2)",row.getCell(0).getStringCellValue()); var row=workbook.getSheetAt(0).getRow(1);assertEquals("'=SUM(1,2)",row.getCell(0).getStringCellValue());
assertEquals(CellType.NUMERIC,row.getCell(1).getCellType());assertEquals(CellType.NUMERIC,row.getCell(2).getCellType());assertEquals(CellType.BOOLEAN,row.getCell(3).getCellType());assertEquals(CellType.NUMERIC,row.getCell(4).getCellType());assertEquals(CellType.NUMERIC,row.getCell(5).getCellType()); assertEquals(CellType.NUMERIC,row.getCell(1).getCellType());assertEquals(CellType.NUMERIC,row.getCell(2).getCellType());assertEquals(CellType.STRING,row.getCell(3).getCellType());assertEquals("是",row.getCell(3).getStringCellValue());assertEquals("否",workbook.getSheetAt(0).getRow(2).getCell(3).getStringCellValue());assertEquals(CellType.NUMERIC,row.getCell(4).getCellType());assertEquals(CellType.NUMERIC,row.getCell(5).getCellType());
assertEquals("'\n=SUM(1,2)",workbook.getSheetAt(0).getRow(101).getCell(0).getStringCellValue()); assertEquals("'\n=SUM(1,2)",workbook.getSheetAt(0).getRow(101).getCell(0).getStringCellValue());
assertTrue(workbook.getNumCellStyles()<10,"日期样式必须复用,不能按单元格创建"); assertTrue(workbook.getNumCellStyles()<10,"日期样式必须复用,不能按单元格创建");
} }
@@ -0,0 +1,19 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class LooseboardTransportPhotoFileTest {
/** 两节点文件独立归属原排车,客户文件及PDF不能充当现场照片。 */
@Test void photosHaveDistinctPlatformOwnershipAndImagePolicy(){
var pickup=BusinessFileCategory.require("OVERSEAS_DISPATCH_LOOSEBOARD_PICKUP_EVIDENCE");
var arrival=BusinessFileCategory.require("OVERSEAS_DISPATCH_LOOSEBOARD_ARRIVAL_EVIDENCE");
assertThat(pickup).isNotEqualTo(arrival);
for(var category:new BusinessFileCategory[]{pickup,arrival}){
category.validateOwner(0);assertThatThrownBy(()->category.validateOwner(100)).hasMessageContaining("平台");
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
assertThat(category.storageBusinessId("100")).isEqualTo("100");
assertThatThrownBy(()->category.storageBusinessId("../100")).isInstanceOf(RuntimeException.class);
}
}
}
@@ -148,7 +148,10 @@ public class BusinessFeeSourceEventService {
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId"); positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用"); if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
walletIdentity(value); walletIdentity(value);
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) { } else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())
// 新采购主单复用人工收费,只有显式账单结算可入池;旧采购和余额费用仍必须引用实扣。
&& !("LASTLEG".equals(value.sourceSystem()) && "OVERSEAS_PROCUREMENT".equals(value.businessType())
&& "BILL".equals(value.settlementMode()))) {
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池"); throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
} }
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) { if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
@@ -279,8 +282,7 @@ public class BusinessFeeSourceEventService {
if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem())) if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem()))
throw error("FIN-EVT-017", "资金业务引用不完整"); throw error("FIN-EVT-017", "资金业务引用不完整");
if ("OVERSEAS_MANUAL_FEE".equals(type)) { if ("OVERSEAS_MANUAL_FEE".equals(type)) {
if ("FEE_TYPE_077".equals(source.feeItemCode()) if (!no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
|| !no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配"); throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配");
} else if ("STORAGE_RENT".equals(type)) { } else if ("STORAGE_RENT".equals(type)) {
if (!"FEE_TYPE_077".equals(source.feeItemCode())) if (!"FEE_TYPE_077".equals(source.feeItemCode()))
@@ -0,0 +1,107 @@
package com.baiyue.finance.service;
import java.util.Locale;
import java.util.Map;
/** 导出业务枚举沿用财务页面中文口径,不改动筛选值、币种及业务编号。 */
final class FinanceExportLabels {
/** 中文导出映射仅提供静态转换。 */
private FinanceExportLabels() { }
private static final Map<String, String> STATUSES = Map.ofEntries(
Map.entry("ACTIVE", "正常"),
Map.entry("DISABLED", "停用"),
Map.entry("INACTIVE", "停用"),
Map.entry("DRAFT", "草稿"),
Map.entry("PENDING", "待审核"),
Map.entry("PENDING_REVIEW", "待审核"),
Map.entry("APPROVED", "已审核"),
Map.entry("ISSUED", "已出账"),
Map.entry("REJECTED", "已驳回"),
Map.entry("UNPAID", "未支付"),
Map.entry("PARTIALLY_PAID", "部分结清"),
Map.entry("PAID", "已结清"),
Map.entry("VOIDED", "已作废"),
Map.entry("AVAILABLE", "待结算"),
Map.entry("LOCKED", "结算锁定"),
Map.entry("BILLED", "已入账"),
Map.entry("SETTLED", "已结算"),
Map.entry("RECORDED", "已登记"),
Map.entry("CONFIRMED", "已确认"),
Map.entry("PENDING_CONFIRMATION", "待客户确认"),
Map.entry("NOT_REQUIRED", "无需确认"));
private static final Map<String, String> SOURCES = Map.ofEntries(
Map.entry("INBOUND_FEE", "入库费用"),
Map.entry("ORDER_FEE", "订单费用"),
Map.entry("CONTAINER_FEE", "装柜费用"),
Map.entry("CONTAINER_COST", "整柜成本"),
Map.entry("MANUAL_COST", "人工成本"),
Map.entry("STORAGE_FEE", "仓储费用"),
Map.entry("OVERSEAS_INBOUND_FEE", "海外入仓费用"),
Map.entry("OVERSEAS_PROCUREMENT_FEE", "海外采购费用"),
Map.entry("OVERSEAS_TRUCK_FEE", "本土卡车费用"),
Map.entry("OVERSEAS_LOOSEBOARD_FEE", "本土散板费用"),
Map.entry("OVERSEAS_WAREHOUSE_FEE", "海外仓服务费"),
Map.entry("OVERSEAS_CONTAINER_FEE", "海外柜子费用"),
Map.entry("OVERSEAS_ORDER_FEE", "尾程订单费用"),
Map.entry("OVERSEAS_DELIVERY_FEE", "尾程派送费用"),
Map.entry("OVERSEAS_RELABEL_FEE", "换标费用"));
private static final Map<String, String> BUSINESSES = Map.ofEntries(
Map.entry("RECHARGE", "充值"),
Map.entry("DEDUCTION", "扣款"),
Map.entry("CREDIT_LIMIT", "授信额度调整"),
Map.entry("FREEZE", "冻结"),
Map.entry("UNFREEZE", "解冻"),
Map.entry("CONSUMPTION", "消费扣款"),
Map.entry("REFUND", "退款"),
Map.entry("RECONCILIATION_CREATED", "生成对账单"),
Map.entry("SETTLEMENT_ISSUED", "结算单生效"),
Map.entry("RECEIPT", "收款"),
Map.entry("PAYMENT", "付款"),
Map.entry("SUPPLIER_DIRECT_PAID", "供应商线下直付"),
Map.entry("SUPPLIER_ON_ACCOUNT_PAYMENT", "供应商账期付款"),
Map.entry("ADJUSTMENT", "账务调整"),
Map.entry("VOID", "作废"),
Map.entry("BILL", "对账单"),
Map.entry("COST", "成本"),
Map.entry("SETTLEMENT", "结算"),
Map.entry("CREATE", "创建"),
Map.entry("APPROVE", "审核通过"),
Map.entry("REJECT", "审核驳回"),
Map.entry("UPDATE", "更新"),
Map.entry("FEE_SOURCE_EDIT", "编辑费用"),
Map.entry("FEE_SOURCE_SUBMIT", "提交审核"),
Map.entry("FEE_SOURCE_REVIEW_APPROVE", "审核通过"),
Map.entry("FEE_SOURCE_REVIEW_REJECT", "审核驳回"),
Map.entry("FEE_SOURCE_ADJUST", "调整费用"),
Map.entry("FEE_SOURCE_VOID", "作废费用"),
Map.entry("FEE_SOURCE_PUBLISHED", "费用同步"),
Map.entry("FEE_SOURCE_VERSION_ADJUST", "费用调整同步"),
Map.entry("RECONCILIATION_CREATE", "生成对账单"),
Map.entry("RECONCILIATION_ADJUST", "调整对账单"),
Map.entry("BILL_CREATE", "创建账单"),
Map.entry("BILL_ISSUE", "出账"),
Map.entry("BILL_ADJUST", "调整账单"),
Map.entry("BILL_VOID", "作废账单"),
Map.entry("SETTLEMENT_AR_CREATE", "生成应收结算单"),
Map.entry("SETTLEMENT_AP_CREATE", "生成应付结算单"),
Map.entry("SETTLEMENT_PAYMENT_CREATE", "登记收付款"),
Map.entry("RECEIPT_CREATE", "登记收款"),
Map.entry("COST_CREATE", "登记成本"),
Map.entry("REOPEN", "重开"));
/** 转换账户、审核及结算状态。 */
static String status(String value) { return label(value, STATUSES, "未知状态"); }
/** 转换费用来源类型。 */
static String source(String value) { return label(value, SOURCES, "未知来源类型"); }
/** 转换账务及资金流水业务类型。 */
static String business(String value) { return label(value, BUSINESSES, "未知业务类型"); }
/** 转换应收应付方向。 */
static String direction(String value) { return label(value, Map.of("RECEIVABLE", "应收", "PAYABLE", "应付"), "未知方向"); }
/** 空值保持空白,已是中文的历史快照保持原文;新增英文枚举不直接泄露到业务报表。 */
private static String label(String value, Map<String, String> labels, String fallback) {
if (value == null || value.isBlank()) return value;
String code = value.trim().toUpperCase(Locale.ROOT);
return labels.getOrDefault(code, value.matches(".*[一-鿿].*") ? value : fallback);
}
}
@@ -339,41 +339,47 @@ public class FinanceExportProviderService {
} catch (GeneralSecurityException exception) { throw new IllegalStateException("HmacSHA256 is unavailable", exception); } } catch (GeneralSecurityException exception) { throw new IllegalStateException("HmacSHA256 is unavailable", exception); }
} }
/** 导出账户摘要,状态使用中文展示。 */
private Map<String, Object> accountRow(AccountSummary value) { private Map<String, Object> accountRow(AccountSummary value) {
return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(), return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(),
"contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(), "contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(),
"receivedInPeriod", value.receivedInPeriod(), "totalCost", value.totalCost(), "paidCost", value.paidCost(), "receivedInPeriod", value.receivedInPeriod(), "totalCost", value.totalCost(), "paidCost", value.paidCost(),
"outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt()); "outstandingPayable", value.outstandingPayable(), "status", FinanceExportLabels.status(value.status()), "lastChangedAt", value.lastChangedAt());
} }
/** 导出账户流水,转换业务类型并保留金额和编号。 */
private Map<String, Object> ledgerRow(LedgerEntry value) { private Map<String, Object> ledgerRow(LedgerEntry value) {
return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", value.businessType(), return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", FinanceExportLabels.business(value.businessType()),
"documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(), "documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(),
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark()); "outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
} }
/** 费用方向、来源类型及审核和结算状态统一展示中文。 */
private Map<String, Object> feeRow(FeeSourceSummary source) { private Map<String, Object> feeRow(FeeSourceSummary source) {
return row("customerName", source.customerName(), "direction", source.direction(), "sourceType", source.sourceType(), return row("customerName", source.customerName(), "direction", FinanceExportLabels.direction(source.direction()), "sourceType", FinanceExportLabels.source(source.sourceType()),
"sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(), "sourceFeeId", String.valueOf(source.sourceFeeId()), "sourceVersion", source.sourceVersion(), "inboundCode", source.inboundCode(),
"containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(), "containerNo", source.containerNo(), "businessDate", source.businessDate(), "feeItemName", source.feeItemName(),
"quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(), "quantity", source.quantity(), "unitPrice", source.unitPrice(), "amount", source.amount(),
"reviewStatus", source.reviewStatus(), "sourceStatus", source.sourceStatus(), "statementGenerated", source.statementGenerated()); "reviewStatus", FinanceExportLabels.status(source.reviewStatus()), "sourceStatus", FinanceExportLabels.status(source.sourceStatus()), "statementGenerated", source.statementGenerated());
} }
/** 对账单导出沿用页面状态口径,是否结清仍传递布尔值。 */
private Map<String, Object> statementRow(ReconciliationDetail value) { private Map<String, Object> statementRow(ReconciliationDetail value) {
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(), return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
"periodEnd", value.periodEnd(), "creditDays", value.creditDays(), "currency", value.currency(), "totalReceivable", value.totalReceivable(), "periodEnd", value.periodEnd(), "creditDays", value.creditDays(), "currency", value.currency(), "totalReceivable", value.totalReceivable(),
"receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(), "receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", FinanceExportLabels.status(value.status()),
"remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt()); "remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt());
} }
/** 结算单列表导出使用中文状态,便于财务人员直接阅读。 */
private Map<String, Object> settlementRow(SettlementSummary value) { private Map<String, Object> settlementRow(SettlementSummary value) {
return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(), return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(),
"currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(), "currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(),
"businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", value.status()); "businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", FinanceExportLabels.status(value.status()));
} }
/** 明细导出与列表保持相同的中文结算状态。 */
private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) { private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) {
return row("settlementNo", summary.settlementNo(), "customerName", summary.customerName(), "supplierName", summary.supplierName(), return row("settlementNo", summary.settlementNo(), "customerName", summary.customerName(), "supplierName", summary.supplierName(),
"feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(), "feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(),
"containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(), "containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(),
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(), "lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
"outstandingAmount", summary.outstandingAmount(), "status", summary.status()); "outstandingAmount", summary.outstandingAmount(), "status", FinanceExportLabels.status(summary.status()));
} }
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) { private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
return row("statisticsMonth", month.toString(), "customerName", value.customerName(), return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
+2 -1
View File
@@ -225,7 +225,8 @@ CREATE TABLE IF NOT EXISTS fin_bill_line (
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_line_source (source_type, source_id, source_version), UNIQUE KEY uk_fin_bill_line_bill_source (bill_id, source_type, source_id, source_version),
KEY idx_fin_bill_line_source (source_type, source_id, source_version),
KEY idx_fin_bill_line_bill (tenant_id, bill_id), KEY idx_fin_bill_line_bill (tenant_id, bill_id),
CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0), CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'), CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'),
@@ -328,6 +328,53 @@ class BusinessFeeSourceEventServiceTest {
noFinancialWrites(); noFinancialWrites();
} }
/** 新采购主单只有明确选择账单时才可作为未收应收入池。 */
@Test
void manualProcurementExplicitBillCanEnterReceivablePool() {
EventEnvelope event = manualProcurementEvent("BILL");
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class));
}
/** 采购主单的余额或默认模式不能因兼容账单而绕过实扣校验。 */
@ParameterizedTest
@org.junit.jupiter.params.provider.NullSource
@ValueSource(strings = {"WALLET"})
void manualProcurementWithoutConsumptionCannotEnterPool(String mode) {
assertThatThrownBy(() -> service.consume(manualProcurementEvent(mode))).hasMessageContaining("必须引用成功实扣");
noFinancialWrites();
}
/** 保留旧采购子费用的扣款约束,即使载荷声明账单结算也不能入池。 */
@Test
void legacyProcurementCannotBypassConsumptionWithBillMode() {
EventEnvelope base = procurementEvent(false);
ObjectNode payload = (ObjectNode) base.payload().deepCopy();
payload.put("settlementMode", "BILL");
assertThatThrownBy(() -> service.consume(resignProcurementEvent(base, payload))).hasMessageContaining("必须引用成功实扣");
noFinancialWrites();
}
private EventEnvelope manualProcurementEvent(String mode) {
EventEnvelope base = procurementEvent(false);
ObjectNode payload = (ObjectNode) base.payload().deepCopy();
payload.put("businessType", "OVERSEAS_PROCUREMENT");
if (mode == null) payload.putNull("settlementMode"); else payload.put("settlementMode", mode);
return resignProcurementEvent(base, payload);
}
private EventEnvelope resignProcurementEvent(EventEnvelope base, ObjectNode payload) {
payload.putNull("action"); payload.putNull("sourceHash");
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
payload.put("action", "PUBLISHED");
return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(),
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
@Test @Test
void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() { void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() {
EventEnvelope event = procurementEvent(true); EventEnvelope event = procurementEvent(true);
@@ -375,6 +422,12 @@ class BusinessFeeSourceEventServiceTest {
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071"); verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071");
} }
/** 手动仓租保留人工费用资金身份,仍校验原冻结与实扣来源。 */
@Test
void manualStorageRentConsumptionUsesManualFundingIdentity() {
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_077");
}
@Test @Test
void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() { void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() {
verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077"); verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077");
@@ -0,0 +1,66 @@
package com.baiyue.finance;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FinanceReconciliationSchemaContractTest {
/** 执行真实明细表约束,复现两笔费用作废后重新出单,保留原单快照且单内不能重复。 */
@ParameterizedTest
@ValueSource(booleans = {false, true})
void voidedStatementKeepsHistoryWhileItsTwoSourcesCanEnterANewStatement(boolean unified) throws Exception {
JdbcTemplate jdbc = createSchema(unified);
jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT NOT NULL,status VARCHAR(32) NOT NULL)");
jdbc.update("INSERT INTO fin_bill VALUES(501,9,'ISSUED'),(502,9,'DRAFT')");
insertLine(jdbc, 1, 501, 88);
insertLine(jdbc, 2, 501, 89);
jdbc.update("UPDATE fin_bill SET status='VOIDED' WHERE id=501");
insertLine(jdbc, 3, 502, 88);
insertLine(jdbc, 4, 502, 89);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE bill_id=501", Integer.class)).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE bill_id=502", Integer.class)).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT status FROM fin_bill WHERE id=501", String.class)).isEqualTo("VOIDED");
assertThat(jdbc.queryForObject("SELECT SUM(l.amount) FROM fin_bill_line l JOIN fin_bill b ON b.id=l.bill_id AND b.tenant_id=l.tenant_id WHERE b.status<>'VOIDED'", BigDecimal.class))
.isEqualByComparingTo("200.00");
assertThatThrownBy(() -> insertLine(jdbc, 5, 502, 88)).isInstanceOf(DuplicateKeyException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line", Integer.class)).isEqualTo(4);
}
/** 从模块或统一空库入口读取真实建表语句,仅移除 H2 不支持的 MySQL 存储语法。 */
private static JdbcTemplate createSchema(boolean unified) throws Exception {
Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
if (!Files.exists(unifiedPath)) unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
String sql = Files.readString(unified ? unifiedPath : Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
var table = Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?fin_bill_line \\(.*?\\) ENGINE=[^;]*;", Pattern.DOTALL).matcher(sql);
assertThat(table.find()).isTrue();
String ddl = table.group().replaceFirst("\\) ENGINE=[^;]*;", ");")
.replaceAll("(?i) COMMENT '(?:[^']|'')*'", "")
.replaceAll("(?i)\\bUNSIGNED\\b|_utf8mb4", "")
.replaceAll("(?m)^\\s*KEY [^\\r\\n]+\\r?\\n", "")
.replaceAll("UNIQUE KEY (\\w+)", "CONSTRAINT $1 UNIQUE");
var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"));
jdbc.execute(ddl);
return jdbc;
}
/** 写入完整合成明细,仅改变本测试关注的单据和费用来源身份。 */
private static void insertLine(JdbcTemplate jdbc, long id, long billId, long sourceId) {
jdbc.update("INSERT INTO fin_bill_line(id,tenant_id,bill_id,source_type,source_id,source_version,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,9,?,'FIRSTLEG_FEE',?,1,'SYNTHETIC','合成测试费用',100,'CNY')",
id, billId, sourceId);
}
}
@@ -126,6 +126,22 @@ class ReconciliationStatementBehaviorTest {
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L)); verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L));
} }
/** 已被占用的费用不能完成第二张有效对账单的生成,事务必须拒绝提交。 */
@Test
void createRejectsSourceWhoseAvailableStatusCannotBeLocked() throws Exception {
JdbcTemplate jdbc = createJdbc();
when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source SET status='LOCKED'"), eq(88L), eq(9L)))
.thenReturn(0);
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L),
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
assertThatThrownBy(() -> serviceWithNumber(jdbc).create(71L, "create-key", request))
.isInstanceOf(FinanceEventException.class)
.hasMessage("费用来源已被其他对账单占用")
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), (Object[]) any());
}
@Test @Test
void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception { void selectedCrossCustomerSourceIsHiddenAsNotFoundBeforeBillInsert() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> { JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> {
@@ -428,6 +444,24 @@ class ReconciliationStatementBehaviorTest {
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)); verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L));
} }
/** 作废单的两笔原费用有任一已进入新单时,旧单不得重开为另一张有效单。 */
@ParameterizedTest
@ValueSource(ints = {0, 1})
void reopenRejectsVoidedStatementWhenReplacementOccupiesAnySource(int reacquired) throws Exception {
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-conflict-key");
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L)))
.thenReturn(2);
when(jdbc.update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_fee_source f JOIN"), eq(9L), eq(501L), eq(9L)))
.thenReturn(reacquired);
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L, "reopen-conflict-key",
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of())))
.isInstanceOf(FinanceEventException.class)
.hasMessage("原费用来源已被占用,无法重开对账单")
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
verify(jdbc, never()).update(org.mockito.ArgumentMatchers.startsWith("UPDATE fin_bill SET status="), (Object[]) any());
}
@Test @Test
void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() { void reconciliationIdempotencyRecoversFailedAndStaleProcessingRecords() {
ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of()); ReconciliationActionRequest body = new ReconciliationActionRequest("SUBMIT", null, 0, List.of());
@@ -0,0 +1,34 @@
package com.baiyue.finance.service;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceExportLabelsTest {
@Test void translatesStatusSourceAndLedgerFamiliesWithoutLeakingCodes() {
assertThat(FinanceExportLabels.status("PARTIALLY_PAID")).isEqualTo("部分结清");
assertThat(FinanceExportLabels.status("SETTLED")).isEqualTo("已结算");
for (String code : List.of("ACTIVE", "DISABLED", "DRAFT", "PENDING", "PENDING_REVIEW", "APPROVED", "REJECTED", "ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED", "AVAILABLE", "LOCKED", "BILLED")) {
assertThat(FinanceExportLabels.status(code)).matches(".*[一-鿿].*").doesNotContain("未知");
}
for (String code : List.of("INBOUND_FEE", "ORDER_FEE", "CONTAINER_FEE", "CONTAINER_COST", "MANUAL_COST", "STORAGE_FEE", "OVERSEAS_INBOUND_FEE", "OVERSEAS_PROCUREMENT_FEE", "OVERSEAS_TRUCK_FEE", "OVERSEAS_LOOSEBOARD_FEE", "OVERSEAS_WAREHOUSE_FEE", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_ORDER_FEE", "OVERSEAS_DELIVERY_FEE", "OVERSEAS_RELABEL_FEE")) {
assertThat(FinanceExportLabels.source(code)).matches(".*[一-鿿].*").doesNotContain("未知");
}
for (String code : List.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED", "SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "SUPPLIER_DIRECT_PAID", "SUPPLIER_ON_ACCOUNT_PAYMENT", "ADJUSTMENT", "VOID")) {
assertThat(FinanceExportLabels.business(code)).matches(".*[一-鿿].*").doesNotContain("未知");
}
assertThat(FinanceExportLabels.direction("RECEIVABLE")).isEqualTo("应收");
for (String code : List.of("RECHARGE", "DEDUCTION", "CREDIT_LIMIT", "FREEZE", "UNFREEZE", "CONSUMPTION", "REFUND")) {
assertThat(FinanceExportLabels.business(code)).matches(".*[一-鿿].*").doesNotContain("未知");
}
assertThat(FinanceExportLabels.direction("PAYABLE")).isEqualTo("应付");
}
@Test void handlesEmptyChineseSnapshotsAndUnknownValues() {
assertThat(FinanceExportLabels.status(null)).isNull();
assertThat(FinanceExportLabels.status("")).isEmpty();
assertThat(FinanceExportLabels.status("已结清")).isEqualTo("已结清");
assertThat(FinanceExportLabels.status("NEW_INTERNAL_STATUS")).isEqualTo("未知状态");
assertThat(FinanceExportLabels.status(" partially_paid ")).isEqualTo("部分结清");
}
}
@@ -45,6 +45,39 @@ class FinanceExportProviderServiceTest {
service.initializeSigningKey(); service.initializeSigningKey();
} }
@Test
void exportsAccountAndLedgerEnumsAsChineseWithoutChangingFiltersOrReferences() throws Exception {
var mapper = new ObjectMapper().findAndRegisterModules();
var account = mapper.convertValue(Map.of("customerId", 10001L, "customerCode", "C-10001", "status", "ACTIVE"), com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary.class);
var ledger = mapper.convertValue(Map.of("ledgerId", 1L, "businessType", "SUPPLIER_ON_ACCOUNT_PAYMENT", "documentNo", "AP-1"), com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry.class);
when(accountService.list(1, 100, null, "ACTIVE", 10001L)).thenReturn(PageEnvelope.success(List.of(account), 1, 100, 1));
when(operationService.listDerivedCustomerAccountLedger(10001L, 1, 100, "SUPPLIER_ON_ACCOUNT_PAYMENT", null, null)).thenReturn(PageEnvelope.success(List.of(ledger), 1, 100, 1));
var accounts = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, request(Map.of("customerId", "10001", "status", "ACTIVE"), null, null));
var ledgers = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LEDGER, request(Map.of("customerId", "10001", "businessType", "SUPPLIER_ON_ACCOUNT_PAYMENT"), null, null));
assertThat(accounts.rows().get(0)).containsEntry("status", "正常").containsEntry("customerCode", "C-10001");
assertThat(ledgers.rows().get(0)).containsEntry("businessType", "供应商账期付款").containsEntry("documentNo", "AP-1");
}
@Test
/** 应付结算列表和详情导出均使用中文状态,包括无明细的单据。 */
void exportsSettlementStatusesInChinese() {
for (var entry : Map.of("ISSUED", "已出账", "PARTIALLY_PAID", "部分结清", "PAID", "已结清", "VOIDED", "已作废").entrySet()) {
var summary = new com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary(
701L, "AP-701", "PAYABLE", 10001L, "客户A", 301L, "供应商A", null, null,
"2026-11", "操作费", "CNY", BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN,
LocalDate.parse("2026-11-03"), null, "UNPAID", entry.getKey(), 7L, "财务员", null, null, null, 1);
when(settlementService.listSettlements("PAYABLE", null)).thenReturn(List.of(summary));
when(settlementService.getSettlement(701L)).thenReturn(new com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail(
summary, List.of(), List.of(), List.of()));
var list = service.exportBatch(FinanceExportProviderService.PAYABLE_SETTLEMENT_LIST,
request(Map.of("status", entry.getKey()), null, null));
var detail = service.exportBatch(FinanceExportProviderService.PAYABLE_SETTLEMENT_DETAIL,
request(Map.of("settlementId", "701"), null, null));
assertThat(list.rows().get(0)).containsEntry("status", entry.getValue());
assertThat(detail.rows().get(0)).containsEntry("status", entry.getValue());
}
}
@Test @Test
void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() { void exportsStatementRowsUsingBusinessContainerNumberAndStableSnapshot() {
Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"), Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"),
@@ -78,7 +111,7 @@ class FinanceExportProviderServiceTest {
assertThat(first.columns()).isNotEmpty(); assertThat(first.columns()).isNotEmpty();
assertThat(first.rows()).hasSize(1); assertThat(first.rows()).hasSize(1);
assertThat(first.rows().get(0)).containsEntry("statementNo", "REC-501").containsEntry("customerName", "客户A"); assertThat(first.rows().get(0)).containsEntry("statementNo", "REC-501").containsEntry("customerName", "客户A").containsEntry("status", "已出账");
assertThat(first.totalRows()).isEqualTo(1L); assertThat(first.totalRows()).isEqualTo(1L);
assertThat(continued.columns()).isEmpty(); assertThat(continued.columns()).isEmpty();
assertThat(continued.rows()).isEmpty(); assertThat(continued.rows()).isEmpty();
@@ -99,7 +132,7 @@ class FinanceExportProviderServiceTest {
var result = service.exportBatch(FinanceExportProviderService.FEE_SOURCE_LIST, var result = service.exportBatch(FinanceExportProviderService.FEE_SOURCE_LIST,
request(Map.of("direction", "PAYABLE", "status", "PAID"), null, null)); request(Map.of("direction", "PAYABLE", "status", "PAID"), null, null));
assertThat(result.rows()).singleElement().satisfies(row -> { assertThat(result.rows()).singleElement().satisfies(row -> {
assertThat(row).containsEntry("sourceStatus", "PAID").containsEntry("sourceFeeId", "9001"); assertThat(row).containsEntry("sourceStatus", "已结清").containsEntry("reviewStatus", "已审核").containsEntry("direction", "应付").containsEntry("sourceType", "装柜费用").containsEntry("sourceFeeId", "9001");
assertThat(row.get("amount")).isEqualTo(new BigDecimal("300")); assertThat(row.get("amount")).isEqualTo(new BigDecimal("300"));
}); });
org.mockito.Mockito.verifyNoInteractions(queryService); org.mockito.Mockito.verifyNoInteractions(queryService);
+2 -2
View File
@@ -32,8 +32,8 @@
| 称重确认、整票库位块、部分拣货结算 | `service/MeasurementLocationOccupationService.java`、`service/MeasurementSubmissionService.java`;查“首张称重图片”“删除称重图片”“PDA 部分拣货” | | 称重确认、整票库位块、部分拣货结算 | `service/MeasurementLocationOccupationService.java`、`service/MeasurementSubmissionService.java`;查“首张称重图片”“删除称重图片”“PDA 部分拣货” |
| 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” | | 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” |
| 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” | | 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” |
| FBA 计价、客户专属价格簿、柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“客户专属价格簿、柜级阶梯运费与调价边界” | | FBA 计价、私人地址人工报价、客户价格簿与柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“私人地址”“客户专属价格簿、柜级阶梯运费与调价边界” |
| 费用项目、价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`pricebookquery/application/PriceBookQueryService.java`;查“价格簿查询内部切片” | | 费用项目、共享费用目录与价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`controller/InternalFeeCatalogController.java`、`pricebookquery/application/PriceBookQueryService.java`;共享目录契约 `src/main/resources/openapi/fee-catalog-internal-v1.yaml`,价格簿查“价格簿查询内部切片” |
| 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” | | 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” |
| 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” | | 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” |
| 分柜、预装、正式装柜清单生命周期 | `service/LoadingListService.java`、`split/controller/PlatformSplitPreloadController.java`;查“分柜、预装”“预装进入” | | 分柜、预装、正式装柜清单生命周期 | `service/LoadingListService.java`、`split/controller/PlatformSplitPreloadController.java`;查“分柜、预装”“预装进入” |
+4 -4
View File
@@ -30,8 +30,8 @@
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | | 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | | 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` | | 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认 `ACTUAL` 不调整尺寸,可启用的调整算法为 `ADD/MULTIPLY` |
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | | FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值;私人地址 `PRIVATE` 使用 `/fees/manual-quote` 生成人工头程报价,按客户计费方式计算计费量且附加费为零,预览返回 `priceMatched=false`,报价后沿用费用版本、幂等与预装前初始费用校验;`POST /fees/preview` 与正式计价共用算法,只读返回当前选择值的计费量、参考单价和合计,不生成费用版本或改变履约、财务状态 |
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 | | 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` | | 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` | | 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
@@ -47,7 +47,7 @@
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | | 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | | 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 | | PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 |
| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),国内 PRIVATE 按 OVERSEAS 交接并保留私人地址及箱码快照,同一入仓单的原始目的类型必须唯一;OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | | 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` | | 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` |
@@ -76,7 +76,7 @@
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。 - 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。 - 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 - 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 - 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ACTUAL` 不调整尺寸,仓库修正值替换对应实测边;`ADD` 以修正后的实测尺寸为基准对三个维度分别增加命中箱规的尺寸值;`MULTIPLY` 使用选定的启用系数配置,未指定配置时取当前客户唯一默认配置,由服务端按规格档位选择系数并分别作用于长宽高,体积按系数三次方变化。设备与人工录入共用计算入口,行明确选择的算法优先,否则仅使用客户默认算法;预置或保存系数配置不等于启用该算法。测量行必须保存实际尺寸、选用规则和算法快照,修改确认箱数复用已保存单箱快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 - 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
@@ -37,6 +37,16 @@ import java.security.MessageDigest;
@RestController @RestController
@RequestMapping("/internal/firstleg/v1/inbounds") @RequestMapping("/internal/firstleg/v1/inbounds")
public class InternalInboundOrderController { public class InternalInboundOrderController {
/** 海外废弃溯源只读取已生成的有效箱唛,不触发打印或补建。 */
@org.springframework.web.bind.annotation.GetMapping("/{inboundId}/existing-carton-marks")
public ApiResponse<List<String>> existingCartonMarks(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@org.springframework.web.bind.annotation.PathVariable long inboundId,
@org.springframework.web.bind.annotation.RequestParam long customerId,
@org.springframework.web.bind.annotation.RequestParam String inboundCode) {
requireToken(token);
return ApiResponse.success(cartonLabels.existingCartonMarks(customerId, inboundId, inboundCode));
}
@Resource @Resource
private InboundOrderCreationService service; private InboundOrderCreationService service;
@Resource @Resource
@@ -7,6 +7,8 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
import com.baiyue.firstleg.service.FbaPricingService; import com.baiyue.firstleg.service.FbaPricingService;
@@ -34,6 +36,16 @@ public class PlatformFbaPricingController {
@Resource @Resource
private FbaPricingService service; private FbaPricingService service;
/** 预览当前重量、体积对应的计费量和价格,不生成费用版本或改变履约状态。 */
@PostMapping("/preview")
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')")
public ApiResponse<FeePreviewResponse> preview(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @Valid @RequestBody FeePreviewRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.preview(scope, inboundId, request));
}
@PostMapping("/calculate") @PostMapping("/calculate")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')") @PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')")
@BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId", @BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId",
@@ -35,6 +35,13 @@ public interface ContainerCustomsMapper {
f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy, f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy,
f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version
"""; """;
String CONTAINER_COLUMNS = """
id id,loading_plan_id loadingPlanId,container_booking_id containerBookingId,status status,
started_by startedBy,started_by_name startedByName,started_at startedAt,
completed_by completedBy,completed_by_name completedByName,completed_at completedAt,
broker_submitted_by brokerSubmittedBy,broker_submitted_by_name brokerSubmittedByName,
broker_submitted_at brokerSubmittedAt,created_at createdAt,updated_at updatedAt,version version
""";
@Select(""" @Select("""
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId, SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
@@ -132,19 +139,19 @@ public interface ContainerCustomsMapper {
int insertContainer(@Param("id") long id,@Param("planId") long planId, int insertContainer(@Param("id") long id,@Param("planId") long planId,
@Param("containerBookingId") long containerBookingId,@Param("status") String status); @Param("containerBookingId") long containerBookingId,@Param("status") String status);
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId}") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE loading_plan_id=#{planId}")
ContainerRow container(@Param("planId") long planId); ContainerRow container(@Param("planId") long planId);
@Select("SELECT * FROM fl_customs_container WHERE id=#{id}") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE id=#{id}")
ContainerRow containerById(@Param("id") long id); ContainerRow containerById(@Param("id") long id);
@Select("SELECT * FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}")
ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId); ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE")
ContainerRow lockContainer(@Param("planId") long planId); ContainerRow lockContainer(@Param("planId") long planId);
@Select("SELECT * FROM fl_customs_container WHERE id=#{id} FOR UPDATE") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE id=#{id} FOR UPDATE")
ContainerRow lockContainerById(@Param("id") long id); ContainerRow lockContainerById(@Param("id") long id);
@Update(""" @Update("""
@@ -254,6 +254,9 @@ public class ContainerCustomsService {
throw conflict("当前柜尚未完成首次发送报关行资料"); throw conflict("当前柜尚未完成首次发送报关行资料");
} }
CompletionRow completion = mapper.lockCompletion(documentId); CompletionRow completion = mapper.lockCompletion(documentId);
if (completion != null && "CONFIRMED".equals(completion.customerConfirmStatus())) {
throw conflict("客户已确认预录单,不可再次上传");
}
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long completionId; long completionId;
int version; int version;
@@ -76,6 +76,25 @@ public final class FbaPricingDtos {
} }
} }
/** 当前输入的只读预览,人工单价仅用于预览金额,不写入费用。 */
public record FeePreviewRequest(
LocalDateTime pricingAt,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
@Size(max = 32) String weightSource,
@Size(max = 32) String volumeSource,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedUnitPrice
) {}
/** 当前选择值的只读计费预览,不包含费用版本或持久化标识。 */
public record FeePreviewResponse(
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
BigDecimal unitPrice, BigDecimal additionalAmount, BigDecimal totalAmount,
String currency, boolean priceMatched
) {}
public record ManualQuoteRequest( public record ManualQuoteRequest(
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.entity; package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter; import lombok.Getter;
@@ -34,6 +36,7 @@ public class InboundMeasurementItemEntity {
private BigDecimal volumeCbm; private BigDecimal volumeCbm;
private BigDecimal totalVolumeCbm; private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg; private BigDecimal volumeWeightKg;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long chargeRuleId; private Long chargeRuleId;
private String coefficientMode; private String coefficientMode;
private String calculationRuleVersion; private String calculationRuleVersion;
@@ -44,8 +47,12 @@ public class InboundMeasurementItemEntity {
private BigDecimal outerLengthCm; private BigDecimal outerLengthCm;
private BigDecimal outerWidthCm; private BigDecimal outerWidthCm;
private BigDecimal outerHeightCm; private BigDecimal outerHeightCm;
// 清空修正表示恢复对应实测边,必须显式写入 NULL,不能被默认的非空更新策略忽略。
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterLengthCm; private BigDecimal manualOuterLengthCm;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterWidthCm; private BigDecimal manualOuterWidthCm;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterHeightCm; private BigDecimal manualOuterHeightCm;
private String remark; private String remark;
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -14,7 +14,7 @@ public interface CabinetLoadingEvidenceMapper extends BaseMapper<CabinetLoadingE
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} ORDER BY evidence_type,id") @Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} ORDER BY evidence_type,id")
List<CabinetLoadingEvidenceEntity> byContainer(@Param("containerBookingId") long containerBookingId); List<CabinetLoadingEvidenceEntity> byContainer(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 LIMIT 1 FOR UPDATE") @Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 ORDER BY id DESC LIMIT 1 FOR UPDATE")
CabinetLoadingEvidenceEntity activeForUpdate(@Param("containerBookingId") long containerBookingId, CabinetLoadingEvidenceEntity activeForUpdate(@Param("containerBookingId") long containerBookingId,
@Param("type") String type); @Param("type") String type);
@@ -74,8 +74,6 @@ public class CabinetLoadingEvidenceService {
if (current != null && !Objects.equals(current.getVersion(), expected)) throw conflict("装柜证据版本已变化,请刷新后重试"); if (current != null && !Objects.equals(current.getVersion(), expected)) throw conflict("装柜证据版本已变化,请刷新后重试");
if (current != null && Objects.equals(current.getFileId(), request.fileId()) if (current != null && Objects.equals(current.getFileId(), request.fileId())
&& Objects.equals(current.getSealNo(), sealNo)) return view(scope); && Objects.equals(current.getSealNo(), sealNo)) return view(scope);
if (current != null && mapper.voidActive(current.getId(), scope.containerBookingId(), current.getVersion(),
"REPLACED", operatorId, operatorName) != 1) throw conflict("装柜证据版本已变化,请刷新后重试");
CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity(); CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity();
value.setContainerBookingId(scope.containerBookingId()); value.setSourceTaskId(taskId); value.setContainerBookingId(scope.containerBookingId()); value.setSourceTaskId(taskId);
value.setEvidenceType(type); value.setFileId(request.fileId()); value.setFileName(file.fileName()); value.setEvidenceType(type); value.setFileId(request.fileId()); value.setFileName(file.fileName());
@@ -84,8 +82,7 @@ public class CabinetLoadingEvidenceService {
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
try { mapper.insert(value); } catch (DuplicateKeyException exception) { try { mapper.insert(value); } catch (DuplicateKeyException exception) {
// A replacement may already have voided the old row in this transaction. // The task-scoped operation key protects concurrent duplicate uploads.
// Roll it back; a subsequent same-key request can read the committed winner.
throw conflict("装柜证据发生并发变更,请刷新后重试"); throw conflict("装柜证据发生并发变更,请刷新后重试");
} }
return view(scope); return view(scope);
@@ -103,7 +103,8 @@ public final class CarrierScheduleQueryDtos {
String providerNodeCode, String standardNode, String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} String sourceReference, String rawPayloadHash, LocalDateTime createdAt,
String operatorName) {}
public record ContainerNodeDetail(Long containerBookingId, String currentNode, public record ContainerNodeDetail(Long containerBookingId, String currentNode,
LocalDateTime currentNodeAt, List<NodeFact> nodes) {} LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
@@ -30,5 +30,7 @@ public class CarrierScheduleNodeFactEntity {
private String locationName; private String locationName;
private String sourceReference; private String sourceReference;
private String rawPayloadHash; private String rawPayloadHash;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
} }
@@ -11,14 +11,15 @@ import java.util.List;
@Mapper @Mapper
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> { public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
/** 保存不可变节点事实及执行该操作的账号快照。 */
@Insert(""" @Insert("""
INSERT INTO fl_carrier_schedule_node_fact INSERT INTO fl_carrier_schedule_node_fact
(id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at, (id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at) estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,operator_id,operator_name,created_at)
VALUES VALUES
(#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode}, (#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt}, #{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},#{value.operatorId},#{value.operatorName},
CURRENT_TIMESTAMP(3)) CURRENT_TIMESTAMP(3))
""") """)
int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value); int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value);
@@ -111,7 +111,7 @@ public class CarrierScheduleQueryService {
public long customerId(long id) { return required(id).getCustomerId(); } public long customerId(long id) { return required(id).getCustomerId(); }
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */ /** 国内确认出仓后幂等建立跟踪任务,并保存确认人的账号快照。 */
@Transactional @Transactional
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId, public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
long containerBookingId, long containerBookingId,
@@ -161,7 +161,7 @@ public class CarrierScheduleQueryService {
persistenceStage = "NODE_FACT"; persistenceStage = "NODE_FACT";
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
sha256("OUTBOUND_CONFIRM|" + containerBookingId)); sha256("OUTBOUND_CONFIRM|" + containerBookingId), operatorId, operatorName);
if (departed != null) { if (departed != null) {
persistenceStage = "CROSS_REGION_OUTBOX"; persistenceStage = "CROSS_REGION_OUTBOX";
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
@@ -185,6 +185,7 @@ public class CarrierScheduleQueryService {
+ ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), ""); + ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), "");
} }
/** 保存船司自动采集事实,操作人留空以区别人工补录。 */
@Transactional @Transactional
public Detail applyProviderObservation(long id, ProviderObservationRequest request) { public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id); CarrierScheduleQueryEntity value = requiredLocked(id);
@@ -193,7 +194,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(), CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
request.providerNodeCode(), node, request.providerNodeCode(), node,
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
request.locationName(), request.sourceReference(), request.rawPayloadHash()); request.locationName(), request.sourceReference(), request.rawPayloadHash(), null, null);
if (fact == null) { if (fact == null) {
return detail(value); return detail(value);
} }
@@ -220,6 +221,7 @@ public class CarrierScheduleQueryService {
return detail(value); return detail(value);
} }
/** 人工补录节点与操作账号一并留存,重放不覆盖原有事实。 */
@Transactional @Transactional
public Detail addManualNode(long id, long operatorId, String operatorName, String key, public Detail addManualNode(long id, long operatorId, String operatorName, String key,
ManualNodeRequest request) { ManualNodeRequest request) {
@@ -238,7 +240,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node, CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
request.observedAt(), request.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request)); "MANUAL:" + operatorId, hash(operation, id, request), operatorId, operatorName);
if (fact == null) { if (fact == null) {
return complete(id, operatorId, key, detail(value)); return complete(id, operatorId, key, detail(value));
} }
@@ -261,6 +263,7 @@ public class CarrierScheduleQueryService {
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId)); return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
} }
/** 按货柜补录节点并保存操作账号,未建立查询任务时同样保留署名。 */
@Transactional @Transactional
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId, public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
String operatorName, String key, String operatorName, String key,
@@ -280,7 +283,8 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId, CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
"MANUAL", providerEventId, "MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(), node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash); request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash,
operatorId, operatorName);
if (fact != null && query != null) { if (fact != null && query != null) {
CarrierScheduleQueryEntity before = copy(query); CarrierScheduleQueryEntity before = copy(query);
requireManualActualAdvance(query, node, request.actualAt()); requireManualActualAdvance(query, node, request.actualAt());
@@ -546,11 +550,12 @@ public class CarrierScheduleQueryService {
repository.histories(value.getId()).stream().map(this::history).toList()); repository.histories(value.getId()).stream().map(this::history).toList());
} }
/** 返回节点发生时保存的用户名,避免以来源引用代替操作人。 */
private NodeFact node(CarrierScheduleNodeFactEntity value) { private NodeFact node(CarrierScheduleNodeFactEntity value) {
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(), return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(), value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt()); value.getRawPayloadHash(), value.getCreatedAt(), value.getOperatorName());
} }
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) { private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
@@ -565,21 +570,23 @@ public class CarrierScheduleQueryService {
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
} }
/** 为查询任务追加节点事实并透传人工操作账号。 */
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId, private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
String providerNodeCode, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) { String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId, return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName, providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
sourceReference, rawPayloadHash); sourceReference, rawPayloadHash, operatorId, operatorName);
} }
/** 节点与操作人快照同次入库,重复事件保留首次记录。 */
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId, private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
String providerCode, String providerEventId, String providerNodeCode, String providerCode, String providerEventId, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName, LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) { String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity(); CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId); fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
@@ -589,6 +596,7 @@ public class CarrierScheduleQueryService {
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt); fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
fact.setOperatorId(operatorId); fact.setOperatorName(trim(operatorName));
if (repository.insertIfAbsent(fact)) return fact; if (repository.insertIfAbsent(fact)) return fact;
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey()); CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) { if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) {
@@ -44,21 +44,22 @@ public class CartonMeasurementService {
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider; private final ObjectProvider<CartonLabelBindingResolver> resolverProvider;
private final MeasurementImageClient imageClient; private final MeasurementImageClient imageClient;
private final TransactionTemplate transactionTemplate; private final TransactionTemplate transactionTemplate;
private final CustomerDimensionWeightingService dimensionWeighting; private final MeasurementBatchService measurementBatchService;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String internalToken; private String internalToken;
/** 设备及手工单箱录入复用测量批次的计算规则。 */
public CartonMeasurementService(InboundMeasurementRepository repository, public CartonMeasurementService(InboundMeasurementRepository repository,
ObjectProvider<CartonLabelBindingResolver> resolverProvider, ObjectProvider<CartonLabelBindingResolver> resolverProvider,
MeasurementImageClient imageClient, MeasurementImageClient imageClient,
TransactionTemplate transactionTemplate, TransactionTemplate transactionTemplate,
CustomerDimensionWeightingService dimensionWeighting) { MeasurementBatchService measurementBatchService) {
this.repository = repository; this.repository = repository;
this.resolverProvider = resolverProvider; this.resolverProvider = resolverProvider;
this.imageClient = imageClient; this.imageClient = imageClient;
this.transactionTemplate = transactionTemplate; this.transactionTemplate = transactionTemplate;
this.dimensionWeighting = dimensionWeighting; this.measurementBatchService = measurementBatchService;
} }
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) { public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
@@ -178,6 +179,7 @@ public class CartonMeasurementService {
return result(measurement, item, false); return result(measurement, item, false);
} }
/** 保存设备原始尺寸与证据,按客户默认算法或已保存的行算法生成计费快照。 */
private UpsertOutcome persist(long tenantId, long operatorId, private UpsertOutcome persist(long tenantId, long operatorId,
CartonLabelBindingResolver.Binding binding, CartonLabelBindingResolver.Binding binding,
String boxCode, CartonMeasurementCommand command, long imageFileId, String boxCode, CartonMeasurementCommand command, long imageFileId,
@@ -228,7 +230,7 @@ public class CartonMeasurementService {
item.setMachineNo(command.machineNo().trim()); item.setMachineNo(command.machineNo().trim());
item.setDeviceMeasuredAt(command.measuredAt()); item.setDeviceMeasuredAt(command.measuredAt());
item.setUpdatedAt(LocalDateTime.now()); item.setUpdatedAt(LocalDateTime.now());
applyCalculatedValues(item, dimensionWeighting.load(tenantId)); measurementBatchService.calculateCartonDimensions(item);
if (created) { if (created) {
repository.insertItem(item); repository.insertItem(item);
@@ -265,33 +267,12 @@ public class CartonMeasurementService {
return measurement; return measurement;
} }
private void applyCalculatedValues(InboundMeasurementItemEntity item, /** 仅修改确认箱数时复用已保存的单箱快照,不重新套用当前客户规则。 */
CustomerDimensionWeightingService.RuleSet dimensionRules) {
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolveDeviceMeasurement(
item.getLengthCm(), item.getWidthCm(), item.getHeightCm());
BigDecimal unitRaw = weighted.rawVolumeCbm();
BigDecimal unitVolume = weighted.unitVolumeCbm();
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
BigDecimal totalRaw = unitRaw.multiply(count).setScale(6, RoundingMode.HALF_UP);
BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.UNNECESSARY);
item.setGirthCm(item.getLengthCm().add(item.getWidthCm().add(item.getHeightCm())
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
item.setUnitVolumeCbm(unitVolume);
item.setVolumeRawCbm(totalRaw);
item.setVolumeCbm(totalVolume);
item.setTotalVolumeCbm(totalVolume);
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
item.setCalculationRuleVersion(weighted.calculationRuleVersion());
item.setOuterLengthCm(weighted.outerLengthCm());
item.setOuterWidthCm(weighted.outerWidthCm());
item.setOuterHeightCm(weighted.outerHeightCm());
}
private void applyCalculatedValuesFromSnapshot(InboundMeasurementItemEntity item) { private void applyCalculatedValuesFromSnapshot(InboundMeasurementItemEntity item) {
if (!positive(item.getOuterLengthCm()) || !positive(item.getOuterWidthCm()) if (!positive(item.getOuterLengthCm()) || !positive(item.getOuterWidthCm())
|| !positive(item.getOuterHeightCm()) || !positive(item.getUnitVolumeCbm()) || !positive(item.getOuterHeightCm()) || !positive(item.getUnitVolumeCbm())
|| !StringUtils.hasText(item.getCalculationRuleVersion())) { || !StringUtils.hasText(item.getCalculationRuleVersion())) {
applyCalculatedValues(item, dimensionWeighting.load(item.getTenantId())); measurementBatchService.calculateCartonDimensions(item);
return; return;
} }
BigDecimal count = BigDecimal.valueOf(item.getPackageCount()); BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
@@ -25,6 +25,7 @@ public class CustomerDimensionWeightingService {
this.repository = repository; this.repository = repository;
} }
/** 加载完整四档箱规及客户明确选择的默认算法,配置存在不代表算法已启用。 */
public RuleSet load(long tenantId) { public RuleSet load(long tenantId) {
List<CustomerDimensionConfigEntity> values = repository.findAll(tenantId); List<CustomerDimensionConfigEntity> values = repository.findAll(tenantId);
if (values == null || values.size() != CustomerDimensionBoxType.values().length if (values == null || values.size() != CustomerDimensionBoxType.values().length
@@ -33,7 +34,14 @@ public class CustomerDimensionWeightingService {
.collect(java.util.stream.Collectors.toSet()).size() != CustomerDimensionBoxType.values().length) { .collect(java.util.stream.Collectors.toSet()).size() != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.CONFLICT, "客户箱规尺寸配置不完整,不能计算计费体积"); throw new BizException(ResultCode.CONFLICT, "客户箱规尺寸配置不完整,不能计算计费体积");
} }
return new RuleSet(values.stream().map(Rule::from).toList()); List<String> modes = values.stream().map(value -> value.getDefaultCalculationMode() == null
|| value.getDefaultCalculationMode().isBlank() ? "ACTUAL"
: value.getDefaultCalculationMode().trim().toUpperCase(java.util.Locale.ROOT))
.distinct().toList();
if (modes.size() != 1 || !List.of("ACTUAL", "ADD", "MULTIPLY").contains(modes.get(0))) {
throw new BizException(ResultCode.CONFLICT, "客户默认箱规计算方式配置不一致或无效");
}
return new RuleSet(values.stream().map(Rule::from).toList(), modes.get(0));
} }
private static boolean invalidRule(CustomerDimensionConfigEntity value) { private static boolean invalidRule(CustomerDimensionConfigEntity value) {
@@ -48,14 +56,22 @@ public class CustomerDimensionWeightingService {
public static final class RuleSet { public static final class RuleSet {
private final List<Rule> rules; private final List<Rule> rules;
private final String defaultCalculationMode;
private RuleSet(List<Rule> rules) { /** 保存同一客户的箱规和默认算法,供设备及人工测量统一使用。 */
private RuleSet(List<Rule> rules, String defaultCalculationMode) {
this.defaultCalculationMode = defaultCalculationMode;
this.rules = rules.stream() this.rules = rules.stream()
.sorted(Comparator.comparing(Rule::innerVolume) .sorted(Comparator.comparing(Rule::innerVolume)
.thenComparing(rule -> rule.boxType().ordinal())) .thenComparing(rule -> rule.boxType().ordinal()))
.toList(); .toList();
} }
/** 返回客户已启用的默认算法;ACTUAL 表示尺寸不调整。 */
public String defaultCalculationMode() {
return defaultCalculationMode;
}
public WeightedDimensions resolve(BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) { public WeightedDimensions resolve(BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) {
List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm); List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm);
return rules.stream() return rules.stream()
@@ -66,24 +82,6 @@ public class CustomerDimensionWeightingService {
.weightedActualDimensions(lengthCm, widthCm, heightCm)); .weightedActualDimensions(lengthCm, widthCm, heightCm));
} }
public WeightedDimensions resolveDeviceMeasurement(BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
List<BigDecimal> measured = sortedDimensions(lengthCm, widthCm, heightCm);
return rules.stream()
.filter(rule -> rule.contains(measured))
.findFirst()
.map(rule -> rule.weightedActualDimensions(lengthCm, widthCm, heightCm))
.orElseGet(() -> actualDimensions(lengthCm, widthCm, heightCm));
}
private WeightedDimensions actualDimensions(BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
BigDecimal rawVolume = lengthCm.multiply(widthCm).multiply(heightCm)
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
return new WeightedDimensions(null, null, null, lengthCm, widthCm, heightCm,
rawVolume, rawVolume.setScale(3, RoundingMode.HALF_UP),
"ACTUAL_DIMENSION_V1");
}
} }
public record WeightedDimensions( public record WeightedDimensions(
@@ -5,6 +5,8 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
@@ -63,6 +65,49 @@ public class FbaPricingService {
@Resource @Resource
private ContainerTierAmountAllocator containerTierAllocator; private ContainerTierAmountAllocator containerTierAllocator;
/** 按当前选择值只读预览,复用正式计价和柜级阶梯规则,不写费用、幂等或财务记录。 */
public FeePreviewResponse preview(long tenantId, long inboundId, FeePreviewRequest request) {
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
requireMeasurementSources(tenantId, inboundId, request.weightSource(), request.volumeSource());
BigDecimal weight = selectedWeight(inbound, request.selectedWeightKg());
BigDecimal volume = selectedVolume(inbound, request.selectedVolumeCbm());
PricingPolicy policy = pricingPolicy(inbound,
request.pricingAt() == null ? LocalDateTime.now(clock) : request.pricingAt());
if ("PRIVATE".equals(inbound.getDestinationType())) {
return manualPreview(weight, volume, policy, request.selectedUnitPrice());
}
try {
ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume);
BigDecimal additional = estimate.rate().getAdditionalFee() == null ? BigDecimal.ZERO
: estimate.rate().getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal subtotal = request.selectedUnitPrice() == null
|| request.selectedUnitPrice().compareTo(estimate.unitPrice()) == 0
? estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP)
: estimate.chargeableQuantity().multiply(request.selectedUnitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
return new FeePreviewResponse(weight, volume, estimate.chargeableQuantity(), estimate.chargeUnit(),
estimate.conversionFactor(), estimate.roundingScale(), estimate.roundingMode(),
estimate.unitPrice(), additional,
subtotal.add(additional),
estimate.currency(), true);
} catch (BizException exception) {
// 仅价格未命中且可人工定价的 FBA 返回计费量;其他业务错误继续明确拒绝。
if (exception.getResultCode() != ResultCode.NOT_FOUND || !"FBA".equals(inbound.getDestinationType())) {
throw exception;
}
return manualPreview(weight, volume, policy, request.selectedUnitPrice());
}
}
private FeePreviewResponse manualPreview(BigDecimal weight, BigDecimal volume, PricingPolicy policy,
BigDecimal selectedUnitPrice) {
BigDecimal quantity = manualChargeableQuantity(policy.chargeMode(), weight, volume);
BigDecimal amount = selectedUnitPrice == null ? null
: quantity.multiply(selectedUnitPrice).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal factor = new BigDecimal("KG".equals(policy.chargeMode()) ? "167" : "363");
return new FeePreviewResponse(weight, volume, quantity, policy.chargeMode(), factor, 6, "HALF_UP",
null, BigDecimal.ZERO.setScale(MONEY_SCALE), amount, "CNY", false);
}
@Transactional @Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, String idempotencyKey) { LocalDateTime requestedAt, String idempotencyKey) {
@@ -77,6 +122,7 @@ public class FbaPricingService {
selectedVolumeCbm, null, null, idempotencyKey); selectedVolumeCbm, null, null, idempotencyKey);
} }
/** 复用预览的计价规则生成费用版本,仅正式确认流程写入计费事实。 */
@Transactional @Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, BigDecimal selectedWeightKg, LocalDateTime requestedAt, BigDecimal selectedWeightKg,
@@ -95,6 +141,9 @@ public class FbaPricingService {
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null); if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId); InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
if ("PRIVATE".equals(inbound.getDestinationType())) {
throw conflict("私人地址请使用人工头程报价");
}
InboundFeeEntity published = null; InboundFeeEntity published = null;
if ("SENT".equals(inbound.getFeeStatus())) { if ("SENT".equals(inbound.getFeeStatus())) {
published = repository.findLatestFeeForUpdate(tenantId, inboundId); published = repository.findLatestFeeForUpdate(tenantId, inboundId);
@@ -105,38 +154,11 @@ public class FbaPricingService {
throw conflict("客户已确认,不允许再修改或重新发送费用"); throw conflict("客户已确认,不允许再修改或重新发送费用");
} }
} }
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId); var measurement = requireMeasurementSources(tenantId, inboundId, weightSource, volumeSource);
boolean actualMeasurementSource = "ACTUAL".equalsIgnoreCase(weightSource)
|| "ACTUAL".equalsIgnoreCase(volumeSource);
boolean outerMeasurementSource = "OUTER".equalsIgnoreCase(weightSource)
|| "OUTER".equalsIgnoreCase(volumeSource);
if ((actualMeasurementSource || outerMeasurementSource) && measurement == null) {
throw conflict(actualMeasurementSource
? "实际测量数据不存在,不能生成对客费用"
: "外箱规测量批次不存在,不能生成对客费用");
}
if (actualMeasurementSource || outerMeasurementSource) {
var measurementItems = measurementRepository.listItems(tenantId, measurement.getId());
if (actualMeasurementSource && measurementItems.isEmpty()) {
throw conflict("实际测量明细不存在,不能生成对客费用");
}
boolean incomplete = outerMeasurementSource && (measurementItems.isEmpty() || measurementItems.stream().anyMatch(item ->
item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0
|| item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0
|| item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0
|| !StringUtils.hasText(item.getCalculationRuleVersion())));
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt); PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest( BigDecimal weight = selectedWeight(inbound, selectedWeightKg);
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), BigDecimal volume = selectedVolume(inbound, selectedVolumeCbm);
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume);
pricingTaxMode(inbound), policy.chargeMode(),
selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm));
BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = (selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm).setScale(4, RoundingMode.HALF_UP);
estimate = applyContainerCustomerTier(inbound, estimate, weight, volume);
RateRow rate = estimate.rate(); RateRow rate = estimate.rate();
BigDecimal chargeable = estimate.chargeableQuantity(); BigDecimal chargeable = estimate.chargeableQuantity();
BigDecimal converted = chargeable; BigDecimal converted = chargeable;
@@ -179,6 +201,7 @@ public class FbaPricingService {
return response(inbound, fee, rate.getUnitPrice(), nextStatus); return response(inbound, fee, rate.getUnitPrice(), nextStatus);
} }
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
@Transactional @Transactional
public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId,
ManualQuoteRequest request, String idempotencyKey) { ManualQuoteRequest request, String idempotencyKey) {
@@ -194,7 +217,9 @@ public class FbaPricingService {
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null); if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId); InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
if (!"FBA".equals(inbound.getDestinationType())) throw conflict("仅FBA头程运费支持未命中人工定价"); if (!"FBA".equals(inbound.getDestinationType()) && !"PRIVATE".equals(inbound.getDestinationType())) {
throw conflict("仅FBA未命中报价或私人地址支持人工头程定价");
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId); var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
if (measurement == null || measurementRepository.listItems(tenantId, measurement.getId()).isEmpty()) { if (measurement == null || measurementRepository.listItems(tenantId, measurement.getId()).isEmpty()) {
throw conflict("实际测量明细不存在,不能生成人工头程费用"); throw conflict("实际测量明细不存在,不能生成人工头程费用");
@@ -204,24 +229,26 @@ public class FbaPricingService {
if (current != null && !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) { if (current != null && !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
throw conflict("当前费用状态不允许重新定价"); throw conflict("当前费用状态不允许重新定价");
} }
LocalDateTime now = LocalDateTime.now(clock); // 与DATETIME(3)保存精度一致,首次响应和原键重放保留同一时间事实。
LocalDateTime now = LocalDateTime.now(clock).truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
PricingPolicy policy = pricingPolicy(inbound, now); PricingPolicy policy = pricingPolicy(inbound, now);
try { // 私人地址使用人工报价。FBA仍须先证明价格表未命中,避免绕开有效报价。
priceBookEstimator.estimate(tenantId, new EstimateRequest( if ("FBA".equals(inbound.getDestinationType())) {
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), try {
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, priceBookEstimator.estimate(tenantId, new EstimateRequest(
pricingTaxMode(inbound), policy.chargeMode(), request.selectedWeightKg(), request.selectedVolumeCbm())); inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
throw conflict("当前条件已有匹配价格,请使用正常试算并调整单价"); destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
} catch (BizException exception) { pricingTaxMode(inbound), policy.chargeMode(), request.selectedWeightKg(), request.selectedVolumeCbm()));
if (exception.getResultCode() != ResultCode.NOT_FOUND) throw exception; throw conflict("当前条件已有匹配价格,请使用正常试算并调整单价");
} catch (BizException exception) {
if (exception.getResultCode() != ResultCode.NOT_FOUND) throw exception;
}
} }
BigDecimal weight = request.selectedWeightKg().setScale(3, RoundingMode.HALF_UP); BigDecimal weight = request.selectedWeightKg().setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP); BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP);
String unit = policy.chargeMode(); String unit = policy.chargeMode();
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363"); BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
BigDecimal quantity = ("KG".equals(unit) ? weight.max(volume.multiply(factor)) BigDecimal quantity = manualChargeableQuantity(unit, weight, volume);
: volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP)))
.setScale(6, RoundingMode.HALF_UP);
BigDecimal subtotal = quantity.multiply(request.unitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP); BigDecimal subtotal = quantity.multiply(request.unitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
int version = repository.nextFeeVersion(tenantId, inboundId); int version = repository.nextFeeVersion(tenantId, inboundId);
InboundFeeEntity fee = new InboundFeeEntity(); InboundFeeEntity fee = new InboundFeeEntity();
@@ -254,7 +281,8 @@ public class FbaPricingService {
throw conflict("费用已被其他操作员修改,请刷新后重试"); throw conflict("费用已被其他操作员修改,请刷新后重试");
} }
repository.insertFee(fee); repository.insertFee(fee);
InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", "FBA基础运费", subtotal, now); String feeName = "PRIVATE".equals(inbound.getDestinationType()) ? "头程基础运费" : "FBA基础运费";
InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", feeName, subtotal, now);
item.setQuantity(quantity); item.setUnit(unit); item.setUnitPrice(request.unitPrice()); item.setQuantity(quantity); item.setUnit(unit); item.setUnitPrice(request.unitPrice());
item.setAutomaticFlag(false); item.setRemark(reason); item.setAutomaticFlag(false); item.setRemark(reason);
repository.insertItem(item); repository.insertItem(item);
@@ -267,6 +295,61 @@ public class FbaPricingService {
return response(inbound, fee, request.unitPrice(), nextStatus); return response(inbound, fee, request.unitPrice(), nextStatus);
} }
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
private ActivePriceBookEstimator.Estimate estimateFee(long tenantId, InboundPricingEntity inbound,
PricingPolicy policy, BigDecimal weight, BigDecimal volume) {
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), policy.chargeMode(), weight, volume));
return applyContainerCustomerTier(inbound, estimate, weight, volume);
}
/** 计费重量统一保留三位精度,预览与最终费用使用同一基数。 */
private BigDecimal selectedWeight(InboundPricingEntity inbound, BigDecimal selected) {
BigDecimal weight = selected == null ? inbound.getActualNetWeightKg() : selected;
if (weight == null || weight.signum() <= 0) throw conflict("计费重量必须大于0");
return weight.setScale(3, RoundingMode.HALF_UP);
}
/** 计费体积统一保留四位精度,预览与最终费用使用同一基数。 */
private BigDecimal selectedVolume(InboundPricingEntity inbound, BigDecimal selected) {
BigDecimal volume = selected == null ? inbound.getActualVolumeCbm() : selected;
if (volume == null || volume.signum() <= 0) throw conflict("计费体积必须大于0");
return volume.setScale(4, RoundingMode.HALF_UP);
}
/** 人工定价的重量与体积换算规则由服务端统一提供给预览和正式报价。 */
private BigDecimal manualChargeableQuantity(String unit, BigDecimal weight, BigDecimal volume) {
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
return ("KG".equals(unit) ? weight.max(volume.multiply(factor))
: volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP)))
.setScale(6, RoundingMode.HALF_UP);
}
/** 仅验证测量来源及保存快照完整性,不写入或重新计算测量数据。 */
private com.baiyue.firstleg.entity.InboundMeasurementEntity requireMeasurementSources(
long tenantId, long inboundId, String weightSource, String volumeSource) {
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
boolean actual = "ACTUAL".equalsIgnoreCase(weightSource) || "ACTUAL".equalsIgnoreCase(volumeSource);
boolean outer = "OUTER".equalsIgnoreCase(weightSource) || "OUTER".equalsIgnoreCase(volumeSource);
if ((actual || outer) && measurement == null) {
throw conflict(actual ? "实际测量数据不存在,不能生成对客费用" : "外箱规测量批次不存在,不能生成对客费用");
}
if (actual || outer) {
var items = measurementRepository.listItems(tenantId, measurement.getId());
if (actual && items.isEmpty()) throw conflict("实际测量明细不存在,不能生成对客费用");
if (outer && (items.isEmpty() || items.stream().anyMatch(item ->
item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0
|| item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0
|| item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0
|| !StringUtils.hasText(item.getCalculationRuleVersion())))) {
throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
}
return measurement;
}
private ActivePriceBookEstimator.Estimate applyContainerCustomerTier( private ActivePriceBookEstimator.Estimate applyContainerCustomerTier(
InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual, InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual,
BigDecimal currentWeight, BigDecimal currentVolume) { BigDecimal currentWeight, BigDecimal currentVolume) {
@@ -719,8 +802,9 @@ public class FbaPricingService {
private InboundPricingEntity requireApprovedInbound(long tenantId, long inboundId) { private InboundPricingEntity requireApprovedInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId); InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())) { if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())
throw conflict("当前目的地类型不支持自动计费"); && !"PRIVATE".equals(inbound.getDestinationType())) {
throw conflict("当前目的地类型不支持头程计费");
} }
if (inbound.getWarehouseId() == null || !StringUtils.hasText(inbound.getChannelCode())) { if (inbound.getWarehouseId() == null || !StringUtils.hasText(inbound.getChannelCode())) {
throw conflict("入库单缺少起运仓或渠道匹配维度"); throw conflict("入库单缺少起运仓或渠道匹配维度");
@@ -45,6 +45,16 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
@Resource private InboundOrderCreationService inboundOrderCreationService; @Resource private InboundOrderCreationService inboundOrderCreationService;
@Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.security.internal-service-token}") private String internalToken;
/** 海外按数量收货后的只读溯源:仅读已有有效批次,不生成或修改箱号。 */
public List<String> existingCartonMarks(long customerId, long inboundId, String inboundCode) {
requireIds(customerId, inboundId);
var labels = repository.findActiveByInbound(customerId, inboundId);
if (labels.isEmpty()) return List.of();
return requireConsistent(labels).stream()
.filter(label -> inboundCode.equals(label.getInboundCode()))
.map(InboundCartonLabelEntity::getBoxCode).toList();
}
@Transactional @Transactional
public InboundCartonLabels.Batch getOrCreate(long tenantId, long inboundId) { public InboundCartonLabels.Batch getOrCreate(long tenantId, long inboundId) {
requireIds(tenantId, inboundId); requireIds(tenantId, inboundId);
@@ -335,11 +335,13 @@ public class LoadingCompletedOutboxService {
Set<String> values = rows.stream().map(Row::getDestinationType) Set<String> values = rows.stream().map(Row::getDestinationType)
.map(value -> value == null ? "" : value.trim().toUpperCase(java.util.Locale.ROOT)) .map(value -> value == null ? "" : value.trim().toUpperCase(java.util.Locale.ROOT))
.collect(java.util.stream.Collectors.toSet()); .collect(java.util.stream.Collectors.toSet());
if (values.size() != 1 || (!values.contains("FBA") && !values.contains("OVERSEAS"))) { if (values.size() != 1
|| (!values.contains("FBA") && !values.contains("OVERSEAS") && !values.contains("PRIVATE"))) {
throw new BizException(ResultCode.CONFLICT, throw new BizException(ResultCode.CONFLICT,
"装柜入仓单目的类型必须唯一且为 FBA 或 OVERSEAS,不能生成海外交接事件"); "装柜入仓单目的类型必须唯一且为 FBA、OVERSEAS 或 PRIVATE,不能生成海外交接事件");
} }
return values.iterator().next(); String value = values.iterator().next();
return "PRIVATE".equals(value) ? "OVERSEAS" : value;
} }
private String fulfillmentType(List<Row> rows) { private String fulfillmentType(List<Row> rows) {
@@ -56,6 +56,7 @@ public class MeasurementBatchService {
@Resource @Resource
private FbaPricingRepository pricingRepository; private FbaPricingRepository pricingRepository;
/** 按已选算法保存测量批次及计算快照,不推进履约状态。 */
@Transactional @Transactional
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) { public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
var inbound = repository.findInbound(command.tenantId(), command.inboundId()); var inbound = repository.findInbound(command.tenantId(), command.inboundId());
@@ -68,7 +69,7 @@ public class MeasurementBatchService {
} }
validateSource(command); validateSource(command);
List<InboundMeasurementItemEntity> calculatedItems = calculateItems( List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
command, dimensionWeighting.load(command.tenantId())); command.tenantId(), command.items(), dimensionWeighting.load(command.tenantId()));
InboundMeasurementEntity measurement; InboundMeasurementEntity measurement;
if (command.measurementId() == null) { if (command.measurementId() == null) {
@@ -93,6 +94,7 @@ public class MeasurementBatchService {
return measurement; return measurement;
} }
/** 修改批次算法时,以实测及人工修正基数重算,避免重复叠加。 */
void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement, void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> existingItems, List<InboundMeasurementItemEntity> existingItems,
String coefficientMode, Long chargeRuleId) { String coefficientMode, Long chargeRuleId) {
@@ -112,7 +114,7 @@ public class MeasurementBatchService {
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(), item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList()); item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList());
List<InboundMeasurementItemEntity> recalculated = calculateItems( List<InboundMeasurementItemEntity> recalculated = calculateItems(
command, dimensionWeighting.load(tenantId)); tenantId, command.items(), dimensionWeighting.load(tenantId));
for (int index = 0; index < existingItems.size(); index++) { for (int index = 0; index < existingItems.size(); index++) {
InboundMeasurementItemEntity target = existingItems.get(index); InboundMeasurementItemEntity target = existingItems.get(index);
InboundMeasurementItemEntity source = recalculated.get(index); InboundMeasurementItemEntity source = recalculated.get(index);
@@ -121,6 +123,7 @@ public class MeasurementBatchService {
applyTotals(measurement, existingItems); applyTotals(measurement, existingItems);
} }
/** 按保存的算法重算单行外箱修正,并更新批次汇总。 */
void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement, void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> existingItems, long itemId, List<InboundMeasurementItemEntity> existingItems, long itemId,
BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerLengthCm, BigDecimal outerWidthCm,
@@ -143,14 +146,42 @@ public class MeasurementBatchService {
outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(), outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(),
target.getRemark()))); target.getRemark())));
InboundMeasurementItemEntity recalculated = calculateItems( InboundMeasurementItemEntity recalculated = calculateItems(
command, dimensionWeighting.load(tenantId)).get(0); tenantId, command.items(), dimensionWeighting.load(tenantId)).get(0);
applyRecalculatedItem(target, recalculated, true); applyRecalculatedItem(target, recalculated, true);
applyTotals(measurement, existingItems); applyTotals(measurement, existingItems);
} }
/** 设备首次测量与复测共用批次算法,保留已有人工修正及明确选择的算法。 */
void calculateCartonDimensions(InboundMeasurementItemEntity item) {
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getSourceImageFileId(),
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
item.getManualOuterHeightCm(), item.getChargeRuleId(), item.getCoefficientMode(), item.getRemark());
InboundMeasurementItemEntity calculated = calculateItems(item.getTenantId(), List.of(source),
dimensionWeighting.load(item.getTenantId())).get(0);
copyCalculatedValues(item, calculated);
}
/** 保存重算后的行,保留箱码、设备证据和批次身份。 */
private void applyRecalculatedItem(InboundMeasurementItemEntity target, private void applyRecalculatedItem(InboundMeasurementItemEntity target,
InboundMeasurementItemEntity source, InboundMeasurementItemEntity source,
boolean updateManualDimensions) { boolean updateManualDimensions) {
copyCalculatedValues(target, source);
if (updateManualDimensions) {
target.setManualOuterLengthCm(source.getManualOuterLengthCm());
target.setManualOuterWidthCm(source.getManualOuterWidthCm());
target.setManualOuterHeightCm(source.getManualOuterHeightCm());
}
target.setUpdatedAt(LocalDateTime.now());
if (!repository.updateItem(target)) {
throw concurrentModification();
}
}
/** 统一复制尺寸、体积、材积重及算法快照,不覆盖原始测量值。 */
private void copyCalculatedValues(InboundMeasurementItemEntity target,
InboundMeasurementItemEntity source) {
target.setGirthCm(source.getGirthCm()); target.setGirthCm(source.getGirthCm());
target.setUnitVolumeCbm(source.getUnitVolumeCbm()); target.setUnitVolumeCbm(source.getUnitVolumeCbm());
target.setVolumeRawCbm(source.getVolumeRawCbm()); target.setVolumeRawCbm(source.getVolumeRawCbm());
@@ -163,15 +194,6 @@ public class MeasurementBatchService {
target.setOuterLengthCm(source.getOuterLengthCm()); target.setOuterLengthCm(source.getOuterLengthCm());
target.setOuterWidthCm(source.getOuterWidthCm()); target.setOuterWidthCm(source.getOuterWidthCm());
target.setOuterHeightCm(source.getOuterHeightCm()); target.setOuterHeightCm(source.getOuterHeightCm());
if (updateManualDimensions) {
target.setManualOuterLengthCm(source.getManualOuterLengthCm());
target.setManualOuterWidthCm(source.getManualOuterWidthCm());
target.setManualOuterHeightCm(source.getManualOuterHeightCm());
}
target.setUpdatedAt(LocalDateTime.now());
if (!repository.updateItem(target)) {
throw concurrentModification();
}
} }
/** /**
@@ -257,10 +279,12 @@ public class MeasurementBatchService {
measurement.setActualGoodsName(command.actualGoodsName()); measurement.setActualGoodsName(command.actualGoodsName());
} }
/** 明确的行算法优先;未选择时仅使用客户默认算法,不因预置系数而自动启用。 */
private List<InboundMeasurementItemEntity> calculateItems( private List<InboundMeasurementItemEntity> calculateItems(
MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules) { long tenantId, List<MeasurementBatchCommand.Item> sourceItems,
CustomerDimensionWeightingService.RuleSet dimensionRules) {
Set<String> itemNumbers = new HashSet<>(); Set<String> itemNumbers = new HashSet<>();
return command.items().stream().map(source -> { return sourceItems.stream().map(source -> {
if (!itemNumbers.add(source.itemNo())) { if (!itemNumbers.add(source.itemNo())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复"); throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复");
} }
@@ -279,10 +303,9 @@ public class MeasurementBatchService {
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve( CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve(
baseLength, baseWidth, baseHeight); baseLength, baseWidth, baseHeight);
String requestedMode = normalizeMode(source.coefficientMode()); String requestedMode = normalizeMode(source.coefficientMode());
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(requestedMode) String coefficientMode = requestedMode == null ? dimensionRules.defaultCalculationMode() : requestedMode;
? requireCoefficientRule(command.tenantId(), source.chargeRuleId()) CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(coefficientMode)
: requestedMode == null ? findDefaultCoefficientRule(command.tenantId()) : null; ? requireCoefficientRule(tenantId, source.chargeRuleId()) : null;
String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode;
boolean useDimensionRule = "ADD".equals(coefficientMode); boolean useDimensionRule = "ADD".equals(coefficientMode);
BigDecimal addition = weighted.edgeAdditionCm() == null BigDecimal addition = weighted.edgeAdditionCm() == null
? null : weighted.edgeAdditionCm().multiply(BigDecimal.valueOf(2)); ? null : weighted.edgeAdditionCm().multiply(BigDecimal.valueOf(2));
@@ -374,10 +397,6 @@ public class MeasurementBatchService {
return normalized; return normalized;
} }
private CustomerChargeRuleEntity findDefaultCoefficientRule(long tenantId) {
return pricingRepository.findDefaultRule(tenantId, tenantId);
}
private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long ruleId) { private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long ruleId) {
CustomerChargeRuleEntity rule = ruleId == null CustomerChargeRuleEntity rule = ruleId == null
? pricingRepository.findDefaultRule(tenantId, tenantId) ? pricingRepository.findDefaultRule(tenantId, tenantId)
@@ -0,0 +1,16 @@
-- Existing databases: allow multiple active photos per evidence type. No photo rows are changed.
USE baiyue_firstleg;
SET @evidence_ddl = IF(EXISTS(
SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_loading_cabinet_evidence' AND index_name='uk_cabinet_evidence_active'
), 'ALTER TABLE fl_loading_cabinet_evidence DROP INDEX uk_cabinet_evidence_active', 'SELECT 1');
PREPARE evidence_ddl FROM @evidence_ddl;
EXECUTE evidence_ddl;
DEALLOCATE PREPARE evidence_ddl;
SET @evidence_ddl = IF(EXISTS(
SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_loading_cabinet_evidence' AND index_name='idx_cabinet_evidence_active'
), 'SELECT 1', 'ALTER TABLE fl_loading_cabinet_evidence ADD INDEX idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)');
PREPARE evidence_ddl FROM @evidence_ddl;
EXECUTE evidence_ddl;
DEALLOCATE PREPARE evidence_ddl;
@@ -129,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
location_name VARCHAR(128) NULL COMMENT '节点地点名称', location_name VARCHAR(128) NULL COMMENT '节点地点名称',
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
operator_id BIGINT UNSIGNED NULL COMMENT '节点操作人账号ID;自动采集时为空',
operator_name VARCHAR(128) NULL COMMENT '节点操作人用户名快照;自动采集时为空',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key), UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
@@ -16,4 +16,4 @@ CREATE TABLE IF NOT EXISTS fl_direct_delivery_unloading (
PRIMARY KEY (container_booking_id,inbound_id), PRIMARY KEY (container_booking_id,inbound_id),
UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key), UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key),
CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id) CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='直送目的仓真实卸货凭据';
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence (
seal_no VARCHAR(64) NULL COMMENT '带锁码照对应锁码', seal_no VARCHAR(64) NULL COMMENT '带锁码照对应锁码',
remark VARCHAR(1000) NULL COMMENT '证据备注', remark VARCHAR(1000) NULL COMMENT '证据备注',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
active_flag TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '有效唯一标识;作废后置NULL', active_flag TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '有效标识;同类型允许多张,作废后置NULL',
operation_key VARCHAR(128) NOT NULL COMMENT '任务范围内幂等操作键', operation_key VARCHAR(128) NOT NULL COMMENT '任务范围内幂等操作键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
void_reason VARCHAR(500) NULL COMMENT '作废原因', void_reason VARCHAR(500) NULL COMMENT '作废原因',
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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 '最后更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag), KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key), UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
KEY idx_cabinet_evidence_container (container_booking_id,status), KEY idx_cabinet_evidence_container (container_booking_id,status),
KEY idx_cabinet_evidence_seal (seal_no,active_flag), KEY idx_cabinet_evidence_seal (seal_no,active_flag),
@@ -23,7 +23,7 @@ CREATE TABLE fl_sailing_channel (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_channel_code (channel_code), UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
KEY idx_fl_sailing_channel_status (status) KEY idx_fl_sailing_channel_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing_template ( CREATE TABLE fl_sailing_template (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -59,7 +59,7 @@ CREATE TABLE fl_sailing_template (
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday), KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7), CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30) CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing ( CREATE TABLE fl_sailing (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -96,7 +96,7 @@ CREATE TABLE fl_sailing (
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status), KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
KEY idx_fl_sailing_route (departure_port_code,destination_port_code), KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED')) CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE fl_sailing_history ( CREATE TABLE fl_sailing_history (
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -110,7 +110,7 @@ CREATE TABLE fl_sailing_history (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id) KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
USE baiyue_iam; USE baiyue_iam;
@@ -234,6 +234,7 @@ components:
locationCode: {type: string, maxLength: 32, nullable: true} locationCode: {type: string, maxLength: 32, nullable: true}
locationName: {type: string, maxLength: 128, nullable: true} locationName: {type: string, maxLength: 128, nullable: true}
sourceReference: {type: string, maxLength: 500, nullable: true} sourceReference: {type: string, maxLength: 500, nullable: true}
operatorName: {type: string, maxLength: 128, nullable: true, description: '节点操作人用户名快照;人工补录和确认出仓时记录,自动采集为空'}
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
createdAt: {type: string, format: date-time} createdAt: {type: string, format: date-time}
ExceptionFact: ExceptionFact:
@@ -0,0 +1,18 @@
openapi: 3.0.3
info: { title: 原入仓有效箱唛内部查询, version: 1.0.0 }
security: [{ internalToken: [] }]
paths:
/internal/firstleg/v1/inbounds/{inboundId}/existing-carton-marks:
get:
summary: 只读查询指定客户及原入仓已有有效箱唛批次,不补建箱号
parameters:
- { in: path, name: inboundId, required: true, schema: { type: integer, format: int64, minimum: 1 } }
- { in: query, name: customerId, required: true, schema: { type: integer, format: int64, minimum: 1 } }
- { in: query, name: inboundCode, required: true, schema: { type: string } }
responses:
'200':
description: 统一响应;data为有效箱唛字符串数组,归属不匹配或无已有批次时为空;批次不完整返回业务错误
'403': { description: 内部服务凭证缺失或无效 }
components:
securitySchemes:
internalToken: { type: apiKey, in: header, name: X-Internal-Service-Token }
@@ -147,6 +147,13 @@ paths:
description: 只返回基础参考费用,不包含复杂附加费和最终结算调整;pricingTaxMode 不复用订单 taxMode。 description: 只返回基础参考费用,不包含复杂附加费和最终结算调整;pricingTaxMode 不复用订单 taxMode。
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceEstimateRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceEstimateRequest'}}}}
responses: {'200': {description: 成功或业务失败}} responses: {'200': {description: 成功或业务失败}}
/api/platform/v1/inbounds/{inboundId}/fees/preview:
post:
summary: 只读预览当前计费重量和体积
description: 不生成费用版本,不修改测量、费用、履约状态或财务记录;与正式计价共用价格匹配及柜级阶梯规则。FBA价格未命中时返回 priceMatched=false、计费量和空单价,供人工单价预览使用;其他错误明确返回。需要 firstleg:inbound:confirm-send-fee 或 firstleg:fba-pricing:calculate 权限。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FeePreviewRequest'}}}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeePreviewApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/calculate: /api/platform/v1/inbounds/{inboundId}/fees/calculate:
post: post:
summary: 按审核通过测量快照试算 FBA 费用 summary: 按审核通过测量快照试算 FBA 费用
@@ -156,8 +163,8 @@ paths:
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/manual-quote: /api/platform/v1/inbounds/{inboundId}/fees/manual-quote:
post: post:
summary: FBA价格未命中时人工确定头程单价 summary: 人工确定FBA未命中报价或私人地址头程单价
description: 仅FBA且价格查询返回未命中时可用;按客户计费方式与选定测量重量、体积计算计费量,附加费为零。生成正式头程费用快照,后续复用确认与发送;需要 firstleg:inbound:confirm-send-fee 权限。 description: FBA须价格查询返回未命中;私人地址PRIVATE按人工单价报价,预览返回计费量且priceMatched=false。按客户计费方式与选定测量重量、体积计算计费量,附加费为零。生成CALCULATED正式头程费用快照并保留WAIT_SPLIT,后续复用版本、确认、发送与幂等流程,预装确认继续校验初始费用已完成;需要 firstleg:inbound:confirm-send-fee 权限。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ManualQuoteRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ManualQuoteRequest'}}}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
@@ -296,6 +303,32 @@ components:
updatedAt: {type: string, format: date-time, nullable: true} updatedAt: {type: string, format: date-time, nullable: true}
deleted: {type: boolean, nullable: true} deleted: {type: boolean, nullable: true}
version: {type: integer, nullable: true} version: {type: integer, nullable: true}
FeePreviewApiResponse:
type: object
required: [code, message, data]
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/FeePreviewResponse'}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time, nullable: true}
FeePreviewResponse:
type: object
required: [actualWeightKg, actualVolumeCbm, chargeableQuantity, chargeUnit, conversionFactor, roundingScale, roundingMode, unitPrice, additionalAmount, totalAmount, currency, priceMatched]
properties:
actualWeightKg: {type: number}
actualVolumeCbm: {type: number}
chargeableQuantity: {type: number}
chargeUnit: {type: string, enum: [KG, CBM]}
conversionFactor: {type: number}
roundingScale: {type: integer}
roundingMode: {type: string}
unitPrice: {type: number, nullable: true}
additionalAmount: {type: number}
totalAmount: {type: number, nullable: true, description: 按本次人工单价或参考单价计算并取整后的费用合计;两种单价都缺失时为空}
currency: {type: string}
priceMatched: {type: boolean}
FeeSnapshotApiResponse: FeeSnapshotApiResponse:
type: object type: object
required: [code, message, data] required: [code, message, data]
@@ -503,6 +536,15 @@ components:
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0} selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0}
unitPrice: {type: number, exclusiveMinimum: true, minimum: 0} unitPrice: {type: number, exclusiveMinimum: true, minimum: 0}
reason: {type: string, minLength: 2, maxLength: 500} reason: {type: string, minLength: 2, maxLength: 500}
FeePreviewRequest:
type: object
properties:
pricingAt: {type: string, format: date-time, nullable: true}
selectedWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
weightSource: {type: string, maxLength: 32, nullable: true}
volumeSource: {type: string, maxLength: 32, nullable: true}
selectedUnitPrice: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 本次人工单价;为空时使用当前参考单价,仅用于只读金额预览}
CalculateRequest: CalculateRequest:
type: object type: object
properties: properties:
@@ -2245,7 +2245,7 @@ components:
outerWidthCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱宽度修正;保存后参与联动计算} outerWidthCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱宽度修正;保存后参与联动计算}
outerHeightCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱高度修正;保存后参与联动计算} outerHeightCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱高度修正;保存后参与联动计算}
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true} coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true, description: 明确选择的行算法优先;未指定时仅使用客户默认算法,ACTUAL 不调整修正后的实测尺寸,系数配置存在不代表启用 MULTIPLY}
remark: {type: string, maxLength: 500, nullable: true} remark: {type: string, maxLength: 500, nullable: true}
InboundMeasurementDetailEnvelope: InboundMeasurementDetailEnvelope:
type: object type: object
@@ -140,6 +140,7 @@ paths:
responses: {'200': {description: 仅platform:firstleg:pda:work且任务必须由当前用户领取}} responses: {'200': {description: 仅platform:firstleg:pda:work且任务必须由当前用户领取}}
post: post:
summary: 通过Firstleg代理上传并登记当前柜次装柜证据 summary: 通过Firstleg代理上传并登记当前柜次装柜证据
description: 同类型照片追加保留,不替换已有有效照片。expectedVersion取当前同类型最新有效照片版本,无照片时为0;重复幂等键不追加。
parameters: parameters:
- {$ref: '#/components/parameters/TaskId'} - {$ref: '#/components/parameters/TaskId'}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} - {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
@@ -19,6 +19,7 @@ class FbaPricingContractTest {
@Test @Test
@DisplayName("确认和发送费用兼容新的组合权限与原有权限") @DisplayName("确认和发送费用兼容新的组合权限与原有权限")
void allowsCombinedConfirmSendPermissionAndLegacyPermissions() { void allowsCombinedConfirmSendPermissionAndLegacyPermissions() {
assertPricingAuthority("preview", "hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')");
assertPricingAuthority("confirm", "hasAnyAuthority('firstleg:inbound:confirm-send-fee'," assertPricingAuthority("confirm", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:fba-pricing:confirm')"); + " 'firstleg:fba-pricing:confirm')");
assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee'," assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
@@ -58,6 +59,8 @@ class FbaPricingContractTest {
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi) assertThat(openapi)
.contains("/api/platform/v1/fba-pricing/customer-rules/versions:") .contains("/api/platform/v1/fba-pricing/customer-rules/versions:")
.contains("/api/platform/v1/inbounds/{inboundId}/fees/preview:")
.contains("不生成费用版本")
.contains("/api/platform/v1/fba-pricing/customer-rules/{id}/default:") .contains("/api/platform/v1/fba-pricing/customer-rules/{id}/default:")
.contains("/api/platform/v1/fba-pricing/carrier-rates/versions:") .contains("/api/platform/v1/fba-pricing/carrier-rates/versions:")
.contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:")
@@ -28,6 +28,14 @@ class ContainerCustomsMapperContractTest {
assertThat(currentRead.getCache()).isNull(); assertThat(currentRead.getCache()).isNull();
} }
@Test void containerQueriesExplicitlyMapEveryRecordComponentInOrder() throws Exception {
assertRecordProjection("container", ContainerCustomsMapper.ContainerRow.class, long.class);
assertRecordProjection("containerById", ContainerCustomsMapper.ContainerRow.class, long.class);
assertRecordProjection("containerByBooking", ContainerCustomsMapper.ContainerRow.class, long.class);
assertRecordProjection("lockContainer", ContainerCustomsMapper.ContainerRow.class, long.class);
assertRecordProjection("lockContainerById", ContainerCustomsMapper.ContainerRow.class, long.class);
}
@Test void merchantOrderUsesCompletedTaxRefundPlanWithRealCompletionTimestamp() throws Exception { @Test void merchantOrderUsesCompletedTaxRefundPlanWithRealCompletionTimestamp() throws Exception {
String value=sql("completedPlanForOrder",long.class,long.class); String value=sql("completedPlanForOrder",long.class,long.class);
assertThat(value) assertThat(value)
@@ -304,6 +304,19 @@ class ContainerCustomsServiceTest {
verify(mapper,never()).insertBrokerSubmission(anyLong(),anyLong(),anyLong(),anyString(),anyInt(),anyString(),anyLong(),anyString()); verify(mapper,never()).insertBrokerSubmission(anyLong(),anyLong(),anyLong(),anyString(),anyInt(),anyString(),anyLong(),anyString());
} }
@Test void receiptUploadRejectsAfterCustomerConfirmationBeforeBrokerExecution(){
var source=document(1L);var declaring=container(ContainerCustomsService.DECLARING,3);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("confirmed"),anyString())).thenReturn(1);
when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(declaring);
when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.brokerSubmission(2L)).thenReturn(brokerSubmission());
when(mapper.lockCompletion(1L)).thenReturn(new ContainerCustomsMapper.CompletionRow(21L,1L,99L,
"receipt.pdf","b".repeat(64),"application/pdf",1L,5L,"admin",java.time.LocalDateTime.now(),1,"CONFIRMED",null));
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","confirmed",1,
new MockMultipartFile("file","receipt.pdf","application/pdf",new byte[]{1})))
.isInstanceOf(BizException.class).hasMessageContaining("客户已确认预录单");
verifyNoInteractions(files);
}
@Test void receiptUploadRejectsWhenInitialBrokerSubmissionIsMissing(){ @Test void receiptUploadRejectsWhenInitialBrokerSubmissionIsMissing(){
var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3); var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("before-send"),anyString())).thenReturn(1); when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("before-send"),anyString())).thenReturn(1);
@@ -58,7 +58,7 @@ class CabinetLoadingEvidenceTransactionTest {
}).collect(Collectors.joining(",")); }).collect(Collectors.joining(","));
jdbc.execute("CREATE TABLE fl_loading_cabinet_evidence(" + columns + ")"); jdbc.execute("CREATE TABLE fl_loading_cabinet_evidence(" + columns + ")");
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT uq_operation UNIQUE(source_task_id,operation_key)"); jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT uq_operation UNIQUE(source_task_id,operation_key)");
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT uq_seal UNIQUE(seal_no,active_flag)"); jdbc.execute("CREATE INDEX idx_active ON fl_loading_cabinet_evidence(container_booking_id,evidence_type,active_flag)");
String taskColumns = Arrays.stream(LoadingPickTaskEntity.class.getDeclaredFields()) String taskColumns = Arrays.stream(LoadingPickTaskEntity.class.getDeclaredFields())
.filter(field -> !Modifier.isStatic(field.getModifiers())).map(field -> .filter(field -> !Modifier.isStatic(field.getModifiers())).map(field ->
field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase() + " " field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase() + " "
@@ -98,7 +98,7 @@ class CabinetLoadingEvidenceTransactionTest {
} }
@Test @Test
void replacementInsertFailureRollsBackOldActiveRowAndSameRequestCanRetry() { void appendInsertFailureRetainsOldActiveRowAndSameRequestCanRetry() {
nextFile = 301; upload("original"); nextFile = 301; upload("original");
var before = snapshot(); var before = snapshot();
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT reject_new_file CHECK(file_id <> 302)"); jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT reject_new_file CHECK(file_id <> 302)");
@@ -108,11 +108,11 @@ class CabinetLoadingEvidenceTransactionTest {
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence DROP CONSTRAINT reject_new_file"); jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence DROP CONSTRAINT reject_new_file");
upload("replacement"); upload("replacement");
assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus())) assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus()))
.singleElement().satisfies(row -> assertThat(row.getFileId()).isEqualTo(302L)); .extracting(CabinetLoadingEvidenceEntity::getFileId).containsExactly(301L, 302L);
} }
@Test @Test
void duplicateKeyAfterStaleReplayReadsMustRollBackPriorVoid() { void duplicateKeyAfterStaleReplayReadsRetainsAllPhotos() {
nextFile = 301; upload("original"); nextFile = 301; upload("original");
nextFile = 302; upload("winner"); nextFile = 302; upload("winner");
var before = snapshot(); var before = snapshot();
@@ -167,7 +167,7 @@ class CabinetLoadingEvidenceTransactionTest {
boolean secondPassed = second.get(20, java.util.concurrent.TimeUnit.SECONDS); boolean secondPassed = second.get(20, java.util.concurrent.TimeUnit.SECONDS);
assertThat(firstPassed || secondPassed).isTrue(); assertThat(firstPassed || secondPassed).isTrue();
assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus())) assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus()))
.singleElement().satisfies(row -> assertThat(row.getOperationKey()).isEqualTo("concurrent")); .extracting(CabinetLoadingEvidenceEntity::getOperationKey).containsExactly("original", "concurrent");
var committed = snapshot(); var committed = snapshot();
upload("concurrent"); upload("concurrent");
assertThat(snapshot()).isEqualTo(committed); assertThat(snapshot()).isEqualTo(committed);
@@ -8,6 +8,16 @@ import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQueryOpenApiContractTest { class CarrierScheduleQueryOpenApiContractTest {
/** 两个节点读取入口使用相同的操作用户名响应契约。 */
@Test
void nodeFactsExposeTheRecordedOperatorUsername() throws Exception {
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
String node = yaml.substring(yaml.indexOf(" NodeFact:"), yaml.indexOf(" ExceptionFact:"));
assertThat(node).contains("operatorName: {type: string, maxLength: 128, nullable: true")
.contains("节点操作人用户名快照");
}
@Test @Test
void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception { void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception {
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml") String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
@@ -16,6 +16,23 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQuerySqlContractTest { class CarrierScheduleQuerySqlContractTest {
/** 模块与部署基线共同保存节点署名,Mapper 同次写入两项快照。 */
@Test
void nodeFactsPersistOperatorSnapshotInBothBaselines() throws Exception {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
for (String source : List.of(moduleSql(), unified)) {
String lower = source.toLowerCase(java.util.Locale.ROOT);
int start = lower.indexOf("fl_carrier_schedule_node_fact (");
String table = lower.substring(start, lower.indexOf("engine=innodb", start));
assertThat(table).contains("operator_id bigint unsigned", "operator_name varchar(128)");
}
String insert = String.join("\n", com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper.class
.getMethod("insertStrict", com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)
.getAnnotation(org.apache.ibatis.annotations.Insert.class).value());
assertThat(insert).contains("operator_id,operator_name", "#{value.operatorId},#{value.operatorName}");
}
@Test @Test
void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception { void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception {
JdbcDataSource database = new JdbcDataSource(); JdbcDataSource database = new JdbcDataSource();
@@ -133,14 +133,21 @@ class CarrierScheduleExceptionPersistenceTest {
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_carrier_schedule_exception_fact WHERE source_reference IS NOT NULL AND raw_payload_hash IS NOT NULL", Integer.class)).isEqualTo(8); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_carrier_schedule_exception_fact WHERE source_reference IS NOT NULL AND raw_payload_hash IS NOT NULL", Integer.class)).isEqualTo(8);
} }
/** 真实 Mapper 保存并返回操作用户名,账号更名后重放不改写原署名。 */
@Test @Test
void originalContainerManualBodyReplaysWithoutUpdateHistoryOrFactMutationAndRejectsChangedFact() { void originalContainerManualBodyReplaysWithoutUpdateHistoryOrFactMutationAndRejectsChangedFact() {
var body = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest( var body = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
"ARRIVED_DESTINATION_PORT", TIME.plusDays(2), null, TIME.plusDays(2), "USLAX", "Los Angeles", "ARRIVED_DESTINATION_PORT", TIME.plusDays(2), null, TIME.plusDays(2), "USLAX", "Los Angeles",
"synthetic container manual fact"); "synthetic container manual fact");
service.addContainerManualNode(3,10001,"test-operator","original-body-key",body); var detail = service.addContainerManualNode(3,10001,"platform_admin","original-body-key",body);
assertThat(detail.nodes().stream().filter(node -> "MANUAL".equals(node.providerCode())).toList())
.singleElement().satisfies(node -> assertThat(node.operatorName()).isEqualTo("platform_admin"));
assertThat(jdbc.queryForObject("SELECT operator_id FROM fl_carrier_schedule_node_fact WHERE provider_code='MANUAL'", Long.class))
.isEqualTo(10001L);
assertThat(jdbc.queryForObject("SELECT operator_name FROM fl_carrier_schedule_node_fact WHERE provider_code='MANUAL'", String.class))
.isEqualTo("platform_admin");
var saved = allSnapshot(); var saved = allSnapshot();
service.addContainerManualNode(3,10001,"test-operator","original-body-key",body); service.addContainerManualNode(3,10001,"renamed_operator","original-body-key",body);
assertThat(allSnapshot()).isEqualTo(saved); assertThat(allSnapshot()).isEqualTo(saved);
assertThatThrownBy(() -> service.addContainerManualNode(3,10001,"test-operator","original-body-key", assertThatThrownBy(() -> service.addContainerManualNode(3,10001,"test-operator","original-body-key",
new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest( new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
@@ -183,6 +183,7 @@ class CarrierScheduleQueryServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移"); .isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移");
} }
/** 出仓节点记录确认人的账号,供两个货柜详情展示。 */
@Test @Test
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() { void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
@@ -194,7 +195,7 @@ class CarrierScheduleQueryServiceTest {
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM"); CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "platform_admin");
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM"); assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
assertThat(result.getCarrierCode()).isEqualTo("COSCO"); assertThat(result.getCarrierCode()).isEqualTo("COSCO");
@@ -204,23 +205,25 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(result.getCreatedBy()).isZero(); assertThat(result.getCreatedBy()).isEqualTo(7L);
assertThat(result.getCreatedByName()).isEqualTo("SYSTEM"); assertThat(result.getCreatedByName()).isEqualTo("platform_admin");
assertThat(result.getUpdatedBy()).isZero(); assertThat(result.getUpdatedBy()).isEqualTo(7L);
assertThat(result.getUpdatedByName()).isEqualTo("SYSTEM"); assertThat(result.getUpdatedByName()).isEqualTo("platform_admin");
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
verify(nodeOutbox).publish(eq(result), argThat(fact -> verify(nodeOutbox).publish(eq(result), argThat(fact ->
"FIRSTLEG".equals(fact.getProviderCode()) "FIRSTLEG".equals(fact.getProviderCode())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderEventId()) && "OUTBOUND_CONFIRM".equals(fact.getProviderEventId())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode()) && "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode())
&& "DEPARTED_WAREHOUSE".equals(fact.getStandardNode()) && "DEPARTED_WAREHOUSE".equals(fact.getStandardNode())
&& Long.valueOf(7L).equals(fact.getOperatorId())
&& "platform_admin".equals(fact.getOperatorName())
&& fact.getActualAt() != null), eq("ZCSU7881970")); && fact.getActualAt() != null), eq("ZCSU7881970"));
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) -> verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"AUTO_CREATE".equals(history.getAction()) "AUTO_CREATE".equals(history.getAction())
&& history.getFromVersion() == null && history.getFromVersion() == null
&& Integer.valueOf(0).equals(history.getToVersion()) && Integer.valueOf(0).equals(history.getToVersion())
&& Long.valueOf(0L).equals(history.getOperatorId()) && Long.valueOf(7L).equals(history.getOperatorId())
&& "SYSTEM".equals(history.getOperatorName()) && "platform_admin".equals(history.getOperatorName())
&& "国内确认出仓自动创建".equals(history.getReason()))); && "国内确认出仓自动创建".equals(history.getReason())));
} }
@@ -360,6 +363,7 @@ class CarrierScheduleQueryServiceTest {
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
} }
/** 自动采集推进原节点,但不冒用平台操作账号。 */
@Test @Test
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() { void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
CarrierScheduleQueryEntity task = task(0, 0); CarrierScheduleQueryEntity task = task(0, 0);
@@ -377,6 +381,8 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.query().query().status()).isEqualTo("READY"); assertThat(result.query().query().status()).isEqualTo("READY");
assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT"); assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT");
assertThat(result.nodes()).isEmpty(); assertThat(result.nodes()).isEmpty();
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) ->
fact.getOperatorId() == null && fact.getOperatorName() == null));
} }
@Test @Test
@@ -448,6 +454,7 @@ class CarrierScheduleQueryServiceTest {
verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970")); verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
} }
/** 人工实际节点保留操作账号及既有还柜幂等行为。 */
@Test @Test
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() { void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
CarrierScheduleQueryEntity task = task(0, 3); CarrierScheduleQueryEntity task = task(0, 3);
@@ -462,6 +469,8 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.query().query().status()).isEqualTo("STOPPED"); assertThat(result.query().query().status()).isEqualTo("STOPPED");
assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED"); assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED");
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) ->
Long.valueOf(7L).equals(fact.getOperatorId()) && "admin".equals(fact.getOperatorName())));
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString()); verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
} }
@@ -488,6 +497,7 @@ class CarrierScheduleQueryServiceTest {
verify(repository).updateState(before, 3, 7L, "admin"); verify(repository).updateState(before, 3, 7L, "admin");
} }
/** 柜级补录沿用当前账号,并保持已有跟踪任务和事件投递。 */
@Test @Test
void containerManualNodeJoinsAndAdvancesExistingCarrierTask() { void containerManualNodeJoinsAndAdvancesExistingCarrierTask() {
CarrierScheduleQueryEntity task = task(0, 8); CarrierScheduleQueryEntity task = task(0, 8);
@@ -509,7 +519,8 @@ class CarrierScheduleQueryServiceTest {
"PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup")); "PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup"));
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> Long.valueOf(1L).equals(fact.getQueryId()) verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> Long.valueOf(1L).equals(fact.getQueryId())
&& fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode()))); && fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode())
&& Long.valueOf(7L).equals(fact.getOperatorId()) && "admin".equals(fact.getOperatorName())));
verify(repository).updateState(task, 8, 7L, "admin"); verify(repository).updateState(task, 8, 7L, "admin");
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) -> verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"MANUAL_NODE".equals(history.getAction()) && Long.valueOf(1L).equals(history.getQueryId()) "MANUAL_NODE".equals(history.getAction()) && Long.valueOf(1L).equals(history.getQueryId())
@@ -0,0 +1,76 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/** 使用真实 MyBatis 更新语句验证清空外箱修正,不以 Mock 掩盖 NULL 被忽略的问题。 */
class InboundMeasurementItemPersistenceTest {
private SqlSession session;
private JdbcTemplate sql;
private InboundMeasurementRepository repository;
/** 每项用例使用隔离内存表,仅保留本轮更新涉及的列。 */
@BeforeEach
void setUp() {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:measurement-clear-" + UUID.randomUUID() + ";MODE=MySQL");
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
config.addMapper(InboundMeasurementItemMapper.class);
session = new MybatisSqlSessionFactoryBuilder().build(config).openSession(true);
session.getConnection();
sql = new JdbcTemplate(ds);
sql.execute("CREATE TABLE fl_inbound_measurement_item(id BIGINT PRIMARY KEY, tenant_id BIGINT, "
+ "manual_outer_length_cm DECIMAL(12,3), manual_outer_width_cm DECIMAL(12,3), "
+ "manual_outer_height_cm DECIMAL(12,3), charge_rule_id BIGINT)");
sql.update("INSERT INTO fl_inbound_measurement_item VALUES (88, 10001, 63, 53, 53, 77)");
repository = new InboundMeasurementRepository();
ReflectionTestUtils.setField(repository, "items", session.getMapper(InboundMeasurementItemMapper.class));
}
/** 关闭隔离数据库的最后一个会话,释放本项测试资源。 */
@AfterEach
void tearDown() {
if (session != null) session.close();
}
/** 单边清空与全部清空都须真正持久化,并可解除已取消的系数规则关联。 */
@ParameterizedTest
@CsvSource({"false,true,true", "true,false,true", "true,true,false", "false,false,false"})
void persistsClearedWarehouseDimensions(boolean keepLength, boolean keepWidth, boolean keepHeight) {
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setId(88L);
item.setTenantId(10001L);
item.setManualOuterLengthCm(keepLength ? new BigDecimal("63") : null);
item.setManualOuterWidthCm(keepWidth ? new BigDecimal("53") : null);
item.setManualOuterHeightCm(keepHeight ? new BigDecimal("53") : null);
item.setChargeRuleId(null);
assertThat(repository.updateItem(item)).isTrue();
var stored = sql.queryForMap("SELECT * FROM fl_inbound_measurement_item WHERE id=88");
assertThat(stored.get("MANUAL_OUTER_LENGTH_CM")).isEqualTo(keepLength ? new BigDecimal("63.000") : null);
assertThat(stored.get("MANUAL_OUTER_WIDTH_CM")).isEqualTo(keepWidth ? new BigDecimal("53.000") : null);
assertThat(stored.get("MANUAL_OUTER_HEIGHT_CM")).isEqualTo(keepHeight ? new BigDecimal("53.000") : null);
assertThat(stored.get("CHARGE_RULE_ID")).isNull();
item.setTenantId(20002L);
assertThat(repository.updateItem(item)).isFalse();
}
}
@@ -7,12 +7,14 @@ import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult; import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType; import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@@ -46,13 +48,18 @@ class CartonMeasurementServiceTest {
private final CustomerDimensionConfigRepository dimensionConfigs = mock(CustomerDimensionConfigRepository.class); private final CustomerDimensionConfigRepository dimensionConfigs = mock(CustomerDimensionConfigRepository.class);
private final CustomerDimensionWeightingService dimensionWeighting = private final CustomerDimensionWeightingService dimensionWeighting =
new CustomerDimensionWeightingService(dimensionConfigs); new CustomerDimensionWeightingService(dimensionConfigs);
private final MeasurementBatchService measurementBatchService = new MeasurementBatchService();
private final FbaPricingRepository pricingRepository = mock(FbaPricingRepository.class);
private CartonMeasurementService service; private CartonMeasurementService service;
private final AtomicReference<InboundMeasurementItemEntity> insertedItem = new AtomicReference<>(); private final AtomicReference<InboundMeasurementItemEntity> insertedItem = new AtomicReference<>();
@BeforeEach @BeforeEach
/** 构造真实共享尺寸计算入口,设备回归不以模拟计算结果代替业务算法。 */
void setUp() { void setUp() {
ReflectionTestUtils.setField(measurementBatchService, "dimensionWeighting", dimensionWeighting);
ReflectionTestUtils.setField(measurementBatchService, "pricingRepository", pricingRepository);
service = new CartonMeasurementService(repository, resolverProvider, images, transactions, service = new CartonMeasurementService(repository, resolverProvider, images, transactions,
dimensionWeighting); measurementBatchService);
ReflectionTestUtils.setField(service, "internalToken", "internal-token"); ReflectionTestUtils.setField(service, "internalToken", "internal-token");
when(resolverProvider.getIfAvailable()).thenReturn(resolver); when(resolverProvider.getIfAvailable()).thenReturn(resolver);
when(resolver.resolveByBoxCode(10001L, "RK-001#5-1")) when(resolver.resolveByBoxCode(10001L, "RK-001#5-1"))
@@ -164,7 +171,9 @@ class CartonMeasurementServiceTest {
} }
@Test @Test
/** 超出四档尺寸且未启用调整规则时,仍按设备原尺寸计算。 */
void scanOutsideConfiguredWeightingRulesKeepsImageAndUsesActualDimensions() { void scanOutsideConfiguredWeightingRulesKeepsImageAndUsesActualDimensions() {
useDefaultMode("ACTUAL");
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0); InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null);
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
@@ -187,7 +196,7 @@ class CartonMeasurementServiceTest {
assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("60.100"); assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("60.100");
assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("50.100"); assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("50.100");
assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("50.100"); assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("50.100");
assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.151"); assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.1509");
assertThat(insertedItem.get().getOverLimitWarning()).isNull(); assertThat(insertedItem.get().getOverLimitWarning()).isNull();
verify(images, never()).delete(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE)); verify(images, never()).delete(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE));
} }
@@ -347,6 +356,61 @@ class CartonMeasurementServiceTest {
verify(repository).updateInbound(inbound); verify(repository).updateInbound(inbound);
} }
@Test
/** 预置系数记录存在但未启用时,设备首次测量不加边也不乘系数。 */
void firstScanUsesActualDimensionsWhenCoefficientIsNotEnabled() {
useDefaultMode("ACTUAL");
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setLengthFactor(new BigDecimal("1.25"));
when(pricingRepository.findDefaultRule(10001L, 10001L)).thenReturn(rule);
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get()));
when(repository.updateMeasurement(measurement)).thenReturn(true);
service.upsert(10001L, 90001L, acceptanceCommand());
assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("59.500");
assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("41.200");
assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("31.300");
assertThat(insertedItem.get().getCoefficientMode()).isEqualTo("ACTUAL");
assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.0768");
verify(pricingRepository, never()).findDefaultRule(10001L, 10001L);
}
@Test
/** 客户明确启用系数后,设备首次录入与人工批次使用相同系数算法。 */
void firstScanAppliesExplicitlyEnabledMultiplyDefault() {
useDefaultMode("MULTIPLY");
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(77L);
rule.setTenantId(10001L);
rule.setStatus("ACTIVE");
rule.setRuleVersion(2);
rule.setLengthFactor(new BigDecimal("1.25"));
when(pricingRepository.findDefaultRule(10001L, 10001L)).thenReturn(rule);
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get()));
when(repository.updateMeasurement(measurement)).thenReturn(true);
service.upsert(10001L, 90001L, acceptanceCommand());
assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("74.375");
assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("51.500");
assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("39.125");
assertThat(insertedItem.get().getCoefficientMode()).isEqualTo("MULTIPLY");
assertThat(insertedItem.get().getChargeRuleId()).isEqualTo(77L);
assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.1499");
}
/** 设置客户默认算法,与是否保存系数规则分开。 */
private void useDefaultMode(String mode) {
List<CustomerDimensionConfigEntity> rules = dimensionRules(10001L);
rules.forEach(rule -> rule.setDefaultCalculationMode(mode));
when(dimensionConfigs.findAll(10001L)).thenReturn(rules);
}
private CartonMeasurementCommand command() { private CartonMeasurementCommand command() {
return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"), return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"),
new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("40.200"), new BigDecimal("30.300"),
@@ -388,6 +452,7 @@ class CartonMeasurementServiceTest {
return item; return item;
} }
/** 既有加边用例显式启用 ADD,避免依赖配置存在即生效的旧行为。 */
private List<CustomerDimensionConfigEntity> dimensionRules(long tenantId) { private List<CustomerDimensionConfigEntity> dimensionRules(long tenantId) {
return java.util.Arrays.stream(CustomerDimensionBoxType.values()).map(type -> { return java.util.Arrays.stream(CustomerDimensionBoxType.values()).map(type -> {
CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity(); CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity();
@@ -395,6 +460,7 @@ class CartonMeasurementServiceTest {
rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm()); rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm());
rule.setInnerHeightCm(type.defaultHeightCm()); rule.setInnerHeightCm(type.defaultHeightCm());
rule.setEdgeAdditionCm(new BigDecimal("2.000")); rule.setEdgeAdditionCm(new BigDecimal("2.000"));
rule.setDefaultCalculationMode("ADD");
return rule; return rule;
}).toList(); }).toList();
} }
@@ -39,15 +39,9 @@ class CustomerDimensionWeightingServiceTest {
} }
@Test @Test
void deviceMeasurementsUseActualDimensionsAndAddEachSideWhenRuleMatches() { /** 未勾选默认调整算法时,四档尺寸配置不能隐式启用加边。 */
var weighted = service.load(10001L).resolveDeviceMeasurement( void defaultCalculationModeRemainsActualWhenNoAdjustmentWasSelected() {
new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10")); assertThat(service.load(10001L).defaultCalculationMode()).isEqualTo("ACTUAL");
assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.SMALL);
assertThat(weighted.outerLengthCm()).isEqualByComparingTo("14.000");
assertThat(weighted.outerWidthCm()).isEqualByComparingTo("14.000");
assertThat(weighted.outerHeightCm()).isEqualByComparingTo("14.000");
assertThat(weighted.calculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_SMALL_V3");
} }
@Test @Test
@@ -81,16 +75,15 @@ class CustomerDimensionWeightingServiceTest {
} }
@Test @Test
void deviceDimensionsOutsideConfiguredRulesRemainUnchanged() { /** 默认算法必须在四档配置中一致,防止各录入入口取得不同计算方式。 */
var weighted = service.load(10001L).resolveDeviceMeasurement( void inconsistentCustomerDefaultModesAreRejected() {
new BigDecimal("60.001"), new BigDecimal("50"), new BigDecimal("50")); List<CustomerDimensionConfigEntity> rules = defaultRules(10001L);
rules.get(0).setDefaultCalculationMode("MULTIPLY");
when(repository.findAll(10001L)).thenReturn(rules);
assertThat(weighted.boxType()).isNull(); assertThatThrownBy(() -> service.load(10001L))
assertThat(weighted.edgeAdditionCm()).isNull(); .isInstanceOf(BizException.class)
assertThat(weighted.outerLengthCm()).isEqualByComparingTo("60.001"); .hasMessageContaining("默认箱规计算方式配置不一致");
assertThat(weighted.outerWidthCm()).isEqualByComparingTo("50.000");
assertThat(weighted.outerHeightCm()).isEqualByComparingTo("50.000");
assertThat(weighted.calculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1");
} }
@Test @Test
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity; import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundFeeItemEntity;
@@ -70,6 +71,87 @@ class FbaPricingServiceTest {
@MockBean private ContainerTierAmountAllocator containerTierAllocator; @MockBean private ContainerTierAmountAllocator containerTierAllocator;
@Resource private FbaPricingService service; @Resource private FbaPricingService service;
@Test
/** 预览只按当前选择值估价,不复用旧费用,也不产生任何计费写入。 */
void previewsSelectedOuterVolumeWithoutWritingFeeState() {
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("570.500", "3.6894"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(901L);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setOuterLengthCm(new BigDecimal("63"));
item.setOuterWidthCm(new BigDecimal("53"));
item.setOuterHeightCm(new BigDecimal("53"));
item.setCalculationRuleVersion("WAREHOUSE_OUTER_OVERRIDE_V1");
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
when(measurementRepository.listItems(TENANT, 901L)).thenReturn(List.of(item));
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(
estimate("KG", "650.298000", "13.6", "8844.05", "0"));
var preview = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW,
new BigDecimal("570.500"), new BigDecimal("3.8940"), "ACTUAL", "OUTER", null));
assertThat(preview.actualVolumeCbm()).isEqualByComparingTo("3.8940");
assertThat(preview.chargeableQuantity()).isEqualByComparingTo("650.298000");
assertThat(preview.totalAmount()).isEqualByComparingTo("8844.05");
assertThat(preview.priceMatched()).isTrue();
verify(priceBookEstimator).estimate(eq(TENANT), argThat(request ->
request.actualWeightKg().compareTo(new BigDecimal("570.5")) == 0
&& request.actualVolumeCbm().compareTo(new BigDecimal("3.894")) == 0));
verify(repository, never()).insertFee(any());
verify(repository, never()).insertIdempotency(any());
verify(repository, never()).nextFeeVersion(anyLong(), anyLong());
verify(repository, never()).markInboundCalculated(anyLong(), anyLong(), any(), any(), any(), any(), any());
org.mockito.Mockito.verifyNoInteractions(financeOutbox, feeDetails);
}
@Test
/** FBA 未命中价格时仍返回服务端计费量,供人工单价预览使用。 */
void previewsManualQuantityWhenPriceIsMissing() {
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("570.500", "3.6894"));
when(priceBookEstimator.estimate(anyLong(), any()))
.thenThrow(new BizException(ResultCode.NOT_FOUND, "价格未命中"));
var preview = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW,
new BigDecimal("570.5"), new BigDecimal("3.894"), null, null, null));
assertThat(preview.priceMatched()).isFalse();
assertThat(preview.unitPrice()).isNull();
assertThat(preview.chargeableQuantity()).isEqualByComparingTo("650.298000");
assertThat(preview.additionalAmount()).isZero();
assertThat(preview.totalAmount()).isNull();
verify(repository, never()).insertFee(any());
}
@Test
/** 外箱快照缺失时拒绝预览,不把当前规则的前端推导结果视作已保存事实。 */
void rejectsOuterPreviewWithoutSavedMeasurementSnapshot() {
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("100", "1"));
assertThatThrownBy(() -> service.preview(TENANT, INBOUND,
new FeePreviewRequest(NOW, new BigDecimal("100"), BigDecimal.ONE, "ACTUAL", "OUTER", null)))
.isInstanceOf(BizException.class).hasMessageContaining("测量数据不存在");
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, financeOutbox, feeDetails);
}
@Test
/** 预览不能读取其他客户的入库单。 */
void rejectsCrossCustomerPreview() {
assertThatThrownBy(() -> service.preview(10002L, INBOUND, new FeePreviewRequest(NOW, null, null, null, null, null)))
.isInstanceOf(BizException.class).hasMessageContaining("入库单不存在");
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, financeOutbox, feeDetails);
}
@Test
/** 人工单价的金额也由服务端精确取整,不在页面重复实现舍入。 */
void previewsManualUnitPriceUsingExactMoneyRounding() {
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("1", "0.001"));
when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(estimate("KG", "1", "2", "2", "0"));
var preview = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW,
BigDecimal.ONE, new BigDecimal("0.001"), null, null, new BigDecimal("1.005")));
assertThat(preview.unitPrice()).isEqualByComparingTo("2");
assertThat(preview.totalAmount()).isEqualByComparingTo("1.01");
verify(repository, never()).insertFee(any());
}
@Test @Test
@DisplayName("客户B不能读取客户A的费用快照") @DisplayName("客户B不能读取客户A的费用快照")
void rejectsCrossCustomerFeeLookup() { void rejectsCrossCustomerFeeLookup() {
@@ -401,6 +483,111 @@ class FbaPricingServiceTest {
assertThat(feeItem.getValue().getRemark()).isEqualTo("FBA价格未维护"); assertThat(feeItem.getValue().getRemark()).isEqualTo("FBA价格未维护");
} }
@Test
void previewsPrivateAddressManualQuantityWithoutLookingUpWarehouseRates() {
InboundPricingEntity inbound = inbound("62.500", "0.3840");
inbound.setDestinationType("PRIVATE");
inbound.setDestinationCode(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
var result = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW,
new BigDecimal("62.500"), new BigDecimal("0.3840"), null, null, new BigDecimal("12.2")));
assertThat(result.priceMatched()).isFalse();
assertThat(result.chargeableQuantity()).isEqualByComparingTo("64.128000");
assertThat(result.totalAmount()).isEqualByComparingTo("782.36");
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, feeDetails, financeOutbox);
verify(repository, never()).insertFee(any());
}
@Test
void createsPrivateAddressManualQuoteAndPreservesWaitSplit() {
readyRepository();
InboundPricingEntity inbound = inbound("62.500", "0.3840");
inbound.setDestinationType("PRIVATE");
inbound.setDestinationCode(null);
inbound.setProcessStatus("WAIT_SPLIT");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(301L);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(new InboundMeasurementItemEntity()));
var request = new ManualQuoteRequest(new BigDecimal("62.500"), new BigDecimal("0.3840"),
new BigDecimal("12.2"), "私人地址人工报价");
var result = service.manualQuote(TENANT, OPERATOR, INBOUND, request, "private-manual");
assertThat(result.rateCode()).isEqualTo("MANUAL_QUOTE");
assertThat(result.feeStatus()).isEqualTo("CALCULATED");
assertThat(result.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.totalAmount()).isEqualByComparingTo("782.36");
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getDestinationType()).isEqualTo("PRIVATE");
assertThat(fee.getValue().getCustomerId()).isEqualTo(TENANT);
assertThat(fee.getValue().getAdditionalAmount()).isEqualByComparingTo("0");
ArgumentCaptor<InboundFeeItemEntity> item = ArgumentCaptor.forClass(InboundFeeItemEntity.class);
verify(repository).insertItem(item.capture());
assertThat(item.getValue().getFeeName()).isEqualTo("头程基础运费");
assertThat(item.getValue().getAutomaticFlag()).isFalse();
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, financeOutbox);
ArgumentCaptor<InboundPricingIdempotencyEntity> command =
ArgumentCaptor.forClass(InboundPricingIdempotencyEntity.class);
verify(repository).insertIdempotency(command.capture());
command.getValue().setStatus("COMPLETED");
command.getValue().setResultResourceId(result.feeId());
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_MANUAL_QUOTE", "private-manual"))
.thenReturn(command.getValue());
when(repository.findFee(TENANT, result.feeId())).thenReturn(fee.getValue());
when(repository.findBaseItem(TENANT, result.feeId())).thenReturn(item.getValue());
var replay = service.manualQuote(TENANT, OPERATOR, INBOUND, request, "private-manual");
assertThat(replay.feeId()).isEqualTo(result.feeId());
verify(repository, times(1)).insertFee(any());
verify(repository, times(1)).insertIdempotency(any());
}
@Test
void rejectsAutomaticPricingForPrivateAddress() {
readyRepository();
InboundPricingEntity inbound = inbound("62.500", "0.3840");
inbound.setDestinationType("PRIVATE");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "private-auto"))
.isInstanceOf(BizException.class).hasMessageContaining("私人地址请使用人工头程报价");
verify(repository, never()).insertFee(any());
org.mockito.Mockito.verifyNoInteractions(priceBookEstimator);
}
@Test
void manualQuoteFreezesDatabaseMillisecondPrecisionForExactReplay() {
readyRepository();
InboundPricingEntity inbound = inbound("62.500", "0.3840");
inbound.setDestinationType("PRIVATE");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(301L);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement);
when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(new InboundMeasurementItemEntity()));
when(repository.completeIdempotency(any(), eq(501L), any())).thenReturn(1);
Clock original = (Clock) org.springframework.test.util.ReflectionTestUtils.getField(service, "clock");
var preciseClock = Clock.fixed(Instant.parse("2026-08-03T02:00:00.123456789Z"), ZoneId.of("Asia/Shanghai"));
org.springframework.test.util.ReflectionTestUtils.setField(service, "clock", preciseClock);
try {
var result = service.manualQuote(TENANT, OPERATOR, INBOUND,
new ManualQuoteRequest(new BigDecimal("62.500"), new BigDecimal("0.3840"),
BigDecimal.ONE, "私人地址测试人工报价"), "private-precision");
assertThat(result.calculatedAt().getNano()).isEqualTo(123000000);
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getCreatedAt()).isEqualTo(result.calculatedAt());
assertThat(fee.getValue().getUpdatedAt()).isEqualTo(result.calculatedAt());
} finally {
org.springframework.test.util.ReflectionTestUtils.setField(service, "clock", original);
}
}
@Test @Test
@DisplayName("已有匹配价格时拒绝绕过价格表创建人工报价") @DisplayName("已有匹配价格时拒绝绕过价格表创建人工报价")
void rejectsManualQuoteWhenRateExists() { void rejectsManualQuoteWhenRateExists() {
@@ -30,6 +30,18 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class InboundCartonLabelServiceTest { class InboundCartonLabelServiceTest {
/** 海外溯源必须使用已有批次,不得因空记录补建箱号。 */
@Test void existingMarksAreReadOnlyAndCheckInboundCode() {
when(repository.findActiveByInbound(1L, 2L)).thenReturn(List.of());
assertThat(service.existingCartonMarks(1L, 2L, "IN")).isEmpty();
var label = new InboundCartonLabelEntity();
label.setTotalCartonCount(1); label.setCartonIndex(1); label.setLabelVersion(1);
label.setBatchStatus("ACTIVE"); label.setInboundCode("IN"); label.setBoxCode("IN#1-1");
when(repository.findActiveByInbound(1L, 2L)).thenReturn(List.of(label));
assertThat(service.existingCartonMarks(1L, 2L, "IN")).containsExactly("IN#1-1");
assertThat(service.existingCartonMarks(1L, 2L, "OTHER")).isEmpty();
org.mockito.Mockito.verifyNoInteractions(orderCargoClient, inboundOrderCreationService, labelSkus);
}
@Mock private InboundCartonLabelRepository repository; @Mock private InboundCartonLabelRepository repository;
@Mock private OrderCargoClient orderCargoClient; @Mock private OrderCargoClient orderCargoClient;
@Mock private PrintableOrderInboundClient printableOrderInboundClient; @Mock private PrintableOrderInboundClient printableOrderInboundClient;
@@ -189,13 +189,46 @@ class LoadingCompletedOutboxServiceTest {
verify(manifests, org.mockito.Mockito.never()).labelPool(11L, 100L); verify(manifests, org.mockito.Mockito.never()).labelPool(11L, 100L);
} }
@Test void privateAddressFreezesAsOverseasWithOriginalCartonIdentity() throws Exception {
Row value = row(11, 100, 101);
value.setDestinationType("PRIVATE");
value.setDestinationWarehouseId(null);
value.setDestinationWarehouseCode(null);
value.setDestinationWarehouseName("私人地址");
when(snapshots.rows(90L)).thenReturn(List.of(value));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS");
assertThat(event.payload().at("/orders/0/destinationWarehouseId").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/destinationWarehouseCode").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/destinationWarehouseName").asText()).isEqualTo("私人地址");
assertThat(event.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
assertThat(event.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
assertThat(event.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101");
verify(manifests).labelPool(11L, 100L);
}
@Test void rejectsMixedPrivateAndOverseasDestinationsInOneInbound() {
Row privateAddress = row(11, 100, 101);
privateAddress.setDestinationType("PRIVATE");
when(snapshots.rows(90L)).thenReturn(List.of(privateAddress, row(11, 100, 102)));
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("目的类型必须唯一");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
@Test void rejectsUnfrozenDestinationType() { @Test void rejectsUnfrozenDestinationType() {
Row value = row(11, 100, 101); Row value = row(11, 100, 101);
value.setDestinationType(null); value.setDestinationType(null);
when(snapshots.rows(90L)).thenReturn(List.of(value)); when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L)) assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("目的类型必须唯一且为 FBA 或 OVERSEAS"); .hasMessageContaining("目的类型必须唯一且为 FBA、OVERSEAS 或 PRIVATE");
verify(outbox, org.mockito.Mockito.never()).insert( verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
} }
@@ -52,6 +52,7 @@ class MeasurementBatchServiceTest {
} }
@Test @Test
/** 未选择调整算法时,第三方与人工批次均保存实际外箱快照。 */
void manualAndExternalUseSameTablesAndExternalSaveNeverAdvancesInbound() { void manualAndExternalUseSameTablesAndExternalSaveNeverAdvancesInbound() {
MeasurementBatchCommand command = new MeasurementBatchCommand( MeasurementBatchCommand command = new MeasurementBatchCommand(
1L, 10L, null, 0, "THIRD_PARTY", "DEVICE_VENDOR", "EVENT-001", 1L, 10L, null, 0, "THIRD_PARTY", "DEVICE_VENDOR", "EVENT-001",
@@ -73,10 +74,10 @@ class MeasurementBatchServiceTest {
assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("21.000"); assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("21.000");
assertThat(items.getValue().get(0).getCalculationRuleVersion()) assertThat(items.getValue().get(0).getCalculationRuleVersion())
.isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1"); .isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1");
assertThat(items.getValue().get(0).getCoefficientMode()).isNull(); assertThat(items.getValue().get(0).getCoefficientMode()).isEqualTo("ACTUAL");
assertThat(items.getValue().get(0).getChargeRuleId()).isNull(); assertThat(items.getValue().get(0).getChargeRuleId()).isNull();
assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("145.000"); assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("145.000");
verify(pricingRepository).findDefaultRule(1L, 1L); verify(pricingRepository, never()).findDefaultRule(1L, 1L);
verify(repository, never()).updateInbound(any()); verify(repository, never()).updateInbound(any());
} }
@@ -173,8 +174,78 @@ class MeasurementBatchServiceTest {
verify(repository, never()).replaceItems(anyLong(), anyLong(), any()); verify(repository, never()).replaceItems(anyLong(), anyLong(), any());
} }
/** 全部清空人工修正后,计算与批次汇总须恢复设备实测尺寸。 */
@Test @Test
void clearsWarehouseOverridesAndRecalculatesFromDeviceDimensions() {
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(100L);
measurement.setTenantId(1L);
measurement.setInboundId(10L);
measurement.setVersion(3);
measurement.setCreatedBy(9L);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setId(200L);
item.setTenantId(1L);
item.setItemNo("B1");
item.setPackageCount(22);
item.setLengthCm(new BigDecimal("62"));
item.setWidthCm(new BigDecimal("52"));
item.setHeightCm(new BigDecimal("52"));
item.setManualOuterLengthCm(new BigDecimal("63"));
item.setManualOuterWidthCm(new BigDecimal("53"));
item.setManualOuterHeightCm(new BigDecimal("53"));
item.setCoefficientMode("ACTUAL");
when(repository.updateItem(item)).thenReturn(true);
service.recalculateOuterDimensions(1L, measurement, List.of(item), 200L, null, null, null);
assertThat(item.getManualOuterLengthCm()).isNull();
assertThat(item.getManualOuterWidthCm()).isNull();
assertThat(item.getManualOuterHeightCm()).isNull();
assertThat(item.getOuterLengthCm()).isEqualByComparingTo("62.000");
assertThat(item.getOuterWidthCm()).isEqualByComparingTo("52.000");
assertThat(item.getOuterHeightCm()).isEqualByComparingTo("52.000");
assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("3.6894");
verify(repository).updateItem(item);
}
@Test
/** 客户未启用算法时,预置系数不能放大仓库修正的实际外箱体积。 */
void savedCoefficientDoesNotOverrideActualDefaultMode() {
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(77L);
rule.setTenantId(1L);
rule.setCustomerId(1L);
rule.setStatus("ACTIVE");
rule.setDefaultRule(true);
rule.setLengthFactor(new BigDecimal("1.25"));
when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule);
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
80001L, "001", "A", 22, new BigDecimal("62"), new BigDecimal("52"),
new BigDecimal("52"), 500L, null, new BigDecimal("63"), new BigDecimal("53"),
new BigDecimal("53"), null, null, "仓库修正");
service.saveBatch(new MeasurementBatchCommand(
1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null,
LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)));
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), items.capture());
InboundMeasurementItemEntity calculated = items.getValue().get(0);
assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("63.000");
assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("53.000");
assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("53.000");
assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("3.8940");
assertThat(calculated.getCoefficientMode()).isEqualTo("ACTUAL");
assertThat(calculated.getChargeRuleId()).isNull();
verify(pricingRepository, never()).findDefaultRule(1L, 1L);
}
@Test
/** 只有客户明确启用系数算法,才自动选取当前默认系数配置。 */
void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() { void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() {
useDefaultMode("MULTIPLY");
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(77L); rule.setId(77L);
rule.setTenantId(1L); rule.setTenantId(1L);
@@ -212,7 +283,9 @@ class MeasurementBatchServiceTest {
} }
@Test @Test
/** 系数模式按当前尺寸匹配档位,同一系数作用于三个维度。 */
void usesOneMatchedTierCoefficientForAllDimensionsOfEachSpecification() { void usesOneMatchedTierCoefficientForAllDimensionsOfEachSpecification() {
useDefaultMode("MULTIPLY");
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(78L); rule.setId(78L);
rule.setTenantId(1L); rule.setTenantId(1L);
@@ -251,6 +324,7 @@ class MeasurementBatchServiceTest {
} }
@Test @Test
/** 无默认系数且未选择调整算法时,显式保存 ACTUAL 计算方式。 */
void keepsActualMeasurementUnchangedWhenCustomerHasNoDefaultRule() { void keepsActualMeasurementUnchangedWhenCustomerHasNoDefaultRule() {
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
@@ -269,7 +343,7 @@ class MeasurementBatchServiceTest {
assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("30.000"); assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("30.000");
assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("20.000"); assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("20.000");
assertThat(calculated.getChargeRuleId()).isNull(); assertThat(calculated.getChargeRuleId()).isNull();
assertThat(calculated.getCoefficientMode()).isNull(); assertThat(calculated.getCoefficientMode()).isEqualTo("ACTUAL");
assertThat(calculated.getCalculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1"); assertThat(calculated.getCalculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1");
} }
@@ -530,6 +604,58 @@ class MeasurementBatchServiceTest {
assertThat(saved.getDeviceMeasuredAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); assertThat(saved.getDeviceMeasuredAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
} }
@Test
/** 行明确选择实测算法后,即使客户默认系数已启用,也不能被覆盖。 */
void explicitActualModeOverridesCustomerMultiplyDefault() {
useDefaultMode("MULTIPLY");
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setTenantId(1L);
item.setItemNo("B1");
item.setPackageCount(22);
item.setLengthCm(new BigDecimal("62"));
item.setWidthCm(new BigDecimal("52"));
item.setHeightCm(new BigDecimal("52"));
item.setManualOuterLengthCm(new BigDecimal("63"));
item.setManualOuterWidthCm(new BigDecimal("53"));
item.setManualOuterHeightCm(new BigDecimal("53"));
item.setCoefficientMode("ACTUAL");
service.calculateCartonDimensions(item);
assertThat(item.getTotalVolumeCbm()).isEqualByComparingTo("3.8940");
assertThat(item.getCoefficientMode()).isEqualTo("ACTUAL");
assertThat(item.getChargeRuleId()).isNull();
verify(pricingRepository, never()).findDefaultRule(anyLong(), anyLong());
}
@Test
/** 客户明确启用加边规则后,未指定行算法的设备及人工录入均采用该默认方式。 */
void appliesCustomerAddDefaultWhenRowModeIsNotSelected() {
useDefaultMode("ADD");
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setTenantId(1L);
item.setItemNo("B1");
item.setPackageCount(1);
item.setLengthCm(new BigDecimal("30"));
item.setWidthCm(new BigDecimal("20"));
item.setHeightCm(new BigDecimal("20"));
service.calculateCartonDimensions(item);
assertThat(item.getOuterLengthCm()).isEqualByComparingTo("34.000");
assertThat(item.getOuterWidthCm()).isEqualByComparingTo("24.000");
assertThat(item.getOuterHeightCm()).isEqualByComparingTo("24.000");
assertThat(item.getCoefficientMode()).isEqualTo("ADD");
verify(pricingRepository, never()).findDefaultRule(anyLong(), anyLong());
}
/** 为用例设置客户真正启用的算法,独立于系数配置是否存在。 */
private void useDefaultMode(String mode) {
List<CustomerDimensionConfigEntity> rules = dimensionRules(1L);
rules.forEach(rule -> rule.setDefaultCalculationMode(mode));
when(dimensionConfigs.findAll(1L)).thenReturn(rules);
}
private MeasurementBatchCommand.Item item(BigDecimal unitWeight) { private MeasurementBatchCommand.Item item(BigDecimal unitWeight) {
return new MeasurementBatchCommand.Item( return new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
@@ -0,0 +1,13 @@
-- 散板需求沿用既有菜单权限,补全当前名称和添加货物独立按钮。
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107410,0,'PLATFORM','MENU',0,'platform:fleet:capacity-offer:list','散板需求','/overseas-master-data/capacity-offers','GET',1040,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0
WHERE NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:list');
UPDATE iam_permission SET permission_name='散板需求',status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3) WHERE permission_code='platform:fleet:capacity-offer:list';
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted)
SELECT 5107415,0,'PLATFORM','BUTTON',p.id,'platform:fleet:capacity-offer:cargo-add','加入散板货物',NULL,'POST',1045,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0 FROM iam_permission p
WHERE p.permission_code='platform:fleet:capacity-offer:list' AND NOT EXISTS (SELECT 1 FROM iam_permission WHERE permission_code='platform:fleet:capacity-offer:cargo-add');
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code IN ('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:cargo-add') AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
-- 上门取货复用正式排车创建入口,仅补齐既有平台管理员角色的必要权限。
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,p.id,0,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.permission_code='platform:overseas-warehouse:dispatch:create' AND p.status='ACTIVE' AND p.deleted=0 AND NOT EXISTS (SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
@@ -30,8 +30,8 @@ DROP PROCEDURE check_overseas_fee_permission_identity;
START TRANSACTION; START TRANSACTION;
INSERT INTO iam_permission INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,0) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),parent_id=0,status='ACTIVE',deleted=0; ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),parent_id=5105000,sort_no=60,status='ACTIVE',deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,5117200,CURRENT_TIMESTAMP(3) FROM iam_role r SELECT 0,r.id,5117200,CURRENT_TIMESTAMP(3) FROM iam_role r
+13 -11
View File
@@ -17,7 +17,7 @@
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。 拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有业务按无实际拼车货物、外配任务按已实际接入需求池货物分类;车辆归属仍读取装车快照,完整判定唯一见外配业务契约。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 为装车后运营视图;判定与权限分别见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`;OpenAPI 为各自 `fleet-*-business-v1.yaml`,部署/验收只查环境主契约。
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
@@ -44,7 +44,7 @@
| 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” | | 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| 跨客户同仓预约、日历 | `InboundPlanController.java`;在途/外接货跟进与覆盖见`appointmentplanning/` | | 跨客户同仓预约、日历 | `InboundPlanController.java`;在途/外接货跟进与覆盖见`appointmentplanning/` |
| PDA 入仓、柜号签收、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” | | PDA 入仓、私人地址收货、实收与上架完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` |
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` | | PDA 国内装柜照片对照 | `ReceivingContainerController.java` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java` |
@@ -56,7 +56,8 @@
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` | | 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;发布字段仅随智能排车创建保存且缺省不发布,编辑命令不接收该字段;重发须按原门禁取消排车再重新创建,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 | | 散板需求列表与货物登记 | `dispatch/service/DispatchCapacityDemandService.java`、`dispatch/controller/LooseboardCargoController.java`;列表与加货接口分开,发布规则查拼车主契约 |
| 排车散板提货/派送执行与取证 | `dispatch/controller/DispatchLooseboardExecutionController.java`、`dispatch/service/DispatchLooseboardExecutionService.java`;契约 `src/main/resources/openapi/dispatch-looseboard-execution-v1.yaml` |
| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` | | 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` |
| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` | | 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
@@ -65,21 +66,22 @@
| 货物废弃、待审锁箱、实际确认扣减 | `discard/controller/CargoDiscardController.java` | | 货物废弃、待审锁箱、实际确认扣减 | `discard/controller/CargoDiscardController.java` |
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java` | | 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java` |
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java` | | 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java` |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` | | 异常回仓建单与 PDA 分批实收 | `exceptionorder/controller/ExceptionOrderController.java`、`PdaExceptionReturnController.java`;契约 `src/main/resources/openapi/exception-return-inbound-v1.yaml` |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java` | | 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java` |
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java` | | 海外业务费用来源、费用明细审核与重试 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`;目录入口 `financefee/controller/PlatformOverseasFeeCatalogController.java`,细则查详细索引同主题 |
| 自动仓租、仓租库存与出库资金预校验 | `storagerent/controller/PlatformStorageRentController.java`、`storagerent/controller/PdaStorageRentController.java`、`storagerent/service/preparation/StorageRentPreparationService.java`;查详细索引“自动仓租及出库资金门禁” |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java` | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java` |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` |
| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` | | 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` |
| 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` | | 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` |
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 | | 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同 `fleet-vehicle-records-v1.yaml`。维修救援经 `financefee/fleet/VehicleCareFinanceAdapter` 入公司成本;Finance 为付款权威,未关联为 UNLINKED,不生成客户应收;权限、幂等与更正见详细索引同主题 |
| 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 | | 车次计划、车/司机占用与排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;协作入口 `dispatch/fleet/DispatchFleetAdapter.java`、`FleetDispatchBindingGuard`、`VehicleAssignmentGuard`,不穿透车辆持久化;契约 `fleet-trips-v1.yaml` |
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 | | 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java`;两级审核、依据失效与零扫码退回规则查当前排车主契约 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 | | 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;来源、实收及资金边界查 `fleet-trips-v1.yaml` 与详细索引同主题 |
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 | | 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 | | 实物装载、逐段容量与双端整车发车 | `dispatch/service/DispatchLoadingDepartureService.java`、`DispatchLoadingDocumentService.java`;双端使用 `recordVersion`;装车确认、照片齐全后的自动派送、容量与幂等只查当前排车主契约,无公开 `/depart` |
| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 | | 车次货物结案、车辆和司机自动释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 |
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
| 测试 | `src/test/java/com/baiyue/lastleg/` | | 测试 | `src/test/java/com/baiyue/lastleg/` |

Some files were not shown because too many files have changed in this diff Show More