feat: freeze and propagate first-leg pallet labels

This commit is contained in:
2026-09-09 17:27:00 +08:00
parent 83e95d764e
commit de1f7e8ddd
24 changed files with 560 additions and 21 deletions
@@ -5279,6 +5279,29 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1)) CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; ) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
CREATE TABLE IF NOT EXISTS fl_loading_pallet_label (
id BIGINT UNSIGNED NOT NULL COMMENT '散货托盘标签ID(雪花ID)',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '标签归属装柜明细ID',
account_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单ID快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
container_no VARCHAR(64) NOT NULL COMMENT '真实集装箱号快照',
destination_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码快照',
destination_warehouse_name VARCHAR(128) NULL COMMENT 'FBA仓名称快照',
pallet_index INT UNSIGNED NOT NULL COMMENT '当前柜当前FBA仓内从1开始的序号',
pallet_code VARCHAR(128) NOT NULL COMMENT '托盘码:集装箱号-FBA仓编码-三位序号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pallet_code (pallet_code),
UNIQUE KEY uk_loading_pallet_index (loading_plan_id,destination_warehouse_code,pallet_index),
KEY idx_loading_pallet_item (loading_plan_item_id,pallet_index),
KEY idx_loading_pallet_inbound (account_id,inbound_id),
CONSTRAINT fk_loading_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan(id),
CONSTRAINT fk_loading_pallet_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item(id),
CONSTRAINT chk_loading_pallet_index CHECK (pallet_index BETWEEN 1 AND 999)
) ENGINE=InnoDB COMMENT='散货按柜号和FBA仓冻结的可打印托盘码';
CREATE TABLE IF NOT EXISTS fl_container_document ( CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
@@ -112,6 +112,21 @@ CREATE DATABASE IF NOT EXISTS baiyue_lastleg
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE baiyue_lastleg; USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_order_source_pallet (
id BIGINT NOT NULL COMMENT '来源托盘快照ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID',
source_pallet_no VARCHAR(128) NOT NULL COMMENT '头程冻结托盘码',
sequence_no INT NOT NULL COMMENT '订单内稳定顺序',
source_event_id VARCHAR(128) NOT NULL COMMENT '来源装柜完成事件ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_source_pallet_no(source_pallet_no),
UNIQUE KEY uk_ll_source_pallet_sequence(customer_id,order_id,sequence_no),
KEY idx_ll_source_pallet_order(customer_id,order_id),
CONSTRAINT fk_ll_source_pallet_order FOREIGN KEY(order_id) REFERENCES ll_order(id)
) ENGINE=InnoDB COMMENT='头程交接的不可变托盘码快照';
CREATE TABLE IF NOT EXISTS ll_event_outbox ( CREATE TABLE IF NOT EXISTS ll_event_outbox (
id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
@@ -13,8 +13,11 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.LifecycleResolution; import com.baiyue.firstleg.dto.LoadingListDtos.LifecycleResolution;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelPrintRequest;
import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingListService; import com.baiyue.firstleg.service.LoadingListService;
import com.baiyue.firstleg.service.LoadingPalletLabelService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -32,6 +35,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.ResponseEntity;
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询") @Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
@@ -41,6 +45,8 @@ import org.springframework.web.bind.annotation.RestController;
public class PlatformLoadingListController { public class PlatformLoadingListController {
@Resource @Resource
private LoadingListService service; private LoadingListService service;
@Resource
private LoadingPalletLabelService palletLabels;
@Operation(summary = "分页查询预装清单") @Operation(summary = "分页查询预装清单")
@GetMapping("/loading-plans") @GetMapping("/loading-plans")
@@ -118,4 +124,26 @@ public class PlatformLoadingListController {
return ApiResponse.success(service.confirmedDetail(planId, warehouseId)); return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
} }
@Operation(summary = "生成并查询散货托盘码", description = "仅为散货按真实集装箱号与 FBA 仓稳定生成托盘码;一件代发不参与。")
@PostMapping("/loading-plans/{planId}/pallet-labels/prepare")
@PreAuthorize("hasAnyAuthority('platform:firstleg:preload:list','platform:firstleg:loading-list:list','platform:firstleg:container:list')")
public ApiResponse<PalletLabelManifest> preparePalletLabels(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletLabels.prepare(planId, warehouseId));
}
@Operation(summary = "打印散货托盘码", description = "不传仓码与张数时打印全部;按仓打印时返回该仓从第 1 张开始的指定数量,用于补打。")
@PostMapping(value = "/loading-plans/{planId}/pallet-labels/print", produces = "application/pdf")
@PreAuthorize("hasAnyAuthority('platform:firstleg:preload:list','platform:firstleg:loading-list:list','platform:firstleg:container:list')")
public ResponseEntity<byte[]> printPalletLabels(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@Valid @RequestBody PalletLabelPrintRequest request) {
FirstlegScopeGuard.platform(user);
return palletLabels.print(planId, warehouseId, request, requestId);
}
} }
@@ -125,6 +125,37 @@ public final class LoadingListDtos {
) { ) {
} }
public record PalletLabelPrintRequest(
@Size(max = 64) String destinationWarehouseCode,
@Min(1) @Max(999) Integer count
) {
}
public record PalletLabel(
Long labelId, Long itemId, Long accountId, Long inboundId, String inboundCode,
String containerNo, String destinationWarehouseCode, String destinationWarehouseName,
int palletIndex, String palletCode
) {
}
public record PalletLabelGroup(
String destinationWarehouseCode, String destinationWarehouseName,
int totalCount, List<PalletLabel> labels
) {
public PalletLabelGroup {
labels = labels == null ? List.of() : List.copyOf(labels);
}
}
public record PalletLabelManifest(
Long planId, String planNo, String containerNo, int totalCount,
List<PalletLabelGroup> groups
) {
public PalletLabelManifest {
groups = groups == null ? List.of() : List.copyOf(groups);
}
}
public record PreloadSummary( public record PreloadSummary(
Long planId, Long planId,
Long containerBookingId, Long containerBookingId,
@@ -0,0 +1,70 @@
package com.baiyue.firstleg.mapper;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface LoadingPalletLabelMapper {
@Select("""
SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.warehouse_id,
i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,
i.destination_warehouse_code,i.destination_warehouse_name,
COALESCE(NULLIF(i.pallet_count,0),
CEIL(COALESCE(NULLIF(o.pallet_count,0),
(SELECT COUNT(*) FROM fl_inbound_weight_result weight
WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id
AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0)
* i.package_count / NULLIF(o.total_package_count,0)),0) AS pallet_count,
COALESCE(o.fulfillment_type,'STANDARD') AS fulfillment_type,
o.destination_type,i.load_sequence
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
LEFT JOIN fl_inbound_order o ON o.id=i.inbound_id AND o.tenant_id=i.account_id AND o.deleted=0
WHERE p.id=#{planId}
ORDER BY i.destination_warehouse_code,i.load_sequence,i.id
FOR UPDATE
""")
List<SourceRow> lockSources(@Param("planId") long planId);
@Select("""
SELECT id AS label_id,loading_plan_id AS plan_id,loading_plan_item_id AS item_id,
account_id,inbound_id,inbound_code,container_no,destination_warehouse_code,
destination_warehouse_name,pallet_index,pallet_code
FROM fl_loading_pallet_label WHERE loading_plan_id=#{planId}
ORDER BY destination_warehouse_code,pallet_index,id
""")
List<LabelRow> labels(@Param("planId") long planId);
@Insert("""
INSERT INTO fl_loading_pallet_label
(id,loading_plan_id,loading_plan_item_id,account_id,inbound_id,inbound_code,
container_no,destination_warehouse_code,destination_warehouse_name,pallet_index,pallet_code,created_at)
VALUES
(#{labelId},#{planId},#{itemId},#{accountId},#{inboundId},#{inboundCode},
#{containerNo},#{destinationWarehouseCode},#{destinationWarehouseName},#{palletIndex},#{palletCode},CURRENT_TIMESTAMP(3))
""")
int insert(LabelRow row);
@Getter @Setter
class SourceRow {
private Long planId; private String planNo; private String planStatus; private String containerNo;
private Long warehouseId; private Long itemId; private Long accountId; private Long inboundId;
private String inboundCode; private String destinationWarehouseCode; private String destinationWarehouseName;
private Integer palletCount; private String fulfillmentType; private Integer loadSequence;
private String destinationType;
}
@Getter @Setter
class LabelRow {
private Long labelId; private Long planId; private Long itemId; private Long accountId; private Long inboundId;
private String inboundCode; private String containerNo; private String destinationWarehouseCode;
private String destinationWarehouseName; private Integer palletIndex; private String palletCode;
}
}
@@ -17,6 +17,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
@@ -44,6 +45,8 @@ public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 2L; private static final long HANDOVER_VERSION = 2L;
@Resource private LoadingCompletedEventMapper snapshotMapper; @Resource private LoadingCompletedEventMapper snapshotMapper;
@Resource private LoadingCartonManifestMapper cartonManifestMapper; @Resource private LoadingCartonManifestMapper cartonManifestMapper;
@Resource private LoadingPalletLabelMapper palletLabelMapper;
@Resource private LoadingPalletLabelService palletLabelService;
@Resource private InboundOutboxMapper outboxMapper; @Resource private InboundOutboxMapper outboxMapper;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@@ -61,12 +64,15 @@ public class LoadingCompletedOutboxService {
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
validateContainerIdentity(rows); validateContainerIdentity(rows);
validateCustomerSnapshots(rows); validateCustomerSnapshots(rows);
palletLabelService.prepare(planId, rows.get(0).getWarehouseId());
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows); Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
List<PalletRow> palletRows = palletRows(planId); List<PalletRow> palletRows = palletRows(planId);
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels = palletLabelMapper.labels(planId);
return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
java.util.TreeMap::new, java.util.stream.Collectors.toList())) java.util.TreeMap::new, java.util.stream.Collectors.toList()))
.entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(), .entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(),
palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks)) palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(),
standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks))
.toList(); .toList();
} }
@@ -91,13 +97,15 @@ public class LoadingCompletedOutboxService {
} }
private EventEnvelope envelope(long accountId, List<Row> rows, List<PalletRow> palletRows, private EventEnvelope envelope(long accountId, List<Row> rows, List<PalletRow> palletRows,
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
Map<Long, List<String>> cartonMarks) { Map<Long, List<String>> cartonMarks) {
Row first = rows.get(0); Row first = rows.get(0);
Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV2.Order> orders = byInbound.values().stream() List<FirstLegLoadingPlanCompletedV2.Order> orders = byInbound.values().stream()
.map(value -> order(value, palletRows.stream() .map(value -> order(value, palletRows.stream()
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList(); .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(),
standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()), var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()), first.getPlanNo(), string(first.getContainerBookingId()),
ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(), ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(),
@@ -114,6 +122,7 @@ public class LoadingCompletedOutboxService {
} }
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows, private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
Map<Long, List<String>> cartonMarks) { Map<Long, List<String>> cartonMarks) {
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0); Row first = sorted.get(0);
@@ -129,15 +138,18 @@ public class LoadingCompletedOutboxService {
String fulfillmentType = fulfillmentType(sorted); String fulfillmentType = fulfillmentType(sorted);
String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType) List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType)
? palletSnapshots(sorted, palletRows) : List.of(); ? palletSnapshots(sorted, palletRows) : standardLabels.stream()
.map(label -> new FirstLegLoadingPlanCompletedV2.PalletSnapshot(label.getPalletCode(), List.of())).toList();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) { if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照"); throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
} }
Integer frozenPalletCount = dropship(fulfillmentType) || standardLabels.isEmpty()
? actualPalletCount(palletRows) : Integer.valueOf(standardLabels.size());
return new FirstLegLoadingPlanCompletedV2.Order(string(first.getSourceInboundId()), return new FirstLegLoadingPlanCompletedV2.Order(string(first.getSourceInboundId()),
string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(), string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()), string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
sumNullableInt(sorted, Row::getProductQuantity), actualPalletCount(palletRows), sumNullableInt(sorted, Row::getProductQuantity), frozenPalletCount,
sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items, sum(sorted, Row::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items,
string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets, string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets,
@@ -0,0 +1,147 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.firstleg.client.PrintPdfClient;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelGroup;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelPrintRequest;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.SourceRow;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/** 散货托盘码是柜号与 FBA 仓维度的稳定业务标识;一件代发继续使用装箱打托事实。 */
@Service
public class LoadingPalletLabelService {
private static final BigDecimal PAGE_MM = new BigDecimal("100");
@Resource private LoadingPalletLabelMapper mapper;
@Resource private PrintPdfClient printClient;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Transactional
public PalletLabelManifest prepare(long planId, long warehouseId) {
List<SourceRow> sources = mapper.lockSources(planId);
if (sources.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在或没有有效明细");
SourceRow first = sources.get(0);
if (!Objects.equals(first.getWarehouseId(), warehouseId)) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不属于当前仓库");
if (!List.of("LOADING", "COMPLETED").contains(first.getPlanStatus())) {
throw new BizException(ResultCode.CONFLICT, "请先确认预装清单,再生成散货托盘码");
}
String containerNo;
try { containerNo = ContainerNumbers.requireCanonical(first.getContainerNo()); }
catch (IllegalArgumentException exception) { throw new BizException(ResultCode.CONFLICT, "请先补录有效集装箱号"); }
List<SourceRow> standard = sources.stream().filter(row -> !"ONE_CLICK_DROPSHIP".equals(row.getFulfillmentType()))
.filter(row -> "FBA".equals(row.getDestinationType()))
.filter(row -> Objects.requireNonNullElse(row.getPalletCount(), 0) > 0).toList();
if (standard.stream().anyMatch(row -> !StringUtils.hasText(row.getDestinationWarehouseCode()))) {
throw new BizException(ResultCode.CONFLICT, "散货明细缺少 FBA 仓编码,不能生成托盘码");
}
List<LabelRow> existing = mapper.labels(planId);
List<LabelRow> expected = expected(first, standard, containerNo);
if (existing.isEmpty()) {
expected.forEach(row -> { if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试"); });
existing = mapper.labels(planId);
}
requireSame(expected, existing);
return manifest(first, existing);
}
@Transactional
public ResponseEntity<byte[]> print(long planId, long warehouseId, PalletLabelPrintRequest request, String requestId) {
PalletLabelManifest manifest = prepare(planId, warehouseId);
String warehouseCode = normalize(request == null ? null : request.destinationWarehouseCode());
Integer count = request == null ? null : request.count();
if ((warehouseCode == null) != (count == null)) throw new BizException(ResultCode.BAD_REQUEST, "按仓打印时必须同时选择 FBA 仓和打印张数");
List<PalletLabel> selected = warehouseCode == null ? manifest.groups().stream().flatMap(group -> group.labels().stream()).toList()
: manifest.groups().stream().filter(group -> group.destinationWarehouseCode().equalsIgnoreCase(warehouseCode))
.flatMap(group -> group.labels().stream()).limit(count).toList();
if (selected.isEmpty() || (count != null && selected.size() != count)) throw new BizException(ResultCode.BAD_REQUEST, "所选 FBA 仓或打印张数无效");
List<PrintPdfClient.Label> labels = selected.stream().map(label -> new PrintPdfClient.Label(
label.palletCode(), label.destinationWarehouseName(), "PALLET " + label.palletIndex(),
"CONTAINER " + label.containerNo(), label.palletCode(), null)).toList();
String suffix = warehouseCode == null ? "全部" : warehouseCode + "-" + count + "张";
var pdfRequest = new PrintPdfClient.InboundMarkPdfRequest("INBOUND_MARK_V1",
new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM), manifest.containerNo() + "-托盘码-" + suffix + ".pdf", labels);
try { return requirePdf(printClient.generateInboundMarks(internalToken, requestId, pdfRequest)); }
catch (FeignException | NoFallbackAvailableException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); }
}
private List<LabelRow> expected(SourceRow plan, List<SourceRow> sources, String containerNo) {
Map<String, Integer> indexes = new LinkedHashMap<>();
List<LabelRow> result = new ArrayList<>();
for (SourceRow source : sources) {
String code = source.getDestinationWarehouseCode().trim().toUpperCase(Locale.ROOT);
for (int local = 0; local < source.getPalletCount(); local++) {
int index = indexes.merge(code, 1, Integer::sum);
if (index > 999) throw new BizException(ResultCode.CONFLICT, "单柜同一 FBA 仓托盘数不能超过 999");
LabelRow row = new LabelRow(); row.setLabelId(IdWorker.getId()); row.setPlanId(plan.getPlanId());
row.setItemId(source.getItemId()); row.setAccountId(source.getAccountId()); row.setInboundId(source.getInboundId());
row.setInboundCode(source.getInboundCode()); row.setContainerNo(containerNo);
row.setDestinationWarehouseCode(code); row.setDestinationWarehouseName(source.getDestinationWarehouseName());
row.setPalletIndex(index); row.setPalletCode(containerNo + "-" + code + "-" + String.format("%03d", index));
if (row.getPalletCode().length() > 128) throw new BizException(ResultCode.CONFLICT, "托盘码长度超过系统限制");
result.add(row);
}
}
return result;
}
private void requireSame(List<LabelRow> expected, List<LabelRow> actual) {
List<String> left = expected.stream().map(this::identity).toList();
List<String> right = actual.stream().map(this::identity).toList();
if (!left.equals(right)) throw new BizException(ResultCode.CONFLICT, "已生成的托盘码与当前装柜明细不一致");
}
private String identity(LabelRow row) { return row.getItemId()+"|"+row.getPalletCode(); }
private PalletLabelManifest manifest(SourceRow plan, List<LabelRow> rows) {
Map<String, List<LabelRow>> grouped = rows.stream().collect(java.util.stream.Collectors.groupingBy(
LabelRow::getDestinationWarehouseCode, LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<PalletLabelGroup> groups = grouped.values().stream().map(values -> new PalletLabelGroup(
values.get(0).getDestinationWarehouseCode(), values.get(0).getDestinationWarehouseName(), values.size(),
values.stream().map(this::label).toList())).toList();
String containerNo = rows.isEmpty() ? plan.getContainerNo() : rows.get(0).getContainerNo();
return new PalletLabelManifest(plan.getPlanId(), plan.getPlanNo(), containerNo, rows.size(), groups);
}
private PalletLabel label(LabelRow row) { return new PalletLabel(row.getLabelId(), row.getItemId(), row.getAccountId(), row.getInboundId(), row.getInboundCode(), row.getContainerNo(), row.getDestinationWarehouseCode(), row.getDestinationWarehouseName(), row.getPalletIndex(), row.getPalletCode()); }
private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private ResponseEntity<byte[]> requirePdf(ResponseEntity<byte[]> response) {
byte[] body = response == null || response.getBody() == null ? new byte[0] : response.getBody();
MediaType type = response == null ? null : response.getHeaders().getContentType();
if (type == null || !MediaType.APPLICATION_PDF.isCompatibleWith(type) || body.length == 0) {
try { ApiResponse<Object> error = objectMapper.readValue(body, new TypeReference<>() { });
if (error != null && !error.successful()) throw new BizException(ResultCode.of(error.code()), error.message());
} catch (BizException exception) { throw exception; } catch (Exception ignored) { }
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务返回了无效响应");
}
HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF);
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
if (StringUtils.hasText(disposition)) headers.set(HttpHeaders.CONTENT_DISPOSITION, disposition);
headers.setContentLength(body.length); return ResponseEntity.ok().headers(headers).body(body);
}
}
@@ -348,6 +348,29 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1)) CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; ) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
CREATE TABLE IF NOT EXISTS fl_loading_pallet_label (
id BIGINT UNSIGNED NOT NULL COMMENT '散货托盘标签ID(雪花ID)',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '标签归属装柜明细ID',
account_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单ID快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
container_no VARCHAR(64) NOT NULL COMMENT '真实集装箱号快照',
destination_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码快照',
destination_warehouse_name VARCHAR(128) NULL COMMENT 'FBA仓名称快照',
pallet_index INT UNSIGNED NOT NULL COMMENT '当前柜当前FBA仓内从1开始的序号',
pallet_code VARCHAR(128) NOT NULL COMMENT '托盘码:集装箱号-FBA仓编码-三位序号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pallet_code (pallet_code),
UNIQUE KEY uk_loading_pallet_index (loading_plan_id,destination_warehouse_code,pallet_index),
KEY idx_loading_pallet_item (loading_plan_item_id,pallet_index),
KEY idx_loading_pallet_inbound (account_id,inbound_id),
CONSTRAINT fk_loading_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan(id),
CONSTRAINT fk_loading_pallet_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item(id),
CONSTRAINT chk_loading_pallet_index CHECK (pallet_index BETWEEN 1 AND 999)
) ENGINE=InnoDB COMMENT='散货按柜号和FBA仓冻结的可打印托盘码';
-- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。 -- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。
CREATE TABLE IF NOT EXISTS fl_container_document ( CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
@@ -74,6 +74,23 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmPreloadRequest'}}} content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmPreloadRequest'}}}
responses: responses:
'200': {$ref: '#/components/responses/PreloadConfirmationResponse'} '200': {$ref: '#/components/responses/PreloadConfirmationResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/prepare:
post:
summary: 生成并查询散货托盘码清单
description: 预装确认且已有真实柜号后,按柜号-FBA仓编码-三位序号生成稳定清单;一件代发不参与。
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses:
'200': {description: 按 FBA 仓分组的散货托盘码清单, content: {application/json: {schema: {type: object}}}}
/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/print:
post:
summary: 打印散货托盘码
description: 仓码和张数均为空时打印全部;均有值时打印该 FBA 仓从第1张开始的指定数量,用于补打。
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/PalletLabelPrintRequest'}}}
responses:
'200': {description: 100mm × 100mm 托盘码 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}}
/api/platform/v1/first-leg/loading-lists: /api/platform/v1/first-leg/loading-lists:
get: get:
summary: 分页查询装柜清单 summary: 分页查询装柜清单
@@ -430,3 +447,9 @@ components:
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次} voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, minLength: 1, maxLength: 500, nullable: true, description: 纠正已有真实集装箱号时必填;首次补录或保持当前柜号时可省略} operationReason: {type: string, minLength: 1, maxLength: 500, nullable: true, description: 纠正已有真实集装箱号时必填;首次补录或保持当前柜号时可省略}
PalletLabelPrintRequest:
type: object
additionalProperties: false
properties:
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
count: {type: integer, minimum: 1, maximum: 999, nullable: true}
@@ -9,6 +9,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -30,14 +31,19 @@ class LoadingCompletedOutboxServiceTest {
private final LoadingCompletedEventMapper snapshots = mock(LoadingCompletedEventMapper.class); private final LoadingCompletedEventMapper snapshots = mock(LoadingCompletedEventMapper.class);
private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class); private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class);
private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class); private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class);
private final LoadingPalletLabelMapper palletLabels = mock(LoadingPalletLabelMapper.class);
private final LoadingPalletLabelService palletLabelService = mock(LoadingPalletLabelService.class);
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService(); private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService();
@BeforeEach void setup() { @BeforeEach void setup() {
ReflectionTestUtils.setField(service,"snapshotMapper",snapshots); ReflectionTestUtils.setField(service,"snapshotMapper",snapshots);
ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests); ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests);
ReflectionTestUtils.setField(service,"palletLabelMapper",palletLabels);
ReflectionTestUtils.setField(service,"palletLabelService",palletLabelService);
ReflectionTestUtils.setField(service,"outboxMapper",outbox); ReflectionTestUtils.setField(service,"outboxMapper",outbox);
ReflectionTestUtils.setField(service,"objectMapper",mapper); ReflectionTestUtils.setField(service,"objectMapper",mapper);
when(palletLabels.labels(anyLong())).thenReturn(List.of());
when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of( when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of(
label(101, "BOX-101"), label(102, "BOX-102"), label(101, "BOX-101"), label(102, "BOX-102"),
label(202, "BOX-202"), label(302, "BOX-302"))); label(202, "BOX-202"), label(302, "BOX-302")));
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class LoadingPalletLabelServiceTest {
@Test
void generatesStableIndexesPerContainerAndFbaAndSkipsDropship() {
LoadingPalletLabelMapper mapper = mock(LoadingPalletLabelMapper.class);
LoadingPalletLabelService service = new LoadingPalletLabelService();
ReflectionTestUtils.setField(service, "mapper", mapper);
List<LoadingPalletLabelMapper.LabelRow> saved = new ArrayList<>();
when(mapper.lockSources(10L)).thenReturn(List.of(source(3, "LAX9", 1, "STANDARD"),
source(1, "ONT8", 2, "STANDARD"), source(2, "ONT8", 1, "STANDARD"),
source(4, "ONT8", 4, "ONE_CLICK_DROPSHIP")));
when(mapper.labels(10L)).thenAnswer(ignored -> List.copyOf(saved));
when(mapper.insert(any())).thenAnswer(invocation -> { saved.add(invocation.getArgument(0)); return 1; });
var manifest = service.prepare(10L, 20L);
assertThat(manifest.totalCount()).isEqualTo(4);
assertThat(manifest.groups()).extracting(group -> group.destinationWarehouseCode() + ":" + group.totalCount())
.containsExactly("LAX9:1", "ONT8:3");
assertThat(saved).extracting(LoadingPalletLabelMapper.LabelRow::getPalletCode)
.containsExactly("MSCU6639870-LAX9-001", "MSCU6639870-ONT8-001",
"MSCU6639870-ONT8-002", "MSCU6639870-ONT8-003");
}
private LoadingPalletLabelMapper.SourceRow source(long itemId, String destination, int pallets, String fulfillment) {
var row = new LoadingPalletLabelMapper.SourceRow(); row.setPlanId(10L); row.setPlanNo("LP-1");
row.setPlanStatus("LOADING"); row.setContainerNo("MSCU6639870"); row.setWarehouseId(20L);
row.setItemId(itemId); row.setAccountId(100L); row.setInboundId(itemId + 1000);
row.setInboundCode("RK-" + itemId); row.setDestinationWarehouseCode(destination);
row.setDestinationWarehouseName(destination); row.setPalletCount(pallets);
row.setFulfillmentType(fulfillment); row.setDestinationType("FBA"); return row;
}
}
@@ -142,13 +142,20 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
throw new ResponseStatusException(HttpStatus.CONFLICT, throw new ResponseStatusException(HttpStatus.CONFLICT,
"Reserved pallet customer binding is invalid"); "Reserved pallet customer binding is invalid");
} }
WarehousePalletData pallet = lookupPallet(palletCode).data(); WarehousePalletReference pallet;
if (pallet.id() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId try {
pallet = reference(lookupPallet(palletCode).data());
} catch (ResponseStatusException exception) {
if (exception.getStatusCode().value() != 404) throw exception;
// 已生成计划可能保留早期展示码;确认过的资源绑定仍是唯一可信物理身份。
pallet = resolvePallet(palletId);
}
if (pallet.palletId() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId
|| !"OCCUPIED".equals(pallet.status())) { || !"OCCUPIED".equals(pallet.status())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, throw new ResponseStatusException(HttpStatus.CONFLICT,
"Scanned pallet does not match the reserved inbound plan resource"); "Scanned pallet does not match the reserved inbound plan resource");
} }
return reference(pallet); return pallet;
} }
@Override @Override
@@ -55,7 +55,8 @@ public interface WarehouseResourceGateway {
java.math.BigDecimal maxLoadKg, String status) { } java.math.BigDecimal maxLoadKg, String status) { }
record PalletBatchEnsure(long warehouseId, String batchKey, List<PalletBatchItemRequest> items) { } record PalletBatchEnsure(long warehouseId, String batchKey, List<PalletBatchItemRequest> items) { }
record PalletBatchItemRequest(long customerId, String businessKey, String inboundPlanNo, record PalletBatchItemRequest(long customerId, String businessKey, String inboundPlanNo,
int sequenceNo, String palletType, String fbaWarehouseCode) { } int sequenceNo, String palletType, String fbaWarehouseCode,
String palletCode) { }
record PalletBatchCommand(long warehouseId, String batchKey, String commandKey) { } record PalletBatchCommand(long warehouseId, String batchKey, String commandKey) { }
record PalletBatchCompensation(long warehouseId, String batchKey, String commandKey, String reason) { } record PalletBatchCompensation(long warehouseId, String batchKey, String commandKey, String reason) { }
record PalletBatchResponse(long batchId, String batchKey, String status, int attemptNo, record PalletBatchResponse(long batchId, String batchKey, String status, int attemptNo,
@@ -117,7 +117,7 @@ public class ContainerInboundPlanConversionCoordinator {
preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item -> preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item ->
new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()), new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()),
item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD", item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD",
item.fbaWarehouseCode())).toList()); item.fbaWarehouseCode(), item.palletCode())).toList());
WarehouseResourceGateway.PalletBatchResponse reservation; WarehouseResourceGateway.PalletBatchResponse reservation;
try { try {
reservation = warehouse.ensurePalletBatch(ensure); reservation = warehouse.ensurePalletBatch(ensure);
@@ -207,10 +207,15 @@ public class ContainerInboundPlanConversionCoordinator {
.flatMap(group -> group.orders().stream()).filter(item -> "READY".equals(item.readiness())).toList()) { .flatMap(group -> group.orders().stream()).filter(item -> "READY".equals(item.readiness())).toList()) {
long planId = IdWorker.getId(); long planId = IdWorker.getId();
String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId); String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId);
List<String> sourcePalletNos = plans.sourcePalletNos(
order.customerId(), Long.parseLong(order.orderId()), order.orderType());
if (sourcePalletNos.size() != order.suggestedPalletCount()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与柜级预览不一致");
}
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) { for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo, items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence, localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
trimToNull(order.fbaWarehouseCode()))); trimToNull(order.fbaWarehouseCode()), sourcePalletNos.get(localSequence - 1)));
} }
} }
Preparation preparation = new Preparation(warehouseId, List.copyOf(items)); Preparation preparation = new Preparation(warehouseId, List.copyOf(items));
@@ -373,5 +378,5 @@ public class ContainerInboundPlanConversionCoordinator {
public record Preparation(long warehouseId, List<PreparedItem> items) { } public record Preparation(long warehouseId, List<PreparedItem> items) { }
public record PreparedItem(String customerId, long orderId, long planId, String planNo, public record PreparedItem(String customerId, long orderId, long planId, String planNo,
int localSequenceNo, int globalSequenceNo, String businessKey, int localSequenceNo, int globalSequenceNo, String businessKey,
String fbaWarehouseCode) { } String fbaWarehouseCode, String palletCode) { }
} }
@@ -27,6 +27,7 @@ import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.common.event.ContainerNumbers; import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
import com.baiyue.lastleg.mapper.LastlegSourcePalletMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -65,8 +66,16 @@ public class InboundPlanService {
@Resource private DropshipOrderService dropshipOrders; @Resource private DropshipOrderService dropshipOrders;
@Resource private WarehouseResourceGateway warehouseGateway; @Resource private WarehouseResourceGateway warehouseGateway;
@Resource private LastlegOrderCommandRecordMapper commandMapper; @Resource private LastlegOrderCommandRecordMapper commandMapper;
@Resource private LastlegSourcePalletMapper sourcePalletMapper;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
public List<String> sourcePalletNos(String customerId, long orderId, String orderType) {
return "ONE_CLICK_DROPSHIP".equals(orderType)
? dropshipOrders.palletSnapshots(customerId, orderId).stream()
.map(DropshipPalletSnapshot::sourcePalletNo).toList()
: sourcePalletMapper.palletNos(customerId, orderId);
}
@Transactional @Transactional
public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey, public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey,
String operatorId) { String operatorId) {
@@ -393,6 +402,10 @@ public class InboundPlanService {
PalletAllocator.Result allocation = wholePallets == null && manual == null ? PalletAllocator.allocate(orders.stream() PalletAllocator.Result allocation = wholePallets == null && manual == null ? PalletAllocator.allocate(orders.stream()
.map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
order.packageCount(), order.recommendedPalletCount())).toList()) : null; order.packageCount(), order.recommendedPalletCount())).toList()) : null;
if (allocation != null && sourcePalletNos != null && !sourcePalletNos.isEmpty()
&& sourcePalletNos.size() != allocation.pallets().size()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与尾程计划托盘数不一致");
}
if (wholePallets != null) validateWholePallets(orders, wholePallets); if (wholePallets != null) validateWholePallets(orders, wholePallets);
plan.setPalletCount(manual != null ? manual.palletCount() plan.setPalletCount(manual != null ? manual.palletCount()
: wholePallets == null ? allocation.pallets().size() : wholePallets.size()); : wholePallets == null ? allocation.pallets().size() : wholePallets.size());
@@ -479,6 +492,7 @@ public class InboundPlanService {
List<Integer> pendingIndexes = new ArrayList<>(); List<Integer> pendingIndexes = new ArrayList<>();
List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>(); List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>();
List<List<WholePalletInput>> wholePallets = new ArrayList<>(); List<List<WholePalletInput>> wholePallets = new ArrayList<>();
List<List<String>> standardPalletNos = new ArrayList<>();
for (int index = 0; index < refs.size(); index++) { for (int index = 0; index < refs.size(); index++) {
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index);
InboundPlan existing = existingPlans.get(index); InboundPlan existing = existingPlans.get(index);
@@ -517,6 +531,11 @@ public class InboundPlanService {
validateWholePallets(orders, sourcePallets); validateWholePallets(orders, sourcePallets);
} }
wholePallets.add(sourcePallets); wholePallets.add(sourcePallets);
List<String> standardNos = "ONE_CLICK_DROPSHIP".equals(ref.orderType()) ? List.of()
: sourcePalletMapper.palletNos(ref.customerId(), ref.orderId());
if (sourcePallets == null && standardNos.isEmpty()) throw new ResponseStatusException(
HttpStatus.CONFLICT, "散货订单缺少头程托盘码快照");
standardPalletNos.add(standardNos);
} }
requireSingleWarehouseId(warehouseIds); requireSingleWarehouseId(warehouseIds);
@@ -531,7 +550,7 @@ public class InboundPlanService {
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reserved = prepared == null ? Map.of() Map<Integer, WarehouseResourceGateway.PalletBatchItem> reserved = prepared == null ? Map.of()
: prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo, : prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo,
PreparedPallet::reservation)); PreparedPallet::reservation));
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), List.of(), created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), standardPalletNos.get(pendingIndex),
wholePallets.get(pendingIndex), null, null, prepared, reserved)); wholePallets.get(pendingIndex), null, null, prepared, reserved));
} }
List<InboundPlanResponse> result = new ArrayList<>(refs.size()); List<InboundPlanResponse> result = new ArrayList<>(refs.size());
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface LastlegSourcePalletMapper {
@Insert("""
INSERT INTO ll_order_source_pallet
(id,customer_id,order_id,source_pallet_no,sequence_no,source_event_id,created_at)
VALUES (#{id},#{customerId},#{orderId},#{palletNo},#{sequenceNo},#{eventId},CURRENT_TIMESTAMP(3))
""")
int insert(@Param("id") long id, @Param("customerId") String customerId,
@Param("orderId") long orderId, @Param("palletNo") String palletNo,
@Param("sequenceNo") int sequenceNo, @Param("eventId") String eventId);
@Select("""
SELECT source_pallet_no FROM ll_order_source_pallet
WHERE customer_id=#{customerId} AND order_id=#{orderId}
ORDER BY sequence_no,id
""")
List<String> palletNos(@Param("customerId") String customerId, @Param("orderId") long orderId);
}
@@ -44,6 +44,7 @@ public class LoadingCompletedConsumer {
@Resource private DropshipOrderService dropshipOrders; @Resource private DropshipOrderService dropshipOrders;
@Resource private InventoryCommandService inventory; @Resource private InventoryCommandService inventory;
@Resource private ContainerShippingGateMapper shippingGates; @Resource private ContainerShippingGateMapper shippingGates;
@Resource private LastlegSourcePalletMapper sourcePallets;
@Transactional(isolation = Isolation.READ_COMMITTED) @Transactional(isolation = Isolation.READ_COMMITTED)
public int consume(EventEnvelope envelope) { public int consume(EventEnvelope envelope) {
@@ -273,6 +274,21 @@ public class LoadingCompletedConsumer {
} }
createInboundOrderSnapshot(event, payload, source, order, orderHash); createInboundOrderSnapshot(event, payload, source, order, orderHash);
if ("FIRST_LEG".equals(orderType) && event.eventVersion() >= 2) {
List<FirstLegLoadingPlanCompletedV1.PalletSnapshot> pallets = source.pallets() == null ? List.of() : source.pallets();
Set<String> palletNos = new HashSet<>();
for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) {
String palletNo = pallets.get(palletIndex).palletNo();
if (palletNo == null || palletNo.isBlank() || palletNo.length() > 128 || !palletNos.add(palletNo)) {
throw new IllegalArgumentException("散货来源托盘码为空、重复或过长");
}
sourcePallets.insert(IdWorker.getId(), customerId, order.getId(), palletNo.trim(), palletIndex + 1, event.eventId());
}
if (!pallets.isEmpty() && order.getPalletCount() > 0 && pallets.size() != order.getPalletCount()) {
throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致");
}
}
for(var item:source.items()) { for(var item:source.items()) {
LastlegOrderDetail detail=new LastlegOrderDetail(); LastlegOrderDetail detail=new LastlegOrderDetail();
detail.setCustomerId(customerId); detail.setCustomerId(customerId);
@@ -112,6 +112,22 @@ CREATE TABLE IF NOT EXISTS ll_order (
KEY idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id) KEY idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)
) ENGINE=InnoDB COMMENT='尾程订单主表'; ) ENGINE=InnoDB COMMENT='尾程订单主表';
CREATE TABLE IF NOT EXISTS ll_order_source_pallet (
id BIGINT NOT NULL COMMENT '来源托盘快照ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID',
source_pallet_no VARCHAR(128) NOT NULL COMMENT '头程冻结托盘码',
sequence_no INT NOT NULL COMMENT '订单内稳定顺序',
source_event_id VARCHAR(128) NOT NULL COMMENT '来源装柜完成事件ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_source_pallet_no(source_pallet_no),
UNIQUE KEY uk_ll_source_pallet_sequence(customer_id,order_id,sequence_no),
KEY idx_ll_source_pallet_order(customer_id,order_id),
CONSTRAINT fk_ll_source_pallet_order FOREIGN KEY(order_id) REFERENCES ll_order(id)
) ENGINE=InnoDB COMMENT='头程交接的不可变托盘码快照';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl; PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained'));
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.HttpStatus;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
@@ -98,6 +99,23 @@ class HttpWarehouseResourceGatewayTest {
server.verify(); server.verify();
} }
@Test
void resolveReservedFallsBackToConfirmedPhysicalBindingForEarlierPlanDisplayCode() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/IP-ACY2-20260909-166786-P001"))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/id/31"))
.andRespond(withSuccess("""
{"code":"200","data":{"palletId":31,"palletCode":"PLT-US-LAX-01-31",
"warehouseId":21,"locationId":41,"status":"OCCUPIED","version":3,
"reservable":false,"reservationBlockReason":"PALLET_NOT_IDLE"}}
""", MediaType.APPLICATION_JSON));
var result = gateway.resolveReservedPallet("IP-ACY2-20260909-166786-P001", "11", 21L, 31L, 41L, 51L);
assertThat(result.palletId()).isEqualTo(31L);
server.verify();
}
@Test @Test
void resolveReservedRejectsDifferentPhysicalBinding() { void resolveReservedRejectsDifferentPhysicalBinding() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-RESERVED")) server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-RESERVED"))
@@ -203,7 +221,7 @@ class HttpWarehouseResourceGatewayTest {
var result = gateway.ensurePalletBatch(new WarehouseResourceGateway.PalletBatchEnsure(31L, "BATCH-1", var result = gateway.ensurePalletBatch(new WarehouseResourceGateway.PalletBatchEnsure(31L, "BATCH-1",
List.of(new WarehouseResourceGateway.PalletBatchItemRequest( List.of(new WarehouseResourceGateway.PalletBatchItemRequest(
100L, "CIP:1:1", "IP-21", 1, "STANDARD", "ONT8")))); 100L, "CIP:1:1", "IP-21", 1, "STANDARD", "ONT8", "CONT-ONT8-001"))));
assertThat(result.batchId()).isEqualTo(41L); assertThat(result.batchId()).isEqualTo(41L);
assertThat(result.items().get(0).resourceLockId()).isEqualTo(71L); assertThat(result.items().get(0).resourceLockId()).isEqualTo(71L);
@@ -117,7 +117,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
@Test @Test
void reservationMustMatchEveryStableItemIdentity() throws Exception { void reservationMustMatchEveryStableItemIdentity() throws Exception {
var expected = new ContainerInboundPlanConversionCoordinator.PreparedItem( var expected = new ContainerInboundPlanConversionCoordinator.PreparedItem(
"100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8"); "100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8", "CONT-ONT8-001");
var preparation = new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(expected)); var preparation = new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(expected));
var response = new WarehouseResourceGateway.PalletBatchResponse(41L, "BATCH-1", "RESERVED", 1, var response = new WarehouseResourceGateway.PalletBatchResponse(41L, "BATCH-1", "RESERVED", 1,
false, List.of(new WarehouseResourceGateway.PalletBatchItem( false, List.of(new WarehouseResourceGateway.PalletBatchItem(
@@ -239,6 +239,8 @@ class ContainerInboundPlanConversionCoordinatorTest {
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))) when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class)))
.thenReturn("IP-FBA", "IP-OVERSEAS"); .thenReturn("IP-FBA", "IP-OVERSEAS");
when(plans.sourcePalletNos("100", 11L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-ABE3-001"));
when(plans.sourcePalletNos("200", 12L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-US-LAX-01-001"));
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand", ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
1L, 2L, BOOKING_ID, "FIRST-KEY", request, 1L, 2L, BOOKING_ID, "FIRST-KEY", request,
@@ -291,7 +293,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
private void setOnePreparedItem(ContainerPlanConversionCommand command) throws Exception { private void setOnePreparedItem(ContainerPlanConversionCommand command) throws Exception {
var item = new ContainerInboundPlanConversionCoordinator.PreparedItem( var item = new ContainerInboundPlanConversionCoordinator.PreparedItem(
"100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8"); "100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8", "CONT-ONT8-001");
command.setPreparationJson(objectMapper.writeValueAsString( command.setPreparationJson(objectMapper.writeValueAsString(
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(item)))); new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(item))));
} }
@@ -23,7 +23,8 @@ public final class WarehousePalletBatchRequests {
@NotBlank @Size(max = 128) String inboundPlanNo, @NotBlank @Size(max = 128) String inboundPlanNo,
@Positive int sequenceNo, @Positive int sequenceNo,
@NotBlank @Size(max = 32) String palletType, @NotBlank @Size(max = 32) String palletType,
@Size(max = 128) String fbaWarehouseCode @Size(max = 128) String fbaWarehouseCode,
@NotBlank @Size(max = 128) String palletCode
) { } ) { }
public record Confirm( public record Confirm(
@@ -80,7 +80,7 @@ public class WarehousePalletBatchService {
List<WarehousePalletBatchItemEntity> created = new ArrayList<>(); List<WarehousePalletBatchItemEntity> created = new ArrayList<>();
for (WarehousePalletBatchRequests.Item requested : normalized.items()) { for (WarehousePalletBatchRequests.Item requested : normalized.items()) {
Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
normalized.warehouseId(), new PalletRequests.SaveRequest(null, requested.palletType(), null, normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), requested.palletType(), null,
BigDecimal.ZERO, "IDLE", null, "Auto-created for inbound plan " + requested.inboundPlanNo(), BigDecimal.ZERO, "IDLE", null, "Auto-created for inbound plan " + requested.inboundPlanNo(),
requested.fbaWarehouseCode())); requested.fbaWarehouseCode()));
WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
@@ -257,11 +257,13 @@ public class WarehousePalletBatchService {
if (item.fbaWarehouseCode() != null) { if (item.fbaWarehouseCode() != null) {
requireMax(item.fbaWarehouseCode().trim(), 128, "FBA warehouse code is too long"); requireMax(item.fbaWarehouseCode().trim(), 128, "FBA warehouse code is too long");
} }
String palletCode = required(item.palletCode(), "Pallet code is required");
requireMax(palletCode, 128, "Pallet code is too long");
if (!businessKeys.add(businessKey)) throw badRequest("Pallet business key must be unique"); if (!businessKeys.add(businessKey)) throw badRequest("Pallet business key must be unique");
if (!sequences.add(item.sequenceNo())) throw badRequest("Pallet sequence must be unique"); if (!sequences.add(item.sequenceNo())) throw badRequest("Pallet sequence must be unique");
return new WarehousePalletBatchRequests.Item(item.customerId(), businessKey, inboundPlanNo, return new WarehousePalletBatchRequests.Item(item.customerId(), businessKey, inboundPlanNo,
item.sequenceNo(), palletType, item.sequenceNo(), palletType,
trim(item.fbaWarehouseCode())); trim(item.fbaWarehouseCode()), palletCode);
}).sorted(Comparator.comparingInt(WarehousePalletBatchRequests.Item::sequenceNo)).toList(); }).sorted(Comparator.comparingInt(WarehousePalletBatchRequests.Item::sequenceNo)).toList();
return new WarehousePalletBatchRequests.EnsureReserved(request.warehouseId(), batchKey, normalized); return new WarehousePalletBatchRequests.EnsureReserved(request.warehouseId(), batchKey, normalized);
} }
@@ -34,7 +34,7 @@ components:
schemas: schemas:
BatchItemRequest: BatchItemRequest:
type: object type: object
required: [customerId, businessKey, inboundPlanNo, sequenceNo, palletType] required: [customerId, businessKey, inboundPlanNo, sequenceNo, palletType, palletCode]
properties: properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' } customerId: { type: string, pattern: '^[1-9][0-9]*$' }
businessKey: { type: string, minLength: 1, maxLength: 100 } businessKey: { type: string, minLength: 1, maxLength: 100 }
@@ -42,6 +42,7 @@ components:
sequenceNo: { type: integer, minimum: 1 } sequenceNo: { type: integer, minimum: 1 }
palletType: { type: string, minLength: 1, maxLength: 32 } palletType: { type: string, minLength: 1, maxLength: 32 }
fbaWarehouseCode: { type: string, nullable: true, maxLength: 128 } fbaWarehouseCode: { type: string, nullable: true, maxLength: 128 }
palletCode: { type: string, minLength: 1, maxLength: 128, description: 头程冻结并已打印的托盘码,海外实体托盘必须原样沿用 }
EnsureReservedRequest: EnsureReservedRequest:
type: object type: object
required: [warehouseId, batchKey, items] required: [warehouseId, batchKey, items]
@@ -364,8 +364,8 @@ class WarehousePalletBatchServiceTest {
private static WarehousePalletBatchRequests.EnsureReserved request() { private static WarehousePalletBatchRequests.EnsureReserved request() {
return new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "container-1", List.of( return new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "container-1", List.of(
new WarehousePalletBatchRequests.Item(CUSTOMER_B, "container:plan-b:2", "PLAN-B", 2, "STANDARD", "ONT8"), new WarehousePalletBatchRequests.Item(CUSTOMER_B, "container:plan-b:2", "PLAN-B", 2, "STANDARD", "ONT8", "CONT-ONT8-001"),
new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:plan-a:1", "PLAN-A", 1, "STANDARD", "LAX9"))); new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:plan-a:1", "PLAN-A", 1, "STANDARD", "LAX9", "CONT-LAX9-001")));
} }
private static WarehousePalletBatchEntity batch(long id, String status, int attempt) { private static WarehousePalletBatchEntity batch(long id, String status, int attempt) {