feat: receive dropship inbound by source pallet

This commit is contained in:
2026-09-09 18:33:17 +08:00
parent c8ae626e87
commit 45e7c98b3f
13 changed files with 174 additions and 12 deletions
+1 -1
View File
@@ -28,7 +28,7 @@
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 | | 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`,合托只作现场可选提示;首扫确认已预留的真实 pallet/location/lock 绑定,不得重新分配计划资源 | | PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;首扫确认已预留的真实 pallet/location/lock 绑定,不得重新分配计划资源 |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 | | 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 |
@@ -134,6 +134,14 @@ public class ReceivingContainerController {
return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request)); return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request));
} }
@PostMapping("/current/receipts/pallet")
public ApiResponse<ReceivingContainerResponses.Receipt> receiveWholePallet(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.WholePalletReceive request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.receiveWholePallet(identity.operatorKey(), identity.operatorName(), request));
}
@PostMapping("/current/complete") @PostMapping("/current/complete")
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Action request) { @Valid @RequestBody ReceivingContainerRequests.Action request) {
@@ -23,4 +23,6 @@ public final class ReceivingContainerRequests {
@Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { } @Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { }
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) { } @NotBlank @Size(max = 255) String idempotencyKey) { }
public record WholePalletReceive(@NotBlank String sourcePalletNo,
@NotBlank @Size(max = 255) String idempotencyKey) { }
} }
@@ -56,7 +56,7 @@ public final class ReceivingContainerResponses {
String fbaWarehouseCode, int plannedPalletCount, int totalPackages, String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages, int receivedPackages, int pendingPackages,
boolean assigned, boolean mergeAllowed, boolean urgent, boolean assigned, boolean mergeAllowed, boolean urgent,
String mergeHint) { } String mergeHint, String orderType, boolean wholePalletInbound) { }
public record CartonResolved(String cartonMark, String inboundCode, public record CartonResolved(String cartonMark, String inboundCode,
String destinationType, String overseasWarehouseId, String destinationType, String overseasWarehouseId,
@@ -361,6 +361,46 @@ public class ReceivingContainerService {
} }
} }
@Transactional
public ReceivingContainerResponses.Receipt receiveWholePallet(String operatorKey, String operatorName,
ReceivingContainerRequests.WholePalletReceive request) {
ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<DropshipPalletRef> matches = new ArrayList<>();
for (InboundRef ref : refs(customerId, session.getContainerNo())) {
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, customerId)
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
.eq(InboundPlanPallet::getSourcePalletNo, request.sourcePalletNo()));
for (InboundPlanPallet pallet : sourcePallets) {
List<InboundPlanPalletAllocation> palletAllocations = allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, customerId)
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId()));
palletAllocations.forEach(allocation -> matches.add(new DropshipPalletRef(ref, pallet, allocation)));
}
}
if (matches.isEmpty()) throw notFound("Source pallet is not part of the current container");
if (matches.size() != 1) throw conflict("Source pallet is duplicated inside the current container");
DropshipPalletRef match = matches.get(0);
int accepted = value(match.allocation().getPlannedPackageCount());
workflow.pickWholePallet(customerId, match.ref().plan().getPlanNo(), match.allocation().getId(),
new PdaRequests.WholePalletOperation(request.idempotencyKey() + ":PICK", operatorKey, operatorName));
markReceiving(session);
ReceivingContainerResponses.Inbound inbound = inbound(match.ref());
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), match.pallet().getPalletNo(),
accepted, inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(),
inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
save(session, "PALLET_RECEIVE", request.idempotencyKey(), request, result);
return result;
}
@Transactional(isolation = Isolation.READ_COMMITTED) @Transactional(isolation = Isolation.READ_COMMITTED)
public ReceivingContainerResponses.Session complete(String operatorKey, public ReceivingContainerResponses.Session complete(String operatorKey,
ReceivingContainerRequests.Action request) { ReceivingContainerRequests.Action request) {
@@ -705,6 +745,9 @@ public class ReceivingContainerService {
private record PalletSelection(InboundPlanPallet pallet, private record PalletSelection(InboundPlanPallet pallet,
WarehouseResourceGateway.WarehousePalletReference resource) { } WarehouseResourceGateway.WarehousePalletReference resource) { }
private record DropshipPalletRef(InboundRef ref, InboundPlanPallet pallet,
InboundPlanPalletAllocation allocation) { }
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) { private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream()
.map(this::inbound).toList(); .map(this::inbound).toList();
@@ -729,7 +772,8 @@ 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),
assigned(ref.plan()), mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan())); assigned(ref.plan()), mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()),
ref.order().getOrderType(), "ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()));
} }
private boolean assigned(InboundPlan plan) { private boolean assigned(InboundPlan plan) {
@@ -121,6 +121,13 @@ paths:
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCarton' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCarton' } } } }
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } } responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } }
/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet:
post:
summary: 一件代发按完整来源托盘收货
description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描国内封托后冻结的来源托盘号,一次接收该托全部箱和 SKU 快照;不允许逐箱或拆分接收。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWholePallet' } } } }
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '404': { description: 来源托盘不属于当前柜 }, '409': { description: 非一件代发、来源托盘重复或已操作 } }
/api/pda/v1/last-leg/receiving-containers/current/complete: /api/pda/v1/last-leg/receiving-containers/current/complete:
post: post:
summary: 完成当前柜收货并入库存 summary: 完成当前柜收货并入库存
@@ -1518,6 +1525,12 @@ components:
cartonMark: { type: string, minLength: 1 } cartonMark: { type: string, minLength: 1 }
palletCode: { type: string, minLength: 1 } palletCode: { type: string, minLength: 1 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
ReceivingWholePallet:
type: object
required: [sourcePalletNo, idempotencyKey]
properties:
sourcePalletNo: { type: string, minLength: 1 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
ReceivingApiResponseBase: ReceivingApiResponseBase:
type: object type: object
required: [code, message, timestamp] required: [code, message, timestamp]
@@ -1529,7 +1542,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, assigned, mergeAllowed, urgent] required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages, assigned, mergeAllowed, urgent, wholePalletInbound]
properties: properties:
inboundCode: { type: string } inboundCode: { type: string }
destinationType: { type: string, enum: [FBA, OVERSEAS] } destinationType: { type: string, enum: [FBA, OVERSEAS] }
@@ -1641,6 +1654,8 @@ components:
mergeAllowed: { type: boolean, description: 现场可选择合托,不作为实收或完成门禁 } mergeAllowed: { type: boolean, description: 现场可选择合托,不作为实收或完成门禁 }
urgent: { type: boolean } urgent: { type: boolean }
mergeHint: { type: string, nullable: true } mergeHint: { type: string, nullable: true }
orderType: { type: string, nullable: true }
wholePalletInbound: { type: boolean, description: 一件代发必须按完整来源托盘扫描收货 }
ReceivingCandidateListApiResponse: ReceivingCandidateListApiResponse:
allOf: allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' } - { $ref: '#/components/schemas/ReceivingApiResponseBase' }
@@ -128,14 +128,15 @@ class ReceivingContainerContractTest {
assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); assertTrue(yaml.contains(prefix + "/current/receipts/bulk:"));
assertFalse(yaml.contains(prefix + "/current/skus/resolve:")); assertFalse(yaml.contains(prefix + "/current/skus/resolve:"));
assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); assertTrue(yaml.contains(prefix + "/current/receipts/carton:"));
assertTrue(yaml.contains(prefix + "/current/receipts/pallet:"));
assertTrue(yaml.contains(prefix + "/current/complete:")); assertTrue(yaml.contains(prefix + "/current/complete:"));
assertTrue(yaml.contains(prefix + "/current/unload-photos/files/{fileId}/download:")); assertTrue(yaml.contains(prefix + "/current/unload-photos/files/{fileId}/download:"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}")); assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}"));
String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:"));
List<String> successResponses = receivingPaths.lines().map(String::trim) List<String> successResponses = receivingPaths.lines().map(String::trim)
.filter(line -> line.startsWith("responses: { '200':")).toList(); .filter(line -> line.startsWith("responses: { '200':")).toList();
assertEquals(16, successResponses.size()); assertEquals(17, successResponses.size());
assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); assertEquals(13, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count());
assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count());
assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜"));
assertTrue(receivingPaths.contains("仅 PLATFORM OVERSEAS_WAREHOUSE_PDA_OPERATOR 可用")); assertTrue(receivingPaths.contains("仅 PLATFORM OVERSEAS_WAREHOUSE_PDA_OPERATOR 可用"));
@@ -743,6 +743,38 @@ class ReceivingContainerServiceBehaviorTest {
verify(cartons, never()).insert(any(InboundPlanCarton.class)); verify(cartons, never()).insert(any(InboundPlanCarton.class));
} }
@Test
void dropshipReceivesTheCompleteFrozenSourcePalletThroughTheContainerSession() {
stubCurrent("OVERSEAS", "CONT-A");
LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectById(1L)).thenReturn(dropship);
InboundPlanPallet pallet = planPallet();
pallet.setPalletNo("PAL-PHYSICAL-1"); pallet.setSourcePalletNo("DP-260909-0001");
InboundPlanPalletAllocation pending = new InboundPlanPalletAllocation();
pending.setId(30L); pending.setCustomerId("100"); pending.setPlanId(2L);
pending.setLineId(10L); pending.setPalletId(20L); pending.setPlannedPackageCount(2);
InboundPlanPalletAllocation received = new InboundPlanPalletAllocation();
received.setId(30L); received.setCustomerId("100"); received.setPlanId(2L);
received.setLineId(10L); received.setPalletId(20L); received.setPlannedPackageCount(2);
received.setPickedPackageCount(2);
when(pallets.selectList(any())).thenReturn(List.of(pallet));
when(allocations.selectList(any())).thenReturn(List.of(pending), List.of(received));
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
var result = service.receiveWholePallet("7", "receiver",
new ReceivingContainerRequests.WholePalletReceive("DP-260909-0001", "whole-key"));
assertThat(result.acceptedPackages()).isEqualTo(2);
assertThat(result.receivedPackages()).isEqualTo(2);
assertThat(result.pendingPackages()).isZero();
assertThat(result.palletCode()).isEqualTo("PAL-PHYSICAL-1");
verify(workflow).pickWholePallet(org.mockito.ArgumentMatchers.eq("100"),
org.mockito.ArgumentMatchers.eq("PLAN-1"), org.mockito.ArgumentMatchers.eq(30L),
any(com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests.WholePalletOperation.class));
verifyNoInteractions(warehouse);
}
private void stubCurrent(String destinationType, String containerNo) { private void stubCurrent(String destinationType, String containerNo) {
InboundPlan plan = plan(containerNo); InboundPlan plan = plan(containerNo);
InboundPlanLine line = line(destinationType); InboundPlanLine line = line(destinationType);
@@ -1067,11 +1067,14 @@ export async function run(context) {
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS) `processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS)
let sealedCartonMarks = [] let sealedCartonMarks = []
let sealedSourcePalletNos = []
if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') { if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') {
const sealed = await completeDropshipPalletization(context, { const sealed = await completeDropshipPalletization(context, {
inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement, inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement,
}) })
sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : [] sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : []
sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED'
? (sealed.pallets || []).map((item) => item?.palletNo).filter(Boolean) : []
completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10) completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10)
finalResponse = completed.response finalResponse = completed.response
finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse, finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse,
@@ -1093,7 +1096,7 @@ export async function run(context) {
OPTIONS) OPTIONS)
context.createdResources.inboundMeasurement = { context.createdResources.inboundMeasurement = {
sealedCartonMarks, sealedCartonMarks, sealedSourcePalletNos,
marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId), marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId),
boxCode, finalStatus: finalMeasurement?.measurementStatus || null, boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
processStatus: finalMeasurement?.processStatus || null, processStatus: finalMeasurement?.processStatus || null,
@@ -506,6 +506,7 @@ export async function run(context) {
cartonMark: fixture.cartonMark || null, cartonMark: fixture.cartonMark || null,
inboundCode: fixture.inboundCode || null, inboundCode: fixture.inboundCode || null,
sealedCartonMarks: Array.isArray(fixture.sealedCartonMarks) ? fixture.sealedCartonMarks : [], sealedCartonMarks: Array.isArray(fixture.sealedCartonMarks) ? fixture.sealedCartonMarks : [],
sealedSourcePalletNos: Array.isArray(fixture.sealedSourcePalletNos) ? fixture.sealedSourcePalletNos : [],
cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [], cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [],
skuCodes: Array.isArray(fixture.skuCodes) ? fixture.skuCodes : [], skuCodes: Array.isArray(fixture.skuCodes) ? fixture.skuCodes : [],
skuLines: Array.isArray(fixture.skuLines) ? fixture.skuLines : [], skuLines: Array.isArray(fixture.skuLines) ? fixture.skuLines : [],
@@ -1607,6 +1607,7 @@ export async function run(context) {
context.createdResources.phase3Loading = { context.createdResources.phase3Loading = {
inboundCode: initial?.summary?.inboundCode || source?.inboundCode, inboundCode: initial?.summary?.inboundCode || source?.inboundCode,
sealedCartonMarks: Array.isArray(source?.sealedCartonMarks) ? source.sealedCartonMarks : [], sealedCartonMarks: Array.isArray(source?.sealedCartonMarks) ? source.sealedCartonMarks : [],
sealedSourcePalletNos: Array.isArray(source?.sealedSourcePalletNos) ? source.sealedSourcePalletNos : [],
tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId, tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId,
cartonMark: source?.boxCode || source?.cartonMark || null, cartonMark: source?.boxCode || source?.cartonMark || null,
cartonMarks: stableLoadedCartonMarks(stagedTask?.items), cartonMarks: stableLoadedCartonMarks(stagedTask?.items),
@@ -86,7 +86,7 @@ test('PDA module is injected, token-scoped, traceable, and contains no direct ne
test('PDA module covers the container-number receiving contract and excludes outbound domains', () => { test('PDA module covers the container-number receiving contract and excludes outbound domains', () => {
for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign', for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign',
'/current/cartons/resolve', '/current/pallets/resolve', '/current/cartons/resolve', '/current/pallets/resolve',
'/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token) '/current/receipts/bulk', '/current/receipts/carton', '/current/receipts/pallet', '/current/complete']) assert.ok(source.includes(token), token)
for (const token of ['/inventory/resolve', '/locations/resolve', '/pallets/resolve', '/operations?page=1&size=50']) { for (const token of ['/inventory/resolve', '/locations/resolve', '/pallets/resolve', '/operations?page=1&size=50']) {
assert.ok(source.includes(token), token) assert.ok(source.includes(token), token)
} }
@@ -564,6 +564,37 @@ async function receiveOverseas(context, token, inbounds, pallets, cartonFixtures
return true return true
} }
async function receiveDropship(context, token, producer, inbounds) {
const sourcePalletNos = Array.isArray(producer?.sealedSourcePalletNos)
? producer.sealedSourcePalletNos.map(id).filter(nonBlank) : []
const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0)
context.assert(MODULE, '一件代发来源托盘快照已从国内封托完整传递', null,
sourcePalletNos.length > 0 && new Set(sourcePalletNos).size === sourcePalletNos.length,
`sourcePallets=${sourcePalletNos.length}, pendingPackages=${expectedPending}`, OPTIONS)
if (!sourcePalletNos.length) return false
let accepted = 0
for (const [index, sourcePalletNo] of sourcePalletNos.entries()) {
const body = { sourcePalletNo, idempotencyKey: key(context, `dropship-pallet-${index}`) }
const first = await pda(context, token, 'POST', `一件代发整托实收${index + 1}`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', body)
if (!success(first.response)) return false
accepted += Number(first.data?.acceptedPackages || 0)
const replay = await pda(context, token, 'POST', `一件代发整托${index + 1}同键同载荷重放`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet', body)
context.assert(MODULE, `一件代发整托${index + 1}幂等结果一致`, replay.response,
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
await expected(context, token, 'POST', `一件代发整托${index + 1}同键异载荷冲突`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet',
{ ...body, sourcePalletNo: `${sourcePalletNo}-MUTATED` }, conflict)
await expected(context, token, 'POST', `一件代发来源托盘重复收货被拒绝${index + 1}`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet',
{ ...body, idempotencyKey: key(context, `dropship-pallet-duplicate-${index}`) }, conflict)
}
context.assert(MODULE, '一件代发整托实收箱数与待收箱数守恒', null,
accepted === expectedPending, `accepted=${accepted}, expected=${expectedPending}`, OPTIONS)
return accepted === expectedPending
}
async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) {
if (runAuthorizationProbes && context.overseasToken) { if (runAuthorizationProbes && context.overseasToken) {
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken)
@@ -666,7 +697,8 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
} }
const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA') const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA')
const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS') const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS')
if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { const strictDropship = preferredProducer?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
if (!strictDropship && fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) {
const firstFba = fbaInbounds[0] const firstFba = fbaInbounds[0]
const fbaCarton = cartonFixtures.find((item) => item.resolved.destinationType === 'FBA' const fbaCarton = cartonFixtures.find((item) => item.resolved.destinationType === 'FBA'
&& item.inbound.inboundCode === firstFba.inboundCode) && item.inbound.inboundCode === firstFba.inboundCode)
@@ -681,10 +713,10 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
for (const [index, inbound] of fbaInbounds.entries()) { for (const [index, inbound] of fbaInbounds.entries()) {
await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), cartonFixtures, index) await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), cartonFixtures, index)
} }
} else if (preferredProducer?.destinationType !== 'OVERSEAS') { } else if (!strictDropship && preferredProducer?.destinationType !== 'OVERSEAS') {
blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码')
} }
if (overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) { if (!strictDropship && overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) {
const firstOverseas = overseasInbounds[0] const firstOverseas = overseasInbounds[0]
const pallet = pallets.get(firstOverseas.inboundCode) const pallet = pallets.get(firstOverseas.inboundCode)
await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', {
@@ -692,9 +724,32 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'),
}, (response) => a0409Message(response, /^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden$/)) }, (response) => a0409Message(response, /^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden$/))
await receiveOverseas(context, token, overseasInbounds, pallets, cartonFixtures) await receiveOverseas(context, token, overseasInbounds, pallets, cartonFixtures)
} else if (preferredProducer?.destinationType !== 'FBA') { } else if (!strictDropship && preferredProducer?.destinationType !== 'FBA') {
blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码')
} }
if (strictDropship) {
const dropshipInbounds = [...fbaInbounds, ...overseasInbounds]
const firstInbound = dropshipInbounds[0]
const pallet = firstInbound ? pallets.get(firstInbound.inboundCode) : null
if (!firstInbound || !pallet) {
blockerGap(context, '一件代发整托链', '当前一件代发柜没有带预留实体托盘的入仓码')
return
}
if (firstInbound.destinationType === 'OVERSEAS') {
await expected(context, token, 'POST', '一件代发硬拒绝逐箱收货',
'/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', {
cartonMark: cartonFixtures[0]?.cartonMark,
palletCode: pallet.palletCode, idempotencyKey: key(context, 'dropship-carton-forbidden'),
}, (response) => a0409Message(response, /^One-click dropship inbound must be operated by complete source pallet$/))
} else {
await expected(context, token, 'POST', '一件代发硬拒绝按箱数收货',
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', {
inboundCode: firstInbound.inboundCode, palletCode: pallet.palletCode,
packageCount: 1, idempotencyKey: key(context, 'dropship-bulk-forbidden'),
}, (response) => a0409Message(response, /^One-click dropship inbound must be picked by complete source pallet$/))
}
await receiveDropship(context, token, preferredProducer, dropshipInbounds)
}
const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current')
if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) {
blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response) blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response)