This commit is contained in:
2026-10-29 13:36:38 +08:00
parent 353c86f4cf
commit 1b27d7e6c2
60 changed files with 1208 additions and 60 deletions
+3
View File
@@ -224,3 +224,6 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。 本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。 采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
2026-10-29 客户采购驳回和下单预算冻结:源结构与海外空库入口同步,已有 Lastleg 库使用 `overseas-order-budget-and-procurement-rejection.sql` 增加两列和预算预占表,不向已有库重放空库入口。需要匹配的 Lastleg、Admin、Client;本轮仅交付脚本,未执行数据库变更。
@@ -0,0 +1,29 @@
-- 2026-10-29:采购驳回照片返工及客户下单预估冻结;仅在Lastleg所属库执行。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_rejection_reason'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN customer_rejection_reason VARCHAR(1000) NULL COMMENT ''客户采购结果驳回原因'''));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='rejected_photo_ids'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN rejected_photo_ids TEXT NULL COMMENT ''本轮客户驳回的收货照片主键快照'''));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
KEY idx_order_budget_release(status,release_requested,created_at),
KEY idx_order_budget_order(customer_id,kind,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
+24 -1
View File
@@ -2105,6 +2105,8 @@ CREATE TABLE ll_overseas_procurement_order (
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注', review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
customer_rejection_reason VARCHAR(1000) NULL COMMENT '客户采购结果驳回原因',
rejected_photo_ids TEXT NULL COMMENT '本轮客户驳回的收货照片主键快照',
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希', review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希',
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
@@ -4897,4 +4899,25 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
PRIMARY KEY (order_id,command_key) PRIMARY KEY (order_id,command_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 222 AS initialized_tables; CREATE TABLE ll_order_budget_reservation (
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
KEY idx_order_budget_release(status,release_requested,created_at),
KEY idx_order_budget_order(customer_id,kind,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 223 AS initialized_tables;
View File
+10 -1
View File
@@ -14,7 +14,7 @@
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。 拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。 收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并逐批重新上传照片。驳回前所有已上传文件均不可复用;仅替换照片,不重复累加数量。全部旧照片替换后方可再次提交确认。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。 出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
@@ -46,3 +46,12 @@
- Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。 - Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。
- 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。 - 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。
- 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。 - 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。
## 2026-10-29 客户下单预算与页面调整
客户采购下单前,以相同幂等键调用 `order-budgets/procurement/{key}`,金额为预算单价乘数量,按有效汇率折人民币;仅Finance冻结成功且usable=true时允许建单。余额不足使用可用授信,合计不足则提示Finance原因,不创建订单。免费样品金额为0时不发资金指令。未绑定预占15分钟后释放,晚到回执仍释放;已绑定订单终态释放,失败定时重试。预算预占独立于现有人工费用冻结/实扣,不计入已扣款汇总。
本土卡车同样校验预算,托数与预算金额须大于0,原件数字段不再作为客户下单数量;预算金额及币种创建后不可通过普通编辑改变。Client采购详情将四项数量卡片置顶,商品及收件信息置底。
结构变化:Lastleg源基线与海外空库基线同步;已有库的独立增量为 `deploy/sql/overseas-order-budget-and-procurement-rejection.sql`。本轮未执行SQL或部署,Finance真实回执与人工截图验收仍待环境联调。
@@ -0,0 +1,80 @@
package com.baiyue.lastleg.discard.mapper;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 已确认待发换标箱的废弃归属账本;取消及扣减均保留原换标事实。 */
@Mapper
public interface CargoDiscardRelabelMapper {
/** 与排车使用相同原申请行锁,锁顺序由调用方统一排列。 */
@Select("SELECT relabel_no FROM ll_relabel_application WHERE relabel_no=#{no} FOR UPDATE")
String lock(@Param("no") String no);
String ELIGIBLE = "a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' "
+ "AND a.customer_confirmed_at IS NOT NULL AND COALESCE(a.outbound_order_no,'')='' "
+ "AND i.return_carton_id IS NULL AND i.notified_package_count=1 "
+ "AND NOT EXISTS(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=i.customer_id "
+ "AND dc.carton_id=i.id AND dc.source_type IN ('RELABEL','RELABEL_DISCARDED')) ";
/** 已确认箱必须仍在实际冻结仓位;当前轮次绑定由业务端口继续复核。 */
@Select("SELECT i.* FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE i.customer_id=#{customerId} AND i.source_inventory_position_id=#{positionId} AND " + ELIGIBLE + " ORDER BY i.id")
List<RelabelItem> cartons(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 通过原入仓单及客户/系统箱唛定位唯一转运实收箱,废弃扫码始终使用系统箱唛。 */
@Select("SELECT DISTINCT c.carton_mark FROM ll_relabel_item i "
+ "JOIN ll_warehouse_transfer_order t ON t.customer_id=i.customer_id AND t.inbound_order_id=i.source_inbound_order_id "
+ "JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id "
+ "AND (UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.carton_mark)) OR UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.customer_carton_mark))) "
+ "WHERE i.customer_id=#{customerId} AND i.id=#{id} ORDER BY c.carton_mark")
List<String> systemCartonMarks(@Param("customerId") String customerId, @Param("id") Long id);
/** 箱级交接使用锁定读复核最新归属,不能复用提交前的一致性读快照。 */
@Select("SELECT i.id FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE i.customer_id=#{customerId} AND i.id=#{id} AND " + ELIGIBLE + " FOR UPDATE")
Long lockEligible(@Param("customerId") String customerId, @Param("id") Long id);
/** 增补仍有已确认换标冻结实箱的候选仓位,不开放其他业务冻结库存。 */
@Select("<script>SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id "
+ "WHERE p.customer_id=#{customerId} AND p.hold_packages&gt;0 AND b.hold_packages&gt;0 "
+ "AND EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE i.customer_id=p.customer_id AND i.source_inventory_position_id=p.id AND a.overseas_warehouse_id=p.overseas_warehouse_id AND " + ELIGIBLE + ") "
+ "AND NOT EXISTS(SELECT 1 FROM ll_warehouse_transfer_inventory_allocation t WHERE t.customer_id=p.customer_id AND t.inventory_position_id=p.id AND t.status='RESERVED') "
+ "<if test='keyword != null and keyword != &quot;&quot;'>AND b.inbound_code LIKE CONCAT('%',#{keyword},'%')</if> ORDER BY p.id</script>")
List<InventoryPosition> positions(@Param("customerId") String customerId, @Param("keyword") String keyword);
/** 排车与废弃统一先锁原换标申请,再锁库存仓位。 */
@Select("<script>SELECT DISTINCT a.relabel_no FROM ll_relabel_application a JOIN ll_relabel_item i ON i.application_id=a.id AND i.customer_id=a.customer_id "
+ "WHERE i.customer_id=#{customerId} AND i.source_inventory_position_id IN "
+ "<foreach collection='positions' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY a.relabel_no</script>")
List<String> applications(@Param("customerId") String customerId, @Param("positions") List<Long> positions);
/** 原申请业务号用于交接及取消恢复冻结。 */
@Select("SELECT a.relabel_no FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE i.customer_id=#{customerId} AND i.id=#{id}")
String relabelNo(@Param("customerId") String customerId, @Param("id") Long id);
/** 识别已转交换标的旧转运箱,避免同一箱在原托盘再次出现。 */
@Select("SELECT DISTINCT c.id FROM ll_warehouse_transfer_carton c JOIN ll_warehouse_transfer_order t ON t.id=c.transfer_order_id AND t.customer_id=c.customer_id "
+ "JOIN ll_relabel_item i ON i.customer_id=c.customer_id AND i.source_inbound_order_id=t.inbound_order_id "
+ "AND (UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.carton_mark)) OR UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.customer_carton_mark))) "
+ "JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE c.customer_id=#{customerId} AND a.status NOT IN ('CANCELLED','REJECTED')")
List<Long> transferredCartons(@Param("customerId") String customerId);
/** 使用原状态条件将本次箱级占用改为取消或已实际废弃。 */
@Update("UPDATE ll_cargo_discard_carton SET source_type=#{target} WHERE customer_id=#{customerId} AND carton_id=#{id} AND source_type='RELABEL'")
int finish(@Param("customerId") String customerId, @Param("id") Long id, @Param("target") String target);
/** 有待废弃箱时暂停排车,结算后剩余箱恢复;全部实际废弃后终止履约。 */
@Update("UPDATE ll_relabel_application SET fulfillment_status=CASE "
+ "WHEN EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc ON dc.carton_id=i.id AND dc.customer_id=i.customer_id "
+ "AND dc.source_type='RELABEL' WHERE i.application_id=ll_relabel_application.id) THEN 'DISCARD_PENDING' "
+ "WHEN EXISTS(SELECT 1 FROM ll_relabel_item i WHERE i.application_id=ll_relabel_application.id AND NOT EXISTS "
+ "(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=i.customer_id AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED')) "
+ "THEN 'WAIT_PRE_DISPATCH' ELSE 'DISCARDED' END,updated_at=NOW(3) "
+ "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND status='READY_FOR_OUTBOUND' "
+ "AND fulfillment_status IN ('WAIT_PRE_DISPATCH','DISCARD_PENDING','DISCARDED') AND COALESCE(outbound_order_no,'')=''")
int refresh(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
}
@@ -69,10 +69,11 @@ public class PdaCargoDiscardService {
if (!line.actualQuantityEditable() && actual != line.plannedPackages()) if (!line.actualQuantityEditable() && actual != line.plannedPackages())
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出"); throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
} }
if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) { // 有真实箱号必须逐箱核对,兼容字段不能绕过扫码校验。
if ("CARTON".equals(task.scanMode())) {
var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks(); var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks();
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks()))) if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
throw conflict("必须扫描全部箱唛,或选择一键全部废弃"); throw conflict("必须扫描本次废弃的全部箱号");
} }
List<CargoDiscardItem> palletItems = new ArrayList<>(); List<CargoDiscardItem> palletItems = new ArrayList<>();
for (PdaCargoDiscardDtos.Sku line : task.skus()) { for (PdaCargoDiscardDtos.Sku line : task.skus()) {
@@ -179,8 +180,8 @@ public class PdaCargoDiscardService {
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList(); isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList();
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING"; 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(); var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。 // 整托同样须核对已知箱号;仅无箱号的普通库存按实际箱数作业。
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream() boolean cartonScan = !marks.isEmpty() || entry.getValue().stream()
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired())); .anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛"); if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(), return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.mapper.CargoDiscardRelabelMapper;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 换标废弃事实仅经本库 Mapper 读取和原状态条件更新。 */
@Repository
public class CargoDiscardRelabelRepository {
private final CargoDiscardRelabelMapper mapper;
public CargoDiscardRelabelRepository(CargoDiscardRelabelMapper mapper) { this.mapper = mapper; }
/** 按原申请业务号取得正式行锁。 */
public String lock(String no) { return mapper.lock(no); }
/** 复验最新箱级状态,拒绝并发废弃或已经排车的旧页面。 */
public boolean lockEligible(String customer, Long id) { return mapper.lockEligible(customer, id) != null; }
/** 已确认待发实箱。 */
public List<RelabelItem> cartons(String customer, Long position) { return mapper.cartons(customer, position); }
/** 读取换标箱对应的原系统箱唛,避免以 FBA 箱唛替代仓内扫码身份。 */
public List<String> systemCartonMarks(String customer, Long item) { return mapper.systemCartonMarks(customer, item); }
/** 已确认冻结仓位。 */
public List<InventoryPosition> positions(String customer, String keyword) { return mapper.positions(customer, keyword); }
/** 原申请锁集合。 */
public List<String> applications(String customer, List<Long> positions) { return positions.isEmpty() ? List.of() : mapper.applications(customer, positions); }
/** 换标业务归属。 */
public String relabelNo(String customer, Long item) { return mapper.relabelNo(customer, item); }
/** 原托盘旧箱记录。 */
public List<Long> transferredCartons(String customer) { return mapper.transferredCartons(customer); }
/** 结束箱级独占。 */
public int finish(String customer, Long item, String target) { return mapper.finish(customer, item, target); }
/** 刷新整单剩余履约资格。 */
public int refresh(String customer, String no) { return mapper.refresh(customer, no); }
}
@@ -0,0 +1,123 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow;
import com.baiyue.lastleg.discard.repository.CargoDiscardRelabelRepository;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.*;
/** 已换标实箱在原换标冻结与废弃冻结之间原子交接,保留原箱历史。 */
@Service
public class CargoDiscardRelabelStockService {
@Resource private CargoDiscardRelabelRepository repository;
@Resource private RelabelDispatchSourcePort sources;
@Resource private InventoryCommandService commands;
@Resource private ObjectMapper json;
/** 仅增补有待发换标库存的位置。 */
public List<InventoryPosition> positions(String customer, String keyword) { return repository.positions(customer, keyword); }
/** 从当前轮次实托绑定核对箱级贡献,换标完成但未绑定的箱不能猜测位置。 */
public List<CargoDiscardStockCartonRow> cartons(String customer, InventoryPosition position) {
var items = repository.cartons(customer, position.getId());
if (items.isEmpty() || position.getPalletId() == null || position.getPalletId() <= 0) return List.of();
var snapshot = sources.snapshot(position.getOverseasWarehouseId(), List.of(position.getPalletId()));
if (!snapshot.complete()) return List.of();
var bound = snapshot.sources().stream().filter(s -> customer.equals(s.customerId())
&& Objects.equals(position.getId(), s.inventoryPositionId()) && s.boundPackageCount() == 1)
.map(RelabelDispatchSourcePort.BoundSource::itemId).collect(java.util.stream.Collectors.toSet());
var result = new ArrayList<CargoDiscardStockCartonRow>();
for (var item : items) {
if (!bound.contains(item.getId())) continue;
var box = new CargoDiscardStockCartonRow(); box.setId(item.getId()); box.setSourceType("RELABEL");
// 系统箱唛是仓内实箱身份;换标仅更新 FBA 箱唛和商品标签,不替换废弃扫码身份。
var systemMarks = repository.systemCartonMarks(customer, item.getId());
if (systemMarks.size() > 1) throw conflict("换标箱对应多个系统箱唛,请先核对原转运箱记录");
box.setCartonMark(systemMarks.isEmpty() ? item.getCartonMark() : systemMarks.get(0));
box.setSkuCode(item.getNewSkuCode() == null || item.getNewSkuCode().isBlank() ? item.getSkuCode() : item.getNewSkuCode());
try {
var values = json.readTree(Objects.toString(item.getSkuRelabelItemsJson(), "[]"));
if (values.isArray() && !values.isEmpty()) {
var contents = json.createArrayNode();
for (var value : values) {
var content = contents.addObject();
String code = value.path("newSkuCode").asText();
content.put("skuCode", code.isBlank() ? value.path("skuCode").asText() : code);
content.put("quantity", value.path("quantity").asLong());
}
box.setSkuItemsJson(json.writeValueAsString(contents));
}
} catch (Exception invalid) { throw conflict("换标箱内 SKU 事实无法读取"); }
result.add(box);
}
return result.size() <= number(position.getHoldPackages()) ? result : List.of();
}
/** 排除已转交换标处理的旧转运箱,不能同时宣称原托盘和新托盘都存有该箱。 */
public Set<Long> transferred(String customer) { return new HashSet<>(repository.transferredCartons(customer)); }
/** 保存废弃选箱账本前以最新锁定读认领实箱。 */
public int claim(String customer, Long item) { return repository.lockEligible(customer, item) ? 1 : 0; }
/** 与排车共用按业务号排序的原申请锁,之后再取得库存正式锁。 */
public void lock(String customer, List<Long> positions) {
var applications = repository.applications(customer, positions);
applications.stream().distinct().sorted().forEach(no -> {
if (repository.lock(no) == null) throw conflict("换标原申请不存在");
});
}
/** 先归还原换标冻结,紧接着由调用方以废弃周期重新冻结,同事务不暴露可用窗口。 */
public void handoff(String customer, String operator, CargoDiscardApplication app, CargoDiscardItem line,
List<CargoDiscardStockCartonRow> selected) {
for (var box : selected) if ("RELABEL".equals(box.getSourceType())) {
String no = requireNo(customer, box.getId());
commands.release(customer, operator, move(line, app, box.getId(), no, "HANDOFF"));
}
}
/** 箱级占用已持久化后暂停原换标出库;同一混标箱多SKU行只需刷新一次。 */
public void synchronize(List<CargoDiscardCarton> links, String customer) {
var ids = links.stream().filter(link -> "RELABEL".equals(link.getSourceType()))
.map(CargoDiscardCarton::getCartonId).distinct().toList();
var no = new TreeSet<String>(); ids.forEach(id -> no.add(requireNo(customer, id)));
for (String relabelNo : no) if (repository.refresh(customer, relabelNo) != 1)
throw conflict("换标单已进入其他出库流程");
}
/** 实际废弃或撤销记录保留箱级结果;整单仅依据剩余箱恢复资格。 */
public int finish(String customer, Long item, boolean discarded) {
String no = requireNo(customer, item);
int changed = repository.finish(customer, item, discarded ? "RELABEL_DISCARDED" : "RELABEL_RELEASED");
if (changed == 1 && repository.refresh(customer, no) != 1) throw conflict("换标废弃归属发生变化");
return changed;
}
/** 撤销后恢复原换标 HOLD,不能把已换标待发箱释放为普通可用箱。 */
public void restore(String customer, CargoDiscardApplication app, CargoDiscardItem line, List<CargoDiscardCarton> links) {
for (var link : links) if (Objects.equals(line.getId(), link.getItemId()) && "RELABEL".equals(link.getSourceType()))
commands.hold(customer, app.getCreatedBy(), move(line, app, link.getCartonId(), requireNo(customer, link.getCartonId()), "RESTORE"));
}
/** 每次废弃申请独立幂等,允许取消后重新申请而不复用旧释放结果。 */
private InventoryCommandRequests.Move move(CargoDiscardItem line, CargoDiscardApplication app, Long item, String no, String action) {
return new InventoryCommandRequests.Move(line.getInventoryPositionId(), 1, no, null, "RELABEL:" + no,
"DISCARD_RELABEL:" + app.getId() + ":" + item + ":" + action, "换标库存废弃交接 " + action);
}
/** 原申请必须可追溯。 */
private String requireNo(String customer, Long item) {
String no = repository.relabelNo(customer, item);
if (no == null) throw conflict("换标箱原申请不存在");
return no;
}
private long number(Long value) { return value == null ? 0 : value; }
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -20,14 +20,18 @@ public class CargoDiscardStockService {
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository; @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository;
@Resource private InventoryCommandService commands; @Resource private InventoryCommandService commands;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private CargoDiscardRelabelStockService relabelStock;
/** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */ /** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */
public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) { public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) {
return repository.candidates(customer, keyword).stream().map(position -> { 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 -> {
var balance = repository.balance(position.getBalanceId()); var balance = repository.balance(position.getBalanceId());
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在"); if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); long available = available(customer, position, balance);
var cartons = repository.cartons(customer, position.getId()); var cartons = cartons(customer, position);
boolean complete = completeCartons(cartons, available); boolean complete = completeCartons(cartons, available);
var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(), var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(),
contents(c, balance.getProductNameZh()))).toList() : List.<CargoDiscardResponses.Box>of(); contents(c, balance.getProductNameZh()))).toList() : List.<CargoDiscardResponses.Box>of();
@@ -47,16 +51,17 @@ public class CargoDiscardStockService {
if (balance == null || !customer.equals(balance.getCustomerId()) if (balance == null || !customer.equals(balance.getCustomerId())
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId())) || !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
throw conflict("库存不属于当前客户或仓库"); throw conflict("库存不属于当前客户或仓库");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); long available = available(customer, position, balance);
if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足"); if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足");
if (request.cartonIds() != null) { if (request.cartonIds() != null) {
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size()) if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
throw conflict("废弃箱子数量与所选箱子不一致或重复"); throw conflict("废弃箱子数量与所选箱子不一致或重复");
var cartons = repository.cartons(customer, position.getId()); var cartons = cartons(customer, position);
var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet()); var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet());
if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选"); if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选");
if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子"); if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子");
} else { } else {
if (relabelStock.cartons(customer, position).size() > 0) throw conflict("换标库存必须选择具体箱子");
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存"); if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量"); if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
} }
@@ -66,14 +71,16 @@ public class CargoDiscardStockService {
/** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */ /** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) { public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
relabelStock.lock(customer, List.of(request.inventoryPositionId()));
if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
var balance = validate(customer, request); var balance = validate(customer, request);
reserveValidated(customer, operator, application, request, at, balance, repository.cartons(customer, request.inventoryPositionId()), new HashSet<>(), new HashSet<>()); reserveValidated(customer, operator, application, request, at, balance, cartons(customer, position(customer, request.inventoryPositionId())), new HashSet<>(), new HashSet<>());
} }
/** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */ /** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */
public void reserveAll(String customer, String operator, CargoDiscardApplication application, List<CargoDiscardRequests.Item> requests, LocalDateTime at) { public void reserveAll(String customer, String operator, CargoDiscardApplication application, List<CargoDiscardRequests.Item> requests, LocalDateTime at) {
var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList(); var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList();
relabelStock.lock(customer, stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().toList());
stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> { stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> {
if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在"); if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在");
}); });
@@ -82,7 +89,7 @@ public class CargoDiscardStockService {
var selectedSkus = new HashMap<String, Set<String>>(); var selectedSkus = new HashMap<String, Set<String>>();
for (var request : stocks) { for (var request : stocks) {
balances.put(request.inventoryPositionId(), validate(customer, request)); balances.put(request.inventoryPositionId(), validate(customer, request));
var facts = repository.cartons(customer, request.inventoryPositionId()); var facts = cartons(customer, position(customer, request.inventoryPositionId()));
cartons.put(request.inventoryPositionId(), facts); cartons.put(request.inventoryPositionId(), facts);
for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId())) for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId()))
selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim()); selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim());
@@ -124,9 +131,15 @@ public class CargoDiscardStockService {
if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录"); if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
snapshotMarks(line, marks); snapshotMarks(line, marks);
} }
int physical = selected.isEmpty() ? request.discardPackages() : (int)selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).count(); var newlyHeld = selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).toList();
if (physical > 0) commands.hold(customer, operator, move(application, line, physical, "HOLD")); int physical = selected.isEmpty() ? request.discardPackages() : newlyHeld.size();
if (physical > 0) {
relabelStock.handoff(customer, operator, application, line, newlyHeld);
commands.hold(customer, operator, move(application, line, physical, "HOLD"));
}
repository.saveItem(line); repository.saveItem(line);
if (newlyHeld.stream().anyMatch(c -> "RELABEL".equals(c.getSourceType())))
relabelStock.synchronize(repository.selectedCartons(customer, application.getId()), customer);
} }
/** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */ /** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */
@@ -165,14 +178,18 @@ public class CargoDiscardStockService {
/** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */ /** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */
public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) { public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
var links = repository.selectedCartons(customer, application.getId());
relabelStock.lock(customer, List.of(line.getInventoryPositionId()));
changeSelectedCartons(customer, application, line, "AVAILABLE"); changeSelectedCartons(customer, application, line, "AVAILABLE");
int physical = physicalPackages(customer, application, line); int physical = physicalPackages(customer, application, line);
if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE")); if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE"));
relabelStock.restore(customer, application, line, links);
} }
/** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */ /** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) { public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) {
relabelStock.lock(customer, List.of(line.getInventoryPositionId()));
if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数"); if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数");
if (!actualQuantityEditable(line) && actual != line.getDiscardPackages()) if (!actualQuantityEditable(line) && actual != line.getDiscardPackages())
throw conflict("必须完成本次申请所选全部箱子,不能漏出"); throw conflict("必须完成本次申请所选全部箱子,不能漏出");
@@ -203,6 +220,7 @@ public class CargoDiscardStockService {
/** 按箱来源执行带原状态条件的变更。 */ /** 按箱来源执行带原状态条件的变更。 */
private int changeBox(String customer, Long cartonId, String source, String from, String to) { private int changeBox(String customer, Long cartonId, String source, String from, String to) {
if ("RELABEL".equals(source)) return "DISCARD_PENDING".equals(to) ? relabelStock.claim(customer, cartonId) : relabelStock.finish(customer, cartonId, "DISCARDED".equals(to));
if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to); if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to);
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确"); if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
return repository.changeInboundCarton(customer, cartonId, from, to); return repository.changeInboundCarton(customer, cartonId, from, to);
@@ -239,6 +257,21 @@ public class CargoDiscardStockService {
} }
/** 无 SKU 来源保留未指定标记,不猜测商品。 */ /** 无 SKU 来源保留未指定标记,不猜测商品。 */
private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); } private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); }
/** 当前可废弃数量包括已完成并确认的换标实箱,不包含其他业务冻结。 */
private long available(String customer, InventoryPosition position, InventoryBalance balance) {
long normal = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
long relabel = relabelStock.cartons(customer, position).size();
return normal + Math.min(relabel, value(balance.getHoldPackages()));
}
/** 原转运旧箱与当前换标实箱互斥,避免换托后重复展示。 */
private List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> cartons(String customer, InventoryPosition position) {
var moved = relabelStock.transferred(customer);
var result = new ArrayList<>(repository.cartons(customer, position.getId()).stream()
.filter(c -> !"TRANSFER".equals(c.getSourceType()) || !moved.contains(c.getId())).toList());
result.addAll(relabelStock.cartons(customer, position));
return result;
}
/** 空库存桶按零处理。 */ /** 空库存桶按零处理。 */
private static long value(Long value) { return value == null ? 0 : value; } private static long value(Long value) { return value == null ? 0 : value; }
/** 返回可恢复的业务冲突。 */ /** 返回可恢复的业务冲突。 */
@@ -328,6 +328,8 @@ public interface DispatchCandidateMapper {
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " + "LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
"AND wtp.warehouse_pallet_id=pos.pallet_id " + "AND wtp.warehouse_pallet_id=pos.pallet_id " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " + "WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
// 部分废弃后只统计仍在仓内的换标箱,保留原任务供追溯。
"AND NOT EXISTS(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=ri.customer_id AND dc.carton_id=ri.id AND dc.source_type='RELABEL_DISCARDED') " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" + "<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" + "<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" + "<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
@@ -16,11 +16,14 @@ public final class TransportTaskModels {
@NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { } @NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey, public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { } @NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { }
public record Cargo(int palletCount,int packageCount,java.math.BigDecimal totalWeightKg,java.math.BigDecimal totalVolumeCbm,
String origin,String originAddress,String originContactName,String originContactPhone,
String destination,String destinationAddress,String destinationContactName,String destinationContactPhone) { }
public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution, public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution,
String customerId,String orderNo,List<FleetPickupModels.Task> pickups, String customerId,String orderNo,List<FleetPickupModels.Task> pickups,
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> departureEvidence, List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> departureEvidence,
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence, List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence,
Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources) { } Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources,Cargo cargo) { }
public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId, public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
@NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason, @NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason,
@Min(0) Long dispatchRecordVersion, @Min(0) Long dispatchRecordVersion,
@@ -101,12 +101,16 @@ public class TransportTaskService {
var departureIds=commandEvidence.isEmpty()?List.<Long>of():evidenceJson(commandEvidence.get(0)); var departureIds=commandEvidence.isEmpty()?List.<Long>of():evidenceJson(commandEvidence.get(0));
var completionIds=commandEvidence.size()>1?commandEvidence.subList(1,commandEvidence.size()).stream().flatMap(value->evidenceJson(value).stream()).distinct().toList() var completionIds=commandEvidence.size()>1?commandEvidence.subList(1,commandEvidence.size()).stream().flatMap(value->evidenceJson(value).stream()).distinct().toList()
: "COMPLETED".equals(trip.getStatus()) ? departureIds : List.<Long>of(); : "COMPLETED".equals(trip.getStatus()) ? departureIds : List.<Long>of();
var cargo=link==null?null:trucks.findNo(link.customerId(),link.orderNo());
return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(), return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(),
"PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(), "PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(),
"LOCAL_TRUCK".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus()) ? metadata(trip.getId(),departureIds) : "LOCAL_TRUCK".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus()) ? metadata(trip.getId(),departureIds) :
"LOCAL_TRUCK".equals(row.getTaskType()) && "COMPLETED".equals(trip.getStatus()) && commandEvidence.size()>1 ? metadata(trip.getId(),departureIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(), "LOCAL_TRUCK".equals(row.getTaskType()) && "COMPLETED".equals(trip.getStatus()) && commandEvidence.size()>1 ? metadata(trip.getId(),departureIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),
"LOCAL_TRUCK".equals(row.getTaskType()) ? metadata(trip.getId(),completionIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),row.getRecordVersion(), "LOCAL_TRUCK".equals(row.getTaskType()) ? metadata(trip.getId(),completionIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),row.getRecordVersion(),
reviewPhotos.resources(row)); reviewPhotos.resources(row),cargo==null?null:new TransportTaskModels.Cargo(
cargo.getPalletCount()==null?0:cargo.getPalletCount(),cargo.getPackageCount()==null?0:cargo.getPackageCount(),cargo.getTotalWeightKg(),cargo.getTotalVolumeCbm(),
cargo.getOrigin(),cargo.getOriginAddress(),cargo.getOriginContactName(),cargo.getOriginContactPhone(),
cargo.getDestination(),cargo.getDestinationAddress(),cargo.getDestinationContactName(),cargo.getDestinationContactPhone()));
} }
/** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */ /** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */
public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);} public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);}
@@ -15,8 +15,14 @@ public final class DriverModels {
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Size(max = 128) String keyword, Boolean active, String reviewStatus, @Size(max = 128) String keyword, Boolean active, String reviewStatus,
String ownership, String region, String licenseState, String ownership, String region, String licenseState,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) { @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd,
@Size(max = 64) String dispatchNo) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
/** 无排车上下文的档案查询保持原有语义。 */
public Query(Integer page, Integer size, String keyword, Boolean active, String reviewStatus,
String ownership, String region, String licenseState, Instant expectedEnd) {
this(page, size, keyword, active, reviewStatus, ownership, region, licenseState, expectedEnd, null);
}
} }
public record Draft(@NotBlank @Size(max = 128) String name, public record Draft(@NotBlank @Size(max = 128) String name,
@@ -29,6 +29,7 @@ public class DriverService {
private final ObjectMapper json; private final ObjectMapper json;
private final Clock clock; private final Clock clock;
private final FleetWarehouseDirectory warehouses; private final FleetWarehouseDirectory warehouses;
@Autowired private com.baiyue.lastleg.fleet.port.DriverAvailability availability;
@Autowired @Autowired
public DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, FleetWarehouseDirectory warehouses) { public DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, FleetWarehouseDirectory warehouses) {
@@ -55,6 +56,7 @@ public class DriverService {
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages(), summary); page.getCurrent(), page.getSize(), page.getTotal(), page.getPages(), summary);
} }
/** 先排除其他车次占用,再分页统计,避免空页或错误总数。 */
private LambdaQueryWrapper<DriverEntity> filter(DriverModels.Query query, Instant now) { private LambdaQueryWrapper<DriverEntity> filter(DriverModels.Query query, Instant now) {
String review = optionalChoice(query.reviewStatus(), Set.of("PENDING", "APPROVED", "REJECTED"), "审核状态"); String review = optionalChoice(query.reviewStatus(), Set.of("PENDING", "APPROVED", "REJECTED"), "审核状态");
String ownership = optionalChoice(query.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属"); String ownership = optionalChoice(query.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属");
@@ -65,6 +67,10 @@ public class DriverService {
.eq(review != null, DriverEntity::getReviewStatus, review) .eq(review != null, DriverEntity::getReviewStatus, review)
.eq(ownership != null, DriverEntity::getOwnership, ownership) .eq(ownership != null, DriverEntity::getOwnership, ownership)
.eq(region != null, DriverEntity::getRegion, region); .eq(region != null, DriverEntity::getRegion, region);
if (query.dispatchNo() != null && !query.dispatchNo().isBlank()) {
var unavailable = availability.unavailable(query.dispatchNo());
if (!unavailable.isEmpty()) filter.notIn(DriverEntity::getId, unavailable);
}
if (keyword != null) filter.and(w -> w.like(DriverEntity::getName, keyword) if (keyword != null) filter.and(w -> w.like(DriverEntity::getName, keyword)
.or().like(DriverEntity::getPhone, keyword).or().like(DriverEntity::getDriverNo, keyword)); .or().like(DriverEntity::getPhone, keyword).or().like(DriverEntity::getDriverNo, keyword));
String state = optionalChoice(query.licenseState(), Set.of("MISSING", "VALID", "EXPIRING", "EXPIRED"), "驾驶证有效期"); String state = optionalChoice(query.licenseState(), Set.of("MISSING", "VALID", "EXPIRING", "EXPIRED"), "驾驶证有效期");
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.fleet.port;
import java.util.List;
/** 司机候选读取权威占用的车队端口。 */
public interface DriverAvailability {
/** 返回其他车次实际占用的司机,保留当前排车资源。 */
List<Long> unavailable(String dispatchNo);
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.port.DriverAvailability;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 司机候选只读取车队权威占用,保留当前排车关联资源。 */
@Repository
public class DriverAvailabilityRepository implements DriverAvailability {
private final FleetResourceMapper resources;
/** 注入资源占用持久层。 */
public DriverAvailabilityRepository(FleetResourceMapper resources) { this.resources = resources; }
/** 返回其他车次占用的司机身份。 */
public List<Long> unavailable(String dispatchNo) { return resources.unavailableDrivers(dispatchNo); }
}
@@ -28,5 +28,12 @@ public interface FleetResourceMapper {
SELECT 1 FROM ll_fleet_trip_dispatch d WHERE d.trip_id=a.trip_id AND d.dispatch_no=#{dispatchNo}) SELECT 1 FROM ll_fleet_trip_dispatch d WHERE d.trip_id=a.trip_id AND d.dispatch_no=#{dispatchNo})
""") """)
List<Long> unavailableVehicles(@Param("dispatchNo") String dispatchNo); List<Long> unavailableVehicles(@Param("dispatchNo") String dispatchNo);
/** 当前排车的司机可保留,其他车次实际占用的司机不进入候选。 */
@Select("""
SELECT a.resource_id FROM ll_fleet_resource_assignment a
WHERE a.resource_kind='DRIVER' AND NOT EXISTS (
SELECT 1 FROM ll_fleet_trip_dispatch d WHERE d.trip_id=a.trip_id AND d.dispatch_no=#{dispatchNo})
""")
List<Long> unavailableDrivers(@Param("dispatchNo") String dispatchNo);
record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { } record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { }
} }
@@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements") @RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
public class MerchantOverseasProcurementController { public class MerchantOverseasProcurementController {
@Resource private OverseasProcurementService service; @Resource private OverseasProcurementService service;
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets;
@Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService; @Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService;
@PostMapping @PostMapping
@@ -31,7 +32,8 @@ public class MerchantOverseasProcurementController {
public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerView> create( public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerView> create(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody OverseasProcurementRequests.Create request) { @Valid @RequestBody OverseasProcurementRequests.Create request) {
var created=service.create(customer(user),operator(user),request); var created=budgets.submit(customer(user),"procurement",request.idempotencyKey(),request.budgetUnitPrice().multiply(java.math.BigDecimal.valueOf(request.purchaseQuantity())),request.currency(),
()->service.create(customer(user),operator(user),request),result->result.summary().orderNo());
return ApiResponse.success(queryService.customer(customer(user),created.summary().orderNo())); return ApiResponse.success(queryService.customer(customer(user),created.summary().orderNo()));
} }
@@ -29,6 +29,12 @@ public class MerchantProcurementWorkflowController {
public ApiResponse<Workflow> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@Valid @RequestBody Command request) { public ApiResponse<Workflow> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@Valid @RequestBody Command request) {
return ApiResponse.success(workflow.confirm(customer(user),orderNo,user.userId()==null?user.username():String.valueOf(user.userId()),request)); return ApiResponse.success(workflow.confirm(customer(user),orderNo,user.userId()==null?user.username():String.valueOf(user.userId()),request));
} }
/** 客户驳回必须说明原因,由当前认证客户确认归属。 */
@PostMapping("/{orderNo}/reject")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_CREATE+"')")
public ApiResponse<Workflow> reject(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@Valid @RequestBody Rejection request) {
return ApiResponse.success(workflow.reject(customer(user),orderNo,user.userId()==null?user.username():String.valueOf(user.userId()),request));
}
/** 只下载本单收货与出库已关联凭证,其他内部文件不外露。 */ /** 只下载本单收货与出库已关联凭证,其他内部文件不外露。 */
@GetMapping("/{orderNo}/files/{fileId}/content") @GetMapping("/{orderNo}/files/{fileId}/content")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_LIST+"')") @PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_LIST+"')")
@@ -29,6 +29,12 @@ public class ProcurementWorkflowController {
public ApiResponse<Workflow> receive(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) { public ApiResponse<Workflow> receive(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) {
return ApiResponse.success(service.receive(customerId,orderNo,platform(user,customerId),request)); return ApiResponse.success(service.receive(customerId,orderNo,platform(user,customerId),request));
} }
/** 客户驳回后的照片替换复用正式收货权限。 */
@PostMapping("/{orderNo}/receipt-photos")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
public ApiResponse<Workflow> replacePhotos(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody ReceiptPhotos request) {
return ApiResponse.success(service.replacePhotos(customerId,orderNo,platform(user,customerId),request));
}
/** 将本单实收交给客户确认。 */ /** 将本单实收交给客户确认。 */
@PostMapping("/{orderNo}/request-confirmation") @PostMapping("/{orderNo}/request-confirmation")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')") @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
@@ -8,6 +8,10 @@ import java.util.List;
public final class ProcurementWorkflowDtos { public final class ProcurementWorkflowDtos {
private ProcurementWorkflowDtos() { } private ProcurementWorkflowDtos() { }
public record Command(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey) { } public record Command(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey) { }
public record Rejection(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotBlank @Size(max=1000) String reason) { }
public record ReceiptPhotos(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long receiptId,@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { }
public record Receipt(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, public record Receipt(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long quantity,@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { } @Positive long quantity,@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { }
public record Outbound(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey, public record Outbound(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@@ -22,5 +26,5 @@ public final class ProcurementWorkflowDtos {
public record FileInfo(Long fileId,String fileName,String contentType) { } public record FileInfo(Long fileId,String fileName,String contentType) { }
public record Workflow(String orderNo,long version,String status,long receivedQuantity,long stockQuantity, public record Workflow(String orderNo,long version,String status,long receivedQuantity,long stockQuantity,
long reservedQuantity,long availableQuantity,List<ReceiptRow> receipts, long reservedQuantity,long availableQuantity,List<ReceiptRow> receipts,
List<OutboundRow> outbounds,List<FileInfo> files) { } List<OutboundRow> outbounds,List<FileInfo> files,String rejectionReason,boolean photosNeedUpdate) { }
} }
@@ -33,6 +33,8 @@ public class OverseasProcurementOrder {
private Long inboundQuantity; private Long inboundQuantity;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt; @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt;
private String status; private String status;
private String customerRejectionReason;
private String rejectedPhotoIds;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
private String idempotencyKey; private String idempotencyKey;
private String requestHash; private String requestHash;
@@ -22,6 +22,9 @@ public interface ProcurementWorkflowMapper {
/** 保存带照片的分批收货。 */ /** 保存带照片的分批收货。 */
@Insert("INSERT INTO ll_procurement_workflow_receipt(id,order_id,quantity,file_ids,received_at,created_by) VALUES(#{id},#{orderId},#{quantity},#{files},CURRENT_TIMESTAMP(3),#{operator})") @Insert("INSERT INTO ll_procurement_workflow_receipt(id,order_id,quantity,file_ids,received_at,created_by) VALUES(#{id},#{orderId},#{quantity},#{files},CURRENT_TIMESTAMP(3),#{operator})")
void receipt(@Param("id") long id,@Param("orderId") long orderId,@Param("quantity") long quantity,@Param("files") String files,@Param("operator") String operator); void receipt(@Param("id") long id,@Param("orderId") long orderId,@Param("quantity") long quantity,@Param("files") String files,@Param("operator") String operator);
/** 驳回返工只替换本批照片,不重复增加收货数量。 */
@Update("UPDATE ll_procurement_workflow_receipt SET file_ids=#{files} WHERE id=#{id} AND order_id=#{orderId}")
int replacePhotos(@Param("id") long id,@Param("orderId") long orderId,@Param("files") String files);
/** 创建占用可用采购库存的出库单。 */ /** 创建占用可用采购库存的出库单。 */
@Insert("INSERT INTO ll_procurement_workflow_outbound(id,order_id,outbound_no,quantity,tracking_no,status,file_ids,created_at,created_by) VALUES(#{id},#{orderId},#{no},#{quantity},#{tracking},'IN_TRANSIT','[]',CURRENT_TIMESTAMP(3),#{operator})") @Insert("INSERT INTO ll_procurement_workflow_outbound(id,order_id,outbound_no,quantity,tracking_no,status,file_ids,created_at,created_by) VALUES(#{id},#{orderId},#{no},#{quantity},#{tracking},'IN_TRANSIT','[]',CURRENT_TIMESTAMP(3),#{operator})")
void outbound(@Param("id") long id,@Param("orderId") long orderId,@Param("no") String no,@Param("quantity") long quantity,@Param("tracking") String tracking,@Param("operator") String operator); void outbound(@Param("id") long id,@Param("orderId") long orderId,@Param("no") String no,@Param("quantity") long quantity,@Param("tracking") String tracking,@Param("operator") String operator);
@@ -27,6 +27,8 @@ public class ProcurementWorkflowRepository {
public List<Map<String,Object>> outbounds(long id) { return facts.outbounds(id); } public List<Map<String,Object>> outbounds(long id) { return facts.outbounds(id); }
/** 写入收货事实。 */ /** 写入收货事实。 */
public void receipt(long id,long orderId,long quantity,String files,String operator) { facts.receipt(id,orderId,quantity,files,operator); } public void receipt(long id,long orderId,long quantity,String files,String operator) { facts.receipt(id,orderId,quantity,files,operator); }
/** 照片返工保留原始收货数量和时间。 */
public int replacePhotos(long id,long orderId,String files) { return facts.replacePhotos(id,orderId,files); }
/** 写入出库事实。 */ /** 写入出库事实。 */
public void outbound(long id,long orderId,String no,long quantity,String tracking,String operator) { facts.outbound(id,orderId,no,quantity,tracking,operator); } public void outbound(long id,long orderId,String no,long quantity,String tracking,String operator) { facts.outbound(id,orderId,no,quantity,tracking,operator); }
/** 更新出库批次。 */ /** 更新出库批次。 */
@@ -49,6 +49,7 @@ public class ProcurementWorkflowService {
if(replay(order,request.idempotencyKey(),"REQUEST_CONFIRMATION",request)) return snapshot(order); if(replay(order,request.idempotencyKey(),"REQUEST_CONFIRMATION",request)) return snapshot(order);
version(order,request.version());state(order,"RECEIVING"); version(order,request.version());state(order,"RECEIVING");
if(snapshot(order).receivedQuantity()<=0) throw conflict("请先登记收货照片和数量"); if(snapshot(order).receivedQuantity()<=0) throw conflict("请先登记收货照片和数量");
if(snapshot(order).photosNeedUpdate()) throw conflict("客户已驳回,请重新上传各收货批次照片后再提交确认");
order.setStatus("WAIT_CUSTOMER_CONFIRM"); order.setStatus("WAIT_CUSTOMER_CONFIRM");
finish(order,operator,request.idempotencyKey(),"REQUEST_CONFIRMATION",request); finish(order,operator,request.idempotencyKey(),"REQUEST_CONFIRMATION",request);
return snapshot(order); return snapshot(order);
@@ -65,6 +66,33 @@ public class ProcurementWorkflowService {
return snapshot(order); return snapshot(order);
} }
/** 客户驳回后回到收货中,冻结本轮照片快照以强制重新上传。 */
@Transactional
public Workflow reject(String customer,String no,String operator,Rejection request) {
var order=order(customer,no,true);
if(replay(order,request.idempotencyKey(),"CUSTOMER_REJECT",request)) return snapshot(order);
version(order,request.version());state(order,"WAIT_CUSTOMER_CONFIRM");
if(request.reason()==null || request.reason().isBlank() || request.reason().length()>1000) throw bad("请填写驳回原因,最多1000字");
order.setCustomerRejectionReason(request.reason().trim());
order.setRejectedPhotoIds(serialize(repository.files(order).stream().map(f->f.getFileId()).distinct().toList()));
order.setStatus("RECEIVING");
finish(order,operator,request.idempotencyKey(),"CUSTOMER_REJECT",request);return snapshot(order);
}
/** 平台逐批重新上传图片,禁止复用本轮被驳回照片或追加虚假收货量。 */
@Transactional
public Workflow replacePhotos(String customer,String no,String operator,ReceiptPhotos request) {
var order=order(customer,no,true);
if(replay(order,request.idempotencyKey(),"REPLACE_PHOTOS",request)) return snapshot(order);
version(order,request.version());state(order,"RECEIVING");
if(order.getCustomerRejectionReason()==null) throw conflict("只有客户驳回后可以重新上传收货照片");
files(order,request.fileIds(),true);
var rejected=ids(order.getRejectedPhotoIds());
if(request.fileIds().stream().anyMatch(rejected::contains)) throw bad("请重新上传照片,不能复用被驳回的图片");
if(repository.replacePhotos(request.receiptId(),order.getId(),serialize(request.fileIds()))!=1) throw bad("收货批次不属于本采购单");
finish(order,operator,request.idempotencyKey(),"REPLACE_PHOTOS",request);return snapshot(order);
}
/** 创建出库先占用库存,避免其他批次重复使用相同数量;完成才实际扣除。 */ /** 创建出库先占用库存,避免其他批次重复使用相同数量;完成才实际扣除。 */
@Transactional @Transactional
public Workflow outbound(String customer,String no,String operator,Outbound request) { public Workflow outbound(String customer,String no,String operator,Outbound request) {
@@ -152,7 +180,7 @@ public class ProcurementWorkflowService {
long reserved=outbounds.stream().filter(b->!Set.of("COMPLETED","CANCELLED").contains(b.status())).mapToLong(OutboundRow::quantity).sum(); long reserved=outbounds.stream().filter(b->!Set.of("COMPLETED","CANCELLED").contains(b.status())).mapToLong(OutboundRow::quantity).sum();
Set<Long> visible=new HashSet<>();receipts.forEach(r->visible.addAll(r.fileIds()));outbounds.forEach(b->visible.addAll(b.fileIds())); Set<Long> visible=new HashSet<>();receipts.forEach(r->visible.addAll(r.fileIds()));outbounds.forEach(b->visible.addAll(b.fileIds()));
var files=visible.isEmpty()?List.<FileInfo>of():repository.files(order).stream().filter(f->visible.contains(f.getFileId())).map(f->new FileInfo(f.getFileId(),f.getFileName(),f.getContentType())).toList(); var files=visible.isEmpty()?List.<FileInfo>of():repository.files(order).stream().filter(f->visible.contains(f.getFileId())).map(f->new FileInfo(f.getFileId(),f.getFileName(),f.getContentType())).toList();
return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files); return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files,order.getCustomerRejectionReason(),order.getRejectedPhotoIds()!=null && receipts.stream().flatMap(r->r.fileIds().stream()).anyMatch(ids(order.getRejectedPhotoIds())::contains));
} }
/** 文件数量、客户归属、用途和照片类型均由服务端验证。 */ /** 文件数量、客户归属、用途和照片类型均由服务端验证。 */
@@ -16,6 +16,10 @@ import java.util.List;
/** 只读取换标及库存事实;不连接排车账本,不取得库存位置锁。 */ /** 只读取换标及库存事实;不连接排车账本,不取得库存位置锁。 */
@Mapper @Mapper
public interface RelabelDispatchSourceMapper { public interface RelabelDispatchSourceMapper {
/** 已实际废弃箱不再贡献排车库存,原换标明细及作业事实继续保留追溯。 */
@Select("SELECT DISTINCT i.id FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc ON dc.customer_id=i.customer_id "
+ "AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED' WHERE i.application_id=#{id}")
List<Long> discardedItems(@Param("id") Long id);
String PALLETS = "<foreach collection='palletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>"; String PALLETS = "<foreach collection='palletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>";
/** 找出实托当前所有明细关联的申请,不按状态或页面资格筛选。 */ /** 找出实托当前所有明细关联的申请,不按状态或页面资格筛选。 */
@@ -6,6 +6,10 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper @Mapper
public interface RelabelItemMapper extends BaseMapper<RelabelItem> { public interface RelabelItemMapper extends BaseMapper<RelabelItem> {
/** 废弃完成后原明细仅用于追溯,不再参与剩余箱的绑定门禁。 */
@org.apache.ibatis.annotations.Select("SELECT DISTINCT i.id FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc "
+ "ON dc.customer_id=i.customer_id AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED' WHERE i.application_id=#{id}")
java.util.List<Long> discardedItems(@org.apache.ibatis.annotations.Param("id") Long id);
/** 批量查询退货箱已完成的换标事实,避免逐箱查询。 */ /** 批量查询退货箱已完成的换标事实,避免逐箱查询。 */
@org.apache.ibatis.annotations.Select("<script>SELECT DISTINCT i.return_carton_id FROM ll_relabel_item i " + @org.apache.ibatis.annotations.Select("<script>SELECT DISTINCT i.return_carton_id FROM ll_relabel_item i " +
"JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " + "JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " +
@@ -58,10 +58,11 @@ public class RelabelDispatchSourceRepository {
} }
// 正式取消已释放 HOLD,旧 item 指针仅供追溯;不把它重新解释为当前贡献或沿其他托盘扩组。 // 正式取消已释放 HOLD,旧 item 指针仅供追溯;不把它重新解释为当前贡献或沿其他托盘扩组。
// 请求实托的全部真实位置已在上方保留,残余 HOLD 仍交现有消费方完整核对。 // 请求实托的全部真实位置已在上方保留,残余 HOLD 仍交现有消费方完整核对。
if ("CANCELLED".equals(application.getStatus())) continue; if ("CANCELLED".equals(application.getStatus()) || "DISCARDED".equals(application.getFulfillmentStatus())) continue;
List<RelabelItem> items = mapper.items(applicationId); var discarded = new java.util.HashSet<>(mapper.discardedItems(applicationId));
List<RelabelItem> items = mapper.items(applicationId).stream().filter(i -> !discarded.contains(i.getId())).toList();
applications.add(new ApplicationRows(application, items, mapper.tasks(applicationId), applications.add(new ApplicationRows(application, items, mapper.tasks(applicationId),
mapper.histories(applicationId), mapper.facts(applicationId))); mapper.histories(applicationId), mapper.facts(applicationId).stream().filter(f -> !discarded.contains(f.getItemId())).toList()));
for (RelabelItem item : items) { for (RelabelItem item : items) {
if (item.getSourceInventoryPositionId() == null) continue; if (item.getSourceInventoryPositionId() == null) continue;
InventoryPosition position = positions.get(item.getSourceInventoryPositionId()); InventoryPosition position = positions.get(item.getSourceInventoryPositionId());
@@ -27,9 +27,10 @@ public class RelabelBindingGuard {
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
public void requireBound(RelabelApplication app) { public void requireBound(RelabelApplication app) {
var discarded = new HashSet<>(items.discardedItems(app.getId()));
List<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>() List<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())) .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()))
.stream().filter(item -> (item.getReturnCartonId() != null && "SKU_LABEL".equals(item.getOperationType())) || Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL") .stream().filter(item -> !discarded.contains(item.getId())).filter(item -> (item.getReturnCartonId() != null && "SKU_LABEL".equals(item.getOperationType())) || Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL")
.contains(item.getOperationType())).toList(); .contains(item.getOperationType())).toList();
if (cartons.isEmpty()) return; if (cartons.isEmpty()) return;
Map<String, Set<String>> bindings = new HashMap<>(); Map<String, Set<String>> bindings = new HashMap<>();
@@ -25,12 +25,14 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/merchant/v1/last-leg/us-truck-orders") @RequestMapping("/api/merchant/v1/last-leg/us-truck-orders")
public class MerchantUsTruckOrderController { public class MerchantUsTruckOrderController {
@Resource private UsTruckOrderService service; @Resource private UsTruckOrderService service;
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets;
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_CREATE + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_CREATE + "')")
public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody UsTruckOrderRequests.Create request) { @Valid @RequestBody UsTruckOrderRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request)); return ApiResponse.success(budgets.submit(customer(user),"trucking",request.idempotencyKey(),request.budgetAmount(),request.currency(),
()->service.create(customer(user),operator(user),request),result->result.summary().orderNo()));
} }
@GetMapping @GetMapping
@@ -51,7 +53,8 @@ public class MerchantUsTruckOrderController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_EDIT + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_EDIT + "')")
public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) { @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) {
return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request)); return ApiResponse.success(budgets.update(customer(user),"trucking",orderNo,request.budgetAmount(),request.currency(),
()->service.update(customer(user),orderNo,operator(user),request)));
} }
private String customer(AuthenticatedUser user) { private String customer(AuthenticatedUser user) {
@@ -27,10 +27,10 @@ public final class UsTruckOrderRequests {
@Size(max = 500) String originAddress, @Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination, @NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress, @Size(max = 500) String destinationAddress,
@Min(0) Integer packageCount, @NotNull @Positive Integer palletCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount, @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency, @Size(min = 3, max = 3) String currency,
@NotBlank @Size(max = 128) String originContactName, @NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone, @NotBlank @Size(max = 128) String originContactPhone,
@@ -67,10 +67,10 @@ public final class UsTruckOrderRequests {
@Size(max = 500) String originAddress, @Size(max = 500) String originAddress,
@Size(max = 255) String destination, @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress, @Size(max = 500) String destinationAddress,
@Min(0) Integer packageCount, @NotNull @Positive Integer palletCount,
@DecimalMin("0.00") BigDecimal totalWeightKg, @DecimalMin("0.00") BigDecimal totalWeightKg,
@DecimalMin("0.00") BigDecimal totalVolumeCbm, @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount, @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency, @Size(min = 3, max = 3) String currency,
@NotBlank @Size(max = 128) String originContactName, @NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone, @NotBlank @Size(max = 128) String originContactPhone,
@@ -77,8 +77,10 @@ public class UsTruckOrderService {
order.setVehicleType(request.vehicleType()); order.setVehicleType(request.vehicleType());
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
// 车型只表达用车需求;实际托数由收货记录,不能按车型虚构货量。 // 客户申报托数独立保存,件数由后续实际收货提供。
order.setPalletCount(0); order.setPackageCount(value(request.packageCount())); if(request.palletCount()==null || request.palletCount()<=0) throw badRequest("请输入大于0的托数");
if(request.budgetAmount()==null || request.budgetAmount().signum()<=0) throw badRequest("请输入大于0的预算总价");
order.setPalletCount(request.palletCount()); order.setPackageCount(0);
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0);
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm()); order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency())); order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
@@ -127,7 +129,7 @@ public class UsTruckOrderService {
order.setVehicleType(request.vehicleType()); order.setVehicleType(request.vehicleType());
setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress); setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress);
setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress); setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress);
if (request.packageCount() != null) order.setPackageCount(request.packageCount()); if (request.palletCount() != null) order.setPalletCount(request.palletCount());
if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm()); if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm());
if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency())); if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency()));
order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim()); order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim());
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.wallet.budget;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
/** 客户下单只使用当前认证客户资金,不接受请求客户ID。 */
@RestController @RequiredArgsConstructor
@RequestMapping("/api/merchant/v1/last-leg/order-budgets")
public class MerchantOrderBudgetController {
private final OrderBudgetService service;
public record Prepare(@NotNull @DecimalMin("0") @Digits(integer=12,fraction=2) BigDecimal amount,
@NotBlank @Pattern(regexp="CNY|USD") String currency) { }
/** 按业务创建权限发起预估冻结。 */
@PostMapping("/{kind}/{key}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
public ApiResponse<OrderBudgetService.View> prepare(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable @Size(max=128) String key,@Valid @RequestBody Prepare request) {
return ApiResponse.success(service.prepare(customer(user),kind,key,request.amount(),request.currency()));
}
/** 只读资金回执沿同一业务权限。 */
@GetMapping("/{kind}/{key}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) {
return ApiResponse.success(service.read(customer(user),kind,key));
}
/** 客户身份只取认证上下文,平台身份不可借用。 */
private String customer(AuthenticatedUser user) {
if(user==null || user.tenantId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(user.tenantId()<=0 || "PLATFORM".equals(user.appCode())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
return user.tenantId().toString();
}
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 下单预估冻结的本地回执投影,不维护余额或授信账本。 */
@Data @TableName("ll_order_budget_reservation")
public class OrderBudget {
@TableId(type=IdType.INPUT) private Long id;
private String customerId;
private String kind;
private String requestKey;
private String orderNo;
private BigDecimal originalAmount;
private String currency;
private BigDecimal amountCny;
private String status;
private String errorMessage;
private String commandEventId;
private Long commandVersion;
private Boolean releaseRequested;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 全部预算冻结查询按客户和业务隔离,订单绑定与资金回执串行更新。 */
@Mapper
public interface OrderBudgetMapper extends BaseMapper<OrderBudget> {
/** 按当前客户、业务与下单键读取并锁定。 */
@Select("SELECT * FROM ll_order_budget_reservation WHERE customer_id=#{customer} AND kind=#{kind} AND request_key=#{key} FOR UPDATE")
OrderBudget lock(@Param("customer") String customer,@Param("kind") String kind,@Param("key") String key);
/** 读取已绑定订单的原始预算。 */
@Select("SELECT * FROM ll_order_budget_reservation WHERE customer_id=#{customer} AND kind=#{kind} AND order_no=#{no} FOR UPDATE")
OrderBudget lockOrder(@Param("customer") String customer,@Param("kind") String kind,@Param("no") String no);
/** 资金结果只更新当前记录,禁止跨客户凭单号写入。 */
@Select("SELECT * FROM ll_order_budget_reservation WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE")
OrderBudget lockId(@Param("id") long id,@Param("customer") String customer);
/** 清理未下单预占及已经取消、拒绝或履约结束的订单预算,实际费用仍沿共用费用链。 */
@Select("""
SELECT b.* FROM ll_order_budget_reservation b
WHERE (b.status='RELEASE_FAILED' AND b.updated_at < TIMESTAMPADD(MINUTE,-1,CURRENT_TIMESTAMP(3))) OR (b.status IN ('FREEZE_PENDING','FROZEN','FAILED') AND b.release_requested=0 AND (
(b.order_no IS NULL AND b.created_at < TIMESTAMPADD(MINUTE,-15,CURRENT_TIMESTAMP(3)))
OR (b.kind='procurement' AND EXISTS (SELECT 1 FROM ll_overseas_procurement_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED','EXCEPTION_CLOSED')))
OR (b.kind='trucking' AND EXISTS (SELECT 1 FROM ll_us_truck_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED')))
)) ORDER BY b.id LIMIT 100 FOR UPDATE SKIP LOCKED
""")
List<OrderBudget> releasable();
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.wallet.budget;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 下单预算的唯一持久化入口。 */
@Repository @RequiredArgsConstructor
public class OrderBudgetRepository {
private final OrderBudgetMapper mapper;
/** 预算写入同业务事务。 */
public void insert(OrderBudget row) { mapper.insert(row); }
/** 保存当前资金指令与状态。 */
public void save(OrderBudget row) { mapper.updateById(row); }
/** 客户及下单键隔离锁定。 */
public OrderBudget lock(String customer,String kind,String key) { return mapper.lock(customer,kind,key); }
/** 已下单后的预估预算不可通过普通编辑绕过冻结。 */
public OrderBudget lockOrder(String customer,String kind,String no) { return mapper.lockOrder(customer,kind,no); }
/** 资金回执按原始记录及客户锁定。 */
public OrderBudget lockId(long id,String customer) { return mapper.lockId(id,customer); }
/** 有界读取待释放记录。 */
public List<OrderBudget> releasable() { return mapper.releasable(); }
}
@@ -0,0 +1,140 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.wallet.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.math.BigDecimal;
import java.time.*;
import java.util.Objects;
import java.util.Set;
import java.util.function.*;
/** Finance 冻结回执成功后才能提交客户订单;不足或超时均不能绕过。 */
@Service @RequiredArgsConstructor
public class OrderBudgetService implements WalletResultHandler {
public static final String BUSINESS_TYPE="OVERSEAS_ORDER_BUDGET";
private final OrderBudgetRepository repository;
private final OverseasFeeCurrencyConverter converter;
private final WalletCommandOutboxService wallet;
public record View(String status,BigDecimal amountCny,String message,String orderNo,boolean usable) { }
/** 预估按当月有效汇率折人民币;余额优先及授信拆分由Finance原子完成。 */
@Transactional
public View prepare(String customer,String kind,String key,BigDecimal amount,String currency) {
requireKind(kind);
if(key==null || key.isBlank() || key.length()>128 || amount==null || (amount.signum()<0 || ("trucking".equals(kind) && amount.signum()==0)) || amount.scale()>2 || !Set.of("CNY","USD").contains(currency)) throw bad("请填写有效的预估金额(卡车须大于0),最多两位小数");
var row=repository.lock(customer,kind,key);
if(row!=null) {
requireAmount(row,amount,currency);
if(!Boolean.TRUE.equals(row.getReleaseRequested()) && "FAILED".equals(row.getStatus())) sendFreeze(row);
return view(row);
}
row=new OrderBudget();row.setId(IdWorker.getId());row.setCustomerId(customer);row.setKind(kind);row.setRequestKey(key);
row.setOriginalAmount(amount);row.setCurrency(currency);
row.setAmountCny(amount.signum()==0 ? BigDecimal.ZERO : converter.convert(currency,amount,LocalDate.now(ZoneOffset.UTC)).settlementAmount());
if(amount.signum()>0 && row.getAmountCny().signum()<=0) throw bad("预估折算人民币金额须大于0");
row.setCommandVersion(0L);row.setReleaseRequested(false);row.setCreatedAt(now());row.setUpdatedAt(now());
row.setStatus("FREEZE_PENDING");repository.insert(row);if(amount.signum()==0) { row.setStatus("FROZEN");repository.save(row); } else sendFreeze(row);return view(row);
}
/** 仅返回当前客户的预占状态,不暴露资金账户或内部事件。 */
@Transactional
public View read(String customer,String kind,String key) {
requireKind(kind);var row=repository.lock(customer,kind,key);
if(row==null) throw bad("未找到本次下单的资金校验记录");return view(row);
}
/** 预算锁、创建订单及绑定在同一事务;失败保留预占供相同下单键重试。 */
@Transactional
public <T> T submit(String customer,String kind,String key,BigDecimal amount,String currency,Supplier<T> create,Function<T,String> number) {
var row=repository.lock(customer,kind,key);
if(row==null) throw bad("请先完成预估金额及余额授信校验");
requireAmount(row,amount,currency);
if(row.getOrderNo()!=null) {
var prior=create.get();if(!row.getOrderNo().equals(number.apply(prior))) throw bad("本次冻结已用于其他订单");return prior;
}
if(!"FROZEN".equals(row.getStatus()) || Boolean.TRUE.equals(row.getReleaseRequested())) throw bad(view(row).message());
if(row.getOrderNo()==null && row.getCreatedAt().isBefore(now().minusMinutes(15))) throw bad("本次资金预占已过期,请重新下单");
var result=create.get();String no=number.apply(result);
if(row.getOrderNo()!=null && !row.getOrderNo().equals(no)) throw bad("本次冻结已用于其他订单");
row.setOrderNo(no);row.setUpdatedAt(now());repository.save(row);return result;
}
/** 原订单预算锁与普通编辑同事务,金额变化须重新下单冻结。 */
@Transactional
public <T> T update(String customer,String kind,String no,BigDecimal amount,String currency,Supplier<T> change) {
var row=repository.lockOrder(customer,kind,no);
if(row!=null) requireAmount(row,amount,currency);
return change.get();
}
/** 仅接收本服务独立预算资金身份。 */
@Override public boolean supports(String type) { return BUSINESS_TYPE.equals(type); }
/** 相关指令ID、客户、操作、金额均匹配后才接受最终回执。 */
@Override @Transactional
public void handle(WalletCommandCompletedV1 result) {
var row=repository.lockId(Long.parseLong(result.businessNo()),String.valueOf(result.customerId()));
if(row==null || !Objects.equals(row.getCommandEventId(),result.commandEventId())) return;
String operation="FREEZE_PENDING".equals(row.getStatus())?"FREEZE":"RELEASE_PENDING".equals(row.getStatus())?"RELEASE":null;
if(operation==null) return;
if(!operation.equals(result.operation()) || result.amount()==null || row.getAmountCny().compareTo(result.amount())!=0) throw bad("预算资金回执与原指令不一致");
if("SUCCEEDED".equals(result.result())) {
String expected="FREEZE".equals(operation)?"FROZEN":"RELEASED";
if(!expected.equals(result.holdStatus())) throw bad("预算资金回执状态无效");
row.setStatus(expected);row.setErrorMessage("");
if("FROZEN".equals(expected) && Boolean.TRUE.equals(row.getReleaseRequested())) sendRelease(row);
} else if("FAILED".equals(result.result())) {
row.setStatus("FREEZE".equals(operation)?"FAILED":"RELEASE_FAILED");
row.setErrorMessage(result.errorMessage()==null?"资金处理失败,请联系平台处理":result.errorMessage());
} else throw bad("预算资金结果无效");
row.setUpdatedAt(now());repository.save(row);
}
/** 未提交预占到期释放;终态订单不再占用预算;冻结在途时先登记释放意图。 */
@Scheduled(fixedDelay=30000) @Transactional
public void releaseFinished() {
for(var row:repository.releasable()) {
row.setReleaseRequested(true);
if(Set.of("FROZEN","RELEASE_FAILED").contains(row.getStatus())) { if(row.getAmountCny().signum()==0) row.setStatus("RELEASED"); else sendRelease(row); }
else if("FAILED".equals(row.getStatus())) row.setStatus("RELEASED");
row.setUpdatedAt(now());repository.save(row);
}
}
/** 生成单调版本指令,失败重试不能冒充旧回执成功。 */
private void sendFreeze(OrderBudget row) {
row.setCommandVersion(row.getCommandVersion()+1);row.setStatus("FREEZE_PENDING");row.setErrorMessage("");
row.setCommandEventId(wallet.publish("FREEZE",Long.parseLong(row.getCustomerId()),BUSINESS_TYPE,row.getId().toString(),row.getAmountCny(),"海外订单预估金额冻结",row.getCommandVersion()));
row.setUpdatedAt(now());repository.save(row);
}
/** 释放仍由Finance处理,发出指令不能当作已解冻。 */
private void sendRelease(OrderBudget row) {
row.setCommandVersion(row.getCommandVersion()+1);row.setStatus("RELEASE_PENDING");
row.setCommandEventId(wallet.publish("RELEASE",Long.parseLong(row.getCustomerId()),BUSINESS_TYPE,row.getId().toString(),row.getAmountCny(),"海外订单预算预占释放",row.getCommandVersion()));
}
/** 预算、币种和真实下单值必须一致,禁止以小额冻结提交大额订单。 */
private void requireAmount(OrderBudget row,BigDecimal amount,String currency) {
if(amount==null || row.getOriginalAmount().compareTo(amount)!=0 || !row.getCurrency().equals(currency)) throw bad("本次预估金额已变化,请重新校验资金");
}
/** 资金状态使用确定性文案,失败透传Finance不足原因。 */
private View view(OrderBudget row) {
String message=switch(row.getStatus()) {
case "FROZEN" -> (Boolean.TRUE.equals(row.getReleaseRequested()) || (row.getOrderNo()==null && row.getCreatedAt().isBefore(now().minusMinutes(15))))?"本次资金预占已过期,请重新下单":"预估金额冻结成功";
case "FAILED","RELEASE_FAILED" -> row.getErrorMessage();
case "RELEASED","RELEASE_PENDING" -> "本次资金预占已释放,请重新下单";
default -> "余额及授信校验处理中,请稍后再次提交";
};return new View(row.getStatus(),row.getAmountCny(),message,row.getOrderNo(),"FROZEN".equals(row.getStatus()) && !Boolean.TRUE.equals(row.getReleaseRequested()) && (row.getOrderNo()!=null || !row.getCreatedAt().isBefore(now().minusMinutes(15))));
}
/** 仅开放本轮客户下单业务。 */
private void requireKind(String kind) { if(!Set.of("procurement","trucking").contains(kind)) throw bad("不支持的预算业务"); }
/** 时间按UTC保存以匹配资金事件。 */
private LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
/** 返回可恢复的业务校验失败。 */
private ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
}
@@ -2913,7 +2913,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton ( CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID', id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER、换标箱RELABEL及结案标记',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id), UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id), KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
@@ -2945,6 +2945,8 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
reviewed_at DATETIME(3) NULL COMMENT '审核时间', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', reviewed_at DATETIME(3) NULL COMMENT '审核时间', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
customer_rejection_reason VARCHAR(1000) NULL COMMENT '客户采购结果驳回原因',
rejected_photo_ids TEXT NULL COMMENT '本轮客户驳回的收货照片主键快照',
PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no),
UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key), UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key),
KEY idx_ll_overseas_procurement_list(customer_id,status,created_at), KEY idx_ll_overseas_procurement_list(customer_id,status,created_at),
@@ -4589,3 +4591,24 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
PRIMARY KEY (order_id,command_key) PRIMARY KEY (order_id,command_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
KEY idx_order_budget_release(status,release_requested,created_at),
KEY idx_order_budget_order(customer_id,kind,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
@@ -20,9 +20,10 @@ info:
RETURN_CARTON selections consume every SKU in the selected physical carton. RETURN_CARTON selections consume every SKU in the selected physical carton.
Candidates return cartons, cartonSelectionSupported and cartonSelectionBlockReason. Candidates return cartons, cartonSelectionSupported and cartonSelectionBlockReason.
Stock without complete carton/pallet ownership remains available for full disposal only. Stock without complete carton/pallet ownership remains available for full disposal only.
PDA tasks return scanMode PALLET or CARTON and the real cartonMarks for loose stock. PDA tasks with real cartonMarks use CARTON scanMode, including full bound-pallet tasks.
Bound pallets require their pallet number; loose stock requires every distinct carton mark scanned, Every distinct selected carton mark must be scanned; confirmAllCartons cannot bypass this check.
or confirmAllCartons=true. The loose group uses 散箱 as its API path key, never as a scan code. Ordinary stock without carton marks uses PALLET mode and actual carton quantities.
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; 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. short actual quantities are rejected. At least one photo remains required.
Partial bound-pallet tasks use CARTON scanMode with the real palletNo and selected cartonMarks. Partial bound-pallet tasks use CARTON scanMode with the real palletNo and selected cartonMarks.
@@ -109,11 +110,12 @@ paths:
type: array type: array
uniqueItems: true uniqueItems: true
items: { type: string } items: { type: string }
description: All task cartonMarks must be scanned unless confirmAllCartons is true. description: All task cartonMarks must be scanned exactly once, regardless of confirmAllCartons.
confirmAllCartons: confirmAllCartons:
type: boolean type: boolean
default: false default: false
description: Explicitly confirm all loose cartons without individual scans; photos remain mandatory. deprecated: true
description: Compatibility field only; does not bypass individual carton scans. Photos remain mandatory.
photoFileIds: photoFileIds:
type: array type: array
minItems: 1 minItems: 1
@@ -42,7 +42,7 @@ paths:
- { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 } - { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 }
get: get:
summary: 排车运输详情(list) summary: 排车运输详情(list)
description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)、dispatchRecordVersion(排车审核版本)、resources(取货车辆司机照片快照;其他类型为null)。resources.driver.evidence.masterLicenseFileId仅表示司机主档驾驶证来源,包含在fileIds内;其他fileIds属于本车补传。 description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)、dispatchRecordVersion(排车审核版本)、resources(车辆司机照片快照)、cargo(本土卡车原始托数、箱数、重量kg、体积m³、起讫地地址及双方联系人电话;非本土卡车为null)。resources.driver.evidence.masterLicenseFileId仅表示司机主档驾驶证来源,包含在fileIds内;其他fileIds属于本车补传。
responses: *response responses: *response
/{number}/{action}: /{number}/{action}:
parameters: parameters:
@@ -28,6 +28,7 @@ paths:
- {name: region, in: query, schema: {type: string, enum: [DOMESTIC, OVERSEAS]}} - {name: region, in: query, schema: {type: string, enum: [DOMESTIC, OVERSEAS]}}
- {name: licenseState, in: query, schema: {type: string, enum: [MISSING, VALID, EXPIRING, EXPIRED]}, description: 仅驾驶证日期状态,与审核和附件状态分别展示} - {name: licenseState, in: query, schema: {type: string, enum: [MISSING, VALID, EXPIRING, EXPIRED]}, description: 仅驾驶证日期状态,与审核和附件状态分别展示}
- {name: expectedEnd, in: query, schema: {type: string, format: date-time}} - {name: expectedEnd, in: query, schema: {type: string, format: date-time}}
- {name: dispatchNo, in: query, schema: {type: string, maxLength: 64}, description: 排车候选上下文;在分页和统计前过滤其他车次占用,保留当前排车绑定司机}
responses: responses:
'200': '200':
description: 业务响应,分页数据 description: 业务响应,分页数据
@@ -0,0 +1,56 @@
openapi: 3.0.3
info:
title: 海外客户下单预估资金校验
version: 1.0.0
description: Finance异步冻结成功后才能创建客户采购/本土卡车订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。创建订单须使用相同幂等键、金额及币种。未绑定预占15分钟过期;订单终态释放,释放失败重试。预占独立于最终人工费用结算。
security:
- bearerAuth: []
paths:
/api/merchant/v1/last-leg/order-budgets/{kind}/{key}:
parameters:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
- {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
post:
summary: 发起冻结或读取原请求,失败后可用原键重试
description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [amount, currency]
properties:
amount: {type: number, minimum: 0, multipleOf: 0.01}
currency: {type: string, enum: [CNY, USD]}
responses:
'200': {$ref: '#/components/responses/Result'}
'403': {description: 身份或业务创建权限不足}
get:
summary: 读取当前客户本次下单的权威资金回执
description: 与POST相同创建权限;只有usable=true才允许继续下单。FREEZE_PENDING处理期间应轮询,超时保留原键重试;失败显示Finance返回原因。
responses:
'200': {$ref: '#/components/responses/Result'}
'403': {description: 身份或业务创建权限不足}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
responses:
Result:
description: 统一业务响应;code为字符串200表示查询或发起成功,不等于资金冻结成功。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
type: object
required: [status, amountCny, message, usable]
properties:
status: {type: string, enum: [FREEZE_PENDING, FROZEN, FAILED, RELEASE_PENDING, RELEASE_FAILED, RELEASED]}
amountCny: {type: number, minimum: 0}
message: {type: string}
orderNo: {type: string, nullable: true}
usable: {type: boolean}
@@ -5172,17 +5172,17 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 } remark: { type: string, nullable: true, maxLength: 1000 }
TruckCreate: TruckCreate:
type: object type: object
required: [vehicleType, origin, destination, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, idempotencyKey] required: [vehicleType, origin, destination, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount, idempotencyKey]
properties: properties:
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' } vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
origin: { type: string, maxLength: 255 } origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 } originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 } destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 } destinationAddress: { type: string, nullable: true, maxLength: 500 }
packageCount: { type: integer, nullable: true, minimum: 0 } palletCount: { type: integer, minimum: 1, description: '下单预估托数' }
totalWeightKg: { type: number, minimum: 0 } totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 } budgetAmount: { type: number, minimum: 0, exclusiveMinimum: true }
currency: { type: string, minLength: 3, maxLength: 3, default: USD } currency: { type: string, minLength: 3, maxLength: 3, default: USD }
originContactName: { type: string, maxLength: 128 } originContactName: { type: string, maxLength: 128 }
originContactPhone: { type: string, maxLength: 128 } originContactPhone: { type: string, maxLength: 128 }
@@ -5194,7 +5194,7 @@ components:
idempotencyKey: { type: string, maxLength: 255 } idempotencyKey: { type: string, maxLength: 255 }
TruckUpdate: TruckUpdate:
type: object type: object
required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt] required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount]
properties: properties:
version: { type: integer, format: int64, minimum: 0 } version: { type: integer, format: int64, minimum: 0 }
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' } vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
@@ -5202,10 +5202,10 @@ components:
originAddress: { type: string, nullable: true, maxLength: 500 } originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 } destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 } destinationAddress: { type: string, nullable: true, maxLength: 500 }
packageCount: { type: integer, minimum: 0 } palletCount: { type: integer, minimum: 1, description: '下单预估托数' }
totalWeightKg: { type: number, minimum: 0 } totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 } budgetAmount: { type: number, minimum: 0, exclusiveMinimum: true }
currency: { type: string, minLength: 3, maxLength: 3 } currency: { type: string, minLength: 3, maxLength: 3 }
originContactName: { type: string, maxLength: 128 } originContactName: { type: string, maxLength: 128 }
originContactPhone: { type: string, maxLength: 128 } originContactPhone: { type: string, maxLength: 128 }
@@ -6,6 +6,33 @@ info:
security: security:
- bearerAuth: [] - bearerAuth: []
paths: paths:
/api/merchant/v1/last-leg/overseas-procurements/{orderNo}/reject:
post:
summary: 客户驳回采购结果并填写原因
description: 仅当前认证客户,权限 merchant:cross-border:procurement:create。仅 WAIT_CUSTOMER_CONFIRM,成功回到 RECEIVING;保存原因及当前已上传照片快照,平台须重新上传各批收货照片才能再次提交确认。
parameters:
- $ref: '#/components/parameters/OrderNo'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RejectionCommand' }
responses:
'200': { $ref: '#/components/responses/WorkflowResponse' }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/receipt-photos:
post:
summary: 驳回后替换指定批次的收货照片
description: 权限 platform:overseas-business:purchase:receive-overseas。仅客户驳回后的 RECEIVING;文件须为本单新上传图片,不接受驳回前的文件;仅替换照片,不改变实收数量及时间。
parameters:
- $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ReceiptPhotosCommand' }
responses:
'200': { $ref: '#/components/responses/WorkflowResponse' }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/workflow: /api/platform/v1/last-leg/overseas-procurements/{orderNo}/workflow:
get: get:
summary: 读取采购实收、库存和出库批次 summary: 读取采购实收、库存和出库批次
@@ -43,7 +70,7 @@ paths:
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/request-confirmation: /api/platform/v1/last-leg/overseas-procurements/{orderNo}/request-confirmation:
post: post:
summary: 采购完成并请求客户确认 summary: 采购完成并请求客户确认
description: 权限 platform:overseas-business:purchase:receive-overseas;仅平台主体,必须指定目标客户 customerId。仅 RECEIVING 且已有实收;提交后不再追加收货。 description: 权限 platform:overseas-business:purchase:receive-overseas;仅平台主体,必须指定目标客户 customerId。仅 RECEIVING 且已有实收;客户驳回后必须替换各批旧照片。提交后不再追加收货。
parameters: parameters:
- $ref: '#/components/parameters/OrderNo' - $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId' - $ref: '#/components/parameters/CustomerId'
@@ -265,6 +292,21 @@ components:
uniqueItems: true uniqueItems: true
items: items:
$ref: '#/components/schemas/Id' $ref: '#/components/schemas/Id'
RejectionCommand:
allOf:
- $ref: '#/components/schemas/Command'
- type: object
required: [reason]
properties:
reason: {type: string, minLength: 1, maxLength: 1000}
ReceiptPhotosCommand:
allOf:
- $ref: '#/components/schemas/Command'
- type: object
required: [receiptId, fileIds]
properties:
receiptId: { $ref: '#/components/schemas/Id' }
fileIds: { $ref: '#/components/schemas/FileIds' }
ReceiptCommand: ReceiptCommand:
allOf: allOf:
- $ref: '#/components/schemas/Command' - $ref: '#/components/schemas/Command'
@@ -331,6 +373,8 @@ components:
required: [orderNo, version, status, receivedQuantity, stockQuantity, reservedQuantity, availableQuantity, receipts, outbounds, files] required: [orderNo, version, status, receivedQuantity, stockQuantity, reservedQuantity, availableQuantity, receipts, outbounds, files]
description: stockQuantity = receivedQuantity - 已完成出库数量;reservedQuantity 为未完成出库占用;availableQuantity = stockQuantity - reservedQuantity。主状态优先显示运输中批次,其次派送中;全部批次完成但仍有库存时回到已确认,无剩余库存才签收完成。 description: stockQuantity = receivedQuantity - 已完成出库数量;reservedQuantity 为未完成出库占用;availableQuantity = stockQuantity - reservedQuantity。主状态优先显示运输中批次,其次派送中;全部批次完成但仍有库存时回到已确认,无剩余库存才签收完成。
properties: properties:
rejectionReason: {type: string, nullable: true, description: 客户驳回原因}
photosNeedUpdate: {type: boolean, description: 存在尚未替换的被驳回照片}
orderNo: {type: string} orderNo: {type: string}
version: {type: integer, format: int64, minimum: 0} version: {type: integer, format: int64, minimum: 0}
status: status:
@@ -211,7 +211,7 @@ components:
actualPackages: {type: integer, minimum: 0} actualPackages: {type: integer, minimum: 0}
photoFileIds: {type: array, items: {type: string}} photoFileIds: {type: array, items: {type: string}}
scannedCartonMarks: {type: array, items: {type: string}} scannedCartonMarks: {type: array, items: {type: string}}
confirmAllCartons: {type: boolean} confirmAllCartons: {type: boolean, deprecated: true, description: 仅兼容旧请求,不能绕过逐箱扫码校验}
Preparation: Preparation:
type: object type: object
properties: properties:
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.mapper.CargoDiscardRelabelMapper;
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.*;
/** 执行真实 MyBatis SQL,验证仅已确认待发换标箱进入可选来源。 */
class CargoDiscardRelabelMapperTest {
@Test
void selectsConfirmedHeldBoxesAndExcludesClaimedOrShippedSources() {
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,overseas_warehouse_id VARCHAR(20),hold_packages BIGINT,pallet_id BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),hold_packages BIGINT,inbound_code VARCHAR(80))");
db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR(20),relabel_no VARCHAR(80),overseas_warehouse_id VARCHAR(20),status VARCHAR(40),fulfillment_status VARCHAR(40),customer_confirmed_at TIMESTAMP,outbound_order_no VARCHAR(80),updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_relabel_item(id BIGINT,application_id BIGINT,customer_id VARCHAR(20),source_inventory_position_id BIGINT,return_carton_id BIGINT,notified_package_count INT)");
db.execute("CREATE TABLE ll_cargo_discard_carton(customer_id VARCHAR(20),carton_id BIGINT,source_type VARCHAR(32))");
db.execute("CREATE TABLE ll_warehouse_transfer_inventory_allocation(customer_id VARCHAR(20),inventory_position_id BIGINT,status VARCHAR(30))");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,carton_mark VARCHAR(80),customer_carton_mark VARCHAR(80))");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT)");
db.execute("INSERT INTO ll_inventory_position VALUES(10,'C',20,'W',2,30)");
db.execute("INSERT INTO ll_inventory_balance VALUES(20,'C',2,'ZY-1')");
db.execute("INSERT INTO ll_relabel_application VALUES(1,'C','TB-1','W','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH',CURRENT_TIMESTAMP,NULL,NULL),(2,'C','TB-2','W','READY_FOR_OUTBOUND','PRE_DISPATCH_CREATED',CURRENT_TIMESTAMP,NULL,NULL)");
db.execute("INSERT INTO ll_relabel_item VALUES(101,1,'C',10,NULL,1),(102,1,'C',10,NULL,1),(201,2,'C',10,NULL,1)");
db.execute("INSERT INTO ll_cargo_discard_carton VALUES('C',101,'RELABEL_DISCARDED')");
db.execute("ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT");
db.execute("ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(80)");
db.execute("UPDATE ll_relabel_item SET source_inbound_order_id=50,carton_mark='CUSTOMER-BOX-1' WHERE id=102");
db.execute("INSERT INTO ll_warehouse_transfer_order VALUES(60,'C',50),(61,'OTHER',50),(62,'C',51)");
db.execute("INSERT INTO ll_warehouse_transfer_carton VALUES(70,'C',60,'BX-SYSTEM-1','CUSTOMER-BOX-1'),"
+ "(71,'OTHER',61,'BX-OTHER-CUSTOMER','CUSTOMER-BOX-1'),(72,'C',62,'BX-OTHER-INBOUND','CUSTOMER-BOX-1')");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
configuration.setMapUnderscoreToCamelCase(true); configuration.addMapper(CargoDiscardRelabelMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession(true)) {
var mapper = session.getMapper(CargoDiscardRelabelMapper.class);
assertEquals(java.util.List.of(102L), mapper.cartons("C", 10L).stream().map(i -> i.getId()).toList());
assertEquals(java.util.List.of(10L), mapper.positions("C", "ZY-1").stream().map(p -> p.getId()).toList());
assertTrue(mapper.lockEligible("C", 102L) != null);
assertNull(mapper.lockEligible("C", 101L));
assertTrue(mapper.cartons("OTHER", 10L).isEmpty());
assertTrue(mapper.cartons("C", 999L).isEmpty());
assertEquals(java.util.List.of("BX-SYSTEM-1"), mapper.systemCartonMarks("C", 102L));
assertTrue(mapper.systemCartonMarks("OTHER", 102L).isEmpty());
db.update("UPDATE ll_relabel_item SET carton_mark='BX-SYSTEM-1' WHERE id=102");
session.clearCache();
assertEquals(java.util.List.of("BX-SYSTEM-1"), mapper.systemCartonMarks("C", 102L));
assertEquals(1, mapper.refresh("C", "TB-1"));
}
}
}
@@ -210,8 +210,11 @@ class PdaCargoDiscardServiceTest {
noWrites(); verifyNoInteractions(photoService); noWrites(); verifyNoInteractions(photoService);
} }
@ParameterizedTest @ValueSource(booleans = {false, true}) @ParameterizedTest @ValueSource(booleans = {false, true})
void looseTaskCompletesAfterAllScansOrExplicitBulkAndReplays(boolean bulk) { void looseTaskRequiresAllScansEvenWithBulkFlagAndReplays(boolean bulk) {
looseTask(); var request = looseRequest(bulk ? List.of() : List.of("BOX-2", "BOX-1"), bulk); looseTask();
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "散箱", looseRequest(List.of(), bulk)));
noWrites(); verifyNoInteractions(photoService);
var request = looseRequest(List.of("BOX-2", "BOX-1"), bulk);
assertEquals("COMPLETED", service.confirm(identity, "CD-1", "散箱", request).status()); assertEquals("COMPLETED", service.confirm(identity, "CD-1", "散箱", request).status());
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus()); assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus());
service.confirm(identity, "CD-1", "散箱", request); service.confirm(identity, "CD-1", "散箱", request);
@@ -242,8 +245,9 @@ class PdaCargoDiscardServiceTest {
verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any()); verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any());
} }
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() { @ParameterizedTest @ValueSource(booleans = {false, true})
looseTask(); item.setCartonScanRequired(true); void boundPalletExposesSelectedBoxesAndRequiresTheirScan(boolean partial) {
looseTask(); item.setCartonScanRequired(partial);
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService"); var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42"); var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42");
when(stockService.position("CUSTOMER", 42L)).thenReturn(position); when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.repository.CargoDiscardRelabelRepository;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 废弃实箱扫码身份保持系统箱唛,商品仍使用换标后的 SKU。 */
class CargoDiscardRelabelStockServiceTest {
@Test
void keepsSystemCartonMarkInsteadOfCustomerOrFbaLabel() {
var repository = mock(CargoDiscardRelabelRepository.class);
var sources = mock(RelabelDispatchSourcePort.class);
var service = new CargoDiscardRelabelStockService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "sources", sources);
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
var position = new InventoryPosition(); position.setId(10L); position.setPalletId(30L);
position.setOverseasWarehouseId("W"); position.setHoldPackages(1L);
var item = new RelabelItem(); item.setId(102L); item.setCartonMark("CUSTOMER-BOX-1");
item.setFbaCartonMark("FBA-BOX-1"); item.setSkuCode("OLD-SKU"); item.setNewSkuCode("NEW-SKU");
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-SKU\",\"newSkuCode\":\"NEW-SKU\",\"quantity\":5}]");
when(repository.cartons("C", 10L)).thenReturn(List.of(item));
var bound = new RelabelDispatchSourcePort.BoundSource(1L, "TB-1", 102L, "C", "W", 30L,
10L, 20L, 20L, 50L, "ZY-1", "ZY-1", 1, 1, 5L, "hash", List.of());
when(sources.snapshot("W", List.of(30L))).thenReturn(new RelabelDispatchSourcePort.Snapshot(
List.of(), List.of(bound), List.of(), "version", true, List.of()));
when(repository.systemCartonMarks("C", 102L)).thenReturn(List.of("BX-SYSTEM-1"));
var box = service.cartons("C", position).get(0);
assertEquals("BX-SYSTEM-1", box.getCartonMark());
assertEquals("NEW-SKU", box.getSkuCode());
assertTrue(box.getSkuItemsJson().contains("NEW-SKU"));
when(repository.systemCartonMarks("C", 102L)).thenReturn(List.of("BX-SYSTEM-1", "BX-SYSTEM-2"));
assertThrows(ResponseStatusException.class, () -> service.cartons("C", position));
// 普通非转运来源沿用原我方箱唛,也不采用 FBA 标签。
item.setCartonMark("BX-NORMAL");
when(repository.systemCartonMarks("C", 102L)).thenReturn(List.of());
assertEquals("BX-NORMAL", service.cartons("C", position).get(0).getCartonMark());
}
}
@@ -32,6 +32,7 @@ class CargoDiscardStockServiceTest {
final CargoDiscardApplication application = new CargoDiscardApplication(); final CargoDiscardApplication application = new CargoDiscardApplication();
CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); } CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); }
@BeforeEach void setup() { @BeforeEach void setup() {
ReflectionTestUtils.setField(service, "relabelStock", mock(CargoDiscardRelabelStockService.class));
var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository(); var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository();
ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(repository, "stock", stock); ReflectionTestUtils.setField(repository, "stock", stock);
@@ -229,7 +229,7 @@ class DispatchMergedRelabelPersistenceTest {
var schema=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); var schema=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard", for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard",
"ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet", "ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet",
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet", "ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_cargo_discard_carton","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate", "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate",
"ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) { "ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) {
f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start); f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start);
@@ -46,6 +46,9 @@ class DriverPersistenceTest {
configuration.addInterceptor(pagination); configuration.addInterceptor(pagination);
configuration.addMapper(DriverMapper.class); configuration.addMapper(DriverMapper.class);
configuration.addMapper(DriverHistoryMapper.class); configuration.addMapper(DriverHistoryMapper.class);
configuration.addMapper(com.baiyue.lastleg.fleet.trip.FleetResourceMapper.class);
jdbc.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(16),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(trip_id BIGINT,dispatch_no VARCHAR(64))");
var factory = new MybatisSqlSessionFactoryBean(); var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setDataSource(ds);
factory.setConfiguration(configuration); factory.setConfiguration(configuration);
@@ -53,12 +56,25 @@ class DriverPersistenceTest {
var target = new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class), var target = new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class),
mock(DriverFileGateway.class), new ObjectMapper().registerModule(new JavaTimeModule()), mock(DriverFileGateway.class), new ObjectMapper().registerModule(new JavaTimeModule()),
Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC), mock(FleetWarehouseDirectory.class)); Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC), mock(FleetWarehouseDirectory.class));
org.springframework.test.util.ReflectionTestUtils.setField(target, "availability", new com.baiyue.lastleg.fleet.trip.DriverAvailabilityRepository(session.getMapper(com.baiyue.lastleg.fleet.trip.FleetResourceMapper.class)));
var proxy = new ProxyFactory(target); var proxy = new ProxyFactory(target);
proxy.setProxyTargetClass(true); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
service = (DriverService) proxy.getProxy(); service = (DriverService) proxy.getProxy();
} }
@Test void dispatchCandidatesExcludeOtherTripsBeforePagingButKeepOwnDriver() {
var own = service.create("operator", draft("本车司机"));
var occupied = service.create("operator", draft("其他车司机"));
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('DRIVER',?,1,'TR1')", Long.parseLong(own.id()));
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('DRIVER',?,2,'TR2')", Long.parseLong(occupied.id()));
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(1,'PC1')");
var result = service.page(new DriverModels.Query(1,1,null,null,null,null,null,null,null,"PC1"));
assertThat(result.total()).isEqualTo(1);
assertThat(result.records()).extracting(DriverModels.Response::id).containsExactly(own.id());
assertThat(service.page(new DriverModels.Query(1,20,null,null,null,null,null,null,null)).total()).isEqualTo(2);
}
@Test void historyFailureRollsBackDriverStatusAndVersion() { @Test void historyFailureRollsBackDriverStatusAndVersion() {
var driver = service.create("operator", draft("初始备注")); var driver = service.create("operator", draft("初始备注"));
jdbc.execute("ALTER TABLE ll_fleet_driver_history ADD CONSTRAINT reject_deactivate CHECK(action <> 'DEACTIVATE')"); jdbc.execute("ALTER TABLE ll_fleet_driver_history ADD CONSTRAINT reject_deactivate CHECK(action <> 'DEACTIVATE')");
@@ -58,6 +58,7 @@ class ProcurementWorkflowAuthorizationTest {
var user=login(9,"MERCHANT","merchant:cross-border:procurement:list"); var user=login(9,"MERCHANT","merchant:cross-border:procurement:list");
merchant.read(user,"CG-1");verify(service).read("9","CG-1"); merchant.read(user,"CG-1");verify(service).read("9","CG-1");
assertThatThrownBy(()->merchant.confirm(user,"CG-1",new Command(0,"c"))).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(()->merchant.confirm(user,"CG-1",new Command(0,"c"))).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->merchant.reject(user,"CG-1",new Rejection(0,"r","照片不符合要求"))).isInstanceOf(AccessDeniedException.class);
} }
private AuthenticatedUser login(long customer,String app,String permission) { private AuthenticatedUser login(long customer,String app,String permission) {
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission)); var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
@@ -51,6 +51,27 @@ class ProcurementWorkflowPersistenceTest {
file(101L,11L,"9","image/jpeg");file(102L,11L,"9","application/pdf");file(103L,12L,"10","image/jpeg");file(104L,11L,"9","image/png"); file(101L,11L,"9","image/jpeg");file(102L,11L,"9","application/pdf");file(103L,12L,"10","image/jpeg");file(104L,11L,"9","image/png");
} }
/** 驳回后必须全批换新照片,返工不能重复增加库存。 */
@Test void rejectionRequiresNewPhotosWithoutChangingStock() {
tx.execute(t->service.receive("9","CG-1","7",new Receipt(0,"receipt",10,List.of(101L))));
tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
assertThatThrownBy(()->tx.execute(t->service.reject("9","CG-1","8",new Rejection(2,"empty"," ")))).hasMessageContaining("原因");
assertThatThrownBy(()->tx.execute(t->service.reject("10","CG-1","8",new Rejection(2,"other","破损")))).hasMessageContaining("不属于");
var request=new Rejection(2,"reject","照片不清晰");
var rejected=tx.execute(t->service.reject("9","CG-1","8",request));
assertThat(rejected.status()).isEqualTo("RECEIVING");assertThat(rejected.rejectionReason()).isEqualTo("照片不清晰");assertThat(rejected.photosNeedUpdate()).isTrue();
assertThat(tx.execute(t->service.reject("9","CG-1","8",request)).version()).isEqualTo(3);
assertThatThrownBy(()->tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(3,"early")))).hasMessageContaining("重新上传");
long receipt=rejected.receipts().get(0).id();
assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old",receipt,List.of(101L))))).hasMessageContaining("不能复用");
assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old-upload",receipt,List.of(104L))))).hasMessageContaining("重新上传");
file(105L,11L,"9","image/png");
var current=tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"new",receipt,List.of(105L))));
assertThat(current.photosNeedUpdate()).isFalse();assertThat(current.stockQuantity()).isEqualTo(10);assertThat(current.receipts()).hasSize(1);
tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(4,"again")));
assertThat(tx.execute(t->service.confirm("9","CG-1","8",new Command(5,"confirm"))).status()).isEqualTo("CONFIRMED");
}
@Test void twoReceiptsCustomerConfirmationAndTwoOutboundsDeductOnlyAtCompletion() { @Test void twoReceiptsCustomerConfirmationAndTwoOutboundsDeductOnlyAtCompletion() {
var first=new Receipt(0,"receive1",4,List.of(101L)); var first=new Receipt(0,"receive1",4,List.of(101L));
var current=tx.execute(s->service.receive("9","CG-1","7",first)); var current=tx.execute(s->service.receive("9","CG-1","7",first));
@@ -60,6 +60,7 @@ class RelabelDispatchSourcePersistenceTest {
source_inventory_balance_id BIGINT, source_inventory_position_id BIGINT, source_inventory_balance_id BIGINT, source_inventory_position_id BIGINT,
source_inbound_order_id BIGINT, source_inbound_order_no VARCHAR(80), source_inbound_code VARCHAR(80)) source_inbound_order_id BIGINT, source_inbound_order_no VARCHAR(80), source_inbound_code VARCHAR(80))
"""); """);
jdbc.execute("CREATE TABLE ll_cargo_discard_carton(customer_id VARCHAR(40),carton_id BIGINT,source_type VARCHAR(32))");
jdbc.execute(""" jdbc.execute("""
CREATE TABLE ll_inventory_position ( CREATE TABLE ll_inventory_position (
id BIGINT PRIMARY KEY, customer_id VARCHAR(40), balance_id BIGINT, id BIGINT PRIMARY KEY, customer_id VARCHAR(40), balance_id BIGINT,
@@ -16,7 +16,7 @@ class UsTruckOrderRequestsTest {
var request = new UsTruckOrderRequests.Create(null, "LA", null, "ONT", null, 0, var request = new UsTruckOrderRequests.Create(null, "LA", null, "ONT", null, 0,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", " ", null, null, "", null, null, null, "required"); 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()); 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"), fields); assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt", "palletCount", "budgetAmount"), fields);
} }
} }
@@ -25,8 +25,8 @@ class UsTruckOrderRequestsTest {
void onlyCurrentVehicleTypesAreAccepted() { void onlyCurrentVehicleTypesAreAccepted() {
try (var factory = Validation.buildDefaultValidatorFactory()) { try (var factory = Validation.buildDefaultValidatorFactory()) {
for (String vehicle : java.util.List.of("PALLET_15", "PALLET_30", "OTHER")) { for (String vehicle : java.util.List.of("PALLET_15", "PALLET_30", "OTHER")) {
var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 0, var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 1,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101", 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"); 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()); assertEquals(!vehicle.equals("OTHER"), factory.getValidator().validate(request).isEmpty());
} }
@@ -91,7 +91,7 @@ class UsTruckOrderServiceTest {
assertEquals("+1-555-0101", detail.summary().destinationContactPhone()); assertEquals("+1-555-0101", detail.summary().destinationContactPhone());
assertEquals(request.pickupAt(), detail.summary().pickupAt()); assertEquals(request.pickupAt(), detail.summary().pickupAt());
assertEquals(request.deliveryAt(), detail.summary().deliveryAt()); assertEquals(request.deliveryAt(), detail.summary().deliveryAt());
assertEquals(0, detail.summary().palletCount()); assertEquals(3, detail.summary().palletCount());
assertTrue(detail.pallets().isEmpty()); assertTrue(detail.pallets().isEmpty());
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid"))); assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid")));
} }
@@ -108,7 +108,7 @@ class UsTruckOrderServiceTest {
@Test @Test
void reversedTimesAreRejectedBeforeCreatingAnOrder() { void reversedTimesAreRejectedBeforeCreatingAnOrder() {
var request = create("PALLET_30", "reversed"); var request = create("PALLET_30", "reversed");
var invalid = new UsTruckOrderRequests.Create(request.vehicleType(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.deliveryAt(), request.pickupAt(), request.remark(), request.idempotencyKey()); var invalid = new UsTruckOrderRequests.Create(request.vehicleType(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.palletCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.deliveryAt(), request.pickupAt(), request.remark(), request.idempotencyKey());
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", invalid)); assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", invalid));
} }
@@ -169,7 +169,7 @@ class UsTruckOrderServiceTest {
private UsTruckOrderRequests.Create create(String vehicleType, String key) { private UsTruckOrderRequests.Create create(String vehicleType, String key) {
return new UsTruckOrderRequests.Create(vehicleType, "Los Angeles", null, "Ontario", null, return new UsTruckOrderRequests.Create(vehicleType, "Los Angeles", null, "Ontario", null,
0, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", 3, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.TEN, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101",
LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, key); LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, key);
} }
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.wallet.budget;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.*;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 预算入口不允许跨业务借用权限或由平台身份指定客户资金。 */
class OrderBudgetAuthorizationTest {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
@Configuration @EnableMethodSecurity(proxyTargetClass=true)
static class Config {
@Bean OrderBudgetService service() { return mock(OrderBudgetService.class); }
@Bean MerchantOrderBudgetController controller(OrderBudgetService service) { return new MerchantOrderBudgetController(service); }
}
@AfterEach void cleanup() { SecurityContextHolder.clearContext();context.close(); }
@Test void procurementPermissionCannotFreezeTruckAndOnlyUsesAuthenticatedCustomer() {
var controller=context.getBean(MerchantOrderBudgetController.class);
var user=login(9,"MERCHANT","merchant:cross-border:procurement:create");
var request=new MerchantOrderBudgetController.Prepare(BigDecimal.TEN,"USD");
controller.prepare(user,"procurement","key",request);
verify(context.getBean(OrderBudgetService.class)).prepare("9","procurement","key",BigDecimal.TEN,"USD");
assertThatThrownBy(()->controller.prepare(user,"trucking","key",request)).isInstanceOf(AccessDeniedException.class);
}
@Test void readPermissionCannotReserveAndPlatformCannotImpersonateCustomer() {
var controller=context.getBean(MerchantOrderBudgetController.class);
var reader=login(9,"MERCHANT","merchant:cross-border:procurement:list");
assertThatThrownBy(()->controller.read(reader,"procurement","key")).isInstanceOf(AccessDeniedException.class);
var platform=login(9,"PLATFORM","merchant:cross-border:procurement:create");
assertThatThrownBy(()->controller.read(platform,"procurement","key")).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(context.getBean(OrderBudgetService.class));
}
private AuthenticatedUser login(long customer,String app,String permission) {
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
}
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
/** 用真实 Mapper 验证客户隔离、持久化及到期/终态释放候选。 */
class OrderBudgetPersistenceTest {
OrderBudgetRepository repository;
JdbcTemplate jdbc;
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
var match = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_order_budget_reservation \\(.*?;").matcher(sql);
assertThat(match.find()).isTrue();jdbc.execute(match.group());
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_us_truck_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
var config = new MybatisConfiguration();config.addMapper(OrderBudgetMapper.class);
var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
repository = new OrderBudgetRepository(new SqlSessionTemplate(factory.getObject()).getMapper(OrderBudgetMapper.class));
}
@Test void isolatesCustomerAndKeepsOriginalMoneyOnBinding() {
var row = row(1,"FROZEN");repository.insert(row);
assertThat(repository.lock("10","procurement","key1")).isNull();
assertThat(repository.lockId(1,"10")).isNull();
var loaded = repository.lock("9","procurement","key1");
loaded.setOrderNo("CG1");repository.save(loaded);
assertThat(repository.lockOrder("9","procurement","CG1").getOriginalAmount()).isEqualByComparingTo("100");
assertThat(repository.lockOrder("10","procurement","CG1")).isNull();
}
@Test void selectsExpiredTerminalAndFailedReleaseButKeepsLiveOrders() {
var expired = row(1,"FREEZE_PENDING");expired.setCreatedAt(LocalDateTime.now().minusMinutes(16));repository.insert(expired);
var terminal = row(2,"FROZEN");terminal.setOrderNo("CG2");repository.insert(terminal);
jdbc.update("INSERT INTO ll_overseas_procurement_order VALUES('9','CG2','COMPLETED')");
var failed = row(3,"RELEASE_FAILED");failed.setReleaseRequested(true);failed.setUpdatedAt(LocalDateTime.now().minusMinutes(2));repository.insert(failed);
repository.insert(row(4,"FROZEN"));
assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L);
}
private OrderBudget row(long id,String status) {
var row=new OrderBudget();row.setId(id);row.setCustomerId("9");row.setKind("procurement");row.setRequestKey("key"+id);
row.setOriginalAmount(new BigDecimal("100"));row.setCurrency("USD");row.setAmountCny(new BigDecimal("800"));
row.setStatus(status);row.setCommandVersion(1L);row.setReleaseRequested(false);
row.setCreatedAt(LocalDateTime.now());row.setUpdatedAt(LocalDateTime.now());return row;
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.wallet.budget;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import org.junit.jupiter.api.*;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
/** 验证资金不足、金额篡改、过期在途回执及客户隔离不能绕过建单门禁。 */
class OrderBudgetServiceTest {
OrderBudgetRepository repository=mock(OrderBudgetRepository.class);
WalletCommandOutboxService wallet=mock(WalletCommandOutboxService.class);
OrderBudgetService service=new OrderBudgetService(repository,mock(OverseasFeeCurrencyConverter.class),wallet);
OrderBudget row;
@BeforeEach void setup() {
row=new OrderBudget();row.setId(11L);row.setCustomerId("9");row.setKind("procurement");row.setRequestKey("key");row.setOriginalAmount(new BigDecimal("100"));row.setCurrency("USD");row.setAmountCny(new BigDecimal("800"));row.setStatus("FREEZE_PENDING");row.setCommandEventId("freeze");row.setCommandVersion(1L);row.setReleaseRequested(false);row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
when(repository.lock("9","procurement","key")).thenReturn(row);when(repository.lockId(11L,"9")).thenReturn(row);
}
@Test void insufficientNeverCreatesOrderAndExplicitRetryUsesNewCommand() {
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->{throw new AssertionError("不得建单");},v->"CG1")).hasMessageContaining("处理中");
service.handle(result("freeze","FREEZE","FAILED",null,"可用余额和授信额度不足"));
assertThat(service.read("9","procurement","key").message()).contains("授信额度不足");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).hasMessageContaining("授信额度不足");
when(wallet.publish(eq("FREEZE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L))).thenReturn("retry");
assertThat(service.prepare("9","procurement","key",new BigDecimal("100"),"USD").status()).isEqualTo("FREEZE_PENDING");assertThat(row.getCommandEventId()).isEqualTo("retry");
}
@Test void latestSuccessfulReceiptAndExactCustomerAmountCurrencyAreRequired() {
service.handle(result("old","FREEZE","SUCCEEDED","FROZEN",null));assertThat(row.getStatus()).isEqualTo("FREEZE_PENDING");
service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null));
assertThatThrownBy(()->service.submit("10","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).hasMessageContaining("校验");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("200"),"USD",()->"CG1",v->v)).hasMessageContaining("变化");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"CNY",()->"CG1",v->v)).hasMessageContaining("变化");
assertThat(service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).isEqualTo("CG1");assertThat(row.getOrderNo()).isEqualTo("CG1");
}
@Test void expiryWaitsForLateFreezeThenReleases() {
when(repository.releasable()).thenReturn(List.of(row));service.releaseFinished();assertThat(row.getReleaseRequested()).isTrue();
when(wallet.publish(eq("RELEASE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L))).thenReturn("release");
service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null));assertThat(row.getStatus()).isEqualTo("RELEASE_PENDING");
service.handle(result("release","RELEASE","SUCCEEDED","RELEASED",null));assertThat(row.getStatus()).isEqualTo("RELEASED");
}
@Test void failedReleaseRetriesWithANewVersionAndBlocksSubmission() {
row.setStatus("RELEASE_FAILED");row.setReleaseRequested(true);
when(repository.releasable()).thenReturn(List.of(row));
when(wallet.publish(eq("RELEASE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L))).thenReturn("release-retry");
service.releaseFinished();
assertThat(row.getStatus()).isEqualTo("RELEASE_PENDING");
assertThat(row.getCommandEventId()).isEqualTo("release-retry");
assertThat(service.read("9","procurement","key").usable()).isFalse();
}
@Test void expiredFrozenBudgetIsNeverReportedUsable() {
row.setStatus("FROZEN");row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(16));
assertThat(service.read("9","procurement","key").usable()).isFalse();
assertThat(service.read("9","procurement","key").message()).contains("过期");
}
private WalletCommandCompletedV1 result(String event,String operation,String outcome,String status,String error) {
return new WalletCommandCompletedV1(event,operation,9L,OrderBudgetService.BUSINESS_TYPE,"11",outcome,status,new BigDecimal("800"),null,error,Instant.now(),"hold",null);
}
}