优化
This commit is contained in:
+1
-1
@@ -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 地址内部编号、三位序号生成冻结标签;内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。支持全部或按仓指定张数输出 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 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为包含 141 等内部编号的完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。
|
||||
|
||||
| 目标 | 入口 |
|
||||
|---|---|
|
||||
|
||||
@@ -624,8 +624,7 @@ public class SmartLoadingService {
|
||||
}
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
InboundOrderEntity lockedInbound = repository.inboundForUpdate(request.customerId(), inboundId);
|
||||
if (lockedInbound == null || !Set.of("PREPARING", "WAIT_MEASURE", "WAIT_SPLIT")
|
||||
.contains(lockedInbound.getProcessStatus())) {
|
||||
if (lockedInbound == null || !SplitPreloadService.supportsPreAllocation(lockedInbound.getProcessStatus())) {
|
||||
throw conflict("订单当前状态不能加入装柜清单");
|
||||
}
|
||||
if (!repository.activeItemsByInbound(request.customerId(), inboundId).isEmpty()) {
|
||||
@@ -1170,8 +1169,13 @@ public class SmartLoadingService {
|
||||
if(repository.remove(planId,item.getId(),item.getVersion(),operatorId,operatorName)!=1)
|
||||
throw conflict("整托装柜明细已被并发修改");
|
||||
}
|
||||
LoadingPlanItemEntity first=palletItems.get(0);
|
||||
loadingModelBridge.removeIfUnallocated(planId,first.getAccountId(),first.getInboundId());
|
||||
Set<String> releasedInbounds = new HashSet<>();
|
||||
for (LoadingPlanItemEntity item : palletItems) {
|
||||
String key = item.getAccountId() + "|" + item.getInboundId();
|
||||
if (releasedInbounds.add(key)) {
|
||||
loadingModelBridge.removeIfUnallocated(planId, item.getAccountId(), item.getInboundId());
|
||||
}
|
||||
}
|
||||
history(planId,null,"REMOVE_PALLET",plan.getPlanStatus(),plan.getPlanStatus(),operation,
|
||||
json(values("palletId",palletId,"version",request.expectedPlanVersion())),
|
||||
json(values("requestHash",requestHash,"removedLineCount",palletItems.size())),operatorId,operatorName);
|
||||
|
||||
@@ -27,6 +27,9 @@ import java.util.Set;
|
||||
@Service
|
||||
public class SplitPreloadService {
|
||||
private static final String SMART_SPLIT = "SMART_SPLIT";
|
||||
private static final Set<String> PRE_ALLOCATION_STATES = Set.of(
|
||||
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
||||
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT");
|
||||
|
||||
@Resource private SplitRepository repository;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@@ -72,12 +75,12 @@ public class SplitPreloadService {
|
||||
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, false);
|
||||
}
|
||||
|
||||
/** Creates a provisional preload lifecycle for an order selected before review/measurement. */
|
||||
/** Creates a provisional preload lifecycle for an order selected before it reaches WAIT_SPLIT. */
|
||||
@Transactional
|
||||
public void ensurePendingForPreAllocation(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName) {
|
||||
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
|
||||
if (!Set.of("PREPARING", "WAIT_MEASURE", "WAIT_SPLIT").contains(inbound.processStatus())) {
|
||||
if (!supportsPreAllocation(inbound.processStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "订单当前状态不能加入装柜清单");
|
||||
}
|
||||
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
|
||||
@@ -89,7 +92,7 @@ public class SplitPreloadService {
|
||||
}
|
||||
plan = new PreloadPlanEntity();
|
||||
plan.setTenantId(tenantId); plan.setInboundId(inboundId); plan.setStatus(PreloadStatus.PENDING);
|
||||
plan.setVersion(0); plan.setItemsJson("[]"); plan.setRemark("订单审核前加入装柜清单自动建立");
|
||||
plan.setVersion(0); plan.setItemsJson("[]"); plan.setRemark("智能装柜提前分配自动建立");
|
||||
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName);
|
||||
plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName);
|
||||
try {
|
||||
@@ -102,6 +105,10 @@ public class SplitPreloadService {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean supportsPreAllocation(String processStatus) {
|
||||
return PRE_ALLOCATION_STATES.contains(processStatus);
|
||||
}
|
||||
|
||||
/** Reuses the confirmed preload lifecycle while a running PDA task owns the mutation lock. */
|
||||
@Transactional
|
||||
public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId,
|
||||
|
||||
@@ -15,6 +15,38 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class LoadingPalletLabelServiceTest {
|
||||
@Test
|
||||
void printsAllAndWarehouseSubsetThroughSharedFbaRendererWithoutRegeneratingCodes() {
|
||||
var service = org.mockito.Mockito.spy(new LoadingPalletLabelService());
|
||||
var print = mock(com.baiyue.firstleg.client.PrintPdfClient.class);
|
||||
ReflectionTestUtils.setField(service, "printClient", print);
|
||||
ReflectionTestUtils.setField(service, "internalToken", "test-token");
|
||||
var label1 = new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel(1L, 1L, 1L, 1L,
|
||||
"IN-1", "MSCU6639870", "SCK8", "141", "FBA destination", 1, "MSCU6639870-141-001");
|
||||
var label2 = new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel(2L, 1L, 1L, 1L,
|
||||
"IN-1", "MSCU6639870", "SCK8", "141", "FBA destination", 2, "MSCU6639870-141-002");
|
||||
var manifest = new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest(10L, "LP-1", "MSCU6639870", 2,
|
||||
List.of(new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelGroup("SCK8", "141", "FBA destination", 2, List.of(label1, label2))));
|
||||
org.mockito.Mockito.doReturn(manifest).when(service).prepare(10L, 20L);
|
||||
when(print.generateFbaPalletLabels(any(), any(), any())).thenReturn(org.springframework.http.ResponseEntity.ok()
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_PDF).body("%PDF-test".getBytes()));
|
||||
service.print(10L, 20L, null, "request-1");
|
||||
service.print(10L, 20L, new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelPrintRequest("SCK8", 1), "request-2");
|
||||
var captor = org.mockito.ArgumentCaptor.forClass(com.baiyue.firstleg.client.PrintPdfClient.FbaPalletRequest.class);
|
||||
org.mockito.Mockito.verify(print, org.mockito.Mockito.times(2)).generateFbaPalletLabels(any(), any(), captor.capture());
|
||||
var requests = captor.getAllValues();
|
||||
assertThat(requests.get(0).templateCode()).isEqualTo("FBA_PALLET_A4_LANDSCAPE_V1");
|
||||
assertThat(requests.get(0).pageSpec().widthMm()).isEqualByComparingTo("297");
|
||||
assertThat(requests.get(0).pageSpec().heightMm()).isEqualByComparingTo("210");
|
||||
assertThat(requests.get(0).labels()).extracting(com.baiyue.firstleg.client.PrintPdfClient.FbaPalletLabel::palletCode)
|
||||
.containsExactly("MSCU6639870-141-001", "MSCU6639870-141-002");
|
||||
assertThat(requests.get(1).labels()).singleElement().satisfies(label -> {
|
||||
assertThat(label.fbaWarehouseCode()).isEqualTo("SCK8");
|
||||
assertThat(label.palletCode()).isEqualTo("MSCU6639870-141-001");
|
||||
});
|
||||
org.mockito.Mockito.verify(print, org.mockito.Mockito.never()).generateInboundMarks(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatesStableIndexesPerContainerAndFbaAndSkipsDropship() {
|
||||
LoadingPalletLabelMapper mapper = mock(LoadingPalletLabelMapper.class);
|
||||
|
||||
@@ -495,7 +495,7 @@ class SmartLoadingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void manuallyAssignsCrossChannelWaitMeasureInboundUsingDeclaredFacts() {
|
||||
void manuallyAssignsCrossChannelMeasurePendingInboundUsingDeclaredFacts() {
|
||||
long customerId = 9L;
|
||||
long inboundId = 10L;
|
||||
long orderId = 30L;
|
||||
@@ -506,7 +506,7 @@ class SmartLoadingServiceTest {
|
||||
projection.setInboundCode("I10");
|
||||
projection.setWarehouseId(100L);
|
||||
projection.setChannelCode("SLOW");
|
||||
projection.setProcessStatus("WAIT_MEASURE");
|
||||
projection.setProcessStatus("MEASURE_PENDING");
|
||||
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
|
||||
customerId, orderId, "O30", "I10", customerId, "客户", 100L, "义乌仓",
|
||||
null, null, null, "FIRST_LEG", "SLOW", "慢船", null, null, null,
|
||||
@@ -1046,6 +1046,56 @@ class SmartLoadingServiceTest {
|
||||
verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(19L), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void removingSharedPalletChecksEveryInboundOnceAfterAllLinesAreRemoved() {
|
||||
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
|
||||
LoadingPlanItemEntity sameInbound = item(9L, 9L, 21L, 2);
|
||||
LoadingPlanItemEntity secondInbound = item(10L, 9L, 22L, 3);
|
||||
secondInbound.setInboundId(11L);
|
||||
LoadingPlanItemEntity otherCustomer = item(11L, 19L, 23L, 1);
|
||||
List<LoadingPlanItemEntity> members = List.of(first, sameInbound, secondInbound, otherCustomer);
|
||||
members.forEach(value -> value.setPalletId(801L));
|
||||
when(repository.plan(1L)).thenReturn(plan("EDITING", 2), plan("EDITING", 3));
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.activeItemsByPallet(801L)).thenReturn(members);
|
||||
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
|
||||
when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1);
|
||||
|
||||
Plan result = service.removePallet(1L, 801L,
|
||||
new com.baiyue.firstleg.dto.SmartLoadingDtos.RemovePalletRequest(2, "remove-shared-pallet"),
|
||||
7L, "u");
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
var ordered = inOrder(repository, loadingModelBridge);
|
||||
for (LoadingPlanItemEntity member : members) {
|
||||
ordered.verify(repository).remove(1L, member.getId(), 0, 7L, "u");
|
||||
}
|
||||
ordered.verify(loadingModelBridge).removeIfUnallocated(1L, 9L, 10L);
|
||||
ordered.verify(loadingModelBridge).removeIfUnallocated(1L, 9L, 11L);
|
||||
ordered.verify(loadingModelBridge).removeIfUnallocated(1L, 19L, 10L);
|
||||
verify(loadingModelBridge, times(3)).removeIfUnallocated(anyLong(), anyLong(), anyLong());
|
||||
verify(repository).touch(1L, 2, 7L, "u");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedPalletWithAnyPickedMemberCannotRemoveLinesOrLinks() {
|
||||
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
|
||||
LoadingPlanItemEntity second = item(9L, 9L, 21L, 3);
|
||||
second.setInboundId(11L);
|
||||
when(repository.plan(1L)).thenReturn(plan("LOADING", 2));
|
||||
when(repository.activeItemsByPallet(801L)).thenReturn(List.of(first, second));
|
||||
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
|
||||
new LoadingPickService.MutationSnapshot(77L, "LOADING", 5, Map.of(9L, 3)));
|
||||
|
||||
assertThatThrownBy(() -> service.removePallet(1L, 801L,
|
||||
new com.baiyue.firstleg.dto.SmartLoadingDtos.RemovePalletRequest(2, "keep-picked-pallet"),
|
||||
7L, "u")).isInstanceOf(BizException.class).hasMessageContaining("已发生实物拣货");
|
||||
|
||||
verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), anyString());
|
||||
verify(loadingPickService, never()).remove(anyLong());
|
||||
verify(loadingModelBridge, never()).removeIfUnallocated(anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void removesAllUnpickedItemsWithOnePlanVersionChange() {
|
||||
LoadingPlanEntity initial = plan("EDITING", 2);
|
||||
|
||||
+18
-4
@@ -10,6 +10,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
@@ -69,9 +71,11 @@ class SplitPreloadServiceTest {
|
||||
assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜分配自动建立");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingReviewPreAllocationCreatesAProvisionalPreloadLifecycle() {
|
||||
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "PREPARING", 0));
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
||||
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT"})
|
||||
void preAllocationCreatesAProvisionalPreloadLifecycleForEveryCandidateState(String processStatus) {
|
||||
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, processStatus, 0));
|
||||
when(repository.findPlan(10, 20)).thenReturn(null);
|
||||
|
||||
service.ensurePendingForPreAllocation(10, 20, 7, "admin");
|
||||
@@ -80,7 +84,17 @@ class SplitPreloadServiceTest {
|
||||
org.mockito.ArgumentCaptor.forClass(PreloadPlanEntity.class);
|
||||
verify(repository).createPlan(captor.capture());
|
||||
assertThat(captor.getValue().getStatus()).isEqualTo("PENDING");
|
||||
assertThat(captor.getValue().getRemark()).contains("审核前");
|
||||
assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜提前分配自动建立");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preAllocationRejectsPalletizingState() {
|
||||
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_PALLETIZE", 0));
|
||||
|
||||
assertThatThrownBy(() -> service.ensurePendingForPreAllocation(10, 20, 7, "admin"))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
|
||||
.hasMessage("订单当前状态不能加入装柜清单");
|
||||
verify(repository, never()).createPlan(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
-1
@@ -4,13 +4,14 @@
|
||||
|
||||
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
|
||||
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
|
||||
- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`DROPSHIP_PALLET_A4_V2`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(后三者均为 `210mm x 297mm`);一件代发托盘模板采用黑白打印版,每托固定一页,展示托盘 Code 128 一维码、目的仓、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示作业状态、重量或体积。新尺寸或布局必须使用新模板编码。
|
||||
- 当前支持 `INBOUND_MARK_V1`(100×100 mm)、`FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm),以及纵向 A4 的 `DROPSHIP_PALLET_A4_V2`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1`、`BILL_OF_LADING_CONFIRMATION_A4_V1`、`DISPATCH_BILL_OF_LADING_A4_V2`。FBA 模板由国内和海外统一使用,每托一页,只展示 SCK8 等 FBA 仓码、完整托盘号的 Code 128 条码和完整托盘号;141 等内部编号保留在托盘号中。一件代发每托一页,展示托盘 Code 128、目的仓、最多 10 行入仓码/客户昵称和最多 34 行 SKU,不展示状态、重量或体积。新尺寸或布局必须使用新模板编码。
|
||||
|
||||
## 快速索引
|
||||
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 调用方接入指南 | `README.md` |
|
||||
| 国内/海外统一 FBA 托盘标 | `dto/FbaPalletPdfDtos.java`、`service/FbaPalletPdfService.java`、`renderer/FbaPalletRenderer.java`;内部 `/internal/print/v1/pdfs/fba-pallet-labels`,回归 `src/test/java/com/baiyue/print/service/FbaPalletPdfServiceTest.java` |
|
||||
| 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` |
|
||||
| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
|
||||
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/dropship-pallet-a4-documents`、`/internal/print/v1/pdfs/freight-a4-documents`) |
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
- 服务发现名:`print-service`。
|
||||
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
|
||||
- FBA 亚马逊托盘标:`POST /internal/print/v1/pdfs/fba-pallet-labels`;国内散货打印与海外 FBA 单托补打统一使用 `FBA_PALLET_A4_LANDSCAPE_V1`。每托一页 297×210 mm 横向 A4,仅展示 FBA 仓码、完整托盘号的 Code 128 条码和完整托盘号文字。
|
||||
- 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V2`,采用黑白打印版,每托一页 A4,包含托盘 Code 128 一维码、目的仓、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示托盘序号、作业状态、重量或体积。
|
||||
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
|
||||
- 海外仓排车 BOL 内部地址:`POST /internal/print/v1/pdfs/dispatch-bol-documents`;模板固定为 `DISPATCH_BILL_OF_LADING_A4_V2`,每个 document 对应一张 A4、包含 1-26 行明细。调用方首张按 18 行、续页按 26 行稳定分页;同页连续相同柜号和托盘号由 Print 纵向合并显示。
|
||||
@@ -20,6 +21,21 @@
|
||||
|
||||
## 2. 请求契约
|
||||
|
||||
### FBA 亚马逊托盘标
|
||||
|
||||
```json
|
||||
{
|
||||
"templateCode": "FBA_PALLET_A4_LANDSCAPE_V1",
|
||||
"pageSpec": { "widthMm": 297, "heightMm": 210 },
|
||||
"downloadFileName": "FBA托盘标.pdf",
|
||||
"labels": [{ "fbaWarehouseCode": "SCK8", "palletCode": "MSCU6639870-141-001" }]
|
||||
}
|
||||
```
|
||||
|
||||
两个业务字段均必填:`fbaWarehouseCode` 为正式 FBA 仓码(1–32 位大写字母、数字或连字符,首位不得为连字符);`palletCode` 为已保存的完整托盘业务编码(1–80 位可打印 ASCII,无首尾空格),不另传条码内容以免文字与条码不一致。`141` 等地址内部编号保留在托盘号中,不取代顶部的 `SCK8`。调用方不重新编号,Print 不裁切、不补号、不反查仓库;缺失或不合法数据返回 `A0422`。每个请求支持 1–2000 张,沿用全局请求体、输出大小及并发上限。文件名必填,最多 120 字符,以 `.pdf` 结尾且无路径、保留字符或控制字符。
|
||||
|
||||
Firstleg 从冻结标签的 `destinationWarehouseCode/palletCode` 映射;海外资源服务从当前托盘的 `fbaWarehouseCode/palletCode` 映射。两个入口共用本接口、模板和校验;非 FBA 托盘与头程箱唛使用各自模板,不由 FBA 模板推断类型。
|
||||
|
||||
### 2.1 入仓标签
|
||||
|
||||
```http
|
||||
|
||||
@@ -23,9 +23,9 @@ public class FbaPalletRenderer {
|
||||
PdfUnits units = new PdfUnits(210);
|
||||
try (PDPageContentStream stream = new PDPageContentStream(pdf, page)) {
|
||||
stream.setNonStrokingColor(0, 0, 0);
|
||||
centered(stream, units, font, label.fbaWarehouseCode(), 96, 49);
|
||||
barcodeRenderer.draw(stream, barcode, PdfUnits.mmToPt(33.5), units.topDownMmToPdfY(140),
|
||||
PdfUnits.mmToPt(230), PdfUnits.mmToPt(55));
|
||||
centered(stream, units, font, label.fbaWarehouseCode(), 280, 60);
|
||||
barcodeRenderer.draw(stream, barcode, PdfUnits.mmToPt(33.5), units.topDownMmToPdfY(146),
|
||||
PdfUnits.mmToPt(230), PdfUnits.mmToPt(30));
|
||||
centered(stream, units, font, label.palletCode(), 64, 174);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,29 @@ info:
|
||||
version: 1.0.0
|
||||
description: Internal stateless PDF rendering API. This path must not be exposed through Gateway.
|
||||
paths:
|
||||
/internal/print/v1/pdfs/fba-pallet-labels:
|
||||
post:
|
||||
summary: Generate shared domestic and overseas FBA pallet labels as landscape A4 pages
|
||||
operationId: generateFbaPalletLabels
|
||||
description: One label per page in input order. The destination is the FBA code such as SCK8; the stored pallet code (including internal warehouse number such as 141) is printed verbatim and encoded as Code 128. No business lookup or new numbering.
|
||||
security: [{ internalServiceToken: [] }]
|
||||
parameters: [{ $ref: '#/components/parameters/RequestId' }]
|
||||
requestBody:
|
||||
required: true
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/FbaPalletPdfRequest' } } }
|
||||
responses:
|
||||
'200':
|
||||
description: PDF generation succeeded, or a business error was returned as ApiResponse JSON.
|
||||
headers:
|
||||
Content-Disposition: { schema: { type: string } }
|
||||
Cache-Control: { schema: { type: string, example: no-store } }
|
||||
X-Print-Page-Count: { schema: { type: integer, minimum: 1, maximum: 2000 } }
|
||||
content:
|
||||
application/pdf: { schema: { type: string, format: binary } }
|
||||
application/json: { schema: { $ref: '#/components/schemas/ApiError' } }
|
||||
'413':
|
||||
description: Request body exceeds the configured limit.
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } }
|
||||
/internal/print/v1/pdfs/inbound-marks:
|
||||
post:
|
||||
summary: Generate an inbound-mark PDF
|
||||
@@ -153,6 +176,28 @@ components:
|
||||
schema:
|
||||
type: string
|
||||
schemas:
|
||||
FbaPalletPdfRequest:
|
||||
type: object
|
||||
required: [templateCode, pageSpec, downloadFileName, labels]
|
||||
properties:
|
||||
templateCode: { type: string, enum: [FBA_PALLET_A4_LANDSCAPE_V1] }
|
||||
pageSpec:
|
||||
type: object
|
||||
required: [widthMm, heightMm]
|
||||
properties:
|
||||
widthMm: { type: number, enum: [297] }
|
||||
heightMm: { type: number, enum: [210] }
|
||||
downloadFileName: { type: string, minLength: 4, maxLength: 120, description: PDF filename without path separators or control characters }
|
||||
labels:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 2000
|
||||
items:
|
||||
type: object
|
||||
required: [fbaWarehouseCode, palletCode]
|
||||
properties:
|
||||
fbaWarehouseCode: { type: string, pattern: '^[A-Z0-9][A-Z0-9-]{0,31}$', example: SCK8 }
|
||||
palletCode: { type: string, minLength: 1, maxLength: 80, description: Printable ASCII with no surrounding spaces; unchanged text and Code 128 payload, example: MSCU6639870-141-001 }
|
||||
InboundMarkPdfRequest:
|
||||
type: object
|
||||
required: [templateCode, pageSpec, labels]
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.baiyue.print.service.FreightA4PdfService;
|
||||
import com.baiyue.print.service.DispatchBolPdfService;
|
||||
import com.baiyue.print.service.DropshipPalletA4PdfService;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import com.baiyue.print.service.FbaPalletPdfService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -49,6 +50,8 @@ class InternalPdfControllerTest {
|
||||
private DispatchBolPdfService dispatchBolPdfService;
|
||||
@MockBean
|
||||
private DropshipPalletA4PdfService dropshipPalletA4PdfService;
|
||||
@MockBean
|
||||
private FbaPalletPdfService fbaPalletPdfService;
|
||||
@Resource
|
||||
private InternalPdfController controller;
|
||||
private MockMvc mockMvc;
|
||||
@@ -60,6 +63,24 @@ class InternalPdfControllerTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void protectsFbaLabelsAndReturnsPdfHeaders() throws Exception {
|
||||
String json = """
|
||||
{"templateCode":"FBA_PALLET_A4_LANDSCAPE_V1","pageSpec":{"widthMm":297,"heightMm":210},
|
||||
"downloadFileName":"fba.pdf","labels":[{"fbaWarehouseCode":"SCK8","palletCode":"MSCU6639870-141-001"}]}
|
||||
""";
|
||||
mockMvc.perform(post("/internal/print/v1/pdfs/fba-pallet-labels").contentType(MediaType.APPLICATION_JSON).content(json))
|
||||
.andExpect(jsonPath("$.code").value("A0403"));
|
||||
verify(fbaPalletPdfService, never()).generate(any());
|
||||
byte[] pdf = "%PDF-fba".getBytes(StandardCharsets.US_ASCII);
|
||||
when(fbaPalletPdfService.generate(any())).thenReturn(new FbaPalletPdfService.GeneratedPdf(pdf, "fba.pdf", 1));
|
||||
mockMvc.perform(post("/internal/print/v1/pdfs/fba-pallet-labels").header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(json))
|
||||
.andExpect(status().isOk()).andExpect(content().contentType(MediaType.APPLICATION_PDF))
|
||||
.andExpect(content().bytes(pdf)).andExpect(header().string("X-Print-Page-Count", "1"))
|
||||
.andExpect(header().string("Cache-Control", "no-store"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsPdfWithDownloadHeaders() throws Exception {
|
||||
byte[] pdf = "%PDF-test".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
@@ -52,6 +52,16 @@ class PrintRequestSizeFilterTest {
|
||||
assertTooLarge(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void capsFbaPalletRequestsBeforeDeserialization() throws Exception {
|
||||
var response = new MockHttpServletResponse();
|
||||
AtomicReference<Boolean> called = new AtomicReference<>(false);
|
||||
filter.doFilter(request("12345678901", true, "/internal/print/v1/pdfs/fba-pallet-labels"), response,
|
||||
(ignoredRequest, ignoredResponse) -> called.set(true));
|
||||
assertThat(called).hasValue(false);
|
||||
assertTooLarge(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest() {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.baiyue.print.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.print.config.PrintPdfProperties;
|
||||
import com.baiyue.print.dto.FbaPalletPdfDtos;
|
||||
import com.baiyue.print.renderer.FbaPalletRenderer;
|
||||
import com.baiyue.print.renderer.PdfBarcodeRenderer;
|
||||
import com.baiyue.print.renderer.PdfUnits;
|
||||
import com.google.zxing.*;
|
||||
import com.google.zxing.common.HybridBinarizer;
|
||||
import jakarta.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
@SpringJUnitConfig(classes = {FbaPalletPdfService.class, PrintPdfProperties.class,
|
||||
PdfGenerationLimiter.class, FbaPalletRenderer.class, PdfBarcodeRenderer.class})
|
||||
class FbaPalletPdfServiceTest {
|
||||
@Resource private FbaPalletPdfService service;
|
||||
|
||||
@Test
|
||||
void rendersLandscapePagesWithOnlyFbaCodeAndUnchangedDecodablePalletIdentity() throws Exception {
|
||||
var labels = List.of(new FbaPalletPdfDtos.Label("SCK8", "MSCU6639870-141-001"),
|
||||
new FbaPalletPdfDtos.Label("ONT8", "MSCU6639870-CADF-002"),
|
||||
new FbaPalletPdfDtos.Label("SCK8", "A".repeat(80)));
|
||||
var result = service.generate(request(labels));
|
||||
assertThat(result.pageCount()).isEqualTo(3);
|
||||
assertThat(result.fileName()).isEqualTo("FBA托盘标.pdf");
|
||||
try (var pdf = Loader.loadPDF(result.content())) {
|
||||
assertThat(pdf.getNumberOfPages()).isEqualTo(labels.size());
|
||||
var renderer = new PDFRenderer(pdf);
|
||||
for (int i = 0; i < labels.size(); i++) {
|
||||
var page = pdf.getPage(i);
|
||||
assertThat(page.getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(297), within(.002f));
|
||||
assertThat(page.getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(210), within(.002f));
|
||||
assertThat(page.getRotation()).isZero();
|
||||
var stripper = new PDFTextStripper();
|
||||
stripper.setStartPage(i + 1); stripper.setEndPage(i + 1);
|
||||
assertThat(stripper.getText(pdf).strip().lines().toList())
|
||||
.containsExactly(labels.get(i).fbaWarehouseCode(), labels.get(i).palletCode());
|
||||
for (var name : page.getResources().getFontNames()) {
|
||||
assertThat(page.getResources().getFont(name).isEmbedded()).isTrue();
|
||||
}
|
||||
var image = renderer.renderImageWithDPI(i, 300);
|
||||
var source = new RGBLuminanceSource(image.getWidth(), image.getHeight(),
|
||||
image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth()));
|
||||
var decoded = new MultiFormatReader().decode(new BinaryBitmap(new HybridBinarizer(source)),
|
||||
Map.of(DecodeHintType.POSSIBLE_FORMATS, List.of(BarcodeFormat.CODE_128)));
|
||||
assertThat(decoded.getText()).isEqualTo(labels.get(i).palletCode());
|
||||
assertThat(decoded.getBarcodeFormat()).isEqualTo(BarcodeFormat.CODE_128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidIdentifiersWithoutChangingThem() {
|
||||
for (String code : new String[] {"", "托盘001", "A".repeat(81), " PALLET", "PALLET\n"}) {
|
||||
assertThatThrownBy(() -> service.generate(request(List.of(new FbaPalletPdfDtos.Label("SCK8", code)))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("palletCode");
|
||||
}
|
||||
assertThatThrownBy(() -> service.generate(request(List.of(new FbaPalletPdfDtos.Label(null, "MSCU6639870-141-001")))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("fbaWarehouseCode");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPortraitWrongTemplateEmptyBatchAndUnsafeFilename() {
|
||||
var valid = request(List.of(new FbaPalletPdfDtos.Label("SCK8", "MSCU6639870-141-001")));
|
||||
assertThatThrownBy(() -> service.generate(new FbaPalletPdfDtos.Request(valid.templateCode(),
|
||||
new FbaPalletPdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), valid.downloadFileName(), valid.labels())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("landscape A4");
|
||||
assertThatThrownBy(() -> service.generate(new FbaPalletPdfDtos.Request("INBOUND_MARK_V1", valid.pageSpec(), valid.downloadFileName(), valid.labels())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("templateCode");
|
||||
assertThatThrownBy(() -> service.generate(request(List.of())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("labels count");
|
||||
assertThatThrownBy(() -> service.generate(new FbaPalletPdfDtos.Request(valid.templateCode(), valid.pageSpec(), "../label.pdf", valid.labels())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("downloadFileName");
|
||||
}
|
||||
|
||||
private FbaPalletPdfDtos.Request request(List<FbaPalletPdfDtos.Label> labels) {
|
||||
return new FbaPalletPdfDtos.Request(FbaPalletPdfDtos.TEMPLATE,
|
||||
new FbaPalletPdfDtos.PageSpec(new BigDecimal("297"), new BigDecimal("210")), "FBA托盘标.pdf", labels);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase4-overseas-return-followups', 'manual-order-spli
|
||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
||||
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
|
||||
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
|
||||
'coload-groups', 'dropship-pallet-matrix', 'dropship-browser-fixture', 'mixed-container-receiving']
|
||||
'coload-groups', 'dropship-pallet-matrix', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression']
|
||||
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
|
||||
const A0_LOCATION_PAGE_SIZE = 200
|
||||
const A0_MAX_LOCATION_MUTATIONS = 8
|
||||
@@ -42,6 +42,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1,
|
||||
}
|
||||
|
||||
const MODULE_DEPENDENCIES = new Map([
|
||||
['pda-loading-link-regression', ['merchant-qualification-fixture']],
|
||||
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
|
||||
['dropship-pallet-matrix', ['merchant-qualification-fixture']],
|
||||
['dropship-browser-fixture', ['merchant-qualification-fixture']],
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
|
||||
|
||||
`pda-loading-link-regression.mjs` 通过总入口 `--modules pda-loading-link-regression` 运行,复用 `dropship-pallet-matrix.mjs` 的国内正式订单、测量、封托、订舱、证据和完成生产链,仅执行国内关联回归。覆盖单票加入后全移除换柜完成、多票共托反复换三个旧柜后完成、逐托即时提交及同键重放,以及一单多柜中仍有实际分配的未开始柜导致 `OWNER_READY/A0409`。每次重新创建带 marker 的业务数据,通过公开 HTTP 回查旧柜有效分配数、PDA 终态和入仓状态;不写业务数据库伪造前置。零分配残留由真实整托移除链观察,报告保存 `createdResources.pdaLoadingLinkRegression`。负向柜保留为 `COMPLETION_FAILED`,成功柜保留为 `COMPLETED`,不自动清理;失败报告保留且不得算通过。使用显式测试环境文件及 `--output reports/<本轮名称>.json`,须有真实 HTTP 写入授权;不启动海外收货或出库。
|
||||
|
||||
退货专项同时核验新建退货单、退货主单、签收单、批次和自动箱唛的完整业务编号:`RT/RR/RB/RC/RETU-` 加 25 位大写 Base36 UUID 编码;不再截取内部 ID 六位尾号。幂等重放仍核对同一已保存编号,手工指定箱唛不受系统生成格式约束。编号生成器另有完整 128 位编码往返、原截断碰撞和本地并行生成单测;本地生成压测不增加 HTTP 并发。
|
||||
|
||||
退货专项只运行 `--modules phase4-overseas-return-receipt`,自动准备本轮客户;覆盖非法/重复预报、退货单号和 SKU 查询、非法/未知 SKU 实收、超收及失败不落账、分批签收完成、部分签收禁止合箱、合箱非法箱规/数量/来源、幂等重放前后库存快照一致、逐箱分页无重复遗漏、客户 SKU 数量守恒及标签。通过显式测试环境文件运行总入口,JSON 路径由 `--output` 指定;会创建隔离测试数据,finally 停用临时 PDA,不删除业务记录。失败保留报告,先核对记录再决定是否新批次重跑。
|
||||
|
||||
@@ -13,6 +13,7 @@ import { runUntilPickupReady } from './phase4-carrier-nodes.mjs'
|
||||
import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from './phase4-overseas-pda.mjs'
|
||||
import { receivingProgressMatches, outboundCandidateGuards, outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs'
|
||||
import { receiveMixedContainer } from './mixed-container-receiving.mjs'
|
||||
import { exerciseOldCabinets } from './pda-loading-link-regression.mjs'
|
||||
|
||||
const MODULE = 'dropship-pallet-matrix'
|
||||
const BASE = '/api/platform/v1/first-leg/pda-dropship'
|
||||
@@ -42,7 +43,11 @@ export async function runMixedContainer(context) {
|
||||
return runWorkflow(context, false, true)
|
||||
}
|
||||
|
||||
async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = false) {
|
||||
export async function runDomesticLinkRegression(context) {
|
||||
return runWorkflow(context, false, false, true)
|
||||
}
|
||||
|
||||
async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = false, linkRegression = false) {
|
||||
const marker = context.marker
|
||||
const savedOptions = context.options.prepareDropshipManualCount
|
||||
const savedContext = { ...context.context }
|
||||
@@ -122,7 +127,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
record.summary = inbound.summary
|
||||
record.domesticVerified = true
|
||||
}
|
||||
async function allocate(group) {
|
||||
async function allocate(group, regression = {}) {
|
||||
const label = group.map((item) => item.label).join('+')
|
||||
context.marker = `${marker}-${group[0].label}-LOAD`
|
||||
const smart = '/api/platform/v1/first-leg/smart-loading'
|
||||
@@ -141,6 +146,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
check(`${record.label}初始费用已计算`, null, Boolean(fee))
|
||||
}
|
||||
const summary = group[0].summary
|
||||
if (regression.rounds) await exerciseOldCabinets({ context, group, pallets, command, check, rounds: regression.rounds })
|
||||
let cabinet = await command('创建本场景模拟柜', 'POST', '/api/platform/v1/containers', {
|
||||
channelGroupCode: channelGroup(summary.channelCode), warehouseId: String(summary.warehouseId),
|
||||
containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500,
|
||||
@@ -205,6 +211,13 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
await conserve('智能推荐后')
|
||||
}
|
||||
for (const record of group) record.loading = { cabinet, plan, allocationVerified: true }
|
||||
if (regression.activeUnstarted) {
|
||||
const pallet = pallets.at(-1)
|
||||
plan = await command('分配另一实托到未开始柜前移除', 'DELETE', `${path}/pallet-assignments/${pallet.palletId}`, {
|
||||
expectedPlanVersion: Number(plan.version), operationId: `${context.marker}-split-remove`,
|
||||
})
|
||||
await exerciseOldCabinets({ context, group, pallets: [pallet], command, check, rounds: 1, active: true })
|
||||
}
|
||||
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
|
||||
const agent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS)
|
||||
check(`${label}真实船司和订舱代理齐全`, null, Boolean(carrier?.id && agent?.id))
|
||||
@@ -259,8 +272,22 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
claimed.items.length > 0 && (mixedContainer
|
||||
? claimed.items.some(item => item.unitType === 'SEALED_PALLET') && claimed.items.some(item => item.unitType !== 'SEALED_PALLET')
|
||||
: claimed.items.every((item) => item.unitType === 'SEALED_PALLET')))
|
||||
const picked = await command('整托拣货', 'POST', `${pda}/batch`, batch,
|
||||
{ 'Idempotency-Key': `${context.marker}-pick` })
|
||||
let picked = claimed
|
||||
if (linkRegression) {
|
||||
for (const palletNo of [...new Set(claimed.items.map(item => item.palletNo))]) {
|
||||
const priorVersion = Number(picked.version)
|
||||
const payload = { warehouseId: String(summary.warehouseId), palletNo,
|
||||
expectedTaskVersion: priorVersion, operationId: `${context.marker}-pick-${palletNo}` }
|
||||
picked = await command('逐托即时提交', 'POST', `${pda}/pallets/pick`, payload)
|
||||
check('即时提交返回最新任务版本', null, Number(picked.version) > priorVersion)
|
||||
const replay = await command('逐托提交同键重放', 'POST', `${pda}/pallets/pick`, payload)
|
||||
check('重放不重复累加数量或版本', null,
|
||||
replay.version === picked.version && JSON.stringify(replay.items) === JSON.stringify(picked.items))
|
||||
}
|
||||
} else {
|
||||
picked = await command('整托拣货', 'POST', `${pda}/batch`, batch,
|
||||
{ 'Idempotency-Key': `${context.marker}-pick` })
|
||||
}
|
||||
check(`${label}整托拣货等待最终确认`, null, picked.workflowStatus === 'AWAITING_REVIEW')
|
||||
for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
|
||||
const response = await uploadEvidence(context, taskId, String(summary.warehouseId), evidenceType, 0,
|
||||
@@ -276,6 +303,18 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
}, { 'Idempotency-Key': `${context.marker}-complete` })
|
||||
const terminal = await waitForCompletion(context, `${pda}?warehouseId=${summary.warehouseId}`, `${pda}/operations`)
|
||||
const finalTask = terminal.task
|
||||
if (regression.activeUnstarted) {
|
||||
check('有实际货物的未开始柜次阻止PDA完成', terminal.taskResponse,
|
||||
finalTask?.taskStatus === 'COMPLETION_FAILED'
|
||||
&& finalTask.completionCommand?.failureStage === 'OWNER_READY'
|
||||
&& finalTask.completionCommand?.failureCode === 'A0409')
|
||||
const inbound = await command('失败后回查入仓状态未完成', 'GET',
|
||||
`/api/platform/inbounds/${group[0].source.inboundId}?tenantId=${group[0].order.tenantId}`)
|
||||
check('负向完成未推进LOADED', null, inbound.summary?.processStatus === 'LOADING')
|
||||
context.createdResources.pdaLoadingLinkRegression.scenarios.push({ name: label, taskId,
|
||||
outcome: 'ACTIVE_UNSTARTED_BLOCKED', task: finalTask })
|
||||
return
|
||||
}
|
||||
check(`${label}任务真实完成`, terminal.taskResponse, finalTask?.taskStatus === 'COMPLETED',
|
||||
`status=${finalTask?.taskStatus}, attempts=${terminal.attempts}`)
|
||||
for (const record of group) {
|
||||
@@ -285,6 +324,8 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
record.loading = { cabinet: linked, plan, task: finalTask, completed, booking: released,
|
||||
allocationVerified: true, loadingVerified: true, containerNo }
|
||||
}
|
||||
if (linkRegression) context.createdResources.pdaLoadingLinkRegression.scenarios.push({
|
||||
name: label, taskId, outcome: 'COMPLETED', task: finalTask })
|
||||
}
|
||||
async function progressShipping(group) {
|
||||
const record = group[0]
|
||||
@@ -580,6 +621,21 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals
|
||||
context.createdResources.dropshipPalletMatrix.stage = 'MIXED_CONTAINER_INBOUND_COMPLETED'
|
||||
return
|
||||
}
|
||||
if (linkRegression) {
|
||||
const single = await prepare('LINK-SINGLE', 2)
|
||||
await verify(single, 2)
|
||||
await allocate([single], { rounds: 1 })
|
||||
const first = await prepare('MULTI-MERGE-LINK-A', 1, { deferSeal: true })
|
||||
const second = await prepare('MULTI-MERGE-LINK-B', 1, { targetPalletNo: first.task.pallets[0].palletNo })
|
||||
await verify(first, 1)
|
||||
await verify(second, 1)
|
||||
await allocate([first, second], { rounds: 3 })
|
||||
const negative = await prepare('LINK-NEGATIVE', 2)
|
||||
await verify(negative, 2)
|
||||
await allocate([negative], { activeUnstarted: true })
|
||||
context.createdResources.dropshipPalletMatrix.stage = 'DOMESTIC_LINK_REGRESSION_VERIFIED'
|
||||
return
|
||||
}
|
||||
if (stopBeforeOverseasPda) {
|
||||
const record = await prepare('BROWSER-MULTI', 2)
|
||||
await verify(record, 2)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { runDomesticLinkRegression } from './dropship-pallet-matrix.mjs'
|
||||
import { channelGroup } from './smart-loading-channel-group.mjs'
|
||||
|
||||
export const run = runDomesticLinkRegression
|
||||
|
||||
// All resources come from this run's HTTP producer. No direct database writes.
|
||||
export async function exerciseOldCabinets({ context, group, pallets, command, check, rounds, active = false }) {
|
||||
const summary = group[0].summary
|
||||
const evidence = context.createdResources.pdaLoadingLinkRegression ||= { cabinets: [], scenarios: [] }
|
||||
for (let round = 0; round < rounds; round++) {
|
||||
const cabinet = await command('创建回归旧柜', 'POST', '/api/platform/v1/containers', {
|
||||
channelGroupCode: channelGroup(summary.channelCode), warehouseId: String(summary.warehouseId),
|
||||
containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500,
|
||||
})
|
||||
const smart = '/api/platform/v1/first-leg/smart-loading'
|
||||
let plan = await command('读取回归旧柜计划', 'GET', `${smart}/plans/by-booking/${cabinet.id}`)
|
||||
const path = `${smart}/plans/${plan.id}`
|
||||
const record = { cabinetNo: cabinet.cabinetNo, cabinetId: cabinet.id, planId: plan.id, active, rounds: round + 1 }
|
||||
evidence.cabinets.push(record)
|
||||
for (const pallet of pallets) {
|
||||
plan = await command('旧柜加入整托', 'POST', `${path}/pallet-assignments`, {
|
||||
palletId: String(pallet.palletId), expectedVersion: Number(plan.version),
|
||||
operationId: `${context.marker}-old-add-${plan.id}-${pallet.palletId}`,
|
||||
})
|
||||
}
|
||||
check('旧柜真实加入箱数大于零', null, plan.items?.reduce((sum, i) => sum + Number(i.packageCount), 0) > 0)
|
||||
if (!active) {
|
||||
for (const pallet of pallets) {
|
||||
plan = await command('旧柜全部移除整托', 'DELETE', `${path}/pallet-assignments/${pallet.palletId}`, {
|
||||
expectedPlanVersion: Number(plan.version), operationId: `${context.marker}-old-del-${plan.id}-${pallet.palletId}`,
|
||||
})
|
||||
}
|
||||
check('旧柜移除后没有有效明细', null, plan.items?.length === 0)
|
||||
}
|
||||
record.links = []
|
||||
for (const item of group) {
|
||||
const links = await command('回查旧柜关联及有效箱数', 'GET',
|
||||
`/api/platform/v1/inbounds/${item.source.inboundId}/preload/loading-plans?tenantId=${item.order.tenantId}`)
|
||||
const current = links.filter(link => String(link.loadingPlanId) === String(plan.id))
|
||||
record.links.push(...current)
|
||||
check('旧柜关联数量与真实分配一致', null, active
|
||||
? current.some(link => Number(link.allocatedPackageCount) > 0)
|
||||
: current.every(link => Number(link.allocatedPackageCount) === 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` |
|
||||
| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 |
|
||||
| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
|
||||
| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。使用公共托盘真实编码并复用 Print `INBOUND_MARK_V1`,模板仍只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` |
|
||||
| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。有 FBA 仓码的托盘与国内统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`、`FBA_PALLET_A4_LANDSCAPE_V1`:297×210 mm,顶部 SCK8 等仓码,完整托盘号作为 Code 128 与底部文字,141 等内部编号原样保留;其他托盘使用 `INBOUND_MARK_V1`。模板只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` |
|
||||
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
||||
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |
|
||||
| 测试 | `src/test/java/com/baiyue/warehouse/` |
|
||||
|
||||
@@ -7,7 +7,7 @@ paths:
|
||||
get:
|
||||
operationId: printOverseasPalletLabel
|
||||
summary: 下载指定托盘的一页标签 PDF
|
||||
description: 平台公共资源,要求 platform:overseas-warehouse:pallet:list。校验仓库和托盘归属后复用 Print 的 INBOUND_MARK_V1(100×100mm),原样使用托盘编码作为二维码,不创建或修改托盘。当前模板要求编码为 1–34 位可打印 ASCII 字符。
|
||||
description: 平台公共资源,要求 platform:overseas-warehouse:pallet:list。校验仓库和托盘归属后,有 FBA 仓码的托盘统一调用 Print FBA_PALLET_A4_LANDSCAPE_V1(297×210mm),顶部显示 SCK8 等 FBA 仓码,托盘完整编码同时作为 Code 128 内容和底部文字;内部编号如 141 原样保留。FBA 编码限 1–80 位可打印 ASCII,禁止首尾空格。不创建或修改托盘。其他托盘继续使用其现有 INBOUND_MARK_V1(100×100mm,编码限 1–34 位)。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
|
||||
+27
-16
@@ -30,34 +30,33 @@ class PalletLabelPrintServiceTest {
|
||||
ReflectionTestUtils.setField(service, "warehouses", warehouses);
|
||||
ReflectionTestUtils.setField(service, "print", print);
|
||||
ReflectionTestUtils.setField(service, "internalToken", "test-token");
|
||||
pallet.setPalletCode("ABCD1234567-ABE3-003");
|
||||
pallet.setFbaWarehouseCode("ABE3");
|
||||
pallet.setPalletCode("MSCU6639870-141-001");
|
||||
pallet.setFbaWarehouseCode("SCK8");
|
||||
when(resources.pallet(0L, 200L, 9L)).thenReturn(pallet);
|
||||
when(warehouses.required(200L)).thenReturn(new SharedWarehouseReference(200L, "LAX", "洛杉矶仓", "ACTIVE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reusesExistingRendererWithExactlyTheSelectedCode() {
|
||||
void usesSharedFbaRendererWithExactlyTheSelectedCode() {
|
||||
byte[] pdf = "%PDF-label".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
when(print.generateInboundMarks(anyString(), anyString(), any()))
|
||||
when(print.generateFbaPalletLabels(anyString(), anyString(), any()))
|
||||
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(pdf));
|
||||
|
||||
var response = service.print(200L, 9L, "request-1");
|
||||
|
||||
var request = ArgumentCaptor.forClass(PrintPdfClient.Request.class);
|
||||
verify(print).generateInboundMarks(eq("test-token"), eq("request-1"), request.capture());
|
||||
assertThat(request.getValue().templateCode()).isEqualTo("INBOUND_MARK_V1");
|
||||
assertThat(request.getValue().pageSpec().widthMm()).isEqualByComparingTo("100");
|
||||
assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("100");
|
||||
var request = ArgumentCaptor.forClass(PrintPdfClient.FbaPalletRequest.class);
|
||||
verify(print).generateFbaPalletLabels(eq("test-token"), eq("request-1"), request.capture());
|
||||
assertThat(request.getValue().templateCode()).isEqualTo("FBA_PALLET_A4_LANDSCAPE_V1");
|
||||
assertThat(request.getValue().pageSpec().widthMm()).isEqualByComparingTo("297");
|
||||
assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("210");
|
||||
assertThat(request.getValue().labels()).singleElement().satisfies(label -> {
|
||||
assertThat(label.topCode()).isEqualTo("ABCD1234567-ABE3-003");
|
||||
assertThat(label.qrContent()).isEqualTo(label.topCode());
|
||||
assertThat(label.warehouseText()).isEqualTo("ABE3");
|
||||
assertThat(label.routeText()).isEqualTo("洛杉矶仓");
|
||||
assertThat(label.palletCode()).isEqualTo("MSCU6639870-141-001");
|
||||
assertThat(label.fbaWarehouseCode()).isEqualTo("SCK8");
|
||||
});
|
||||
assertThat(response.getBody()).isEqualTo(pdf);
|
||||
verify(resources).pallet(0L, 200L, 9L);
|
||||
verifyNoMoreInteractions(resources);
|
||||
verify(print, never()).generateInboundMarks(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -69,7 +68,7 @@ class PalletLabelPrintServiceTest {
|
||||
|
||||
@Test
|
||||
void mapsCircuitBreakerWrappedPrintFailureToServiceUnavailable() {
|
||||
when(print.generateInboundMarks(anyString(), anyString(), any()))
|
||||
when(print.generateFbaPalletLabels(anyString(), anyString(), any()))
|
||||
.thenThrow(new NoFallbackAvailableException("No fallback available.",
|
||||
new IllegalStateException("No instances available for print-service")));
|
||||
|
||||
@@ -82,7 +81,7 @@ class PalletLabelPrintServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsJsonErrorInsteadOfDownloadingItAsPdf() {
|
||||
when(print.generateInboundMarks(anyString(), anyString(), any()))
|
||||
when(print.generateFbaPalletLabels(anyString(), anyString(), any()))
|
||||
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body("{}".getBytes()));
|
||||
assertThatThrownBy(() -> service.print(200L, 9L, "request-1"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效 PDF");
|
||||
@@ -90,11 +89,23 @@ class PalletLabelPrintServiceTest {
|
||||
|
||||
@Test
|
||||
void neverTruncatesOrSubstitutesUnsupportedPalletCodes() {
|
||||
for (String code : new String[] { "", "托盘001", "A".repeat(35) }) {
|
||||
for (String code : new String[] { "", "托盘001", "A".repeat(81), " PALLET" }) {
|
||||
pallet.setPalletCode(code);
|
||||
assertThatThrownBy(() -> service.print(200L, 9L, "request-1"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("托盘编码");
|
||||
}
|
||||
verifyNoInteractions(print);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsNonFbaLabelsOnTheirOwnTemplate() {
|
||||
pallet.setFbaWarehouseCode(null);
|
||||
when(print.generateInboundMarks(anyString(), anyString(), any()))
|
||||
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body("%PDF-other".getBytes()));
|
||||
service.print(200L, 9L, "request-1");
|
||||
var captor = ArgumentCaptor.forClass(PrintPdfClient.Request.class);
|
||||
verify(print).generateInboundMarks(anyString(), anyString(), captor.capture());
|
||||
assertThat(captor.getValue().templateCode()).isEqualTo("INBOUND_MARK_V1");
|
||||
verify(print, never()).generateFbaPalletLabels(any(), any(), any());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user