优化
This commit is contained in:
@@ -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/` |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+26
@@ -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) { }
|
||||
}
|
||||
+23
-6
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+19
@@ -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
-2
@@ -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) { }
|
||||
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+129
-17
@@ -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<>'' " +
|
||||
"<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 >=#{q.createdFrom} </if>" +
|
||||
"<if test='q.createdTo != null'>AND o.created_at <=#{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)>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,''))<>COUNT(*) " +
|
||||
"OR COUNT(DISTINCT NULLIF(o.container_no,''))<>1 THEN '柜号缺失或不一致' " +
|
||||
"WHEN SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 0 ELSE 1 END)>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)>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)>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)<>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);
|
||||
}
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
+38
@@ -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");
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -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
|
||||
|
||||
+2
-1
@@ -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());
|
||||
|
||||
|
||||
+66
-5
@@ -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);
|
||||
|
||||
+77
@@ -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("<"));
|
||||
}
|
||||
}
|
||||
+101
@@ -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仓不存在或已停用");
|
||||
}
|
||||
}
|
||||
+64
-18
@@ -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) {
|
||||
|
||||
+1
@@ -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)); }
|
||||
|
||||
-46
@@ -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)"));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user