Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-09 18:31:14 +08:00
18 changed files with 143 additions and 28 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
## 模块入口
散货托盘码:`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java`,契约 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按柜号、FBA 仓编码、三位序号生成冻结标签;重复生成须核对已有标签与明细一致,冲突直接拒绝。支持全部或按仓指定张数输出 100×100 mm PDF;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。
散货托盘码:`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java`,契约 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按柜号、FBA 地址内部编号、三位序号生成冻结标签;内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。支持全部或按仓指定张数输出 100×100 mm PDF;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。
| 目标 | 入口 |
|---|---|
@@ -18,7 +18,7 @@ public interface WarehouseMasterDataClient {
record Detail(Warehouse warehouse) {
}
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
record Warehouse(Long id, Long tenantId, String warehouseCode, String internalCode, String warehouseName,
String warehouseType, String status) {
}
}
@@ -133,13 +133,14 @@ public final class LoadingListDtos {
public record PalletLabel(
Long labelId, Long itemId, Long accountId, Long inboundId, String inboundCode,
String containerNo, String destinationWarehouseCode, String destinationWarehouseName,
String containerNo, String destinationWarehouseCode, String destinationWarehouseInternalCode,
String destinationWarehouseName,
int palletIndex, String palletCode
) {
}
public record PalletLabelGroup(
String destinationWarehouseCode, String destinationWarehouseName,
String destinationWarehouseCode, String destinationWarehouseInternalCode, String destinationWarehouseName,
int totalCount, List<PalletLabel> labels
) {
public PalletLabelGroup {
@@ -13,7 +13,7 @@ import java.util.List;
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.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id,
i.destination_warehouse_code,i.destination_warehouse_name,
COALESCE(NULLIF(i.pallet_count,0),
CEIL(COALESCE(NULLIF(o.pallet_count,0),
@@ -56,6 +56,7 @@ public interface LoadingPalletLabelMapper {
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 Long destinationWarehouseId;
private String inboundCode; private String destinationWarehouseCode; private String destinationWarehouseName;
private Integer palletCount; private String fulfillmentType; private Integer loadSequence;
private String destinationType;
@@ -577,7 +577,7 @@ public class LoadingListService {
}
/** 完成 worker 在远程结算前验证最终跨区快照能够完整生成。 */
@Transactional(readOnly = true)
@Transactional
public void requireCompletedEventReady(long planId) {
loadingCompletedOutboxService.requirePublishable(planId);
}
@@ -6,6 +6,7 @@ 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.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelGroup;
import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest;
@@ -34,12 +35,13 @@ import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/** 散货托盘码是柜号与 FBA 仓维度的稳定业务标识;一件代发继续使用装箱打托事实。 */
/** 散货托盘码按真实柜号、FBA 地址内部编号和仓内序号组成;一件代发继续使用装箱打托事实。 */
@Service
public class LoadingPalletLabelService {
private static final BigDecimal PAGE_MM = new BigDecimal("100");
@Resource private LoadingPalletLabelMapper mapper;
@Resource private PrintPdfClient printClient;
@Resource private WarehouseMasterDataClient warehouseClient;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@@ -58,11 +60,13 @@ public class LoadingPalletLabelService {
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 仓编码,不能生成托盘码");
if (standard.stream().anyMatch(row -> row.getDestinationWarehouseId() == null
|| !StringUtils.hasText(row.getDestinationWarehouseCode()))) {
throw new BizException(ResultCode.CONFLICT, "散货明细缺少 FBA 仓信息,不能生成托盘码");
}
Map<Long, String> internalCodes = resolveInternalCodes(standard);
List<LabelRow> existing = mapper.labels(planId);
List<LabelRow> expected = expected(first, standard, containerNo);
List<LabelRow> expected = expected(first, standard, containerNo, internalCodes);
if (existing.isEmpty()) {
expected.forEach(row -> { if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试"); });
existing = mapper.labels(planId);
@@ -91,19 +95,21 @@ public class LoadingPalletLabelService {
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<>();
private List<LabelRow> expected(SourceRow plan, List<SourceRow> sources, String containerNo,
Map<Long, String> internalCodes) {
Map<Long, Integer> indexes = new LinkedHashMap<>();
List<LabelRow> result = new ArrayList<>();
for (SourceRow source : sources) {
String code = source.getDestinationWarehouseCode().trim().toUpperCase(Locale.ROOT);
String warehouseCode = source.getDestinationWarehouseCode().trim().toUpperCase(Locale.ROOT);
String internalCode = internalCodes.get(source.getDestinationWarehouseId());
for (int local = 0; local < source.getPalletCount(); local++) {
int index = indexes.merge(code, 1, Integer::sum);
int index = indexes.merge(source.getDestinationWarehouseId(), 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));
row.setDestinationWarehouseCode(warehouseCode); row.setDestinationWarehouseName(source.getDestinationWarehouseName());
row.setPalletIndex(index); row.setPalletCode(containerNo + "-" + internalCode + "-" + String.format("%03d", index));
if (row.getPalletCode().length() > 128) throw new BizException(ResultCode.CONFLICT, "托盘码长度超过系统限制");
result.add(row);
}
@@ -111,6 +117,37 @@ public class LoadingPalletLabelService {
return result;
}
private Map<Long, String> resolveInternalCodes(List<SourceRow> sources) {
Map<Long, String> codes = new LinkedHashMap<>();
Map<String, Long> owners = new LinkedHashMap<>();
for (SourceRow source : sources) {
long warehouseId = source.getDestinationWarehouseId();
if (codes.containsKey(warehouseId)) continue;
WarehouseMasterDataClient.Warehouse warehouse;
try {
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouseClient.active(internalToken, 0L, warehouseId);
warehouse = response == null || !response.successful() || response.data() == null
? null : response.data().warehouse();
} catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,请稍后重试");
}
if (warehouse == null || !"FBA".equals(warehouse.warehouseType())) {
throw new BizException(ResultCode.CONFLICT, "目的 FBA 仓不存在或未启用");
}
String internalCode = normalize(warehouse.internalCode());
if (internalCode == null) {
throw new BizException(ResultCode.CONFLICT, "FBA 仓“" + warehouse.warehouseCode() + "”未配置内部编号");
}
internalCode = internalCode.toUpperCase(Locale.ROOT);
Long owner = owners.putIfAbsent(internalCode, warehouseId);
if (owner != null && owner != warehouseId) {
throw new BizException(ResultCode.CONFLICT, "同柜 FBA 仓内部编号重复,请先修正仓库主数据");
}
codes.put(warehouseId, internalCode);
}
return codes;
}
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();
@@ -122,12 +159,19 @@ public class LoadingPalletLabelService {
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.get(0).getDestinationWarehouseCode(), internalCode(values.get(0)),
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 PalletLabel label(LabelRow row) { return new PalletLabel(row.getLabelId(), row.getItemId(), row.getAccountId(), row.getInboundId(), row.getInboundCode(), row.getContainerNo(), row.getDestinationWarehouseCode(), internalCode(row), row.getDestinationWarehouseName(), row.getPalletIndex(), row.getPalletCode()); }
private String internalCode(LabelRow row) {
String prefix = row.getContainerNo() + "-";
String suffix = "-" + String.format("%03d", row.getPalletIndex());
String palletCode = row.getPalletCode();
return palletCode.substring(prefix.length(), palletCode.length() - suffix.length());
}
private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private ResponseEntity<byte[]> requirePdf(ResponseEntity<byte[]> response) {
@@ -67,7 +67,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
KEY idx_terminal_drayage_history(container_booking_id,created_at,id),
CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id),
CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
@@ -77,7 +77,7 @@ paths:
/api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/prepare:
post:
summary: 生成并查询散货托盘码清单
description: 预装确认且已有真实柜号后,按柜号-FBA仓编码-三位序号生成稳定清单;一件代发不参与。
description: 预装确认且已有真实柜号后,按柜号-FBA地址内部编号-三位序号生成稳定清单;一件代发不参与。内部编号缺失时拒绝生成。
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses:
'200': {description: 按 FBA 仓分组的散货托盘码清单, content: {application/json: {schema: {type: object}}}}
@@ -77,7 +77,7 @@ class LoadingListControllerContractTest {
.contains("/loading-plans/{planId}/lifecycle")
.contains("/loading-plans/{planId}/pallet-labels/prepare")
.contains("/loading-plans/{planId}/pallet-labels/print")
.contains("柜号-FBA仓编码-三位序号")
.contains("柜号-FBA地址内部编号-三位序号")
.contains("不得以错误文案或 A0404 猜测阶段")
.contains("confirmationStatus")
.contains("canConfirm")
@@ -390,7 +390,7 @@ class OceanBookingServiceTest {
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(row));
when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success(
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", "深圳中转仓", "DOMESTIC", "ACTIVE"))));
701L, 0L, "SZX", null, "深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON")));
when(partners.active(any(), eq(0L), eq(71L))).thenReturn(ApiResponse.success(
@@ -468,7 +468,7 @@ class ContainerServiceTest {
String type,
String name) {
return ApiResponse.success(new WarehouseMasterDataClient.Detail(
new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", name, type, "ACTIVE")));
new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", null, name, type, "ACTIVE")));
}
private static ContainerBookingEntity booking(String status, int version) {
@@ -30,6 +30,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@@ -315,6 +316,16 @@ class LoadingListServiceTest {
verify(loadingCompletedOutboxService).publish(10L);
}
@Test
void completedEventReadinessMustAllowPalletLabelFreeze() throws Exception {
Transactional transaction = LoadingListService.class
.getDeclaredMethod("requireCompletedEventReady", long.class)
.getAnnotation(Transactional.class);
assertThat(transaction).isNotNull();
assertThat(transaction.readOnly()).isFalse();
}
@Test
void shouldRejectAutomaticCompletionWhenPdaItemsRemain() {
when(pickRepository.allPlanItemsPicked(10L)).thenReturn(false);
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -18,21 +20,28 @@ class LoadingPalletLabelServiceTest {
LoadingPalletLabelMapper mapper = mock(LoadingPalletLabelMapper.class);
LoadingPalletLabelService service = new LoadingPalletLabelService();
ReflectionTestUtils.setField(service, "mapper", mapper);
WarehouseMasterDataClient warehouseClient = mock(WarehouseMasterDataClient.class);
ReflectionTestUtils.setField(service, "warehouseClient", warehouseClient);
ReflectionTestUtils.setField(service, "internalToken", "test-token");
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; });
when(warehouseClient.active("test-token", 0L, 101L)).thenReturn(warehouse("LAX9", "154"));
when(warehouseClient.active("test-token", 0L, 102L)).thenReturn(warehouse("ONT8", "CADF"));
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(manifest.groups()).extracting(group -> group.destinationWarehouseInternalCode())
.containsExactly("154", "CADF");
assertThat(saved).extracting(LoadingPalletLabelMapper.LabelRow::getPalletCode)
.containsExactly("MSCU6639870-LAX9-001", "MSCU6639870-ONT8-001",
"MSCU6639870-ONT8-002", "MSCU6639870-ONT8-003");
.containsExactly("MSCU6639870-154-001", "MSCU6639870-CADF-001",
"MSCU6639870-CADF-002", "MSCU6639870-CADF-003");
}
private LoadingPalletLabelMapper.SourceRow source(long itemId, String destination, int pallets, String fulfillment) {
@@ -40,7 +49,13 @@ class LoadingPalletLabelServiceTest {
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.setDestinationWarehouseId("LAX9".equals(destination) ? 101L : 102L);
row.setDestinationWarehouseName(destination); row.setPalletCount(pallets);
row.setFulfillmentType(fulfillment); row.setDestinationType("FBA"); return row;
}
private ApiResponse<WarehouseMasterDataClient.Detail> warehouse(String code, String internalCode) {
return ApiResponse.success(new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
"LAX9".equals(code) ? 101L : 102L, 0L, code, internalCode, code, "FBA", "ACTIVE")));
}
}
@@ -23,7 +23,10 @@ class TerminalDrayageSqlContractTest {
if (name.equals("pendingMails")) assertThat(sql).contains("earlier.container_booking_id=m.container_booking_id", "earlier.id<m.id", "earlier.status<>'SENT'");
}
for (Path path : java.util.List.of(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"), Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) {
assertThat(Files.readString(path)).contains("CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (", "CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))");
assertThat(Files.readString(path)).contains(
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (",
"CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))",
"('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')");
}
}
@@ -38,7 +41,9 @@ class TerminalDrayageSqlContractTest {
}
String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertThat(release).contains("DROP CHECK chk_terminal_drayage_times",
"ADD CONSTRAINT chk_terminal_drayage_times CHECK(");
"ADD CONSTRAINT chk_terminal_drayage_times CHECK(",
"DROP CHECK chk_terminal_drayage_history_action",
"ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN");
}
@Test