优化
This commit is contained in:
+27
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
|
||||
import com.baiyue.lastleg.dto.ContainerArrivalTimeView;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
@@ -40,6 +41,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@Tag(name = "Tail-leg orders")
|
||||
@@ -112,6 +114,31 @@ public class LastlegOrderController {
|
||||
return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId));
|
||||
}
|
||||
|
||||
@PostMapping("/api/platform/v1/last-leg/orders/container-arrival-times/query")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
||||
@Operation(summary = "批量查询整柜预计与实际到仓时间",
|
||||
description = "预计到仓取平台人工维护的整柜冻结值;实际到仓取海外PDA首次开始收货时间。")
|
||||
public ApiResponse<List<ContainerArrivalTimeView>> containerArrivalTimes(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody LastlegOrderRequests.ContainerArrivalTimeQuery request) {
|
||||
platformScope(user, null);
|
||||
return ApiResponse.success(orderService.containerArrivalTimes(request));
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/expected-arrival")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
||||
@Operation(summary = "修改整柜预计到仓时间",
|
||||
description = "在入库计划生成前原子更新来源柜全部尾程订单;入库计划统一读取该柜时间。")
|
||||
public ApiResponse<ContainerArrivalTimeView> changeExpectedWarehouseArrival(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String sourceContainerBookingId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody LastlegOrderRequests.ChangeExpectedWarehouseArrival request) {
|
||||
platformScope(user, null);
|
||||
return ApiResponse.success(orderService.changeExpectedWarehouseArrival(sourceContainerBookingId,
|
||||
idempotencyKey, request, operatorName(user)));
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
||||
@Operation(summary = "修改整柜海外接柜仓",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.lastleg.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 平台柜子工作台使用的整柜到仓时间事实。 */
|
||||
public record ContainerArrivalTimeView(
|
||||
String sourceContainerBookingId,
|
||||
LocalDateTime estimatedWarehouseArrivalAt,
|
||||
LocalDateTime actualWarehouseArrivalAt,
|
||||
int version
|
||||
) { }
|
||||
@@ -61,6 +61,16 @@ public final class LastlegOrderRequests {
|
||||
@Size(max = 500) String remark
|
||||
) { }
|
||||
|
||||
public record ContainerArrivalTimeQuery(
|
||||
@NotEmpty @Size(max = 200) List<@NotBlank @Size(max = 128) String> sourceContainerBookingIds
|
||||
) { }
|
||||
|
||||
public record ChangeExpectedWarehouseArrival(
|
||||
@NotNull LocalDateTime estimatedWarehouseArrivalAt,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@Size(max = 500) String remark
|
||||
) { }
|
||||
|
||||
public record TransferDestination(
|
||||
@NotEmpty @Size(max = 200) List<Long> orderIds,
|
||||
@NotBlank @Size(max = 128) String warehouseId,
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_container_arrival_time_operation")
|
||||
public class ContainerArrivalTimeOperation {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String sourceContainerBookingId;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private Integer resultVersion;
|
||||
private String operatorName;
|
||||
private String remark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -52,6 +52,7 @@ public class LastlegOrder {
|
||||
private LocalDateTime estimatedArrivalAt;
|
||||
private LocalDateTime actualArrivalAt;
|
||||
private LocalDateTime estimatedWarehouseAt;
|
||||
private Integer arrivalTimeVersion;
|
||||
private String inboundOrderNo;
|
||||
private String outboundOrderNo;
|
||||
private String deliveryOrderNo;
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.lastleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface ContainerArrivalTimeOperationMapper extends BaseMapper<ContainerArrivalTimeOperation> {
|
||||
@Select("SELECT * FROM ll_container_arrival_time_operation WHERE idempotency_key=#{key} LIMIT 1")
|
||||
ContainerArrivalTimeOperation findByKey(@Param("key") String key);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
|
||||
import com.baiyue.lastleg.dto.ContainerArrivalTimeView;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
@@ -138,6 +139,29 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
@Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
|
||||
List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
|
||||
|
||||
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
|
||||
"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 estimatedWarehouseArrivalAt," +
|
||||
"(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s " +
|
||||
"WHERE s.source_container_booking_id=o.source_container_booking_id AND s.receiving_at IS NOT NULL) AS actualWarehouseArrivalAt," +
|
||||
"CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version " +
|
||||
"FROM ll_order o WHERE o.source_container_booking_id IN " +
|
||||
"<foreach collection='bookingIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"GROUP BY o.source_container_booking_id</script>")
|
||||
List<ContainerArrivalTimeView> findContainerArrivalTimes(@Param("bookingIds") List<String> bookingIds);
|
||||
|
||||
@Update("UPDATE ll_order SET estimated_warehouse_at=#{estimatedAt},arrival_time_version=arrival_time_version+1," +
|
||||
"version=version+1,updated_at=NOW(3) WHERE source_container_booking_id=#{bookingId} " +
|
||||
"AND arrival_time_version=#{expectedVersion}")
|
||||
int updateContainerExpectedWarehouseArrival(@Param("bookingId") String bookingId,
|
||||
@Param("estimatedAt") java.time.LocalDateTime estimatedAt,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order o " +
|
||||
"ON o.id=p.source_order_id AND o.customer_id=p.customer_id " +
|
||||
"WHERE o.source_container_booking_id=#{bookingId}")
|
||||
long countContainerPlans(@Param("bookingId") String bookingId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_receiving_container_session WHERE source_container_booking_id=#{sourceContainerBookingId}")
|
||||
long countReceivingSessions(@Param("sourceContainerBookingId") String sourceContainerBookingId);
|
||||
|
||||
|
||||
@@ -11,12 +11,14 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
|
||||
import com.baiyue.lastleg.dto.ContainerArrivalTimeView;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
|
||||
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
|
||||
import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
|
||||
@@ -30,6 +32,7 @@ import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
|
||||
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
|
||||
import com.baiyue.lastleg.mapper.ContainerArrivalTimeOperationMapper;
|
||||
import com.baiyue.lastleg.model.LastlegOrderType;
|
||||
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
|
||||
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||
@@ -49,6 +52,7 @@ import java.time.ZoneOffset;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -77,6 +81,7 @@ public class LastlegOrderService {
|
||||
@Resource private InventoryCommandService inventory;
|
||||
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
|
||||
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
|
||||
@Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
|
||||
@Resource private WarehouseResourceGateway warehouseResourceGateway;
|
||||
@Resource private ContainerShippingGateMapper containerShippingGates;
|
||||
|
||||
@@ -126,6 +131,76 @@ public class LastlegOrderService {
|
||||
orderMapper.findContainerProgressItems(bookingId));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ContainerArrivalTimeView> containerArrivalTimes(
|
||||
LastlegOrderRequests.ContainerArrivalTimeQuery request) {
|
||||
LinkedHashSet<String> bookingIds = new LinkedHashSet<>();
|
||||
for (String value : request.sourceContainerBookingIds()) bookingIds.add(normalizeBookingId(value));
|
||||
if (bookingIds.size() != request.sourceContainerBookingIds().size()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能重复");
|
||||
}
|
||||
return orderMapper.findContainerArrivalTimes(List.copyOf(bookingIds));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerArrivalTimeView changeExpectedWarehouseArrival(String sourceContainerBookingId,
|
||||
String idempotencyKey, LastlegOrderRequests.ChangeExpectedWarehouseArrival request,
|
||||
String operatorName) {
|
||||
String bookingId = normalizeBookingId(sourceContainerBookingId);
|
||||
String key = normalizeIdempotencyKey(idempotencyKey);
|
||||
String requestHash = hash(bookingId + "\n" + request.estimatedWarehouseArrivalAt() + "\n"
|
||||
+ request.expectedVersion() + "\n" + Objects.toString(request.remark(), ""));
|
||||
ContainerArrivalTimeOperation replay = containerArrivalTimeOperations.findByKey(key);
|
||||
if (replay != null) {
|
||||
if (!bookingId.equals(replay.getSourceContainerBookingId())
|
||||
|| !requestHash.equals(replay.getRequestHash())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Idempotency-Key 已用于其他预计到仓时间修改请求");
|
||||
}
|
||||
return requireContainerArrivalTime(bookingId);
|
||||
}
|
||||
|
||||
List<LastlegOrder> orders = orderMapper.lockContainerOrders(bookingId);
|
||||
if (orders.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
|
||||
int version = requireSingleArrivalTimeVersion(orders);
|
||||
if (version != request.expectedVersion()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "预计到仓时间版本已变化,请刷新后重试");
|
||||
}
|
||||
if (orderMapper.countContainerPlans(bookingId) > 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜已生成入库计划,预计到仓时间不可修改");
|
||||
}
|
||||
int updated = orderMapper.updateContainerExpectedWarehouseArrival(bookingId,
|
||||
request.estimatedWarehouseArrivalAt(), version);
|
||||
if (updated != orders.size()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间发生并发变化");
|
||||
}
|
||||
ContainerArrivalTimeOperation operation = new ContainerArrivalTimeOperation();
|
||||
operation.setId(IdWorker.getId()); operation.setSourceContainerBookingId(bookingId);
|
||||
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
|
||||
operation.setResultVersion(version + 1); operation.setOperatorName(operatorName);
|
||||
operation.setRemark(request.remark() == null ? null : request.remark().trim());
|
||||
operation.setCreatedAt(LocalDateTime.now()); containerArrivalTimeOperations.insert(operation);
|
||||
return requireContainerArrivalTime(bookingId);
|
||||
}
|
||||
|
||||
private ContainerArrivalTimeView requireContainerArrivalTime(String bookingId) {
|
||||
List<ContainerArrivalTimeView> values = orderMapper.findContainerArrivalTimes(List.of(bookingId));
|
||||
if (values.size() != 1) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
|
||||
if (values.get(0).version() < 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致");
|
||||
}
|
||||
return values.get(0);
|
||||
}
|
||||
|
||||
private int requireSingleArrivalTimeVersion(List<LastlegOrder> orders) {
|
||||
Set<Integer> versions = orders.stream().map(LastlegOrder::getArrivalTimeVersion)
|
||||
.map(value -> value == null ? 0 : value).collect(Collectors.toSet());
|
||||
if (versions.size() != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致");
|
||||
}
|
||||
return versions.iterator().next();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerPlanProgressSummary changeReceivingWarehouse(String sourceContainerBookingId,
|
||||
String idempotencyKey, LastlegOrderRequests.ChangeReceivingWarehouse request, String operatorName) {
|
||||
|
||||
@@ -83,6 +83,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
|
||||
estimated_arrival_at DATETIME(3) COMMENT '预计到港时间',
|
||||
actual_arrival_at DATETIME(3) COMMENT '实际到港时间',
|
||||
estimated_warehouse_at DATETIME(3) COMMENT '预计到仓时间',
|
||||
arrival_time_version INT NOT NULL DEFAULT 0 COMMENT '柜级预计到仓时间乐观锁版本',
|
||||
inbound_order_no VARCHAR(128) COMMENT '入库单号',
|
||||
outbound_order_no VARCHAR(128) COMMENT '出库单号',
|
||||
delivery_order_no VARCHAR(128) COMMENT '派送单号',
|
||||
@@ -245,6 +246,20 @@ CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation (
|
||||
KEY idx_ll_receiving_warehouse_container(source_container_booking_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='柜级海外接柜仓修改幂等审计记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_container_arrival_time_operation (
|
||||
id BIGINT NOT NULL COMMENT '柜级预计到仓时间修改操作ID',
|
||||
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '平台请求幂等键',
|
||||
request_hash VARCHAR(64) NOT NULL COMMENT '规范化修改请求SHA-256摘要',
|
||||
result_version INT NOT NULL COMMENT '修改成功后的柜级时间版本',
|
||||
operator_name VARCHAR(128) NOT NULL COMMENT '平台操作人名称',
|
||||
remark VARCHAR(500) NULL COMMENT '预计到仓时间修改备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_container_arrival_time_idempotency(idempotency_key),
|
||||
KEY idx_ll_container_arrival_time_container(source_container_booking_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='柜级预计到仓时间修改幂等审计记录';
|
||||
|
||||
-- 派送单列表与详情快照、转派送费用及操作记录。
|
||||
CREATE TABLE IF NOT EXISTS ll_delivery_order (
|
||||
id BIGINT NOT NULL COMMENT '派送单主键ID',
|
||||
@@ -388,6 +403,8 @@ 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.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='arrival_time_version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN arrival_time_version INT NOT NULL DEFAULT 0 COMMENT ''柜级预计到仓时间乐观锁版本'' AFTER estimated_warehouse_at'));
|
||||
PREPARE lastleg_order_arrival_time_version_stmt FROM @ddl; EXECUTE lastleg_order_arrival_time_version_stmt; DEALLOCATE PREPARE lastleg_order_arrival_time_version_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 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)'));
|
||||
|
||||
@@ -311,7 +311,7 @@ paths:
|
||||
/api/platform/v1/last-leg/orders/container-summaries:
|
||||
get:
|
||||
summary: 分页查询柜级入库计划进度
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,返回全部稳定来源柜;全部入仓码均已生成有效独立计划的柜仍保留,供柜级接柜仓查询与修改,生成计划动作由 allPlansReady/pendingPlanCount 控制。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,返回全部稳定来源柜;全部入仓码均已生成有效独立计划的柜仍保留,供柜级接柜仓查询与修改,生成计划动作由 allPlansReady/pendingPlanCount 控制。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 取 ll_order.estimated_warehouse_at,可由装柜完成事件初始化,并在入库计划生成前由平台按整柜人工修正;按该时间升序、null 最后、稳定柜 ID 升序。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
@@ -381,6 +381,52 @@ paths:
|
||||
data: { $ref: '#/components/schemas/ContainerPlanProgressSummary' }
|
||||
'400': { description: 请求无效或目标不是有效 OVERSEAS 仓 }
|
||||
'409': { description: 版本冲突、幂等键冲突或PDA接柜会话已建立 }
|
||||
/api/platform/v1/last-leg/orders/container-arrival-times/query:
|
||||
post:
|
||||
summary: 批量查询整柜预计与实际到仓时间
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:list;预计到仓取整柜人工冻结值,实际到仓取海外 PDA 首次开始收货时间。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ContainerArrivalTimeQuery' }
|
||||
responses:
|
||||
'200':
|
||||
description: 柜级到仓时间列表
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string, enum: ['200'] }
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/ContainerArrivalTimeView' }
|
||||
/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/expected-arrival:
|
||||
put:
|
||||
summary: 修改整柜预计到仓时间
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用柜级时间版本与 Idempotency-Key,在入库计划生成前原子更新柜内全部尾程订单。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: sourceContainerBookingId, in: path, required: true, schema: { type: string } }
|
||||
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 255 } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ChangeExpectedWarehouseArrivalRequest' }
|
||||
responses:
|
||||
'200':
|
||||
description: 更新后的柜级到仓时间
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string, enum: ['200'] }
|
||||
data: { $ref: '#/components/schemas/ContainerArrivalTimeView' }
|
||||
'409': { description: 已生成入库计划、版本冲突或幂等键冲突 }
|
||||
/api/platform/v1/last-leg/orders/{id}/destination:
|
||||
put:
|
||||
summary: 将单票尾程订单转入 FBA 目的仓
|
||||
@@ -1669,7 +1715,7 @@ components:
|
||||
plannedInboundCodeCount: { type: integer, format: int64 }
|
||||
pendingPlanCount: { type: integer, format: int64, description: 尚未生成有效计划的尾程订单数,即未操作数 }
|
||||
resourcePendingPlanCount: { type: integer, format: int64, description: 已有有效计划但尚未完整绑定海外仓真实托盘的订单数 }
|
||||
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 仅来源于装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at }
|
||||
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 整柜一致的 ll_order.estimated_warehouse_at,可由装柜事件初始化并在计划生成前人工维护 }
|
||||
allPlansReady: { type: boolean, description: 全柜每票均有入仓码且均存在有效独立计划时为 true }
|
||||
blockingReason: { type: string, nullable: true }
|
||||
ChangeReceivingWarehouseRequest:
|
||||
@@ -1679,6 +1725,31 @@ components:
|
||||
overseasWarehouseId: { type: integer, format: int64, minimum: 1 }
|
||||
expectedVersion: { type: integer, minimum: 0 }
|
||||
remark: { type: string, maxLength: 500, nullable: true }
|
||||
ContainerArrivalTimeQuery:
|
||||
type: object
|
||||
required: [sourceContainerBookingIds]
|
||||
properties:
|
||||
sourceContainerBookingIds:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
uniqueItems: true
|
||||
items: { type: string, minLength: 1, maxLength: 128 }
|
||||
ChangeExpectedWarehouseArrivalRequest:
|
||||
type: object
|
||||
required: [estimatedWarehouseArrivalAt, expectedVersion]
|
||||
properties:
|
||||
estimatedWarehouseArrivalAt: { type: string, format: date-time }
|
||||
expectedVersion: { type: integer, minimum: 0 }
|
||||
remark: { type: string, maxLength: 500, nullable: true }
|
||||
ContainerArrivalTimeView:
|
||||
type: object
|
||||
required: [sourceContainerBookingId, version]
|
||||
properties:
|
||||
sourceContainerBookingId: { type: string }
|
||||
estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true }
|
||||
actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 }
|
||||
version: { type: integer, minimum: 0 }
|
||||
ContainerPlanProgressDetail:
|
||||
type: object
|
||||
required: [summary, orders]
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ContainerArrivalTimeContractTest {
|
||||
@Test
|
||||
void freezesExpectedArrivalByContainerAndReadsActualArrivalFromPdaReceivingStart() throws Exception {
|
||||
String mapper = Files.readString(Path.of("src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java"));
|
||||
String api = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"));
|
||||
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
|
||||
assertTrue(mapper.contains("arrival_time_version=arrival_time_version+1"));
|
||||
assertTrue(mapper.contains("MIN(s.receiving_at)"));
|
||||
assertTrue(api.contains("/api/platform/v1/last-leg/orders/container-arrival-times/query:"));
|
||||
assertTrue(api.contains("/{sourceContainerBookingId}/expected-arrival:"));
|
||||
assertTrue(schema.contains("ll_container_arrival_time_operation"));
|
||||
assertTrue(schema.contains("arrival_time_version INT NOT NULL DEFAULT 0"));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.service;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
|
||||
import com.baiyue.lastleg.dto.ContainerArrivalTimeView;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
@@ -17,6 +18,8 @@ import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
|
||||
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
|
||||
import com.baiyue.lastleg.mapper.ContainerArrivalTimeOperationMapper;
|
||||
import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
|
||||
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||
@@ -42,6 +45,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
@@ -65,6 +69,7 @@ class LastlegOrderServiceTest {
|
||||
@Mock InventoryCommandService inventory;
|
||||
@Mock InboundOrderDetainedOutboxService detainedOutbox;
|
||||
@Mock ReceivingWarehouseOperationMapper receivingWarehouseOperations;
|
||||
@Mock ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
|
||||
@Mock WarehouseResourceGateway warehouseResourceGateway;
|
||||
@Mock ContainerShippingGateMapper containerShippingGates;
|
||||
LastlegOrderService service;
|
||||
@@ -97,10 +102,47 @@ class LastlegOrderServiceTest {
|
||||
ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
|
||||
ReflectionTestUtils.setField(service, "receivingWarehouseOperations", receivingWarehouseOperations);
|
||||
ReflectionTestUtils.setField(service, "containerArrivalTimeOperations", containerArrivalTimeOperations);
|
||||
ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway);
|
||||
ReflectionTestUtils.setField(service, "containerShippingGates", containerShippingGates);
|
||||
}
|
||||
|
||||
@Test
|
||||
void changesExpectedWarehouseArrivalForTheWholeContainerBeforePlanning() {
|
||||
LastlegOrder first = new LastlegOrder(); first.setArrivalTimeVersion(0);
|
||||
LastlegOrder second = new LastlegOrder(); second.setArrivalTimeVersion(0);
|
||||
LocalDateTime estimatedAt = LocalDateTime.of(2026, 9, 25, 15, 31);
|
||||
when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(first, second));
|
||||
when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L);
|
||||
when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2);
|
||||
when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of(
|
||||
new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1)));
|
||||
|
||||
ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1",
|
||||
new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin");
|
||||
|
||||
assertEquals(estimatedAt, result.estimatedWarehouseArrivalAt());
|
||||
ArgumentCaptor<ContainerArrivalTimeOperation> operation = ArgumentCaptor.forClass(ContainerArrivalTimeOperation.class);
|
||||
verify(containerArrivalTimeOperations).insert(operation.capture());
|
||||
assertEquals(1, operation.getValue().getResultVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsExpectedWarehouseArrivalChangesAfterAnyPlanExists() {
|
||||
LastlegOrder order = new LastlegOrder(); order.setArrivalTimeVersion(0);
|
||||
when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(order));
|
||||
when(orders.countContainerPlans("BOOKING-1")).thenReturn(1L);
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||
() -> service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-locked",
|
||||
new LastlegOrderRequests.ChangeExpectedWarehouseArrival(
|
||||
LocalDateTime.of(2026, 9, 25, 15, 31), 0, null), "admin"));
|
||||
|
||||
assertEquals(409, error.getStatusCode().value());
|
||||
assertEquals("该柜已生成入库计划,预计到仓时间不可修改", error.getReason());
|
||||
verify(orders, never()).updateContainerExpectedWarehouseArrival(any(), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void changesReceivingWarehouseForAllPlansReadyContainerBeforePdaSession() {
|
||||
LastlegOrder first = order(1L, "100", "PENDING_ASSIGNMENT", null);
|
||||
|
||||
Reference in New Issue
Block a user