This commit is contained in:
2026-11-03 11:41:58 +08:00
parent 21a9ce7d27
commit 7b5ef9ba64
56 changed files with 679 additions and 173 deletions
+2
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化
退货仓租改为原托整托计费、余一箱仍计一托,现有阶梯不变。已有 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 或部署,文件存在不表示已经执行。
@@ -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;
@@ -28,6 +28,7 @@ CREATE TABLE ll_cargo_discard_application (
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希',
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
created_by_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '申请人姓名快照',
reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
@@ -4794,6 +4795,7 @@ CREATE TABLE ll_storage_rent_lot (
original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量',
remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量',
original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数',
charged_days bigint NOT NULL DEFAULT 0 COMMENT '退货原托已生成仓租的截止库龄天数,同一天不重复计费',
inbound_at datetime(3) NOT NULL COMMENT '实际入库时间',
created_at datetime(3) NOT NULL COMMENT '创建时间',
updated_at datetime(3) NOT NULL COMMENT '更新时间',
@@ -282,8 +282,7 @@ public class BusinessFeeSourceEventService {
if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem()))
throw error("FIN-EVT-017", "资金业务引用不完整");
if ("OVERSEAS_MANUAL_FEE".equals(type)) {
if ("FEE_TYPE_077".equals(source.feeItemCode())
|| !no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
if (!no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配");
} else if ("STORAGE_RENT".equals(type)) {
if (!"FEE_TYPE_077".equals(source.feeItemCode()))
@@ -422,6 +422,12 @@ class BusinessFeeSourceEventServiceTest {
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071");
}
/** 手动仓租保留人工费用资金身份,仍校验原冻结与实扣来源。 */
@Test
void manualStorageRentConsumptionUsesManualFundingIdentity() {
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_077");
}
@Test
void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() {
verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077");
+1 -1
View File
@@ -60,7 +60,7 @@
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购、换标、人工海外费用和仓租通过 `WALLET_COMMAND_REQUESTED` 请求 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`、`OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`;费用必须匹配当前客户的真实服务单,公共费用目录通过 Firstleg 内部接口读取。头程附加费审核后进入账单;海外人工应收创建先冻结、审核后扣款,驳回或取消释放,扣款成功后仅登记本地已收来源,不再推送财务账单;采购、换标原业务收费仍按其独立资金身份处理。头程账单费用及供应商应付经直接 Outbox 投递 Finance,余额应收仅通过资金指令取得扣款回执,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml`。新菜单列表权限与财务编辑、审核按钮权限分离;本地检查不能代替真实资金、账单联调 |
| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,部分出库只承担原批次份额,余货不重置库龄。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存,按真实出库部分生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` |
| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,中转部分出库按原批次份额计费,退货原托余一箱仍按整托计费,同托同计租日不重复收取,余货不重置库龄。退货已计费截止日与实际出库、费用生成同事务推进,后续只收新增库龄;并发推进后旧预占必须释放重建。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存并生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
@@ -4,24 +4,30 @@
## 首版规则
仓租项目 `FEE_TYPE_077` 同时支持手动添加。尾程订单与公共费用编辑窗口均展示有效仓租项目;人工仓租沿普通人工费用的审核、结算方式及资金身份处理,可在审核前调整。自动仓租仍以 `createdBy=STORAGE_RENT` 和仓租项目共同识别,继续走出库计租、预占及仓租审核流程;同单同项目补收沿用已有费用提示和原因必填规则。
费用入口为 Admin「海外业务 → 海外费用管理」,仅保留「费用明细」与「仓租库存」两个页签。仓租库存查看相关单据、入库批次、库龄、剩余数量及出库数量记录,不展示金额和审核、调价入口。仓租金额及入库/出库时间、出库批次、计算金额、应扣及实扣金额在费用明细详情查询,多入库批次分别展示实际入库时间。审核与调价统一在对应尾程单据详情的「费用明细」办理,审核前调价必须填写说明。复用 Firstleg 公共费用项目,仓租项目为 `FEE_TYPE_077 / 仓储费`;不建立第二套海外费用目录。人工费用选择真实客户、当前服务业务单、项目、金额与币种后创建,详情保留必要的审核和资金状态。
海外自动仓租的详情、审核和调价共用计算过程展示:来源批次、入库与出库时间、计费规则、实际托数或出库CBM、原库龄、此前已计费天数、各档实际计费日区间、天数×计租基数×快照单价及原币小计,并显示原币金额×保存汇率的人民币换算。新费用把基数与阶梯结果固化在原预占快照中;历史费用只按原报价快照和完整原批次基数解释,旧比例费用明确标识原口径,缺少依据不按最新报价猜算。各档金额保留原始计算精度,费用总额按分四舍五入。
头程附加费审核后推送 Finance 账单。其余海外人工应收创建时冻结余额及可用授信,审核通过后扣款,Finance 成功扣款回执后登记本地已收费用,直接显示已完成,不再推送财务账单;驳回、取消释放冻结。在途回执、失败重试与同键重放不能重复扣款。车辆或同车运输不能代替每笔费用对应的客户和业务订单。
现有尾程订单没有权威的“原报价已含派送费”字段。首版新增头程派送费仍由审核人员核对原报价,已包含则驳回该费用;系统尚不支持自动判断报价包含关系,审核提示不能替代人工核对。
头程货最终目的地为自有海外仓、仓储转运(含外来货)以及退货建立计租批次。仓租全部使用余额及可用授信。实际 PDA 入库开始、实际 PDA 出库停止本次出库份额计租,每24小时计一天,不足24小时按一天。余货保持原入库时刻,不重置免费期;移库、换标和拆合托不新增免费期。
头程货最终目的地为自有海外仓、仓储转运(含外来货)以及退货建立计租批次。仓租全部使用余额及可用授信。实际 PDA 入库开始,每24小时计一天,不足24小时按一天。中转按实际出库份额停止计租;退货按原托整托收费,原托剩余一箱仍计一托,直到清空才停止计租。余货保持原入库时刻,不重置免费期;移库、换标和拆合托不新增免费期。
头程同单同仓多次实际入库合并为一个计租批次,统一从首次实际入库起算;补入库只追加箱数和计费体积,不重置库龄、免费期或已出库记录。单位体积沿用首次入库比例,后续修改订单体积不改写既有计租比例。其他业务保留各批实际入库时间分别计龄。头程未逐箱清点时,部分出库按来源单据箱数比例估算体积,审核前可调整金额并填写原因。本次不合并历史已存在的计租批次。
| 规则 | 计费阶梯(USD) | 数量口径 |
|---|---|---|
| 中转仓租 | 第1–7天免费;8–30天0.8;31–60天1;61–90天1.5;91天起2 | CBM/天,按阶梯累加 |
| 退货仓租 | 第1–30天1.5;31–60天2;61–90天3;91天起4 | 原始托数向上取整,按本次出库占原托比例分摊 |
| 退货仓租 | 第1–30天1.5;31–60天2;61–90天3;91天起4 | USD/原托/天,不足一托按一托;部分出库不按件分摊,同托同计租日仅收一次 |
自动计租优先读取现有客户仓租配置;无客户配置时使用上述两张报价表。USD 配置缺少整组中转或退货阶梯时,缺失组补用对应默认报价;非 USD 配置不得直接补用美元价格。已配置阶梯须从第1天连续覆盖至无限期,有限尾阶梯、缺段、负价或不支持的币种均阻断计价,不将配置错误或服务异常当作默认报价。
每次出库资金预校验保存客户、配置编号及版本、币种、完整阶梯和计算结果快照。有效预占和实际扣款使用原快照;修改配置不重算已冻结或已生成的费用。后续新预占按最新客户配置计算本次出库份额的全部库龄,仍保留最初入库时间;本版不把一次出库按历次调价日期再拆段计费。过期或货物、计租天数等依据变化时须重新预校验。
每次出库资金预校验保存客户、配置编号及版本、币种、完整阶梯和计算结果快照。有效预占和实际扣款使用原快照;修改配置不重算已冻结或已生成的费用。中转新预占按最新客户配置计算本次出库份额的全部库龄;退货新预占只计算原托尚未生成仓租的期间。两者都保留最初入库时间,按原库龄匹配阶梯,不按历次调价日期再拆段计费。过期或货物、计租天数、退货原托已计费截止日变化时须重新预校验并释放旧冻结。
退货原托的 `charged_days` 在实际出库与待审核费用生成的同一事务内推进,预占、取消、资金回执不推进。待审核费用已覆盖的期间不重复生成;审核减免或驳回属于该期间的人工处理,不因后续出库再次收取。一个扫码含同原托多个箱或 SKU 时先按原托去重;多原托分别收费。商品份额仅追踪库存数量与来源。并发扫码发现其他出库已推进截止日时阻断旧预占,重新预校验后只冻结未覆盖期间的金额。
原币金额与人民币结算额分别保留,冻结时固定汇率快照。无有效汇率时不得将美元直接作为人民币扣款。免费期零仓租保留出库记录,不要求汇率、不冻结资金。仓租自动计算,出库仅登记待审核费用并保留冻结;批准后沿原资金身份按实额扣款,驳回释放原冻结。订单费用和海外费用管理均展示原批次实际入库、实际出库、原计算金额、应扣金额及实扣金额。审核前允许原币金额调整且说明必填,保存版本;调低释放多余占用,调高由Finance结算时原子检查差额,余额不足保留原冻结,不登记实扣。原零元改非零先补冻结,成功后才能审核。其余客户收费人工录入并审核。历史已扣费用保留结果,不补审、不重复扣款。
@@ -30,12 +36,13 @@
## 计租验收样例
1. 中转批次10箱共2CBM,第10天出4箱:3个收费日 × 0.8CBM × 0.8 = **1.92 USD**;余6箱第31天出库:(23×0.8+1×1) × 1.2CBM = **23.28 USD**。余货仍从最初入库计龄。
2. 退货原批次不足一托,100件按1托,第10天出40件:10×1.5×40%=**6 USD**;第40天出余60件:(30×1.5+10×2)×60%=**39 USD**。第二次出库不再次将剩余货物进位到1托。
2. 退货原批次不足一托,100件按1托,第10天出40件:10×1.5=**15 USD**;同一计租日再出部分货不重复收费;剩余一箱仍为一托,第40天清空时补收第11–40天:20×1.5+10×2=**50 USD**,整托40天合计**65 USD**。第61天或第91天仍有货时,新增日分别按3或4 USD/托计费。
3. 入库后恰好24小时为1天,24小时加1毫秒为2天;恰好7天仍免费,7天加1毫秒进入第8天。对金额先按份额精算,完整收费命令汇总后按结算精度取整。
## 发布配套
- 后端:Firstleg 公共目录内部接口、Warehouse 客户仓租内部报价接口、Lastleg 费用/仓租及真实收发货挂钩。Finance 继续作为余额、授信、冻结和实扣的唯一权威;不得绕过 Outbox/Inbox 链路。
- 退货整托口径更新:先对已有 Lastleg 库执行 `deploy/sql/return-storage-rent-whole-pallet.sql` 补充 `charged_days`,再配套发布 Lastleg。新库两份基线已同步。切换时暂停相关出库并取消未消费预占;既有比例计费记录需逐单确认处理方式,脚本不回填计费截止日、不追收或修改既有费用和资金。当前仅准备源码和脚本,未据此认定 TEST 已生效。
- 前端:Admin 海外费用管理与海外 PDA 出库预校验同时发布,不能只更新一端。
- 数据库文件:本次五份版本脚本及两份专项汇总 SQL 已收敛到 [国内统一空库基线](../../deploy/sql/头程数据库统一增量-MySQL8.sql) 和 [海外统一空库基线](../../deploy/sql/海外区域统一增量-MySQL8.sql)。海外基线完整包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租份额快照、审核人名称及采购退款请求幂等字段和唯一索引,国内基线包含菜单和默认管理员授权。内容清单见 [数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。
- 已有库:不能重放统一空库初始化;须按实际结构和明确授权审阅增量。指定 TEST 先使用[表与审核人字段补齐](../../deploy/sql/overseas-fees-storage-rent-structure-add.sql),再使用[免费仓租定向结构修复](../../deploy/sql/overseas-storage-rent-zero-fee-repair.sql);精确范围与失败语义只维护在[数据库变更汇总](../../deploy/sql/20261022-海外费用仓租数据库变更汇总.md)。独立的 [既有费用写权限修复](../../deploy/sql/overseas-fee-canonical-write-permissions-repair.sql) 仍只处理已有管理员两项 canonical 写权限,不能代替结构升级或菜单初始化。
@@ -70,7 +77,7 @@
| 人工海外收费 | 创建只冻结;审核后恰好扣一次;驳回/取消释放;不足余额但授信足够可成功,二者合计不足明确失败 |
| 头程附加费 | 审核前不入账单,批准后回到正确头程来源且不扣钱包;人工核对原报价,已含派送费的新增收费须驳回 |
| 仓租原生入库 | 头程自有仓、外来转运和退货实际扫码均生成正确原批次;非计租范围不误收 |
| 部分及分批出库 | 原托比例、阶梯、库龄和余量符合上例;整托/多箱/混SKU全部来源核对后才能扣库存 |
| 部分及分批出库 | 中转保持来源比例;退货余一箱仍按整托,新期间按原库龄阶梯,同托同日不重复收费;整托/多箱/混SKU全部来源核对后才能扣库存 |
| 客户价格与默认报价 | 客户专价优先;无配置按两张默认报价;接口超时或无效阶梯不允许默认为免费或默认报价 |
| 调价与币种 | 有效预占及已生成费用不受调价影响,新预占使用新版本;USD、CNY 原币及分币种汇总准确,不将混合币种直接相加 |
| 资金门禁 | 未冻结成功、资金不足、过期、未知网络结果均不放行库存写;成功后仅本次实际出库份额扣款 |
@@ -34,10 +34,11 @@ public class CargoDiscardController {
return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode));
}
/** 从可信登录身份保存申请人姓名快照。 */
@PostMapping
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
return ApiResponse.success(service.create(scope(user, customerId), operator(user), org.springframework.util.StringUtils.hasText(user.displayName()) ? user.displayName() : user.username(), request));
}
@GetMapping
@@ -24,10 +24,11 @@ public class MerchantCargoDiscardController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); }
/** 从可信登录身份保存申请人姓名快照。 */
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CREATE + "')")
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), org.springframework.util.StringUtils.hasText(user.displayName()) ? user.displayName() : user.username(), request)); }
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@@ -38,7 +38,7 @@ public final class CargoDiscardResponses {
String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt,
List<String> skuCodes, int skuCount, int requestedPackages, Integer actualPackages) {}
List<String> skuCodes, int skuCount, int requestedPackages, Integer actualPackages, String createdByName) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks,
@@ -26,6 +26,7 @@ public class CargoDiscardApplication {
private String idempotencyKey;
private String requestHash;
private String createdBy;
private String createdByName;
private String reviewedBy;
private String reviewRemark;
private LocalDateTime customerConfirmedAt;
@@ -84,6 +84,25 @@ public interface CargoDiscardInventoryMapper {
@Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " +
"AND business_type IN ('STOCK_IN','RETURN_STOCK_IN') AND carton_mark IS NOT NULL AND carton_mark<>'' ORDER BY carton_mark")
List<String> looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 现场箱唛须能溯源到该客户该仓位的实收流水或原入仓单,不能凭数量放行。 */
@Select("""
SELECT l.carton_mark FROM ll_inventory_ledger l
JOIN ll_inventory_position p ON p.customer_id=l.customer_id AND p.id=l.position_id AND p.balance_id=l.balance_id
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id AND b.inbound_order_id=l.inbound_order_id
WHERE l.customer_id=#{customerId} AND l.position_id=#{positionId}
AND l.carton_mark IS NOT NULL AND l.carton_mark<>''
GROUP BY l.carton_mark HAVING SUM(l.delta_packages)>0
UNION
SELECT DISTINCT c.carton_mark FROM ll_inventory_position p
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id
JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id
WHERE p.customer_id=#{customerId} AND p.id=#{positionId} AND c.received_at IS NOT NULL
AND c.status IN ('AVAILABLE','DISCARD_PENDING') AND c.carton_mark IS NOT NULL AND c.carton_mark<>''
AND (c.warehouse_pallet_id=p.pallet_id OR c.warehouse_pallet_id IS NULL)
AND COALESCE(b.destination_warehouse_code,'')=COALESCE(c.destination_warehouse_code,'')
""")
List<String> traceableCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 按当前可用仓位筛选入库单,不能只依赖箱唛的入库状态。 */
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
@@ -30,7 +30,7 @@ public final class PdaCargoDiscardDtos {
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status, String quantityUnit,
boolean actualQuantityEditable) { }
boolean actualQuantityEditable, List<String> allowedCartonMarks) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks, String remark,
@@ -94,6 +94,7 @@ public class PdaCargoDiscardService {
if (scanned.size() != input.actualPackages() || scanned.stream().anyMatch(mark -> mark == null || mark.isBlank()
|| mark.length() > 128 || !mark.equals(mark.trim()) || !observed.add(mark) || otherMarks.contains(mark)))
throw conflict("实际废弃箱数必须与逐箱扫码一致,箱唛不能重复或跨托盘重复废弃");
if (!"COMPLETED".equals(item.getPdaStatus())) stockService.validateActualCartonMarks(application.getCustomerId(), item, scanned);
if ("COMPLETED".equals(item.getPdaStatus()) && !new HashSet<>(stockService.actualCartonMarks(item)).equals(new HashSet<>(scanned)))
throw conflict("已完成托盘的实际废弃箱唛不能修改");
}
@@ -145,7 +146,7 @@ public class PdaCargoDiscardService {
var actualMarks = task.skus().stream().filter(s -> s.itemId().equals(item.getId())).findFirst().orElseThrow().actualQuantityEditable()
? request.lines().stream().filter(i -> i.itemId().equals(item.getId())).findFirst().orElseThrow().scannedCartonMarks()
: cartonMarks(application, item);
if ("INVENTORY".equals(item.getSourceType())) stockService.saveActualCartonMarks(item, actualMarks == null ? List.of() : actualMarks);
stockService.saveActualCartonMarks(item, actualMarks == null ? List.of() : actualMarks);
item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED");
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item);
}
@@ -218,7 +219,8 @@ public class PdaCargoDiscardService {
CargoDiscardItem first = entry.getValue().get(0);
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(),
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList();
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i),
"INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i) ? stockService.allowedCartonMarks(application.getCustomerId(), i) : cartonMarks(application, i))).toList();
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
// 整托同样须核对已知箱号;仅无箱号的普通库存按实际箱数作业。
@@ -32,6 +32,8 @@ public class CargoDiscardStockRepository {
public boolean dropshipPosition(String customer, Long position) { return stock.isDropshipPosition(customer, position) > 0; }
/** 读取散箱实收流水中的箱码快照。 */
public List<String> looseMarks(String customer, Long position) { return stock.looseCartonMarks(customer, position); }
/** 读取本仓位原入库单与实收流水允许扫描的箱唛。 */
public List<String> traceableMarks(String customer, Long position) { return stock.traceableCartonMarks(customer, position); }
/** 保存废弃申请的库存来源明细。 */
public void saveItem(CargoDiscardItem item) { items.insert(item); }
/** 保存申请所选实物箱快照。 */
@@ -84,16 +84,10 @@ public class CargoDiscardService {
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
private BigDecimal feePerPackage = new BigDecimal("5.00");
/** 仅提供允许废弃的普通库存和退货箱候选。 */
public List<CargoDiscardResponses.Candidate> candidates(String customerId, String inboundCode) {
List<CargoDiscardCandidateRow> rows = inventory.candidates(customerId, trim(inboundCode));
Map<Long, CandidateAccumulator> grouped = new LinkedHashMap<>();
for (CargoDiscardCandidateRow row : rows) {
CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row));
value.add(row);
}
List<CargoDiscardResponses.Candidate> result = grouped.values().stream()
.map(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId())))
.collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new));
// 货物废弃仅开放普通库存和退货库存,不提供一件代发订单候选。
List<CargoDiscardResponses.Candidate> result = new java.util.ArrayList<>();
result.addAll(stockService.candidates(customerId, trim(inboundCode)));
String keyword = trim(inboundCode);
if (returnCartons == null || returnCartonItems == null) return result;
@@ -141,10 +135,12 @@ public class CargoDiscardService {
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
}
/** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */
/** 申请人姓名由认证身份提供快照,不采信请求字段。 */
@Transactional
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
public CargoDiscardResponses.Detail create(String customerId, String operatorId, String operatorName,
CargoDiscardRequests.Create request) {
if (request.items().stream().anyMatch(item -> item.orderId() != null))
throw badRequest("货物废弃不支持一件代发订单");
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (request.requestedPackages() != null && !StringUtils.hasText(request.remark()))
@@ -243,7 +239,7 @@ public class CargoDiscardService {
application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total)));
application.setCurrency("USD"); application.setStatus(PENDING_REVIEW);
application.setRemark(trim(request.remark())); application.setIdempotencyKey(key);
application.setRequestHash(requestHash); application.setCreatedBy(operatorId);
application.setRequestHash(requestHash); application.setCreatedBy(operatorId); application.setCreatedByName(operatorName);
application.setCreatedAt(now); application.setUpdatedAt(now);
applications.insert(application);
@@ -292,6 +288,7 @@ public class CargoDiscardService {
return statisticsService.statistics(customerId, query);
}
/** 详情返回各来源实际完成箱唛及托盘,退货沿用实收箱事实。 */
public CargoDiscardResponses.Detail detail(String customerId, String discardNo) {
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
@@ -299,8 +296,7 @@ public class CargoDiscardService {
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value),
value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus(),
stockService.actualCartonMarks(value), value.getInventoryPositionId() == null ? null
: stockService.position(customerId, value.getInventoryPositionId()).getPalletNo()))
actualMarks(customerId, application, value), detailPallet(customerId, value)))
.toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
@@ -309,6 +305,25 @@ public class CargoDiscardService {
application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews, photoService.list(application, null, true));
}
/** 实际箱唛优先取作业快照,退货已完成明细可沿已核验的所选箱恢复。 */
private List<String> actualMarks(String customer, CargoDiscardApplication application, CargoDiscardItem item) {
var marks = stockService.actualCartonMarks(item);
if (!marks.isEmpty() || !"COMPLETED".equals(item.getPdaStatus()) || item.getActualDiscardPackages() == null || item.getActualDiscardPackages() <= 0) return marks;
return selectedMarks(customer, application, item);
}
/** 托盘只读对应来源的真实实收记录,散箱保留明确标记。 */
private String detailPallet(String customer, CargoDiscardItem item) {
if (item.getInventoryPositionId() != null) return stockService.position(customer, item.getInventoryPositionId()).getPalletNo();
if (isReturnItem(item)) {
var carton = returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !customer.equals(carton.getCustomerId())) return null;
return StringUtils.hasText(carton.getPalletCode()) ? carton.getPalletCode() : "散箱";
}
var profile = profiles.selectById(item.getProfileId());
return profile != null && customer.equals(profile.getCustomerId()) ? profile.getPalletNo() : null;
}
public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String discardNo, long fileId) {
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found");
@@ -539,7 +554,7 @@ public class CargoDiscardService {
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes,
value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt(), skuCodes, skuCodes.size(), requested, actual);
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt(), skuCodes, skuCodes.size(), requested, actual, value.getCreatedByName());
}
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
@@ -27,7 +27,7 @@ public class CargoDiscardStockService {
var positions = new LinkedHashMap<Long, InventoryPosition>();
repository.candidates(customer, keyword).forEach(p -> positions.put(p.getId(), p));
relabelStock.positions(customer, keyword).forEach(p -> positions.put(p.getId(), p));
return positions.values().stream().map(position -> {
return positions.values().stream().filter(position -> !repository.dropshipPosition(customer, position.getId())).map(position -> {
var balance = repository.balance(position.getBalanceId());
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
long available = available(customer, position, balance);
@@ -74,7 +74,7 @@ public class CargoDiscardStockService {
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
if (request.discardPackages() != available) throw conflict("无箱唛的候选托盘须完整预留,废弃目标箱数请填写在申请总数中");
}
if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
if (repository.dropshipPosition(customer, position.getId())) throw conflict("货物废弃不支持一件代发库存");
return balance;
}
@@ -270,6 +270,18 @@ public class CargoDiscardStockService {
try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); }
}
/** 可扫描范围只来自该申请明细仓位的原入仓及当前净实收箱唛。 */
public List<String> allowedCartonMarks(String customer, CargoDiscardItem line) {
position(customer, line.getInventoryPositionId());
return repository.traceableMarks(customer, line.getInventoryPositionId());
}
/** 无逐箱快照时仍需验证原入库箱唛归属,无法溯源的箱子拒绝出库。 */
public void validateActualCartonMarks(String customer, CargoDiscardItem line, List<String> marks) {
position(customer, line.getInventoryPositionId());
var allowed = new HashSet<>(repository.traceableMarks(customer, line.getInventoryPositionId()));
if (!allowed.containsAll(marks)) throw conflict("箱唛不属于本废弃单的原入仓库存,请核对后重新扫描");
}
/** 现场扫码单独保存,不改写客户选箱快照或把无关联库存伪装成已锁定实箱。 */
public void saveActualCartonMarks(CargoDiscardItem line, List<String> marks) {
try { line.setActualCartonMarksJson(objectMapper.writeValueAsString(marks)); }
@@ -24,9 +24,8 @@ public final class DispatchCommands {
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations, Boolean publishLooseboardDemand) {
/** 计划发车时间是车次表必填字段;前端未填写时给验收/草稿单一个可编辑的默认时间。 */
/** 草稿未知计划时间保持为空,避免重试时动态补时间破坏原请求幂等。 */
public Create {
plannedDepartureAt = plannedDepartureAt == null ? Instant.now().plusSeconds(3600) : plannedDepartureAt;
publishLooseboardDemand = Boolean.TRUE.equals(publishLooseboardDemand);
}
}
@@ -20,7 +20,14 @@ public class OverseasFeeSource {
public record RentLine(long lotId, java.time.OffsetDateTime inboundAt, java.math.BigDecimal quantity, long days,
java.math.BigDecimal calculatedAmount, String sourceBusinessType, String sourceBusinessNo, String batchNo) { }
public record RentDetail(String outboundNo, java.time.OffsetDateTime outboundAt,
java.math.BigDecimal calculatedAmount, java.math.BigDecimal chargedAmount, java.util.List<RentLine> lines) { }
java.math.BigDecimal calculatedAmount, java.math.BigDecimal chargedAmount, java.util.List<RentLine> lines,
java.util.List<RentCalculation> calculations) { }
/** 每个原批次的实际计费基数、已覆盖天数及阶梯,金额沿用原计费快照。 */
public record RentCalculation(String sourceBusinessNo,String batchNo,String ruleCode,
java.math.BigDecimal billingQuantity,String billingUnit,long storageDays,long previouslyChargedDays,
java.util.List<RentTier> tiers,java.math.BigDecimal calculatedAmount) { }
/** 阶梯金额保留计算精度,由费用合计统一按分取整。 */
public record RentTier(long startDay,long endDay,long days,java.math.BigDecimal unitPrice,java.math.BigDecimal amount) { }
@TableId(type = IdType.INPUT) private Long id;
private Long sourceFeeId;
private Integer sourceVersion;
@@ -143,7 +143,6 @@ public class OverseasManualFeeService implements WalletResultHandler {
public OverseasFeeSource create(String operator, String key, SaveRequest request) {
if (request.businessId() == null) throw bad("请选择本次服务对应的业务订单");
request = resolveServiceBusiness(request);
requireManualItem(request.feeItemCode());
catalog.requireActive(request.feeItemCode(), request.feeItemName(), request.direction());
OverseasFeeSource fee = sources.create(operator, key, request);
fee = required(repository.lockFee(fee.getSourceFeeId()));
@@ -363,7 +362,6 @@ public class OverseasManualFeeService implements WalletResultHandler {
if (replay)
return create(operator, createKey, request.fee());
requireBeforeReview(fee, state, request.expectedVersion());
requireManualItem(request.fee().feeItemCode());
catalog.requireActive(request.fee().feeItemCode(), request.fee().feeItemName(), request.fee().direction());
if (!Objects.equals(fee.getCustomerId(), request.fee().customerId()) || !Objects.equals(fee.getBusinessType(), request.fee().businessType())
|| !Objects.equals(fee.getBusinessId(), request.fee().businessId()) || !Objects.equals(fee.getDirection(), request.fee().direction())
@@ -504,11 +502,6 @@ public class OverseasManualFeeService implements WalletResultHandler {
return describe(sources.listForBusiness(customerId, "OVERSEAS_PROCUREMENT", orderId));
}
/** 仓租只由实际出库自动生成,人工收费和调价不得重复录入。 */
private void requireManualItem(String code) {
if ("FEE_TYPE_077".equals(code)) throw bad("仓租自动计算,请勿手动添加");
}
/** 钱包只收人民币;核对创建时保存的原币和月度汇率快照,不重新取价。 */
private void requireCnySnapshot(OverseasFeeSource fee) {
if (!"RECEIVABLE".equals(fee.getDirection()) || !"CNY".equals(fee.getCurrency()) || fee.getAmount() == null
@@ -335,7 +335,7 @@ public class DriverService {
case REJECTED -> "司机证照已被打回";
case EXPIRED -> "驾驶证已过期";
case MISSING_TRIP_END -> "请填写司机预计任务结束时间";
case INVALID_TRIP_END -> "司机预计任务结束时间必须晚于当前时间";
case INVALID_TRIP_END -> "预计结束时间已过,请调整运输计划";
case EXPIRES_DURING_TRIP -> "驾驶证将在预计任务结束前到期,请更换司机或调整计划";
case ELIGIBLE -> "司机可派车";
};
@@ -641,7 +641,9 @@ public class FleetTripService {
}
/** 校验发车和任务结束时间以及路线站点完整性。 */
private void validatePlan(Instant departure, Instant end, List<FleetTripModels.Stop> stops) {
if (departure == null || end == null || !end.isAfter(departure) || !end.isAfter(clock.instant())) throw bad("司机预计任务结束时间须晚于计划发车及当前时间");
if (departure == null || end == null) throw bad("请填写发车和预计结束时间");
if (!end.isAfter(departure)) throw bad("预计结束时间须晚于发车时间");
if (!end.isAfter(clock.instant())) throw bad("预计结束时间已过,请调整运输计划");
try {
if (utc(departure).getYear() < 1900 || utc(end).getYear() > 9998) throw bad("车次时间超出允许范围");
} catch (DateTimeException invalid) { throw bad("车次时间超出允许范围"); }
@@ -34,14 +34,32 @@ public final class StorageRentDtos {
public record Owner(String customerId, String customerName, String businessType, Long businessId,
String businessNo, Long orderId, String inboundCode, String warehouseId) { }
public record Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount,
LocalDateTime inboundAt, long days) { }
LocalDateTime inboundAt, long days, Long previouslyChargedDays) {
/** 中转按数量分摊及既有快照不携带退货整托已计费天数。 */
public Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount,
LocalDateTime inboundAt, long days) {
this(lotId, shareId, quantity, amount, inboundAt, days, null);
}
}
public record PricingTier(String id,Integer startDay,Integer endDay,BigDecimal unitPrice,Integer sortNo,
String feeType,String billingUnit) { }
public record Pricing(String configId,Long customerId,Integer version,String currency,List<PricingTier> tiers,String source) {
/** 固化不可变阶梯快照,防止调用方修改已确认的计费依据。 */
public Pricing { if(tiers!=null)tiers=List.copyOf(tiers); }
}
public record Plan(Owner owner, List<Allocation> allocations, LocalDateTime calculatedAt, BigDecimal amount,Pricing pricing) {
/** 一档实际覆盖的计租日、快照单价及原币小计。 */
public record CalculationTier(long startDay,long endDay,long days,BigDecimal unitPrice,BigDecimal amount) { }
/** 按原批次汇总的不可变计算过程,计租数量与出库商品数量分开。 */
public record Calculation(long lotId,String ruleCode,BigDecimal billingQuantity,String billingUnit,
long storageDays,long previouslyChargedDays,List<CalculationTier> tiers,BigDecimal amount) { }
public record Plan(Owner owner, List<Allocation> allocations, LocalDateTime calculatedAt, BigDecimal amount,Pricing pricing,
List<Calculation> calculations) {
/** 旧快照没有计算过程时保留为空,查询时只按原报价与原批次依据解释。 */
public Plan { calculations=calculations==null?List.of():List.copyOf(calculations); }
/** 保留明确提供报价的调用,计算过程由正式定价流程一次性生成。 */
public Plan(Owner owner,List<Allocation> allocations,LocalDateTime calculatedAt,BigDecimal amount,Pricing pricing) {
this(owner,allocations,calculatedAt,amount,pricing,List.of());
}
/** 本轮旧测试构造继续明确使用报价表默认值,实际预占必须读取配置服务。 */
public Plan(Owner owner,List<Allocation> allocations,LocalDateTime calculatedAt,BigDecimal amount) {
this(owner,allocations,calculatedAt,amount,com.baiyue.lastleg.storagerent.service.StorageRentCalculator.defaults(owner==null?null:Long.valueOf(owner.customerId())));
@@ -55,6 +73,6 @@ public final class StorageRentDtos {
BigDecimal settledAmount,long chargeCount,long settledChargeCount) { }
public record ChargeView(Long id,String outboundNo,String businessNo,BigDecimal quantity,long storageDays,
BigDecimal originalAmount,String currency,BigDecimal settlementAmount,BigDecimal currencyRate,String status,
OffsetDateTime createdAt,OffsetDateTime outboundAt) { }
OffsetDateTime createdAt,OffsetDateTime outboundAt,BigDecimal billingQuantity,String billingUnit) { }
public record LotDetail(LotView lot,List<ChargeView> charges) { }
}
@@ -46,6 +46,8 @@ public class StorageRentLot {
private BigDecimal remainingQuantity;
/** 原批次计费体积或托数。 */
private BigDecimal originalUnits;
/** 退货原托已生成仓租的截止库龄天数,部分出库后余货仍按整托续计。 */
private Long chargedDays = 0L;
/** 实际入库时间。 */
private LocalDateTime inboundAt;
/** 创建时间。 */
@@ -30,7 +30,7 @@ public final class StorageRentCalculator {
if(tiers.isEmpty())throw new IllegalArgumentException("仓租报价缺少业务阶梯");
return tiers.stream().map(t->BigDecimal.valueOf(Math.max(0,Math.min(days,t.endDay()==null?Long.MAX_VALUE:t.endDay())-t.startDay()+1)).multiply(t.unitPrice())).reduce(BigDecimal.ZERO,BigDecimal::add);
}
/** 每次出库只承担原批次对应份额,退货原托的拆分不重新向上取托数。 */
/** 中转按原批次份额计费,退货只要有货出库即承担整托未计费仓租。 */
public static BigDecimal amount(String rule, LocalDateTime inbound, LocalDateTime outbound,
BigDecimal originalUnits, BigDecimal originalQuantity, BigDecimal shippedQuantity) {
return amount(defaults(null),rule,inbound,outbound,originalUnits,originalQuantity,shippedQuantity);
@@ -41,9 +41,29 @@ public final class StorageRentCalculator {
if (originalUnits == null || originalUnits.signum() <= 0 || originalQuantity == null || originalQuantity.signum() <= 0
|| shippedQuantity == null || shippedQuantity.signum() <= 0 || shippedQuantity.compareTo(originalQuantity) > 0)
throw new IllegalArgumentException("计租体积或原批次数量不完整");
if ("RETURN".equals(rule)) return returnPalletAmount(pricing, inbound, outbound, originalUnits, 0);
return unitAmount(pricing,rule, days(inbound, outbound)).multiply(originalUnits).multiply(shippedQuantity)
.divide(originalQuantity, 12, RoundingMode.HALF_UP);
}
/** 原托仍有一箱即按整托续租,只累计上次已生成费用之后的阶梯天数。 */
public static BigDecimal returnPalletAmount(Pricing pricing, LocalDateTime inbound, LocalDateTime outbound,
BigDecimal pallets, long chargedDays) {
long currentDays = days(inbound, outbound);
if (pallets == null || pallets.signum() <= 0 || chargedDays < 0 || chargedDays > currentDays)
throw new IllegalArgumentException("退货原托计租数量或已计费天数无效");
return unitAmount(pricing, "RETURN", currentDays).subtract(unitAmount(pricing, "RETURN", chargedDays))
.multiply(pallets.setScale(0, RoundingMode.CEILING));
}
/** 展开真实收费区间的阶梯,免费档也保留,展示金额不采用前端当前报价。 */
public static List<CalculationTier> breakdown(Pricing pricing,String rule,long previousDays,long currentDays,BigDecimal units) {
String type="RETURN".equals(rule)?"RETURN_STORAGE":"STORAGE";
return pricing.tiers().stream().filter(t->type.equals(t.feeType()))
.sorted(java.util.Comparator.comparingInt(PricingTier::startDay))
.map(t->{long start=Math.max(previousDays+1,t.startDay());long end=Math.min(currentDays,t.endDay()==null?Long.MAX_VALUE:t.endDay());
long count=Math.max(0,end-start+1);
return new CalculationTier(start,end,count,t.unitPrice(),t.unitPrice().multiply(BigDecimal.valueOf(count)).multiply(units));})
.filter(t->t.days()>0).toList();
}
/** 两张报价表仅作为明确默认报价及本轮测试构造,服务故障不调用此默认值。 */
public static Pricing defaults(Long customer) {
return new Pricing(null,customer,null,"USD",List.of(
@@ -77,10 +77,39 @@ public class StorageRentReviewService implements StorageRentFeeWorkflow {
&&Objects.equals(lot.getWarehouseId(),rent.getWarehouseId());
return new OverseasFeeSource.RentLine(a.lotId(),a.inboundAt().atOffset(ZoneOffset.UTC),a.quantity(),a.days(),a.amount(),
owned?lot.getBusinessType():null,owned?lot.getBusinessNo():null,owned?lot.getBatchNo():null);
}).toList()));
}).toList(),calculationDetails(plan,rent,fee,lots)));
}
}
/** 按原快照展示计算过程;旧费用仅在同客户同仓原批次基数完整时解释,绝不使用当前报价。 */
private List<OverseasFeeSource.RentCalculation> calculationDetails(Plan plan,StorageRentPreparation rent,OverseasFeeSource fee,
Map<Long,com.baiyue.lastleg.storagerent.entity.StorageRentLot> lots) {
var calculations=new ArrayList<>(plan.calculations());
if(calculations.isEmpty()&&plan.pricing()!=null) {
for(long id:plan.allocations().stream().map(Allocation::lotId).distinct().toList()) {
var lot=lots.get(id);
if(lot==null||!Objects.equals(lot.getCustomerId(),fee.getCustomerId())||!Objects.equals(lot.getWarehouseId(),rent.getWarehouseId())
||lot.getOriginalUnits()==null||lot.getOriginalQuantity()==null||lot.getOriginalQuantity().signum()<=0
||!Set.of("RETURN","TRANSIT").contains(lot.getRuleCode()))continue;
var parts=plan.allocations().stream().filter(a->a.lotId()==id).toList();
boolean whole=parts.get(0).previouslyChargedDays()!=null;
var quantity=parts.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add);
var units=whole?lot.getOriginalUnits().setScale(0,RoundingMode.CEILING)
:lot.getOriginalUnits().multiply(quantity).divide(lot.getOriginalQuantity(),12,RoundingMode.HALF_UP);
long previous=whole?parts.get(0).previouslyChargedDays():0;long days=parts.get(0).days();
calculations.add(new Calculation(id,whole?"RETURN_PALLET":"RETURN".equals(lot.getRuleCode())?"RETURN_SHARE":"TRANSIT",
units,"RETURN".equals(lot.getRuleCode())?"PALLET":"CBM",days,previous,
StorageRentCalculator.breakdown(plan.pricing(),lot.getRuleCode(),previous,days,units),
parts.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add)));
}
}
return calculations.stream().filter(c->{var lot=lots.get(c.lotId());return lot!=null
&&Objects.equals(lot.getCustomerId(),fee.getCustomerId())&&Objects.equals(lot.getWarehouseId(),rent.getWarehouseId());})
.map(c->{var lot=lots.get(c.lotId());return new OverseasFeeSource.RentCalculation(lot.getBusinessNo(),lot.getBatchNo(),c.ruleCode(),
c.billingQuantity(),c.billingUnit(),c.storageDays(),c.previouslyChargedDays(),
c.tiers().stream().map(t->new OverseasFeeSource.RentTier(t.startDay(),t.endDay(),t.days(),t.unitPrice(),t.amount())).toList(),c.amount());}).toList();
}
/** 审核与幂等命令同事务保存;审批通过后才申请按确认金额结算。 */
@Transactional
public OverseasFeeSource review(long id,String operator,String name,ReviewRequest request){
@@ -282,7 +282,7 @@ public class StorageRentService implements WalletResultHandler {
if(value.getAmount().signum()>0){value.setFreezeEventId(wallet.publish("FREEZE",customer(value),WALLET_TYPE,value.getId().toString(),value.getAmount(),"海外仓租预占",1));repository.save(value);}
return result(value);
}
/** 原始扫码事务内校验成功冻结和同一来源份额,任何异常使库存与租金一并回滚。 */
/** 原始扫码事务内校验冻结、来源及原托已计费期间,任何异常使库存与租金一并回滚。 */
@Transactional
public void consume(String warehouse,PrepareRequest request) {
var value=repository.latest(warehouse,request.mode(),request.idempotencyKey());
@@ -301,7 +301,10 @@ public class StorageRentService implements WalletResultHandler {
if(allocation.shareId()!=null){var share=repository.lockShare(allocation.shareId());
if(share==null||share.getQuantity().compareTo(allocation.quantity())<0)throw conflict("退货计租商品份额已变化");
share.setQuantity(share.getQuantity().subtract(allocation.quantity()));repository.save(share);}
lot.setRemainingQuantity(lot.getRemainingQuantity().subtract(allocation.quantity()));lot.setUpdatedAt(plan.calculatedAt());repository.save(lot);
lot.setRemainingQuantity(lot.getRemainingQuantity().subtract(allocation.quantity()));
// 原托行锁与实际出库同事务推进计费截止日,后续箱或其他单据不再收同一天仓租。
if(allocation.previouslyChargedDays()!=null)lot.setChargedDays(allocation.days());
lot.setUpdatedAt(plan.calculatedAt());repository.save(lot);
}
value.setConsumedAt(plan.calculatedAt());value.setUpdatedAt(plan.calculatedAt());
// 出库只生成待审核费用并沿用原冻结;审核通过后才发送真实扣款指令。
@@ -388,10 +391,11 @@ public class StorageRentService implements WalletResultHandler {
}
/** 混合批次同一扫码费用按原冻结原币份额呈现,扣款总额仍归原应收。 */
private ChargeView charge(StorageRentPreparation value,long lotId) {
var allocations=read(value).allocations().stream().filter(a->a.lotId()==lotId).toList();
var snapshot=read(value);var allocations=snapshot.allocations().stream().filter(a->a.lotId()==lotId).toList();
BigDecimal amount=lotAmounts(value,value.getOriginalAmount()).getOrDefault(lotId,BigDecimal.ZERO.setScale(2));
BigDecimal settlement=lotAmounts(value,value.getAmount()).getOrDefault(lotId,BigDecimal.ZERO.setScale(2));
return new ChargeView(value.getId(),value.getExecutionNo(),value.getBusinessNo(),allocations.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add),allocations.stream().mapToLong(Allocation::days).max().orElse(0),amount,read(value).pricing().currency(),settlement,value.getExchangeRate(),value.getStatus(),utc(value.getCreatedAt()),utc(value.getConsumedAt()));
var calculation=snapshot.calculations().stream().filter(c->c.lotId()==lotId).findFirst().orElse(null);
return new ChargeView(value.getId(),value.getExecutionNo(),value.getBusinessNo(),allocations.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add),allocations.stream().mapToLong(Allocation::days).max().orElse(0),amount,snapshot.pricing().currency(),settlement,value.getExchangeRate(),value.getStatus(),utc(value.getCreatedAt()),utc(value.getConsumedAt()),calculation==null?null:calculation.billingQuantity(),calculation==null?null:calculation.billingUnit());
}
/** 原资金总额按批次稳定分配到分,末批承接尾差,确保多批显示合计等于实际扣款。 */
private Map<Long,BigDecimal> lotAmounts(StorageRentPreparation value,BigDecimal amount){
@@ -404,11 +408,17 @@ public class StorageRentService implements WalletResultHandler {
}
/** 新仓租表统一记录 UTC,公开协议携带偏移,避免浏览器当成本地墙钟时间。 */
private static OffsetDateTime utc(LocalDateTime value){return value==null?null:value.atOffset(ZoneOffset.UTC);}
/** 财务费用记本次服务单;余额授信应收不携带仅应付使用的供应商结算方式。 */
/** 财务费用记本次服务单,退货保留整托收费期间,方便核对部分出库的实际计价。 */
private SaveRequest feeRequest(StorageRentPreparation value,Plan plan) {
var owner=plan.owner();String source=switch(owner.businessType()){case "LASTLEG_ORDER"->"OVERSEAS_ORDER_FEE";case "RELABEL_ORDER"->"OVERSEAS_RELABEL_FEE";default->"OVERSEAS_WAREHOUSE_FEE";};
var returnPeriods=plan.allocations().stream().filter(a->a.previouslyChargedDays()!=null)
.map(a->a.days()>a.previouslyChargedDays()
?"第"+(a.previouslyChargedDays()+1)+"–"+a.days()+"计租日":"本计租日已计费,不重复收费")
.distinct().toList();
String remark=returnPeriods.isEmpty()?null:"退货按原托整托计费,余一箱仍计一托;"+String.join(";",returnPeriods);
if(remark!=null&&remark.length()>500)remark=remark.substring(0,499)+"…";
return new SaveRequest(owner.customerId(),owner.customerName(),source,owner.businessType(),owner.businessId(),owner.businessNo(),owner.orderId(),null,owner.inboundCode(),null,null,null,Long.valueOf(owner.warehouseId()),
"FEE_TYPE_077","仓储费", null,"RECEIVABLE",null,null,null,null,null,null,null,null,
"FEE_TYPE_077","仓储费", remark,"RECEIVABLE",null,null,null,null,null,null,null,null,
plan.calculatedAt().toLocalDate(),plan.pricing().currency(),value.getOriginalAmount(),null,null,null,
plan.allocations().stream().map(Allocation::inboundAt).min(Comparator.naturalOrder()).orElseThrow().toLocalDate(),value.getConsumedAt().toLocalDate());
}
@@ -437,11 +447,12 @@ public class StorageRentService implements WalletResultHandler {
if("SETTLE".equals(operation))value.setSettleEventId(event);else value.setReleaseEventId(event);
value.setErrorMessage(null);value.setUpdatedAt(now);repository.save(value);
}
/** 报价重新计算可降低金额,但不能增加已冻结金额或改变原商品份额。 */
/** 原托计费期间变化必须重建预占释放旧冻结,避免两次并发出库重复收整托。 */
private static boolean sameBasis(Plan a,Plan b) {
if(!Objects.equals(a.owner(),b.owner())||a.allocations().size()!=b.allocations().size())return false;
for(int i=0;i<a.allocations().size();i++){var x=a.allocations().get(i);var y=b.allocations().get(i);
if(x.lotId()!=y.lotId()||!Objects.equals(x.shareId(),y.shareId())||x.quantity().compareTo(y.quantity())!=0||x.days()!=y.days())return false;}
if(x.lotId()!=y.lotId()||!Objects.equals(x.shareId(),y.shareId())||x.quantity().compareTo(y.quantity())!=0||x.days()!=y.days()
||!Objects.equals(x.previouslyChargedDays(),y.previouslyChargedDays()))return false;}
return true;
}
/** 返回前端可执行标记;只有已确认冻结或原已消费请求可继续原扫码幂等恢复。 */
@@ -31,11 +31,33 @@ public class StorageRentSourceResolver {
com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway.validate(snapshot,Long.parseLong(plan.owner().customerId()));
return reprice(plan,snapshot);
}
/** 定价与来源解析分离,原批次体积、原托份额及实收时间始终保持不变。 */
/** 中转按份额计价;退货逐原托只收一次未计费期间,不能按件摊薄或逐箱重复收整托。 */
private Plan reprice(Plan plan,Pricing pricing) {
var allocations=plan.allocations().stream().map(a->{var lot=repository.lockLot(a.lotId());
return new Allocation(a.lotId(),a.shareId(),a.quantity(),StorageRentCalculator.amount(pricing,lot.getRuleCode(),lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),lot.getOriginalQuantity(),a.quantity()),a.inboundAt(),a.days());}).toList();
return new Plan(plan.owner(),allocations,plan.calculatedAt(),allocations.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP),pricing);
var allocations=new ArrayList<Allocation>();
var chargedLots=new HashSet<Long>();
for(var a:plan.allocations()) {
var lot=repository.lockLot(a.lotId());
if("RETURN".equals(lot.getRuleCode())) {
long previous=lot.getChargedDays();
var amount=chargedLots.add(a.lotId())
?StorageRentCalculator.returnPalletAmount(pricing,lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),previous)
:BigDecimal.ZERO;
allocations.add(new Allocation(a.lotId(),a.shareId(),a.quantity(),amount,a.inboundAt(),a.days(),previous));
} else allocations.add(new Allocation(a.lotId(),a.shareId(),a.quantity(),StorageRentCalculator.amount(pricing,lot.getRuleCode(),lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),lot.getOriginalQuantity(),a.quantity()),a.inboundAt(),a.days()));
}
var calculations=new ArrayList<Calculation>();
for(long id:allocations.stream().map(Allocation::lotId).distinct().toList()) {
var lot=repository.lockLot(id);var parts=allocations.stream().filter(a->a.lotId()==id).toList();
var quantity=parts.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add);
boolean returns="RETURN".equals(lot.getRuleCode());long previous=returns?lot.getChargedDays():0;
var units=returns?lot.getOriginalUnits().setScale(0,RoundingMode.CEILING)
:lot.getOriginalUnits().multiply(quantity).divide(lot.getOriginalQuantity(),12,RoundingMode.HALF_UP);
long days=parts.get(0).days();
calculations.add(new Calculation(id,returns?"RETURN_PALLET":"TRANSIT",units,returns?"PALLET":"CBM",days,previous,
StorageRentCalculator.breakdown(pricing,lot.getRuleCode(),previous,days,units),
parts.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add)));
}
return new Plan(plan.owner(),allocations,plan.calculatedAt(),allocations.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP),pricing,calculations);
}
/** 先解析真实来源;此阶段默认金额只用于内部数量校验,不作为最终收费依据。 */
private Plan resolveSources(String warehouse,PrepareRequest request,LocalDateTime at) {
@@ -155,7 +177,7 @@ public class StorageRentSourceResolver {
for(int i=0;i<shares.size();i++){var share=shares.get(i);var part=i==shares.size()-1?quantity.subtract(used):quantity.multiply(share.getQuantity()).divide(total,12,RoundingMode.DOWN);used=used.add(part);if(part.signum()>0)result.add(allocation(repository.lockLot(share.getLotId()),share.getId(),part,at));}
return result;
}
/** 退货按当前箱内 SKU 的原托份额计费,部分 SKU 出库不会新增一个计费整托。 */
/** 商品份额仅追溯原托及扣减库存;金额在完整扫码汇总后按原托去重计算。 */
private List<Allocation> returnAllocations(String customer,String warehouse,String mark,String sku,Long quantity,LocalDateTime at) {
var carton=repository.carton(mark);if(carton==null||!customer.equals(carton.getCustomerId())||!warehouse.equals(carton.getOverseasWarehouseId()))throw conflict("原退货箱不属于当前客户和仓库");
var shares=repository.shares(customer,warehouse,mark,sku);var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);
@@ -57,6 +57,14 @@ public class MerchantUsTruckOrderController {
()->service.update(customer(user),orderNo,operator(user),request)));
}
/** 客户只可取消本企业待审核卡车单,并按版本和幂等键处理。 */
@PatchMapping("/{orderNo}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_EDIT + "')")
public ApiResponse<UsTruckOrderResponses.Detail> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) {
return ApiResponse.success(service.changeStatus(customer(user), orderNo, operator(user), request));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
@@ -65,6 +65,7 @@ public class PlatformUsTruckOrderController {
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request));
}
/** 平台不得代客户取消本土卡车订单,保留旧入口的明确拒绝。 */
@PatchMapping("/{orderNo}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')")
@Operation(summary = "Change local truck order status", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS)
@@ -72,7 +73,7 @@ public class PlatformUsTruckOrderController {
@RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) {
requirePermission(user, OverseasPermissions.TRUCK_STATUS);
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "本土卡车订单仅允许客户取消");
}
@PostMapping("/{orderNo}/review")
@@ -24,9 +24,9 @@ public final class UsTruckOrderRequests {
public record Create(
@NotBlank @Pattern(regexp = "PALLET_15|PALLET_30") String vehicleType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank(message = "请填写取货地址") @Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@NotBlank(message = "请填写送货地址") @Size(max = 500) String destinationAddress,
@NotNull @Positive Integer palletCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@@ -64,9 +64,9 @@ public final class UsTruckOrderRequests {
@NotNull @Min(0) Long version,
@NotBlank @Pattern(regexp = "PALLET_15|PALLET_30") String vehicleType,
@Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank(message = "请填写取货地址") @Size(max = 500) String originAddress,
@Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@NotBlank(message = "请填写送货地址") @Size(max = 500) String destinationAddress,
@NotNull @Positive Integer palletCount,
@DecimalMin("0.00") BigDecimal totalWeightKg,
@DecimalMin("0.00") BigDecimal totalVolumeCbm,
@@ -10,7 +10,8 @@ import java.util.List;
@Mapper
public interface UsTruckOrderStatusHistoryMapper extends BaseMapper<UsTruckOrderStatusHistory> {
@Select("SELECT * FROM ll_us_truck_order_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id")
/** 按操作时间及同刻流水倒序展示卡车订单记录。 */
@Select("SELECT * FROM ll_us_truck_order_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at DESC,id DESC")
List<UsTruckOrderStatusHistory> listOrder(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
}
@@ -54,8 +54,7 @@ public class UsTruckOrderService {
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, FUNDING_PENDING, FUNDING_FAILED, WAITING_INBOUND, RECEIVING,
WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED,
PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED);
private static final Set<String> CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH);
private static final Set<String> CANCELLABLE = Set.of(PENDING_REVIEW);
private static final Set<String> VEHICLE_TYPES = Set.of("PALLET_15", "PALLET_30");
@Resource private com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository repository;
@@ -169,6 +168,8 @@ public class UsTruckOrderService {
if (!CANCELLED.equals(norm(request.status())) || !CANCELLABLE.contains(order.getStatus())) {
throw conflict("Only cancellable truck orders can use the generic status endpoint");
}
// 取消待审核订单时同步申请释放预算预占,避免客户资金继续冻结。
if (orderBudget != null) orderBudget.releaseAfterReview(customerId, "trucking", orderNo);
return transition(order, operatorId, "CANCEL", request.idempotencyKey(), request,
order.getStatus(), CANCELLED, request.remark());
}
@@ -2879,7 +2879,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
created_by VARCHAR(128) NULL COMMENT '创建人ID', created_by_name VARCHAR(128) NULL COMMENT '申请人姓名快照', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希',
reviewed_at DATETIME(3) NULL COMMENT '审核时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
@@ -4493,6 +4493,7 @@ CREATE TABLE IF NOT EXISTS ll_storage_rent_lot (
original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量',
remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量',
original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数',
charged_days bigint NOT NULL DEFAULT 0 COMMENT '退货原托已生成仓租的截止库龄天数,同一天不重复计费',
inbound_at datetime(3) NOT NULL COMMENT '实际入库时间',
created_at datetime(3) NOT NULL COMMENT '创建时间',
updated_at datetime(3) NOT NULL COMMENT '更新时间',
@@ -15,14 +15,16 @@ info:
Cancellation must use the discard application workflow. PDA resolve optionally accepts
discardNo together with palletNo; ambiguous scans require selection of a discard application.
Candidates include INVENTORY positions from every document type with available warehouse stock,
plus DROPSHIP and RETURN_CARTON sources. Create items identify exactly one of orderId,
returnCartonId or inventoryPositionId and carry selected cartonIds for partial whole-carton disposal; quantities must match those IDs.
plus RETURN_CARTON sources; one-click dropship orders and positions are excluded.
Create items identify exactly one of returnCartonId or inventoryPositionId and carry selected cartonIds for partial whole-carton disposal; quantities must match those IDs.
RETURN_CARTON selections consume every SKU in the selected physical carton.
Candidates return cartons, cartonSelectionSupported and cartonSelectionBlockReason.
Stock without complete carton/pallet ownership remains available for full disposal only.
PDA tasks with real cartonMarks use CARTON scanMode, including full bound-pallet tasks.
Every distinct selected carton mark must be scanned; confirmAllCartons cannot bypass this check.
Ordinary stock without carton marks uses PALLET mode and actual carton quantities.
Ordinary stock without selected carton marks uses PALLET mode and actual carton quantities.
Each SKU returns allowedCartonMarks from the source inbound cartons or net stock-in ledger;
PDA scans must match this allowlist immediately and confirmation revalidates ownership.
The loose group uses 散箱 as its API path key, never as a scan code.
The application may select a subset of available cartons. PDA must finish every selected carton;
short actual quantities are rejected. At least one photo remains required.
@@ -35,6 +37,18 @@ info:
security:
- bearerAuth: []
paths:
/api/pda/v1/last-leg/cargo-discards:
get:
summary: 查询当前绑定仓已审核废弃任务
description: 返回 PalletTask 数组;skus.allowedCartonMarks 为当前申请明细原入仓库存允许扫描的箱唛,无归属的箱唛不得计数或确认;未知箱唛不降级为自由扫码。
responses:
'200': { description: 当前绑定仓废弃任务及明细箱唛白名单 }
/api/pda/v1/last-leg/cargo-discards/resolve:
post:
summary: 按废弃单及托盘定位任务
description: 返回 PalletTask,包括各明细的 allowedCartonMarks 供即时扫码归属校验;提交确认时服务端再次核验。
responses:
'200': { description: 当前废弃单托盘任务及明细箱唛白名单 }
/api/merchant/v1/last-leg/cargo-discards/{discardNo}/confirm-completion:
parameters:
- $ref: '#/components/parameters/DiscardNo'
@@ -105,7 +119,7 @@ paths:
required: [itemId, actualPackages]
properties:
itemId: { type: string, pattern: '^[1-9][0-9]*$' }
scannedCartonMarks: { type: array, uniqueItems: true, items: { type: string, minLength: 1, maxLength: 128 }, description: 无关联库存现场箱唛清单,数量须与actualPackages一致,整单跨托盘不重复,详情以actualCartonMarks回显 }
scannedCartonMarks: { type: array, uniqueItems: true, items: { type: string, minLength: 1, maxLength: 128 }, description: 无关联库存现场箱唛清单,数量须与actualPackages一致,整单跨托盘不重复;每个箱唛必须能溯源到本单明细原入仓库存,未知或外单箱唛拒绝;详情以actualCartonMarks回显 }
actualPackages: { type: integer, minimum: 0, description: 无精确箱唛对应关系的普通库存允许0至plannedPackages的实际箱数,剩余预留释放;有具体选箱的明细必须等于plannedPackages }
scannedCartonMarks:
type: array
@@ -124,7 +138,7 @@ paths:
uniqueItems: true
items: { type: string, pattern: '^[1-9][0-9]*$' }
responses:
'200': { description: ApiResponse containing the completed PalletTask; conflicts use business body code }
'200': { description: ApiResponse containing the completed PalletTask; skus.allowedCartonMarks provides the source-scoped scan allowlist for immediate PDA validation; conflicts use business body code }
/api/platform/v1/last-leg/cargo-discards/{discardNo}/photos/files/{fileId}/download:
parameters:
- $ref: '#/components/parameters/DiscardNo'
@@ -2665,13 +2665,13 @@ paths:
responses: { '200': { description: 当前客户货物废弃申请分页 }, '403': { description: 缺少货物废弃查看权限 } }
post:
summary: 客户创建货物废弃申请
description: 权限 merchant:overseas-warehouse:discard:create;客户范围只取认证 Token。
description: 权限 merchant:overseas-warehouse:discard:create;客户范围只取认证 Token;仅支持普通库存与退货库存,拒绝一件代发订单,申请人 createdByName 从认证账号姓名保存快照。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 创建后的货物废弃申请 }, '403': { description: 缺少货物废弃创建权限 }, '409': { description: 库存或状态冲突 } }
/api/merchant/v1/last-leg/cargo-discards/inventory-candidates:
get:
summary: 查询当前客户可废弃库存候选
description: 权限 merchant:overseas-warehouse:discard:list;候选按认证客户隔离。
description: 权限 merchant:overseas-warehouse:discard:list;候选按认证客户隔离,过滤一件代发订单与库存仓位。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 可废弃库存候选 }, '403': { description: 缺少货物废弃查看权限 } }
/api/merchant/v1/last-leg/cargo-discards/{discardNo}:
@@ -2837,16 +2837,25 @@ paths:
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckUpdate' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } }
/api/merchant/v1/last-leg/us-truck-orders/{orderNo}/status:
patch:
summary: 客户取消待审核本土卡车订单
description: 权限 merchant:cross-border:domestic-trucking:edit;客户范围取当前身份,仅 PENDING_REVIEW 可取消,携带版本、幂等键和取消原因,同时释放预算预占。
security: [{ bearerAuth: [] }]
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCancel' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 }, '409': { description: 当前订单不可取消或版本已变化 } }
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/status:
patch:
summary: 取消未发车的本土卡车订单
description: 权限 platform:overseas-business:truck:status;该通用入口只接受 CANCELLED,正向状态必须使用具名命令,不能绕过实收、超托与分配事实。
summary: 平台取消入口已禁止
description: 平台不得取消本土卡车订单,旧入口始终拒绝;由客户待审核阶段自行取消。
security: [{ bearerAuth: [] }]
parameters:
- { name: orderNo, in: path, required: true, schema: { type: string } }
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCancel' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 }, '409': { description: 非法状态迁移 } }
responses: { '403': { description: 平台不能取消本土卡车订单 } }
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/review:
parameters: &truckOrderTarget
- { name: orderNo, in: path, required: true, schema: { type: string } }
@@ -5240,13 +5249,13 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 }
TruckCreate:
type: object
required: [vehicleType, origin, destination, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount, idempotencyKey]
required: [vehicleType, origin, originAddress, destination, destinationAddress, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount, idempotencyKey]
properties:
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
originAddress: { type: string, minLength: 1, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
destinationAddress: { type: string, minLength: 1, maxLength: 500 }
palletCount: { type: integer, minimum: 1, description: '下单预估托数' }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
@@ -5262,14 +5271,14 @@ components:
idempotencyKey: { type: string, maxLength: 255 }
TruckUpdate:
type: object
required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount]
required: [version, vehicleType, originAddress, destinationAddress, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount]
properties:
version: { type: integer, format: int64, minimum: 0 }
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
originAddress: { type: string, minLength: 1, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
destinationAddress: { type: string, minLength: 1, maxLength: 500 }
palletCount: { type: integer, minimum: 1, description: '下单预估托数' }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
@@ -1,5 +1,5 @@
openapi: 3.0.3
info: { title: Overseas Business Fee Source API, version: 1.1.0, description: '首版除仓租外均人工登记审核;换标报价、外接运费及拆柜成本不自动收费;旧资金链同项目已锁款或收取时拒绝重复登记。' }
info: { title: Overseas Business Fee Source API, version: 1.1.0, description: '支持人工登记费用(含仓租)并审核;自动仓租继续按出库计租流程生成;换标报价、外接运费及拆柜成本不自动收费;旧资金链同项目已锁款或收取时拒绝重复登记。' }
paths:
/api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/retries:
get:
@@ -184,6 +184,30 @@ components:
outboundAt: {type: string, format: date-time}
calculatedAmount: {type: number, description: 原快照计算金额,币种为originalCurrency}
chargedAmount: {type: number, nullable: true, description: 实际扣费CNY,未成功时为null}
calculations:
type: array
description: 逐原批次的实际计算过程;新费用取冻结快照,历史费用仅在原报价和计租基数完整时解释,不使用当前报价。
items:
type: object
properties:
sourceBusinessNo: {type: string, nullable: true}
batchNo: {type: string, nullable: true}
ruleCode: {type: string, enum: [TRANSIT, RETURN_PALLET, RETURN_SHARE], description: 中转体积、新退货整托、历史退货份额计价}
billingQuantity: {type: number, description: 实际用于乘单价的CBM或托数,区别于出库商品件数}
billingUnit: {type: string, enum: [CBM, PALLET]}
storageDays: {type: integer, format: int64, description: 原入库时刻起累计库龄}
previouslyChargedDays: {type: integer, format: int64, description: 本次计算前该原托已生成费用的截止库龄日,不重复收费}
calculatedAmount: {type: number, description: 原批次未按分取整的计算金额,费用合计统一按分取整}
tiers:
type: array
items:
type: object
properties:
startDay: {type: integer, format: int64}
endDay: {type: integer, format: int64}
days: {type: integer, format: int64}
unitPrice: {type: number, description: 原报价快照中每托或每CBM每天单价}
amount: {type: number, description: days乘billingQuantity乘unitPrice,保留计算精度}
lines:
type: array
items:
@@ -248,7 +272,7 @@ components:
inboundCode: { type: string, maxLength: 64, nullable: true }
feeItemCode:
type: string
description: 必须选择共享费用目录的有效编码,人工录费禁止自动仓租项目FEE_TYPE_077。OVERSEAS_CONTAINER时同时符合OverseasContainerFeeItem枚举。
description: 必须选择共享费用目录的有效编码,支持人工录入仓租项目FEE_TYPE_077并沿普通人工费用审核及收付流程处理。OVERSEAS_CONTAINER时同时符合OverseasContainerFeeItem枚举。
feeItemName: { type: string }
feeRemark: { type: string, maxLength: 500, nullable: true, description: 费用说明;同单同方向已有相同项目时必填,允许实际作业分次收费。已驳回原费用终结,只能重新新增。 }
direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
@@ -3,10 +3,12 @@ info:
title: 海外仓租与出库资金预校验
version: 1.0.0
description: |
仅仓租自动计费。中转按CBM/天、退货按原托份额/天累进,首次或重建预校验读取客户当前仓租配置。
仅仓租自动计费。中转按CBM/天、退货按原托整托/天累进;原托只要剩余一箱仍计一托,不按件数分摊仓租。
退货部分出库生成该原托截至本计租日的未计费期间仓租,同一原托同一天不重复收费,清空后停止计租。
首次或重建预校验读取客户当前仓租配置,退货默认阶梯为第1–30天1.5、第31–60天2、第61–90天3、第91天起4 USD/托/天。
未配置客户使用仓库服务明确返回的两张默认报价表;网络、无效配置或不支持币种均阻断出库,不降级免费。
PlanJson保存配置ID、版本、币种和完整阶梯。有效预占重放与consume使用原快照及冻结金额,不受期间改价影响。
后续部分出库采用客户最新配置,原入库时间与原托份额保持不变。原币支持USD/CNY,结算币为CNY。
后续部分出库采用客户最新配置,原入库时间及阶梯库龄保持不变;退货只计尚未生成仓租的新增库龄。原币支持USD/CNY,结算币为CNY。
实际PDA入库开始计租,实际出库按本次数量生成待审核费用,审核通过后按确认金额扣款;所有海外仓租从余额及有效授信扣除。
头程同单同仓多次入库合并计租,统一沿用首次实际入库时间;其他业务按各批实际入库时间分别计龄。
每满24小时一天,不足24小时向上取整。公开时间均为ISO8601 UTC时间,携带Z,客户端按本地时区显示。
@@ -183,6 +185,8 @@ components:
currency: {type: string, enum: [USD, CNY]}
settlementAmount: {type: number, description: CNY结算金额}
currencyRate: {type: number, nullable: true}
billingQuantity: {type: number, nullable: true, description: 本次真实计租基数,来自原费用快照,整托退货不再按件数摊薄;历史未记录时为空}
billingUnit: {type: string, nullable: true, enum: [CBM, PALLET]}
status: {type: string, enum: [PENDING_REVIEW, REVIEW_FREEZE_PENDING, REVIEW_FREEZE_FAILED, REJECTED, CONSUMED, SETTLE_FAILED, SETTLED]}
createdAt: {type: string, format: date-time}
outboundAt: {type: string, format: date-time}
@@ -67,14 +67,14 @@ class CargoDiscardMysqlAcceptanceTest {
new CargoDiscardRequests.Item(990001001L, null, "SKU-BOX-RED", 3),
new CargoDiscardRequests.Item(990001001L, null, "SKU-RACK-KITCHEN", 2)),
"ACCEPT-DISCARD-APPROVE", "验收:破损货物,同意废弃");
var approved = service.create(CUSTOMER, "acceptance-creator", approveRequest);
var approved = service.create(CUSTOMER, "acceptance-creator", "验收申请人", approveRequest);
assertEquals(20L, skuPackages(990001001L));
approved = service.review(CUSTOMER, approved.summary().discardNo(), "acceptance-reviewer",
new CargoDiscardRequests.Review("APPROVE", "仓库复核破损属实"));
assertEquals(CargoDiscardService.APPROVED, approved.summary().status());
assertEquals(15L, skuPackages(990001001L));
var rejected = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(
var rejected = service.create(CUSTOMER, "acceptance-creator", "验收申请人", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(990001002L, null, "SKU-LAMP-WHITE", 4)),
"ACCEPT-DISCARD-REJECT", "验收:外箱可修复"));
assertEquals(10L, skuPackages(990001002L));
@@ -83,13 +83,13 @@ class CargoDiscardMysqlAcceptanceTest {
assertEquals(CargoDiscardService.REJECTED, rejected.summary().status());
assertEquals(10L, skuPackages(990001002L));
var pending = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(
var pending = service.create(CUSTOMER, "acceptance-creator", "验收申请人", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(990001003L, null, "SKU-MAT-BLUE", 2)),
"ACCEPT-DISCARD-PENDING", "验收:等待仓库审核"));
assertEquals(CargoDiscardService.PENDING_REVIEW, pending.summary().status());
assertEquals(7L, skuPackages(990001003L));
assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator",
assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator", "验收申请人",
new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(990001003L, null, "SKU-MAT-BLUE", 99)),
"ACCEPT-DISCARD-INSUFFICIENT", "验收:库存不足应失败")));
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** 用真实 Mapper 查询验证废弃扫码的来源隔离和已移出箱拦截。 */
class CargoDiscardTraceableCartonMapperTest {
/** 原入仓箱和净实收流水可溯源,其他订单、客户、托盘及已移出箱均不得混入。 */
@Test void onlyCurrentSourceCartonsAreTraceable() {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(source);
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,pallet_id BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,destination_warehouse_code VARCHAR(20))");
db.execute("CREATE TABLE ll_inventory_ledger(customer_id VARCHAR(20),position_id BIGINT,balance_id BIGINT,inbound_order_id BIGINT,carton_mark VARCHAR(80),delta_packages BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan_carton(customer_id VARCHAR(20),order_id BIGINT,warehouse_pallet_id BIGINT,received_at TIMESTAMP,status VARCHAR(30),carton_mark VARCHAR(80),destination_warehouse_code VARCHAR(20))");
db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100)");
db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA')");
db.update("INSERT INTO ll_inventory_ledger VALUES('C',41,1,10,'LEDGER',1),('C',41,1,10,'MOVED',1),('C',41,1,10,'MOVED',-1),('C',41,1,20,'FOREIGN-ORDER',1),('X',41,1,10,'FOREIGN-CUSTOMER',1)");
db.update("INSERT INTO ll_inbound_plan_carton VALUES('C',10,100,CURRENT_TIMESTAMP,'AVAILABLE','INBOUND','FBA'),('C',10,200,CURRENT_TIMESTAMP,'AVAILABLE','OTHER-PALLET','FBA'),('C',20,100,CURRENT_TIMESTAMP,'AVAILABLE','OTHER-ORDER','FBA'),('C',10,100,CURRENT_TIMESTAMP,'DISPATCHED','SHIPPED','FBA')");
var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
config.addMapper(CargoDiscardInventoryMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper = session.getMapper(CargoDiscardInventoryMapper.class);
assertEquals(java.util.Set.of("LEDGER", "INBOUND"), new java.util.HashSet<>(mapper.traceableCartonMarks("C", 41L)));
assertTrue(mapper.traceableCartonMarks("X", 41L).isEmpty());
}
}
}
@@ -42,6 +42,7 @@ class PdaCargoDiscardServiceTest {
@BeforeEach void setup() {
service = new PdaCargoDiscardService();
ReflectionTestUtils.setField(service, "stockService", mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class));
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository();
ReflectionTestUtils.setField(service, "repository", repository);
@@ -127,6 +127,7 @@ class CargoDiscardServiceTest {
lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(
new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE"))));
ReflectionTestUtils.setField(service, "outboundService", outboundService);
ReflectionTestUtils.setField(service, "profiles", org.mockito.Mockito.mock(com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper.class));
ReflectionTestUtils.setField(service, "stockService", org.mockito.Mockito.mock(CargoDiscardStockService.class));
ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class));
ReflectionTestUtils.setField(service, "applications", applications);
@@ -152,28 +153,11 @@ class CargoDiscardServiceTest {
lenient().when(histories.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedHistories));
}
@Test
void createReservesExactSkuCartonsWithoutDeductingInventory() {
LastlegOrder order = order();
order.setOverseasWarehouseId("99"); order.setOverseasWarehouseName(null);
DropshipSkuInventory sku = skuInventory();
when(orders.selectById(101L)).thenReturn(order);
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(2L);
when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku);
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(2L);
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "discard-create-1", "damaged"));
assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status());
assertEquals("洛杉矶仓", storedApplication.get().getOverseasWarehouseName());
assertEquals(2, detail.summary().totalPackages());
assertEquals(new BigDecimal("10.00"), detail.summary().totalFee());
assertEquals(2, storedLinks.size());
verify(orders, never()).insert(any(LastlegOrder.class));
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
/** 新建废弃不能通过旧订单参数绕过一件代发过滤。 */
@Test void dropshipCreationIsRejectedBeforeReservation() {
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () ->
service.create("100", "7", null, new CargoDiscardRequests.Create(List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "key", null)));
verify(applications, never()).insert(any(CargoDiscardApplication.class));
}
@Test void historicalDiscardDetailResolvesMissingWarehouseNameByOperatingWarehouseId() {
@@ -185,41 +169,10 @@ class CargoDiscardServiceTest {
}
@Test void selectedBoxesCreatePartialApplicationWithoutReservingUnselectedStock() {
when(orders.selectById(101L)).thenReturn(order());
when(inventory.lockSelected("100", 101L, "SKU-1", List.of(2L))).thenReturn(List.of(carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(skuInventory());
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L);
when(inventory.availableOrderCartons("100", 101L)).thenReturn(List.of(carton(1L), carton(2L), carton(3L)));
when(inventory.changeCartonStatus("100", 2L, "AVAILABLE", "DISCARD_PENDING")).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 1, null, List.of(2L))), "partial-create", "damage"));
assertEquals(1, detail.summary().totalPackages()); assertEquals(new BigDecimal("5.00"), detail.summary().totalFee());
assertEquals(List.of("C-2"), detail.items().get(0).cartonMarks());
assertEquals(1, storedLinks.size()); assertEquals(2L, storedLinks.get(0).getCartonId());
org.junit.jupiter.api.Assertions.assertTrue(storedItems.get(0).getCartonScanRequired());
verify(inventory, never()).changeCartonStatus(eq("100"), eq(1L), any(), any());
verify(inventory, never()).changeCartonStatus(eq("100"), eq(3L), any(), any());
}
@Test void duplicatePhysicalBoxAcrossSkuLinesIsRejectedBeforeCreation() {
when(orders.selectById(101L)).thenReturn(order());
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-A", 1, null, List.of(2L)),
new CargoDiscardRequests.Item(101L, null, "SKU-B", 1, null, List.of(2L))), "duplicate-box", null)));
verify(applications, never()).insert(any(CargoDiscardApplication.class));
}
@Test
void candidateTotalCountsPhysicalCartonsInsteadOfSkuRows() {
CargoDiscardCandidateRow skuA = candidate("SKU-A", 20L);
CargoDiscardCandidateRow skuB = candidate("SKU-B", 20L);
when(inventory.candidates("100", "RK-1")).thenReturn(List.of(skuA, skuB));
when(inventory.countAvailableOrder("100", 101L)).thenReturn(20L);
var result = service.candidates("100", "RK-1");
assertEquals(1, result.size());
assertEquals(20, result.get(0).availablePackages());
assertEquals(2, result.get(0).skus().size());
/** 即使存在一件代发可用箱,也不向货物废弃候选暴露。 */
@Test void candidatesExcludeDropshipOrders() {
assertEquals(List.of(), service.candidates("100", "RK-1"));
verify(inventory, never()).candidates(any(), any());
}
@Test
@@ -264,15 +217,20 @@ class CargoDiscardServiceTest {
when(returnCartonItems.lockSku(901L, "B")).thenReturn(skuB);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
when(returnCartons.markDiscardPending(901L, "100")).thenReturn(1);
var detail = service.create("100", "OP", new CargoDiscardRequests.Create(List.of(
var detail = service.create("100", "OP", "申请人姓名", new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(null, 901L, "A", 2),
new CargoDiscardRequests.Item(null, 901L, "B", 3)), "return-five-pieces", null));
assertEquals("申请人姓名", detail.summary().createdByName());
assertEquals(1, storedApplication.get().getTotalPackages());
assertEquals(new BigDecimal("5.00"), storedApplication.get().getTotalFee());
assertEquals(1, detail.summary().totalPackages());
assertEquals(5, storedItems.stream().mapToInt(CargoDiscardItem::getDiscardPackages).sum());
storedApplication.get().setTotalPackages(5); storedApplication.get().setTotalFee(new BigDecimal("25.00"));
carton.setPalletCode("RETURN-PALLET");
storedItems.forEach(line -> { line.setPdaStatus("COMPLETED"); line.setActualDiscardPackages(line.getDiscardPackages()); });
var existing = service.detail("100", storedApplication.get().getDiscardNo());
assertEquals("RETURN-PALLET", existing.items().get(0).palletNo());
assertEquals(List.of("BOX-1"), existing.items().get(0).actualCartonMarks());
assertEquals(1, existing.summary().totalPackages());
assertEquals(new BigDecimal("5.00"), existing.summary().totalFee());
}
@@ -54,6 +54,15 @@ class CargoDiscardStockServiceTest {
when(stock.lockGenericPosition("C1", 42L)).thenReturn(position);
when(balances.selectById(51L)).thenReturn(balance);
}
/** 非本单原入仓箱唛必须在任何扣库存前拒绝。 */
@Test void unknownAndForeignCartonsCannotPassQuantityScanning() {
var line = new CargoDiscardItem(); line.setInventoryPositionId(42L);
when(stock.traceableCartonMarks("C1",42L)).thenReturn(List.of("OWN-BOX"));
service.validateActualCartonMarks("C1", line, List.of("OWN-BOX"));
assertThrows(ResponseStatusException.class, () -> service.validateActualCartonMarks("C1", line, List.of("RANDOM")));
assertThrows(ResponseStatusException.class, () -> service.validateActualCartonMarks("OTHER", line, List.of("OWN-BOX")));
verifyNoInteractions(commands);
}
@Test void warehouseTransferStockIsEligibleAndKeepsTrueSourcePosition() {
when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position));
var candidate = service.candidates("C1", "WT").get(0);
@@ -28,6 +28,8 @@ class DispatchDeparturePersistenceTest {
var b=executor.submit(()->fixture.tryDepart(start,new Actor("picker",ActorMode.PDA,"9"),pdaRequest));
start.countDown();assertThat(List.of(a.get(20,TimeUnit.SECONDS),b.get(20,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
} finally {executor.shutdownNow();}
// 发车属于作业阶段变化,单据仍有效;不得重新写入旧版DEPARTED状态。
assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_dispatch_order",String.class)).isEqualTo("ACTIVE");
assertThat(fixture.f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("DELIVERING");
assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
assertThat(fixture.f.db.queryForObject("SELECT shipped_package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(8);
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.BusinessSource;
import com.baiyue.lastleg.dispatch.repository.*;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.port.DispatchOutboundPort;
import com.baiyue.lastleg.fleet.trip.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -42,6 +42,18 @@ class TransportTaskServiceTest {
assertThatThrownBy(()->service.cancel("PC1","OP",new FleetTripModels.Cancel(3L,"取消"))).hasMessage("已进入运输取消");
verify(trips).cancel(eq("TR1"),eq("OP"),any());
}
/** 过期计划不阻止审核前取消;取消不重新核验司机或运输时间。 */
@Test void expiredLocalTruckPlanCanStillCancel() {
var root=roots.find("PC1");root.setStage("BEFORE_LOADING");root.setReviewState("DRAFT");
when(roots.lock("PC1")).thenReturn(root);
trip.setStatus("PLANNED");
trip.setPlannedDepartureAt(LocalDateTime.now(ZoneOffset.UTC).minusDays(2));
trip.setExpectedDriverEndAt(LocalDateTime.now(ZoneOffset.UTC).minusDays(1));
doAnswer(call->{trip.setStatus("CANCELLED");return null;}).when(trips).cancel(eq("TR1"),eq("OP"),any());
service.cancel("PC1","OP",new FleetTripModels.Cancel(3L,"取消过期计划"));
assertThat(trip.getStatus()).isEqualTo("CANCELLED");
verify(trips).cancel(eq("TR1"),eq("OP"),any());
}
/** 本土卡车审核仍拦截执行中任务、无效时间和未维护车辆,不因分流而放松门禁。 */
@Test void localTruckReviewRejectsInvalidExecutionTimeAndMissingResources() {
assertThatThrownBy(()->service.verifyLocalTruckReview("PC1")).hasMessageContaining("未发车");
@@ -390,16 +390,30 @@ class OverseasManualFeeServiceTest {
verifyNoInteractions(outbox);
}
/** 自动仓租项目不能通过人工入口重复建应收或锁款。 */
@Test void automaticRentItemCannotBeCreatedManually() throws Exception {
/** 人工仓租沿人工费用资金身份冻结和审核,不进入自动仓租链。 */
@Test void storageRentItemCanBeCreatedManually() throws Exception {
var mapper = new ObjectMapper().findAndRegisterModules();
com.fasterxml.jackson.databind.node.ObjectNode data = mapper.valueToTree(
OverseasFeeBusinessGuardTest.containerFee("10001", 701L, "MSCU1234567"));
data.put("feeItemCode", "FEE_TYPE_077"); data.put("feeItemName", "仓储费");
var request = mapper.treeToValue(data, com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class);
assertThatThrownBy(() -> service.create("operator", "manual-rent", request))
.hasMessageContaining("仓租自动计算");
verifyNoInteractions(sources, repository, catalog, wallet, outbox);
fee.setFeeItemCode("FEE_TYPE_077"); fee.setFeeItemName("仓储费");
when(sources.create("operator", "manual-rent", request)).thenReturn(fee);
when(repository.lockFunding(701L)).thenReturn(null);
var created = service.create("operator", "manual-rent", request);
assertThat(created.getReviewStatus()).isEqualTo("PENDING");
assertThat(StorageRentFeeWorkflow.isRent(created)).isFalse();
verify(catalog).requireActive("FEE_TYPE_077", "仓储费", request.direction());
var captured = ArgumentCaptor.forClass(OverseasFeeFunding.class);
verify(repository).insertFunding(captured.capture());
verify(wallet).publish(eq("FREEZE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), eq("701-1"),
eq(new BigDecimal("88.00")), anyString(), eq(1L));
captured.getValue().setWalletStatus("FROZEN");
when(repository.lockFunding(701L)).thenReturn(captured.getValue());
service.review(701L, "reviewer", new ReviewRequest("APPROVE", null, "manual-rent-approve"));
verify(wallet).publish(eq("SETTLE"), eq(10001L), eq("OVERSEAS_MANUAL_FEE"), eq("701-1"),
isNull(), anyString(), eq(2L));
verifyNoInteractions(outbox);
}
@Test void createReplayDoesNotSendSecondFreezeCommand() {
@@ -46,13 +46,37 @@ class StorageRentCalculatorTest {
assertThat(amount("TRANSIT", 31, "2", "10", "6")).isEqualByComparingTo("23.28");
}
/** 已进位的一托按原商品比例分摊,剩余六成不能再次进位成一托。 */
/** 退货不足一托也按整托,部分商品出库不能按件数摊薄原托仓租。 */
@Test
void apportionsTheOriginalRoundedPalletAcrossShipments() {
assertThat(amount("RETURN", 10, "1", "100", "40")).isEqualByComparingTo("6");
assertThat(amount("RETURN", 40, "1", "100", "60")).isEqualByComparingTo("39");
var thirds = amount("RETURN", 10, "1", "3", "1");
assertThat(thirds.multiply(new BigDecimal("3"))).isEqualByComparingTo("15");
void chargesWholePalletEvenWhenOnlyOnePieceLeaves() {
assertThat(amount("RETURN", 10, "1", "100", "40")).isEqualByComparingTo("15");
assertThat(amount("RETURN", 40, "1", "100", "1")).isEqualByComparingTo("65");
assertThat(amount("RETURN", 1, "0.2", "16", "9")).isEqualByComparingTo("1.5");
}
/** 部分出库后仅对新增库龄收整托阶梯价,同一计租日不重复收取。 */
@Test
void chargesOnlyUnbilledDaysAcrossAllReturnTiers() {
var pricing = StorageRentCalculator.defaults(null);
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(10), BigDecimal.ONE, 0)).isEqualByComparingTo("15");
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(10), BigDecimal.ONE, 10)).isZero();
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(40), BigDecimal.ONE, 10)).isEqualByComparingTo("50");
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(61), BigDecimal.ONE, 40)).isEqualByComparingTo("43");
assertThat(StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(91), BigDecimal.ONE, 61)).isEqualByComparingTo("91");
assertThatThrownBy(() -> StorageRentCalculator.returnPalletAmount(pricing, inbound, inbound.plusDays(1), BigDecimal.ONE, 2))
.isInstanceOf(IllegalArgumentException.class);
}
/** 解释明细必须截取实际未计费天数,跨阶梯分行,免费区间也能解释为零。 */
@Test void explainsTierDaysUnitsAndUnroundedAmounts() {
var pricing=StorageRentCalculator.defaults(null);
var tiers=StorageRentCalculator.breakdown(pricing,"RETURN",10,40,BigDecimal.ONE);
assertThat(tiers).extracting(com.baiyue.lastleg.storagerent.dto.StorageRentDtos.CalculationTier::days).containsExactly(20L,10L);
assertThat(tiers).extracting(com.baiyue.lastleg.storagerent.dto.StorageRentDtos.CalculationTier::amount)
.usingComparatorForType(BigDecimal::compareTo,BigDecimal.class).containsExactly(new BigDecimal("30"),new BigDecimal("20"));
assertThat(StorageRentCalculator.breakdown(pricing,"TRANSIT",0,7,new BigDecimal("0.8"))).hasSize(1)
.allSatisfy(t->assertThat(t.amount()).isZero());
assertThat(StorageRentCalculator.breakdown(pricing,"RETURN",1,1,BigDecimal.ONE)).isEmpty();
}
/** 缺失体积、逆序时间及超出原批次的数量不能被当成零费用放行。 */
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.storagerent.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.storagerent.entity.StorageRentLot;
import com.baiyue.lastleg.storagerent.mapper.StorageRentLotMapper;
import com.baiyue.lastleg.storagerent.repository.StorageRentRepository;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
/** 真实 Mapper 和行锁验证整托计费截止日与出库数量的原子性。 */
class StorageRentPalletPeriodPersistenceTest {
/** 两个同时出库事务只生成一份当日整托租金,失败事务不能推进计费日或扣余量。 */
@Test void serializesWholePalletDaysAndRollsBackFailedOutbound() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
String baseline;
try(var input=getClass().getResourceAsStream("/db/lastleg.sql")) {baseline=new String(input.readAllBytes(),StandardCharsets.UTF_8);}
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_storage_rent_lot (");
String ddl=baseline.substring(start,baseline.indexOf(';',start));
ddl=ddl.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?","");
ddl=ddl.substring(0,ddl.indexOf(") ENGINE="))+")";ddl=ddl.replaceAll(",\\s*\\)$",")");
new JdbcTemplate(ds).execute(ddl);
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(StorageRentLotMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(StorageRentLotMapper.class);
var repository=new StorageRentRepository();ReflectionTestUtils.setField(repository,"lots",mapper);
var tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
var inbound=LocalDateTime.of(2026,11,2,10,0);
var lot=new StorageRentLot();lot.setId(1L);lot.setSourceKey("RETURN:1");lot.setCustomerId("10");lot.setWarehouseId("20");
lot.setBusinessType("RETURN_ORDER");lot.setBusinessId(1L);lot.setBusinessNo("TH1");lot.setRuleCode("RETURN");
lot.setOriginalQuantity(new BigDecimal("16"));lot.setRemainingQuantity(new BigDecimal("16"));lot.setOriginalUnits(BigDecimal.ONE);
lot.setInboundAt(inbound);lot.setCreatedAt(inbound);lot.setUpdatedAt(inbound);repository.insert(lot);
assertThatThrownBy(()->tx.execute(status->{var locked=repository.lockLot(1L);locked.setChargedDays(1L);
locked.setRemainingQuantity(new BigDecimal("15"));repository.save(locked);throw new IllegalStateException("出库失败");}))
.hasMessageContaining("出库失败");
assertThat(repository.lot(1L).getChargedDays()).isZero();assertThat(repository.lot(1L).getRemainingQuantity()).isEqualByComparingTo("16");
var firstLocked=new CountDownLatch(1);var secondStarted=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
var first=workers.submit(()->tx.execute(status->{var locked=repository.lockLot(1L);firstLocked.countDown();
await(secondStarted);return consumeDay(repository,locked,inbound.plusHours(1));}));
assertThat(firstLocked.await(5,TimeUnit.SECONDS)).isTrue();
var second=workers.submit(()->tx.execute(status->{secondStarted.countDown();
return consumeDay(repository,repository.lockLot(1L),inbound.plusHours(2));}));
assertThat(first.get(5,TimeUnit.SECONDS).add(second.get(5,TimeUnit.SECONDS))).isEqualByComparingTo("1.5");
assertThat(repository.lot(1L).getChargedDays()).isEqualTo(1);assertThat(repository.lot(1L).getRemainingQuantity()).isEqualByComparingTo("14");
} finally {workers.shutdownNow();}
}
/** 锁内计算未收费期间并同时保存实际出库数量,模拟业务事务的持久化边界。 */
private BigDecimal consumeDay(StorageRentRepository repository,StorageRentLot lot,LocalDateTime at) {
var amount=StorageRentCalculator.returnPalletAmount(StorageRentCalculator.defaults(null),lot.getInboundAt(),at,lot.getOriginalUnits(),lot.getChargedDays());
lot.setChargedDays(StorageRentCalculator.days(lot.getInboundAt(),at));lot.setRemainingQuantity(lot.getRemainingQuantity().subtract(BigDecimal.ONE));
repository.save(lot);return amount;
}
/** 有界同步并发测试,不依赖休眠制造时序。 */
private void await(CountDownLatch latch) {
try {if(!latch.await(5,TimeUnit.SECONDS))throw new IllegalStateException("并发测试等待超时");}
catch(InterruptedException interrupted) {Thread.currentThread().interrupt();throw new IllegalStateException(interrupted);}
}
}
@@ -45,6 +45,37 @@ class StorageRentReviewSourceTest {
verify(rents).lots(List.of(1L,2L,3L,4L,5L));
}
/** 测试单的旧比例费用只按原报价解释,当前整托口径和新价格不能改写既有金额。 */
@Test void explainsHistoricalReturnFractionUsingSavedPricing() throws Exception {
var rents=mock(StorageRentRepository.class);var json=new ObjectMapper().findAndRegisterModules();
var service=new StorageRentReviewService(rents,mock(OverseasManualFeeRepository.class),mock(OverseasFeeSourceService.class),
mock(OverseasFeeCurrencyConverter.class),mock(WalletCommandOutboxService.class),json);
var at=LocalDateTime.of(2026,11,2,10,0);
var allocation=new Allocation(1L,2L,new BigDecimal("9"),new BigDecimal("0.84375"),at,1);
var plan=new Plan(new Owner("10",null,"DISCARD_ORDER",3L,"CD3",null,null,"20"),List.of(allocation),at.plusHours(2),new BigDecimal("0.84"));
var rent=new StorageRentPreparation();rent.setId(90L);rent.setSourceFeeId(100L);rent.setWarehouseId("20");
rent.setExecutionNo("CD3");rent.setConsumedAt(at.plusHours(2));rent.setPlanJson(json.writeValueAsString(plan));
var fee=new OverseasFeeSource();fee.setSourceFeeId(100L);fee.setCreatedBy("STORAGE_RENT");fee.setFeeItemCode("FEE_TYPE_077");fee.setCustomerId("10");
var original=lot(1L,"10","20","RETURN_ORDER","TH1");original.setRuleCode("RETURN");original.setOriginalQuantity(new BigDecimal("16"));original.setOriginalUnits(BigDecimal.ONE);
when(rents.forFees(List.of(100L))).thenReturn(List.of(rent));when(rents.lots(List.of(1L))).thenReturn(List.of(original));
service.describe(List.of(fee));
var calculation=fee.getStorageRent().calculations().get(0);
assertThat(calculation.ruleCode()).isEqualTo("RETURN_SHARE");assertThat(calculation.billingQuantity()).isEqualByComparingTo("0.5625");
assertThat(calculation.tiers().get(0).unitPrice()).isEqualByComparingTo("1.5");
assertThat(calculation.tiers().get(0).amount()).isEqualByComparingTo("0.84375");
assertThat(fee.getStorageRent().calculatedAmount()).isEqualByComparingTo("0.84");
// 新费用解释直接采用原快照的基数及阶梯,即使后来原批次信息改变也不重算。
var snapshot=new Calculation(1L,"RETURN_PALLET",BigDecimal.ONE,"PALLET",40,10,
StorageRentCalculator.breakdown(plan.pricing(),"RETURN",10,40,BigDecimal.ONE),new BigDecimal("50"));
rent.setPlanJson(json.writeValueAsString(new Plan(plan.owner(),plan.allocations(),plan.calculatedAt(),new BigDecimal("50"),plan.pricing(),List.of(snapshot))));
original.setOriginalUnits(new BigDecimal("99"));original.setOriginalQuantity(new BigDecimal("999"));
service.describe(List.of(fee));calculation=fee.getStorageRent().calculations().get(0);
assertThat(calculation.ruleCode()).isEqualTo("RETURN_PALLET");assertThat(calculation.billingQuantity()).isEqualByComparingTo("1");
assertThat(calculation.previouslyChargedDays()).isEqualTo(10);assertThat(calculation.tiers()).hasSize(2);
assertThat(calculation.tiers().get(0).amount()).isEqualByComparingTo("30");assertThat(calculation.tiers().get(1).amount()).isEqualByComparingTo("20");
}
/** 构造原入库业务批次,不从现有出库订单推断来源。 */
private StorageRentLot lot(long id,String customer,String warehouse,String type,String number) {
var lot=new StorageRentLot();lot.setId(id);lot.setCustomerId(customer);lot.setWarehouseId(warehouse);
@@ -103,6 +103,52 @@ class StorageRentServiceTest {
verify(fees,times(1)).createPendingStorageRent(anyString(),any(),any());verify(fees,never()).publishSettled(anyString(),anyString(),any(),any(),any());
}
/** 同托一次扫码多条商品只收一次,出库推进整托计费日,余货同一天出库为零租金。 */
@Test void partialReturnConsumesWholePalletPeriodAndRemainingBoxDoesNotPaySameDayAgain() {
var at=plan.calculatedAt();lot.setRuleCode("RETURN");
plan=new Plan(plan.owner(),List.of(
new Allocation(51L,61L,new BigDecimal("4"),new BigDecimal("15"),at.minusDays(10),10,0L),
new Allocation(51L,62L,new BigDecimal("5"),BigDecimal.ZERO,at.minusDays(10),10,0L)),at,new BigDecimal("15"));
share.setQuantity(new BigDecimal("4"));
var second=new StorageRentShare();second.setId(62L);second.setLotId(51L);second.setQuantity(new BigDecimal("6"));
when(repository.lockShare(62L)).thenReturn(second);
ready();assertThat(lot.getChargedDays()).isZero();service.consume("9",request);
assertThat(lot.getChargedDays()).isEqualTo(10);assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("1");
var captured=org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class);
verify(fees).createPendingStorageRent(anyString(),captured.capture(),any());
assertThat(captured.getValue().feeRemark()).contains("余一箱仍计一托", "第1–10计租日");
current=null;clearInvocations(wallet,converter);
plan=new Plan(plan.owner(),List.of(new Allocation(51L,62L,BigDecimal.ONE,BigDecimal.ZERO,at.minusDays(10),10,10L)),at,BigDecimal.ZERO);
assertThat(service.prepare("9",request).ready()).isTrue();service.consume("9",request);
assertThat(lot.getRemainingQuantity()).isZero();assertThat(lot.getChargedDays()).isEqualTo(10);
verifyNoInteractions(wallet,converter);
}
/** 原托另一箱先完成计费后,旧冻结必须重建,不能继续按旧整托金额再收费。 */
@Test void anotherReturnScanAdvancingCoveredDaysInvalidatesOriginalFreeze() {
var at=plan.calculatedAt();lot.setRuleCode("RETURN");
plan=new Plan(plan.owner(),List.of(new Allocation(51L,61L,new BigDecimal("4"),new BigDecimal("15"),at.minusDays(10),10,0L)),at,new BigDecimal("15"));
ready();var first=current;
lot.setChargedDays(10L);
plan=new Plan(plan.owner(),List.of(new Allocation(51L,61L,new BigDecimal("4"),BigDecimal.ZERO,at.minusDays(10),10,10L)),at,BigDecimal.ZERO);
assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("计费时间或货物已变化");
assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10");verifyNoInteractions(fees);
var rebuilt=service.prepare("9",request);
assertThat(rebuilt.ready()).isTrue();assertThat(rebuilt.originalAmount()).isZero();
assertThat(first.getStatus()).isEqualTo("EXPIRED");assertThat(first.getReleaseEventId()).isNotBlank();
service.consume("9",request);assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6");
assertThat(current.getOriginalAmount()).isZero();assertThat(lot.getChargedDays()).isEqualTo(10);
}
/** 冻结未实际出库不代表整托已计费,取消预占后下次仍须计算原期间。 */
@Test void cancelledReturnPreparationDoesNotAdvanceChargedDays() {
var a=plan.allocations().get(0);lot.setRuleCode("RETURN");
plan=new Plan(plan.owner(),List.of(new Allocation(a.lotId(),a.shareId(),a.quantity(),new BigDecimal("15"),a.inboundAt(),10,0L)),plan.calculatedAt(),new BigDecimal("15"));
ready();service.cancel("9",current.getId());
assertThat(lot.getChargedDays()).isZero();assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10");
verify(repository,never()).save(any(StorageRentLot.class));
}
/** 使用真实费用校验验证扣款回执只登记本地明细,重放不重复扣款或推送结算。 */
@ParameterizedTest @ValueSource(strings={"LASTLEG_ORDER","RETURN_ORDER","WAREHOUSE_TRANSFER","RELABEL_ORDER"})
void eachBusinessCreatesPendingRentBeforeApproval(String businessType) {
@@ -116,8 +116,8 @@ class StorageRentSourceResolverTest {
assertThatThrownBy(()->service.resolve("9",request,at)).hasMessageContaining("业务单据关联的入库订单");
}
/** 退货只计原托商品比例,四件从十件原托出库不再向上取一个整托。 */
@Test void returnSkuOutboundUsesOriginalPalletFractionAndCurrentReturnOrder(){
/** 退货四件从十件原托出库仍收整托,费用保留当前退货单及原托库龄。 */
@Test void returnSkuOutboundChargesWholePalletAndCurrentReturnOrder(){
var task=new OutboundPdaTask();task.setOutboundOrderId(41L);task.setCustomerId("101");task.setStatus("PENDING");
var outbound=new OutboundOrder();outbound.setOverseasWarehouseId("9");outbound.setOutboundType("RETURN_OUTBOUND");
var line=new OutboundPalletItem();line.setOutboundOrderId(41L);line.setCustomerId("101");line.setCartonMarks("BOX-1");line.setSkuCode("SKU");line.setReturnSkuQuantity(4L);line.setInboundCode("RET-1");
@@ -127,8 +127,26 @@ class StorageRentSourceResolverTest {
when(repository.task("9","TASK-1")).thenReturn(task);when(repository.outboundItem(12L)).thenReturn(line);when(repository.outbound(41L)).thenReturn(outbound);
when(repository.returnOrder("101","RET-1")).thenReturn(ret);when(repository.carton("BOX-1")).thenReturn(carton);when(repository.shares("101","9","BOX-1","SKU")).thenReturn(List.of(share));when(repository.lockLot(52L)).thenReturn(lot(52L,"RETURN",2,BigDecimal.TEN));
var r=new PrepareRequest("OUTBOUND","TASK-1",12L,null,1L,null,null,"return-scan",List.of("BOX-1"),"SKU",4L);
var plan=service.resolve("9",r,at);assertThat(plan.owner().businessNo()).isEqualTo("RET-1");assertThat(plan.amount()).isEqualByComparingTo("1.20");
var plan=service.resolve("9",r,at);assertThat(plan.owner().businessNo()).isEqualTo("RET-1");assertThat(plan.amount()).isEqualByComparingTo("3.00");
assertThat(plan.allocations().get(0).quantity()).isEqualByComparingTo("4");
assertThat(plan.allocations().get(0).previouslyChargedDays()).isZero();
assertThat(plan.calculations()).hasSize(1);assertThat(plan.calculations().get(0).billingQuantity()).isEqualByComparingTo("1");
assertThat(plan.calculations().get(0).tiers()).containsExactly(new CalculationTier(1,2,2,new BigDecimal("1.5"),new BigDecimal("3.0")));
// 同一天已生成整托租金后另扫余货只扣数量,跨日再按原阶梯补新增整托租金。
var original=repository.lockLot(52L);original.setChargedDays(2L);
assertThat(service.resolve("9",r,at).amount()).isZero();
var continued=service.resolve("9",r,at.plusDays(1));assertThat(continued.amount()).isEqualByComparingTo("1.50");
assertThat(continued.calculations().get(0).tiers()).containsExactly(new CalculationTier(3,3,1,new BigDecimal("1.5"),new BigDecimal("1.5")));
// 同一原托混多个 SKU 只收一次;若来自两个原托则分别各收一托。
original.setChargedDays(0L);line.setReturnSkuQuantity(null);line.setSkuCode(null);
var other=new StorageRentShare();other.setId(63L);other.setLotId(52L);other.setQuantity(BigDecimal.ONE);
share.setQuantity(new BigDecimal("9"));when(repository.shares("101","9","BOX-1",null)).thenReturn(List.of(share,other));
var mixed=service.resolve("9",r,at);assertThat(mixed.amount()).isEqualByComparingTo("3.00");
assertThat(mixed.calculations()).hasSize(1);
assertThat(mixed.allocations()).extracting(Allocation::amount).containsExactly(new BigDecimal("3.0"),BigDecimal.ZERO);
other.setLotId(53L);when(repository.lockLot(53L)).thenReturn(lot(53L,"RETURN",2,BigDecimal.ONE));
assertThat(service.resolve("9",r,at).amount()).isEqualByComparingTo("6.00");
line.setReturnSkuQuantity(4L);line.setSkuCode("SKU");
var wrong=new PrepareRequest("OUTBOUND","TASK-1",12L,null,1L,null,null,"return-scan",List.of("BOX-1"),"OTHER",4L);
assertThatThrownBy(()->service.resolve("9",wrong,at)).hasMessageContaining("原出库单不一致");
}
@@ -16,7 +16,7 @@ class UsTruckOrderRequestsTest {
var request = new UsTruckOrderRequests.Create(null, "LA", null, "ONT", null, 0,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", " ", null, null, "", null, null, null, "required");
var fields = factory.getValidator().validate(request).stream().map(v -> v.getPropertyPath().toString()).collect(Collectors.toSet());
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt", "palletCount", "budgetAmount"), fields);
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt", "palletCount", "budgetAmount", "originAddress", "destinationAddress"), fields);
}
}
@@ -25,7 +25,7 @@ class UsTruckOrderRequestsTest {
void onlyCurrentVehicleTypesAreAccepted() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
for (String vehicle : java.util.List.of("PALLET_15", "PALLET_30", "OTHER")) {
var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 1,
var request = new UsTruckOrderRequests.Create(vehicle, "LA", "123 Main St", "ONT", "456 Oak St", 1,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ONE, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101",
LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, "vehicle");
assertEquals(!vehicle.equals("OTHER"), factory.getValidator().validate(request).isEmpty());
@@ -44,10 +44,20 @@ class UsTruckOrderControllerTest {
}
@Test
void platformStatusChangeUsesExplicitTargetTenant() {
void platformCannotCancelEvenWithStatusPermission() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-1", "cancelled");
platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request);
var error = assertThrows(ResponseStatusException.class,
() -> platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request));
assertEquals(403, error.getStatusCode().value());
}
@Test
void merchantCancellationUsesTrustedCustomer() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-customer", "客户取消需求");
merchant.changeStatus(user(100L, "MERCHANT", Set.of()), "TK1", request);
verify(service).changeStatus("100", "TK1", "7", request);
assertThrows(ResponseStatusException.class,
() -> merchant.changeStatus(user(0L, "PLATFORM", Set.of()), "TK1", request));
}
@Test
@@ -81,6 +81,26 @@ class UsTruckOrderServiceTest {
created.setToStatus(UsTruckOrderService.PENDING_REVIEW); historyRows.add(created);
}
/** 客户取消待审订单须释放预算,同一请求重试不得重复释放。 */
@Test
void pendingCustomerCancellationReleasesBudgetOnceAndReplays() {
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-pending", "客户取消需求");
var result = service.changeStatus("100", "TK1", "operator-7", request);
assertEquals("CANCELLED", result.summary().status());
service.changeStatus("100", "TK1", "operator-7", request);
verify(orderBudget).releaseAfterReview("100", "trucking", "TK1");
assertEquals(1L, order.getVersion());
}
/** 审核后已接入排车,不能通过取消原单绕过运输任务约束。 */
@Test
void customerCannotCancelApprovedTransportOrder() {
order.setStatus(UsTruckOrderService.PENDING_DISPATCH);
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "TK1", "operator-7",
new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-approved", "客户取消需求")));
org.mockito.Mockito.verifyNoInteractions(orderBudget);
}
@Test
void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() {
when(orderNoGenerator.next(anyString())).thenReturn("TK1");
@@ -68,14 +68,20 @@ class OrderBudgetServiceTest {
assertThat(service.read("9","procurement","key").usable()).isFalse();
assertThat(service.read("9","procurement","key").message()).contains("过期");
}
@Test void reviewReleaseImmediatelyReleasesFrozenBudget() {
@ParameterizedTest @ValueSource(strings={"procurement","trucking"})
void reviewReleaseImmediatelyReleasesFrozenBudget(String kind) {
row.setKind(kind);
row.setOrderNo("CG-1"); row.setStatus("FROZEN");
when(repository.lockOrder("9","procurement","CG-1")).thenReturn(row);
when(repository.lockOrder("9",kind,"CG-1")).thenReturn(row);
when(wallet.publish(eq("RELEASE"),eq(9L),eq(OrderBudgetService.BUSINESS_TYPE),eq("11"),eq(new BigDecimal("800")),anyString(),eq(2L))).thenReturn("release");
service.releaseAfterReview("9","procurement","CG-1");
service.releaseAfterReview("9",kind,"CG-1");
assertThat(row.getReleaseRequested()).isTrue();
assertThat(row.getStatus()).isEqualTo("RELEASE_PENDING");
verify(repository).save(row);
service.releaseAfterReview("9",kind,"CG-1");
verify(wallet,times(1)).publish(eq("RELEASE"),eq(9L),eq(OrderBudgetService.BUSINESS_TYPE),eq("11"),eq(new BigDecimal("800")),anyString(),eq(2L));
service.handle(result("release","RELEASE","SUCCEEDED","RELEASED",null));
assertThat(row.getStatus()).isEqualTo("RELEASED");
}
/** 已绑定订单的预算释放后仍允许原键恢复原单,但客户、金额与币种不能借重放改写。 */
@ParameterizedTest @ValueSource(strings={"RELEASE_PENDING","RELEASED","RELEASE_FAILED"})