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

# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java
#	scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs
#	scripts/admin-test-env-modules/phase4-overseas-pda.mjs
This commit is contained in:
2026-09-09 03:00:48 +08:00
20 changed files with 383 additions and 290 deletions
@@ -23,6 +23,9 @@ public record InboundPlanListResponse(
Integer pickedPackageCount, Integer pickedPackageCount,
BigDecimal volumeCbm, BigDecimal volumeCbm,
Integer palletCount, Integer palletCount,
boolean assigned,
boolean mergeAllowed,
boolean urgent,
String status, String status,
String appointmentNo, String appointmentNo,
String appointmentStatus, String appointmentStatus,
@@ -44,7 +44,8 @@ public interface InboundPlanQueryMapper {
"o.customer_name customerName,l.required_package_count packageCount," + "o.customer_name customerName,l.required_package_count packageCount," +
"COALESCE((SELECT SUM(pa.picked_package_count) FROM ll_inbound_plan_pallet_allocation pa WHERE pa.line_id=l.id),0) pickedPackageCount," + "COALESCE((SELECT SUM(pa.picked_package_count) FROM ll_inbound_plan_pallet_allocation pa WHERE pa.line_id=l.id),0) pickedPackageCount," +
"l.volume_cbm volumeCbm," + "l.volume_cbm volumeCbm," +
"l.planned_pallet_count palletCount,p.status status,a.appointment_no appointmentNo," + "l.planned_pallet_count palletCount,p.assigned_flag assigned,p.merge_required mergeAllowed," +
"p.urgent_flag urgent,p.status status,a.appointment_no appointmentNo," +
"a.appointment_status appointmentStatus,a.appointment_at appointmentAt," + "a.appointment_status appointmentStatus,a.appointment_at appointmentAt," +
"a.expected_loading_at expectedLoadingAt," + "a.expected_loading_at expectedLoadingAt," +
"CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE " + "CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE " +
@@ -55,7 +55,8 @@ public final class ReceivingContainerResponses {
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages, String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages, int receivedPackages, int pendingPackages,
boolean palletMergeRequired, String palletMergeHint) { } boolean assigned, boolean mergeAllowed, boolean urgent,
String mergeHint) { }
public record CartonResolved(String cartonMark, String inboundCode, public record CartonResolved(String cartonMark, String inboundCode,
String destinationType, String overseasWarehouseId, String destinationType, String overseasWarehouseId,
@@ -63,7 +64,8 @@ public final class ReceivingContainerResponses {
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages, String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages, int receivedPackages, int pendingPackages,
boolean palletMergeRequired, String palletMergeHint) { boolean assigned, boolean mergeAllowed, boolean urgent,
String mergeHint) {
public CartonResolved(String cartonMark, String inboundCode, String destinationType, public CartonResolved(String cartonMark, String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName, String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode,
@@ -73,7 +75,7 @@ public final class ReceivingContainerResponses {
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName, this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages, fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages,
pendingPackages, false, null); pendingPackages, false, false, false, null);
} }
} }
@@ -84,12 +86,13 @@ public final class ReceivingContainerResponses {
public record Receipt(String result, String containerNo, String inboundCode, public record Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages, String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus, int receivedPackages, int pendingPackages, String sessionStatus,
boolean palletMergeRequired, String palletMergeHint) { boolean assigned, boolean mergeAllowed, boolean urgent,
String mergeHint) {
public Receipt(String result, String containerNo, String inboundCode, public Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages, String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) { int receivedPackages, int pendingPackages, String sessionStatus) {
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
receivedPackages, pendingPackages, sessionStatus, false, null); receivedPackages, pendingPackages, sessionStatus, false, false, false, null);
} }
} }
@@ -117,6 +117,12 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
@Override @Override
public WarehousePalletReference resolvePallet(String palletCode) { public WarehousePalletReference resolvePallet(String palletCode) {
WarehousePalletResponse response = lookupPallet(palletCode);
return reference(response.data());
}
@Override
public WarehousePalletReference resolveAvailablePallet(String palletCode) {
WarehousePalletResponse response = lookupPallet(palletCode); WarehousePalletResponse response = lookupPallet(palletCode);
requireReservable(response.data()); requireReservable(response.data());
return reference(response.data()); return reference(response.data());
@@ -158,7 +164,6 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
.body(WarehousePalletResponse.class); .body(WarehousePalletResponse.class);
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response"); "Warehouse pallet service returned no usable response");
requireReservable(response.data());
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(), return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(),
response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(), response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(),
response.data().unitPrice(), response.data().version()); response.data().unitPrice(), response.data().version());
@@ -14,6 +14,9 @@ public interface WarehouseResourceGateway {
default WarehousePalletReference inspectPallet(String palletCode) { default WarehousePalletReference inspectPallet(String palletCode) {
return resolvePallet(palletCode); return resolvePallet(palletCode);
} }
default WarehousePalletReference resolveAvailablePallet(String palletCode) {
return resolvePallet(palletCode);
}
default WarehousePalletReference resolveReservedPallet(String palletCode, String customerId, long warehouseId, default WarehousePalletReference resolveReservedPallet(String palletCode, String customerId, long warehouseId,
long palletId, long locationId, long resourceLockId) { long palletId, long locationId, long resourceLockId) {
throw new UnsupportedOperationException("Reserved warehouse pallet resolution is not configured"); throw new UnsupportedOperationException("Reserved warehouse pallet resolution is not configured");
@@ -61,7 +61,8 @@ import java.util.stream.Collectors;
public class ReceivingContainerService { public class ReceivingContainerService {
private static final String PHOTO_DOWNLOAD_PATH = private static final String PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download"; "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
private static final String PALLET_MERGE_HINT = "该入仓单已标记合托,请按合托要求拣货"; private static final String PALLET_MERGE_HINT =
"该入库计划可选择合托:合托时扫描同目的仓已使用的托盘码,独立收货时扫描新托盘码;不强制合托";
@Resource private InboundPlanMapper plans; @Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines; @Resource private InboundPlanLineMapper lines;
@@ -266,7 +267,7 @@ public class ReceivingContainerService {
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages(), inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages(),
inbound.palletMergeRequired(), inbound.palletMergeHint()); inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
} }
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
@@ -311,8 +312,8 @@ public class ReceivingContainerService {
int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum(); int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted, session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted,
received, Math.max(0, total - received), session.getStatus(), palletMergeRequired(ref.order()), received, Math.max(0, total - received), session.getStatus(), assigned(ref.plan()),
palletMergeHint(ref.order())); mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()));
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result; return result;
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
@@ -351,7 +352,7 @@ public class ReceivingContainerService {
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1, session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1,
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(), inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(),
inbound.palletMergeRequired(), inbound.palletMergeHint()); inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result);
return result; return result;
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
@@ -654,7 +655,7 @@ public class ReceivingContainerService {
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId()); pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
} else { } else {
try { try {
resource = warehouse.resolvePallet(palletCode.trim()); resource = warehouse.resolveAvailablePallet(palletCode.trim());
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
throw notFound("Pallet was not found"); throw notFound("Pallet was not found");
} }
@@ -728,16 +729,23 @@ public class ReceivingContainerService {
ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(), ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(),
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(), ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received), value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received),
palletMergeRequired(ref.order()), palletMergeHint(ref.order())); assigned(ref.plan()), mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()));
} }
private boolean palletMergeRequired(LastlegOrder order) { private boolean assigned(InboundPlan plan) {
return order != null && ("REQUIRED".equals(order.getPalletMergeFlag()) return plan != null && Boolean.TRUE.equals(plan.getAssignedFlag());
|| "MERGED".equals(order.getPalletMergeFlag()));
} }
private String palletMergeHint(LastlegOrder order) { private boolean mergeAllowed(InboundPlan plan) {
return palletMergeRequired(order) ? PALLET_MERGE_HINT : null; return plan != null && Boolean.TRUE.equals(plan.getMergeRequired());
}
private boolean urgent(InboundPlan plan) {
return plan != null && Boolean.TRUE.equals(plan.getUrgentFlag());
}
private String mergeHint(InboundPlan plan) {
return mergeAllowed(plan) ? PALLET_MERGE_HINT : null;
} }
private Totals totals(List<InboundPlan> containerPlans) { private Totals totals(List<InboundPlan> containerPlans) {
@@ -1669,7 +1669,7 @@ components:
timestamp: { type: string, format: date-time } timestamp: { type: string, format: date-time }
ReceivingInbound: ReceivingInbound:
type: object type: object
required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages, palletMergeRequired] required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages, assigned, mergeAllowed, urgent]
properties: properties:
inboundCode: { type: string } inboundCode: { type: string }
destinationType: { type: string, enum: [FBA, OVERSEAS] } destinationType: { type: string, enum: [FBA, OVERSEAS] }
@@ -1683,8 +1683,10 @@ components:
totalPackages: { type: integer } totalPackages: { type: integer }
receivedPackages: { type: integer } receivedPackages: { type: integer }
pendingPackages: { type: integer } pendingPackages: { type: integer }
palletMergeRequired: { type: boolean, description: 尾程订单是否要求PDA合托提醒 } assigned: { type: boolean, description: 入库计划是否已分配 }
palletMergeHint: { type: string, nullable: true, description: 标记合托时供PDA直接展示的中文提示 } mergeAllowed: { type: boolean, description: 入库计划是否允许现场选择合托;true不代表强制合托 }
urgent: { type: boolean, description: 入库计划是否加急 }
mergeHint: { type: string, nullable: true, description: 允许合托时供PDA直接展示的非强制中文提示 }
ReceivingWarehouseProfile: ReceivingWarehouseProfile:
type: object type: object
required: [warehouseId, warehouseCode, warehouseName] required: [warehouseId, warehouseCode, warehouseName]
@@ -1764,7 +1766,7 @@ components:
compatible: { type: boolean } compatible: { type: boolean }
ReceivingReceipt: ReceivingReceipt:
type: object type: object
required: [result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, receivedPackages, pendingPackages, sessionStatus, palletMergeRequired] required: [result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, receivedPackages, pendingPackages, sessionStatus, assigned, mergeAllowed, urgent]
properties: properties:
result: { type: string } result: { type: string }
containerNo: { type: string } containerNo: { type: string }
@@ -1775,8 +1777,10 @@ components:
receivedPackages: { type: integer } receivedPackages: { type: integer }
pendingPackages: { type: integer } pendingPackages: { type: integer }
sessionStatus: { type: string, enum: [SIGNED, RECEIVING] } sessionStatus: { type: string, enum: [SIGNED, RECEIVING] }
palletMergeRequired: { type: boolean } assigned: { type: boolean }
palletMergeHint: { type: string, nullable: true } mergeAllowed: { type: boolean, description: 现场可选择合托,不作为实收或完成门禁 }
urgent: { type: boolean }
mergeHint: { type: string, nullable: true }
ReceivingCandidateListApiResponse: ReceivingCandidateListApiResponse:
allOf: allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' } - { $ref: '#/components/schemas/ReceivingApiResponseBase' }
@@ -2431,7 +2435,7 @@ components:
properties: { records: { type: array, items: { $ref: '#/components/schemas/InboundPlanSummary' } } } properties: { records: { type: array, items: { $ref: '#/components/schemas/InboundPlanSummary' } } }
InboundPlanSummary: InboundPlanSummary:
type: object type: object
required: [lineId, planId, planNo, customerId, orderId, inboundCode, overseasWarehouseCode, overseasWarehouseName, packageCount, pickedPackageCount, palletCount, status, createdAt, version] required: [lineId, planId, planNo, customerId, orderId, inboundCode, overseasWarehouseCode, overseasWarehouseName, packageCount, pickedPackageCount, palletCount, assigned, mergeAllowed, urgent, status, createdAt, version]
properties: properties:
lineId: { type: string, pattern: '^[0-9]+$' } lineId: { type: string, pattern: '^[0-9]+$' }
planId: { type: string, pattern: '^[0-9]+$' } planId: { type: string, pattern: '^[0-9]+$' }
@@ -2451,6 +2455,9 @@ components:
pickedPackageCount: { type: integer } pickedPackageCount: { type: integer }
volumeCbm: { type: number, nullable: true } volumeCbm: { type: number, nullable: true }
palletCount: { type: integer } palletCount: { type: integer }
assigned: { type: boolean, description: 入库计划是否已分配 }
mergeAllowed: { type: boolean, description: 入库计划是否允许现场选择合托;不作为强制门禁 }
urgent: { type: boolean, description: 入库计划是否加急 }
status: { type: string } status: { type: string }
appointmentNo: { type: string, nullable: true } appointmentNo: { type: string, nullable: true }
appointmentStatus: { type: string, nullable: true } appointmentStatus: { type: string, nullable: true }
@@ -49,7 +49,23 @@ class HttpWarehouseResourceGatewayTest {
} }
@Test @Test
void resolveRejectsPalletWhoseLocationIsNotReservable() { void resolveAllowsOccupiedPalletForInventoryQueryAndOperations() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-OCCUPIED"))
.andRespond(withSuccess("""
{"code":"200","data":{"palletId":31,"palletCode":"PAL-OCCUPIED",
"warehouseId":21,"locationId":41,"status":"OCCUPIED","version":4,
"reservable":false,"reservationBlockReason":"PALLET_NOT_IDLE"}}
""", MediaType.APPLICATION_JSON));
var result = gateway.resolvePallet("PAL-OCCUPIED");
assertThat(result.palletId()).isEqualTo(31L);
assertThat(result.status()).isEqualTo("OCCUPIED");
server.verify();
}
@Test
void availableResolveRejectsPalletWhoseLocationIsNotReservable() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-BLOCKED")) server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-BLOCKED"))
.andRespond(withSuccess(""" .andRespond(withSuccess("""
{"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-BLOCKED", {"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-BLOCKED",
@@ -59,7 +75,7 @@ class HttpWarehouseResourceGatewayTest {
"reservationBlockReason":"LOCATION_NOT_IDLE"}} "reservationBlockReason":"LOCATION_NOT_IDLE"}}
""", MediaType.APPLICATION_JSON)); """, MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolvePallet("PAL-BLOCKED")) assertThatThrownBy(() -> gateway.resolveAvailablePallet("PAL-BLOCKED"))
.isInstanceOf(ResponseStatusException.class) .isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409") .hasMessageContaining("409")
.hasMessageContaining("Pallet location is not idle"); .hasMessageContaining("Pallet location is not idle");
@@ -117,7 +133,7 @@ class HttpWarehouseResourceGatewayTest {
} }
@Test @Test
void resolveFailsClosedWhenWarehouseOmitsReservationFacts() { void availableResolveFailsClosedWhenWarehouseOmitsReservationFacts() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-UNKNOWN")) server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-UNKNOWN"))
.andRespond(withSuccess(""" .andRespond(withSuccess("""
{"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-UNKNOWN", {"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-UNKNOWN",
@@ -125,7 +141,7 @@ class HttpWarehouseResourceGatewayTest {
"status":"IDLE","fbaWarehouseCode":"ABE3","version":3}} "status":"IDLE","fbaWarehouseCode":"ABE3","version":3}}
""", MediaType.APPLICATION_JSON)); """, MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolvePallet("PAL-UNKNOWN")) assertThatThrownBy(() -> gateway.resolveAvailablePallet("PAL-UNKNOWN"))
.isInstanceOf(ResponseStatusException.class) .isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409") .hasMessageContaining("409")
.hasMessageContaining("availability could not be verified"); .hasMessageContaining("availability could not be verified");
@@ -376,7 +376,7 @@ class ReceivingContainerServiceBehaviorTest {
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow); org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
stubCurrent("FBA", "CONT-A"); stubCurrent("FBA", "CONT-A");
stubSku(); stubSku();
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1")); when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
assertThatThrownBy(() -> service.receiveBulk("7", "receiver", assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key"))) new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
@@ -387,7 +387,6 @@ class ReceivingContainerServiceBehaviorTest {
void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() { void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() {
stubCurrent("FBA", "CONT-A"); stubCurrent("FBA", "CONT-A");
stubSku(); stubSku();
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet())); when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt")) doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt()); .when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
@@ -399,10 +398,7 @@ class ReceivingContainerServiceBehaviorTest {
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow); org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
stubCurrent("OVERSEAS", "CONT-A"); stubCurrent("OVERSEAS", "CONT-A");
when(cartons.selectOne(any())).thenReturn(carton(1L, "BOX-1")); when(cartons.selectOne(any())).thenReturn(carton(1L, "BOX-1"));
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, null));
when(pallets.selectList(any())).thenReturn(List.of(planPallet())); when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton"))
.when(workflow).validateCartonResolved(any(), any(), any(), any(), any());
assertThatThrownBy(() -> service.receiveCarton("7", "receiver", assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key"))) new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key")))
.isInstanceOf(ResponseStatusException.class); .isInstanceOf(ResponseStatusException.class);
@@ -415,7 +411,7 @@ class ReceivingContainerServiceBehaviorTest {
InboundPlanLine overseasLine = line("OVERSEAS"); InboundPlanLine overseasLine = line("OVERSEAS");
overseasLine.setDestinationWarehouseId("900"); overseasLine.setDestinationWarehouseId("900");
when(lines.selectList(any())).thenReturn(List.of(overseasLine)); when(lines.selectList(any())).thenReturn(List.of(overseasLine));
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, null)); when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, null));
var resolved = service.resolvePallet("7", var resolved = service.resolvePallet("7",
new ReceivingContainerRequests.PalletResolve("PAL-1", "IN-1")); new ReceivingContainerRequests.PalletResolve("PAL-1", "IN-1"));
@@ -453,7 +449,7 @@ class ReceivingContainerServiceBehaviorTest {
void localFailureAfterRemoteReservationTriggersReleaseCompensation() { void localFailureAfterRemoteReservationTriggersReleaseCompensation() {
stubCurrent("FBA", "CONT-A"); stubCurrent("FBA", "CONT-A");
stubSku(); stubSku();
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1")); when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet())); when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
when(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L); when(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L);
ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race"); ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race");
@@ -490,7 +486,7 @@ class ReceivingContainerServiceBehaviorTest {
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-RESERVED", 1, "reserved-key")); new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-RESERVED", 1, "reserved-key"));
verify(warehouse).resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L); verify(warehouse).resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L);
verify(warehouse, never()).resolvePallet("PAL-RESERVED"); verify(warehouse, never()).resolveAvailablePallet("PAL-RESERVED");
verify(workflow).assignResolvedPallet(org.mockito.ArgumentMatchers.eq("100"), verify(workflow).assignResolvedPallet(org.mockito.ArgumentMatchers.eq("100"),
any(InboundPlan.class), org.mockito.ArgumentMatchers.same(reserved), any(InboundPlan.class), org.mockito.ArgumentMatchers.same(reserved),
org.mockito.ArgumentMatchers.same(occupied)); org.mockito.ArgumentMatchers.same(occupied));
@@ -623,17 +619,21 @@ class ReceivingContainerServiceBehaviorTest {
} }
@Test @Test
void cartonResolutionReturnsInboundAndPalletMergeReminderFromTailLegOrder() { void cartonResolutionReturnsAssignmentOptionalMergeAndUrgencyFromInboundPlan() {
stubCurrent("FBA", "CONT-A"); stubCurrent("FBA", "CONT-A");
LastlegOrder marked = order("FBA"); InboundPlan flagged = plan("CONT-A");
marked.setPalletMergeFlag("REQUIRED"); flagged.setAssignedFlag(true);
when(orders.selectById(1L)).thenReturn(marked); flagged.setMergeRequired(true);
flagged.setUrgentFlag(true);
when(plans.selectList(any())).thenReturn(List.of(flagged));
var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-1")); var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-1"));
assertThat(resolved.inboundCode()).isEqualTo("IN-1"); assertThat(resolved.inboundCode()).isEqualTo("IN-1");
assertThat(resolved.palletMergeRequired()).isTrue(); assertThat(resolved.assigned()).isTrue();
assertThat(resolved.palletMergeHint()).contains("合托"); assertThat(resolved.mergeAllowed()).isTrue();
assertThat(resolved.urgent()).isTrue();
assertThat(resolved.mergeHint()).contains("可选择", "同目的仓", "新托盘码", "不强制");
} }
@Test @Test
@@ -700,7 +700,7 @@ class ReceivingContainerServiceBehaviorTest {
when(orders.selectById(2L)).thenReturn(secondOrder); when(orders.selectById(2L)).thenReturn(secondOrder);
when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-A", 1), detail("SKU-B", 1)), when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-A", 1), detail("SKU-B", 1)),
List.of(detail("SKU-C", 2))); List.of(detail("SKU-C", 2)));
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1")); when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
when(pallets.selectList(any())).thenReturn(List.of(planPallet())); when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any())) when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenAnswer(invocation -> invocation.getArgument(4)); .thenAnswer(invocation -> invocation.getArgument(4));
@@ -49,7 +49,7 @@ class InboundPlanPaginationTest {
LocalDateTime.of(2026, 8, 1, 0, 0), LocalDateTime.of(2026, 8, 31, 23, 59)); LocalDateTime.of(2026, 8, 1, 0, 0), LocalDateTime.of(2026, 8, 31, 23, 59));
InboundPlanListResponse row = new InboundPlanListResponse(9007199254740993L, 2L, "LIP2", "100", InboundPlanListResponse row = new InboundPlanListResponse(9007199254740993L, 2L, "LIP2", "100",
3L, "IN-1", null, "FIRST_LEG", "US-LAX-01", "洛杉矶仓", "W1", "ONT", "安大略仓", 3L, "IN-1", null, "FIRST_LEG", "US-LAX-01", "洛杉矶仓", "W1", "ONT", "安大略仓",
"ONT8", "客户A", 10, 0, BigDecimal.ONE, 1, "PENDING_ASSIGNMENT", null, null, null, "ONT8", "客户A", 10, 0, BigDecimal.ONE, 1, true, true, true, "PENDING_ASSIGNMENT", null, null, null,
null, "UNSCHEDULED", LocalDateTime.of(2026, 8, 20, 10, 0), 0); null, "UNSCHEDULED", LocalDateTime.of(2026, 8, 20, 10, 0), 0);
when(queryMapper.count("100", query)).thenReturn(11L); when(queryMapper.count("100", query)).thenReturn(11L);
when(queryMapper.rows("100", query, 10, 10)).thenReturn(List.of(row)); when(queryMapper.rows("100", query, 10, 10)).thenReturn(List.of(row));
@@ -61,6 +61,9 @@ class InboundPlanPaginationTest {
assertThat(result.records().get(0).appointmentStatus()).isNull(); assertThat(result.records().get(0).appointmentStatus()).isNull();
assertThat(result.records().get(0).overseasWarehouseCode()).isEqualTo("US-LAX-01"); assertThat(result.records().get(0).overseasWarehouseCode()).isEqualTo("US-LAX-01");
assertThat(result.records().get(0).overseasWarehouseName()).isEqualTo("洛杉矶仓"); assertThat(result.records().get(0).overseasWarehouseName()).isEqualTo("洛杉矶仓");
assertThat(result.records().get(0).assigned()).isTrue();
assertThat(result.records().get(0).mergeAllowed()).isTrue();
assertThat(result.records().get(0).urgent()).isTrue();
assertThat(result.pages()).isEqualTo(2); assertThat(result.pages()).isEqualTo(2);
verify(queryMapper).count("100", query); verify(queryMapper).count("100", query);
verify(queryMapper).rows("100", query, 10, 10); verify(queryMapper).rows("100", query, 10, 10);
@@ -76,6 +79,8 @@ class InboundPlanPaginationTest {
assertThat(sql).contains("o.overseas_warehouse_code overseasWarehouseCode", assertThat(sql).contains("o.overseas_warehouse_code overseasWarehouseCode",
"o.overseas_warehouse_name overseasWarehouseName", "o.overseas_warehouse_name overseasWarehouseName",
"o.overseas_warehouse_id=#{q.overseasWarehouseId}"); "o.overseas_warehouse_id=#{q.overseasWarehouseId}");
assertThat(sql).contains("p.assigned_flag assigned", "p.merge_required mergeAllowed",
"p.urgent_flag urgent");
assertThat(sql).doesNotContain("l.destination_warehouse_id=#{q.overseasWarehouseId}"); assertThat(sql).doesNotContain("l.destination_warehouse_id=#{q.overseasWarehouseId}");
assertThat(sql).contains("q.planNo", "q.inboundCode", "q.status", "q.overseasWarehouseId", assertThat(sql).contains("q.planNo", "q.inboundCode", "q.status", "q.overseasWarehouseId",
"q.destinationWarehouseCode", "q.warehouse", "q.destinationWarehouseCode", "q.warehouse",
+1 -1
View File
@@ -70,7 +70,7 @@ const MODULE_DEPENDENCIES = new Map([
['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']], ['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']],
['phase3-order-splitting', ['phase3-loading-finalize']], ['phase3-order-splitting', ['phase3-loading-finalize']],
['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']],
['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries']], ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries', 'phase3-container-documents']],
['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']], ['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']],
['phase4-overseas-pda', ['phase4-tail-order-controls']], ['phase4-overseas-pda', ['phase4-tail-order-controls']],
['phase4-overseas-pda-operations', ['phase4-overseas-pda']], ['phase4-overseas-pda-operations', ['phase4-overseas-pda']],
+4 -4
View File
@@ -66,7 +66,7 @@ export async function run(context) {
| 费用项目、价格配置与基础 Finance | `fee-items.mjs`、`pricing.mjs`、`finance.mjs` | `fee-items-contract.test.mjs`、`pricing-contract.test.mjs`、`finance-contract.test.mjs` | | 费用项目、价格配置与基础 Finance | `fee-items.mjs`、`pricing.mjs`、`finance.mjs` | `fee-items-contract.test.mjs`、`pricing-contract.test.mjs`、`finance-contract.test.mjs` |
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
| 国内货柜履约模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 国内货柜履约模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、国内报关证据、出仓及离仓后目的港资料 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs`、`phase3-container-documents.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式订单、测量、同柜装载和最终完成 API 生成独立 `BUY_DECLARATION + TAX_REFUND` 混合柜,客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` | | 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式订单、测量、同柜装载和最终完成 API 生成独立 `BUY_DECLARATION + TAX_REFUND` 混合柜,客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` |
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
| 同柜且不可拆组 | `coload-groups.mjs`(可选;前置正式生成至少7票同线路 `WAIT_SPLIT`,另造不同线路反向票;覆盖创建/取消幂等、原子分配/移除、目标组隔离超容拒绝与零漂移、推送防绕过、PDA短拣完成拒绝且无余量子单、足量整组消费、普通单拆分不回归及已有父子单建组拒绝) | `coload-groups-contract.test.mjs` | | 同柜且不可拆组 | `coload-groups.mjs`(可选;前置正式生成至少7票同线路 `WAIT_SPLIT`,另造不同线路反向票;覆盖创建/取消幂等、原子分配/移除、目标组隔离超容拒绝与零漂移、推送防绕过、PDA短拣完成拒绝且无余量子单、足量整组消费、普通单拆分不回归及已有父子单建组拒绝) | `coload-groups-contract.test.mjs` |
@@ -89,7 +89,7 @@ export async function run(context) {
| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 | | 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 |
| 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed | | 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed |
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | | 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(四类柜资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` | | 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO、PU)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
| 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 | | 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 | | 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 |
| 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
@@ -102,9 +102,9 @@ export async function run(context) {
海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。 海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定经过本轮 `LOADED` 柜次的 `DESTINATION_CLEARANCE`、`CUSTOMS_DECLARATION`、`DELIVERY_ORDER`、`PICKUP_ORDER` 四类资料生命周期,再确认国内出仓、核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。四类资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。 节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER`、`PICKUP_ORDER` 三类目的港资料,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再对目标柜全部尾程订单生成人工计划;PDA 卸柜完成后再运行 `phase5-terminal-drayage`,只复用公共 `ACTIVE + TRUCKING` 供应商,依次推进选车队、预约、提柜和还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。 尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。卸柜完成后再运行 `phase5-terminal-drayage`,只复用公共 `ACTIVE + TRUCKING` 供应商,依次推进选车队、预约、提柜和还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
```powershell ```powershell
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes
@@ -8,9 +8,9 @@ import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8')
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
test('two container operational documents are frozen and customs stays on customs-v1', () => { test('three generic container documents are frozen and customs stays on customs-v1', () => {
assert.deepEqual(DOCUMENT_TYPES, [ assert.deepEqual(DOCUMENT_TYPES, [
'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER',
]) ])
for (const token of [ for (const token of [
'/clearance/document/upload', '/clearance/complete', '/clearance/document/upload', '/clearance/complete',
@@ -22,23 +22,28 @@ test('two container operational documents are frozen and customs stays on custom
test('document lifecycle is fail-closed, traceable and covers replay, hash conflict and zero drift', () => { test('document lifecycle is fail-closed, traceable and covers replay, hash conflict and zero drift', () => {
for (const token of [ for (const token of [
'finalizeSucceeded !== true', "finalStatus !== 'LOADED'", 'deploymentBlocker: true', 'finalizeSucceeded !== true', "finalStatus !== 'LOADED'", "outbound?.finalStatus !== 'WAITING_FOR_LOADING'",
"outbound?.finalInboundStatus !== 'LOADED'", 'deploymentBlocker: true',
'同键同载荷重放', '同键异文件返回A0409', '完成同键重放', '完成同键异载荷返回A0409', '同键同载荷重放', '同键异文件返回A0409', '完成同键重放', '完成同键异载荷返回A0409',
'负向请求后持久事实零漂移', 'requestId=', 'traceId=', 'phase3ContainerDocuments', '负向请求后持久事实零漂移', 'requestId=', 'traceId=', 'phase3ContainerDocuments',
]) assert.ok(source.includes(token), token) ]) assert.ok(source.includes(token), token)
}) })
test('focused pickup-ready dependencies reach outbound and candidate visibility', () => { test('focused pickup-ready chain orders destination documents after outbound and before candidate visibility', () => {
const modules = normalizeModuleOrder(['phase4-overseas-pda']) const modules = normalizeModuleOrder(['phase4-overseas-pda'])
const required = [ const required = [
'phase3-loading-finalize', 'phase3-customs', 'phase3-outbound-dispatch', 'phase3-loading-finalize', 'phase3-customs', 'phase3-outbound-dispatch',
'phase3-carrier-schedule-queries', 'phase3-container-documents', 'phase3-carrier-schedule-queries',
'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda',
] ]
for (const module of required) assert.ok(modules.includes(module), module) for (const module of required) assert.ok(modules.includes(module), module)
for (let index = 1; index < required.length; index += 1) { assert.ok(modules.indexOf('phase3-loading-finalize') < modules.indexOf('phase3-customs'))
assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index]) assert.ok(modules.indexOf('phase3-customs') < modules.indexOf('phase3-outbound-dispatch'))
} assert.ok(modules.indexOf('phase3-outbound-dispatch') < modules.indexOf('phase3-container-documents'))
assert.ok(modules.indexOf('phase3-outbound-dispatch') < modules.indexOf('phase3-carrier-schedule-queries'))
assert.ok(modules.indexOf('phase3-container-documents') < modules.indexOf('phase4-carrier-pickup-ready'))
assert.ok(modules.indexOf('phase3-carrier-schedule-queries') < modules.indexOf('phase4-carrier-pickup-ready'))
assert.ok(modules.indexOf('phase4-carrier-pickup-ready') < modules.indexOf('phase4-overseas-pda'))
assert.match(entry, /phase3-container-documents/) assert.match(entry, /phase3-container-documents/)
assert.match(entry, /phase3-container-documents-phase4-/) assert.match(entry, /phase3-container-documents-phase4-/)
}) })
@@ -3,7 +3,7 @@ const OPTIONS = { scenario: 'phase3-container-operational-documents', deployment
const CONTAINERS = '/api/platform/v1/containers' const CONTAINERS = '/api/platform/v1/containers'
export const DOCUMENT_TYPES = Object.freeze([ export const DOCUMENT_TYPES = Object.freeze([
'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER',
]) ])
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
@@ -96,6 +96,7 @@ async function uploadAndVerify(context, { type, uploadPath, readPath, completePa
export async function run(context) { export async function run(context) {
const fixture = context.createdResources?.phase3Loading const fixture = context.createdResources?.phase3Loading
const outbound = context.createdResources?.phase3OutboundDispatch
const containerId = id(fixture?.containerBookingId) const containerId = id(fixture?.containerBookingId)
const planId = id(fixture?.loadingPlanId) const planId = id(fixture?.loadingPlanId)
const warehouseId = id(fixture?.warehouseId) const warehouseId = id(fixture?.warehouseId)
@@ -106,6 +107,13 @@ export async function run(context) {
OPTIONS) OPTIONS)
return return
} }
if (outbound?.marker !== context.marker || id(outbound?.containerBookingId) !== containerId
|| outbound?.finalStatus !== 'WAITING_FOR_LOADING' || outbound?.finalInboundStatus !== 'LOADED') {
context.addStep(MODULE, '取得本轮已确认国内离仓货柜', null, false,
`marker=${outbound?.marker || 'missing'}, status=${outbound?.finalStatus || 'missing'}, inboundStatus=${outbound?.finalInboundStatus || 'missing'}, containerBookingId=${id(outbound?.containerBookingId) || 'missing'}`,
OPTIONS)
return
}
const specs = [ const specs = [
{ {
@@ -114,7 +122,7 @@ export async function run(context) {
readPath: `${CONTAINERS}/${containerId}/clearance`, readPath: `${CONTAINERS}/${containerId}/clearance`,
completePath: `${CONTAINERS}/${containerId}/clearance/complete`, completePath: `${CONTAINERS}/${containerId}/clearance/complete`,
}, },
...['DELIVERY_ORDER'].map((type) => ({ ...['DELIVERY_ORDER', 'PICKUP_ORDER'].map((type) => ({
type, type,
uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`, uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`,
readPath: `${CONTAINERS}/${containerId}/documents/${type}`, readPath: `${CONTAINERS}/${containerId}/documents/${type}`,
@@ -125,76 +125,46 @@ async function uploadVersion(context, path, label, expectedVersion, operation, c
return { response, document } return { response, document }
} }
async function verifyFourContainerDocuments(context, { containerBookingId, loadingPlanId, warehouseId }) { async function verifyDomesticCustomsEvidence(context, { containerBookingId, loadingPlanId, warehouseId }) {
const reason = `${context.marker}-人工验收节点资料` const reason = `${context.marker}-人工验收节点资料`
const simpleDocuments = [ const type = 'CUSTOMS_DECLARATION'
{ type: 'DESTINATION_CLEARANCE', base: `/api/platform/v1/containers/${containerBookingId}/clearance`, const query = `warehouseId=${encodeURIComponent(warehouseId)}`
uploadPath: `/api/platform/v1/containers/${containerBookingId}/clearance/document/upload` }, const base = `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs`
{ type: 'CUSTOMS_DECLARATION', base: `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs`, const uploadPath = `${base}/document/upload?${query}`
query: `warehouseId=${encodeURIComponent(warehouseId)}`, const first = await uploadVersion(context, uploadPath, `首次上传${type}`, 0,
uploadPath: `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs/document/upload?warehouseId=${encodeURIComponent(warehouseId)}` }, `${type.toLowerCase()}-v1`, `${context.marker} ${type} v1`)
] if (!first.document?.fileId) return false
const completed = [] const firstRead = await context.request('GET', `${base}?${query}`)
for (const document of simpleDocuments) { const firstEcho = context.apiSuccess(MODULE, `回显${type}首版`, firstRead, '', OPTIONS)
const first = await uploadVersion(context, document.uploadPath, `首次上传${document.type}`, 0, context.assert(MODULE, `${type}首版回显身份一致`, firstRead,
`${document.type.toLowerCase()}-v1`, `${context.marker} ${document.type} v1`) id(firstEcho?.id) === id(first.document.id) && id(firstEcho?.fileId) === id(first.document.fileId),
if (!first.document?.fileId) return false `documentId=${id(firstEcho?.id) || 'missing'}, version=${firstEcho?.version ?? 'missing'}`, OPTIONS)
const readPath = `${document.base}${document.query ? `?${document.query}` : ''}` const firstDownload = await download(context,
const firstRead = await context.request('GET', readPath) `${base}/document/${id(first.document.fileId)}/download?${query}`, context.token)
const firstEcho = context.apiSuccess(MODULE, `回显${document.type}首版`, firstRead, '', OPTIONS) context.assert(MODULE, `下载${type}首版`, firstDownload,
context.assert(MODULE, `${document.type}首版回显身份一致`, firstRead, firstDownload.http === 200 && firstDownload.bytes.length > 0 && firstDownload.contentType.includes('text/plain'),
id(firstEcho?.id) === id(first.document.id) && id(firstEcho?.fileId) === id(first.document.fileId), `HTTP=${firstDownload.http}, bytes=${firstDownload.bytes.length}`, OPTIONS)
`documentId=${id(firstEcho?.id) || 'missing'}, version=${firstEcho?.version ?? 'missing'}`, OPTIONS) const second = await uploadVersion(context, uploadPath, `更新${type}为第二版`,
const firstDownloadPath = document.type === 'DESTINATION_CLEARANCE' Number(firstEcho?.version), `${type.toLowerCase()}-v2`, `${context.marker} ${type} v2`)
? `${document.base}/document/${id(first.document.fileId)}/download` context.assert(MODULE, `${type}更新保持同一聚合并递增版本`, second.response,
: `${document.base}/document/${id(first.document.fileId)}/download?${document.query}` id(second.document?.id) === id(first.document.id) && Number(second.document?.version) === Number(firstEcho?.version) + 1,
const firstDownload = await download(context, firstDownloadPath, context.token) `documentId=${id(second.document?.id) || 'missing'}, version=${second.document?.version ?? 'missing'}`, OPTIONS)
context.assert(MODULE, `下载${document.type}首版`, firstDownload, const completeResponse = await request(context, 'POST', `${base}/complete?${query}`, {
firstDownload.http === 200 && firstDownload.bytes.length > 0 && firstDownload.contentType.includes('text/plain'), expectedVersion: Number(second.document?.version), operationReason: reason,
`HTTP=${firstDownload.http}, bytes=${firstDownload.bytes.length}`, OPTIONS) }, context.token, { 'Idempotency-Key': key(context, `${type.toLowerCase()}-complete`) })
const second = await uploadVersion(context, document.uploadPath, `更新${document.type}为第二版`, const complete = context.apiSuccess(MODULE, `完成${type}`, completeResponse, '', OPTIONS)
Number(firstEcho?.version), `${document.type.toLowerCase()}-v2`, `${context.marker} ${document.type} v2`) context.assert(MODULE, `${type}完成态、时间和版本可回读`, completeResponse,
context.assert(MODULE, `${document.type}更新保持同一聚合并递增版本`, second.response, id(complete?.id) === id(first.document.id) && Boolean(complete?.completedAt)
id(second.document?.id) === id(first.document.id) && Number(second.document?.version) === Number(firstEcho?.version) + 1, && Number(complete?.version) === Number(second.document?.version) + 1,
`documentId=${id(second.document?.id) || 'missing'}, version=${second.document?.version ?? 'missing'}`, OPTIONS) `completedAt=${complete?.completedAt || 'missing'}, version=${complete?.version ?? 'missing'}`, OPTIONS)
const completeResponse = await request(context, 'POST', `${document.base}/complete${document.query ? `?${document.query}` : ''}`, {
expectedVersion: Number(second.document?.version), operationReason: reason,
}, context.token, { 'Idempotency-Key': key(context, `${document.type.toLowerCase()}-complete`) })
const complete = context.apiSuccess(MODULE, `完成${document.type}`, completeResponse, '', OPTIONS)
context.assert(MODULE, `${document.type}完成态、时间和版本可回读`, completeResponse,
id(complete?.id) === id(first.document.id) && Boolean(complete?.completedAt)
&& Number(complete?.version) === Number(second.document?.version) + 1,
`completedAt=${complete?.completedAt || 'missing'}, version=${complete?.version ?? 'missing'}`, OPTIONS)
completed.push(complete)
}
for (const type of ['DELIVERY_ORDER', 'PICKUP_ORDER']) {
const base = `/api/platform/v1/containers/${containerBookingId}/documents/${type}`
const first = await uploadVersion(context, `${base}/upload`, `首次上传${type}`, 0,
`${type.toLowerCase()}-v1`, `${context.marker} ${type} v1`)
if (!first.document?.fileId) return false
const firstDownload = await download(context, `${base}/${id(first.document.fileId)}/download`, context.token)
context.assert(MODULE, `下载${type}首版`, firstDownload,
firstDownload.http === 200 && firstDownload.bytes.length > 0 && firstDownload.contentType.includes('text/plain'),
`HTTP=${firstDownload.http}, bytes=${firstDownload.bytes.length}`, OPTIONS)
const second = await uploadVersion(context, `${base}/upload`, `更新${type}为第二版`,
Number(first.document.version), `${type.toLowerCase()}-v2`, `${context.marker} ${type} v2`)
const echoResponse = await context.request('GET', base)
const echo = context.apiSuccess(MODULE, `回显${type}当前版本`, echoResponse, '', OPTIONS)
context.assert(MODULE, `${type}首次与更新保持同一聚合且当前版本唯一`, echoResponse,
id(echo?.id) === id(first.document.id) && id(echo?.fileId) === id(second.document?.fileId)
&& Number(echo?.version) === Number(first.document.version) + 1,
`documentId=${id(echo?.id) || 'missing'}, version=${echo?.version ?? 'missing'}`, OPTIONS)
}
const overviewResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}/overview`) const overviewResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}/overview`)
const overview = context.apiSuccess(MODULE, '四类资料完成后回查整柜聚合', overviewResponse, '', OPTIONS) const overview = context.apiSuccess(MODULE, '国内报关完成后回查整柜聚合', overviewResponse, '', OPTIONS)
const documents = Array.isArray(overview?.documents) ? overview.documents : [] const documents = Array.isArray(overview?.documents) ? overview.documents : []
const photos = Array.isArray(overview?.pda?.photos) ? overview.pda.photos.filter((item) => item?.status === 'ACTIVE') : [] const photos = Array.isArray(overview?.pda?.photos) ? overview.pda.photos.filter((item) => item?.status === 'ACTIVE') : []
context.assert(MODULE, '整柜聚合回显四类当前资料与真实PDA照片证据', overviewResponse, context.assert(MODULE, '整柜聚合回显国内报关资料与真实PDA照片证据', overviewResponse,
['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER'] documents.some((item) => item?.documentType === type
.every((type) => documents.some((item) => item?.documentType === type)) && id(item?.id) === id(complete?.id) && item?.completed === true)
&& completed.every((item) => documents.some((value) => id(value?.id) === id(item?.id) && value?.completed === true))
&& photos.length >= 5 && photos.every((item) => /^\/api\/platform\/v1\/containers\/\d+\/pda\/photos\/\d+\/download$/.test(String(item?.downloadPath || ''))), && photos.length >= 5 && photos.every((item) => /^\/api\/platform\/v1\/containers\/\d+\/pda\/photos\/\d+\/download$/.test(String(item?.downloadPath || ''))),
`documents=${documents.map((item) => item?.documentType).join('|') || 'none'}, activePhotos=${photos.length}`, OPTIONS) `documents=${documents.map((item) => item?.documentType).join('|') || 'none'}, activePhotos=${photos.length}`, OPTIONS)
for (const [index, photo] of photos.entries()) { for (const [index, photo] of photos.entries()) {
@@ -505,8 +475,8 @@ export async function run(context) {
context.addStep(MODULE, '回执 fileId 不能伪造为客户报关资料引用', forgedReference, failure(forgedReference), context.addStep(MODULE, '回执 fileId 不能伪造为客户报关资料引用', forgedReference, failure(forgedReference),
`HTTP=${forgedReference.http}, code=${codeOf(forgedReference)}`, OPTIONS) `HTTP=${forgedReference.http}, code=${codeOf(forgedReference)}`, OPTIONS)
if (!(await verifyFourContainerDocuments(context, { containerBookingId, loadingPlanId, warehouseId }))) { if (!(await verifyDomesticCustomsEvidence(context, { containerBookingId, loadingPlanId, warehouseId }))) {
context.addStep(MODULE, '四类整柜资料必须完成后才允许进入出仓确认', null, false, context.addStep(MODULE, '国内报关资料与PDA证据必须完成后才允许进入出仓确认', null, false,
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}, warehouseId=${warehouseId}`, OPTIONS) `containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}, warehouseId=${warehouseId}`, OPTIONS)
return return
} }
@@ -9,19 +9,18 @@ const read = (name) => readFileSync(resolve(here, name), 'utf8')
const readFirstlegTest = (name) => readFileSync(resolve(here, const readFirstlegTest = (name) => readFileSync(resolve(here,
'../../firstleg/src/test/java/com/baiyue/firstleg/portschedule/service', name), 'utf8') '../../firstleg/src/test/java/com/baiyue/firstleg/portschedule/service', name), 'utf8')
test('current-marker departure batch proves operational documents photos customs and container-level confirm', () => { test('current-marker departure batch separates domestic customs evidence from post-departure destination documents', () => {
const customs = read('phase3-customs.mjs') const customs = read('phase3-customs.mjs')
const documents = read('phase3-container-documents.mjs') const documents = read('phase3-container-documents.mjs')
const outbound = read('phase3-outbound-dispatch.mjs') const outbound = read('phase3-outbound-dispatch.mjs')
for (const type of ['DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER']) { assert.doesNotMatch(customs, /DESTINATION_CLEARANCE|DELIVERY_ORDER|PICKUP_ORDER/)
assert.match(customs, new RegExp(type)) for (const type of ['DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER']) assert.match(documents, new RegExp(type))
}
for (const evidence of ['上传', '回读', '完成']) assert.match(documents, new RegExp(evidence)) for (const evidence of ['上传', '回读', '完成']) assert.match(documents, new RegExp(evidence))
assert.match(customs, /整柜聚合回显四类当前资料与真实PDA照片证据/) assert.match(customs, /整柜聚合回显国内报关资料与真实PDA照片证据/)
assert.match(customs, /photos\.length >= 5/) assert.match(customs, /photos\.length >= 5/)
assert.match(customs, /\/customs\/loading-plans/) assert.match(customs, /\/customs\/loading-plans/)
assert.match(customs, /complete-all-buy/) assert.match(customs, /complete-all-buy/)
assert.match(customs, /四类整柜资料必须完成后才允许进入出仓确认/) assert.match(customs, /国内报关资料与PDA证据必须完成后才允许进入出仓确认/)
assert.match(outbound, /CUSTOMS_CLEARANCE/) assert.match(outbound, /CUSTOMS_CLEARANCE/)
assert.match(outbound, /拖车与独立船期仅作聚合参考且不参与出仓门禁/) assert.match(outbound, /拖车与独立船期仅作聚合参考且不参与出仓门禁/)
assert.match(outbound, /\$\{BASE\}\/\$\{encodeURIComponent\(containerBookingId\)\}\/confirm/) assert.match(outbound, /\$\{BASE\}\/\$\{encodeURIComponent\(containerBookingId\)\}\/confirm/)
@@ -33,12 +33,8 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.match(source, /inbound-plans\/container-preview/) assert.match(source, /inbound-plans\/container-preview/)
assert.match(source, /snapshotVersion: Number\(preview\.snapshotVersion\)/) assert.match(source, /snapshotVersion: Number\(preview\.snapshotVersion\)/)
assert.match(source, /snapshotHash: String\(preview\.snapshotHash\)/) assert.match(source, /snapshotHash: String\(preview\.snapshotHash\)/)
assert.match(source, /reservedPalletsByInbound/) assert.match(source, /rememberGeneratedPlans\(context, plans\)/)
assert.match(source, /warehouseResourceLockId/) assert.match(source, /resolveGeneratedPallet\(context, inbound\)/)
assert.doesNotMatch(source, /reservedCoverage/)
assert.match(source, /convertedInboundCodes\.size !== inboundCodes/)
assert.match(source, /preferredProducer\?\.receivingPalletsByInbound\?\.\[inbound\.inboundCode\]/)
assert.match(source, /复用整柜转换预留托盘/)
assert.match(source, /同键异载荷冲突/) assert.match(source, /同键异载荷冲突/)
assert.match(source, /跨柜切换锁返回409/) assert.match(source, /跨柜切换锁返回409/)
assert.match(source, /item\.operable === true && item\.allPlansReady === true/) assert.match(source, /item\.operable === true && item\.allPlansReady === true/)
@@ -62,11 +58,14 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.doesNotMatch(source, /requestOverseas\('POST', '\/api\/platform\/v1\/warehouse-admin'/) assert.doesNotMatch(source, /requestOverseas\('POST', '\/api\/platform\/v1\/warehouse-admin'/)
assert.doesNotMatch(source, /requestOverseas\('GET', '\/api\/platform\/v1\/warehouse-admin'/) assert.doesNotMatch(source, /requestOverseas\('GET', '\/api\/platform\/v1\/warehouse-admin'/)
assert.match(source, /receiving-containers\/current\/pallets\/resolve/) assert.match(source, /receiving-containers\/current\/pallets\/resolve/)
assert.match(source, /const locationCode = resolvedPallet\.data\?\.locationCode \|\| pallet\.locationCode/)
assert.match(source, /overseasWarehouseId, password/) assert.match(source, /overseasWarehouseId, password/)
assert.match(source, /inbound\?\.overseasWarehouseId/) assert.match(source, /inbound\?\.overseasWarehouseId/)
assert.doesNotMatch(source, /const warehouseId = id\(inbound\?\.destinationWarehouseId\)/) assert.doesNotMatch(source, /const warehouseId = id\(inbound\?\.destinationWarehouseId\)/)
assert.match(source, /目的仓 .* 不得代替物理仓/) assert.match(source, /禁止在PDA收货时临时新建脱离计划的托盘/)
assert.match(source, /rememberGeneratedPlans\(context, plans\)/)
assert.match(source, /resolveGeneratedPallet\(context, inbound\)/)
assert.match(source, /generatedPalletsByInbound/)
assert.doesNotMatch(source, /创建当前柜实收托盘|创建当前柜实收库位|async function createPallet/)
assert.match(source, /retained as acceptance evidence/) assert.match(source, /retained as acceptance evidence/)
assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/) assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/)
assert.match(source, /const cartonCandidates =/) assert.match(source, /const cartonCandidates =/)
@@ -169,7 +168,7 @@ test('PDA receiving lifecycle is ordered from candidate claim through photos, mo
const orderedTokens = [ const orderedTokens = [
"'查询当前客户待收柜号'", "'选择柜号并建立WAIT_SIGN会话'", "'查询当前客户待收柜号'", "'选择柜号并建立WAIT_SIGN会话'",
'uploadPhotos(context, token)', "'绑定车牌封签开柜三角色照片并签收'", 'uploadPhotos(context, token)', "'绑定车牌封签开柜三角色照片并签收'",
"`解析${inbound.destinationType}实物托盘`", 'receiveFba(context, token', 'resolveGeneratedPallet(context, inbound)', "`解析${inbound.destinationType}实物托盘`", 'receiveFba(context, token',
'receiveOverseas(context, token', "'回查柜内逐入仓码实收进度'", 'receiveOverseas(context, token', "'回查柜内逐入仓码实收进度'",
"'完成当前柜并写入库存与卸柜事件'", "'完成后current返回404正常空态'", "'完成当前柜并写入库存与卸柜事件'", "'完成后current返回404正常空态'",
] ]
@@ -183,7 +182,7 @@ test('PDA receiving lifecycle is ordered from candidate claim through photos, mo
'/current/receipts/bulk', '/current/receipts/carton', '/current/receipts/bulk', '/current/receipts/carton',
"'FBA模式硬拒绝逐箱收货'", "'OVERSEAS模式硬拒绝批量收货'", "'FBA模式硬拒绝逐箱收货'", "'OVERSEAS模式硬拒绝批量收货'",
'PHOTO_ROLES.every', "finalStatus === 'COMPLETED'", 'completedContainers.push', 'PHOTO_ROLES.every', "finalStatus === 'COMPLETED'", 'completedContainers.push',
'completedContainerFacts.push', 'expectedPalletCost', 'physicalPallets', '完成入库后读取托盘计费单价', 'completedContainerFacts.push', 'expectedPalletCost', 'physicalPallets', 'unitPrice',
]) assert.ok(source.includes(token), token) ]) assert.ok(source.includes(token), token)
}) })
@@ -163,29 +163,42 @@ export async function disablePicker(context, picker, index) {
context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS) context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS)
} }
async function createPallet(context, inbound, index) { function rememberGeneratedPlans(context, plans) {
const warehouseId = id(inbound?.overseasWarehouseId) if (!Array.isArray(plans)) return
if (!longId(warehouseId)) return null for (const plan of plans) {
const suffix = markerSuffix(context) const inboundCode = id(plan?.lines?.[0]?.inboundCode)
const locationCode = `PDA-${suffix}-${index + 1}`.slice(0, 64) if (!nonBlank(inboundCode)) continue
const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { const generated = Array.isArray(plan?.pallets) ? plan.pallets : []
zoneCode: `PDA-${index + 1}`, locationCode, context.createdResources.phase4OverseasPda.generatedPalletsByInbound[inboundCode] = generated.map((pallet) => ({
locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-receiving`, warehouseId: id(plan?.overseasWarehouseId),
}) locationId: id(pallet?.warehouseLocationId),
context.addStep(MODULE, `创建当前柜实收库位${index + 1}`, location, success(location), trace(location), OPTIONS) palletId: id(pallet?.warehousePalletId),
const locationId = id(location?.body?.data) palletCode: id(pallet?.palletNo),
if (!longId(locationId)) return null unitPrice: Number(pallet?.unitPrice || 0),
context.createdResources.phase4OverseasPda.locationIds.push(locationId) retained: true,
const palletCode = `PDA-${suffix}-${index + 1}`.slice(0, 128) }))
const unitPrice = Number((1.01 + index).toFixed(2)) }
const pallet = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { }
palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice, status: 'IDLE', locationId,
remark: `${context.marker}-receiving-retained`, async function resolveGeneratedPallet(context, inbound) {
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null, const candidates = context.createdResources.phase4OverseasPda.generatedPalletsByInbound[inbound.inboundCode] || []
}) const pallet = candidates[0]
context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS) if (!pallet || !longId(pallet.palletId) || !nonBlank(pallet.palletCode)) return null
const palletId = id(pallet?.body?.data) const warehouseId = id(inbound?.overseasWarehouseId || pallet.warehouseId)
return longId(palletId) ? { warehouseId, locationId, locationCode, palletId, palletCode, unitPrice, retained: true } : null const listed = await context.requestOverseas('GET',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets?page=1&size=10&keyword=${encodeURIComponent(pallet.palletCode)}`)
const stored = records(listed?.body?.data).find((row) => id(row?.id) === pallet.palletId && row?.palletCode === pallet.palletCode)
const locations = stored && longId(stored?.locationId)
? await context.requestOverseas('GET',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations?page=1&size=200`)
: null
const location = records(locations?.body?.data).find((row) => id(row?.id) === id(stored?.locationId))
const resolved = success(listed) && success(locations) && stored && longId(stored?.locationId) && nonBlank(location?.locationCode)
context.addStep(MODULE, `复用入仓码${inbound.inboundCode}自动计划托盘`, listed, resolved,
`palletCode=${pallet.palletCode}, palletId=${pallet.palletId}, locationId=${id(stored?.locationId) || 'missing'}, locationCode=${location?.locationCode || 'missing'}; ${trace(listed)}`, OPTIONS)
if (!resolved) return null
return { ...pallet, warehouseId, locationId: id(stored.locationId), locationCode: location.locationCode,
unitPrice: Number(stored.unitPrice || pallet.unitPrice || 0) }
} }
async function verifyWarehouseQueries(context, token, inboundCode, pallet, destinationType) { async function verifyWarehouseQueries(context, token, inboundCode, pallet, destinationType) {
@@ -203,6 +216,14 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet, desti
&& inventoryItems.every((item) => nonBlank(item?.skuCode) && Number(item?.availablePackages) > 0), && inventoryItems.every((item) => nonBlank(item?.skuCode) && Number(item?.availablePackages) > 0),
`skuCodes=${[...new Set(inventoryItems.map((item) => item?.skuCode).filter(nonBlank))].join('|') || 'missing'}, availablePackages=${inventoryItems.reduce((total, item) => total + Number(item?.availablePackages || 0), 0)}; ${trace(inventory.response)}`, OPTIONS) `skuCodes=${[...new Set(inventoryItems.map((item) => item?.skuCode).filter(nonBlank))].join('|') || 'missing'}, availablePackages=${inventoryItems.reduce((total, item) => total + Number(item?.availablePackages || 0), 0)}; ${trace(inventory.response)}`, OPTIONS)
const location = await pda(context, token, 'POST', '完成入库后扫码查询库位',
'/api/pda/v1/last-leg/locations/resolve', { code: pallet.locationCode })
context.assert(MODULE, '库位查询返回当前仓真实占用汇总', location.response,
success(location.response) && id(location.data?.locationId) === pallet.locationId
&& location.data?.locationCode === pallet.locationCode
&& Number(location.data?.occupiedPackages) > 0 && Number(location.data?.inventoryPositionCount) > 0,
`locationId=${id(location.data?.locationId) || 'missing'}; ${trace(location.response)}`, OPTIONS)
const resolvedPallet = await pda(context, token, 'POST', '完成入库后扫码查询托盘内容', const resolvedPallet = await pda(context, token, 'POST', '完成入库后扫码查询托盘内容',
'/api/pda/v1/last-leg/pallets/resolve', { code: pallet.palletCode }) '/api/pda/v1/last-leg/pallets/resolve', { code: pallet.palletCode })
context.assert(MODULE, '托盘查询返回当前位置与库存明细', resolvedPallet.response, context.assert(MODULE, '托盘查询返回当前位置与库存明细', resolvedPallet.response,
@@ -212,22 +233,6 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet, desti
&& Number(resolvedPallet.data?.occupiedPackages) > 0, && Number(resolvedPallet.data?.occupiedPackages) > 0,
`palletId=${id(resolvedPallet.data?.palletId) || 'missing'}; ${trace(resolvedPallet.response)}`, OPTIONS) `palletId=${id(resolvedPallet.data?.palletId) || 'missing'}; ${trace(resolvedPallet.response)}`, OPTIONS)
const palletDetail = await context.requestOverseas('GET',
`/api/platform/v1/warehouse-admin/${encodeURIComponent(pallet.warehouseId)}/pallets/${encodeURIComponent(pallet.palletId)}`)
context.addStep(MODULE, '完成入库后读取托盘计费单价', palletDetail,
success(palletDetail) && Number(palletDetail.body?.data?.unitPrice) > 0,
`palletId=${pallet.palletId}, unitPrice=${palletDetail.body?.data?.unitPrice ?? 'missing'}; ${trace(palletDetail)}`, OPTIONS)
if (Number(palletDetail.body?.data?.unitPrice) > 0) pallet.unitPrice = Number(palletDetail.body.data.unitPrice)
const locationCode = resolvedPallet.data?.locationCode || pallet.locationCode
const location = await pda(context, token, 'POST', '完成入库后扫码查询库位',
'/api/pda/v1/last-leg/locations/resolve', { code: locationCode })
context.assert(MODULE, '库位查询返回当前仓真实占用汇总', location.response,
success(location.response) && id(location.data?.locationId) === pallet.locationId
&& location.data?.locationCode === locationCode
&& Number(location.data?.occupiedPackages) > 0 && Number(location.data?.inventoryPositionCount) > 0,
`locationId=${id(location.data?.locationId) || 'missing'}; ${trace(location.response)}`, OPTIONS)
const history = await pda(context, token, 'GET', '查询当前仓管PDA作业历史', const history = await pda(context, token, 'GET', '查询当前仓管PDA作业历史',
'/api/pda/v1/last-leg/operations?page=1&size=50') '/api/pda/v1/last-leg/operations?page=1&size=50')
const historyRows = records(history.data) const historyRows = records(history.data)
@@ -314,64 +319,59 @@ async function convertReceivingContainers(context, producers) {
convertible, convertible,
`sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS) `sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS)
if (!convertible) return false if (!convertible) return false
const previewResponse = await context.requestOverseas('GET', const bookingId = id(summary.sourceContainerBookingId)
`/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(summary.sourceContainerBookingId)}`, const previewPath = `/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(bookingId)}`
const previewResponse = await context.requestOverseas('GET', previewPath,
undefined, {}, context.overseasToken) undefined, {}, context.overseasToken)
const preview = success(previewResponse) ? previewResponse.body?.data : null const preview = success(previewResponse) ? previewResponse.body?.data : null
const previewReady = preview?.ready === true context.addStep(MODULE, `读取PDA前柜级计划与自动托盘预览 ${containerNo}`, previewResponse,
&& Number.isInteger(Number(preview?.snapshotVersion)) && Number(preview.snapshotVersion) >= 0 preview?.ready === true && Number(preview?.totalOrderCount) === totalOrders
&& /^[0-9a-f]{64}$/.test(String(preview?.snapshotHash || '')) && Number(preview?.pendingOrderCount) === pendingPlans,
&& Number(preview?.pendingOrderCount) === pendingPlans `orders=${preview?.totalOrderCount ?? 'missing'}, pendingOrders=${preview?.pendingOrderCount ?? 'missing'}, pendingPallets=${preview?.pendingPalletCount ?? 'missing'}; ${trace(previewResponse)}`,
context.addStep(MODULE, `预览整柜原子转换快照 ${containerNo}`, previewResponse, OPTIONS)
previewReady, if (preview?.ready !== true) return false
`ready=${preview?.ready === true}, version=${preview?.snapshotVersion ?? 'missing'}, hash=${nonBlank(preview?.snapshotHash) ? 'present' : 'missing'}, pending=${preview?.pendingOrderCount ?? 'missing'}; ${trace(previewResponse)}`, OPTIONS) if (pendingPlans > 0) {
if (!previewReady) return false const body = { sourceContainerBookingId: bookingId,
const body = { snapshotVersion: Number(preview.snapshotVersion), snapshotHash: String(preview.snapshotHash) }
sourceContainerBookingId: id(summary.sourceContainerBookingId), const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128)
snapshotVersion: Number(preview.snapshotVersion), const convertedResponse = await context.requestOverseas('POST',
snapshotHash: String(preview.snapshotHash), '/api/platform/v1/last-leg/inbound-plans/convert-container', body,
{ 'Idempotency-Key': key }, context.overseasToken)
const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data)
? convertedResponse.body.data : []
const convertedInboundCodes = new Set(plans
.flatMap((item) => Array.isArray(item?.lines) ? item.lines : [])
.map((line) => id(line?.inboundCode)).filter(nonBlank))
context.addStep(MODULE, `整柜转换为逐入仓码独立计划及实体托盘 ${containerNo}`, convertedResponse,
plans.length === inboundCodes
&& plans.every((item) => Array.isArray(item?.lines) && item.lines.length > 0
&& Array.isArray(item?.pallets) && item.pallets.length > 0
&& item.pallets.every((pallet) => nonBlank(pallet?.palletNo) && longId(id(pallet?.warehousePalletId))))
&& convertedInboundCodes.size === inboundCodes,
`plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS)
if (plans.length !== inboundCodes) return false
rememberGeneratedPlans(context, plans)
const replayResponse = await context.requestOverseas('POST',
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
{ 'Idempotency-Key': key }, context.overseasToken)
context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse,
success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans),
trace(replayResponse), OPTIONS)
if (!success(replayResponse)) return false
} else {
const retainedPlans = context.createdResources?.phase4TailOrderControls?.generatedPlans
rememberGeneratedPlans(context, retainedPlans)
context.addStep(MODULE, `复用尾程控制已生成的整柜计划与实体托盘 ${containerNo}`, previewResponse,
summary?.allPlansReady === true && existingPlans === totalOrders
&& Number(preview?.existingPlannedPalletCount) > 0,
`plans=${existingPlans}, plannedPallets=${preview?.existingPlannedPalletCount ?? 'missing'}; ${trace(previewResponse)}`,
OPTIONS)
} }
const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128)
const convertedResponse = await context.requestOverseas('POST',
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
{ 'Idempotency-Key': key }, context.overseasToken)
const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data)
? convertedResponse.body.data : []
const convertedInboundCodes = new Set(plans
.flatMap((item) => Array.isArray(item?.lines) ? item.lines : [])
.map((line) => id(line?.inboundCode)).filter(nonBlank))
const reservedPalletsByInbound = {}
for (const plan of plans) {
const reserved = Array.isArray(plan?.pallets) ? plan.pallets.find((item) =>
longId(item?.warehousePalletId) && longId(item?.warehouseLocationId)
&& longId(item?.warehouseResourceLockId)
&& ['RESERVED', 'ASSIGNED', 'LOCKED'].includes(item?.assignmentStatus)) : null
for (const line of Array.isArray(plan?.lines) ? plan.lines : []) {
if (reserved && nonBlank(line?.inboundCode)) reservedPalletsByInbound[id(line.inboundCode)] = {
palletId: id(reserved.warehousePalletId), palletCode: reserved.palletNo,
locationId: id(reserved.warehouseLocationId), retained: true,
}
}
}
context.addStep(MODULE, `整柜转换为逐入仓码独立计划 ${containerNo}`, convertedResponse,
plans.length === inboundCodes
&& plans.every((item) => Array.isArray(item?.lines) && item.lines.length > 0)
&& convertedInboundCodes.size === inboundCodes,
`plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, reserved=${Object.keys(reservedPalletsByInbound).length}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS)
if (plans.length !== inboundCodes || convertedInboundCodes.size !== inboundCodes) return false
producer.receivingPalletsByInbound = reservedPalletsByInbound
const replayResponse = await context.requestOverseas('POST',
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
{ 'Idempotency-Key': key }, context.overseasToken)
context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse,
success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans),
trace(replayResponse), OPTIONS)
if (!success(replayResponse)) return false
const progressResponse = await context.requestOverseas('GET', const progressResponse = await context.requestOverseas('GET',
`/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`, `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`,
undefined, {}, context.overseasToken) undefined, {}, context.overseasToken)
const progress = success(progressResponse) ? progressResponse.body?.data?.summary : null const progress = success(progressResponse) ? progressResponse.body?.data?.summary : null
context.addStep(MODULE, `整柜转换后计划完整度闭环 ${containerNo}`, progressResponse, context.addStep(MODULE, `PDA前整柜计划完整度闭环 ${containerNo}`, progressResponse,
progress?.allPlansReady === true && Number(progress?.planCount) === totalOrders progress?.allPlansReady === true && Number(progress?.planCount) === totalOrders
&& Number(progress?.pendingPlanCount) === 0, && Number(progress?.pendingPlanCount) === 0,
`allPlansReady=${progress?.allPlansReady === true}, plans=${progress?.planCount ?? 'missing'}, pending=${progress?.pendingPlanCount ?? 'missing'}, plannedArrivalAt=${progress?.plannedArrivalAt || 'missing'}; ${trace(progressResponse)}`, OPTIONS) `allPlansReady=${progress?.allPlansReady === true}, plans=${progress?.planCount ?? 'missing'}, pending=${progress?.pendingPlanCount ?? 'missing'}, plannedArrivalAt=${progress?.plannedArrivalAt || 'missing'}; ${trace(progressResponse)}`, OPTIONS)
@@ -636,19 +636,14 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
if (!success(afterResolve.response) || receivingSnapshot(afterResolve.data) !== beforeResolve) return if (!success(afterResolve.response) || receivingSnapshot(afterResolve.data) !== beforeResolve) return
const pallets = new Map() const pallets = new Map()
for (const [index, inbound] of current.data.inbounds.entries()) { for (const [index, inbound] of current.data.inbounds.entries()) {
const reservedPallet = preferredProducer?.receivingPalletsByInbound?.[inbound.inboundCode] const pallet = await resolveGeneratedPallet(context, inbound)
const pallet = reservedPallet || await createPallet(context, inbound, index)
if (reservedPallet) context.addStep(MODULE, `${inbound.destinationType}复用整柜转换预留托盘`, null,
longId(reservedPallet.palletId) && nonBlank(reservedPallet.palletCode)
&& longId(reservedPallet.locationId),
`inboundCode=${inbound.inboundCode}, palletCode=${reservedPallet.palletCode}`, OPTIONS)
if (!pallet) { if (!pallet) {
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`, blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}自动计划托盘`,
`无法在实际海外收货仓 ${inbound.overseasWarehouseId || 'missing'} 创建可解析真实托盘;目的仓 ${inbound.destinationWarehouseId || 'missing'} 不得代替物理仓`, 'warehouse-resource') `按柜确认没有为该入仓码留下可解析的实体托盘;禁止在PDA收货时临时新建脱离计划的托盘`, 'warehouse-resource')
continue continue
} }
pallets.set(inbound.inboundCode, pallet) pallets.set(inbound.inboundCode, pallet)
if (!reservedPallet) context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId) context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId)
const resolved = await pda(context, token, 'POST', `解析${inbound.destinationType}实物托盘`, const resolved = await pda(context, token, 'POST', `解析${inbound.destinationType}实物托盘`,
'/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve', { palletCode: pallet.palletCode, inboundCode: inbound.inboundCode }) '/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve', { palletCode: pallet.palletCode, inboundCode: inbound.inboundCode })
context.assert(MODULE, `${inbound.destinationType}托盘目的模式匹配`, resolved.response, context.assert(MODULE, `${inbound.destinationType}托盘目的模式匹配`, resolved.response,
@@ -700,6 +695,14 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
const physicalPallets = [...new Map([...pallets.values()]
.map((item) => [id(item.palletId), item])).values()]
context.createdResources.phase4OverseasPda.completedContainerFacts.push({
containerNo,
containerBookingId: id(preferredProducer?.containerBookingId),
expectedPalletCost: physicalPallets.reduce((sum, item) => sum + Number(item.unitPrice || 0), 0),
palletIds: physicalPallets.map((item) => item.palletId),
})
const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId)
for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) { for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) {
const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode, const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode,
@@ -712,14 +715,6 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
}) })
} }
} }
const physicalPallets = [...new Map([...pallets.values()]
.map((item) => [id(item.palletId), item])).values()]
context.createdResources.phase4OverseasPda.completedContainerFacts.push({
containerNo,
containerBookingId: id(preferredProducer?.containerBookingId),
expectedPalletCost: physicalPallets.reduce((sum, item) => sum + Number(item.unitPrice || 0), 0),
palletIds: physicalPallets.map((item) => item.palletId),
})
} }
} }
@@ -729,6 +724,7 @@ export async function run(context) {
palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [], palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [],
completedContainerFacts: [], completedContainerFacts: [],
overseasWarehouseId: null, overseasWarehouseName: null, overseasWarehouseId: null, overseasWarehouseName: null,
generatedPalletsByInbound: {},
cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence',
} }
const merchantToken = await loginMerchantForNegativeProbe(context) const merchantToken = await loginMerchantForNegativeProbe(context)
@@ -756,7 +752,6 @@ export async function run(context) {
primary: primary ? { membershipId: primary.membershipId, username: primary.username, token: primary.token } : null, primary: primary ? { membershipId: primary.membershipId, username: primary.username, token: primary.token } : null,
secondary: secondary ? { membershipId: secondary.membershipId, username: secondary.username, token: secondary.token } : null, secondary: secondary ? { membershipId: secondary.membershipId, username: secondary.username, token: secondary.token } : null,
overseasWarehouseId: sharedWarehouse.id, overseasWarehouseId: sharedWarehouse.id,
merchantToken,
completedFixtures: [], completedFixtures: [],
cleanup, cleanup,
} }
@@ -773,7 +768,7 @@ export async function run(context) {
if (allProducers.length) await awaitProducerConsumption(context, allProducers) if (allProducers.length) await awaitProducerConsumption(context, allProducers)
if (producers.length && !(await convertReceivingContainers(context, producers))) { if (producers.length && !(await convertReceivingContainers(context, producers))) {
blockerGap(context, '实际处理柜整柜转换门禁', blockerGap(context, '实际处理柜整柜转换门禁',
'目标柜未能通过正式平台convert-container逐入仓码生成独立计划') '目标柜未能通过正式柜级预览与确认形成逐入仓码计划及实体托盘')
return return
} }
if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) { if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) {
@@ -26,10 +26,11 @@ test('module covers the public tail-order write surface before PDA receiving', (
'暂扣命令同键同载荷重放', '暂扣同键异载荷返回A0409', '暂扣尾程订单不能生成人工计划', '暂扣命令同键同载荷重放', '暂扣同键异载荷返回A0409', '暂扣尾程订单不能生成人工计划',
'解除本轮单票尾程暂扣', '保存尾程八个人工节点快照', '人工节点同键同载荷重放', '解除本轮单票尾程暂扣', '保存尾程八个人工节点快照', '人工节点同键同载荷重放',
'人工节点同键异载荷返回A0409', '人工节点读取固定为MANUAL且不回退柜级节点', '人工节点同键异载荷返回A0409', '人工节点读取固定为MANUAL且不回退柜级节点',
'人工计划按订单稳定接柜仓回读当前主档并冻结入仓码、托数和操作标志', '人工计划同键异载荷返回A0409', '预览整柜待生成入仓计划清单', '按柜确认并自动生成入仓计划与实体托盘',
'按柜确认同键同快照幂等重放', '确认后预览待生成票数和托数归零',
]) assert.ok(source.includes(evidence), `missing ${evidence}`) ]) assert.ok(source.includes(evidence), `missing ${evidence}`)
assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/) assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/)
assert.match(source, /creationMode === 'MANUAL'/) assert.match(source, /creationMode === 'AUTO'/)
assert.match(source, /requestOverseasBinary\('GET'/) assert.match(source, /requestOverseasBinary\('GET'/)
assert.match(source, /palletMergeBatchId/) assert.match(source, /palletMergeBatchId/)
}) })
@@ -49,11 +50,17 @@ test('module is marker-owned and never borrows historical tail orders', () => {
assert.doesNotMatch(source, /records\([^)]*\)\[0\]/) assert.doesNotMatch(source, /records\([^)]*\)\[0\]/)
}) })
test('manual plans preserve long identifiers and are retained for PDA consumption', () => { test('container conversion preserves long identifiers and generated pallets for PDA consumption', () => {
assert.match(source, /orderId: id\(order\.id\)/) assert.match(source, /orderId: id\(order\.id\)/)
assert.doesNotMatch(source, /plannedWarehouse(?:Id|Code|Name):/) assert.doesNotMatch(source, /plannedWarehouse(?:Id|Code|Name):/)
assert.match(source, /!order\.overseasWarehouseId/) assert.match(source, /!order\.overseasWarehouseId/)
assert.match(source, /planNos: plans/) assert.match(source, /container-preview/)
assert.match(source, /MANUAL_PLANS_READY/) assert.match(source, /snapshotVersion/)
assert.match(source, /remain as the marker-owned prerequisites consumed by overseas PDA receiving/) assert.match(source, /snapshotHash/)
assert.match(source, /convert-container/)
assert.match(source, /自动托盘进入托盘列表并保留FBA目的仓/)
assert.match(source, /planNos: generated\.map/)
assert.match(source, /generatedPlans: generated/)
assert.match(source, /AUTO_CONTAINER_PLANS_READY/)
assert.match(source, /remain as marker-owned prerequisites consumed by overseas PDA receiving/)
}) })
@@ -207,11 +207,95 @@ function completeManualNodes(marker) {
})).map((node, index) => index === 3 ? { ...node, nodeAt: null } : node) })).map((node, index) => index === 3 ? { ...node, nodeAt: null } : node)
} }
function previewGroupRank(group) {
return ({ FBA: 0, ONE_CLICK_DROPSHIP: 1, PRIVATE_ADDRESS: 2 })[group?.destinationType] ?? 99
}
function previewGroupsSorted(groups) {
const rows = Array.isArray(groups) ? groups : []
return rows.every((group, index) => {
if (index === 0) return true
const previous = rows[index - 1]
const rankDifference = previewGroupRank(previous) - previewGroupRank(group)
return rankDifference < 0 || (rankDifference === 0
&& String(previous?.destinationWarehouseCode || '').localeCompare(
String(group?.destinationWarehouseCode || '')) <= 0)
})
}
async function convertContainerPlans(context, producer, details) {
const bookingId = id(producer.containerBookingId)
const previewPath = query(`${PLANS}/container-preview`, { sourceContainerBookingId: bookingId })
const preview = await api(context, 'GET', '预览整柜待生成入仓计划清单', previewPath)
const groups = Array.isArray(preview.data?.groups) ? preview.data.groups : []
const previewOrders = groups.flatMap((group) => Array.isArray(group?.orders) ? group.orders : [])
const pendingPalletCount = Number(preview.data?.pendingPalletCount)
context.assert(MODULE, '柜级预览列清尾程单、入仓码、目的仓、业务类型与建议托数', preview.response,
preview.data?.ready === true && id(preview.data?.sourceContainerBookingId) === bookingId
&& previewOrders.length === details.length && pendingPalletCount > 0
&& previewOrders.every((order) => nonBlank(order?.businessOrderNo) && nonBlank(order?.inboundCode)
&& nonBlank(order?.destinationType) && nonBlank(order?.destinationWarehouseCode)
&& Number(order?.suggestedPalletCount) > 0 && order?.readiness === 'READY'
&& Array.isArray(order?.blockers) && order.blockers.length === 0),
`orders=${previewOrders.length}, pendingPallets=${pendingPalletCount}, groups=${groups.length}; ${trace(preview.response)}`,
OPTIONS)
context.assert(MODULE, '柜级预览按FBA、一件代发、私人地址及目的仓稳定排序', preview.response,
previewGroupsSorted(groups), groups.map((group) => `${group.destinationType}:${group.destinationWarehouseCode}`).join(','), OPTIONS)
context.assert(MODULE, '一件代发的FBA目的仓快照保留给自动托盘', preview.response,
previewOrders.filter((order) => order?.destinationType === 'ONE_CLICK_DROPSHIP'
&& nonBlank(order?.destinationWarehouseCode)).every((order) => nonBlank(order?.fbaWarehouseCode)),
`dropshipFba=${previewOrders.filter((order) => nonBlank(order?.fbaWarehouseCode)).length}`, OPTIONS)
if (!success(preview.response) || preview.data?.ready !== true || pendingPalletCount <= 0) return null
const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.data.snapshotVersion),
snapshotHash: preview.data.snapshotHash }
const key = `${context.marker}-container-plan-confirm`.slice(0, 128)
const converted = await api(context, 'POST', '按柜确认并自动生成入仓计划与实体托盘',
`${PLANS}/convert-container`, body, { 'Idempotency-Key': key })
const plans = Array.isArray(converted.data) ? converted.data : []
const pallets = plans.flatMap((plan) => Array.isArray(plan?.pallets) ? plan.pallets.map((pallet) => ({ plan, pallet })) : [])
context.assert(MODULE, '按柜确认逐票生成独立计划且托盘总数与预览一致', converted.response,
plans.length === details.length && pallets.length === pendingPalletCount
&& plans.every((plan) => plan?.creationMode === 'AUTO' && nonBlank(plan?.planNo)
&& Array.isArray(plan?.lines) && plan.lines.length === 1)
&& pallets.every(({ pallet }) => nonBlank(pallet?.palletNo) && id(pallet?.warehousePalletId)
&& id(pallet?.warehouseLocationId) && pallet?.assignmentStatus === 'RESERVED'),
`plans=${plans.length}, pallets=${pallets.length}, expectedPallets=${pendingPalletCount}; ${trace(converted.response)}`,
OPTIONS)
if (!success(converted.response)) return null
for (const { plan, pallet } of pallets) {
const line = plan.lines[0]
const palletPath = query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(preview.data.overseasWarehouseId))}/pallets`, {
page: 1, size: 10, keyword: pallet.palletNo,
})
const listed = await api(context, 'GET', `回查自动托盘${pallet.palletNo}`, palletPath)
const stored = records(listed.data).find((row) => row?.palletCode === pallet.palletNo)
context.assert(MODULE, '自动托盘进入托盘列表并保留FBA目的仓', listed.response,
id(stored?.id) === id(pallet.warehousePalletId) && stored?.status === 'OCCUPIED'
&& (!nonBlank(line?.fbaWarehouseCode) || stored?.fbaWarehouseCode === line.fbaWarehouseCode),
`pallet=${pallet.palletNo}, expectedFba=${line?.fbaWarehouseCode || 'none'}, actualFba=${stored?.fbaWarehouseCode || 'none'}; ${trace(listed.response)}`,
OPTIONS)
}
const replay = await api(context, 'POST', '按柜确认同键同快照幂等重放',
`${PLANS}/convert-container`, body, { 'Idempotency-Key': key })
context.assert(MODULE, '按柜确认重放不新增计划或托盘', replay.response,
JSON.stringify(replay.data) === JSON.stringify(converted.data), trace(replay.response), OPTIONS)
const after = await api(context, 'GET', '回查整柜计划与托盘均已生成', previewPath)
context.assert(MODULE, '确认后预览待生成票数和托数归零', after.response,
Number(after.data?.pendingOrderCount) === 0 && Number(after.data?.pendingPalletCount) === 0
&& Number(after.data?.totalPlannedPalletCount) >= pendingPalletCount,
`pendingOrders=${after.data?.pendingOrderCount}, pendingPallets=${after.data?.pendingPalletCount}, totalPlannedPallets=${after.data?.totalPlannedPalletCount}; ${trace(after.response)}`,
OPTIONS)
return plans
}
export async function run(context) { export async function run(context) {
context.createdResources.phase4TailOrderControls = { context.createdResources.phase4TailOrderControls = {
marker: context.marker, containerNo: null, orderIds: [], planNos: [], representativeOrderId: null, marker: context.marker, containerNo: null, orderIds: [], planNos: [], representativeOrderId: null,
finalStatus: null, finalStatus: null,
cleanup: 'Manual inbound plans remain as the marker-owned prerequisites consumed by overseas PDA receiving', cleanup: 'Automatically generated container inbound plans and pallets remain as marker-owned prerequisites consumed by overseas PDA receiving',
} }
const producer = targetProducer(context) const producer = targetProducer(context)
if (!producer?.containerNo || !producer?.tenantId) { if (!producer?.containerNo || !producer?.tenantId) {
@@ -299,38 +383,13 @@ export async function run(context) {
nodesRead.data?.manualOverrideEnabled === true && nodesRead.data?.nodes?.length === 8 nodesRead.data?.manualOverrideEnabled === true && nodesRead.data?.nodes?.length === 8
&& nodesRead.data.nodes.every((node) => node?.source === 'MANUAL'), trace(nodesRead.response), OPTIONS) && nodesRead.data.nodes.every((node) => node?.source === 'MANUAL'), trace(nodesRead.response), OPTIONS)
const plans = [] const generated = await convertContainerPlans(context, producer, refreshedDetails)
for (const original of refreshedDetails) { if (!generated) return
const refreshed = await detail(context, original, `创建人工计划前刷新订单${id(original.id)}版本`)
const order = refreshed.data?.order
if (!success(refreshed.response) || !order) return
const body = manualPlanBody(order, order.version, context.marker)
const key = `${context.marker}-manual-plan-${id(order.id)}`.slice(0, 128)
const created = await api(context, 'POST', `为本轮尾程订单${id(order.id)}创建人工计划`, `${PLANS}/manual`, body,
{ 'Idempotency-Key': key })
context.assert(MODULE, '人工计划按订单稳定接柜仓回读当前主档并冻结入仓码、托数和操作标志', created.response,
Boolean(created.data?.planNo) && created.data?.creationMode === 'MANUAL'
&& Boolean(created.data?.overseasWarehouseCode) && Boolean(created.data?.overseasWarehouseName)
&& created.data?.plannedArrivalDate === body.plannedArrivalDate
&& created.data?.palletType === body.palletType && created.data?.urgent === true
&& Number(created.data?.palletCount) === body.palletCount
&& created.data?.lines?.some((line) => id(line?.orderId) === id(order.id) && line?.inboundCode === order.inboundCode),
`orderId=${id(order.id)}, planNo=${created.data?.planNo || 'missing'}; ${trace(created.response)}`, OPTIONS)
if (!success(created.response) || !created.data?.planNo) return
const replay = await api(context, 'POST', `人工计划${created.data.planNo}幂等重放`, `${PLANS}/manual`, body,
{ 'Idempotency-Key': key })
context.assert(MODULE, '人工计划重放复用同一计划且零新增', replay.response,
replay.data?.planNo === created.data.planNo, trace(replay.response), OPTIONS)
if (plans.length === 0) {
const hashConflict = await context.requestOverseas('POST', `${PLANS}/manual`,
{ ...body, remark: `${context.marker}-DIFFERENT` }, { 'Idempotency-Key': key }, context.overseasToken)
context.addStep(MODULE, '人工计划同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS)
}
plans.push(created.data.planNo)
}
context.createdResources.phase4TailOrderControls = { context.createdResources.phase4TailOrderControls = {
...context.createdResources.phase4TailOrderControls, ...context.createdResources.phase4TailOrderControls,
containerNo: producer.containerNo, orderIds: refreshedDetails.map((order) => id(order.id)), containerNo: producer.containerNo, orderIds: refreshedDetails.map((order) => id(order.id)),
planNos: plans, representativeOrderId: id(representative.id), finalStatus: 'MANUAL_PLANS_READY', planNos: generated.map((plan) => plan.planNo), representativeOrderId: id(representative.id),
generatedPlans: generated,
finalStatus: 'AUTO_CONTAINER_PLANS_READY',
} }
} }