This commit is contained in:
2026-08-27 16:34:30 +08:00
parent 1db3e48506
commit ecf48886d5
10 changed files with 423 additions and 5 deletions
@@ -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<LoadingPickItemEntity> source = warehouseId == null
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
Map<Long, List<CartonLabelRow>> 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(),
@@ -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
@@ -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<java.util.List<InboundPlanResponse>> 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 + "')")
/** 查询入仓计划及其明细、托盘和分配信息。 */
@@ -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
) {
}
@@ -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<ContainerOrderRef> 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);
@@ -121,6 +121,30 @@ public class InboundPlanService {
return detail(customerId, plan);
}
/** 同一来源柜的每个尾程订单独立建计划;整个柜级批次共用一个事务。 */
@Transactional
public List<InboundPlanResponse> convertContainer(String sourceContainerBookingId) {
String bookingId = trimToNull(sourceContainerBookingId);
if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空");
List<InboundPlanOrderMapper.ContainerOrderRef> 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<InboundPlanResponse> 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) {
@@ -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: 分页查询排车单
@@ -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)
}
@@ -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 })
}
@@ -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<InboundPlanResponse>|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/)
})