Test #1
@@ -1799,6 +1799,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
||||
return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID',
|
||||
source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照',
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛',
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
application_id BIGINT NOT NULL COMMENT '申请单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
@@ -2564,6 +2567,7 @@ CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
|
||||
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
|
||||
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
||||
@@ -4589,6 +4593,7 @@ CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||
source_return_no VARCHAR(2000) NULL COMMENT '来源退货单号',
|
||||
fba_carton_mark VARCHAR(255) NULL COMMENT 'FBA箱唛',
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
|
||||
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码',
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称',
|
||||
relabel_application_id BIGINT NULL COMMENT '绑定换标申请单ID',
|
||||
|
||||
+2
-2
@@ -142,7 +142,7 @@ public class PlatformDropshipPalletizationController {
|
||||
}
|
||||
|
||||
@GetMapping("/inbound-orders/{inboundId}/pallets")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:list')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
|
||||
public ApiResponse<List<Pallet>> inboundPallets(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long inboundId,
|
||||
@RequestParam @Positive long accountId) {
|
||||
@@ -151,7 +151,7 @@ public class PlatformDropshipPalletizationController {
|
||||
}
|
||||
|
||||
@GetMapping("/inbound-orders/{inboundId}/pallets/{palletId}/print")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:list')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
|
||||
public ResponseEntity<byte[]> printPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long inboundId,
|
||||
@PathVariable @Positive long palletId,
|
||||
|
||||
@@ -87,12 +87,12 @@ paths:
|
||||
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets:
|
||||
get:
|
||||
summary: Admin 查询入仓单权威托盘结果
|
||||
x-required-authority: firstleg:inbound:list
|
||||
x-required-authority: platform:firstleg:inbound:list
|
||||
responses: {'200': {description: OK}}
|
||||
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print:
|
||||
get:
|
||||
summary: 获取包含托盘码、入仓码和 SKU 实收汇总的 A4 托盘信息单
|
||||
x-required-authority: firstleg:inbound:list
|
||||
x-required-authority: platform:firstleg:inbound:list
|
||||
responses: {'200': {description: PDF}}
|
||||
components:
|
||||
parameters:
|
||||
|
||||
+15
@@ -12,6 +12,21 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DropshipPalletizationSqlContractTest {
|
||||
@Test
|
||||
void inboundPalletReadAndPrintUseTheRegisteredPlatformInboundPermission() throws Exception {
|
||||
var controller = com.baiyue.firstleg.controller.PlatformDropshipPalletizationController.class;
|
||||
for (var method : controller.getDeclaredMethods()) {
|
||||
if (method.getName().equals("inboundPallets") || method.getName().equals("printPallet")) {
|
||||
assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
|
||||
}
|
||||
}
|
||||
String api = new ClassPathResource("openapi/dropship-palletization-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(api).contains("x-required-authority: platform:firstleg:inbound:list")
|
||||
.doesNotContain("x-required-authority: firstleg:inbound:list");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingPdaTasksAreScopedByTheSelectedWarehouse() throws Exception {
|
||||
String sql = DropshipPalletizationMapper.class
|
||||
|
||||
@@ -1706,6 +1706,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
||||
return_carton_id BIGINT NULL COMMENT '退货换箱唛来源退货库存箱ID',
|
||||
source_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛原箱唛快照',
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '退货换箱唛新箱唛',
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
application_id BIGINT NOT NULL COMMENT '申请单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
@@ -2471,6 +2474,7 @@ CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
|
||||
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
|
||||
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
||||
|
||||
+22
@@ -12,6 +12,28 @@ import java.util.Map;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ReturnCartonQueryTest {
|
||||
@Test
|
||||
void baseMapperReplayColumnExistsInEveryReturnCartonBaseline() throws Exception {
|
||||
var table = com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
|
||||
new org.apache.ibatis.builder.MapperBuilderAssistant(
|
||||
new com.baomidou.mybatisplus.core.MybatisConfiguration(), "return-carton-contract"),
|
||||
com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton.class);
|
||||
assertThat(table.getAllSqlSelect()).contains("new_carton_mark");
|
||||
for (var path : java.util.List.of(java.nio.file.Path.of("src/main/resources/db/lastleg.sql"),
|
||||
java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))) {
|
||||
String sql = java.nio.file.Files.readString(path);
|
||||
var matcher = java.util.regex.Pattern.compile(
|
||||
"CREATE TABLE IF NOT EXISTS ll_return_inventory_carton \\(([\\s\\S]*?)\\) ENGINE")
|
||||
.matcher(sql);
|
||||
int definitions = 0;
|
||||
while (matcher.find()) {
|
||||
definitions++;
|
||||
assertThat(matcher.group(1)).as(path.toString()).contains("new_carton_mark VARCHAR(255)");
|
||||
}
|
||||
assertThat(definitions).isPositive();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothQueriesUseExactCustomerScopeAndSkuExistenceWithoutAnOuterJoin() throws Exception {
|
||||
var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", "RC", "AVAILABLE", "RT");
|
||||
|
||||
@@ -37,6 +37,11 @@ export async function run(context) {
|
||||
|
||||
## 可用上下文
|
||||
|
||||
- 一件代发任务 `Task.pallets` 只列实际参与绑箱的托盘;未使用的预生成托盘通过 `/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets?accountId=...` 读取,客户范围取本轮任务。脚本同时核对 `generatedPalletCount/palletSetupReady`,不因空的任务托盘列表新增资源。
|
||||
- `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。
|
||||
- `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。
|
||||
- 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。
|
||||
|
||||
货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。
|
||||
|
||||
一件代发的 `inbound-measurement` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。人工验收造数可传 `--prepare-dropship-manual-count true`,脚本会停在任务未领取、箱未清点、托盘未绑定的状态。
|
||||
|
||||
@@ -123,7 +123,7 @@ test('confirmed package count reconciles an uncertain transport result before re
|
||||
})
|
||||
|
||||
async function exerciseSystemPalletScenario(fault = '') {
|
||||
const task = { taskId: '81', inboundId: '82', warehouseId: '83', taskStatus: 'CLAIMED',
|
||||
const task = { taskId: '81', inboundId: '82', warehouseId: '83', tenantId: '85', generatedPalletCount: 1, palletSetupReady: true, taskStatus: 'CLAIMED',
|
||||
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [{ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: 0, status: 'OPEN', version: 1 }] }
|
||||
const seenCounts = new Map()
|
||||
const seenCreates = new Set()
|
||||
@@ -138,7 +138,9 @@ async function exerciseSystemPalletScenario(fault = '') {
|
||||
assert(_module, name, _response, condition) { checks.push({ name, passed: Boolean(condition) }) },
|
||||
async request(method, path, body) {
|
||||
if (path.includes('/pending?')) return response([task])
|
||||
if (path.endsWith('/claim') || (method === 'GET' && path.includes('/tasks/81?'))) return response(task)
|
||||
if (path.endsWith('/claim')) return response({ ...task, pallets: [] })
|
||||
if (path === '/api/platform/v1/first-leg/pda-dropship/inbound-orders/82/pallets?accountId=85') return response(task.pallets)
|
||||
if (method === 'GET' && path.includes('/tasks/81?')) return response(task)
|
||||
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, skuLines: [{ inboundDetailId: '91', skuCode: 'SKU-1', expectedQuantity: 10 }] })
|
||||
if (path.endsWith('/cartons/count')) {
|
||||
if (seenCounts.has(body.operationId)) return seenCounts.get(body.operationId)
|
||||
|
||||
@@ -112,12 +112,20 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
const totalCartons = Number(measurement?.actualPackageCount)
|
||||
const expectedPalletCount = Number(measurement?.expectedPalletCount)
|
||||
const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}`
|
||||
const generatedPallets = Array.isArray(claimed?.pallets) ? claimed.pallets : []
|
||||
context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', claimResponse,
|
||||
generatedPallets.length === expectedPalletCount && generatedPallets.every((item) =>
|
||||
item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || '')),
|
||||
if (!claimed?.tenantId || claimed.taskStatus !== 'CLAIMED') return null
|
||||
// Task.pallets contains pallets with actual bound cartons, not unused pre-generated pallets.
|
||||
const palletResponse = await context.request('GET',
|
||||
`/api/platform/v1/first-leg/pda-dropship/inbound-orders/${inboundId}/pallets?accountId=${asId(claimed.tenantId)}`)
|
||||
const palletData = context.apiSuccess(MODULE, '读取本轮入仓单预生成托盘', palletResponse, '', OPTIONS)
|
||||
const generatedPallets = Array.isArray(palletData) ? palletData : []
|
||||
const palletSetupValid = Number(claimed.generatedPalletCount) === expectedPalletCount
|
||||
&& claimed.palletSetupReady === true && generatedPallets.length === expectedPalletCount
|
||||
&& generatedPallets.every((item) =>
|
||||
item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || ''))
|
||||
context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', palletResponse,
|
||||
palletSetupValid,
|
||||
`expected=${expectedPalletCount}, generated=${generatedPallets.length}`, OPTIONS)
|
||||
if (!generatedPallets.length) return null
|
||||
if (!palletSetupValid) return null
|
||||
|
||||
let countedCartons = 0
|
||||
const sealedCartonMarks = []
|
||||
@@ -189,13 +197,13 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
|
||||
Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1
|
||||
&& pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo
|
||||
&& bound?.pallets?.length === expectedPalletCount,
|
||||
&& bound?.pallets?.length === 1,
|
||||
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
|
||||
const replayBindResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload)
|
||||
const replayBind = context.apiSuccess(MODULE, '预生成托盘绑定同键重放', replayBindResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '预生成托盘绑定重放不新增托盘且计数稳定', replayBindResponse,
|
||||
replayBind?.pallets?.length === expectedPalletCount
|
||||
replayBind?.pallets?.length === bound?.pallets?.length
|
||||
&& Number(replayBind?.boundCartons) === Number(bound?.boundCartons)
|
||||
&& Number(replayBind?.unboundCartons) === 0, '', OPTIONS)
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,15 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { DOCUMENT_TYPES } from './phase3-container-documents.mjs'
|
||||
import { DOCUMENT_TYPES, applicableDocumentTypes } from './phase3-container-documents.mjs'
|
||||
|
||||
test('rail excludes DO but truck still requires it; missing channel fails closed', () => {
|
||||
for (const code of ['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT']) {
|
||||
assert.deepEqual(applicableDocumentTypes(code), ['DESTINATION_CLEARANCE'])
|
||||
}
|
||||
assert.deepEqual(applicableDocumentTypes('EXX_TRUCK'), DOCUMENT_TYPES)
|
||||
assert.throws(() => applicableDocumentTypes(null), /权威渠道/)
|
||||
})
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8')
|
||||
|
||||
@@ -6,6 +6,13 @@ export const DOCUMENT_TYPES = Object.freeze([
|
||||
'DESTINATION_CLEARANCE', 'DELIVERY_ORDER',
|
||||
])
|
||||
|
||||
export function applicableDocumentTypes(channelCode) {
|
||||
if (!String(channelCode || '').trim()) throw new Error('货柜缺少权威渠道,不能判断DO适用性')
|
||||
const seaRail = ['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT']
|
||||
.includes(String(channelCode).trim().toUpperCase())
|
||||
return seaRail ? ['DESTINATION_CLEARANCE'] : [...DOCUMENT_TYPES]
|
||||
}
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
||||
@@ -115,6 +122,13 @@ export async function run(context) {
|
||||
return
|
||||
}
|
||||
|
||||
const detailResponse = await context.request('GET', `${CONTAINERS}/domestic/${containerId}`)
|
||||
const detail = context.apiSuccess(MODULE, '读取本轮货柜权威渠道', detailResponse, '', OPTIONS)
|
||||
if (id(detail?.container?.id) !== containerId || !detail?.container?.channelCode) {
|
||||
context.addStep(MODULE, '货柜渠道适用性前置', detailResponse, false, '缺少同柜权威渠道', OPTIONS)
|
||||
return
|
||||
}
|
||||
const expectedTypes = applicableDocumentTypes(detail.container.channelCode)
|
||||
const specs = [
|
||||
{
|
||||
type: 'DESTINATION_CLEARANCE',
|
||||
@@ -122,7 +136,7 @@ export async function run(context) {
|
||||
readPath: `${CONTAINERS}/${containerId}/clearance`,
|
||||
completePath: `${CONTAINERS}/${containerId}/clearance/complete`,
|
||||
},
|
||||
...['DELIVERY_ORDER'].map((type) => ({
|
||||
...expectedTypes.filter((type) => type === 'DELIVERY_ORDER').map((type) => ({
|
||||
type,
|
||||
uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`,
|
||||
readPath: `${CONTAINERS}/${containerId}/documents/${type}`,
|
||||
@@ -131,17 +145,28 @@ export async function run(context) {
|
||||
|
||||
const documents = []
|
||||
for (const spec of specs) documents.push(await uploadAndVerify(context, spec))
|
||||
let applicabilityPassed = true
|
||||
if (!expectedTypes.includes('DELIVERY_ORDER')) {
|
||||
const rejected = await upload(context, `${CONTAINERS}/${containerId}/documents/DELIVERY_ORDER/upload`,
|
||||
'DELIVERY_ORDER', 0, `${context.marker} 海铁DO负向验收`, key(context, 'rail-do-rejected'))
|
||||
applicabilityPassed = conflict(rejected) && traced(rejected)
|
||||
&& rejected.body?.message === '海铁渠道不适用DO资料'
|
||||
context.addStep(MODULE, '海铁DO上传按渠道规则拒绝', rejected, applicabilityPassed,
|
||||
`code=${codeOf(rejected)}, requestId=${rejected.requestId}, traceId=${rejected.traceId}`, OPTIONS)
|
||||
}
|
||||
const overviewResponse = await context.request('GET', `${CONTAINERS}/${containerId}/overview`)
|
||||
const overview = context.apiSuccess(MODULE, '回查货柜作业资料聚合', overviewResponse, '', OPTIONS)
|
||||
const storedTypes = new Set((overview?.documents || []).map((item) => item?.documentType))
|
||||
const typesValid = expectedTypes.every((type) => storedTypes.has(type))
|
||||
&& (expectedTypes.includes('DELIVERY_ORDER') || !storedTypes.has('DELIVERY_ORDER'))
|
||||
context.assert(MODULE, '同一LOADED柜次聚合返回作业资料且身份未漂移', overviewResponse,
|
||||
id(overview?.container?.id) === containerId && documents.every(Boolean)
|
||||
&& DOCUMENT_TYPES.every((type) => storedTypes.has(type)),
|
||||
&& typesValid && applicabilityPassed,
|
||||
`containerBookingId=${containerId}, documentTypes=${[...storedTypes].join('|') || 'none'}`, OPTIONS)
|
||||
if (documents.every(Boolean) && DOCUMENT_TYPES.every((type) => storedTypes.has(type))) {
|
||||
if (id(overview?.container?.id) === containerId && documents.every(Boolean) && typesValid && applicabilityPassed) {
|
||||
context.createdResources.phase3ContainerDocuments = {
|
||||
marker: context.marker, containerBookingId: containerId, loadingPlanId: planId,
|
||||
warehouseId, finalStatus: 'COMPLETED', documentTypes: [...DOCUMENT_TYPES],
|
||||
warehouseId, finalStatus: 'COMPLETED', documentTypes: expectedTypes,
|
||||
documentIds: Object.fromEntries(documents.map((item) => [item.documentType, id(item.id)])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@ import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { expandOverseasCartonCandidates, resolveCartonFixtures, run } from './phase4-overseas-pda.mjs'
|
||||
import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer } from './phase4-overseas-pda.mjs'
|
||||
|
||||
test('one physical container is received once even when matrix roles overlap', () => {
|
||||
const fba = { containerNo: 'TEST-C1', fulfillmentType: 'STANDARD' }
|
||||
const overseas = { containerNo: 'TEST-C2' }
|
||||
assert.deepEqual([null, fba, { ...fba }, overseas].filter(uniqueContainerProducer), [fba, overseas])
|
||||
assert.doesNotMatch(source, /dropship \|\| matrix/)
|
||||
})
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8')
|
||||
|
||||
@@ -5,7 +5,26 @@ import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
import { run } from './phase4-overseas-pda-operations.mjs'
|
||||
import { run, remainingRelabelItem, selectPhysicalRelabelSources } from './phase4-overseas-pda-operations.mjs'
|
||||
|
||||
test('relabel uses three physical cartons across current inbounds, never UNBOUND or duplicate positions', () => {
|
||||
const a = { inventoryBalanceId: 'A', inboundOrderId: '1', positions: [
|
||||
{ positionId: '10', palletNo: 'REAL-A', availablePackages: 2 },
|
||||
{ positionId: '11', palletNo: 'UNBOUND', availablePackages: 1 }] }
|
||||
const b = { inventoryBalanceId: 'B', inboundOrderId: '2', positions: [
|
||||
{ positionId: '20', palletNo: 'REAL-B', availablePackages: 1 }] }
|
||||
const selected = selectPhysicalRelabelSources([b, a, a], 'A')
|
||||
assert.deepEqual(selected.map(line => line.position.palletNo), ['REAL-A', 'REAL-A', 'REAL-B'])
|
||||
assert.equal(selectPhysicalRelabelSources([a, a], 'A').length, 2)
|
||||
})
|
||||
|
||||
test('exception uses the untouched third carton, independent of response ordering', () => {
|
||||
const third = { itemId: '3', expectedPackages: 1 }
|
||||
const detail = { items: [third, { itemId: '2' }, { itemId: '1' }] }
|
||||
assert.equal(remainingRelabelItem(detail, [1, '2']), third)
|
||||
assert.equal(remainingRelabelItem(detail, ['1', '2', '3']), null)
|
||||
assert.equal(remainingRelabelItem(null, []), null)
|
||||
})
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8')
|
||||
|
||||
@@ -87,7 +87,7 @@ async function expectConflict(context, token, method, name, path, body) {
|
||||
}
|
||||
|
||||
async function resolvePallet(context, token, palletCode, name = '扫码解析托盘') {
|
||||
return request(context, token, 'POST', name,
|
||||
return request(context, token, 'POST', `${name}(${palletCode})`,
|
||||
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
|
||||
}
|
||||
|
||||
@@ -103,6 +103,11 @@ async function resolvePublicLocation(context, fixture) {
|
||||
? { locationId: id(location.id), locationCode: location.locationCode } : null
|
||||
}
|
||||
|
||||
export function remainingRelabelItem(detail, usedItemIds) {
|
||||
const used = new Set(usedItemIds.map(id))
|
||||
return (detail?.items || []).find((item) => id(item?.itemId) && !used.has(id(item.itemId))) || null
|
||||
}
|
||||
|
||||
async function resolveReusableAuxiliaryPallet(context, token, fixture) {
|
||||
for (const candidate of fixture.auxiliaryPallets || []) {
|
||||
if (!nonBlank(candidate?.palletCode) || id(candidate?.palletId) === id(fixture.pallet?.palletId)) continue
|
||||
@@ -328,6 +333,27 @@ async function verifyPalletOperations(context, token, fixture) {
|
||||
return success(unbound.response)
|
||||
}
|
||||
|
||||
export function selectPhysicalRelabelSources(candidates, preferredBalanceId) {
|
||||
const ordered = [...candidates].sort((a, b) =>
|
||||
Number(id(b.inventoryBalanceId) === id(preferredBalanceId)) - Number(id(a.inventoryBalanceId) === id(preferredBalanceId)))
|
||||
const sourceLines = []
|
||||
const seen = new Set()
|
||||
for (const source of ordered) {
|
||||
if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue
|
||||
for (const position of source.positions || []) {
|
||||
const positionId = id(position?.positionId)
|
||||
if (!positionId || seen.has(positionId) || !nonBlank(position?.palletNo)
|
||||
|| position.palletNo.toUpperCase() === 'UNBOUND') continue
|
||||
seen.add(positionId)
|
||||
const selected = Math.min(3 - sourceLines.length, Number(position.availablePackages))
|
||||
if (!Number.isInteger(selected) || selected <= 0) continue
|
||||
for (let index = 0; index < selected; index++) sourceLines.push({ source, position, packageCount: 1 })
|
||||
if (sourceLines.length === 3) return sourceLines
|
||||
}
|
||||
}
|
||||
return sourceLines
|
||||
}
|
||||
|
||||
async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
const customerId = id(fixture.order?.order?.customerId || context.createdResources?.phase4OverseasPda?.customerId)
|
||||
const customerOrderNo = fixture.order?.order?.orderNo || fixture.order?.order?.sourceOrderNo
|
||||
@@ -338,33 +364,27 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
return false
|
||||
}
|
||||
if (!await fundRelabelWallet(context, customerId)) return false
|
||||
const inventoryCandidatesResponse = await context.requestOverseas('GET', query(
|
||||
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', {
|
||||
operationWarehouseId: fixture.overseasWarehouseId, inboundCode: fixture.inboundCode,
|
||||
}), undefined, {}, merchantToken)
|
||||
const inventoryCandidates = success(inventoryCandidatesResponse)
|
||||
&& Array.isArray(inventoryCandidatesResponse.body?.data) ? inventoryCandidatesResponse.body.data : []
|
||||
const orderedCandidates = [...inventoryCandidates].sort((left, right) => {
|
||||
const leftPreferred = id(left?.inventoryBalanceId) === id(inventory?.balanceId) ? 1 : 0
|
||||
const rightPreferred = id(right?.inventoryBalanceId) === id(inventory?.balanceId) ? 1 : 0
|
||||
return rightPreferred - leftPreferred
|
||||
})
|
||||
const sourceLines = []
|
||||
let requiredPackages = 3
|
||||
for (const source of orderedCandidates) {
|
||||
if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue
|
||||
for (const position of source?.positions || []) {
|
||||
const available = Number(position?.availablePackages)
|
||||
if (!id(position?.positionId) || available <= 0) continue
|
||||
const selected = Math.min(requiredPackages, available)
|
||||
for (let packageIndex = 0; packageIndex < selected; packageIndex += 1) {
|
||||
sourceLines.push({ source, position, packageCount: 1 })
|
||||
}
|
||||
requiredPackages -= selected
|
||||
if (requiredPackages === 0) break
|
||||
}
|
||||
if (requiredPackages === 0) break
|
||||
// P4 intentionally leaves one carton UNBOUND. P5 requires physical source pallets,
|
||||
// and may combine only the current completed container's same-destination inbounds.
|
||||
const inboundCodes = [...new Set((context.context.phase4OverseasPdaOperations?.completedFixtures || [])
|
||||
.filter((item) => item.containerNo === fixture.containerNo && item.destinationType === fixture.destinationType
|
||||
&& item.fbaWarehouseCode === fixture.fbaWarehouseCode)
|
||||
.map((item) => item.inboundCode).filter(nonBlank))]
|
||||
const inventoryCandidates = []
|
||||
let inventoryCandidatesResponse = null
|
||||
for (const inboundCode of inboundCodes) {
|
||||
inventoryCandidatesResponse = await context.requestOverseas('GET', query(
|
||||
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', {
|
||||
operationWarehouseId: fixture.overseasWarehouseId, inboundCode,
|
||||
}), undefined, {}, merchantToken)
|
||||
context.addStep(MODULE, 'P5读取本轮同柜同目的仓换标候选', inventoryCandidatesResponse,
|
||||
success(inventoryCandidatesResponse), trace(inventoryCandidatesResponse), OPTIONS)
|
||||
if (!success(inventoryCandidatesResponse)) return false
|
||||
inventoryCandidates.push(...records(inventoryCandidatesResponse.body?.data).filter((item) =>
|
||||
item.inboundCode === inboundCode && id(item.overseasWarehouseId) === id(fixture.overseasWarehouseId)))
|
||||
}
|
||||
const sourceLines = selectPhysicalRelabelSources(inventoryCandidates, inventory.balanceId)
|
||||
const requiredPackages = 3 - sourceLines.length
|
||||
context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse,
|
||||
requiredPackages === 0,
|
||||
`sourceLines=${sourceLines.length}, selectedPackages=${3 - requiredPackages}; ${trace(inventoryCandidatesResponse)}`, OPTIONS)
|
||||
@@ -452,8 +472,13 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
await expectConflict(context, token, 'POST', 'P5正常换标旧版本A0409', unitPath,
|
||||
{ ...scanBody, oldLabel: `${context.marker}-OLD-STALE`, idempotencyKey: key(context, 'RELABEL-STALE') })
|
||||
detail = scanned.data
|
||||
if (!detail) return false
|
||||
const skipItem = Array.isArray(detail?.items)
|
||||
? detail.items.find((candidate) => id(candidate?.itemId) && id(candidate.itemId) !== id(item.itemId)) : null
|
||||
if (!skipItem) {
|
||||
blocker(context, 'P5跳过换标明细', '缺少独立的第二箱换标任务', scanned.response)
|
||||
return false
|
||||
}
|
||||
const skipBody = { itemId: id(skipItem?.itemId), oldLabel: skipItem?.cartonMark,
|
||||
reason: `${context.marker}-skip`, expectedVersion: Number(detail.version),
|
||||
idempotencyKey: key(context, 'RELABEL-SKIP') }
|
||||
@@ -462,7 +487,12 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
context.assert(MODULE, 'P5跳过换标重放不重复计数', skipReplay.response,
|
||||
Number(skipReplay.data?.skippedPackages) === 1, trace(skipReplay.response), OPTIONS)
|
||||
detail = skipped.data
|
||||
const exceptionItem = item
|
||||
if (!detail) return false
|
||||
const exceptionItem = remainingRelabelItem(detail, [item.itemId, skipItem.itemId])
|
||||
if (!exceptionItem) {
|
||||
blocker(context, 'P5异常换标明细', '缺少未被正常换标或跳过的第三箱任务', skipped.response)
|
||||
return false
|
||||
}
|
||||
const exceptionBody = { itemId: id(exceptionItem?.itemId), oldLabel: null,
|
||||
exceptionCode: 'DAMAGED', reason: `${context.marker}-damaged`, expectedVersion: Number(detail.version),
|
||||
idempotencyKey: key(context, 'RELABEL-EXCEPTION') }
|
||||
@@ -473,6 +503,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
context.assert(MODULE, 'P5换标异常重放不重复计数', exceptionReplay.response,
|
||||
Number(exceptionReplay.data?.exceptionPackages) === 1, trace(exceptionReplay.response), OPTIONS)
|
||||
detail = exception.data
|
||||
if (!detail) return false
|
||||
|
||||
const photo = await context.requestOverseasMultipart('POST',
|
||||
`${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, {
|
||||
|
||||
@@ -10,6 +10,10 @@ const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' }
|
||||
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
|
||||
export function uniqueContainerProducer(item, index, all) {
|
||||
return nonBlank(item?.containerNo)
|
||||
&& all.findIndex((candidate) => candidate?.containerNo === item.containerNo) === index
|
||||
}
|
||||
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||
function code(response) { return String(response?.body?.code ?? '') }
|
||||
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
|
||||
@@ -897,9 +901,9 @@ export async function run(context) {
|
||||
const standardFba = matrix?.byDestinationType?.FBA?.find((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP')
|
||||
// The sealed dropship pallet proves the indivisible handover. A separate standard FBA
|
||||
// fixture supplies multiple cartons for reversible split/merge/unbind and relabel checks.
|
||||
const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], standardFba,
|
||||
const producers = [dropship, standardFba,
|
||||
matrix?.byDestinationType?.OVERSEAS?.[0]]
|
||||
.filter((item) => nonBlank(item?.containerNo))
|
||||
.filter(uniqueContainerProducer)
|
||||
const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers
|
||||
if (allProducers.length) await awaitProducerConsumption(context, allProducers)
|
||||
if (producers.length && !(await convertReceivingContainers(context, producers))) {
|
||||
|
||||
Reference in New Issue
Block a user