fix(lastleg): complete generated pallet receiving flow
This commit is contained in:
+3
@@ -23,6 +23,9 @@ public record InboundPlanListResponse(
|
||||
Integer pickedPackageCount,
|
||||
BigDecimal volumeCbm,
|
||||
Integer palletCount,
|
||||
boolean assigned,
|
||||
boolean mergeAllowed,
|
||||
boolean urgent,
|
||||
String status,
|
||||
String appointmentNo,
|
||||
String appointmentStatus,
|
||||
|
||||
+2
-1
@@ -44,7 +44,8 @@ public interface InboundPlanQueryMapper {
|
||||
"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," +
|
||||
"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.expected_loading_at expectedLoadingAt," +
|
||||
"CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE " +
|
||||
|
||||
+8
-5
@@ -55,7 +55,8 @@ public final class ReceivingContainerResponses {
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
||||
int receivedPackages, int pendingPackages,
|
||||
boolean palletMergeRequired, String palletMergeHint) { }
|
||||
boolean assigned, boolean mergeAllowed, boolean urgent,
|
||||
String mergeHint) { }
|
||||
|
||||
public record CartonResolved(String cartonMark, String inboundCode,
|
||||
String destinationType, String overseasWarehouseId,
|
||||
@@ -63,7 +64,8 @@ public final class ReceivingContainerResponses {
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
||||
int receivedPackages, int pendingPackages,
|
||||
boolean palletMergeRequired, String palletMergeHint) {
|
||||
boolean assigned, boolean mergeAllowed, boolean urgent,
|
||||
String mergeHint) {
|
||||
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
@@ -73,7 +75,7 @@ public final class ReceivingContainerResponses {
|
||||
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
|
||||
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||
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,
|
||||
String destinationType, String palletCode, int acceptedPackages,
|
||||
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,
|
||||
String destinationType, String palletCode, int acceptedPackages,
|
||||
int receivedPackages, int pendingPackages, String sessionStatus) {
|
||||
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
|
||||
receivedPackages, pendingPackages, sessionStatus, false, null);
|
||||
receivedPackages, pendingPackages, sessionStatus, false, false, false, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -117,6 +117,12 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
|
||||
@Override
|
||||
public WarehousePalletReference resolvePallet(String palletCode) {
|
||||
WarehousePalletResponse response = lookupPallet(palletCode);
|
||||
return reference(response.data());
|
||||
}
|
||||
|
||||
@Override
|
||||
public WarehousePalletReference resolveAvailablePallet(String palletCode) {
|
||||
WarehousePalletResponse response = lookupPallet(palletCode);
|
||||
requireReservable(response.data());
|
||||
return reference(response.data());
|
||||
@@ -153,7 +159,6 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
.body(WarehousePalletResponse.class);
|
||||
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Warehouse pallet service returned no usable response");
|
||||
requireReservable(response.data());
|
||||
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(),
|
||||
response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(),
|
||||
response.data().unitPrice(), response.data().version());
|
||||
|
||||
+3
@@ -11,6 +11,9 @@ public interface WarehouseResourceGateway {
|
||||
void scrapPallet(String customerId, long palletId);
|
||||
WarehousePalletReference resolvePallet(String palletCode);
|
||||
WarehousePalletReference resolvePallet(long palletId);
|
||||
default WarehousePalletReference resolveAvailablePallet(String palletCode) {
|
||||
return resolvePallet(palletCode);
|
||||
}
|
||||
default WarehousePalletReference resolveReservedPallet(String palletCode, String customerId, long warehouseId,
|
||||
long palletId, long locationId, long resourceLockId) {
|
||||
throw new UnsupportedOperationException("Reserved warehouse pallet resolution is not configured");
|
||||
|
||||
+20
-12
@@ -61,7 +61,8 @@ import java.util.stream.Collectors;
|
||||
public class ReceivingContainerService {
|
||||
private static final String PHOTO_DOWNLOAD_PATH =
|
||||
"/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 InboundPlanLineMapper lines;
|
||||
@@ -266,7 +267,7 @@ public class ReceivingContainerService {
|
||||
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
|
||||
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
|
||||
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,
|
||||
@@ -311,8 +312,8 @@ public class ReceivingContainerService {
|
||||
int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum();
|
||||
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
|
||||
session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted,
|
||||
received, Math.max(0, total - received), session.getStatus(), palletMergeRequired(ref.order()),
|
||||
palletMergeHint(ref.order()));
|
||||
received, Math.max(0, total - received), session.getStatus(), assigned(ref.plan()),
|
||||
mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()));
|
||||
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
|
||||
return result;
|
||||
} catch (RuntimeException failure) {
|
||||
@@ -351,7 +352,7 @@ public class ReceivingContainerService {
|
||||
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
|
||||
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1,
|
||||
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);
|
||||
return result;
|
||||
} catch (RuntimeException failure) {
|
||||
@@ -654,7 +655,7 @@ public class ReceivingContainerService {
|
||||
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
|
||||
} else {
|
||||
try {
|
||||
resource = warehouse.resolvePallet(palletCode.trim());
|
||||
resource = warehouse.resolveAvailablePallet(palletCode.trim());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw notFound("Pallet was not found");
|
||||
}
|
||||
@@ -728,16 +729,23 @@ public class ReceivingContainerService {
|
||||
ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(),
|
||||
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
|
||||
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) {
|
||||
return order != null && ("REQUIRED".equals(order.getPalletMergeFlag())
|
||||
|| "MERGED".equals(order.getPalletMergeFlag()));
|
||||
private boolean assigned(InboundPlan plan) {
|
||||
return plan != null && Boolean.TRUE.equals(plan.getAssignedFlag());
|
||||
}
|
||||
|
||||
private String palletMergeHint(LastlegOrder order) {
|
||||
return palletMergeRequired(order) ? PALLET_MERGE_HINT : null;
|
||||
private boolean mergeAllowed(InboundPlan plan) {
|
||||
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) {
|
||||
|
||||
@@ -1503,7 +1503,7 @@ components:
|
||||
timestamp: { type: string, format: date-time }
|
||||
ReceivingInbound:
|
||||
type: object
|
||||
required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages, palletMergeRequired]
|
||||
required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages, assigned, mergeAllowed, urgent]
|
||||
properties:
|
||||
inboundCode: { type: string }
|
||||
destinationType: { type: string, enum: [FBA, OVERSEAS] }
|
||||
@@ -1517,8 +1517,10 @@ components:
|
||||
totalPackages: { type: integer }
|
||||
receivedPackages: { type: integer }
|
||||
pendingPackages: { type: integer }
|
||||
palletMergeRequired: { type: boolean, description: 尾程订单是否要求PDA合托提醒 }
|
||||
palletMergeHint: { type: string, nullable: true, description: 标记合托时供PDA直接展示的中文提示 }
|
||||
assigned: { type: boolean, description: 入库计划是否已分配 }
|
||||
mergeAllowed: { type: boolean, description: 入库计划是否允许现场选择合托;true不代表强制合托 }
|
||||
urgent: { type: boolean, description: 入库计划是否加急 }
|
||||
mergeHint: { type: string, nullable: true, description: 允许合托时供PDA直接展示的非强制中文提示 }
|
||||
ReceivingWarehouseProfile:
|
||||
type: object
|
||||
required: [warehouseId, warehouseCode, warehouseName]
|
||||
@@ -1598,7 +1600,7 @@ components:
|
||||
compatible: { type: boolean }
|
||||
ReceivingReceipt:
|
||||
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:
|
||||
result: { type: string }
|
||||
containerNo: { type: string }
|
||||
@@ -1609,8 +1611,10 @@ components:
|
||||
receivedPackages: { type: integer }
|
||||
pendingPackages: { type: integer }
|
||||
sessionStatus: { type: string, enum: [SIGNED, RECEIVING] }
|
||||
palletMergeRequired: { type: boolean }
|
||||
palletMergeHint: { type: string, nullable: true }
|
||||
assigned: { type: boolean }
|
||||
mergeAllowed: { type: boolean, description: 现场可选择合托,不作为实收或完成门禁 }
|
||||
urgent: { type: boolean }
|
||||
mergeHint: { type: string, nullable: true }
|
||||
ReceivingCandidateListApiResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||
@@ -2252,7 +2256,7 @@ components:
|
||||
properties: { records: { type: array, items: { $ref: '#/components/schemas/InboundPlanSummary' } } }
|
||||
InboundPlanSummary:
|
||||
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:
|
||||
lineId: { type: string, pattern: '^[0-9]+$' }
|
||||
planId: { type: string, pattern: '^[0-9]+$' }
|
||||
@@ -2272,6 +2276,9 @@ components:
|
||||
pickedPackageCount: { type: integer }
|
||||
volumeCbm: { type: number, nullable: true }
|
||||
palletCount: { type: integer }
|
||||
assigned: { type: boolean, description: 入库计划是否已分配 }
|
||||
mergeAllowed: { type: boolean, description: 入库计划是否允许现场选择合托;不作为强制门禁 }
|
||||
urgent: { type: boolean, description: 入库计划是否加急 }
|
||||
status: { type: string }
|
||||
appointmentNo: { type: string, nullable: true }
|
||||
appointmentStatus: { type: string, nullable: true }
|
||||
|
||||
+20
-4
@@ -49,7 +49,23 @@ class HttpWarehouseResourceGatewayTest {
|
||||
}
|
||||
|
||||
@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"))
|
||||
.andRespond(withSuccess("""
|
||||
{"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-BLOCKED",
|
||||
@@ -59,7 +75,7 @@ class HttpWarehouseResourceGatewayTest {
|
||||
"reservationBlockReason":"LOCATION_NOT_IDLE"}}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThatThrownBy(() -> gateway.resolvePallet("PAL-BLOCKED"))
|
||||
assertThatThrownBy(() -> gateway.resolveAvailablePallet("PAL-BLOCKED"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("409")
|
||||
.hasMessageContaining("Pallet location is not idle");
|
||||
@@ -99,7 +115,7 @@ class HttpWarehouseResourceGatewayTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveFailsClosedWhenWarehouseOmitsReservationFacts() {
|
||||
void availableResolveFailsClosedWhenWarehouseOmitsReservationFacts() {
|
||||
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-UNKNOWN"))
|
||||
.andRespond(withSuccess("""
|
||||
{"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-UNKNOWN",
|
||||
@@ -107,7 +123,7 @@ class HttpWarehouseResourceGatewayTest {
|
||||
"status":"IDLE","fbaWarehouseCode":"ABE3","version":3}}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThatThrownBy(() -> gateway.resolvePallet("PAL-UNKNOWN"))
|
||||
assertThatThrownBy(() -> gateway.resolveAvailablePallet("PAL-UNKNOWN"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("409")
|
||||
.hasMessageContaining("availability could not be verified");
|
||||
|
||||
+15
-15
@@ -376,7 +376,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
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",
|
||||
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
@@ -387,7 +387,6 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() {
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
stubSku();
|
||||
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
|
||||
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
|
||||
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
|
||||
.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);
|
||||
stubCurrent("OVERSEAS", "CONT-A");
|
||||
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()));
|
||||
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton"))
|
||||
.when(workflow).validateCartonResolved(any(), any(), any(), any(), any());
|
||||
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
|
||||
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key")))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
@@ -415,7 +411,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
InboundPlanLine overseasLine = line("OVERSEAS");
|
||||
overseasLine.setDestinationWarehouseId("900");
|
||||
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",
|
||||
new ReceivingContainerRequests.PalletResolve("PAL-1", "IN-1"));
|
||||
@@ -453,7 +449,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
void localFailureAfterRemoteReservationTriggersReleaseCompensation() {
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
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(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L);
|
||||
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"));
|
||||
|
||||
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"),
|
||||
any(InboundPlan.class), org.mockito.ArgumentMatchers.same(reserved),
|
||||
org.mockito.ArgumentMatchers.same(occupied));
|
||||
@@ -623,17 +619,21 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void cartonResolutionReturnsInboundAndPalletMergeReminderFromTailLegOrder() {
|
||||
void cartonResolutionReturnsAssignmentOptionalMergeAndUrgencyFromInboundPlan() {
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
LastlegOrder marked = order("FBA");
|
||||
marked.setPalletMergeFlag("REQUIRED");
|
||||
when(orders.selectById(1L)).thenReturn(marked);
|
||||
InboundPlan flagged = plan("CONT-A");
|
||||
flagged.setAssignedFlag(true);
|
||||
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"));
|
||||
|
||||
assertThat(resolved.inboundCode()).isEqualTo("IN-1");
|
||||
assertThat(resolved.palletMergeRequired()).isTrue();
|
||||
assertThat(resolved.palletMergeHint()).contains("合托");
|
||||
assertThat(resolved.assigned()).isTrue();
|
||||
assertThat(resolved.mergeAllowed()).isTrue();
|
||||
assertThat(resolved.urgent()).isTrue();
|
||||
assertThat(resolved.mergeHint()).contains("可选择", "同目的仓", "新托盘码", "不强制");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -700,7 +700,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
when(orders.selectById(2L)).thenReturn(secondOrder);
|
||||
when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-A", 1), detail("SKU-B", 1)),
|
||||
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(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
|
||||
.thenAnswer(invocation -> invocation.getArgument(4));
|
||||
|
||||
+6
-1
@@ -49,7 +49,7 @@ class InboundPlanPaginationTest {
|
||||
LocalDateTime.of(2026, 8, 1, 0, 0), LocalDateTime.of(2026, 8, 31, 23, 59));
|
||||
InboundPlanListResponse row = new InboundPlanListResponse(9007199254740993L, 2L, "LIP2", "100",
|
||||
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);
|
||||
when(queryMapper.count("100", query)).thenReturn(11L);
|
||||
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).overseasWarehouseCode()).isEqualTo("US-LAX-01");
|
||||
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);
|
||||
verify(queryMapper).count("100", query);
|
||||
verify(queryMapper).rows("100", query, 10, 10);
|
||||
@@ -76,6 +79,8 @@ class InboundPlanPaginationTest {
|
||||
assertThat(sql).contains("o.overseas_warehouse_code overseasWarehouseCode",
|
||||
"o.overseas_warehouse_name overseasWarehouseName",
|
||||
"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).contains("q.planNo", "q.inboundCode", "q.status", "q.overseasWarehouseId",
|
||||
"q.destinationWarehouseCode", "q.warehouse",
|
||||
|
||||
@@ -59,18 +59,18 @@ const MODULE_DEPENDENCIES = new Map([
|
||||
['truck-appointments', ['merchant-qualification-fixture']],
|
||||
['service-center-flow', ['ocean-bookings', 'truck-appointments']],
|
||||
['phase3-loading-finalize', ['phase3-loading', 'phase3-evidence-gate']],
|
||||
['phase3-container-documents', ['phase3-loading-finalize']],
|
||||
['phase3-container-documents', ['phase3-outbound-dispatch']],
|
||||
['phase3-customs', ['phase3-loading-finalize']],
|
||||
['phase3-container-customs', ['merchant-qualification-fixture']],
|
||||
['phase3-port-schedules', ['ocean-bookings']],
|
||||
['phase3-outbound-dispatch', ['phase3-container-documents', 'phase3-customs', 'phase3-port-schedules', 'service-center-flow']],
|
||||
['phase3-outbound-dispatch', ['phase3-customs', 'phase3-port-schedules', 'service-center-flow']],
|
||||
['phase3-outbound-aggregate', ['phase3-outbound-dispatch']],
|
||||
['phase3-dispatch-gate', ['phase3-outbound-dispatch']],
|
||||
['phase3-waiting-for-loading', ['phase3-outbound-dispatch']],
|
||||
['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']],
|
||||
['phase3-order-splitting', ['phase3-loading-finalize']],
|
||||
['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-overseas-pda', ['phase4-tail-order-controls']],
|
||||
['phase4-overseas-pda-operations', ['phase4-overseas-pda']],
|
||||
@@ -1497,8 +1497,6 @@ export class AcceptanceRun {
|
||||
await this.executeModule(`phase3-loading-phase4-${label}`, () => loading.run(this))
|
||||
const finalize = await import(new URL('./admin-test-env-modules/phase3-loading-finalize.mjs', import.meta.url))
|
||||
await this.executeModule(`phase3-loading-finalize-phase4-${label}`, () => finalize.run(this))
|
||||
const documents = await import(new URL('./admin-test-env-modules/phase3-container-documents.mjs', import.meta.url))
|
||||
await this.executeModule(`phase3-container-documents-phase4-${label}`, () => documents.run(this))
|
||||
const producerKey = this.createdResources.phase3LoadingCompletedProducer?.resourceKey
|
||||
const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null,
|
||||
carrierLatestActualAt = null) => {
|
||||
@@ -1527,6 +1525,8 @@ export class AcceptanceRun {
|
||||
await this.executeModule(`phase3-port-schedules-phase4-${label}`, () => portSchedules.run(this))
|
||||
const outbound = await import(new URL('./admin-test-env-modules/phase3-outbound-dispatch.mjs', import.meta.url))
|
||||
await this.executeModule(`phase3-outbound-dispatch-phase4-${label}`, () => outbound.run(this))
|
||||
const documents = await import(new URL('./admin-test-env-modules/phase3-container-documents.mjs', import.meta.url))
|
||||
await this.executeModule(`phase3-container-documents-phase4-${label}`, () => documents.run(this))
|
||||
const carrierQueries = await import(new URL('./admin-test-env-modules/phase3-carrier-schedule-queries.mjs', import.meta.url))
|
||||
await this.executeModule(`phase3-carrier-schedule-queries-phase4-${label}`, () => carrierQueries.run(this))
|
||||
const carrierNodes = await import(new URL('./admin-test-env-modules/phase4-carrier-nodes.mjs', import.meta.url))
|
||||
|
||||
@@ -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` |
|
||||
| 入库测量、首次打印冻结箱码与墨家扫码 | `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` |
|
||||
| 国内货柜履约模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、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-order-splitting.mjs` | `phase3-order-splitting-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-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` |
|
||||
| 国内离仓至海外 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` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
|
||||
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-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,不能按空场景通过;船节点禁止手工创建任务。
|
||||
|
||||
节点全链定向入口为 `--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
|
||||
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes
|
||||
|
||||
@@ -22,23 +22,28 @@ test('three generic container documents are frozen and customs stays on customs-
|
||||
|
||||
test('document lifecycle is fail-closed, traceable and covers replay, hash conflict and zero drift', () => {
|
||||
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',
|
||||
'负向请求后持久事实零漂移', 'requestId=', 'traceId=', 'phase3ContainerDocuments',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
})
|
||||
|
||||
test('focused pickup-ready chain orders documents before 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 required = [
|
||||
'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs',
|
||||
'phase3-outbound-dispatch', 'phase3-carrier-schedule-queries',
|
||||
'phase3-loading-finalize', 'phase3-customs', 'phase3-outbound-dispatch',
|
||||
'phase3-container-documents', 'phase3-carrier-schedule-queries',
|
||||
'phase4-carrier-pickup-ready', 'phase4-overseas-pda',
|
||||
]
|
||||
for (const module of required) assert.ok(modules.includes(module), module)
|
||||
for (let index = 1; index < required.length; index += 1) {
|
||||
assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index])
|
||||
}
|
||||
assert.ok(modules.indexOf('phase3-loading-finalize') < modules.indexOf('phase3-customs'))
|
||||
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-phase4-/)
|
||||
})
|
||||
|
||||
@@ -96,6 +96,7 @@ async function uploadAndVerify(context, { type, uploadPath, readPath, completePa
|
||||
|
||||
export async function run(context) {
|
||||
const fixture = context.createdResources?.phase3Loading
|
||||
const outbound = context.createdResources?.phase3OutboundDispatch
|
||||
const containerId = id(fixture?.containerBookingId)
|
||||
const planId = id(fixture?.loadingPlanId)
|
||||
const warehouseId = id(fixture?.warehouseId)
|
||||
@@ -106,6 +107,13 @@ export async function run(context) {
|
||||
OPTIONS)
|
||||
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 = [
|
||||
{
|
||||
|
||||
@@ -125,76 +125,46 @@ async function uploadVersion(context, path, label, expectedVersion, operation, c
|
||||
return { response, document }
|
||||
}
|
||||
|
||||
async function verifyFourContainerDocuments(context, { containerBookingId, loadingPlanId, warehouseId }) {
|
||||
async function verifyDomesticCustomsEvidence(context, { containerBookingId, loadingPlanId, warehouseId }) {
|
||||
const reason = `${context.marker}-人工验收节点资料`
|
||||
const simpleDocuments = [
|
||||
{ type: 'DESTINATION_CLEARANCE', base: `/api/platform/v1/containers/${containerBookingId}/clearance`,
|
||||
uploadPath: `/api/platform/v1/containers/${containerBookingId}/clearance/document/upload` },
|
||||
{ type: 'CUSTOMS_DECLARATION', base: `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs`,
|
||||
query: `warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
uploadPath: `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs/document/upload?warehouseId=${encodeURIComponent(warehouseId)}` },
|
||||
]
|
||||
const completed = []
|
||||
for (const document of simpleDocuments) {
|
||||
const first = await uploadVersion(context, document.uploadPath, `首次上传${document.type}`, 0,
|
||||
`${document.type.toLowerCase()}-v1`, `${context.marker} ${document.type} v1`)
|
||||
if (!first.document?.fileId) return false
|
||||
const readPath = `${document.base}${document.query ? `?${document.query}` : ''}`
|
||||
const firstRead = await context.request('GET', readPath)
|
||||
const firstEcho = context.apiSuccess(MODULE, `回显${document.type}首版`, firstRead, '', OPTIONS)
|
||||
context.assert(MODULE, `${document.type}首版回显身份一致`, firstRead,
|
||||
id(firstEcho?.id) === id(first.document.id) && id(firstEcho?.fileId) === id(first.document.fileId),
|
||||
`documentId=${id(firstEcho?.id) || 'missing'}, version=${firstEcho?.version ?? 'missing'}`, OPTIONS)
|
||||
const firstDownloadPath = document.type === 'DESTINATION_CLEARANCE'
|
||||
? `${document.base}/document/${id(first.document.fileId)}/download`
|
||||
: `${document.base}/document/${id(first.document.fileId)}/download?${document.query}`
|
||||
const firstDownload = await download(context, firstDownloadPath, context.token)
|
||||
context.assert(MODULE, `下载${document.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, document.uploadPath, `更新${document.type}为第二版`,
|
||||
Number(firstEcho?.version), `${document.type.toLowerCase()}-v2`, `${context.marker} ${document.type} v2`)
|
||||
context.assert(MODULE, `${document.type}更新保持同一聚合并递增版本`, second.response,
|
||||
id(second.document?.id) === id(first.document.id) && Number(second.document?.version) === Number(firstEcho?.version) + 1,
|
||||
`documentId=${id(second.document?.id) || 'missing'}, version=${second.document?.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 type = 'CUSTOMS_DECLARATION'
|
||||
const query = `warehouseId=${encodeURIComponent(warehouseId)}`
|
||||
const base = `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs`
|
||||
const uploadPath = `${base}/document/upload?${query}`
|
||||
const first = await uploadVersion(context, uploadPath, `首次上传${type}`, 0,
|
||||
`${type.toLowerCase()}-v1`, `${context.marker} ${type} v1`)
|
||||
if (!first.document?.fileId) return false
|
||||
const firstRead = await context.request('GET', `${base}?${query}`)
|
||||
const firstEcho = context.apiSuccess(MODULE, `回显${type}首版`, firstRead, '', OPTIONS)
|
||||
context.assert(MODULE, `${type}首版回显身份一致`, firstRead,
|
||||
id(firstEcho?.id) === id(first.document.id) && id(firstEcho?.fileId) === id(first.document.fileId),
|
||||
`documentId=${id(firstEcho?.id) || 'missing'}, version=${firstEcho?.version ?? 'missing'}`, OPTIONS)
|
||||
const firstDownload = await download(context,
|
||||
`${base}/document/${id(first.document.fileId)}/download?${query}`, 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, uploadPath, `更新${type}为第二版`,
|
||||
Number(firstEcho?.version), `${type.toLowerCase()}-v2`, `${context.marker} ${type} v2`)
|
||||
context.assert(MODULE, `${type}更新保持同一聚合并递增版本`, second.response,
|
||||
id(second.document?.id) === id(first.document.id) && Number(second.document?.version) === Number(firstEcho?.version) + 1,
|
||||
`documentId=${id(second.document?.id) || 'missing'}, version=${second.document?.version ?? 'missing'}`, OPTIONS)
|
||||
const completeResponse = await request(context, 'POST', `${base}/complete?${query}`, {
|
||||
expectedVersion: Number(second.document?.version), operationReason: reason,
|
||||
}, context.token, { 'Idempotency-Key': key(context, `${type.toLowerCase()}-complete`) })
|
||||
const complete = context.apiSuccess(MODULE, `完成${type}`, completeResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${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)
|
||||
|
||||
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 photos = Array.isArray(overview?.pda?.photos) ? overview.pda.photos.filter((item) => item?.status === 'ACTIVE') : []
|
||||
context.assert(MODULE, '整柜聚合回显四类当前资料与真实PDA照片证据', overviewResponse,
|
||||
['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER']
|
||||
.every((type) => documents.some((item) => item?.documentType === type))
|
||||
&& completed.every((item) => documents.some((value) => id(value?.id) === id(item?.id) && value?.completed === true))
|
||||
context.assert(MODULE, '整柜聚合回显国内报关资料与真实PDA照片证据', overviewResponse,
|
||||
documents.some((item) => item?.documentType === type
|
||||
&& id(item?.id) === id(complete?.id) && item?.completed === true)
|
||||
&& 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)
|
||||
for (const [index, photo] of photos.entries()) {
|
||||
@@ -505,8 +475,8 @@ export async function run(context) {
|
||||
context.addStep(MODULE, '回执 fileId 不能伪造为客户报关资料引用', forgedReference, failure(forgedReference),
|
||||
`HTTP=${forgedReference.http}, code=${codeOf(forgedReference)}`, OPTIONS)
|
||||
|
||||
if (!(await verifyFourContainerDocuments(context, { containerBookingId, loadingPlanId, warehouseId }))) {
|
||||
context.addStep(MODULE, '四类整柜资料必须完成后才允许进入出仓确认', null, false,
|
||||
if (!(await verifyDomesticCustomsEvidence(context, { containerBookingId, loadingPlanId, warehouseId }))) {
|
||||
context.addStep(MODULE, '国内报关资料与PDA证据必须完成后才允许进入出仓确认', null, false,
|
||||
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}, warehouseId=${warehouseId}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
+5
-6
@@ -9,19 +9,18 @@ const read = (name) => readFileSync(resolve(here, name), 'utf8')
|
||||
const readFirstlegTest = (name) => readFileSync(resolve(here,
|
||||
'../../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 documents = read('phase3-container-documents.mjs')
|
||||
const outbound = read('phase3-outbound-dispatch.mjs')
|
||||
for (const type of ['DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER']) {
|
||||
assert.match(customs, new RegExp(type))
|
||||
}
|
||||
assert.doesNotMatch(customs, /DESTINATION_CLEARANCE|DELIVERY_ORDER|PICKUP_ORDER/)
|
||||
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))
|
||||
assert.match(customs, /整柜聚合回显四类当前资料与真实PDA照片证据/)
|
||||
assert.match(customs, /整柜聚合回显国内报关资料与真实PDA照片证据/)
|
||||
assert.match(customs, /photos\.length >= 5/)
|
||||
assert.match(customs, /\/customs\/loading-plans/)
|
||||
assert.match(customs, /complete-all-buy/)
|
||||
assert.match(customs, /四类整柜资料必须完成后才允许进入出仓确认/)
|
||||
assert.match(customs, /国内报关资料与PDA证据必须完成后才允许进入出仓确认/)
|
||||
assert.match(outbound, /CUSTOMS_CLEARANCE/)
|
||||
assert.match(outbound, /拖车与独立船期仅作聚合参考且不参与出仓门禁/)
|
||||
assert.match(outbound, /\$\{BASE\}\/\$\{encodeURIComponent\(containerBookingId\)\}\/confirm/)
|
||||
|
||||
@@ -56,7 +56,11 @@ test('PDA module covers the container-number receiving contract and excludes out
|
||||
assert.match(source, /overseasWarehouseId, password/)
|
||||
assert.match(source, /inbound\?\.overseasWarehouseId/)
|
||||
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, /preferredProducer\?\.cartonMarks\?\.length/)
|
||||
assert.match(source, /const cartonCandidates =/)
|
||||
@@ -159,7 +163,7 @@ test('PDA receiving lifecycle is ordered from candidate claim through photos, mo
|
||||
const orderedTokens = [
|
||||
"'查询当前客户待收柜号'", "'选择柜号并建立WAIT_SIGN会话'",
|
||||
'uploadPhotos(context, token)', "'绑定车牌封签开柜三角色照片并签收'",
|
||||
"`解析${inbound.destinationType}实物托盘`", 'receiveFba(context, token',
|
||||
'resolveGeneratedPallet(context, inbound)', "`解析${inbound.destinationType}实物托盘`", 'receiveFba(context, token',
|
||||
'receiveOverseas(context, token', "'回查柜内逐入仓码实收进度'",
|
||||
"'完成当前柜并写入库存与卸柜事件'", "'完成后current返回404正常空态'",
|
||||
]
|
||||
|
||||
@@ -163,29 +163,42 @@ export async function disablePicker(context, picker, index) {
|
||||
context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS)
|
||||
}
|
||||
|
||||
async function createPallet(context, inbound, index) {
|
||||
const warehouseId = id(inbound?.overseasWarehouseId)
|
||||
if (!longId(warehouseId)) return null
|
||||
const suffix = markerSuffix(context)
|
||||
const locationCode = `PDA-${suffix}-${index + 1}`.slice(0, 64)
|
||||
const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
|
||||
zoneCode: `PDA-${index + 1}`, locationCode,
|
||||
locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-receiving`,
|
||||
})
|
||||
context.addStep(MODULE, `创建当前柜实收库位${index + 1}`, location, success(location), trace(location), OPTIONS)
|
||||
const locationId = id(location?.body?.data)
|
||||
if (!longId(locationId)) return null
|
||||
context.createdResources.phase4OverseasPda.locationIds.push(locationId)
|
||||
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`,
|
||||
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null,
|
||||
})
|
||||
context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS)
|
||||
const palletId = id(pallet?.body?.data)
|
||||
return longId(palletId) ? { warehouseId, locationId, locationCode, palletId, palletCode, unitPrice, retained: true } : null
|
||||
function rememberGeneratedPlans(context, plans) {
|
||||
if (!Array.isArray(plans)) return
|
||||
for (const plan of plans) {
|
||||
const inboundCode = id(plan?.lines?.[0]?.inboundCode)
|
||||
if (!nonBlank(inboundCode)) continue
|
||||
const generated = Array.isArray(plan?.pallets) ? plan.pallets : []
|
||||
context.createdResources.phase4OverseasPda.generatedPalletsByInbound[inboundCode] = generated.map((pallet) => ({
|
||||
warehouseId: id(plan?.overseasWarehouseId),
|
||||
locationId: id(pallet?.warehouseLocationId),
|
||||
palletId: id(pallet?.warehousePalletId),
|
||||
palletCode: id(pallet?.palletNo),
|
||||
unitPrice: Number(pallet?.unitPrice || 0),
|
||||
retained: true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveGeneratedPallet(context, inbound) {
|
||||
const candidates = context.createdResources.phase4OverseasPda.generatedPalletsByInbound[inbound.inboundCode] || []
|
||||
const pallet = candidates[0]
|
||||
if (!pallet || !longId(pallet.palletId) || !nonBlank(pallet.palletCode)) return null
|
||||
const warehouseId = id(inbound?.overseasWarehouseId || pallet.warehouseId)
|
||||
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) {
|
||||
@@ -306,34 +319,59 @@ async function convertReceivingContainers(context, producers) {
|
||||
convertible,
|
||||
`sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS)
|
||||
if (!convertible) return false
|
||||
const body = { sourceContainerBookingId: id(summary.sourceContainerBookingId) }
|
||||
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))
|
||||
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}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS)
|
||||
if (plans.length !== inboundCodes) return false
|
||||
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 bookingId = id(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)
|
||||
const preview = success(previewResponse) ? previewResponse.body?.data : null
|
||||
context.addStep(MODULE, `读取PDA前柜级计划与自动托盘预览 ${containerNo}`, previewResponse,
|
||||
preview?.ready === true && Number(preview?.totalOrderCount) === totalOrders
|
||||
&& Number(preview?.pendingOrderCount) === pendingPlans,
|
||||
`orders=${preview?.totalOrderCount ?? 'missing'}, pendingOrders=${preview?.pendingOrderCount ?? 'missing'}, pendingPallets=${preview?.pendingPalletCount ?? 'missing'}; ${trace(previewResponse)}`,
|
||||
OPTIONS)
|
||||
if (preview?.ready !== true) return false
|
||||
if (pendingPlans > 0) {
|
||||
const body = { sourceContainerBookingId: bookingId,
|
||||
snapshotVersion: Number(preview.snapshotVersion), snapshotHash: preview.snapshotHash }
|
||||
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))
|
||||
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 progressResponse = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`,
|
||||
undefined, {}, context.overseasToken)
|
||||
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
|
||||
&& Number(progress?.pendingPlanCount) === 0,
|
||||
`allPlansReady=${progress?.allPlansReady === true}, plans=${progress?.planCount ?? 'missing'}, pending=${progress?.pendingPlanCount ?? 'missing'}, plannedArrivalAt=${progress?.plannedArrivalAt || 'missing'}; ${trace(progressResponse)}`, OPTIONS)
|
||||
@@ -598,10 +636,10 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
|
||||
if (!success(afterResolve.response) || receivingSnapshot(afterResolve.data) !== beforeResolve) return
|
||||
const pallets = new Map()
|
||||
for (const [index, inbound] of current.data.inbounds.entries()) {
|
||||
const pallet = await createPallet(context, inbound, index)
|
||||
const pallet = await resolveGeneratedPallet(context, inbound)
|
||||
if (!pallet) {
|
||||
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`,
|
||||
`无法在实际海外收货仓 ${inbound.overseasWarehouseId || 'missing'} 创建可解析真实托盘;目的仓 ${inbound.destinationWarehouseId || 'missing'} 不得代替物理仓`, 'warehouse-resource')
|
||||
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}自动计划托盘`,
|
||||
`按柜确认没有为该入仓码留下可解析的实体托盘;禁止在PDA收货时临时新建脱离计划的托盘`, 'warehouse-resource')
|
||||
continue
|
||||
}
|
||||
pallets.set(inbound.inboundCode, pallet)
|
||||
@@ -684,6 +722,7 @@ export async function run(context) {
|
||||
palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [],
|
||||
completedContainerFacts: [],
|
||||
overseasWarehouseId: null, overseasWarehouseName: null,
|
||||
generatedPalletsByInbound: {},
|
||||
cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence',
|
||||
}
|
||||
const merchantToken = await loginMerchantForNegativeProbe(context)
|
||||
@@ -727,7 +766,7 @@ export async function run(context) {
|
||||
if (allProducers.length) await awaitProducerConsumption(context, allProducers)
|
||||
if (producers.length && !(await convertReceivingContainers(context, producers))) {
|
||||
blockerGap(context, '实际处理柜整柜转换门禁',
|
||||
'目标柜未能通过正式平台convert-container逐入仓码生成独立计划')
|
||||
'目标柜未能通过正式柜级预览与确认形成逐入仓码计划及实体托盘')
|
||||
return
|
||||
}
|
||||
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', '人工节点读取固定为MANUAL且不回退柜级节点',
|
||||
'人工计划按订单稳定接柜仓回读当前主档并冻结入仓码、托数和操作标志', '人工计划同键异载荷返回A0409',
|
||||
'预览整柜待生成入仓计划清单', '按柜确认并自动生成入仓计划与实体托盘',
|
||||
'按柜确认同键同快照幂等重放', '确认后预览待生成票数和托数归零',
|
||||
]) assert.ok(source.includes(evidence), `missing ${evidence}`)
|
||||
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, /palletMergeBatchId/)
|
||||
})
|
||||
@@ -49,11 +50,17 @@ test('module is marker-owned and never borrows historical tail orders', () => {
|
||||
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.doesNotMatch(source, /plannedWarehouse(?:Id|Code|Name):/)
|
||||
assert.match(source, /!order\.overseasWarehouseId/)
|
||||
assert.match(source, /planNos: plans/)
|
||||
assert.match(source, /MANUAL_PLANS_READY/)
|
||||
assert.match(source, /remain as the marker-owned prerequisites consumed by overseas PDA receiving/)
|
||||
assert.match(source, /container-preview/)
|
||||
assert.match(source, /snapshotVersion/)
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
context.createdResources.phase4TailOrderControls = {
|
||||
marker: context.marker, containerNo: null, orderIds: [], planNos: [], representativeOrderId: 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)
|
||||
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.nodes.every((node) => node?.source === 'MANUAL'), trace(nodesRead.response), OPTIONS)
|
||||
|
||||
const plans = []
|
||||
for (const original of refreshedDetails) {
|
||||
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)
|
||||
}
|
||||
const generated = await convertContainerPlans(context, producer, refreshedDetails)
|
||||
if (!generated) return
|
||||
context.createdResources.phase4TailOrderControls = {
|
||||
...context.createdResources.phase4TailOrderControls,
|
||||
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',
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user