This commit is contained in:
2026-08-28 13:54:32 +08:00
parent fe68551124
commit 6b5f2f0565
29 changed files with 959 additions and 178 deletions
+1 -1
View File
@@ -40,7 +40,7 @@
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 待分配柜级摘要与整柜入仓计划转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合分页;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等;详见 `lastleg-service/AGENTS.md` |
| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合全柜进度与 `allPlansReady`,详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 |
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
+27 -1
View File
@@ -491,6 +491,24 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''',
'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'),
'ALTER TABLE ll_order DROP INDEX idx_ll_order_pending_container','SELECT 1'));
PREPARE phase4_container_progress_idx_stmt FROM @ddl; EXECUTE phase4_container_progress_idx_stmt; DEALLOCATE PREPARE phase4_container_progress_idx_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container_progress'),
'SELECT 1','ALTER TABLE ll_order ADD INDEX idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)'));
PREPARE phase4_container_progress_idx_stmt FROM @ddl; EXECUTE phase4_container_progress_idx_stmt; DEALLOCATE PREPARE phase4_container_progress_idx_stmt;
DROP PROCEDURE IF EXISTS assert_container_progress_index_ready;
DELIMITER $$
CREATE PROCEDURE assert_container_progress_index_ready()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_order' AND index_name='idx_ll_order_container_progress') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外区域初始化失败:柜级入库计划进度索引缺失';
END IF;
END$$
DELIMITER ;
CALL assert_container_progress_index_ready();
DROP PROCEDURE assert_container_progress_index_ready;
-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'),
@@ -585,7 +603,8 @@ CALL assert_pending_orders_do_not_use_source_warehouse();
DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse;
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
@@ -596,6 +615,13 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at)
) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话';
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='source_container_booking_id'),
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''稳定来源柜预订ID'' AFTER customer_id'));
PREPARE phase4_receiving_fact_stmt FROM @ddl; EXECUTE phase4_receiving_fact_stmt; DEALLOCATE PREPARE phase4_receiving_fact_stmt;
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='container_fact_hash'),
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN container_fact_hash VARCHAR(64) NULL COMMENT ''领取时整柜订单与计划事实摘要'' AFTER source_container_booking_id'));
PREPARE phase4_receiving_fact_stmt FROM @ddl; EXECUTE phase4_receiving_fact_stmt; DEALLOCATE PREPARE phase4_receiving_fact_stmt;
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'),
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no'));
PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt;
+1 -1
View File
@@ -11,7 +11,7 @@
| 功能 | 入口 |
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;待分配柜级摘要固定为 `/api/platform/v1/last-leg/orders/container-summaries`,按 `sourceContainerBookingId` 跨客户聚合分页,不允许前端本地拼柜 |
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、计划数、未操作数和 `allPlansReady`,`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
/** 国内 Warehouse Service 平台公共仓库只读目录。 */
@FeignClient(name = "warehouse-service", contextId = "domesticWarehouseDirectoryClient",
url = "${baiyue.clients.domestic-warehouse.url:}")
public interface DomesticWarehouseDirectoryClient {
@GetMapping("/internal/warehouses/0/{warehouseId}/active")
ApiResponse<Detail> activeWarehouse(
@PathVariable("warehouseId") long warehouseId,
@RequestHeader("X-Internal-Service-Token") String internalToken);
record Detail(Warehouse warehouse, List<Address> addresses) { }
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
String warehouseType, String countryCode, String status) { }
record Address(String stateProvince) { }
}
@@ -7,7 +7,8 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
@@ -77,11 +78,23 @@ public class LastlegOrderController {
@Operation(summary = "分页查询待分配柜级摘要",
description = "按稳定 sourceContainerBookingId 聚合待分配尾程订单;平台跨客户查询。Required authority: "
+ OverseasPermissions.LAST_MILE_ORDER_LIST)
public ApiResponse<PageResult<PendingContainerSummary>> containerSummaries(
public ApiResponse<PageResult<ContainerPlanProgressSummary>> containerSummaries(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid LastlegOrderRequests.PendingContainerQuery query) {
@Valid LastlegOrderRequests.ContainerProgressQuery query) {
platformScope(user, null);
return ApiResponse.success(orderService.pendingContainerPage(query));
return ApiResponse.success(orderService.containerProgressPage(query));
}
@GetMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
@Operation(summary = "查询柜级入库计划进度详情",
description = "按稳定 sourceContainerBookingId 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页影响。Required authority: "
+ OverseasPermissions.LAST_MILE_ORDER_LIST)
public ApiResponse<ContainerPlanProgressDetail> containerSummaryDetail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String sourceContainerBookingId) {
platformScope(user, null);
return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId));
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
@@ -110,7 +123,9 @@ public class LastlegOrderController {
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Transfer one order to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
@Operation(summary = "将单票尾程订单转入 FBA 目的仓",
description = "目标仓必须来自国内 Warehouse Service 的平台公共 ACTIVE FBA 主档,ID/编码/名称必须一致。Required authority: "
+ OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<Integer> transferOne(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -121,7 +136,9 @@ public class LastlegOrderController {
@PutMapping("/api/platform/v1/last-leg/orders/destination")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Transfer multiple orders to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
@Operation(summary = "批量将尾程订单转入 FBA 目的仓",
description = "目标仓必须来自国内 Warehouse Service 的平台公共 ACTIVE FBA 主档,ID/编码/名称必须一致。Required authority: "
+ OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<Integer> transferBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.dto;
import java.time.LocalDateTime;
import java.util.List;
/** 柜级摘要及柜内全部尾程订单、入仓码和计划状态。 */
public record ContainerPlanProgressDetail(
ContainerPlanProgressSummary summary,
List<Item> orders
) {
public ContainerPlanProgressDetail {
orders = orders == null ? List.of() : List.copyOf(orders);
}
public record Item(
Long orderId,
String customerId,
String orderNo,
String inboundCode,
String orderStatus,
Long planId,
String planNo,
String planStatus,
boolean planValid,
LocalDateTime plannedArrivalAt
) {
}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.dto;
import java.time.LocalDateTime;
/** 平台按稳定来源柜查询的入库计划完整度事实。 */
public record ContainerPlanProgressSummary(
String sourceContainerBookingId,
String containerNo,
long totalOrderCount,
long inboundCodeCount,
long missingInboundCodeCount,
long planCount,
long plannedInboundCodeCount,
long pendingPlanCount,
LocalDateTime plannedArrivalAt,
boolean allPlansReady,
String blockingReason
) {
}
@@ -36,14 +36,14 @@ public final class LastlegOrderRequests {
}
}
public record PendingContainerQuery(
public record ContainerProgressQuery(
@Min(1) long page,
@Min(1) @Max(200) long size,
String containerNo,
LocalDateTime createdFrom,
LocalDateTime createdTo
) {
public PendingContainerQuery {
public ContainerProgressQuery {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
@@ -1,18 +0,0 @@
package com.baiyue.lastleg.dto;
import java.math.BigDecimal;
/** 平台待分配尾程订单按来源货柜聚合的只读摘要。 */
public record PendingContainerSummary(
String sourceContainerBookingId,
String containerNo,
long orderCount,
long inboundCodeCount,
long customerCount,
long totalPackageCount,
long totalPalletCount,
BigDecimal totalVolumeCbm,
boolean eligible,
String ineligibleReason
) {
}
@@ -6,9 +6,13 @@ import java.util.List;
public final class ReceivingContainerResponses {
private ReceivingContainerResponses() { }
public record Candidate(String containerNo, String status, int planCount, int inboundCodeCount,
public record Candidate(String sourceContainerBookingId, String containerNo, String status,
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
List<String> destinationCodes, int plannedPalletCount,
int totalPackages, int receivedPackages, boolean currentAccount) { }
int totalPackages, int receivedPackages,
boolean operable, boolean allPlansReady, String blockReason,
LocalDateTime plannedArrivalAt, boolean currentAccount) { }
public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { }
@@ -16,6 +16,8 @@ public class ReceivingContainerSession {
@TableId(type = IdType.INPUT)
private Long id;
private String customerId;
private String sourceContainerBookingId;
private String containerFactHash;
private String containerNo;
private Long overseasWarehouseId;
private String overseasWarehouseName;
@@ -96,28 +96,24 @@ public class ReceivingContainerService {
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> {
if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) {
grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan);
}
});
List<LastlegOrder> orderFacts = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.isNotNull(LastlegOrder::getSourceContainerBookingId)
.isNotNull(LastlegOrder::getContainerNo)
.orderByAsc(LastlegOrder::getSourceContainerBookingId, LastlegOrder::getId));
List<InboundPlan> planFacts = plans.selectList(new LambdaQueryWrapper<InboundPlan>().orderByAsc(InboundPlan::getId));
List<InboundPlanLine> lineFacts = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>().orderByAsc(InboundPlanLine::getId));
Map<String, List<LastlegOrder>> grouped = orderFacts.stream()
.filter(order -> trim(order.getSourceContainerBookingId()) != null && trim(order.getContainerNo()) != null)
.collect(Collectors.groupingBy(LastlegOrder::getSourceContainerBookingId, LinkedHashMap::new, Collectors.toList()));
String operatorLockKey = activeOperatorKey(operatorKey);
Set<String> reservedByOthers = sessions.findActiveContainers().stream()
.filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey()))
.map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet());
return grouped.entrySet().stream()
.filter(entry -> eligible(entry.getValue(), overseasWarehouseId))
.filter(entry -> !reservedByOthers.contains(activeContainerKey(entry.getKey())))
.map(entry -> {
requireSingleCustomer(entry.getValue(), entry.getKey());
Totals totals = totals(entry.getValue());
boolean active = current != null && entry.getKey().equals(current.getContainerNo());
return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN",
entry.getValue().size(), totals.inboundCount(), totals.destinationCodes(), totals.plannedPalletCount(),
totals.total(), totals.received(), active);
}).toList();
.map(entry -> readiness(entry.getKey(), entry.getValue(), planFacts, lineFacts, overseasWarehouseId))
.filter(Objects::nonNull)
.filter(value -> !reservedByOthers.contains(activeContainerKey(value.containerNo())))
.map(value -> candidate(value, current)).toList();
}
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
@@ -144,6 +140,7 @@ public class ReceivingContainerService {
public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo,
ReceivingContainerRequests.Action request) {
PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey());
ContainerReadiness readiness = requireReadyContainer(containerNo, overseasWarehouseId, true);
ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command);
if (replay != null) {
ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class);
@@ -176,6 +173,8 @@ public class ReceivingContainerService {
LocalDateTime now = LocalDateTime.now();
ReceivingContainerSession session = new ReceivingContainerSession();
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
session.setSourceContainerBookingId(readiness.sourceContainerBookingId());
session.setContainerFactHash(readiness.factHash());
session.setOverseasWarehouseId(overseasWarehouseId);
session.setOverseasWarehouseName(receivingWarehouse.warehouseName());
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
@@ -381,9 +380,117 @@ public class ReceivingContainerService {
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key)
: sessions.findActiveByOperator(key);
if (session == null) throw notFound("Current receiving container session was not found");
requireStableSessionFacts(session, lock);
return session;
}
private ContainerReadiness requireReadyContainer(String containerNo, long overseasWarehouseId, boolean lock) {
LambdaQueryWrapper<LastlegOrder> query = new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getContainerNo, containerNo).orderByAsc(LastlegOrder::getId);
if (lock) query.last("FOR UPDATE");
List<LastlegOrder> orderFacts = orders.selectList(query);
Set<String> bookingIds = orderFacts.stream().map(LastlegOrder::getSourceContainerBookingId)
.map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet());
if (orderFacts.isEmpty() || bookingIds.size() != 1) {
throw conflict("Container must resolve exactly one sourceContainerBookingId");
}
List<InboundPlan> planFacts = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId));
List<InboundPlanLine> lineFacts = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.in(InboundPlanLine::getOrderId, orderFacts.stream().map(LastlegOrder::getId).toList())
.orderByAsc(InboundPlanLine::getId));
ContainerReadiness value = readiness(bookingIds.iterator().next(), orderFacts, planFacts, lineFacts,
overseasWarehouseId);
if (value == null || !value.operable()) {
throw conflict(value == null ? "Container is assigned to another overseas warehouse" : value.blockReason());
}
return value;
}
private void requireStableSessionFacts(ReceivingContainerSession session, boolean lock) {
if (trim(session.getContainerFactHash()) == null) return;
ContainerReadiness current = requireReadyContainer(session.getContainerNo(), session.getOverseasWarehouseId(), lock);
if (!Objects.equals(session.getSourceContainerBookingId(), current.sourceContainerBookingId())
|| !Objects.equals(session.getContainerFactHash(), current.factHash())) {
throw conflict("Container orders or inbound plans changed after prepare; restart the receiving session");
}
}
private ContainerReadiness readiness(String sourceContainerBookingId, List<LastlegOrder> orderFacts,
List<InboundPlan> planFacts, List<InboundPlanLine> lineFacts,
long overseasWarehouseId) {
Set<Long> orderIds = orderFacts.stream().map(LastlegOrder::getId).collect(Collectors.toSet());
Map<Long, List<InboundPlanLine>> linesByOrder = lineFacts.stream()
.filter(line -> orderIds.contains(line.getOrderId()))
.collect(Collectors.groupingBy(InboundPlanLine::getOrderId));
Map<Long, InboundPlan> plansById = planFacts.stream().collect(Collectors.toMap(InboundPlan::getId,
value -> value, (left, right) -> left));
List<InboundPlan> matchedPlans = linesByOrder.values().stream().flatMap(List::stream)
.map(line -> plansById.get(line.getPlanId())).filter(Objects::nonNull).distinct().toList();
Set<Long> assignedWarehouses = matchedPlans.stream().map(InboundPlan::getOverseasWarehouseId)
.filter(Objects::nonNull).collect(Collectors.toSet());
orderFacts.stream().map(LastlegOrder::getOverseasWarehouseId).map(ReceivingContainerService::trim)
.filter(Objects::nonNull).forEach(value -> {
try { assignedWarehouses.add(Long.valueOf(value)); }
catch (NumberFormatException ignored) { assignedWarehouses.add(Long.MIN_VALUE); }
});
if (assignedWarehouses.size() > 1) throw conflict("Container plans have inconsistent receiving warehouse assignments");
if (!assignedWarehouses.isEmpty() && !assignedWarehouses.contains(overseasWarehouseId)) return null;
Set<String> customers = orderFacts.stream().map(LastlegOrder::getCustomerId).collect(Collectors.toSet());
String containerNo = orderFacts.stream().map(LastlegOrder::getContainerNo).filter(Objects::nonNull).findFirst().orElse(null);
int totalOrders = orderFacts.size();
int inboundCodes = (int) orderFacts.stream().map(LastlegOrder::getInboundCode).map(ReceivingContainerService::trim)
.filter(Objects::nonNull).distinct().count();
int plannedOrders = (int) orderFacts.stream().filter(order -> {
List<InboundPlanLine> values = linesByOrder.getOrDefault(order.getId(), List.of());
return values.size() == 1 && plansById.containsKey(values.get(0).getPlanId());
}).count();
int planCount = matchedPlans.size();
boolean allPlansReady = totalOrders > 0 && customers.size() == 1 && inboundCodes == totalOrders
&& plannedOrders == totalOrders && planCount == totalOrders;
String customerId = customers.size() == 1 ? customers.iterator().next() : null;
boolean shippingReady = customerId != null && shippingGates.receivingAvailable(customerId, containerNo) == 1;
boolean operable = allPlansReady && shippingReady;
int pending = Math.max(0, totalOrders - plannedOrders);
String blockReason = operable ? null : (!allPlansReady
? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码"
: "柜号尚未达到海外收货可操作节点");
LocalDateTime arrival = orderFacts.stream().map(LastlegOrder::getEstimatedWarehouseAt)
.filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null);
String factHash = hash(orderFacts.stream().sorted(Comparator.comparing(LastlegOrder::getId)).map(order -> {
List<InboundPlanLine> values = linesByOrder.getOrDefault(order.getId(), List.of());
return order.getId() + ":" + trim(order.getInboundCode()) + ":" + values.stream()
.map(InboundPlanLine::getPlanId).sorted().toList();
}).toList());
Map<Long, LastlegOrder> ordersById = orderFacts.stream().collect(Collectors.toMap(LastlegOrder::getId,
value -> value, (left, right) -> left));
List<InboundRef> matchedRefs = lineFacts.stream().filter(line -> orderIds.contains(line.getOrderId()))
.map(line -> new InboundRef(plansById.get(line.getPlanId()), line, ordersById.get(line.getOrderId())))
.filter(ref -> ref.plan() != null && ref.order() != null).toList();
List<ReceivingContainerResponses.Inbound> inboundValues = matchedRefs.stream().map(this::inbound).toList();
List<String> destinationCodes = inboundValues.stream().map(this::destinationCode)
.filter(Objects::nonNull).distinct().sorted().toList();
Totals totals = new Totals(inboundValues.size(), destinationCodes,
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(),
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(),
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum());
return new ContainerReadiness(sourceContainerBookingId, containerNo, totalOrders, inboundCodes,
totalOrders - inboundCodes, planCount, plannedOrders, pending, allPlansReady, operable,
blockReason, arrival, factHash, totals);
}
private ReceivingContainerResponses.Candidate candidate(ContainerReadiness value,
ReceivingContainerSession current) {
boolean active = current != null && value.containerNo().equals(current.getContainerNo());
Totals totals = value.totals();
return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(),
active ? current.getStatus() : "WAIT_SIGN", value.totalOrderCount(), value.inboundCodeCount(),
value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(),
value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(),
totals.received(), value.operable(), value.allPlansReady(), value.blockReason(),
value.plannedArrivalAt(), active);
}
private ReceivingContainerSession requireSigned(String operatorKey) {
ReceivingContainerSession session = requireCurrent(operatorKey, true);
if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) {
@@ -858,5 +965,10 @@ public class ReceivingContainerService {
private record FbaSkuAllocation(InboundRef ref, String skuCode, String productName, int packages) { }
private record Totals(int inboundCount, List<String> destinationCodes, int plannedPalletCount,
int total, int received) { }
private record ContainerReadiness(String sourceContainerBookingId, String containerNo,
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
boolean allPlansReady, boolean operable, String blockReason,
LocalDateTime plannedArrivalAt, String factHash, Totals totals) { }
private record PrepareCommand(String containerNo, String idempotencyKey) { }
}
@@ -3,7 +3,8 @@ package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@@ -13,35 +14,69 @@ import java.util.List;
@Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
String PENDING_CONTAINER_FILTER = " FROM ll_order o " +
String CONTAINER_PROGRESS_FILTER = " FROM ll_order o LEFT JOIN ll_inbound_plan p " +
"ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id&lt;&gt;'' " +
"<if test='q.containerNo != null and q.containerNo != \"\"'>AND o.container_no LIKE CONCAT('%',#{q.containerNo},'%') </if>" +
"<if test='q.createdFrom != null'>AND o.created_at &gt;=#{q.createdFrom} </if>" +
"<if test='q.createdTo != null'>AND o.created_at &lt;=#{q.createdTo} </if>" +
"GROUP BY o.source_container_booking_id " +
"HAVING SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 1 ELSE 0 END)&gt;0 ";
"GROUP BY o.source_container_booking_id ";
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(DISTINCT NULLIF(o.container_no,''))=1 THEN MAX(NULLIF(o.container_no,'')) ELSE NULL END AS containerNo," +
"COUNT(*) AS orderCount,COUNT(DISTINCT o.inbound_code) AS inboundCodeCount," +
"COUNT(DISTINCT o.customer_id) AS customerCount,COALESCE(SUM(o.package_count),0) AS totalPackageCount," +
"COALESCE(SUM(o.pallet_count),0) AS totalPalletCount,COALESCE(SUM(o.volume_cbm),0) AS totalVolumeCbm," +
"CASE WHEN SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 0 ELSE 1 END)=0 " +
String CONTAINER_PROGRESS_COLUMNS = "SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " +
"THEN MAX(NULLIF(o.container_no,'')) ELSE NULL END AS containerNo," +
"COUNT(*) AS totalOrderCount,COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),'')) AS inboundCodeCount," +
"SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END) AS missingInboundCodeCount," +
"COUNT(DISTINCT p.id) AS planCount," +
"COUNT(DISTINCT CASE WHEN p.id IS NOT NULL AND NULLIF(TRIM(p.plan_no),'') IS NOT NULL " +
"AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN NULLIF(TRIM(o.inbound_code),'') END) AS plannedInboundCodeCount," +
"SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " +
"THEN 1 ELSE 0 END) AS pendingPlanCount," +
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) " +
"AND COUNT(DISTINCT o.estimated_warehouse_at)=1 THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS plannedArrivalAt," +
"CASE WHEN SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)=0 " +
"AND SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " +
"THEN 1 ELSE 0 END)=0 AND COUNT(DISTINCT p.id)=COUNT(*) " +
"AND COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " +
"THEN TRUE ELSE FALSE END AS eligible," +
"THEN TRUE ELSE FALSE END AS allPlansReady," +
"CASE WHEN COUNT(NULLIF(o.container_no,''))&lt;&gt;COUNT(*) " +
"OR COUNT(DISTINCT NULLIF(o.container_no,''))&lt;&gt;1 THEN '柜号缺失或不一致' " +
"WHEN SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 0 ELSE 1 END)&gt;0 " +
"THEN '柜内存在已生成计划或非待分配订单' ELSE NULL END AS ineligibleReason" +
PENDING_CONTAINER_FILTER +
"ORDER BY MAX(o.created_at) DESC,o.source_container_booking_id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<PendingContainerSummary> pagePendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query,
@Param("offset") long offset,
@Param("limit") long limit);
"WHEN SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)&gt;0 " +
"THEN CONCAT('存在',SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END),'票缺少入仓码') " +
"WHEN SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " +
"THEN 1 ELSE 0 END)&gt;0 THEN CONCAT('存在',SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL " +
"OR NULLIF(TRIM(p.status),'') IS NULL THEN 1 ELSE 0 END),'票尚未生成有效入库计划') " +
"WHEN COUNT(DISTINCT p.id)&lt;&gt;COUNT(*) THEN '入库计划与尾程订单关联不一致' ELSE NULL END AS blockingReason ";
@Select("<script>SELECT progress.* FROM (" + CONTAINER_PROGRESS_COLUMNS + CONTAINER_PROGRESS_FILTER +
") progress ORDER BY CASE WHEN progress.plannedArrivalAt IS NULL THEN 1 ELSE 0 END," +
"progress.plannedArrivalAt ASC,progress.sourceContainerBookingId ASC LIMIT #{limit} OFFSET #{offset}</script>")
List<ContainerPlanProgressSummary> pageContainerProgress(
@Param("q") LastlegOrderRequests.ContainerProgressQuery query,
@Param("offset") long offset,
@Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM (SELECT o.source_container_booking_id" +
PENDING_CONTAINER_FILTER + ") pending_containers</script>")
long countPendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query);
CONTAINER_PROGRESS_FILTER + ") container_progress</script>")
long countContainerProgress(@Param("q") LastlegOrderRequests.ContainerProgressQuery query);
@Select("<script>" + CONTAINER_PROGRESS_COLUMNS + " FROM ll_order o LEFT JOIN ll_inbound_plan p " +
"ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id=#{sourceContainerBookingId} GROUP BY o.source_container_booking_id</script>")
ContainerPlanProgressSummary findContainerProgress(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.order_no AS orderNo,o.inbound_code AS inboundCode," +
"o.current_status AS orderStatus,p.id AS planId,p.plan_no AS planNo,p.status AS planStatus," +
"CASE WHEN p.id IS NOT NULL AND NULLIF(TRIM(p.plan_no),'') IS NOT NULL " +
"AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN TRUE ELSE FALSE END AS planValid," +
"o.estimated_warehouse_at AS plannedArrivalAt FROM ll_order o LEFT JOIN ll_inbound_plan p " +
"ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id=#{sourceContainerBookingId} " +
"ORDER BY CASE WHEN o.estimated_warehouse_at IS NULL THEN 1 ELSE 0 END," +
"o.estimated_warehouse_at ASC,o.customer_id ASC,o.id ASC")
List<ContainerPlanProgressDetail.Item> findContainerProgressItems(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " +
"AND source_inbound_id=#{sourceInboundId} LIMIT 1")
@@ -0,0 +1,93 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Objects;
/** 通过国内 Warehouse Service 权威主档校验尾程转仓 FBA 目的仓。 */
@Component
public class FbaWarehouseValidator {
private static final String INVALID_MESSAGE = "所选FBA仓不存在或已停用";
@Resource private DomesticWarehouseDirectoryClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public VerifiedFbaWarehouse required(String requestedId, String requestedCode, String requestedName) {
long warehouseId = parseId(requestedId);
var response = lookup(warehouseId);
if (response == null || !response.successful() || response.data() == null
|| response.data().warehouse() == null) {
throw invalid();
}
DomesticWarehouseDirectoryClient.Warehouse warehouse = response.data().warehouse();
if (!Objects.equals(warehouse.id(), warehouseId)
|| !Objects.equals(warehouse.tenantId(), 0L)
|| !"FBA".equals(warehouse.warehouseType())
|| !"ACTIVE".equals(warehouse.status())
|| !same(requestedCode, warehouse.warehouseCode())
|| !same(requestedName, warehouse.warehouseName())) {
throw invalid();
}
String region = firstRegion(response.data().addresses());
return new VerifiedFbaWarehouse(warehouse.id(), warehouse.warehouseCode().trim(),
warehouse.warehouseName().trim(), trimToNull(warehouse.countryCode()), region);
}
private com.baiyue.common.core.api.ApiResponse<DomesticWarehouseDirectoryClient.Detail> lookup(long warehouseId) {
try {
return client.activeWarehouse(warehouseId, internalToken);
} catch (FeignException exception) {
if (exception.status() >= 400 && exception.status() < 500) throw invalid();
throw unavailable(exception);
} catch (ResponseStatusException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable(exception);
}
}
private long parseId(String value) {
try {
long id = Long.parseLong(value == null ? "" : value.trim());
if (id <= 0) throw invalid();
return id;
} catch (NumberFormatException exception) {
throw invalid();
}
}
private boolean same(String requested, String actual) {
return StringUtils.hasText(requested) && StringUtils.hasText(actual)
&& requested.trim().equals(actual.trim());
}
private String firstRegion(List<DomesticWarehouseDirectoryClient.Address> addresses) {
if (addresses == null) return null;
return addresses.stream().filter(Objects::nonNull)
.map(DomesticWarehouseDirectoryClient.Address::stateProvince)
.map(FbaWarehouseValidator::trimToNull).filter(Objects::nonNull).findFirst().orElse(null);
}
private ResponseStatusException invalid() {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, INVALID_MESSAGE);
}
private ResponseStatusException unavailable(RuntimeException cause) {
return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"国内FBA仓主数据暂不可用", cause);
}
private static String trimToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public record VerifiedFbaWarehouse(long id, String code, String name, String countryCode, String region) { }
}
@@ -6,7 +6,8 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
@@ -39,6 +40,7 @@ public class LastlegOrderService {
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private InboundPlanCartonMapper cartonMapper;
@Resource private FbaWarehouseValidator fbaWarehouseValidator;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
Page<LastlegOrder> page = orderMapper.selectPage(
@@ -48,20 +50,33 @@ public class LastlegOrderService {
}
@Transactional(readOnly = true)
public PageResult<PendingContainerSummary> pendingContainerPage(
LastlegOrderRequests.PendingContainerQuery query) {
public PageResult<ContainerPlanProgressSummary> containerProgressPage(
LastlegOrderRequests.ContainerProgressQuery query) {
if (query.createdFrom() != null && query.createdTo() != null
&& query.createdFrom().isAfter(query.createdTo())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"createdFrom must not be after createdTo");
}
long total = orderMapper.countPendingContainers(query);
List<PendingContainerSummary> records = total == 0 ? List.of()
: orderMapper.pagePendingContainers(query, (query.page() - 1) * query.size(), query.size());
long total = orderMapper.countContainerProgress(query);
List<ContainerPlanProgressSummary> records = total == 0 ? List.of()
: orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size());
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
@Transactional(readOnly = true)
public ContainerPlanProgressDetail containerProgressDetail(String sourceContainerBookingId) {
String bookingId = sourceContainerBookingId == null ? null : sourceContainerBookingId.trim();
if (!StringUtils.hasText(bookingId)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能为空");
}
ContainerPlanProgressSummary summary = orderMapper.findContainerProgress(bookingId);
if (summary == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
}
return new ContainerPlanProgressDetail(summary, orderMapper.findContainerProgressItems(bookingId));
}
public LastlegOrderDetailResponse detail(String customerId, long orderId) {
LastlegOrder order = findOwned(customerId, orderId);
List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
@@ -83,6 +98,9 @@ public class LastlegOrderService {
if (ids.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required");
}
FbaWarehouseValidator.VerifiedFbaWarehouse target = fbaWarehouseValidator.required(
request.warehouseId(), request.warehouseCode(), request.warehouseName());
String warehouseId = Long.toString(target.id());
List<LastlegOrder> orders = orderMapper.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.in(LastlegOrder::getId, ids).last("FOR UPDATE"));
@@ -95,25 +113,25 @@ public class LastlegOrderService {
LocalDateTime now = LocalDateTime.now();
for (LastlegOrder order : orders) {
String fromStatus = order.getCurrentStatus();
order.setTransferWarehouseId(request.warehouseId());
order.setTransferWarehouseCode(request.warehouseCode());
order.setTransferWarehouseName(request.warehouseName());
order.setTransferWarehouseId(warehouseId);
order.setTransferWarehouseCode(target.code());
order.setTransferWarehouseName(target.name());
order.setCurrentStatus(DESTINATION_ASSIGNED);
order.setUpdatedAt(now);
orderMapper.updateById(order);
LastlegOrderDetail detailUpdate = new LastlegOrderDetail();
detailUpdate.setDestinationWarehouseId(request.warehouseId());
detailUpdate.setDestinationWarehouseCode(request.warehouseCode());
detailUpdate.setDestinationWarehouseName(request.warehouseName());
detailUpdate.setDestinationWarehouseId(warehouseId);
detailUpdate.setDestinationWarehouseCode(target.code());
detailUpdate.setDestinationWarehouseName(target.name());
detailMapper.update(detailUpdate, new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, order.getCustomerId())
.eq(LastlegOrderDetail::getOrderId, order.getId()));
InboundPlanCarton cartonUpdate = new InboundPlanCarton();
cartonUpdate.setDestinationWarehouseId(request.warehouseId());
cartonUpdate.setDestinationWarehouseCode(request.warehouseCode());
cartonUpdate.setDestinationWarehouseName(request.warehouseName());
cartonUpdate.setDestinationWarehouseId(warehouseId);
cartonUpdate.setDestinationWarehouseCode(target.code());
cartonUpdate.setDestinationWarehouseName(target.name());
cartonMapper.update(cartonUpdate, new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, order.getCustomerId())
.eq(InboundPlanCarton::getOrderId, order.getId()));
@@ -124,7 +142,7 @@ public class LastlegOrderService {
history.setFromStatus(fromStatus);
history.setToStatus(DESTINATION_ASSIGNED);
history.setRemark(StringUtils.hasText(request.remark())
? request.remark() : "Destination warehouse assigned: " + request.warehouseCode());
? request.remark() : "Destination warehouse assigned: " + target.code());
history.setCreatedAt(now);
historyMapper.insert(history);
}
@@ -190,6 +190,7 @@ public class LoadingCompletedConsumer {
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size());
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
order.setEstimatedWarehouseAt(parsePlannedArrival(payload.estimatedWarehouseArrivalAt()));
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
@@ -237,6 +238,15 @@ public class LoadingCompletedConsumer {
}
}
private LocalDateTime parsePlannedArrival(String value) {
if (value == null || value.isBlank()) return null;
try {
return LocalDateTime.parse(value.trim());
} catch (java.time.format.DateTimeParseException exception) {
throw new IllegalArgumentException("事件计划到仓时间 estimatedWarehouseArrivalAt 无效", exception);
}
}
private void createInboundOrderSnapshot(EventEnvelope event, FirstLegLoadingPlanCompletedV1 payload,
FirstLegLoadingPlanCompletedV1.Order source, LastlegOrder order,
String orderHash) {
@@ -101,7 +101,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
KEY idx_ll_order_group(customer_id,order_no),
KEY idx_ll_order_container(customer_id,container_no),
KEY idx_ll_order_list(customer_id,current_status,created_at),
KEY idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)
KEY idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)
) ENGINE=InnoDB COMMENT='尾程订单主表';
CREATE TABLE IF NOT EXISTS ll_order_detail (
@@ -199,8 +199,10 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等
ALTER TABLE ll_order COMMENT='尾程订单主表';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)'));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), 'ALTER TABLE ll_order DROP INDEX idx_ll_order_pending_container', 'SELECT 1'));
PREPARE lastleg_order_pending_container_idx_stmt FROM @ddl; EXECUTE lastleg_order_pending_container_idx_stmt; DEALLOCATE PREPARE lastleg_order_pending_container_idx_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container_progress'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)'));
PREPARE lastleg_order_container_progress_idx_stmt FROM @ddl; EXECUTE lastleg_order_container_progress_idx_stmt; DEALLOCATE PREPARE lastleg_order_container_progress_idx_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name'));
@@ -708,6 +710,7 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL)
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
@@ -158,8 +158,8 @@ paths:
responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } }
/api/platform/v1/last-leg/orders/container-summaries:
get:
summary: 分页查询待分配柜级摘要
description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 聚合。仅返回至少有一票待分配的柜;eligible=false 表示柜内状态或柜号不一致,禁止转换。
summary: 分页查询柜级入库计划进度
description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -185,7 +185,58 @@ paths:
pages: { type: integer, format: int64 }
records:
type: array
items: { $ref: '#/components/schemas/PendingContainerSummary' }
items: { $ref: '#/components/schemas/ContainerPlanProgressSummary' }
/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}:
get:
summary: 查询柜级入库计划进度详情
description: 权限 platform:overseas-warehouse:order:list;按稳定柜 ID 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页截断。
security: [{ bearerAuth: [] }]
parameters:
- { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
responses:
'200':
description: 统一 ApiResponse 包装的柜级进度详情
content:
application/json:
schema:
type: object
properties:
code: { type: string, enum: ['200'] }
data: { $ref: '#/components/schemas/ContainerPlanProgressDetail' }
'404': { description: 稳定柜 ID 不存在 }
/api/platform/v1/last-leg/orders/{id}/destination:
put:
summary: 将单票尾程订单转入 FBA 目的仓
description: 权限 platform:overseas-warehouse:order:edit;目标必须来自国内 Warehouse Service 平台公共 ACTIVE FBA 主档,warehouseId/warehouseCode/warehouseName 必须与权威记录一致。实际海外收货仓、普通国内中转仓、停用仓或伪造字段均返回 A0400“所选FBA仓不存在或已停用”。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/FbaTransferDestination' }
responses:
'200': { description: 统一 ApiResponse 包装的更新订单数 }
'400': { description: 所选FBA仓不存在、已停用、类型错误或字段不一致 }
'503': { description: 国内FBA仓权威主数据暂不可用,写入失败关闭 }
/api/platform/v1/last-leg/orders/destination:
put:
summary: 批量将尾程订单转入 FBA 目的仓
description: 权限 platform:overseas-warehouse:order:edit;一次校验国内 Warehouse Service 权威 FBA 主档后,在 Lastleg 事务内更新所选订单及冻结明细。目标仓必须为平台公共 ACTIVE FBA 且 ID/编码/名称完全一致。
security: [{ bearerAuth: [] }]
parameters:
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/FbaBatchTransferDestination' }
responses:
'200': { description: 统一 ApiResponse 包装的更新订单数 }
'400': { description: 所选FBA仓不存在、已停用、类型错误或字段不一致 }
'503': { description: 国内FBA仓权威主数据暂不可用,写入失败关闭 }
/api/platform/v1/last-leg/inbound-plans/convert:
post:
summary: 平台生成入库计划
@@ -592,20 +643,63 @@ paths:
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
components:
schemas:
PendingContainerSummary:
FbaTransferDestination:
type: object
required: [sourceContainerBookingId, orderCount, inboundCodeCount, customerCount, totalPackageCount, totalPalletCount, totalVolumeCbm, eligible]
required: [warehouseId, warehouseCode, warehouseName]
properties:
warehouseId: { type: string, pattern: '^[1-9][0-9]*$', description: 国内 Warehouse Service 平台公共 FBA 仓 ID }
warehouseCode: { type: string, minLength: 1, maxLength: 128 }
warehouseName: { type: string, minLength: 1, maxLength: 255 }
remark: { type: string, nullable: true, maxLength: 500 }
FbaBatchTransferDestination:
allOf:
- { $ref: '#/components/schemas/FbaTransferDestination' }
- type: object
required: [orderIds]
properties:
orderIds:
type: array
minItems: 1
maxItems: 200
uniqueItems: true
items: { type: string, pattern: '^[1-9][0-9]*$' }
ContainerPlanProgressSummary:
type: object
required: [sourceContainerBookingId, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady]
properties:
sourceContainerBookingId: { type: string }
containerNo: { type: string, nullable: true }
orderCount: { type: integer, format: int64 }
totalOrderCount: { type: integer, format: int64 }
inboundCodeCount: { type: integer, format: int64 }
customerCount: { type: integer, format: int64 }
totalPackageCount: { type: integer, format: int64 }
totalPalletCount: { type: integer, format: int64 }
totalVolumeCbm: { type: number }
eligible: { type: boolean }
ineligibleReason: { type: string, nullable: true }
missingInboundCodeCount: { type: integer, format: int64 }
planCount: { type: integer, format: int64 }
plannedInboundCodeCount: { type: integer, format: int64 }
pendingPlanCount: { type: integer, format: int64, description: 尚未生成有效计划的尾程订单数,即未操作数 }
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 仅来源于装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at }
allPlansReady: { type: boolean, description: 全柜每票均有入仓码且均存在有效独立计划时为 true }
blockingReason: { type: string, nullable: true }
ContainerPlanProgressDetail:
type: object
required: [summary, orders]
properties:
summary: { $ref: '#/components/schemas/ContainerPlanProgressSummary' }
orders:
type: array
items: { $ref: '#/components/schemas/ContainerPlanProgressItem' }
ContainerPlanProgressItem:
type: object
required: [orderId, customerId, orderStatus, planValid]
properties:
orderId: { type: string, pattern: '^[1-9][0-9]*$' }
customerId: { type: string }
orderNo: { type: string, nullable: true }
inboundCode: { type: string, nullable: true }
orderStatus: { type: string }
planId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$' }
planNo: { type: string, nullable: true }
planStatus: { type: string, nullable: true }
planValid: { type: boolean }
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: ll_order.estimated_warehouse_at }
PdaWarehouseScan:
type: object
required: [code]
@@ -773,16 +867,25 @@ components:
warehouseName: { type: string, description: 国内Warehouse Service主档维护的当前中文名称 }
ReceivingCandidate:
type: object
required: [containerNo, status, planCount, inboundCodeCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, currentAccount]
required: [sourceContainerBookingId, containerNo, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount]
properties:
sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 }
containerNo: { type: string }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
totalOrderCount: { type: integer, minimum: 1 }
missingInboundCodeCount: { type: integer, minimum: 0 }
planCount: { type: integer }
inboundCodeCount: { type: integer }
plannedInboundCodeCount: { type: integer, minimum: 0 }
pendingPlanCount: { type: integer, minimum: 0 }
destinationCodes: { type: array, uniqueItems: true, items: { type: string } }
plannedPalletCount: { type: integer }
totalPackages: { type: integer }
receivedPackages: { type: integer }
operable: { type: boolean }
allPlansReady: { type: boolean }
blockReason: { type: string, nullable: true }
plannedArrivalAt: { type: string, format: date-time, nullable: true }
currentAccount: { type: boolean }
ReceivingSession:
type: object
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.client;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class DomesticWarehouseDirectoryClientContractTest {
@Test
void freezesActivePlatformWarehouseLookupAndDto() throws Exception {
FeignClient feign = DomesticWarehouseDirectoryClient.class.getAnnotation(FeignClient.class);
assertThat(feign.name()).isEqualTo("warehouse-service");
assertThat(feign.contextId()).isEqualTo("domesticWarehouseDirectoryClient");
assertThat(feign.url()).isEqualTo("${baiyue.clients.domestic-warehouse.url:}");
Method method = DomesticWarehouseDirectoryClient.class.getDeclaredMethod(
"activeWarehouse", long.class, String.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/internal/warehouses/0/{warehouseId}/active");
assertThat(method.getParameters()[0].getAnnotation(PathVariable.class).value())
.isEqualTo("warehouseId");
assertThat(method.getParameters()[1].getAnnotation(RequestHeader.class).value())
.isEqualTo("X-Internal-Service-Token");
assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Warehouse.class.getRecordComponents())
.map(value -> value.getName() + ":" + value.getType().getSimpleName()))
.containsExactly("id:Long", "tenantId:Long", "warehouseCode:String", "warehouseName:String",
"warehouseType:String", "countryCode:String", "status:String");
assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Address.class.getRecordComponents())
.map(value -> value.getName() + ":" + value.getType().getSimpleName()))
.containsExactly("stateProvince:String");
}
}
@@ -27,6 +27,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
class LastlegOrderControllerTest {
// Controller contract tests intentionally instantiate the controller without a Spring context here.
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
@@ -52,20 +53,27 @@ class LastlegOrderControllerTest {
@Test
void platformOperatorCanQueryCrossCustomerContainerSummaries() {
var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, "CONT-", null, null);
var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, "CONT-", null, null);
controller.containerSummaries(user(0L, "PLATFORM"), summaryQuery);
verify(service).pendingContainerPage(summaryQuery);
verify(service).containerProgressPage(summaryQuery);
}
@Test
void containerSummariesRejectMerchantIdentity() {
var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, null, null, null);
var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, null, null);
assertThrows(ResponseStatusException.class,
() -> controller.containerSummaries(user(100L, "MERCHANT"), summaryQuery));
verify(service, never()).pendingContainerPage(summaryQuery);
verify(service, never()).containerProgressPage(summaryQuery);
}
@Test
void platformOperatorCanQueryUnpagedContainerDetailByStableId() {
controller.containerSummaryDetail(user(0L, "PLATFORM"), "BOOKING-1");
verify(service).containerProgressDetail("BOOKING-1");
}
@Test
@@ -39,7 +39,8 @@ class ReceivingContainerControllerTest {
@Test
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
"CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false)));
"BOOKING-1", "CONT-1", "WAIT_SIGN", 2, 2, 0, 2, 2, 0,
List.of("ONT8"), 4, 20, 0, true, true, null, null, false)));
controller.candidates(picker());
@@ -87,11 +87,18 @@ class ReceivingContainerServiceBehaviorTest {
}
@Test
void containerIsHiddenAndPrepareRejectedBeforePickupReady() {
void containerRemainsVisibleButPrepareIsRejectedBeforePickupReady() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
LastlegOrder order = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1");
when(orders.selectList(any())).thenReturn(List.of(order));
when(shippingGates.receivingAvailable("100", "CONT-A")).thenReturn(0);
assertThat(service.candidates("7", 100L)).isEmpty();
assertThat(service.candidates("7", 100L)).singleElement().satisfies(candidate -> {
assertThat(candidate.operable()).isFalse();
assertThat(candidate.allPlansReady()).isTrue();
assertThat(candidate.blockReason()).contains("尚未达到");
});
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A",
new ReceivingContainerRequests.Action("not-ready")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
@@ -101,7 +108,10 @@ class ReceivingContainerServiceBehaviorTest {
@Test
void prepareIdempotencyHashIncludesContainerPath() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
when(lines.selectList(any())).thenReturn(List.of());
when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
LastlegOrder order = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1");
when(orders.selectList(any())).thenReturn(List.of(order));
when(orders.selectById(1L)).thenReturn(order);
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
@@ -132,14 +142,23 @@ class ReceivingContainerServiceBehaviorTest {
InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200");
when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second));
when(lines.selectList(any())).thenReturn(List.of());
when(orders.selectList(any())).thenReturn(List.of(
containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"),
containerOrder(2L, "200", "CONT-B", "BOOKING-B", "IN-2")));
assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A", "CONT-B");
InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200");
org.mockito.Mockito.reset(plans, sessions);
when(plans.selectList(any())).thenReturn(List.of(first, ambiguous));
assertThatThrownBy(() -> service.candidates("7", 100L)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("exactly one customer");
when(lines.selectList(any())).thenReturn(List.of());
when(orders.selectList(any())).thenReturn(List.of(
containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"),
containerOrder(2L, "200", "CONT-A", "BOOKING-A", "IN-2")));
assertThat(service.candidates("7", 100L)).singleElement().satisfies(candidate -> {
assertThat(candidate.operable()).isFalse();
assertThat(candidate.blockReason()).contains("未操作/缺入仓码");
});
}
@Test
@@ -150,6 +169,12 @@ class ReceivingContainerServiceBehaviorTest {
when(plans.selectList(any())).thenReturn(List.of(unassigned, same, other),
List.of(unassigned), List.of(same));
when(lines.selectList(any())).thenReturn(List.of());
LastlegOrder orderA = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1");
LastlegOrder orderB = containerOrder(2L, "100", "CONT-B", "BOOKING-B", "IN-2");
orderB.setOverseasWarehouseId("100");
LastlegOrder orderC = containerOrder(3L, "100", "CONT-C", "BOOKING-C", "IN-3");
orderC.setOverseasWarehouseId("200");
when(orders.selectList(any())).thenReturn(List.of(orderA, orderB, orderC));
assertThat(service.candidates("7", 100L)).extracting("containerNo")
.containsExactly("CONT-A", "CONT-B");
@@ -164,11 +189,36 @@ class ReceivingContainerServiceBehaviorTest {
other.setActiveContainerKey("CONTAINER:CONT-B");
when(plans.selectList(any())).thenReturn(List.of(available, reserved), List.of(available));
when(lines.selectList(any())).thenReturn(List.of());
when(orders.selectList(any())).thenReturn(List.of(
containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"),
containerOrder(2L, "100", "CONT-B", "BOOKING-B", "IN-2")));
when(sessions.findActiveContainers()).thenReturn(List.of(other));
assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A");
}
@Test
void partialContainerPlansRemainVisibleAndPrepareIsHardRejected() {
InboundPlan firstPlan = plan("CONT-A");
when(plans.selectList(any())).thenReturn(List.of(firstPlan));
when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
when(orders.selectList(any())).thenReturn(List.of(
containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"),
containerOrder(2L, "100", "CONT-A", "BOOKING-A", "IN-2")));
var candidate = service.candidates("7", 100L).get(0);
assertThat(candidate.totalOrderCount()).isEqualTo(2);
assertThat(candidate.plannedInboundCodeCount()).isEqualTo(1);
assertThat(candidate.pendingPlanCount()).isEqualTo(1);
assertThat(candidate.operable()).isFalse();
assertThat(candidate.blockReason()).isEqualTo("1个入仓码已生成计划,1个订单未操作/缺入仓码");
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A",
new ReceivingContainerRequests.Action("partial")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
}
@Test
void detailRejectsContainerReservedByAnotherOperator() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
@@ -193,6 +243,9 @@ class ReceivingContainerServiceBehaviorTest {
@Test
void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(lines.selectList(any())).thenReturn(List.of(line("FBA")));
LastlegOrder order = containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1");
when(orders.selectList(any())).thenReturn(List.of(order));
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class);
@@ -575,6 +628,14 @@ class ReceivingContainerServiceBehaviorTest {
return value;
}
private static LastlegOrder containerOrder(long id, String customerId, String containerNo,
String bookingId, String inboundCode) {
LastlegOrder value = order("FBA");
value.setId(id); value.setCustomerId(customerId); value.setContainerNo(containerNo);
value.setSourceContainerBookingId(bookingId); value.setInboundCode(inboundCode);
return value;
}
private static LastlegOrderDetail detail(String skuCode, int packages) {
LastlegOrderDetail value = new LastlegOrderDetail();
value.setId(11L); value.setCustomerId("100"); value.setOrderId(1L);
@@ -0,0 +1,77 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ContainerPlanProgressContractTest {
@Test
void responseFieldsAndServerAggregateStayFrozen() throws Exception {
assertEquals(List.of("sourceContainerBookingId", "containerNo", "totalOrderCount", "inboundCodeCount",
"missingInboundCodeCount", "planCount", "plannedInboundCodeCount", "pendingPlanCount",
"plannedArrivalAt", "allPlansReady", "blockingReason"),
Arrays.stream(ContainerPlanProgressSummary.class.getRecordComponents())
.map(value -> value.getName()).toList());
assertEquals(List.of("orderId", "customerId", "orderNo", "inboundCode", "orderStatus", "planId",
"planNo", "planStatus", "planValid", "plannedArrivalAt"),
Arrays.stream(ContainerPlanProgressDetail.Item.class.getRecordComponents())
.map(value -> value.getName()).toList());
Select rows = LastlegOrderMapper.class.getMethod("pageContainerProgress",
LastlegOrderRequests.ContainerProgressQuery.class, long.class, long.class).getAnnotation(Select.class);
String sql = String.join("", rows.value()).toLowerCase();
assertTrue(sql.contains("left join ll_inbound_plan"));
assertTrue(sql.contains("o.estimated_warehouse_at"));
assertTrue(sql.contains("progress.plannedarrivalat asc"));
assertTrue(sql.contains("progress.sourcecontainerbookingid asc"));
assertTrue(sql.contains("limit #{limit} offset #{offset}"));
}
@Test
void openApiAndSchemaExposeReadinessAndUnpagedDetail() throws Exception {
String openApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
String deployment = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}:"));
assertTrue(openApi.contains("ContainerPlanProgressSummary:"));
assertTrue(openApi.contains("allPlansReady:"));
assertTrue(openApi.contains("ll_order.estimated_warehouse_at"));
assertTrue(schema.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)"));
assertTrue(deployment.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)"));
assertTrue(deployment.contains("assert_container_progress_index_ready"));
}
@Test
void mybatisRendersExecutableProgressSqlWithoutXmlEntities() throws Exception {
Select select = LastlegOrderMapper.class.getMethod("pageContainerProgress",
LastlegOrderRequests.ContainerProgressQuery.class, long.class, long.class).getAnnotation(Select.class);
SqlSource source = new XMLLanguageDriver().createSqlSource(new Configuration(),
String.join("", select.value()), Map.class);
String sql = source.getBoundSql(Map.of(
"q", new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, null, null),
"limit", 20L, "offset", 0L)).getSql().replaceAll("\\s+", " ").trim();
assertTrue(sql.contains("source_container_booking_id<>''"));
assertTrue(sql.contains("LEFT JOIN ll_inbound_plan"));
assertTrue(!sql.contains("&lt;"));
}
}
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FbaWarehouseValidatorTest {
@Mock private DomesticWarehouseDirectoryClient client;
private FbaWarehouseValidator validator;
@BeforeEach
void setUp() {
validator = new FbaWarehouseValidator();
ReflectionTestUtils.setField(validator, "client", client);
ReflectionTestUtils.setField(validator, "internalToken", "internal-token");
}
@Test
void acceptsOnlyMatchingActivePlatformFbaAndReturnsRegionSnapshot() {
when(client.activeWarehouse(193000000000000001L, "internal-token"))
.thenReturn(ApiResponse.success(detail("FBA", "ACTIVE", "ONT8", "安大略 ONT8")));
var result = validator.required("193000000000000001", "ONT8", "安大略 ONT8");
assertThat(result.id()).isEqualTo(193000000000000001L);
assertThat(result.code()).isEqualTo("ONT8");
assertThat(result.name()).isEqualTo("安大略 ONT8");
assertThat(result.countryCode()).isEqualTo("US");
assertThat(result.region()).isEqualTo("CA");
}
@Test
void rejectsAnActualOverseasReceivingWarehouseAsFbaDestination() {
when(client.activeWarehouse(193000000000000001L, "internal-token"))
.thenReturn(ApiResponse.success(detail("OVERSEAS", "ACTIVE", "US-LAX", "洛杉矶海外收货仓")));
assertInvalid(() -> validator.required("193000000000000001", "US-LAX", "洛杉矶海外收货仓"));
}
@Test
void rejectsForgedCodeOrNameEvenWhenIdPointsToAnActiveFba() {
when(client.activeWarehouse(193000000000000001L, "internal-token"))
.thenReturn(ApiResponse.success(detail("FBA", "ACTIVE", "ONT8", "安大略 ONT8")));
assertInvalid(() -> validator.required("193000000000000001", "FAKE", "安大略 ONT8"));
assertInvalid(() -> validator.required("193000000000000001", "ONT8", "伪造名称"));
}
@Test
void rejectsInactiveOrMissingAuthorityFactWithFrozenMessage() {
when(client.activeWarehouse(193000000000000001L, "internal-token"))
.thenReturn(ApiResponse.failure(ResultCode.NOT_FOUND, "仓库已停用", null, null));
assertInvalid(() -> validator.required("193000000000000001", "ONT8", "安大略 ONT8"));
assertInvalid(() -> validator.required("not-an-id", "ONT8", "安大略 ONT8"));
}
@Test
void openApiFreezesFbaAuthorityValidationAndExistingAdminCandidateContract() throws Exception {
String lastlegOpenApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String warehouseController = Files.readString(Path.of(
"../warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java"),
StandardCharsets.UTF_8);
assertThat(lastlegOpenApi).contains("/api/platform/v1/last-leg/orders/{id}/destination:")
.contains("FbaTransferDestination:")
.contains("所选FBA仓不存在或已停用");
assertThat(warehouseController).contains("@RequestMapping(\"/api/platform/v1/warehouse-admin\")")
.contains("@PreAuthorize(\"hasAuthority('platform:warehouse:list')\")");
}
private DomesticWarehouseDirectoryClient.Detail detail(String type, String status, String code, String name) {
return new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(193000000000000001L, 0L, code, name,
type, "US", status),
List.of(new DomesticWarehouseDirectoryClient.Address("CA")));
}
private void assertInvalid(org.assertj.core.api.ThrowableAssert.ThrowingCallable action) {
assertThatThrownBy(action).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("所选FBA仓不存在或已停用");
}
}
@@ -1,7 +1,8 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
@@ -20,7 +21,6 @@ import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -30,13 +30,16 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
class LastlegOrderServiceTest {
// Service tests keep remote master-data validation and persistence independently observable here.
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
@Mock InboundPlanCartonMapper cartons;
@Mock FbaWarehouseValidator fbaWarehouses;
LastlegOrderService service;
@BeforeEach
@@ -46,6 +49,11 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
ReflectionTestUtils.setField(service, "cartonMapper", cartons);
ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouses);
lenient().when(fbaWarehouses.required(any(), any(), any())).thenAnswer(invocation ->
new FbaWarehouseValidator.VerifiedFbaWarehouse(
Long.parseLong(invocation.getArgument(0)), invocation.getArgument(1), invocation.getArgument(2),
"US", "CA"));
}
@Test
@@ -73,11 +81,11 @@ class LastlegOrderServiceTest {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
when(orders.selectList(any())).thenReturn(List.of(order));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null);
List.of(11L), "193000000000000002", "LAX-02", "Los Angeles 02", null);
assertEquals(1, service.transferDestination("100", request));
assertEquals("wh-2", order.getTransferWarehouseId());
assertEquals("193000000000000002", order.getTransferWarehouseId());
assertEquals("LAX-02", order.getTransferWarehouseCode());
assertEquals("DESTINATION_ASSIGNED", order.getCurrentStatus());
ArgumentCaptor<LastlegOrderDetail> cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class);
@@ -85,7 +93,7 @@ class LastlegOrderServiceTest {
assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode());
ArgumentCaptor<InboundPlanCarton> carton = ArgumentCaptor.forClass(InboundPlanCarton.class);
verify(cartons).update(carton.capture(), any());
assertEquals("wh-2", carton.getValue().getDestinationWarehouseId());
assertEquals("193000000000000002", carton.getValue().getDestinationWarehouseId());
assertEquals("LAX-02", carton.getValue().getDestinationWarehouseCode());
ArgumentCaptor<LastlegOrderStatusHistory> history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
verify(histories).insert(history.capture());
@@ -99,7 +107,7 @@ class LastlegOrderServiceTest {
LastlegOrder order = order(11L, "100", "DESTINATION_ASSIGNED", "IB-1");
when(orders.selectList(any())).thenReturn(List.of(order));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null);
List.of(11L), "193000000000000002", "LAX-02", "Los Angeles 02", null);
assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request));
verify(orders, never()).updateById(any(LastlegOrder.class));
@@ -111,7 +119,7 @@ class LastlegOrderServiceTest {
void batchRejectsMissingOrCrossCustomerIdsBeforeWriting() {
when(orders.selectList(any())).thenReturn(List.of(order(11L, "100", "PENDING_ASSIGNMENT", null)));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L, 22L), "wh-2", "LAX-02", "Los Angeles 02", null);
List.of(11L, 22L), "193000000000000002", "LAX-02", "Los Angeles 02", null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.transferDestination("100", request));
@@ -119,6 +127,22 @@ class LastlegOrderServiceTest {
verify(orders, never()).updateById(any(LastlegOrder.class));
}
@Test
void forgedFbaSelectionIsRejectedBeforeLockingOrWritingOrders() {
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "193000000000000002", "FAKE", "伪造仓库", null);
when(fbaWarehouses.required("193000000000000002", "FAKE", "伪造仓库"))
.thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,
"所选FBA仓不存在或已停用"));
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.transferDestination("100", request));
assertTrue(error.getMessage().contains("所选FBA仓不存在或已停用"));
verify(orders, never()).selectList(any());
verify(orders, never()).updateById(any(LastlegOrder.class));
}
@Test
void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() {
var query = new LastlegOrderRequests.Query(3, 25, "LL-", "FIRST_LEG", "IN-", null,
@@ -137,14 +161,13 @@ class LastlegOrderServiceTest {
}
@Test
void pendingContainerPageUsesServerSideAggregatePagination() {
var query = new LastlegOrderRequests.PendingContainerQuery(2, 10, "CONT-", null, null);
var row = new PendingContainerSummary("BOOKING-1", "CONT-1", 15, 15, 3,
150, 0, new BigDecimal("12.5000"), true, null);
when(orders.countPendingContainers(query)).thenReturn(11L);
when(orders.pagePendingContainers(query, 10, 10)).thenReturn(List.of(row));
void containerProgressPageUsesServerSideAggregatePagination() {
var query = new LastlegOrderRequests.ContainerProgressQuery(2, 10, "CONT-", null, null);
var row = summary(false, 3, "存在3票尚未生成有效入库计划");
when(orders.countContainerProgress(query)).thenReturn(11L);
when(orders.pageContainerProgress(query, 10, 10)).thenReturn(List.of(row));
var result = service.pendingContainerPage(query);
var result = service.containerProgressPage(query);
assertEquals(2, result.page());
assertEquals(10, result.size());
@@ -154,13 +177,36 @@ class LastlegOrderServiceTest {
}
@Test
void pendingContainerPageRejectsReversedDateRangeBeforeQuerying() {
var query = new LastlegOrderRequests.PendingContainerQuery(1, 20, null,
void containerProgressPageRejectsReversedDateRangeBeforeQuerying() {
var query = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null,
LocalDateTime.of(2026, 8, 20, 0, 0), LocalDateTime.of(2026, 8, 19, 0, 0));
assertThrows(ResponseStatusException.class, () -> service.pendingContainerPage(query));
assertThrows(ResponseStatusException.class, () -> service.containerProgressPage(query));
verify(orders, never()).countPendingContainers(any());
verify(orders, never()).countContainerProgress(any());
}
@Test
void containerProgressDetailReturnsEveryOrderWithoutPagination() {
var summary = summary(true, 0, null);
var items = List.of(
new ContainerPlanProgressDetail.Item(1L, "100", "LMO-1", "RK-1", "INBOUND_PLANNED",
101L, "LIP-1", "PENDING_ASSIGNMENT", true, LocalDateTime.of(2026, 10, 3, 9, 0)),
new ContainerPlanProgressDetail.Item(2L, "200", "LMO-1", "RK-2", "INBOUND_PLANNED",
102L, "LIP-2", "PDA_PENDING", true, LocalDateTime.of(2026, 10, 3, 9, 0)));
when(orders.findContainerProgress("BOOKING-1")).thenReturn(summary);
when(orders.findContainerProgressItems("BOOKING-1")).thenReturn(items);
var result = service.containerProgressDetail(" BOOKING-1 ");
assertEquals(summary, result.summary());
assertEquals(items, result.orders());
verify(orders).findContainerProgressItems("BOOKING-1");
}
private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) {
return new ContainerPlanProgressSummary("BOOKING-1", "CONT-1", 15, 15, 0,
15 - pending, 15 - pending, pending, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason);
}
private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) {
@@ -5,6 +5,7 @@ class LoadingCompletedConsumerTest {
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); }
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@@ -1,46 +0,0 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PendingContainerSummaryContractTest {
@Test
void responseFieldsAndServerAggregateSqlStayFrozen() throws Exception {
assertEquals(List.of("sourceContainerBookingId", "containerNo", "orderCount", "inboundCodeCount",
"customerCount", "totalPackageCount", "totalPalletCount", "totalVolumeCbm", "eligible",
"ineligibleReason"),
Arrays.stream(PendingContainerSummary.class.getRecordComponents()).map(value -> value.getName()).toList());
Select rows = LastlegOrderMapper.class.getMethod("pagePendingContainers",
com.baiyue.lastleg.dto.LastlegOrderRequests.PendingContainerQuery.class, long.class, long.class)
.getAnnotation(Select.class);
String sql = String.join("", rows.value()).toLowerCase();
assertTrue(sql.contains("group by o.source_container_booking_id"));
assertTrue(sql.contains("pending_assignment"));
assertTrue(sql.contains("destination_assigned"));
assertTrue(sql.contains("limit #{limit} offset #{offset}"));
}
@Test
void openApiAndSchemaExposeStableContainerIdentity() throws Exception {
String openApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries:"));
assertTrue(openApi.contains("PendingContainerSummary:"));
assertTrue(openApi.contains("sourceContainerBookingId:"));
assertTrue(schema.contains("idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)"));
}
}
+19 -5
View File
@@ -138,12 +138,13 @@ foreach ($pdaApplication in $pdaApplications) {
}
}
# 海外 PDA 当前只开放真实拆柜收货;其余后端虽已存在,前端接入前必须保持禁用。
# 海外 PDA 当前开放真实拆柜收货与托盘管理;其余后端虽已存在,前端接入前必须保持禁用。
$overseasWorkbenchPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/data/overseas-workbench.js'
$overseasWorkbench = Read-RequiredText $overseasWorkbenchPath
Assert-Matches $overseasWorkbench "(?s)name:\s*'拆柜收货'.{0,300}?available:\s*true" 'Overseas PDA 拆柜收货开关'
Assert-Matches $overseasWorkbench "(?s)name:\s*'托盘管理'.{0,300}?available:\s*true.{0,200}?route:\s*'/pages/pallet/pallet'" 'Overseas PDA 托盘管理开关'
foreach ($disabledOperation in @(
'库存盘点', '商品移库', '托盘管理',
'库存盘点', '商品移库',
'换标收货', '换标执行', '换标复核',
'库存查询', '库位查询', '托盘查询'
)) {
@@ -280,6 +281,7 @@ $overseasWarehouseSql = Read-RequiredText (Join-Path $backendRoot 'warehouse-ove
$adminIamApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/iam/api/platformIam.ts')
Assert-Contains $backendAgents '全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`' '后端仓库主档权威'
Assert-Contains $warehouseAgents '/internal/warehouses/shared-overseas' '国内 Warehouse 共享海外目录'
Assert-Contains $warehouseAgents '/internal/warehouses/0/{warehouseId}/active' '国内 Warehouse FBA 内部校验入口'
Assert-Contains $overseasWarehouseAgents '只保存国内仓库 ID 引用' '海外 Warehouse 引用边界'
Assert-Contains $domesticWarehouseController '@GetMapping("/shared-overseas")' '共享海外仓列表入口'
Assert-Contains $domesticWarehouseService 'hasReceivingCapability' '共享海外收货能力筛选'
@@ -305,16 +307,24 @@ Assert-Contains $productImageController "hasAuthority('platform:order:list')" '
Assert-Contains $receivingController '/current/unload-photos/files/{fileId}/download' '海外 PDA 签收照片预览'
Assert-Contains $receivingController 'CacheControl.noStore()' '海外 PDA 签收照片缓存边界'
# 待分配尾程订单按来源柜由服务端聚合,整柜转换每票独立计划且整批原子。
# 尾程订单按来源柜展示服务端计划进度,整柜转换每票独立计划且整批原子。
$lastlegBusinessOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml')
$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md')
$adminLastMileApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts')
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/container-summaries:' 'Lastleg 柜级摘要契约'
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约'
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
Assert-Contains $adminAgents 'allPlansReady' 'Admin 柜级计划就绪事实'
Assert-Contains $adminAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
Assert-Contains $adminAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用'
Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用'
Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressSummary' 'Lastleg 柜级计划进度摘要模型'
Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressDetail' 'Lastleg 柜级计划进度详情模型'
Assert-Contains $lastlegBusinessOpenApi 'allPlansReady' 'Lastleg 全柜计划就绪事实'
Assert-Contains $lastlegBusinessOpenApi 'plannedArrivalAt' 'Lastleg 计划到仓冻结事实'
Assert-Contains $lastlegAgents 'service/FbaWarehouseValidator.java' 'Lastleg FBA 权威仓校验入口'
# 国内 PDA 部分拣货必须展示真实可用库位事实,并在无库位时保持页面整洁。
Assert-Contains $pdaAgents 'tests/partial-layout.test.mjs' '国内 PDA 部分拣货布局合同'
@@ -330,6 +340,9 @@ foreach ($module in $pom.project.modules.module) {
$criticalPaths = @(
'apps/admin-web/src/modules/overseas-warehouse/api/inboundOrders.ts',
'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts',
'apps/pda-overseas-uniapp/src/api/pallet.js',
'apps/pda-overseas-uniapp/src/pages/pallet/pallet.vue',
'apps/pda-overseas-uniapp/tests/pallet-operations.test.mjs',
'apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue',
'apps/admin-web/src/modules/overseas-warehouse/api/inboundPlans.ts',
'apps/admin-web/src/modules/overseas-warehouse/api/inventory.ts',
@@ -444,8 +457,9 @@ $criticalPaths = @(
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java',
'baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java',
'baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java',
'baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java',
+2
View File
@@ -12,6 +12,8 @@
| 目标 | 入口 |
|---|---|
| 仓库/地址/能力 | `controller/PlatformWarehouseController.java`、`MerchantWarehouseController.java`、`service/WarehouseApplicationService.java` |
| FBA 目的仓公共主数据与 Admin 候选 | 权威表 `wh_warehouse` 的 `tenant_id=0 + warehouse_type=FBA` 记录;Admin 直接调用 `GET /api/platform/v1/warehouse-admin?warehouseType=FBA&status=ACTIVE&page=1&size=200`,权限 `platform:warehouse:list`,DTO `WarehouseSummaryResponse` 的 `id/warehouseCode/warehouseName/status/countryCode/primaryAddress.stateProvince` 分别作为 ID/编码/名称/状态/国家/区域 |
| FBA 目的仓内部校验 | `InternalWarehouseController.java` 的 `GET /internal/warehouses/0/{warehouseId}/active`,使用内部服务令牌返回 `WarehouseDetailResponse`;调用方仍须校验 `warehouseType=FBA` 及 ID/编码/名称一致,不得把 `OVERSEAS` 实际收货仓或 `DOMESTIC` 中转仓当作 FBA 目的仓 |
| 公共海外收货仓内部目录 | `controller/InternalWarehouseController.java` 的 `/internal/warehouses/shared-overseas`、`/shared-overseas/{warehouseId}`,响应 `dto/SharedOverseasWarehouseResponse.java`;只返回平台公共 `OVERSEAS + ACTIVE + RECEIVING` 仓,供海外资源服务按 warehouseId 引用 |
| 四期海外仓合作仓类型 | 沿用仓库/地址/能力入口;同步核对仓库 DTO、OpenAPI、`src/main/resources/db/`、统一 SQL 及 Admin `modules/warehouses/`,并与普通海外仓语义明确区分 |
| 往来单位/快递 | `controller/PlatformPartnerController.java`、`PlatformExpressCompanyController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java` |