优化
This commit is contained in:
@@ -12,7 +12,7 @@ import process from 'node:process'
|
||||
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
|
||||
const DEFAULT_USERNAME = 'platform_admin'
|
||||
const DEFAULT_PASSWORD = 'ChangeMe123!'
|
||||
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']
|
||||
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']
|
||||
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
|
||||
const CUSTOMER_FIELDS = [
|
||||
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./manual-state-loading-pda.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('retains all clickable container, loading-list and PDA workflow states through public APIs', () => {
|
||||
for (const status of ['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED',
|
||||
'UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW']) assert.match(source, new RegExp(`['"]${status}['"]`))
|
||||
for (const endpoint of ['/ocean-bookings', '/smart-loading', '/push-preload', '/inventory-snapshot',
|
||||
'/batch', '/complete', '/loading-plans', '/loading-lists']) assert.match(source, new RegExp(endpoint))
|
||||
assert.match(source, /requiredStatuses/)
|
||||
assert.match(source, /detailPath/)
|
||||
assert.match(source, /loading-plans\/\$\{fixture\.id\}/)
|
||||
assert.match(source, /loading-lists\/\$\{listFixture\.id\}/)
|
||||
assert.match(source, /prepareInbound/)
|
||||
assert.match(source, /\.\/orders\.mjs/)
|
||||
assert.match(source, /\.\/inbound-measurement\.mjs/)
|
||||
})
|
||||
|
||||
test('does not mutate historical fixtures or remove retained records', () => {
|
||||
assert.doesNotMatch(source, /\bDELETE\b/)
|
||||
assert.doesNotMatch(source, /createdResources\?\.phase3ContainerLoading/)
|
||||
assert.doesNotMatch(source, /orchestrationFixtures\?\./)
|
||||
assert.match(source, /fixtureMarker/)
|
||||
assert.match(source, /context\.marker = originalMarker/)
|
||||
})
|
||||
@@ -0,0 +1,286 @@
|
||||
const MODULE = 'manual-state-loading-pda'
|
||||
const OPTIONS = { scenario: 'manual-loading-pda-state-matrix', deploymentBlocker: true }
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
const SMART = '/api/platform/v1/first-leg/smart-loading'
|
||||
const LOADING = '/api/platform/v1/first-leg'
|
||||
const PDA = `${LOADING}/pda-loading`
|
||||
const CONTAINER_STATUSES = ['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED']
|
||||
const PRELOAD_STATUSES = ['EDITING', 'LOADING']
|
||||
const PDA_STATUSES = ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED']
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
|
||||
function suffix(context, label) {
|
||||
return `${String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-10)}${label}`.slice(-20)
|
||||
}
|
||||
function operationId(context, label) {
|
||||
return `${context.marker}-manual-loading-${label}-${crypto.randomUUID()}`.slice(0, 128)
|
||||
}
|
||||
|
||||
async function post(context, path, body, label) {
|
||||
return context.request('POST', path, body, context.token,
|
||||
{ 'Idempotency-Key': operationId(context, label) })
|
||||
}
|
||||
|
||||
async function prepareInbound(context, label) {
|
||||
const originalMarker = context.marker
|
||||
const fixtureMarker = `${originalMarker}-MAN-${label}`
|
||||
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement', 'inboundWarehouseResources']
|
||||
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
|
||||
const createdSnapshot = new Map(createdKeys.map((key) => [key,
|
||||
{ present: Object.hasOwn(context.createdResources, key), value: context.createdResources[key] }]))
|
||||
const contextSnapshot = new Map(contextKeys.map((key) => [key,
|
||||
{ present: Object.hasOwn(context.context, key), value: context.context[key] }]))
|
||||
|
||||
try {
|
||||
context.marker = fixtureMarker
|
||||
const orders = await import(new URL('./orders.mjs', import.meta.url))
|
||||
await orders.run(context)
|
||||
const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url))
|
||||
await measurement.run(context)
|
||||
const measured = context.createdResources.inboundMeasurement
|
||||
const order = context.createdResources.orders
|
||||
if (!measured?.inboundId || !measured?.tenantId) throw new Error(`${label} 未生成独立 INBOUNDED 入库单`)
|
||||
return { marker: fixtureMarker, tenantId: id(measured.tenantId), inboundId: id(measured.inboundId),
|
||||
orderId: id(order?.orderId) }
|
||||
} finally {
|
||||
context.marker = originalMarker
|
||||
for (const [key, snapshot] of createdSnapshot) {
|
||||
if (snapshot.present) context.createdResources[key] = snapshot.value
|
||||
else delete context.createdResources[key]
|
||||
}
|
||||
for (const [key, snapshot] of contextSnapshot) {
|
||||
if (snapshot.present) context.context[key] = snapshot.value
|
||||
else delete context.context[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readInbound(context, fixture) {
|
||||
const response = await context.request('GET',
|
||||
`/api/platform/inbounds/${fixture.inboundId}?tenantId=${encodeURIComponent(fixture.tenantId)}`)
|
||||
const value = context.apiSuccess(MODULE, '读取人工装柜独立入库单', response, '', OPTIONS)
|
||||
const warehouseId = id(value?.summary?.warehouseId)
|
||||
context.assert(MODULE, '人工装柜来源入库单可独立排柜', response,
|
||||
value?.summary?.processStatus === 'INBOUNDED' && value?.summary?.measureReviewStatus === 'APPROVED'
|
||||
&& Boolean(warehouseId),
|
||||
`inboundId=${fixture.inboundId}, status=${value?.summary?.processStatus || 'missing'}`, OPTIONS)
|
||||
return { ...fixture, detail: value, warehouseId }
|
||||
}
|
||||
|
||||
async function createWaitingContainer(context, warehouseId, label) {
|
||||
const value = suffix(context, label)
|
||||
const response = await context.request('POST', CONTAINERS, {
|
||||
containerNo: null, cabinetNo: `MAN-CAB-${value}`, warehouseId, warehouseName: '人工验收仓',
|
||||
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ',
|
||||
shippingCompanyId: null, shippingCompanyName: null, departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68,
|
||||
maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null,
|
||||
estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null,
|
||||
actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
||||
})
|
||||
const container = context.apiSuccess(MODULE, `创建 ${label} 货柜`, response, '', OPTIONS)
|
||||
if (!container?.id) throw new Error(`${label} 货柜创建失败`)
|
||||
return container
|
||||
}
|
||||
|
||||
async function createConfirmedContainer(context, source, label) {
|
||||
const value = suffix(context, label)
|
||||
const summary = source.detail.summary
|
||||
const draft = {
|
||||
customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户',
|
||||
channelCode: summary.channelCode, channelName: summary.channelName, warehouseId: source.warehouseId,
|
||||
warehouseName: summary.warehouseName, shippingCompanyId: null, shippingCompanyName: `人工船司-${value}`,
|
||||
bookingAgentId: null, bookingAgentName: `人工代理-${value}`, departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: `${context.marker}-${label}`,
|
||||
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` },
|
||||
{ partyType: 'CONSIGNEE', partyName: `收货方-${value}` },
|
||||
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }],
|
||||
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68,
|
||||
maxWeightKg: 28000, quantity: 1 }],
|
||||
}
|
||||
const createdResponse = await post(context, '/api/platform/v1/ocean-bookings',
|
||||
{ expectedVersion: 0, data: draft }, `${label}-booking-create`)
|
||||
const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS)
|
||||
const bookingId = id(created?.oceanBooking?.id)
|
||||
const submittedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/submit`,
|
||||
{ expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker }, `${label}-booking-submit`)
|
||||
const submitted = context.apiSuccess(MODULE, `${label} 提交订舱`, submittedResponse, '', OPTIONS)
|
||||
const confirmedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/confirm`,
|
||||
{ expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker }, `${label}-booking-confirm`)
|
||||
const confirmed = context.apiSuccess(MODULE, `${label} 确认订舱`, confirmedResponse, '', OPTIONS)
|
||||
const containerId = id(confirmed?.containerAllocations?.[0]?.containerBookingId)
|
||||
if (!containerId) throw new Error(`${label} 确认订舱未生成柜次`)
|
||||
return containerId
|
||||
}
|
||||
|
||||
async function createPlanTask(context, label) {
|
||||
const source = await readInbound(context, await prepareInbound(context, label))
|
||||
const containerId = await createConfirmedContainer(context, source, label)
|
||||
const candidateResponse = await context.request('GET',
|
||||
`${SMART}/candidates?bookingId=${containerId}&warehouseId=${source.warehouseId}`)
|
||||
const candidates = context.apiSuccess(MODULE, `${label} 查询同渠道候选货物`, candidateResponse, '', OPTIONS)
|
||||
const candidate = Array.isArray(candidates)
|
||||
? candidates.find((item) => id(item.inboundId) === source.inboundId && Number(item.availablePackageCount) > 0)
|
||||
: null
|
||||
if (!candidate?.inboundDetailId) throw new Error(`${label} 独立入库单未进入同渠道排柜候选`)
|
||||
|
||||
const planResponse = await context.request('POST', `${SMART}/plans`,
|
||||
{ bookingId: containerId, remark: `${context.marker}-${label}` })
|
||||
let plan = context.apiSuccess(MODULE, `${label} 创建智能排柜计划`, planResponse, '', OPTIONS)
|
||||
const assignResponse = await context.request('POST', `${SMART}/plans/${plan.id}/assignments`, {
|
||||
inboundId: source.inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
|
||||
expectedVersion: Number(plan.version), operationId: operationId(context, `${label}-assign`),
|
||||
})
|
||||
plan = context.apiSuccess(MODULE, `${label} 分配独立候选货物`, assignResponse, '', OPTIONS)
|
||||
const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`,
|
||||
{ expectedVersion: Number(plan.version) }, `${label}-push`)
|
||||
plan = context.apiSuccess(MODULE, `${label} 推送预装`, pushResponse, '', OPTIONS)
|
||||
|
||||
const detailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
|
||||
const container = context.apiSuccess(MODULE, `${label} 读取柜次`, detailResponse, '', OPTIONS)
|
||||
const containerNo = `MAN-${suffix(context, label)}`.slice(0, 64)
|
||||
const transportResponse = await context.request('PATCH', `${CONTAINERS}/${containerId}/transport`, {
|
||||
containerNo, voyageNo: `V-${suffix(context, label)}`.slice(0, 64), version: Number(container.version),
|
||||
reason: `${context.marker} 人工状态样本`,
|
||||
})
|
||||
context.apiSuccess(MODULE, `${label} 写入真实集装箱号并开放 PDA`, transportResponse, '', OPTIONS)
|
||||
const taskResponse = await context.request('GET',
|
||||
`${PDA}/tasks?loadingPlanId=${plan.id}&warehouseId=${source.warehouseId}`)
|
||||
const task = context.apiSuccess(MODULE, `${label} 读取 PDA 任务`, taskResponse, '', OPTIONS)
|
||||
if (!task?.id || !task?.items?.[0]?.id) throw new Error(`${label} PDA 任务未生成`)
|
||||
return { source, containerId, containerNo, plan, task }
|
||||
}
|
||||
|
||||
async function claim(context, chain, label) {
|
||||
const response = await context.request('POST', `${PDA}/tasks/${chain.task.id}/claim`, {
|
||||
expectedAssignmentVersion: Number(chain.task?.assignment?.version || 0),
|
||||
operationId: operationId(context, `${label}-claim`),
|
||||
})
|
||||
chain.task = context.apiSuccess(MODULE, `${label} 领取 PDA 任务`, response, '', OPTIONS)
|
||||
return chain
|
||||
}
|
||||
|
||||
async function pickAll(context, chain, label) {
|
||||
const snapshotResponse = await context.request('GET',
|
||||
`${PDA}/tasks/${chain.task.id}/inventory-snapshot?warehouseId=${chain.source.warehouseId}`)
|
||||
const snapshot = context.apiSuccess(MODULE, `${label} 获取 PDA 库存快照`, snapshotResponse, '', OPTIONS)
|
||||
const response = await post(context, `${PDA}/tasks/${chain.task.id}/batch`, {
|
||||
warehouseId: chain.source.warehouseId, loadingPlanId: id(chain.task.loadingPlanId),
|
||||
containerBookingId: chain.containerId, containerNo: chain.task.currentContainerNo || null,
|
||||
expectedVersion: Number(chain.task.version), inventorySnapshotToken: snapshot.snapshotToken,
|
||||
items: chain.task.items.map((item) => ({ itemId: id(item.id), action: 'PICK',
|
||||
pickedPackageCount: Number(item.plannedPackageCount), finalPickedPackageCount: null,
|
||||
releaseMode: 'MANUAL', locationIds: [], version: Number(item.version), reason: null })),
|
||||
}, `${label}-batch`)
|
||||
chain.task = context.apiSuccess(MODULE, `${label} 完成 PDA 拣货`, response, '', OPTIONS)
|
||||
return chain
|
||||
}
|
||||
|
||||
async function complete(context, chain, label) {
|
||||
const response = await context.request('POST', `${PDA}/tasks/${chain.task.id}/complete`, {
|
||||
expectedTaskVersion: Number(chain.task.version), operationId: operationId(context, `${label}-complete`),
|
||||
remark: `${context.marker} 人工完成状态样本`,
|
||||
})
|
||||
chain.task = context.apiSuccess(MODULE, `${label} 人工确认装柜完成`, response, '', OPTIONS)
|
||||
return chain
|
||||
}
|
||||
|
||||
async function verifyContainer(context, status, fixture) {
|
||||
const response = await context.request('GET', `${CONTAINERS}?status=${status}&page=1&size=100`)
|
||||
const page = context.apiSuccess(MODULE, `${status} 货柜可按列表状态筛选`, response, '', OPTIONS)
|
||||
context.assert(MODULE, `${status} 货柜样本保留`, response,
|
||||
records(page).some((item) => id(item.id) === fixture.id && item.planStatus === status),
|
||||
`id=${fixture.id}`, OPTIONS)
|
||||
}
|
||||
|
||||
async function verifyPlan(context, status, fixture) {
|
||||
const response = await context.request('GET',
|
||||
`${LOADING}/loading-plans?status=${status}&warehouseId=${fixture.warehouseId}&page=1&size=100`)
|
||||
const page = context.apiSuccess(MODULE, `${status} 预装清单可筛选`, response, '', OPTIONS)
|
||||
context.assert(MODULE, `${status} 预装清单样本保留`, response,
|
||||
records(page).some((item) => id(item.planId) === fixture.id && item.status === status),
|
||||
`planId=${fixture.id}`, OPTIONS)
|
||||
const detailResponse = await context.request('GET',
|
||||
`${LOADING}/loading-plans/${fixture.id}?warehouseId=${fixture.warehouseId}`)
|
||||
const detail = context.apiSuccess(MODULE, `${status} 预装清单详情可打开`, detailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${status} 预装清单详情身份正确`, detailResponse,
|
||||
id(detail?.summary?.planId) === fixture.id && detail?.summary?.status === status,
|
||||
`planId=${fixture.id}, actual=${detail?.summary?.status || 'missing'}`, OPTIONS)
|
||||
}
|
||||
|
||||
async function verifyTask(context, status, fixture) {
|
||||
const response = await context.request('GET',
|
||||
`${PDA}/tasks/${fixture.id}?warehouseId=${fixture.warehouseId}`)
|
||||
const task = context.apiSuccess(MODULE, `${status} PDA 任务可打开`, response, '', OPTIONS)
|
||||
context.assert(MODULE, `${status} PDA 任务样本保留`, response,
|
||||
id(task?.id) === fixture.id && task?.workflowStatus === status,
|
||||
`taskId=${fixture.id}, actual=${task?.workflowStatus || 'missing'}`, OPTIONS)
|
||||
}
|
||||
|
||||
function fixture(value, status, detailPath, extra = {}) {
|
||||
return { id: id(value), status, detailPath, ...extra }
|
||||
}
|
||||
|
||||
export async function run(context, matrix = context.createdResources.manualStateMatrix) {
|
||||
matrix.pages.containerLoading = { requiredStatuses: CONTAINER_STATUSES, fixtures: {} }
|
||||
matrix.pages.preloadPlans = { requiredStatuses: PRELOAD_STATUSES, fixtures: {} }
|
||||
matrix.pages.loadingLists = { requiredStatuses: ['COMPLETED'], fixtures: {} }
|
||||
matrix.pages.pdaTasks = { requiredStatuses: PDA_STATUSES, fixtures: {} }
|
||||
|
||||
const warehouseId = '193000000000200001'
|
||||
const waiting = await createWaitingContainer(context, warehouseId, 'WAIT')
|
||||
matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waiting.id, 'WAIT_LOADING',
|
||||
`/container-loading?containerId=${id(waiting.id)}`)
|
||||
|
||||
const editing = await createPlanTask(context, 'EDIT')
|
||||
const inProgress = await claim(context, await createPlanTask(context, 'PROGRESS'), 'PROGRESS')
|
||||
const awaiting = await pickAll(context,
|
||||
await claim(context, await createPlanTask(context, 'REVIEW'), 'REVIEW'), 'REVIEW')
|
||||
const completed = await complete(context, await pickAll(context,
|
||||
await claim(context, await createPlanTask(context, 'DONE'), 'DONE'), 'DONE'), 'DONE')
|
||||
|
||||
const chains = { EDITING: editing, LOADING: inProgress, COMPLETED: completed }
|
||||
for (const [status, chain] of Object.entries(chains)) {
|
||||
matrix.pages.containerLoading.fixtures[status] = fixture(chain.containerId, status,
|
||||
`/container-loading/${chain.containerId}/smart-loading`, { planId: id(chain.plan.id) })
|
||||
}
|
||||
for (const [status, chain] of Object.entries({ EDITING: editing, LOADING: inProgress })) {
|
||||
matrix.pages.preloadPlans.fixtures[status] = fixture(chain.plan.id, status,
|
||||
`/loading-plans/${chain.plan.id}?warehouseId=${chain.source.warehouseId}`,
|
||||
{ warehouseId: chain.source.warehouseId })
|
||||
}
|
||||
matrix.pages.loadingLists.fixtures.COMPLETED = fixture(completed.plan.id, 'COMPLETED',
|
||||
`/loading-lists?planId=${completed.plan.id}&warehouseId=${completed.source.warehouseId}`,
|
||||
{ warehouseId: completed.source.warehouseId })
|
||||
|
||||
const taskChains = { UNASSIGNED: editing, IN_PROGRESS: inProgress, AWAITING_REVIEW: awaiting, COMPLETED: completed }
|
||||
for (const [status, chain] of Object.entries(taskChains)) {
|
||||
matrix.pages.pdaTasks.fixtures[status] = fixture(chain.task.id, status,
|
||||
`/pages/picking/picking?taskId=${chain.task.id}&warehouseId=${chain.source.warehouseId}`,
|
||||
{ warehouseId: chain.source.warehouseId, loadingPlanId: id(chain.plan.id) })
|
||||
}
|
||||
|
||||
for (const [status, value] of Object.entries(matrix.pages.containerLoading.fixtures))
|
||||
await verifyContainer(context, status, value)
|
||||
for (const [status, value] of Object.entries(matrix.pages.preloadPlans.fixtures))
|
||||
await verifyPlan(context, status, value)
|
||||
for (const [status, value] of Object.entries(matrix.pages.pdaTasks.fixtures))
|
||||
await verifyTask(context, status, value)
|
||||
|
||||
const listFixture = matrix.pages.loadingLists.fixtures.COMPLETED
|
||||
const listResponse = await context.request('GET',
|
||||
`${LOADING}/loading-lists?warehouseId=${listFixture.warehouseId}&page=1&size=100`)
|
||||
const list = context.apiSuccess(MODULE, 'COMPLETED 装柜清单可从列表打开', listResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'COMPLETED 装柜清单样本保留', listResponse,
|
||||
records(list).some((item) => id(item.planId) === listFixture.id && item.status === 'COMPLETED'),
|
||||
`planId=${listFixture.id}`, OPTIONS)
|
||||
const listDetailResponse = await context.request('GET',
|
||||
`${LOADING}/loading-lists/${listFixture.id}?warehouseId=${listFixture.warehouseId}`)
|
||||
const listDetail = context.apiSuccess(MODULE, 'COMPLETED 装柜清单详情可打开', listDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'COMPLETED 装柜清单详情身份正确', listDetailResponse,
|
||||
id(listDetail?.summary?.planId) === listFixture.id && listDetail?.summary?.status === 'COMPLETED',
|
||||
`planId=${listFixture.id}, actual=${listDetail?.summary?.status || 'missing'}`, OPTIONS)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const source = readFileSync(new URL('./manual-state-matrix.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('default full acceptance includes the retained manual state matrix after lifecycle modules', () => {
|
||||
assert.match(entry, /'manual-state-matrix'/)
|
||||
assert.ok(entry.indexOf("'phase3-order-splitting'") < entry.indexOf("'manual-state-matrix'"))
|
||||
assert.ok(entry.indexOf("'manual-state-matrix'") < entry.indexOf("'finance-full-fixtures'"))
|
||||
})
|
||||
|
||||
test('matrix delegates bounded groups and asserts every declared status has a retained id', () => {
|
||||
for (const group of ['order-inbound-status-fixtures', 'manual-state-loading-pda',
|
||||
'manual-state-ocean-trucking']) assert.match(source, new RegExp(group))
|
||||
assert.doesNotMatch(source, /manual-state-customs-dispatch/)
|
||||
assert.match(source, /createdResources\.manualStateMatrix/)
|
||||
assert.match(source, /requiredStatuses/)
|
||||
assert.match(source, /fixtures\?\.\[status\]\?\.id/)
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
const MODULE = 'manual-state-matrix'
|
||||
const OPTIONS = { scenario: 'manual-clickable-list-state-matrix', deploymentBlocker: true }
|
||||
|
||||
const GROUPS = [
|
||||
'order-inbound-status-fixtures',
|
||||
'manual-state-loading-pda',
|
||||
'manual-state-ocean-trucking',
|
||||
]
|
||||
|
||||
function rows(value) { return Array.isArray(value) ? value : [] }
|
||||
|
||||
export async function run(context) {
|
||||
const matrix = { marker: context.marker, generatedAt: new Date().toISOString(), pages: {} }
|
||||
context.createdResources.manualStateMatrix = matrix
|
||||
|
||||
for (const name of GROUPS) {
|
||||
const extension = await import(new URL(`./${name}.mjs`, import.meta.url))
|
||||
if (typeof extension.run !== 'function') throw new Error(`${name} 必须导出 async function run(context)`)
|
||||
await extension.run(context, matrix)
|
||||
}
|
||||
|
||||
const pages = Object.entries(matrix.pages)
|
||||
const missing = pages.flatMap(([page, value]) => rows(value?.requiredStatuses)
|
||||
.filter((status) => !value?.fixtures?.[status]?.id)
|
||||
.map((status) => `${page}:${status}`))
|
||||
context.assert(MODULE, '人工验收列表状态数据全部生成并保留', null,
|
||||
pages.length > 0 && missing.length === 0,
|
||||
`pages=${pages.length}, missing=${missing.join(',') || 'none'}`, OPTIONS)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./manual-state-ocean-trucking.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('retains every ocean booking and truck appointment list status through real APIs', () => {
|
||||
for (const status of ['DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED',
|
||||
'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED',
|
||||
'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`))
|
||||
assert.match(source, /change-request/)
|
||||
assert.match(source, /\/reschedule\?plannedLoadingAt=/)
|
||||
assert.match(source, /page=1&size=100&status=/)
|
||||
assert.doesNotMatch(source, /DELETE/)
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
const MODULE = 'manual-state-ocean-trucking'
|
||||
const OPTIONS = { scenario: 'manual-ocean-trucking-state-matrix', deploymentBlocker: true }
|
||||
const OCEAN_BASE = '/api/platform/v1/first-leg/ocean-bookings'
|
||||
const TRUCK_BASE = '/api/platform/v1/truck-appointments'
|
||||
const OCEAN_STATUSES = ['DRAFT', 'SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CHANGE_PENDING', 'CANCELLED']
|
||||
const TRUCK_STATUSES = ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', 'ARRIVED_WAREHOUSE',
|
||||
'LOADING', 'DEPARTED', 'COMPLETED', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function code(response) { return String(response?.body?.code ?? 'missing') }
|
||||
function data(response) { return response?.http === 200 && code(response) === '200' ? response.body.data : null }
|
||||
function key(context, value) { return `${context.marker}-manual-${value}-${crypto.randomUUID()}`.slice(0, 128) }
|
||||
function suffix(context, index) { return `${context.marker.replace(/\D/g, '').slice(-8)}${String(index).padStart(2, '0')}` }
|
||||
|
||||
async function post(context, path, body, operation) {
|
||||
return context.request('POST', path, body, context.token, { 'Idempotency-Key': key(context, operation) })
|
||||
}
|
||||
|
||||
function oceanPayload(context, template, index) {
|
||||
const value = suffix(context, index); const source = template.oceanBooking
|
||||
return { expectedVersion: 0, data: {
|
||||
customerId: source.customerId, customerName: source.customerName,
|
||||
channelCode: source.channelCode, channelName: source.channelName,
|
||||
warehouseId: source.warehouseId, warehouseName: source.warehouseName,
|
||||
billOfLadingNo: `MAN-BL-${value}`, shippingCompanyId: source.shippingCompanyId,
|
||||
shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId,
|
||||
bookingAgentName: source.bookingAgentName, vesselName: `人工验收轮-${value}`, voyageNo: `V${value}`,
|
||||
departurePortCode: source.departurePortCode, departurePortName: source.departurePortName,
|
||||
destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName,
|
||||
placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm,
|
||||
estimatedSailingAt: '2026-09-15T08:00:00', estimatedArrivalAt: '2026-10-15T08:00:00',
|
||||
remark: `${context.marker} 人工列表状态 ${index}`,
|
||||
parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) =>
|
||||
({ partyType, partyName, address, contactName, contactPhone })),
|
||||
containerRequirements: template.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) =>
|
||||
({ containerType, containerKind, capacityCbm, maxWeightKg, quantity })),
|
||||
} }
|
||||
}
|
||||
|
||||
async function createOcean(context, template, index) {
|
||||
const response = await post(context, OCEAN_BASE, oceanPayload(context, template, index), `ocean-create-${index}`)
|
||||
const value = context.apiSuccess(MODULE, `创建订舱状态样本 ${index}`, response, '', OPTIONS)
|
||||
if (!value?.oceanBooking?.id) throw new Error(`创建订舱样本 ${index} 失败`)
|
||||
return value
|
||||
}
|
||||
|
||||
async function oceanAction(context, value, endpoint, body = {}) {
|
||||
const response = await post(context, `${OCEAN_BASE}/${id(value.oceanBooking.id)}/${endpoint}`,
|
||||
{ expectedVersion: Number(value.oceanBooking.version), reason: `${context.marker}-${endpoint}`, ...body }, `ocean-${endpoint}`)
|
||||
const next = context.apiSuccess(MODULE, `订舱推进 ${endpoint}`, response, `id=${id(value.oceanBooking.id)}`, OPTIONS)
|
||||
if (!next?.oceanBooking?.id) throw new Error(`订舱 ${id(value.oceanBooking.id)} 推进 ${endpoint} 失败`)
|
||||
return next
|
||||
}
|
||||
|
||||
async function buildOcean(context, template, status, index) {
|
||||
let value = await createOcean(context, template, index)
|
||||
if (status === 'DRAFT') return value
|
||||
value = await oceanAction(context, value, 'submit')
|
||||
if (status === 'SUBMITTED') return value
|
||||
if (status === 'FAILED') return oceanAction(context, value, 'fail')
|
||||
if (status === 'CANCELLED') return oceanAction(context, value, 'cancel')
|
||||
value = await oceanAction(context, value, 'confirm')
|
||||
if (status === 'CONFIRMED') return value
|
||||
if (status === 'CHANGE_PENDING') return oceanAction(context, value, 'change-request', {
|
||||
vesselName: `变更轮-${suffix(context, index)}`, voyageNo: `CV${suffix(context, index)}`,
|
||||
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' })
|
||||
if (status === 'RELEASED') return oceanAction(context, value, 'release', {
|
||||
billOfLadingNo: `REL-BL-${suffix(context, index)}`, vesselName: `放舱轮-${suffix(context, index)}`,
|
||||
voyageNo: `RV${suffix(context, index)}`, estimatedSailingAt: '2026-09-16T08:00:00',
|
||||
estimatedArrivalAt: '2026-10-16T08:00:00' })
|
||||
throw new Error(`unsupported ocean status ${status}`)
|
||||
}
|
||||
|
||||
function truckPayload(context, oceanBookingId, index) {
|
||||
const value = suffix(context, index)
|
||||
return { outboundOrderId: null, oceanBookingId, containerBookingId: null, loadingPlanId: null,
|
||||
warehouseId: '193000000000200001', customerId: '10001', customerName: 'IT 测试客户',
|
||||
carrierName: `人工验收车队-${value}`, fleetContact: '人工验收联系人', fleetPhone: '13800000004',
|
||||
driverName: `人工司机-${value}`, driverPhone: '13800000005', vehicleNo: `粤B${value.slice(-6)}`,
|
||||
storeName: '人工验收仓', storeContact: '仓库联系人', storePhone: '13800000006',
|
||||
storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000',
|
||||
packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, palletCount: 1, poNo: `PO-${value}`,
|
||||
plannedLoadingAt: '2026-09-16T08:00:00', transitMinutes: 120, payableAmount: 100,
|
||||
receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 }
|
||||
}
|
||||
|
||||
async function createTruck(context, oceanBookingId, index) {
|
||||
const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBookingId, index))
|
||||
const value = context.apiSuccess(MODULE, `创建拖车状态样本 ${index}`, response, '', OPTIONS)
|
||||
if (!value?.id) throw new Error(`创建拖车样本 ${index} 失败`)
|
||||
return value
|
||||
}
|
||||
|
||||
async function truckAction(context, value, endpoint) {
|
||||
const response = await post(context, `${TRUCK_BASE}/${id(value.id)}/${endpoint}`,
|
||||
{ expectedVersion: Number(value.version), reason: `${context.marker}-${endpoint}` }, `truck-${endpoint}`)
|
||||
const next = context.apiSuccess(MODULE, `拖车推进 ${endpoint}`, response, `id=${id(value.id)}`, OPTIONS)
|
||||
if (!next?.id) throw new Error(`拖车 ${id(value.id)} 推进 ${endpoint} 失败`)
|
||||
return next
|
||||
}
|
||||
|
||||
async function buildTruck(context, oceanBookingId, status, index) {
|
||||
let value = await createTruck(context, oceanBookingId, index)
|
||||
if (status === 'DRAFT') return value
|
||||
if (status === 'RESCHEDULED') {
|
||||
const response = await post(context,
|
||||
`${TRUCK_BASE}/${id(value.id)}/reschedule?plannedLoadingAt=2026-09-18T08%3A00%3A00`,
|
||||
{ expectedVersion: Number(value.version), reason: `${context.marker}-reschedule` }, 'truck-reschedule')
|
||||
return context.apiSuccess(MODULE, '拖车推进 reschedule', response, '', OPTIONS)
|
||||
}
|
||||
if (status === 'EXCEPTION') return truckAction(context, value, 'exception')
|
||||
if (status === 'CANCELLED') return truckAction(context, value, 'cancel')
|
||||
const chain = [['submit', 'PENDING_DISPATCH'], ['dispatch', 'DISPATCHED'], ['pickup', 'PICKUP_IN_PROGRESS'],
|
||||
['arrive', 'ARRIVED_WAREHOUSE'], ['load', 'LOADING'], ['depart', 'DEPARTED'], ['complete', 'COMPLETED']]
|
||||
for (const [endpoint, target] of chain) {
|
||||
value = await truckAction(context, value, endpoint)
|
||||
if (target === status) return value
|
||||
}
|
||||
throw new Error(`unsupported truck status ${status}`)
|
||||
}
|
||||
|
||||
async function verify(context, base, status, resourceId) {
|
||||
const response = await context.request('GET', `${base}?page=1&size=100&status=${status}`)
|
||||
const page = data(response)
|
||||
context.assert(MODULE, `${status} 样本可从列表筛选并打开`, response,
|
||||
Array.isArray(page?.records) && page.records.some((item) => id(item.id) === resourceId),
|
||||
`status=${status}, id=${resourceId}`, OPTIONS)
|
||||
}
|
||||
|
||||
export async function run(context, matrix = context.createdResources.manualStateMatrix) {
|
||||
const sourceOceanId = id(context.createdResources?.oceanBooking?.id)
|
||||
if (!sourceOceanId) throw new Error('缺少现有订舱生命周期模块生成的模板资源')
|
||||
const templateResponse = await context.request('GET', `${OCEAN_BASE}/${sourceOceanId}`)
|
||||
const template = context.apiSuccess(MODULE, '读取订舱状态矩阵模板', templateResponse, '', OPTIONS)
|
||||
if (!template?.oceanBooking || !template?.parties?.length || !template?.containerRequirements?.length) {
|
||||
throw new Error('订舱模板缺少渠道、仓库、参与方或箱型需求')
|
||||
}
|
||||
matrix.pages.oceanBookings = { requiredStatuses: OCEAN_STATUSES, fixtures: {} }
|
||||
for (const [index, status] of OCEAN_STATUSES.entries()) {
|
||||
const value = await buildOcean(context, template, status, index)
|
||||
const resourceId = id(value?.oceanBooking?.id); const actual = value?.oceanBooking?.status
|
||||
matrix.pages.oceanBookings.fixtures[status] = { id: resourceId, status: actual, detailPath: `/ocean-bookings/${resourceId}` }
|
||||
context.assert(MODULE, `订舱保留 ${status}`, null, resourceId && actual === status,
|
||||
`id=${resourceId || 'missing'}, actual=${actual || 'missing'}`, OPTIONS)
|
||||
await verify(context, OCEAN_BASE, status, resourceId)
|
||||
}
|
||||
|
||||
const releasedOceanId = matrix.pages.oceanBookings.fixtures.RELEASED.id
|
||||
matrix.pages.truckAppointments = { requiredStatuses: TRUCK_STATUSES, fixtures: {} }
|
||||
for (const [index, status] of TRUCK_STATUSES.entries()) {
|
||||
const value = await buildTruck(context, releasedOceanId, status, index)
|
||||
const resourceId = id(value?.id)
|
||||
matrix.pages.truckAppointments.fixtures[status] = { id: resourceId, status: value?.status, detailPath: `/truck-appointments/${resourceId}` }
|
||||
context.assert(MODULE, `拖车保留 ${status}`, null, resourceId && value?.status === status,
|
||||
`id=${resourceId || 'missing'}, actual=${value?.status || 'missing'}`, OPTIONS)
|
||||
await verify(context, TRUCK_BASE, status, resourceId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'order-inbound-status-fixtures.mjs'), 'utf8')
|
||||
|
||||
test('为头程订单列表八个真实状态分别创建独立样本', () => {
|
||||
for (const status of ['DRAFT', 'SUBMITTED', 'REJECTED', 'APPROVED', 'ACCEPTED', 'PROCESSING', 'COMPLETED', 'CANCELLED']) {
|
||||
assert.match(source, new RegExp(`\\['${status}', \\[`))
|
||||
assert.match(source, new RegExp(`result\\.orders\\[target\\]`))
|
||||
}
|
||||
assert.match(source, /status=\$\{status\}/)
|
||||
assert.match(source, /筛选结果包含人工样本/)
|
||||
})
|
||||
|
||||
test('所有写入均走订单生命周期 HTTP API 且不清理保留数据', () => {
|
||||
assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg/)
|
||||
assert.match(source, /\/api\/platform\/v1\/orders\/first-leg/)
|
||||
assert.match(source, /'Idempotency-Key': key\(context,/)
|
||||
assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE|TRUNCATE)\b/i)
|
||||
assert.doesNotMatch(source, /cleanup|remove|unlink/i)
|
||||
})
|
||||
|
||||
test('入库通过真实订单测量链保留四个测量态和 INBOUNDED', () => {
|
||||
assert.match(source, /result\.inbounds\.WAIT_MEASURE/)
|
||||
assert.match(source, /processStatus: 'WAIT_MEASURE'/)
|
||||
for (const status of ['MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'INBOUNDED']) {
|
||||
assert.match(source, new RegExp(`'${status}'`))
|
||||
}
|
||||
assert.match(source, /\.\/inbound-measurement\.mjs/)
|
||||
assert.match(source, /path\.replace\('\/approve', '\/reject'\)/)
|
||||
})
|
||||
|
||||
test('入库不可独立保留状态显式阻断', () => {
|
||||
for (const status of ['WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) {
|
||||
assert.match(source, new RegExp(`${status}:`))
|
||||
}
|
||||
assert.match(source, /AutomaticInboundCompletionService 在同一调用内立即从 INBOUNDING 推进 INBOUNDED/)
|
||||
assert.match(source, /没有作废入库单 API/)
|
||||
assert.match(source, /context\.assert\(MODULE, `入库列表保留 \$\{status\}`/)
|
||||
})
|
||||
|
||||
test('统一订单声明合法可达状态并显式阻断废弃或无 API 状态', () => {
|
||||
assert.match(source, /result\.unifiedOrders\.PENDING_REVIEW/)
|
||||
assert.match(source, /result\.unifiedOrders\.WAIT_MEASURE/)
|
||||
for (const status of ['INBOUNDED', 'WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED']) {
|
||||
assert.match(source, new RegExp(`'${status}'`))
|
||||
}
|
||||
assert.match(source, /WAIT_SPLIT 标记为历史废弃状态/)
|
||||
assert.match(source, /没有完成派送并写 DELIVERED 的公开业务 API/)
|
||||
})
|
||||
|
||||
test('按总门禁格式写入 matrix.pages 的 requiredStatuses 和 fixtures', () => {
|
||||
assert.match(source, /run\(context, matrix = context\.createdResources\.manualStateMatrix\)/)
|
||||
for (const page of ['firstLegOrders', 'inbounds', 'unifiedOrders']) {
|
||||
assert.match(source, new RegExp(`matrix\\.pages\\.${page} = \\{ requiredStatuses:`))
|
||||
}
|
||||
assert.match(source, /detailPath: `\/orders\/\$\{orderId\}`/)
|
||||
})
|
||||
@@ -0,0 +1,300 @@
|
||||
const MODULE = 'order-inbound-status-fixtures'
|
||||
const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true }
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
|
||||
const ORDER_TARGETS = [
|
||||
['DRAFT', []],
|
||||
['SUBMITTED', ['submit']],
|
||||
['REJECTED', ['submit', 'reject']],
|
||||
['APPROVED', ['submit', 'approve']],
|
||||
['ACCEPTED', ['submit', 'approve', 'accept']],
|
||||
['PROCESSING', ['submit', 'approve', 'accept', 'start']],
|
||||
['COMPLETED', ['submit', 'approve', 'accept', 'start', 'complete']],
|
||||
['CANCELLED', ['cancel']],
|
||||
]
|
||||
const INBOUND_FILTER_STATUSES = [
|
||||
'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'WAIT_PRICING',
|
||||
'FEE_CONFIRMING', 'INBOUNDING', 'INBOUNDED', 'VOIDED',
|
||||
]
|
||||
const UNIFIED_FILTER_STATUSES = [
|
||||
'PENDING_REVIEW', 'WAIT_MEASURE', 'INBOUNDED', 'WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED',
|
||||
]
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function rows(value) { return Array.isArray(value?.records) ? value.records : [] }
|
||||
function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||
function data(response) { return response?.body?.data }
|
||||
function key(context, operation) { return `${context.marker}-manual-${operation}`.slice(0, 128) }
|
||||
function query(value) { return encodeURIComponent(String(value)) }
|
||||
function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) }
|
||||
|
||||
async function merchantRequest(context, method, path, body, token, headers = {}) {
|
||||
const requestHeaders = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...headers }
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body)
|
||||
if (payload !== undefined) requestHeaders['Content-Type'] = 'application/json; charset=utf-8'
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method, headers: requestHeaders, body: payload, signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
const content = await response.text()
|
||||
let parsed = null
|
||||
try { parsed = content ? JSON.parse(content) : null } catch { /* Keep bounded raw evidence. */ }
|
||||
return { method, path, http: response.status, body: parsed, raw: parsed ? null : content.slice(0, 500),
|
||||
requestId: parsed?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: parsed?.traceId || response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function merchantGet(context, path, token) {
|
||||
return merchantRequest(context, 'GET', path, undefined, token)
|
||||
}
|
||||
|
||||
async function loadFixtureInputs(context, token) {
|
||||
const profileResponse = await merchantGet(context, '/api/merchant/v1/iam/tenant/profile', token)
|
||||
const profile = context.apiSuccess(MODULE, '读取 IT 测试客户资料', profileResponse)
|
||||
const domesticResponse = await merchantGet(context,
|
||||
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', token)
|
||||
const domestic = rows(context.apiSuccess(MODULE, '读取国内收货仓', domesticResponse))[0]
|
||||
const destinationResponse = await merchantGet(context,
|
||||
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', token)
|
||||
const destination = rows(context.apiSuccess(MODULE, '读取 FBA 目的仓', destinationResponse))[0]
|
||||
if (!profile?.id || !domestic?.id || !destination?.id) throw new Error('状态样本缺少客户或仓库基础数据')
|
||||
const channelResponse = await merchantGet(context,
|
||||
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${query(domestic.id)}`, token)
|
||||
const channels = context.apiSuccess(MODULE, '读取可下单渠道', channelResponse)
|
||||
const channel = Array.isArray(channels)
|
||||
? channels.find((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0)
|
||||
: null
|
||||
if (!channel) throw new Error('状态样本缺少带计价税制的可用渠道')
|
||||
return { tenantId: id(profile.id), customerName: profile.tenantName || 'IT 测试客户', domestic, destination, channel }
|
||||
}
|
||||
|
||||
function orderPayload(context, fixture, target) {
|
||||
const sample = `${context.marker}-${target}`
|
||||
return {
|
||||
customerId: null, customerName: fixture.customerName, transportMode: 'SEA', tradeTerm: 'FOB',
|
||||
destinationCountryCode: fixture.destination.countryCode || 'US',
|
||||
destinationWarehouseId: fixture.destination.id, destinationWarehouseName: fixture.destination.warehouseName,
|
||||
expectedShipDate: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10),
|
||||
customerRemark: `${sample} 人工列表状态样本`, pickupMethod: 'PICKUP',
|
||||
receivingWarehouseId: fixture.domestic.id, receivingWarehouseName: fixture.domestic.warehouseName,
|
||||
channelCode: fixture.channel.channelCode, channelName: fixture.channel.channelName,
|
||||
carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION',
|
||||
pricingTaxMode: fixture.channel.pricingTaxModes[0], containerMode: 'LCL', customsInspection: false,
|
||||
destinationType: 'FBA', amazonWarehouseCode: fixture.destination.warehouseCode,
|
||||
shipmentId: `MANUAL-${target}-${context.marker}`.slice(0, 64), referenceId: `STATUS-${target}-${context.marker}`.slice(0, 64),
|
||||
sender: { companyName: fixture.customerName, contactName: 'IT 测试联系人', phone: '13800138000',
|
||||
email: 'it-test@example.com', countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区',
|
||||
addressLine: `${sample} 测试地址`, postalCode: '518000' },
|
||||
receiver: null,
|
||||
cargoes: [{ cargoName: `${target} 状态样本货物`, cargoNameEn: `${target} fixture cargo`, hsCode: '8518210000',
|
||||
customsHsCode: '8518210000', packageType: 'CARTON', packageCount: 1, grossWeightKg: 10,
|
||||
volumeCbm: 0.024, lengthCm: 40, widthCm: 30, heightCm: 20, declaredValue: 100, currency: 'USD',
|
||||
dangerousGoods: false, cargoRemark: sample, skuNo: `SKU-${target}-${context.marker}`.slice(0, 64),
|
||||
unitsPerPackage: 10, totalQuantity: 10, netWeightKg: 9, unitDeclaredValue: 10,
|
||||
material: '塑料', usage: '人工验收', model: `MODEL-${target}`, brand: 'IT-TEST' }],
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrder(context, token, fixture, target) {
|
||||
const response = await merchantRequest(context, 'POST', '/api/merchant/v1/orders/first-leg',
|
||||
orderPayload(context, fixture, target), token, { 'Idempotency-Key': key(context, `${target}-create`) })
|
||||
const orderId = id(context.apiSuccess(MODULE, `创建 ${target} 订单状态样本`, response))
|
||||
if (!orderId) throw new Error(`${target} 状态样本未返回订单 ID`)
|
||||
return orderId
|
||||
}
|
||||
|
||||
async function transition(context, token, fixture, orderId, target, action) {
|
||||
const merchantPath = `/api/merchant/v1/orders/first-leg/${query(orderId)}`
|
||||
if (action === 'submit') {
|
||||
const response = await merchantRequest(context, 'POST', `${merchantPath}/submit`, undefined, token,
|
||||
{ 'Idempotency-Key': key(context, `${target}-submit`) })
|
||||
context.apiSuccess(MODULE, `${target} 样本提交订单`, response)
|
||||
return
|
||||
}
|
||||
const platformPath = `/api/platform/v1/orders/first-leg/${query(orderId)}/${action}?tenantId=${query(fixture.tenantId)}`
|
||||
const body = action === 'reject' || action === 'cancel' ? { reason: `${context.marker} 保留 ${target} 人工样本` } : undefined
|
||||
const response = await context.request('POST', platformPath, body)
|
||||
context.apiSuccess(MODULE, `${target} 样本执行 ${action}`, response)
|
||||
}
|
||||
|
||||
async function readOrder(context, fixture, orderId) {
|
||||
const response = await context.request('GET',
|
||||
`/api/platform/v1/orders/first-leg/${query(orderId)}?tenantId=${query(fixture.tenantId)}`)
|
||||
return { response, detail: context.apiSuccess(MODULE, '回读订单状态样本', response) }
|
||||
}
|
||||
|
||||
async function waitForInbound(context, fixture, orderId) {
|
||||
const path = `/api/platform/v1/orders/${query(orderId)}?tenantId=${query(fixture.tenantId)}`
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const response = await context.request('GET', path)
|
||||
const summary = data(response)?.summary
|
||||
if (ok(response) && summary?.inboundId) return { response, summary }
|
||||
if (attempt < 19) await wait(500)
|
||||
}
|
||||
return { response: null, summary: null }
|
||||
}
|
||||
|
||||
async function prepareInboundStatus(context, target) {
|
||||
const originalMarker = context.marker
|
||||
const originalRequest = context.request
|
||||
const fixtureMarker = `${originalMarker}-IN-${target}`
|
||||
const stop = Symbol(target)
|
||||
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement', 'inboundWarehouseResources']
|
||||
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
|
||||
const createdSnapshot = new Map(createdKeys.map((name) => [name,
|
||||
{ present: Object.hasOwn(context.createdResources, name), value: context.createdResources[name] }]))
|
||||
const contextSnapshot = new Map(contextKeys.map((name) => [name,
|
||||
{ present: Object.hasOwn(context.context, name), value: context.context[name] }]))
|
||||
try {
|
||||
context.marker = fixtureMarker
|
||||
const orders = await import(new URL('./orders.mjs', import.meta.url))
|
||||
await orders.run(context)
|
||||
const order = { ...context.createdResources.orders }
|
||||
const inboundId = id(order.inboundId)
|
||||
const tenantId = id(order.tenantId)
|
||||
if (!inboundId || !tenantId) throw new Error(`${target} 未生成独立待测量入库单`)
|
||||
|
||||
context.request = async (method, path, body, token, headers) => {
|
||||
const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`)
|
||||
const isApprove = path.includes(`/inbounds/${inboundId}/measurements/approve`)
|
||||
if (target === 'MEASURING' && isSubmit) throw stop
|
||||
if (target === 'MEASURE_PENDING' && isApprove) throw stop
|
||||
if (target === 'MEASURE_REJECTED' && isApprove) {
|
||||
const response = await originalRequest.call(context, method, path.replace('/approve', '/reject'),
|
||||
{ ...body, reason: `${fixtureMarker} 保留审核驳回样本` }, token, headers)
|
||||
if (!ok(response)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${response?.http}, code=${response?.body?.code}`)
|
||||
throw stop
|
||||
}
|
||||
return originalRequest.call(context, method, path, body, token, headers)
|
||||
}
|
||||
try {
|
||||
const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url))
|
||||
await measurement.run(context)
|
||||
} catch (error) {
|
||||
if (error !== stop) throw error
|
||||
} finally {
|
||||
context.request = originalRequest
|
||||
}
|
||||
|
||||
const response = await originalRequest.call(context, 'GET',
|
||||
`/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}`)
|
||||
const detail = context.apiSuccess(MODULE, `回读 ${target} 独立入库单`, response, '', OPTIONS)
|
||||
context.assert(MODULE, `${target} 入库状态样本可从详情打开`, response,
|
||||
detail?.summary?.processStatus === target, `inboundId=${inboundId}, actual=${detail?.summary?.processStatus || 'missing'}`, OPTIONS)
|
||||
return { inboundId, orderId: id(order.orderId), tenantId, status: target,
|
||||
inboundCode: detail?.summary?.inboundCode || null }
|
||||
} finally {
|
||||
context.request = originalRequest
|
||||
context.marker = originalMarker
|
||||
for (const [name, snapshot] of createdSnapshot) {
|
||||
if (snapshot.present) context.createdResources[name] = snapshot.value
|
||||
else Reflect.deleteProperty(context.createdResources, name)
|
||||
}
|
||||
for (const [name, snapshot] of contextSnapshot) {
|
||||
if (snapshot.present) context.context[name] = snapshot.value
|
||||
else Reflect.deleteProperty(context.context, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(context, matrix = context.createdResources.manualStateMatrix) {
|
||||
const loginResponse = await context.request('POST', '/api/auth/login',
|
||||
{ username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD }, '')
|
||||
const token = context.apiSuccess(MODULE, 'IT 测试客户管理员登录', loginResponse)?.accessToken
|
||||
if (!token) throw new Error('客户管理员登录未返回 accessToken')
|
||||
const fixture = await loadFixtureInputs(context, token)
|
||||
const result = { marker: context.marker, tenantId: fixture.tenantId, orders: {}, inbounds: {}, unifiedOrders: {} }
|
||||
matrix.pages.firstLegOrders = { requiredStatuses: ORDER_TARGETS.map(([status]) => status), fixtures: {} }
|
||||
matrix.pages.inbounds = { requiredStatuses: INBOUND_FILTER_STATUSES, fixtures: {} }
|
||||
matrix.pages.unifiedOrders = { requiredStatuses: UNIFIED_FILTER_STATUSES, fixtures: {} }
|
||||
|
||||
for (const [target, actions] of ORDER_TARGETS) {
|
||||
const orderId = await createOrder(context, token, fixture, target)
|
||||
for (const action of actions) await transition(context, token, fixture, orderId, target, action)
|
||||
const { response, detail } = await readOrder(context, fixture, orderId)
|
||||
context.assert(MODULE, `${target} 订单状态样本可从详情打开`, response,
|
||||
id(detail?.order?.id) === orderId && detail?.order?.status === target,
|
||||
`orderId=${orderId}, status=${detail?.order?.status ?? 'missing'}`)
|
||||
result.orders[target] = { orderId, orderNo: detail?.order?.orderNo || null, status: target,
|
||||
listPath: `/admin/first-leg-orders?status=${target}` }
|
||||
matrix.pages.firstLegOrders.fixtures[target] = {
|
||||
id: orderId, status: target, detailPath: `/orders/${orderId}`,
|
||||
}
|
||||
|
||||
if (actions.includes('approve')) {
|
||||
const inbound = await waitForInbound(context, fixture, orderId)
|
||||
const inboundId = id(inbound.summary?.inboundId)
|
||||
context.assert(MODULE, `${target} 订单生成待测量入库单`, inbound.response, Boolean(inboundId),
|
||||
`orderId=${orderId}, inboundId=${inboundId || 'missing'}`)
|
||||
if (inboundId && !result.inbounds.WAIT_MEASURE) result.inbounds.WAIT_MEASURE = {
|
||||
inboundId, inboundCode: inbound.summary?.inboundCode || null, orderId, processStatus: 'WAIT_MEASURE',
|
||||
listPath: '/admin/inbounds?processStatus=WAIT_MEASURE',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [status, sample] of Object.entries(result.orders)) {
|
||||
const response = await context.request('GET',
|
||||
`/api/platform/v1/orders/first-leg?tenantId=${query(fixture.tenantId)}&status=${status}&keyword=${query(sample.orderNo)}&page=1&size=20`)
|
||||
const page = context.apiSuccess(MODULE, `${status} 订单可按列表状态筛选`, response)
|
||||
context.assert(MODULE, `${status} 筛选结果包含人工样本`, response,
|
||||
rows(page).some((item) => id(item.id) === sample.orderId), `orderId=${sample.orderId}, total=${page?.total ?? 'n/a'}`)
|
||||
}
|
||||
|
||||
const waitMeasure = result.inbounds.WAIT_MEASURE
|
||||
if (waitMeasure) matrix.pages.inbounds.fixtures.WAIT_MEASURE = {
|
||||
id: waitMeasure.inboundId, status: 'WAIT_MEASURE', detailPath: `/orders/${waitMeasure.orderId}`,
|
||||
}
|
||||
|
||||
const submitted = result.orders.SUBMITTED
|
||||
result.unifiedOrders.PENDING_REVIEW = { orderId: submitted.orderId, orderNo: submitted.orderNo,
|
||||
mainStatus: 'PENDING_REVIEW', listPath: '/admin/unified-orders?mainStatus=PENDING_REVIEW' }
|
||||
matrix.pages.unifiedOrders.fixtures.PENDING_REVIEW = {
|
||||
id: submitted.orderId, status: 'PENDING_REVIEW', detailPath: `/orders/${submitted.orderId}`,
|
||||
}
|
||||
if (waitMeasure) result.unifiedOrders.WAIT_MEASURE = { orderId: waitMeasure.orderId,
|
||||
inboundId: waitMeasure.inboundId, mainStatus: 'WAIT_MEASURE', listPath: '/admin/unified-orders?mainStatus=WAIT_MEASURE' }
|
||||
if (waitMeasure) matrix.pages.unifiedOrders.fixtures.WAIT_MEASURE = {
|
||||
id: waitMeasure.orderId, status: 'WAIT_MEASURE', detailPath: `/orders/${waitMeasure.orderId}`,
|
||||
}
|
||||
|
||||
for (const status of ['MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'INBOUNDED']) {
|
||||
const sample = await prepareInboundStatus(context, status)
|
||||
result.inbounds[status] = sample
|
||||
matrix.pages.inbounds.fixtures[status] = {
|
||||
id: sample.inboundId, status, detailPath: `/orders/${sample.orderId}`,
|
||||
}
|
||||
if (status === 'INBOUNDED') {
|
||||
result.unifiedOrders.INBOUNDED = sample
|
||||
matrix.pages.unifiedOrders.fixtures.INBOUNDED = {
|
||||
id: sample.orderId, status, detailPath: `/orders/${sample.orderId}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blockers = {
|
||||
WAIT_PRICING: '当前审核通过 API 直接写 INBOUNDED;仅异常放行开关路径可写 WAIT_PRICING',
|
||||
FEE_CONFIRMING: '需要先取得可匹配的真实计价快照并停在费用确认前',
|
||||
INBOUNDING: 'AutomaticInboundCompletionService 在同一调用内立即从 INBOUNDING 推进 INBOUNDED,无可暂停 API',
|
||||
VOIDED: 'InboundProcessStatus 声明了 VOIDED,但当前 Controller/Service 没有作废入库单 API',
|
||||
}
|
||||
for (const status of INBOUND_FILTER_STATUSES) if (!matrix.pages.inbounds.fixtures[status]) {
|
||||
context.assert(MODULE, `入库列表保留 ${status}`, null, false, blockers[status], OPTIONS)
|
||||
}
|
||||
const unifiedBlockers = {
|
||||
WAIT_SPLIT: '服务端已将 WAIT_SPLIT 标记为历史废弃状态,查询时统一归一为 INBOUNDED,不能再写入或返回',
|
||||
LOADING: '需要分柜、排柜及 PDA 专项独立样本',
|
||||
LOADED: '需要完成装柜证据和 PDA 门禁的专项独立样本',
|
||||
DELIVERED: '当前后端没有完成派送并写 DELIVERED 的公开业务 API',
|
||||
}
|
||||
for (const status of UNIFIED_FILTER_STATUSES) if (!matrix.pages.unifiedOrders.fixtures[status]) {
|
||||
context.assert(MODULE, `统一订单列表保留 ${status}`, null, false, unifiedBlockers[status], OPTIONS)
|
||||
}
|
||||
|
||||
context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true,
|
||||
`orders=${Object.keys(result.orders).length}, inbounds=${Object.keys(result.inbounds).length}`, OPTIONS)
|
||||
}
|
||||
@@ -14,12 +14,21 @@ $requiredTests = @(
|
||||
"firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java",
|
||||
"firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java",
|
||||
"firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java",
|
||||
"order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java"
|
||||
"order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java",
|
||||
"iam/src/test/java/com/baiyue/iam/BaiyueIamApplicationContractTest.java",
|
||||
"iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java",
|
||||
"iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java",
|
||||
"iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java",
|
||||
"iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java",
|
||||
"iam/src/test/java/com/baiyue/iam/notification/NotificationBusinessEventConsumerTest.java",
|
||||
"iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java",
|
||||
"iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java",
|
||||
"gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java"
|
||||
)
|
||||
$mavenArgs = @("-f", $pom, "-pl", "order,firstleg", "-am", "test")
|
||||
$mavenArgs = @("-f", $pom, "-pl", "order,firstleg,iam,gateway", "-am", "test")
|
||||
|
||||
Write-Host "Backend regression root: $repoRoot"
|
||||
Write-Host "Scope: order + firstleg Maven reactor (includes all module tests)"
|
||||
Write-Host "Scope: order + firstleg + iam + gateway Maven reactor (includes all module tests)"
|
||||
Write-Host "Database/API access: none"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $pom)) {
|
||||
|
||||
Reference in New Issue
Block a user