feat: freeze and propagate first-leg pallet labels
This commit is contained in:
@@ -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 '聚合标识',
|
||||||
|
|||||||
+28
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-4
@@ -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}
|
||||||
|
|||||||
+6
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-3
@@ -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
|
||||||
|
|||||||
+2
-1
@@ -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,
|
||||||
|
|||||||
+8
-3
@@ -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) { }
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-1
@@ -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());
|
||||||
|
|||||||
+27
@@ -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);
|
||||||
|
}
|
||||||
+16
@@ -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'));
|
||||||
|
|||||||
+19
-1
@@ -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);
|
||||||
|
|||||||
+4
-2
@@ -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))));
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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(
|
||||||
|
|||||||
+4
-2
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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]
|
||||||
|
|||||||
+2
-2
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user