From ecf48886d5aea98d9dcc4dd6edf8298b3f12f826 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 16:34:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../firstleg/service/LoadingPickService.java | 14 +- .../service/LoadingPickServiceTest.java | 33 ++++ .../controller/InboundPlanController.java | 13 ++ .../ConvertContainerInboundPlanRequest.java | 10 ++ .../mapper/InboundPlanOrderMapper.java | 6 + .../service/InboundPlanService.java | 24 +++ .../openapi/overseas-business-v1.yaml | 15 ++ .../container-inbound-plan-batch-contract.mjs | 58 +++++++ .../phase4-container-inbound-plan-batch.mjs | 151 ++++++++++++++++++ ...ainer-inbound-plan-batch-contract.test.mjs | 104 ++++++++++++ 10 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertContainerInboundPlanRequest.java create mode 100644 scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs create mode 100644 scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs create mode 100644 scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index eb919c242..4fca039ff 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -802,6 +802,13 @@ public class LoadingPickService { } private Task view(LoadingPickTaskEntity task, Long warehouseId) { + var booking = repository.activeBookingByTask(task.getId()); + if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) { + throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录"); + } + if (warehouseId != null && !Objects.equals(warehouseId, booking.getWarehouseId())) { + throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或不属于当前仓库"); + } List source = warehouseId == null ? repository.items(task.getId()) : repository.items(task.getId(), warehouseId); Map> cartonsByItem = repository.cartonLabels(task.getId()).stream() @@ -828,17 +835,14 @@ public class LoadingPickService { : "COMPLETION_FAILED".equals(task.getTaskStatus()) ? "COMPLETION_FAILED" : assigned == null ? "UNASSIGNED" : repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS"; - var booking = repository.activeBookingByTask(task.getId()); - if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) { - throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录"); - } var owners = repository.inboundOwners(task.getId()); Long detailInboundId = source.isEmpty() ? null : source.get(0).getInboundId(); var owner = detailInboundId == null ? null : owners.stream() .filter(value -> detailInboundId.equals(value.inboundId())).findFirst().orElse(null); var inbound = owner == null || owner.tenantId() == null || owner.inboundId() == null ? null : repository.inbound(owner.tenantId(), owner.inboundId()); - LoadingCompletionCommandEntity command = completionCommands.latest(task.getId()); + LoadingCompletionCommandEntity command = "LOADING".equals(task.getTaskStatus()) + ? null : completionCommands.latest(task.getId()); CompletionCommand completionCommand = command == null ? null : new CompletionCommand(command.getId(), command.getOperationId(), command.getCommandStatus(), command.getExpectedTaskVersion(), command.getRemark(), command.getAttemptCount(), command.getNextRetryAt(), command.getFailureStage(), command.getFailureCode(), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 0263816f7..f5ad4b8e7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -92,6 +92,7 @@ class LoadingPickServiceTest { ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(99L); booking.setContainerNo("CSLU1234567"); + booking.setWarehouseId(101L); booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE"); when(repository.activeBookingByTask(20L)).thenReturn(booking); @@ -242,6 +243,7 @@ class LoadingPickServiceTest { task.setContainerBookingId(40L); task.setCabinetNo("CAB-001"); var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); booking.setId(40L); booking.setContainerNo("MSCU-NEW"); + booking.setWarehouseId(101L); booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE"); when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task); when(repository.taskByPlan(10L, 101L)).thenReturn(task); @@ -424,6 +426,37 @@ class LoadingPickServiceTest { assertThat(result.sourceType()).isEqualTo("SIMULATION"); assertThat(result.recordStatus()).isEqualTo("ACTIVE"); assertThat(result.mergedIntoContainerBookingId()).isNull(); + verify(completionCommands, never()).latest(20L); + } + + @Test + void loadingTaskDetailDoesNotDependOnCompletionCommandStorage() { + LoadingPickTaskEntity task = task(); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L, 101L)).thenReturn(List.of(item())); + when(repository.cartonLabels(20L)).thenReturn(List.of()); + when(repository.inboundOwners(20L)).thenReturn(List.of()); + doThrow(new IllegalStateException("completion command schema unavailable")) + .when(completionCommands).latest(20L); + + var result = service.task(20L, 101L); + + assertThat(result.taskStatus()).isEqualTo("LOADING"); + assertThat(result.completionCommand()).isNull(); + verify(completionCommands, never()).latest(20L); + } + + @Test + void taskDetailRejectsWarehouseThatDoesNotOwnTheContainer() { + LoadingPickTaskEntity task = task(); + when(repository.task(20L)).thenReturn(task); + + assertThatThrownBy(() -> service.task(20L, 999L)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于当前仓库"); + + verify(repository, never()).items(20L, 999L); + verify(repository, never()).cartonLabels(20L); + verify(completionCommands, never()).latest(20L); } @Test diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 841d24fd6..42772e141 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; @@ -52,6 +53,18 @@ public class InboundPlanController { return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId())); } + @PostMapping("/convert-container") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") + @Operation(summary = "按整柜批量生成入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;每个入仓码独立生成计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) + public ApiResponse> convertContainer( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ConvertContainerInboundPlanRequest request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可按整柜生成入库计划"); + } + return ApiResponse.success(service.convertContainer(request.sourceContainerBookingId())); + } + @GetMapping("/{planNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") /** 查询入仓计划及其明细、托盘和分配信息。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertContainerInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertContainerInboundPlanRequest.java new file mode 100644 index 000000000..b0908fdda --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertContainerInboundPlanRequest.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** 平台按来源头程柜预订身份批量生成入库计划。 */ +public record ConvertContainerInboundPlanRequest( + @NotBlank @Size(max = 128) String sourceContainerBookingId +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 56d3dda53..544252195 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -12,6 +12,12 @@ import java.util.List; @Mapper public interface InboundPlanOrderMapper { + record ContainerOrderRef(String customerId, Long orderId, String currentStatus) { } + + @Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus " + + "FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE") + List lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId); + @Select("SELECT container_no FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 5509c1510..3ec5eca9f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -121,6 +121,30 @@ public class InboundPlanService { return detail(customerId, plan); } + /** 同一来源柜的每个尾程订单独立建计划;整个柜级批次共用一个事务。 */ + @Transactional + public List convertContainer(String sourceContainerBookingId) { + String bookingId = trimToNull(sourceContainerBookingId); + if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空"); + List refs = orderMapper.lockContainerOrders(bookingId); + if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单"); + boolean hasEligible = refs.stream().anyMatch(ref -> "PENDING_ASSIGNMENT".equals(ref.currentStatus()) + || "DESTINATION_ASSIGNED".equals(ref.currentStatus())); + if (!hasEligible) { + List replay = refs.stream().map(ref -> { + InboundPlan existing = planMapper.findBySourceOrder(ref.customerId(), ref.orderId()); + if (existing == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜转换状态不一致"); + return detail(ref.customerId(), existing); + }).toList(); + return replay; + } + if (refs.stream().anyMatch(ref -> !("PENDING_ASSIGNMENT".equals(ref.currentStatus()) + || "DESTINATION_ASSIGNED".equals(ref.currentStatus())))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单"); + } + return refs.stream().map(ref -> convert(ref.customerId(), ref.orderId())).toList(); + } + @Transactional(readOnly = true) public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 222db0c4b..e493e5f62 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -172,6 +172,21 @@ paths: customerId: { type: string, pattern: '^[1-9][0-9]*$' } orderId: { type: string, pattern: '^[1-9][0-9]*$' } responses: { '200': { description: 统一 ApiResponse 包装的入库计划详情 } } + /api/platform/v1/last-leg/inbound-plans/convert-container: + post: + summary: 按整柜批量生成入库计划 + description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单。每个尾程订单/入仓码独立生成一个入库计划,整批原子且幂等。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sourceContainerBookingId] + properties: + sourceContainerBookingId: { type: string, minLength: 1, maxLength: 128 } + responses: { '200': { description: 统一 ApiResponse 包装的入库计划数组 } } /api/platform/v1/last-leg/dispatches: get: summary: 分页查询排车单 diff --git a/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs b/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs new file mode 100644 index 000000000..b3d0cf419 --- /dev/null +++ b/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs @@ -0,0 +1,58 @@ +export const CONTAINER_CONVERT_ENDPOINT = '/api/platform/v1/last-leg/inbound-plans/convert' +export const CONTAINER_CONVERT_EXPECTED_ORDERS = 15 + +export function groupOrdersByCustomer(orders) { + if (!Array.isArray(orders)) return new Map() + const result = new Map() + for (const order of orders) { + const customerId = String(order?.customerId ?? '').trim() + if (!customerId) return new Map() + result.set(customerId, [...(result.get(customerId) || []), order]) + } + return result +} + +export function validContainerBatchResult(result, sourceOrders) { + const plans = Array.isArray(result?.plans) ? result.plans : [] + if (!Array.isArray(sourceOrders) || !sourceOrders.length || plans.length !== sourceOrders.length) return false + const sourceIds = new Set(sourceOrders.map((order) => String(order.orderId))) + const resultIds = plans.flatMap((plan) => Array.isArray(plan?.lines) ? plan.lines : []) + .map((line) => String(line?.orderId)) + return resultIds.length === sourceIds.size + && new Set(resultIds).size === sourceIds.size + && resultIds.every((orderId) => sourceIds.has(orderId)) + && plans.every((plan) => { + const lines = Array.isArray(plan?.lines) ? plan.lines : [] + const expected = sourceOrders.find((order) => String(order.orderId) === String(lines[0]?.orderId)) + return plan?.status === 'PENDING_ASSIGNMENT' + && lines.length === 1 && expected != null + && String(plan?.customerId ?? '') === String(expected.customerId) + && String(plan?.planNo ?? '').length > 0 + && Array.isArray(plan?.pallets) && Array.isArray(plan?.allocations) + }) +} + +export function validOrderFact(actual, expected) { + const exact = (field) => (actual?.[field] ?? null) === (expected?.[field] ?? null) + return ['packageCount', 'volumeCbm', 'productQuantity', 'volumeWeightKg', + 'palletCount', 'estimatedArrivalAt', 'actualArrivalAt', 'estimatedWarehouseAt'].every(exact) +} + +export function distinctPhysicalPalletCount(rows) { + if (!Array.isArray(rows)) return 0 + return new Set(rows.map((row) => String(row?.physicalPalletId ?? '').trim()).filter(Boolean)).size +} + +export function zeroDrift(before, after) { + return JSON.stringify(before) === JSON.stringify(after) +} + +export function stableReplay(first, replay) { + return zeroDrift(first, replay) +} + +export function validPdaCandidate(candidate, expected) { + return String(candidate?.containerNo ?? '') === String(expected?.containerNo ?? '') + && Number(candidate?.planCount) === Number(expected?.planCount) + && Number(candidate?.inboundCodeCount) === Number(expected?.inboundCodeCount) +} diff --git a/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs b/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs new file mode 100644 index 000000000..dbfe33ebe --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs @@ -0,0 +1,151 @@ +import { + CONTAINER_CONVERT_ENDPOINT, + CONTAINER_CONVERT_EXPECTED_ORDERS, + validContainerBatchResult, + validOrderFact, + validPdaCandidate, + zeroDrift, +} from './container-inbound-plan-batch-contract.mjs' + +const MODULE = 'phase4-container-inbound-plan-batch' +const OPTIONS = { scenario: MODULE, deploymentBlocker: true } +const id = (value) => value == null ? '' : String(value) +const records = (data) => Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] +const success = (response) => response?.http === 200 && String(response?.body?.code) === '200' +const trace = (response) => `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +const rejected = (response, codes) => response?.http === 200 && codes.includes(String(response?.body?.code)) + +function gap(context, name, reason, priority = 'P0') { + context.addCoverageGap(MODULE, MODULE, name, reason, 'fixture', { deploymentBlocker: priority === 'P0' }) + context.addStep(MODULE, name, null, false, `${priority}: ${reason}`, OPTIONS) + throw new Error(`${priority} blocking coverage gap: ${name}: ${reason}`) +} + +function fixture(context) { + const value = context.createdResources?.phase4ContainerInboundPlanFixture + if (value?.marker !== context.marker || !value?.normal?.containerNo + || !Array.isArray(value.normal.orders) || value.normal.orders.length !== CONTAINER_CONVERT_EXPECTED_ORDERS) { + gap(context, '整柜15票新数据前置', '缺少本轮marker的15票同柜正常fixture,禁止借用历史已转换数据') + } + for (const name of ['invalid', 'partial']) { + if (!value[name]?.containerNo || !Array.isArray(value[name]?.orders)) { + gap(context, `${name}整柜前置`, `缺少本轮${name}专用fixture,不能验证整批零漂移`, 'P1') + } + } + if (!value.pdaToken) gap(context, 'PDA候选Token前置', '缺少绑定真实海外仓的WAREHOUSE_PICKER海外Token') + return value +} + +async function listOrders(context, containerNo) { + const response = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) + return { response, rows: records(response?.body?.data).filter((row) => row?.containerNo === containerNo) } +} + +async function snapshot(context, source) { + const listed = await listOrders(context, source.containerNo) + const plans = [] + for (const order of listed.rows) { + if (!order?.inboundCode) continue + const response = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/inbound-plans?page=1&size=200&inboundCode=${encodeURIComponent(order.inboundCode)}`) + plans.push(...records(response?.body?.data).map((row) => ({ + planId: id(row.planId), planNo: row.planNo, orderId: id(row.orderId), customerId: id(row.customerId), status: row.status, + }))) + } + return { + orders: listed.rows.map((row) => ({ + id: id(row.id), customerId: id(row.customerId), currentStatus: row.currentStatus, + inboundOrderNo: row.inboundOrderNo ?? null, palletCount: row.palletCount ?? null, + })).sort((a, b) => a.id.localeCompare(b.id)), + plans: plans.sort((a, b) => `${a.planId}:${a.orderId}`.localeCompare(`${b.planId}:${b.orderId}`)), + } +} + +function convertBody(source) { + const anchor = source.orders[0] + return { orderId: id(anchor.orderId), customerId: id(anchor.customerId) } +} + +async function convert(context, name, source, key) { + const response = await context.requestOverseas('POST', CONTAINER_CONVERT_ENDPOINT, + convertBody(source), {}, context.overseasToken, { 'Idempotency-Key': key }) + context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) + return response +} + +export async function run(context) { + const data = fixture(context) + const normalList = await listOrders(context, data.normal.containerNo) + context.assert(MODULE, 'P0同柜15票列表前置', normalList.response, + success(normalList.response) && normalList.rows.length === CONTAINER_CONVERT_EXPECTED_ORDERS + && normalList.rows.every((row) => typeof row.id === 'string' && typeof row.customerId === 'string'), + `orders=${normalList.rows.length}; ${trace(normalList.response)}`, OPTIONS) + + for (const expected of data.normal.orders) { + const actual = normalList.rows.find((row) => id(row.id) === id(expected.orderId)) + context.assert(MODULE, `P2订单事实不猜算:${id(expected.orderId)}`, normalList.response, + Boolean(actual) && validOrderFact(actual, expected), + `customerId=${id(expected.customerId)}, inboundCode=${expected.inboundCode || 'missing'}`, { ...OPTIONS, deploymentBlocker: false }) + } + + const invalidBefore = await snapshot(context, data.invalid) + const invalid = await convert(context, 'P1任一非法票整柜转换拒绝', data.invalid, `${context.marker}-invalid-container`) + const invalidAfter = await snapshot(context, data.invalid) + context.assert(MODULE, 'P1非法票整批零漂移', invalid, + rejected(invalid, ['A0400', 'A0409']) && zeroDrift(invalidBefore, invalidAfter), + trace(invalid), OPTIONS) + + const partialBefore = await snapshot(context, data.partial) + const partial = await convert(context, 'P1部分已转换整柜冲突', data.partial, `${context.marker}-partial-container`) + const partialAfter = await snapshot(context, data.partial) + context.assert(MODULE, 'P1部分转换冲突零漂移', partial, + rejected(partial, ['A0409']) && zeroDrift(partialBefore, partialAfter), trace(partial), OPTIONS) + + const key = `${context.marker}-container-convert` + const first = await convert(context, 'P0整柜一次转换并逐订单生成独立计划', data.normal, key) + const result = first?.body?.data + context.assert(MODULE, 'P0整柜响应覆盖全部订单和客户计划', first, + success(first) && validContainerBatchResult(result, data.normal.orders), trace(first), OPTIONS) + + const replay = await convert(context, 'P1同键同请求重放', data.normal, key) + context.assert(MODULE, 'P1重放计划号与明细零新增', replay, + success(replay) && zeroDrift(result, replay?.body?.data), trace(replay), OPTIONS) + + const concurrentKey = `${context.marker}-container-double-click` + const concurrent = await Promise.all([ + convert(context, 'P1并发双击A', data.normal, concurrentKey), + convert(context, 'P1并发双击B', data.normal, concurrentKey), + ]) + context.assert(MODULE, 'P1并发双击收敛同一批计划', concurrent[1], + concurrent.every(success) && zeroDrift(concurrent[0].body?.data, concurrent[1].body?.data), + concurrent.map(trace).join('; '), OPTIONS) + + const after = await listOrders(context, data.normal.containerNo) + context.assert(MODULE, 'P0全部15票推进INBOUND_PLANNED', after.response, + success(after.response) && after.rows.length === CONTAINER_CONVERT_EXPECTED_ORDERS + && after.rows.every((row) => row.currentStatus === 'INBOUND_PLANNED' && row.inboundOrderNo), + `planned=${after.rows.filter((row) => row.currentStatus === 'INBOUND_PLANNED').length}; ${trace(after.response)}`, OPTIONS) + + const candidateResponse = await context.requestOverseas('GET', + '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, data.pdaToken) + const candidate = Array.isArray(candidateResponse?.body?.data) + ? candidateResponse.body.data.find((row) => row?.containerNo === data.normal.containerNo) : null + context.assert(MODULE, 'P0同柜PDA候选计划数与入仓码数一致', candidateResponse, + success(candidateResponse) && validPdaCandidate(candidate, { + containerNo: data.normal.containerNo, + planCount: data.normal.orders.length, + inboundCodeCount: data.normal.orders.length, + }), trace(candidateResponse), OPTIONS) + + const carton = data.normal.orders.flatMap((order) => order.cartonMarks || []) + .map((cartonMark) => ({ cartonMark })).at(0) + if (!carton) gap(context, 'P3箱码命中fixture', '正常整柜没有可用于resolve的冻结箱码', 'P3') + const prepared = data.preparedSession + if (!prepared) gap(context, 'P3箱码命中会话', '需先由PDA合同完成prepare+三照片+sign,再执行resolve', 'P3') + const resolved = await context.requestOverseas('POST', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', carton, {}, data.pdaToken) + context.assert(MODULE, 'P3箱码服务端命中当前柜唯一入仓码', resolved, + success(resolved) && data.normal.orders.some((order) => order.inboundCode === resolved?.body?.data?.inboundCode), + trace(resolved), { ...OPTIONS, deploymentBlocker: false }) +} diff --git a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs new file mode 100644 index 000000000..783bdbab3 --- /dev/null +++ b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + CONTAINER_CONVERT_ENDPOINT, + CONTAINER_CONVERT_EXPECTED_ORDERS, + distinctPhysicalPalletCount, + groupOrdersByCustomer, + stableReplay, + validContainerBatchResult, + validOrderFact, + validPdaCandidate, + zeroDrift, +} from './container-inbound-plan-batch-contract.mjs' + +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const scenario = read('phase4-container-inbound-plan-batch.mjs') +const controller = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java') +const service = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java') +const openapi = read('../../lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml') +const pda = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java') +const adminApi = read('../../../apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') +const adminView = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue') + +function orders() { + return Array.from({ length: 15 }, (_, index) => ({ + orderId: String(9_000 + index), customerId: index < 10 ? '101' : '202', + packageCount: 2, volumeCbm: '1.2500', productQuantity: 20, + palletCount: 1, volumeWeightKg: '18.750', + estimatedArrivalAt: '2026-09-01T08:30:00', actualArrivalAt: null, estimatedWarehouseAt: null, + })) +} + +test('semantic contract creates one independent plan per order even for the same customer', () => { + const source = orders() + assert.equal(CONTAINER_CONVERT_EXPECTED_ORDERS, 15) + assert.equal(groupOrdersByCustomer(source).size, 2) + const result = { plans: source.map((order, index) => ({ + customerId: order.customerId, planNo: `LIP-${index + 1}`, status: 'PENDING_ASSIGNMENT', + lines: [{ orderId: order.orderId }], pallets: [{}], allocations: [{}], + })) } + assert.equal(validContainerBatchResult(result, source), true) +}) + +test('facts preserve source values and physical pallets are not counted once per SKU', () => { + const expected = orders()[0] + assert.equal(validOrderFact({ ...expected }, expected), true) + assert.equal(validOrderFact({ ...expected, actualArrivalAt: '2026-09-02T08:00:00' }, expected), false) + assert.equal(distinctPhysicalPalletCount([ + { physicalPalletId: 'P1', sku: 'A' }, { physicalPalletId: 'P1', sku: 'B' }, { physicalPalletId: 'P2', sku: 'A' }, + ]), 2) +}) + +test('atomic failure, replay and PDA count helpers reject drift', () => { + const snapshot = { orders: [{ id: '1', status: 'PENDING_ASSIGNMENT' }], plans: [] } + assert.equal(zeroDrift(snapshot, structuredClone(snapshot)), true) + assert.equal(stableReplay({ plans: [{ planNo: 'LIP-1' }] }, { plans: [{ planNo: 'LIP-1' }] }), true) + assert.equal(validPdaCandidate({ containerNo: 'C1', planCount: '2', inboundCodeCount: '15' }, + { containerNo: 'C1', planCount: 2, inboundCodeCount: 15 }), true) +}) + +test('HTTP scenario freezes normal, invalid, replay, concurrency, partial and PDA evidence', () => { + assert.equal(CONTAINER_CONVERT_ENDPOINT, '/api/platform/v1/last-leg/inbound-plans/convert') + for (const marker of ['P0整柜一次转换并逐订单生成独立计划', 'P1任一非法票整柜转换拒绝', + 'P1同键同请求重放', 'P1并发双击A', 'P1部分已转换整柜冲突', + 'P0同柜PDA候选计划数与入仓码数一致', 'P3箱码服务端命中当前柜唯一入仓码']) { + assert.match(scenario, new RegExp(marker)) + } + assert.match(scenario, /Promise\.all/) + assert.match(scenario, /Idempotency-Key/) + assert.match(scenario, /requestId/) + assert.match(scenario, /traceId/) +}) + +test('platform API keeps two long string identifiers and Admin uses the selected row customer', () => { + const endpoint = openapi.slice(openapi.indexOf(` ${CONTAINER_CONVERT_ENDPOINT}:`), + openapi.indexOf(' /api/platform/v1/last-leg/dispatches:', openapi.indexOf(` ${CONTAINER_CONVERT_ENDPOINT}:`))) + assert.match(endpoint, /required: \[customerId, orderId\]/) + assert.match(endpoint, /customerId:\s*\{ type: string/) + assert.match(endpoint, /orderId:\s*\{ type: string/) + assert.match(controller, /convertScope\(user, request\.customerId\(\)\)/) + assert.match(adminApi, /orderId: string[\s\S]{0,80}customerId: string/) + assert.match(adminView, /selectedRows\.value\.length !== 1/) + assert.match(adminView, /customerId = typeof selected\.customerId === 'string' \? selected\.customerId\.trim\(\)/) + assert.match(adminView, /orderId: String\(selected\.id\), customerId/) +}) + +test('backend batch conversion is one transaction and never merges same-customer orders', () => { + assert.match(controller, /ApiResponse<[^>]*(?:Batch|List|Container)[^>]*>/) + assert.match(service, /@Transactional[\s\S]{0,120}(?:convertContainer|convertBatch|convert)/) + assert.match(service, /(?:List|Container.*Response|Batch.*Response)/) + assert.match(service, /(?:for \([^)]*(?:order|source)|\.map\([^)]*(?:order|source))/) + assert.doesNotMatch(service, /groupingBy\([^)]*(?:customerId|getCustomerId)/) + assert.match(service, /(?:partial|部分已转换|already belongs)[\s\S]{0,180}(?:CONFLICT|HttpStatus\.CONFLICT)/i) +}) + +test('PDA candidate and resolve contracts remain server-owned', () => { + assert.match(pda, /grouped\.computeIfAbsent\(plan\.getContainerNo\(\)/) + assert.match(pda, /entry\.getValue\(\)\.size\(\)/) + assert.match(pda, /totals\.inboundCount\(\)/) + assert.match(pda, /requireReceivingAvailable\(customerId, containerNo\)/) + assert.match(pda, /requireCartonRef|requireUnmatchedFbaCartonPrefix/) +})