提交
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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 原币及分币种汇总准确,不将混合币种直接相加 |
|
||||
| 资金门禁 | 未冻结成功、资金不足、过期、未知网络结果均不放行库存写;成功后仅本次实际出库份额扣款 |
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+19
@@ -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," +
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+4
-2
@@ -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();
|
||||
// 整托同样须核对已知箱号;仅无箱号的普通库存按实际箱数作业。
|
||||
|
||||
+2
@@ -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); }
|
||||
/** 保存申请所选实物箱快照。 */
|
||||
|
||||
+30
-15
@@ -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,
|
||||
|
||||
+14
-2
@@ -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)); }
|
||||
|
||||
+1
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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;
|
||||
|
||||
-7
@@ -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("车次时间超出允许范围"); }
|
||||
|
||||
+21
-3
@@ -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) { }
|
||||
}
|
||||
|
||||
+2
@@ -46,6 +46,8 @@ public class StorageRentLot {
|
||||
private BigDecimal remainingQuantity;
|
||||
/** 原批次计费体积或托数。 */
|
||||
private BigDecimal originalUnits;
|
||||
/** 退货原托已生成仓租的截止库龄天数,部分出库后余货仍按整托续计。 */
|
||||
private Long chargedDays = 0L;
|
||||
/** 实际入库时间。 */
|
||||
private LocalDateTime inboundAt;
|
||||
/** 创建时间。 */
|
||||
|
||||
+21
-1
@@ -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(
|
||||
|
||||
+30
-1
@@ -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){
|
||||
|
||||
+19
-8
@@ -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;
|
||||
}
|
||||
/** 返回前端可执行标记;只有已确认冻结或原已消费请求可继续原扫码幂等恢复。 */
|
||||
|
||||
+27
-5
@@ -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);
|
||||
|
||||
+8
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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")
|
||||
|
||||
+4
-4
@@ -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,
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
|
||||
+3
-2
@@ -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}
|
||||
|
||||
+4
-4
@@ -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", "验收:库存不足应失败")));
|
||||
|
||||
+36
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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);
|
||||
|
||||
+16
-58
@@ -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());
|
||||
}
|
||||
|
||||
+9
@@ -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);
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+12
@@ -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("未发车");
|
||||
|
||||
+19
-5
@@ -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() {
|
||||
|
||||
+30
-6
@@ -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();
|
||||
}
|
||||
|
||||
/** 缺失体积、逆序时间及超出原批次的数量不能被当成零费用放行。 */
|
||||
|
||||
+70
@@ -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);}
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
|
||||
+46
@@ -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) {
|
||||
|
||||
+21
-3
@@ -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("原出库单不一致");
|
||||
}
|
||||
|
||||
+2
-2
@@ -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());
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
+20
@@ -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");
|
||||
|
||||
+9
-3
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user