diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 4bf249086..386de5d13 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5279,6 +5279,29 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1)) ) 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 ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 67f990886..40d184cae 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -112,6 +112,21 @@ CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 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 ( 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 '聚合标识', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index ccfaa1ba3..31408a188 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -13,8 +13,11 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; 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.LoadingListService; +import com.baiyue.firstleg.service.LoadingPalletLabelService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ @Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询") @@ -41,6 +45,8 @@ import org.springframework.web.bind.annotation.RestController; public class PlatformLoadingListController { @Resource private LoadingListService service; + @Resource + private LoadingPalletLabelService palletLabels; @Operation(summary = "分页查询预装清单") @GetMapping("/loading-plans") @@ -118,4 +124,26 @@ public class PlatformLoadingListController { 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 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 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); + } + } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index ee98f5bc5..924466efc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -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 labels + ) { + public PalletLabelGroup { + labels = labels == null ? List.of() : List.copyOf(labels); + } + } + + public record PalletLabelManifest( + Long planId, String planNo, String containerNo, int totalCount, + List groups + ) { + public PalletLabelManifest { + groups = groups == null ? List.of() : List.copyOf(groups); + } + } + public record PreloadSummary( Long planId, Long containerBookingId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java new file mode 100644 index 000000000..5c798ed19 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java @@ -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 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 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; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 78d3eb0f2..9f77042c0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -17,6 +17,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; +import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -44,6 +45,8 @@ public class LoadingCompletedOutboxService { private static final long HANDOVER_VERSION = 2L; @Resource private LoadingCompletedEventMapper snapshotMapper; @Resource private LoadingCartonManifestMapper cartonManifestMapper; + @Resource private LoadingPalletLabelMapper palletLabelMapper; + @Resource private LoadingPalletLabelService palletLabelService; @Resource private InboundOutboxMapper outboxMapper; @Resource private ObjectMapper objectMapper; @@ -61,12 +64,15 @@ public class LoadingCompletedOutboxService { if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); validateContainerIdentity(rows); validateCustomerSnapshots(rows); + palletLabelService.prepare(planId, rows.get(0).getWarehouseId()); Map> cartonMarks = cartonLabelPools(rows); List palletRows = palletRows(planId); + List standardLabels = palletLabelMapper.labels(planId); return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, java.util.TreeMap::new, java.util.stream.Collectors.toList())) .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(); } @@ -91,13 +97,15 @@ public class LoadingCompletedOutboxService { } private EventEnvelope envelope(long accountId, List rows, List palletRows, + List standardLabels, Map> cartonMarks) { Row first = rows.get(0); Map> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); List orders = byInbound.values().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()), first.getPlanNo(), string(first.getContainerBookingId()), ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(), @@ -114,6 +122,7 @@ public class LoadingCompletedOutboxService { } private FirstLegLoadingPlanCompletedV2.Order order(List values, List palletRows, + List standardLabels, Map> cartonMarks) { List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); Row first = sorted.get(0); @@ -129,15 +138,18 @@ public class LoadingCompletedOutboxService { String fulfillmentType = fulfillmentType(sorted); String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; List 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()) { throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照"); } + Integer frozenPalletCount = dropship(fulfillmentType) || standardLabels.isEmpty() + ? actualPalletCount(palletRows) : Integer.valueOf(standardLabels.size()); return new FirstLegLoadingPlanCompletedV2.Order(string(first.getSourceInboundId()), string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(), string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()), 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::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items, string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java new file mode 100644 index 000000000..a9eafa154 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java @@ -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 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 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 existing = mapper.labels(planId); + List 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 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 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 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 expected(SourceRow plan, List sources, String containerNo) { + Map indexes = new LinkedHashMap<>(); + List 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 expected, List actual) { + List left = expected.stream().map(this::identity).toList(); + List 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 rows) { + Map> grouped = rows.stream().collect(java.util.stream.Collectors.groupingBy( + LabelRow::getDestinationWarehouseCode, LinkedHashMap::new, java.util.stream.Collectors.toList())); + List 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 requirePdf(ResponseEntity 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 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); + } +} diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 9c57f40c6..51a5e694d 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -348,6 +348,29 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1)) ) 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资料作为数字码保存在货柜主表。 CREATE TABLE IF NOT EXISTS fl_container_document ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 8dce29cbf..773d54235 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -74,6 +74,23 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmPreloadRequest'}}} responses: '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: get: summary: 分页查询装柜清单 @@ -430,3 +447,9 @@ components: voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次} expectedVersion: {type: integer, format: int32, minimum: 0, 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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index dd2013b77..2926675eb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -9,6 +9,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; +import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -30,14 +31,19 @@ class LoadingCompletedOutboxServiceTest { private final LoadingCompletedEventMapper snapshots = mock(LoadingCompletedEventMapper.class); private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.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 LoadingCompletedOutboxService service = new LoadingCompletedOutboxService(); @BeforeEach void setup() { ReflectionTestUtils.setField(service,"snapshotMapper",snapshots); ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests); + ReflectionTestUtils.setField(service,"palletLabelMapper",palletLabels); + ReflectionTestUtils.setField(service,"palletLabelService",palletLabelService); ReflectionTestUtils.setField(service,"outboxMapper",outbox); ReflectionTestUtils.setField(service,"objectMapper",mapper); + when(palletLabels.labels(anyLong())).thenReturn(List.of()); when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of( label(101, "BOX-101"), label(102, "BOX-102"), label(202, "BOX-202"), label(302, "BOX-302"))); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java new file mode 100644 index 000000000..46759a201 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java @@ -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 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; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 991267f24..ede826990 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -142,13 +142,20 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { throw new ResponseStatusException(HttpStatus.CONFLICT, "Reserved pallet customer binding is invalid"); } - WarehousePalletData pallet = lookupPallet(palletCode).data(); - if (pallet.id() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId + WarehousePalletReference pallet; + 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())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Scanned pallet does not match the reserved inbound plan resource"); } - return reference(pallet); + return pallet; } @Override diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index 88e900246..0a7337a72 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -55,7 +55,8 @@ public interface WarehouseResourceGateway { java.math.BigDecimal maxLoadKg, String status) { } record PalletBatchEnsure(long warehouseId, String batchKey, List items) { } 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 PalletBatchCompensation(long warehouseId, String batchKey, String commandKey, String reason) { } record PalletBatchResponse(long batchId, String batchKey, String status, int attemptNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java index b78c9b395..3f3767265 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java @@ -117,7 +117,7 @@ public class ContainerInboundPlanConversionCoordinator { preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item -> new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()), item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD", - item.fbaWarehouseCode())).toList()); + item.fbaWarehouseCode(), item.palletCode())).toList()); WarehouseResourceGateway.PalletBatchResponse reservation; try { reservation = warehouse.ensurePalletBatch(ensure); @@ -207,10 +207,15 @@ public class ContainerInboundPlanConversionCoordinator { .flatMap(group -> group.orders().stream()).filter(item -> "READY".equals(item.readiness())).toList()) { long planId = IdWorker.getId(); String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId); + List 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++) { items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo, localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence, - trimToNull(order.fbaWarehouseCode()))); + trimToNull(order.fbaWarehouseCode()), sourcePalletNos.get(localSequence - 1))); } } Preparation preparation = new Preparation(warehouseId, List.copyOf(items)); @@ -373,5 +378,5 @@ public class ContainerInboundPlanConversionCoordinator { public record Preparation(long warehouseId, List items) { } public record PreparedItem(String customerId, long orderId, long planId, String planNo, int localSequenceNo, int globalSequenceNo, String businessKey, - String fbaWarehouseCode) { } + String fbaWarehouseCode, String palletCode) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index f28059c3a..7fe420dee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -27,6 +27,7 @@ import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.common.event.ContainerNumbers; import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.baiyue.lastleg.mapper.LastlegSourcePalletMapper; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -65,8 +66,16 @@ public class InboundPlanService { @Resource private DropshipOrderService dropshipOrders; @Resource private WarehouseResourceGateway warehouseGateway; @Resource private LastlegOrderCommandRecordMapper commandMapper; + @Resource private LastlegSourcePalletMapper sourcePalletMapper; @Resource private ObjectMapper objectMapper; + public List 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 public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey, String operatorId) { @@ -393,6 +402,10 @@ public class InboundPlanService { PalletAllocator.Result allocation = wholePallets == null && manual == null ? PalletAllocator.allocate(orders.stream() .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), 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); plan.setPalletCount(manual != null ? manual.palletCount() : wholePallets == null ? allocation.pallets().size() : wholePallets.size()); @@ -479,6 +492,7 @@ public class InboundPlanService { List pendingIndexes = new ArrayList<>(); List> snapshots = new ArrayList<>(); List> wholePallets = new ArrayList<>(); + List> standardPalletNos = new ArrayList<>(); for (int index = 0; index < refs.size(); index++) { InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); InboundPlan existing = existingPlans.get(index); @@ -517,6 +531,11 @@ public class InboundPlanService { validateWholePallets(orders, sourcePallets); } wholePallets.add(sourcePallets); + List 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); @@ -531,7 +550,7 @@ public class InboundPlanService { Map reserved = prepared == null ? Map.of() : prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo, 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)); } List result = new ArrayList<>(refs.size()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegSourcePalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegSourcePalletMapper.java new file mode 100644 index 000000000..7304ff5bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegSourcePalletMapper.java @@ -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 palletNos(@Param("customerId") String customerId, @Param("orderId") long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 47aadc593..6ff108938 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -44,6 +44,7 @@ public class LoadingCompletedConsumer { @Resource private DropshipOrderService dropshipOrders; @Resource private InventoryCommandService inventory; @Resource private ContainerShippingGateMapper shippingGates; + @Resource private LastlegSourcePalletMapper sourcePallets; @Transactional(isolation = Isolation.READ_COMMITTED) public int consume(EventEnvelope envelope) { @@ -273,6 +274,21 @@ public class LoadingCompletedConsumer { } createInboundOrderSnapshot(event, payload, source, order, orderHash); + if ("FIRST_LEG".equals(orderType) && event.eventVersion() >= 2) { + List pallets = source.pallets() == null ? List.of() : source.pallets(); + Set 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()) { LastlegOrderDetail detail=new LastlegOrderDetail(); detail.setCustomerId(customerId); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index a85b17d75..b7187ac84 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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) ) 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')); 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')); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java index 823f6ef10..2b3a8838d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.gateway; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; @@ -98,6 +99,23 @@ class HttpWarehouseResourceGatewayTest { 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 void resolveReservedRejectsDifferentPhysicalBinding() { 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", 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.items().get(0).resourceLockId()).isEqualTo(71L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index c24c3f086..084121a52 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -117,7 +117,7 @@ class ContainerInboundPlanConversionCoordinatorTest { @Test void reservationMustMatchEveryStableItemIdentity() throws Exception { 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 response = new WarehouseResourceGateway.PalletBatchResponse(41L, "BATCH-1", "RESERVED", 1, false, List.of(new WarehouseResourceGateway.PalletBatchItem( @@ -239,6 +239,8 @@ class ContainerInboundPlanConversionCoordinatorTest { when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))) .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", 1L, 2L, BOOKING_ID, "FIRST-KEY", request, @@ -291,7 +293,7 @@ class ContainerInboundPlanConversionCoordinatorTest { private void setOnePreparedItem(ContainerPlanConversionCommand command) throws Exception { 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( new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(item)))); } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java index a15574a96..1cf904435 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java @@ -23,7 +23,8 @@ public final class WarehousePalletBatchRequests { @NotBlank @Size(max = 128) String inboundPlanNo, @Positive int sequenceNo, @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( diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java index d649a00c0..f0de25ab7 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java @@ -80,7 +80,7 @@ public class WarehousePalletBatchService { List created = new ArrayList<>(); for (WarehousePalletBatchRequests.Item requested : normalized.items()) { 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(), requested.fbaWarehouseCode())); WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, @@ -257,11 +257,13 @@ public class WarehousePalletBatchService { if (item.fbaWarehouseCode() != null) { 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 (!sequences.add(item.sequenceNo())) throw badRequest("Pallet sequence must be unique"); return new WarehousePalletBatchRequests.Item(item.customerId(), businessKey, inboundPlanNo, item.sequenceNo(), palletType, - trim(item.fbaWarehouseCode())); + trim(item.fbaWarehouseCode()), palletCode); }).sorted(Comparator.comparingInt(WarehousePalletBatchRequests.Item::sequenceNo)).toList(); return new WarehousePalletBatchRequests.EnsureReserved(request.warehouseId(), batchKey, normalized); } diff --git a/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml b/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml index 5b239e44c..970e25e8f 100644 --- a/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml +++ b/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml @@ -34,7 +34,7 @@ components: schemas: BatchItemRequest: type: object - required: [customerId, businessKey, inboundPlanNo, sequenceNo, palletType] + required: [customerId, businessKey, inboundPlanNo, sequenceNo, palletType, palletCode] properties: customerId: { type: string, pattern: '^[1-9][0-9]*$' } businessKey: { type: string, minLength: 1, maxLength: 100 } @@ -42,6 +42,7 @@ components: sequenceNo: { type: integer, minimum: 1 } palletType: { type: string, minLength: 1, maxLength: 32 } fbaWarehouseCode: { type: string, nullable: true, maxLength: 128 } + palletCode: { type: string, minLength: 1, maxLength: 128, description: 头程冻结并已打印的托盘码,海外实体托盘必须原样沿用 } EnsureReservedRequest: type: object required: [warehouseId, batchKey, items] diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java index 49157c4a2..bde250a7b 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java @@ -364,8 +364,8 @@ class WarehousePalletBatchServiceTest { private static WarehousePalletBatchRequests.EnsureReserved request() { 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_A, "container:plan-a:1", "PLAN-A", 1, "STANDARD", "LAX9"))); + 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", "CONT-LAX9-001"))); } private static WarehousePalletBatchEntity batch(long id, String status, int attempt) {