Merge remote-tracking branch 'origin/test' into dev
# Conflicts: # scripts/admin-test-env-modules/phase4-tail-order-controls.mjs
This commit is contained in:
+5
-2
@@ -209,13 +209,16 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId);
|
||||
List<String> sourcePalletNos = plans.sourcePalletNos(
|
||||
order.customerId(), Long.parseLong(order.orderId()), order.orderType());
|
||||
if (sourcePalletNos.size() != order.suggestedPalletCount()) {
|
||||
boolean warehouseGeneratedPallets = trimToNull(order.fbaWarehouseCode()) == null
|
||||
&& !"ONE_CLICK_DROPSHIP".equals(order.orderType());
|
||||
if (!warehouseGeneratedPallets && sourcePalletNos.size() != order.suggestedPalletCount()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与柜级预览不一致");
|
||||
}
|
||||
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
|
||||
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
|
||||
localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
|
||||
trimToNull(order.fbaWarehouseCode()), sourcePalletNos.get(localSequence - 1)));
|
||||
trimToNull(order.fbaWarehouseCode()), warehouseGeneratedPallets
|
||||
? null : sourcePalletNos.get(localSequence - 1)));
|
||||
}
|
||||
}
|
||||
Preparation preparation = new Preparation(warehouseId, List.copyOf(items));
|
||||
|
||||
+32
@@ -258,6 +258,38 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
.map(ContainerInboundPlanConversionCoordinator.PreparedItem::fbaWarehouseCode).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void standardOverseasOrdersLetWarehouseGeneratePhysicalPalletCodes() {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
var overseas = new ContainerInboundPlanPreviewResponse.Order(
|
||||
"200", "Customer 200", "12", "LM-12", "RK-OVERSEAS", "FIRST_LEG", "FIRST_LEG",
|
||||
"31", "US-LAX-01", "Los Angeles", null, 10, BigDecimal.ONE, 2,
|
||||
null, null, null, 0, "READY", List.of());
|
||||
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
|
||||
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 2, 2, 0, 1, 0, true,
|
||||
List.of(new ContainerInboundPlanPreviewResponse.Group("FIRST_LEG", "31", "US-LAX-01", "Los Angeles",
|
||||
1, 1, 2, 0, 2, 0, List.of(overseas))));
|
||||
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
|
||||
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-OVERSEAS");
|
||||
when(plans.sourcePalletNos("200", 12L, "FIRST_LEG")).thenReturn(List.of());
|
||||
|
||||
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
|
||||
1L, 2L, BOOKING_ID, "FIRST-KEY", request,
|
||||
ReflectionTestUtils.invokeMethod(coordinator, "hash",
|
||||
List.of(BOOKING_ID, request.snapshotVersion(), request.snapshotHash())));
|
||||
ContainerInboundPlanConversionCoordinator.Preparation preparation;
|
||||
try {
|
||||
preparation = objectMapper.readValue(command.getPreparationJson(),
|
||||
ContainerInboundPlanConversionCoordinator.Preparation.class);
|
||||
} catch (Exception exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
|
||||
assertEquals(2, preparation.items().size());
|
||||
assertEquals(java.util.Arrays.asList(null, null), preparation.items().stream()
|
||||
.map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList());
|
||||
}
|
||||
|
||||
private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId,
|
||||
String inboundCode, String destinationCode,
|
||||
String fbaWarehouseCode) {
|
||||
|
||||
@@ -78,6 +78,70 @@ async function request(context, method, path, body, headers = {}) {
|
||||
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPdf(context, path, body) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/pdf', Authorization: `Bearer ${context.token}`,
|
||||
'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify(body), signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const bytes = new Uint8Array(await response.arrayBuffer())
|
||||
return { method: 'POST', path, http: response.status, body: null, raw: null,
|
||||
contentType: response.headers.get('content-type'), byteLength: bytes.length,
|
||||
pdfMagic: String.fromCharCode(...bytes.slice(0, 4)),
|
||||
requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method: 'POST', path, http: 0, body: null, raw: String(error), contentType: null,
|
||||
byteLength: 0, pdfMagic: null, requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPalletLabels(context, fixture, loadingPlanId) {
|
||||
if (fixture.destinationType !== 'FBA' || fixture.fulfillmentType === 'ONE_CLICK_DROPSHIP') return null
|
||||
const warehouseId = id(fixture.warehouseId)
|
||||
const base = `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(loadingPlanId)}/pallet-labels`
|
||||
const path = `${base}/prepare?warehouseId=${encodeURIComponent(warehouseId)}`
|
||||
const preparedResponse = await context.request('POST', path, {})
|
||||
const manifest = context.apiSuccess(MODULE, '生成并读取按 FBA 仓分组的散货托盘码清单', preparedResponse, '', OPTIONS)
|
||||
const labels = (manifest?.groups || []).flatMap((group) => group?.labels || [])
|
||||
const codesValid = labels.length > 0 && labels.every((label) => {
|
||||
const index = Number(label?.palletIndex)
|
||||
return id(label?.containerNo) === id(fixture.containerNo)
|
||||
&& id(label?.destinationWarehouseInternalCode)
|
||||
&& id(label?.palletCode) === `${fixture.containerNo}-${label.destinationWarehouseInternalCode}-${String(index).padStart(3, '0')}`
|
||||
})
|
||||
const indexesValid = (manifest?.groups || []).every((group) =>
|
||||
(group?.labels || []).every((label, index) => Number(label?.palletIndex) === index + 1))
|
||||
context.assert(MODULE, '散货托盘码使用柜号-FBA内部编号-仓内连续下标', preparedResponse,
|
||||
id(manifest?.containerNo) === id(fixture.containerNo)
|
||||
&& Number(manifest?.totalCount) === labels.length && codesValid && indexesValid,
|
||||
`containerNo=${manifest?.containerNo || 'missing'}, groups=${manifest?.groups?.length ?? 0}, labels=${labels.length}`,
|
||||
OPTIONS)
|
||||
|
||||
const replayResponse = await context.request('POST', path, {})
|
||||
const replay = context.apiSuccess(MODULE, '重复生成散货托盘码返回稳定清单', replayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '散货托盘码重复生成零漂移', replayResponse,
|
||||
JSON.stringify(replay) === JSON.stringify(manifest), `labels=${labels.length}`, OPTIONS)
|
||||
|
||||
const printPath = `${base}/print?warehouseId=${encodeURIComponent(warehouseId)}`
|
||||
const allPdf = await requestPdf(context, printPath, { destinationWarehouseCode: null, count: null })
|
||||
context.addStep(MODULE, '打印全部 FBA 仓散货托盘码 PDF', allPdf,
|
||||
allPdf.http === 200 && allPdf.contentType?.toLowerCase().includes('application/pdf')
|
||||
&& allPdf.pdfMagic === '%PDF' && allPdf.byteLength > 0,
|
||||
`HTTP=${allPdf.http}, type=${allPdf.contentType || 'missing'}, bytes=${allPdf.byteLength}`, OPTIONS)
|
||||
const group = manifest?.groups?.[0]
|
||||
const selectedPdf = await requestPdf(context, printPath,
|
||||
{ destinationWarehouseCode: group?.destinationWarehouseCode, count: 1 })
|
||||
context.addStep(MODULE, '按指定 FBA 仓补打 1 张托盘码 PDF', selectedPdf,
|
||||
Boolean(group?.destinationWarehouseCode) && selectedPdf.http === 200
|
||||
&& selectedPdf.contentType?.toLowerCase().includes('application/pdf')
|
||||
&& selectedPdf.pdfMagic === '%PDF' && selectedPdf.byteLength > 0,
|
||||
`warehouse=${group?.destinationWarehouseCode || 'missing'}, HTTP=${selectedPdf.http}, bytes=${selectedPdf.byteLength}`,
|
||||
OPTIONS)
|
||||
return manifest
|
||||
}
|
||||
function blockResourceIdentity(resource) {
|
||||
return JSON.stringify({
|
||||
blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo),
|
||||
@@ -490,6 +554,7 @@ export async function run(context) {
|
||||
inventoryBlockSettlementVerified: expectedBlocks.length > 0
|
||||
&& releasedOrTransferredBlocks.every((item) => item.verified) && completedInventoryReleased,
|
||||
}
|
||||
const palletLabelManifest = await verifyPalletLabels(context, fixture, loadingPlanId)
|
||||
const producer = {
|
||||
marker: context.context?.phase4ProducerMatrixMarker || context.marker,
|
||||
eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED',
|
||||
@@ -514,6 +579,7 @@ export async function run(context) {
|
||||
destinationType: fixture.destinationType || null,
|
||||
destinationWarehouseId: id(fixture.destinationWarehouseId),
|
||||
destinationWarehouseCode: fixture.destinationWarehouseCode || null,
|
||||
palletLabelManifest,
|
||||
containerNo: fixture.containerNo || null,
|
||||
sourceStatus: 'LOADED',
|
||||
verification: 'PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP',
|
||||
|
||||
@@ -69,6 +69,25 @@ test('records LOADED and clears the reason only after a real LOADED reread', ()
|
||||
assert.equal(result.notCleanedReason, null)
|
||||
})
|
||||
|
||||
test('finalize acceptance covers stable pallet generation and both print modes', () => {
|
||||
const value = source('./phase3-loading-finalize.mjs')
|
||||
assert.match(value, /pallet-labels`/)
|
||||
assert.match(value, /`\$\{base\}\/prepare\?warehouseId=/)
|
||||
assert.match(value, /柜号-FBA内部编号-仓内连续下标/)
|
||||
assert.match(value, /重复生成散货托盘码返回稳定清单/)
|
||||
assert.match(value, /destinationWarehouseCode: null, count: null/)
|
||||
assert.match(value, /按指定 FBA 仓补打 1 张托盘码 PDF/)
|
||||
assert.match(value, /pdfMagic === '%PDF'/)
|
||||
assert.match(value, /palletLabelManifest,/)
|
||||
})
|
||||
|
||||
test('overseas conversion proves source-code preservation and native code generation boundaries', () => {
|
||||
const value = source('./phase4-overseas-pda.mjs')
|
||||
assert.match(value, /头程 FBA 散货托盘码跨区原样用于实体托盘/)
|
||||
assert.match(value, /标准海外仓订单由海外仓生成本地实体托盘码/)
|
||||
assert.match(value, /actualPalletCodes\.every\(\(code\) => code\.startsWith\('PLT-'\)\)/)
|
||||
})
|
||||
|
||||
test('container loading follows push, claim, real pick, completed sequence', () => {
|
||||
const value = source('./phase3-container-loading.mjs')
|
||||
assert.match(value, /const containerDetail = containerRecord\(context\.apiSuccess/)
|
||||
|
||||
@@ -350,6 +350,24 @@ async function convertReceivingContainers(context, producers) {
|
||||
&& convertedInboundCodes.size === inboundCodes,
|
||||
`plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, expected=${inboundCodes}; ${trace(convertedResponse)}`, OPTIONS)
|
||||
if (plans.length !== inboundCodes) return false
|
||||
const actualPalletCodes = plans.flatMap((item) => Array.isArray(item?.pallets) ? item.pallets : [])
|
||||
.map((pallet) => id(pallet?.palletNo)).filter(nonBlank).sort()
|
||||
const expectedPalletCodes = (producer?.palletLabelManifest?.groups || [])
|
||||
.flatMap((group) => group?.labels || []).map((label) => id(label?.palletCode)).filter(nonBlank).sort()
|
||||
if (producer?.destinationType === 'FBA' && producer?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') {
|
||||
context.assert(MODULE, `头程 FBA 散货托盘码跨区原样用于实体托盘 ${containerNo}`, convertedResponse,
|
||||
expectedPalletCodes.length > 0
|
||||
&& JSON.stringify(actualPalletCodes) === JSON.stringify(expectedPalletCodes),
|
||||
`source=${expectedPalletCodes.join(',') || 'missing'}, overseas=${actualPalletCodes.join(',') || 'missing'}`,
|
||||
OPTIONS)
|
||||
}
|
||||
if (producer?.destinationType === 'OVERSEAS' && producer?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') {
|
||||
context.assert(MODULE, `标准海外仓订单由海外仓生成本地实体托盘码 ${containerNo}`, convertedResponse,
|
||||
expectedPalletCodes.length === 0 && actualPalletCodes.length > 0
|
||||
&& actualPalletCodes.every((code) => code.startsWith('PLT-')),
|
||||
`sourceCount=${expectedPalletCodes.length}, overseas=${actualPalletCodes.join(',') || 'missing'}`,
|
||||
OPTIONS)
|
||||
}
|
||||
rememberGeneratedPlans(context, plans)
|
||||
const replayResponse = await context.requestOverseas('POST',
|
||||
'/api/platform/v1/last-leg/inbound-plans/convert-container', body,
|
||||
|
||||
@@ -168,22 +168,23 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) {
|
||||
{ orderIds: allIds, palletMergeRequired: false })
|
||||
context.assert(MODULE, '批量合托分组取消成功', batchCleared.response, batchCleared.data == null, trace(batchCleared.response), OPTIONS)
|
||||
|
||||
const feeItemCode = `${context.marker}-TAIL-FEE`.slice(0, 64)
|
||||
const feeItemName = `尾程费用-${context.marker}`.slice(0, 200)
|
||||
const feeItem = `${context.marker}-TAIL-FEE`.slice(0, 128)
|
||||
const fee = await api(context, 'POST', '新增本轮尾程订单费用',
|
||||
query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/fees`, { customerId: id(representative.customerId) }),
|
||||
{ feeItemCode, feeItemName, direction: 'RECEIVABLE', currency: 'USD', amount: 0.01,
|
||||
businessDate: new Date().toISOString().slice(0, 10) })
|
||||
{ feeItemCode: feeItem.slice(0, 64), feeItemName: feeItem, direction: 'RECEIVABLE',
|
||||
currency: 'USD', amount: 0.01, businessDate: new Date().toISOString().slice(0, 10) },
|
||||
{ 'Idempotency-Key': `${context.marker}-tail-fee`.slice(0, 128) })
|
||||
context.assert(MODULE, '尾程费用冻结币种金额和审核状态', fee.response,
|
||||
id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItemCode === feeItemCode
|
||||
&& fee.data?.feeItemName === feeItemName && fee.data?.direction === 'RECEIVABLE'
|
||||
&& fee.data?.currency === 'USD' && Number(fee.data?.amount) === 0.01
|
||||
&& fee.data?.reviewStatus === 'PENDING_REVIEW',
|
||||
id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItemName === feeItem
|
||||
&& fee.data?.originalCurrency === 'USD' && Number(fee.data?.originalAmount) === 0.01
|
||||
&& fee.data?.currency === 'CNY' && Number(fee.data?.amount) > 0
|
||||
&& fee.data?.reviewStatus === 'PENDING',
|
||||
trace(fee.response), OPTIONS)
|
||||
const feeRead = await detail(context, representative, '回查尾程订单费用明细')
|
||||
context.assert(MODULE, '尾程费用详情可追溯', feeRead.response,
|
||||
feeRead.data?.fees?.some((row) => row?.feeItemCode === feeItemCode && row?.feeItemName === feeItemName
|
||||
&& row?.direction === 'RECEIVABLE' && Number(row?.amount) === 0.01), trace(feeRead.response), OPTIONS)
|
||||
feeRead.data?.fees?.some((row) => row?.feeItemName === feeItem
|
||||
&& row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01
|
||||
&& row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS)
|
||||
|
||||
const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, {
|
||||
customerId: id(representative.customerId), containerNo: producer.containerNo,
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ public final class WarehousePalletBatchRequests {
|
||||
@Positive int sequenceNo,
|
||||
@NotBlank @Size(max = 32) String palletType,
|
||||
@Size(max = 128) String fbaWarehouseCode,
|
||||
@NotBlank @Size(max = 128) String palletCode
|
||||
@Size(max = 128) String palletCode
|
||||
) { }
|
||||
|
||||
public record Confirm(
|
||||
|
||||
+2
-2
@@ -268,8 +268,8 @@ public class WarehousePalletBatchService {
|
||||
if (item.fbaWarehouseCode() != null) {
|
||||
requireMax(item.fbaWarehouseCode().trim(), 128, "FBA warehouse code is too long");
|
||||
}
|
||||
String palletCode = required(item.palletCode(), "Pallet code is required");
|
||||
requireMax(palletCode, 128, "Pallet code is too long");
|
||||
String palletCode = trim(item.palletCode());
|
||||
if (palletCode != null) requireMax(palletCode, 128, "Pallet code is too long");
|
||||
if (!businessKeys.add(businessKey)) throw badRequest("Pallet business key must be unique");
|
||||
if (!sequences.add(item.sequenceNo())) throw badRequest("Pallet sequence must be unique");
|
||||
return new WarehousePalletBatchRequests.Item(item.customerId(), businessKey, inboundPlanNo,
|
||||
|
||||
+22
@@ -91,6 +91,28 @@ class WarehousePalletBatchServiceTest {
|
||||
.containsExactly("container:plan-a:1", "container:plan-b:2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSourcePalletCodeUsesWarehouseGeneratedPhysicalCode() {
|
||||
when(batches.insertIgnore(any())).thenReturn(1);
|
||||
when(resources.createPallet(anyLong(), anyLong(), any())).thenReturn(301L);
|
||||
when(resources.pallet(0L, WAREHOUSE, 301L)).thenReturn(pallet(301L, 401L, "PAL-301"));
|
||||
when(locks.reserve(any())).thenAnswer(call -> {
|
||||
WarehouseResourceLockRequests.ReserveRequest value = call.getArgument(0);
|
||||
return lockResponse(value.customerId(), 501L);
|
||||
});
|
||||
var request = new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "container-generated", List.of(
|
||||
new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:generated:1", "PLAN-G", 1,
|
||||
"STANDARD", null, null)));
|
||||
|
||||
var response = service.ensureReserved(request);
|
||||
|
||||
ArgumentCaptor<PalletRequests.SaveRequest> saves = ArgumentCaptor.forClass(PalletRequests.SaveRequest.class);
|
||||
verify(resources).createPallet(org.mockito.ArgumentMatchers.eq(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE),
|
||||
org.mockito.ArgumentMatchers.eq(WAREHOUSE), saves.capture());
|
||||
assertThat(saves.getValue().palletCode()).isNull();
|
||||
assertThat(response.items()).extracting(value -> value.palletCode()).containsExactly("PAL-301");
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentSameRequestReplaysWinningBatchWithoutCreatingResources() {
|
||||
AtomicReference<WarehousePalletBatchEntity> inserted = new AtomicReference<>();
|
||||
|
||||
Reference in New Issue
Block a user