提交
This commit is contained in:
@@ -224,3 +224,6 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
|
||||
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
|
||||
|
||||
采购方向扩展:`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='海外客户下单预估资金冻结投影';
|
||||
@@ -2105,6 +2105,8 @@ CREATE TABLE ll_overseas_procurement_order (
|
||||
created_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 '审核备注',
|
||||
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_request_hash char(64) COLLATE utf8mb4_unicode_ci 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)
|
||||
) 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;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
|
||||
|
||||
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
|
||||
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并逐批重新上传照片。驳回前所有已上传文件均不可复用;仅替换照片,不重复累加数量。全部旧照片替换后方可再次提交确认。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
|
||||
|
||||
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
|
||||
|
||||
@@ -46,3 +46,12 @@
|
||||
- Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。
|
||||
- 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。
|
||||
- 未执行环境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真实回执与人工截图验收仍待环境联调。
|
||||
|
||||
+80
@@ -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>0 AND b.hold_packages>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 != ""'>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);
|
||||
}
|
||||
+5
-4
@@ -69,10 +69,11 @@ public class PdaCargoDiscardService {
|
||||
if (!line.actualQuantityEditable() && actual != line.plannedPackages())
|
||||
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();
|
||||
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
|
||||
throw conflict("必须扫描全部箱唛,或选择一键全部废弃");
|
||||
throw conflict("必须扫描本次废弃的全部箱号");
|
||||
}
|
||||
List<CargoDiscardItem> palletItems = new ArrayList<>();
|
||||
for (PdaCargoDiscardDtos.Sku line : task.skus()) {
|
||||
@@ -179,8 +180,8 @@ public class PdaCargoDiscardService {
|
||||
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList();
|
||||
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
|
||||
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
|
||||
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。
|
||||
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()));
|
||||
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
|
||||
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
|
||||
|
||||
+34
@@ -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); }
|
||||
}
|
||||
+123
@@ -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); }
|
||||
}
|
||||
+42
-9
@@ -20,14 +20,18 @@ public class CargoDiscardStockService {
|
||||
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository;
|
||||
@Resource private InventoryCommandService commands;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private CargoDiscardRelabelStockService relabelStock;
|
||||
|
||||
/** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */
|
||||
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());
|
||||
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
|
||||
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
|
||||
var cartons = repository.cartons(customer, position.getId());
|
||||
long available = available(customer, position, balance);
|
||||
var cartons = cartons(customer, position);
|
||||
boolean complete = completeCartons(cartons, available);
|
||||
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();
|
||||
@@ -47,16 +51,17 @@ public class CargoDiscardStockService {
|
||||
if (balance == null || !customer.equals(balance.getCustomerId())
|
||||
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
|
||||
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 (request.cartonIds() != null) {
|
||||
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
|
||||
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());
|
||||
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 不属于所选箱子");
|
||||
} else {
|
||||
if (relabelStock.cartons(customer, position).size() > 0) throw conflict("换标库存必须选择具体箱子");
|
||||
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
|
||||
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) {
|
||||
relabelStock.lock(customer, List.of(request.inventoryPositionId()));
|
||||
if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
|
||||
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 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */
|
||||
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();
|
||||
relabelStock.lock(customer, stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().toList());
|
||||
stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> {
|
||||
if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在");
|
||||
});
|
||||
@@ -82,7 +89,7 @@ public class CargoDiscardStockService {
|
||||
var selectedSkus = new HashMap<String, Set<String>>();
|
||||
for (var request : stocks) {
|
||||
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);
|
||||
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());
|
||||
@@ -124,9 +131,15 @@ public class CargoDiscardStockService {
|
||||
if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
|
||||
snapshotMarks(line, marks);
|
||||
}
|
||||
int physical = selected.isEmpty() ? request.discardPackages() : (int)selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).count();
|
||||
if (physical > 0) commands.hold(customer, operator, move(application, line, physical, "HOLD"));
|
||||
var newlyHeld = selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).toList();
|
||||
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);
|
||||
if (newlyHeld.stream().anyMatch(c -> "RELABEL".equals(c.getSourceType())))
|
||||
relabelStock.synchronize(repository.selectedCartons(customer, application.getId()), customer);
|
||||
}
|
||||
|
||||
/** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */
|
||||
@@ -165,14 +178,18 @@ public class CargoDiscardStockService {
|
||||
|
||||
/** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */
|
||||
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");
|
||||
int physical = physicalPackages(customer, application, line);
|
||||
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)
|
||||
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 (!actualQuantityEditable(line) && actual != line.getDiscardPackages())
|
||||
throw conflict("必须完成本次申请所选全部箱子,不能漏出");
|
||||
@@ -203,6 +220,7 @@ public class CargoDiscardStockService {
|
||||
|
||||
/** 按箱来源执行带原状态条件的变更。 */
|
||||
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 (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
|
||||
return repository.changeInboundCarton(customer, cartonId, from, to);
|
||||
@@ -239,6 +257,21 @@ public class CargoDiscardStockService {
|
||||
}
|
||||
/** 无 SKU 来源保留未指定标记,不猜测商品。 */
|
||||
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; }
|
||||
/** 返回可恢复的业务冲突。 */
|
||||
|
||||
+2
@@ -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 " +
|
||||
"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' " +
|
||||
// 部分废弃后只统计仍在仓内的换标箱,保留原任务供追溯。
|
||||
"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='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>" +
|
||||
|
||||
+4
-1
@@ -16,11 +16,14 @@ public final class TransportTaskModels {
|
||||
@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,
|
||||
@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,
|
||||
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> 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,
|
||||
@NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason,
|
||||
@Min(0) Long dispatchRecordVersion,
|
||||
|
||||
+5
-1
@@ -101,12 +101,16 @@ public class TransportTaskService {
|
||||
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()
|
||||
: "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(),
|
||||
"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()) && "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(),
|
||||
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);}
|
||||
|
||||
@@ -15,8 +15,14 @@ public final class DriverModels {
|
||||
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
|
||||
@Size(max = 128) String keyword, Boolean active, String reviewStatus,
|
||||
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(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,
|
||||
|
||||
@@ -29,6 +29,7 @@ public class DriverService {
|
||||
private final ObjectMapper json;
|
||||
private final Clock clock;
|
||||
private final FleetWarehouseDirectory warehouses;
|
||||
@Autowired private com.baiyue.lastleg.fleet.port.DriverAvailability availability;
|
||||
|
||||
@Autowired
|
||||
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);
|
||||
}
|
||||
|
||||
/** 先排除其他车次占用,再分页统计,避免空页或错误总数。 */
|
||||
private LambdaQueryWrapper<DriverEntity> filter(DriverModels.Query query, Instant now) {
|
||||
String review = optionalChoice(query.reviewStatus(), Set.of("PENDING", "APPROVED", "REJECTED"), "审核状态");
|
||||
String ownership = optionalChoice(query.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属");
|
||||
@@ -65,6 +67,10 @@ public class DriverService {
|
||||
.eq(review != null, DriverEntity::getReviewStatus, review)
|
||||
.eq(ownership != null, DriverEntity::getOwnership, ownership)
|
||||
.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)
|
||||
.or().like(DriverEntity::getPhone, keyword).or().like(DriverEntity::getDriverNo, keyword));
|
||||
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);
|
||||
}
|
||||
+15
@@ -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})
|
||||
""")
|
||||
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) { }
|
||||
}
|
||||
|
||||
+3
-1
@@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
|
||||
public class MerchantOverseasProcurementController {
|
||||
@Resource private OverseasProcurementService service;
|
||||
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets;
|
||||
@Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService;
|
||||
|
||||
@PostMapping
|
||||
@@ -31,7 +32,8 @@ public class MerchantOverseasProcurementController {
|
||||
public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerView> create(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@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()));
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -29,6 +29,12 @@ public class MerchantProcurementWorkflowController {
|
||||
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));
|
||||
}
|
||||
/** 客户驳回必须说明原因,由当前认证客户确认归属。 */
|
||||
@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")
|
||||
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_LIST+"')")
|
||||
|
||||
+6
@@ -29,6 +29,12 @@ public class ProcurementWorkflowController {
|
||||
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));
|
||||
}
|
||||
/** 客户驳回后的照片替换复用正式收货权限。 */
|
||||
@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")
|
||||
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
|
||||
|
||||
+5
-1
@@ -8,6 +8,10 @@ import java.util.List;
|
||||
public final class ProcurementWorkflowDtos {
|
||||
private ProcurementWorkflowDtos() { }
|
||||
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,
|
||||
@Positive long quantity,@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { }
|
||||
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 Workflow(String orderNo,long version,String status,long receivedQuantity,long stockQuantity,
|
||||
long reservedQuantity,long availableQuantity,List<ReceiptRow> receipts,
|
||||
List<OutboundRow> outbounds,List<FileInfo> files) { }
|
||||
List<OutboundRow> outbounds,List<FileInfo> files,String rejectionReason,boolean photosNeedUpdate) { }
|
||||
}
|
||||
|
||||
+2
@@ -33,6 +33,8 @@ public class OverseasProcurementOrder {
|
||||
private Long inboundQuantity;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt;
|
||||
private String status;
|
||||
private String customerRejectionReason;
|
||||
private String rejectedPhotoIds;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
|
||||
+3
@@ -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})")
|
||||
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})")
|
||||
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);
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ public class ProcurementWorkflowRepository {
|
||||
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 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); }
|
||||
/** 更新出库批次。 */
|
||||
|
||||
+29
-1
@@ -49,6 +49,7 @@ public class ProcurementWorkflowService {
|
||||
if(replay(order,request.idempotencyKey(),"REQUEST_CONFIRMATION",request)) return snapshot(order);
|
||||
version(order,request.version());state(order,"RECEIVING");
|
||||
if(snapshot(order).receivedQuantity()<=0) throw conflict("请先登记收货照片和数量");
|
||||
if(snapshot(order).photosNeedUpdate()) throw conflict("客户已驳回,请重新上传各收货批次照片后再提交确认");
|
||||
order.setStatus("WAIT_CUSTOMER_CONFIRM");
|
||||
finish(order,operator,request.idempotencyKey(),"REQUEST_CONFIRMATION",request);
|
||||
return snapshot(order);
|
||||
@@ -65,6 +66,33 @@ public class ProcurementWorkflowService {
|
||||
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
|
||||
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();
|
||||
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();
|
||||
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));
|
||||
}
|
||||
|
||||
/** 文件数量、客户归属、用途和照片类型均由服务端验证。 */
|
||||
|
||||
+4
@@ -16,6 +16,10 @@ import java.util.List;
|
||||
/** 只读取换标及库存事实;不连接排车账本,不取得库存位置锁。 */
|
||||
@Mapper
|
||||
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>";
|
||||
|
||||
/** 找出实托当前所有明细关联的申请,不按状态或页面资格筛选。 */
|
||||
|
||||
@@ -6,6 +6,10 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
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 " +
|
||||
"JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " +
|
||||
|
||||
+4
-3
@@ -58,10 +58,11 @@ public class RelabelDispatchSourceRepository {
|
||||
}
|
||||
// 正式取消已释放 HOLD,旧 item 指针仅供追溯;不把它重新解释为当前贡献或沿其他托盘扩组。
|
||||
// 请求实托的全部真实位置已在上方保留,残余 HOLD 仍交现有消费方完整核对。
|
||||
if ("CANCELLED".equals(application.getStatus())) continue;
|
||||
List<RelabelItem> items = mapper.items(applicationId);
|
||||
if ("CANCELLED".equals(application.getStatus()) || "DISCARDED".equals(application.getFulfillmentStatus())) continue;
|
||||
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),
|
||||
mapper.histories(applicationId), mapper.facts(applicationId)));
|
||||
mapper.histories(applicationId), mapper.facts(applicationId).stream().filter(f -> !discarded.contains(f.getItemId())).toList()));
|
||||
for (RelabelItem item : items) {
|
||||
if (item.getSourceInventoryPositionId() == null) continue;
|
||||
InventoryPosition position = positions.get(item.getSourceInventoryPositionId());
|
||||
|
||||
+2
-1
@@ -27,9 +27,10 @@ public class RelabelBindingGuard {
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public void requireBound(RelabelApplication app) {
|
||||
var discarded = new HashSet<>(items.discardedItems(app.getId()));
|
||||
List<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>()
|
||||
.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();
|
||||
if (cartons.isEmpty()) return;
|
||||
Map<String, Set<String>> bindings = new HashMap<>();
|
||||
|
||||
+5
-2
@@ -25,12 +25,14 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@RequestMapping("/api/merchant/v1/last-leg/us-truck-orders")
|
||||
public class MerchantUsTruckOrderController {
|
||||
@Resource private UsTruckOrderService service;
|
||||
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets;
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_CREATE + "')")
|
||||
public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@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
|
||||
@@ -51,7 +53,8 @@ public class MerchantUsTruckOrderController {
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_EDIT + "')")
|
||||
public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@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) {
|
||||
|
||||
+4
-4
@@ -27,10 +27,10 @@ public final class UsTruckOrderRequests {
|
||||
@Size(max = 500) String originAddress,
|
||||
@NotBlank @Size(max = 255) String destination,
|
||||
@Size(max = 500) String destinationAddress,
|
||||
@Min(0) Integer packageCount,
|
||||
@NotNull @Positive Integer palletCount,
|
||||
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
|
||||
@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,
|
||||
@NotBlank @Size(max = 128) String originContactName,
|
||||
@NotBlank @Size(max = 128) String originContactPhone,
|
||||
@@ -67,10 +67,10 @@ public final class UsTruckOrderRequests {
|
||||
@Size(max = 500) String originAddress,
|
||||
@Size(max = 255) String destination,
|
||||
@Size(max = 500) String destinationAddress,
|
||||
@Min(0) Integer packageCount,
|
||||
@NotNull @Positive Integer palletCount,
|
||||
@DecimalMin("0.00") BigDecimal totalWeightKg,
|
||||
@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,
|
||||
@NotBlank @Size(max = 128) String originContactName,
|
||||
@NotBlank @Size(max = 128) String originContactPhone,
|
||||
|
||||
+5
-3
@@ -77,8 +77,10 @@ public class UsTruckOrderService {
|
||||
order.setVehicleType(request.vehicleType());
|
||||
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
|
||||
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.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
|
||||
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
|
||||
@@ -127,7 +129,7 @@ public class UsTruckOrderService {
|
||||
order.setVehicleType(request.vehicleType());
|
||||
setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress);
|
||||
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.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());
|
||||
|
||||
+40
@@ -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();
|
||||
}
|
||||
+23
@@ -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(); }
|
||||
}
|
||||
+140
@@ -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 (
|
||||
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 '箱唛',
|
||||
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),
|
||||
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_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 '乐观锁版本',
|
||||
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 '更新时间',
|
||||
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),
|
||||
UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key),
|
||||
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 '记录创建时间',
|
||||
PRIMARY KEY (order_id,command_key)
|
||||
) 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.
|
||||
Candidates return cartons, cartonSelectionSupported and cartonSelectionBlockReason.
|
||||
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.
|
||||
Bound pallets require their pallet number; loose stock requires every distinct carton mark scanned,
|
||||
or confirmAllCartons=true. The loose group uses 散箱 as its API path key, never as a scan code.
|
||||
PDA tasks with real cartonMarks use CARTON scanMode, including full bound-pallet tasks.
|
||||
Every distinct selected carton mark must be scanned; confirmAllCartons cannot bypass this check.
|
||||
Ordinary stock without carton marks uses PALLET mode and actual carton quantities.
|
||||
The loose group uses 散箱 as its API path key, never as a scan code.
|
||||
The application may select a subset of available cartons. PDA must finish every selected carton;
|
||||
short actual quantities are rejected. At least one photo remains required.
|
||||
Partial bound-pallet tasks use CARTON scanMode with the real palletNo and selected cartonMarks.
|
||||
@@ -109,11 +110,12 @@ paths:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
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:
|
||||
type: boolean
|
||||
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:
|
||||
type: array
|
||||
minItems: 1
|
||||
|
||||
@@ -42,7 +42,7 @@ paths:
|
||||
- { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 }
|
||||
get:
|
||||
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
|
||||
/{number}/{action}:
|
||||
parameters:
|
||||
|
||||
@@ -28,6 +28,7 @@ paths:
|
||||
- {name: region, in: query, schema: {type: string, enum: [DOMESTIC, OVERSEAS]}}
|
||||
- {name: licenseState, in: query, schema: {type: string, enum: [MISSING, VALID, EXPIRING, EXPIRED]}, description: 仅驾驶证日期状态,与审核和附件状态分别展示}
|
||||
- {name: expectedEnd, in: query, schema: {type: string, format: date-time}}
|
||||
- {name: dispatchNo, in: query, schema: {type: string, maxLength: 64}, description: 排车候选上下文;在分页和统计前过滤其他车次占用,保留当前排车绑定司机}
|
||||
responses:
|
||||
'200':
|
||||
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 }
|
||||
TruckCreate:
|
||||
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:
|
||||
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
|
||||
origin: { type: string, maxLength: 255 }
|
||||
originAddress: { type: string, nullable: true, maxLength: 500 }
|
||||
destination: { type: string, maxLength: 255 }
|
||||
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 }
|
||||
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 }
|
||||
originContactName: { type: string, maxLength: 128 }
|
||||
originContactPhone: { type: string, maxLength: 128 }
|
||||
@@ -5194,7 +5194,7 @@ components:
|
||||
idempotencyKey: { type: string, maxLength: 255 }
|
||||
TruckUpdate:
|
||||
type: object
|
||||
required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt]
|
||||
required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount]
|
||||
properties:
|
||||
version: { type: integer, format: int64, minimum: 0 }
|
||||
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
|
||||
@@ -5202,10 +5202,10 @@ components:
|
||||
originAddress: { type: string, nullable: true, maxLength: 500 }
|
||||
destination: { type: string, maxLength: 255 }
|
||||
destinationAddress: { type: string, nullable: true, maxLength: 500 }
|
||||
packageCount: { type: integer, minimum: 0 }
|
||||
palletCount: { type: integer, minimum: 1, description: '下单预估托数' }
|
||||
totalWeightKg: { 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 }
|
||||
originContactName: { type: string, maxLength: 128 }
|
||||
originContactPhone: { type: string, maxLength: 128 }
|
||||
|
||||
@@ -6,6 +6,33 @@ info:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
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:
|
||||
get:
|
||||
summary: 读取采购实收、库存和出库批次
|
||||
@@ -43,7 +70,7 @@ paths:
|
||||
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/request-confirmation:
|
||||
post:
|
||||
summary: 采购完成并请求客户确认
|
||||
description: 权限 platform:overseas-business:purchase:receive-overseas;仅平台主体,必须指定目标客户 customerId。仅 RECEIVING 且已有实收;提交后不再追加收货。
|
||||
description: 权限 platform:overseas-business:purchase:receive-overseas;仅平台主体,必须指定目标客户 customerId。仅 RECEIVING 且已有实收;客户驳回后必须替换各批旧照片。提交后不再追加收货。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OrderNo'
|
||||
- $ref: '#/components/parameters/CustomerId'
|
||||
@@ -265,6 +292,21 @@ components:
|
||||
uniqueItems: true
|
||||
items:
|
||||
$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:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Command'
|
||||
@@ -331,6 +373,8 @@ components:
|
||||
required: [orderNo, version, status, receivedQuantity, stockQuantity, reservedQuantity, availableQuantity, receipts, outbounds, files]
|
||||
description: stockQuantity = receivedQuantity - 已完成出库数量;reservedQuantity 为未完成出库占用;availableQuantity = stockQuantity - reservedQuantity。主状态优先显示运输中批次,其次派送中;全部批次完成但仍有库存时回到已确认,无剩余库存才签收完成。
|
||||
properties:
|
||||
rejectionReason: {type: string, nullable: true, description: 客户驳回原因}
|
||||
photosNeedUpdate: {type: boolean, description: 存在尚未替换的被驳回照片}
|
||||
orderNo: {type: string}
|
||||
version: {type: integer, format: int64, minimum: 0}
|
||||
status:
|
||||
|
||||
@@ -211,7 +211,7 @@ components:
|
||||
actualPackages: {type: integer, minimum: 0}
|
||||
photoFileIds: {type: array, items: {type: string}}
|
||||
scannedCartonMarks: {type: array, items: {type: string}}
|
||||
confirmAllCartons: {type: boolean}
|
||||
confirmAllCartons: {type: boolean, deprecated: true, description: 仅兼容旧请求,不能绕过逐箱扫码校验}
|
||||
Preparation:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+57
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -210,8 +210,11 @@ class PdaCargoDiscardServiceTest {
|
||||
noWrites(); verifyNoInteractions(photoService);
|
||||
}
|
||||
@ParameterizedTest @ValueSource(booleans = {false, true})
|
||||
void looseTaskCompletesAfterAllScansOrExplicitBulkAndReplays(boolean bulk) {
|
||||
looseTask(); var request = looseRequest(bulk ? List.of() : List.of("BOX-2", "BOX-1"), bulk);
|
||||
void looseTaskRequiresAllScansEvenWithBulkFlagAndReplays(boolean 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(CargoDiscardService.PDA_COMPLETED, application.getStatus());
|
||||
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());
|
||||
}
|
||||
|
||||
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() {
|
||||
looseTask(); item.setCartonScanRequired(true);
|
||||
@ParameterizedTest @ValueSource(booleans = {false, true})
|
||||
void boundPalletExposesSelectedBoxesAndRequiresTheirScan(boolean partial) {
|
||||
looseTask(); item.setCartonScanRequired(partial);
|
||||
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");
|
||||
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
|
||||
|
||||
+47
@@ -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());
|
||||
}
|
||||
}
|
||||
+1
@@ -32,6 +32,7 @@ class CargoDiscardStockServiceTest {
|
||||
final CargoDiscardApplication application = new CargoDiscardApplication();
|
||||
CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); }
|
||||
@BeforeEach void setup() {
|
||||
ReflectionTestUtils.setField(service, "relabelStock", mock(CargoDiscardRelabelStockService.class));
|
||||
var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(repository, "stock", stock);
|
||||
|
||||
+1
-1
@@ -229,7 +229,7 @@ class DispatchMergedRelabelPersistenceTest {
|
||||
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",
|
||||
"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_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);
|
||||
|
||||
+16
@@ -46,6 +46,9 @@ class DriverPersistenceTest {
|
||||
configuration.addInterceptor(pagination);
|
||||
configuration.addMapper(DriverMapper.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();
|
||||
factory.setDataSource(ds);
|
||||
factory.setConfiguration(configuration);
|
||||
@@ -53,12 +56,25 @@ class DriverPersistenceTest {
|
||||
var target = new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class),
|
||||
mock(DriverFileGateway.class), new ObjectMapper().registerModule(new JavaTimeModule()),
|
||||
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);
|
||||
proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
|
||||
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() {
|
||||
var driver = service.create("operator", draft("初始备注"));
|
||||
jdbc.execute("ALTER TABLE ll_fleet_driver_history ADD CONSTRAINT reject_deactivate CHECK(action <> 'DEACTIVATE')");
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ class ProcurementWorkflowAuthorizationTest {
|
||||
var user=login(9,"MERCHANT","merchant:cross-border:procurement:list");
|
||||
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.reject(user,"CG-1",new Rejection(0,"r","照片不符合要求"))).isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
private AuthenticatedUser login(long customer,String app,String permission) {
|
||||
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
|
||||
|
||||
+21
@@ -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");
|
||||
}
|
||||
|
||||
/** 驳回后必须全批换新照片,返工不能重复增加库存。 */
|
||||
@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() {
|
||||
var first=new Receipt(0,"receive1",4,List.of(101L));
|
||||
var current=tx.execute(s->service.receive("9","CG-1","7",first));
|
||||
|
||||
+1
@@ -60,6 +60,7 @@ class RelabelDispatchSourcePersistenceTest {
|
||||
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))
|
||||
""");
|
||||
jdbc.execute("CREATE TABLE ll_cargo_discard_carton(customer_id VARCHAR(40),carton_id BIGINT,source_type VARCHAR(32))");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_inventory_position (
|
||||
id BIGINT PRIMARY KEY, customer_id VARCHAR(40), balance_id BIGINT,
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ class UsTruckOrderRequestsTest {
|
||||
var request = new UsTruckOrderRequests.Create(null, "LA", null, "ONT", null, 0,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", " ", null, null, "", null, null, null, "required");
|
||||
var fields = factory.getValidator().validate(request).stream().map(v -> v.getPropertyPath().toString()).collect(Collectors.toSet());
|
||||
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt"), fields);
|
||||
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt", "palletCount", "budgetAmount"), fields);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ class UsTruckOrderRequestsTest {
|
||||
void onlyCurrentVehicleTypesAreAccepted() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
for (String vehicle : java.util.List.of("PALLET_15", "PALLET_30", "OTHER")) {
|
||||
var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 0,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101",
|
||||
var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 1,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ONE, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101",
|
||||
LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, "vehicle");
|
||||
assertEquals(!vehicle.equals("OTHER"), factory.getValidator().validate(request).isEmpty());
|
||||
}
|
||||
|
||||
+3
-3
@@ -91,7 +91,7 @@ class UsTruckOrderServiceTest {
|
||||
assertEquals("+1-555-0101", detail.summary().destinationContactPhone());
|
||||
assertEquals(request.pickupAt(), detail.summary().pickupAt());
|
||||
assertEquals(request.deliveryAt(), detail.summary().deliveryAt());
|
||||
assertEquals(0, detail.summary().palletCount());
|
||||
assertEquals(3, detail.summary().palletCount());
|
||||
assertTrue(detail.pallets().isEmpty());
|
||||
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid")));
|
||||
}
|
||||
@@ -108,7 +108,7 @@ class UsTruckOrderServiceTest {
|
||||
@Test
|
||||
void reversedTimesAreRejectedBeforeCreatingAnOrder() {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ class UsTruckOrderServiceTest {
|
||||
|
||||
private UsTruckOrderRequests.Create create(String vehicleType, String key) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+46
@@ -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;
|
||||
}
|
||||
}
|
||||
+55
@@ -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;
|
||||
}
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user