This commit is contained in:
2026-10-21 21:30:24 +08:00
parent 234216b3eb
commit 5356a8c898
28 changed files with 1543 additions and 45 deletions
+6
View File
@@ -1,5 +1,11 @@
# Admin 测试环境 HTTP 模块
固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。
固定客户模式下,默认编排排除 `system`、`master-data`、`suppliers`、`notifications`、`pricing`、`phase4-overseas-master-data`、`phase4-overseas-wallet-and-pallet-cost` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。
Admin TEST 入口必须通过显式环境文件配置 `ADMIN_TEST_FIXED_CUSTOMER_ID`(`TEN` 编号或数字 ID)及该客户的 `MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。每次先核验账号归属、ACTIVE 状态和尺寸初始化,再复用客户造业务数据;校验失败停止后续模块,不自动建客、重提资质或重置密码。客户创建/归档生命周期明确排除,海外仓储费模块只读核验既有规则,不覆盖固定客户配置。需要第二客户的隔离用例在单客户模式下阻断,不用同一客户替代跨客户验证。该限制不代表这些生命周期或跨客户场景验收通过,也不清理历史客户。
`fleet-return-redispatch-acceptance.mjs` 的原出发证明读取本轮 `LOADING_COMPLETE` 回执与 `departureDeliveries`;重派在 PDA 装车完成后回查自动生成的出库/派送及两箱已发运事实,不再调用手工安排派送或确认发车。原键重放和跨操作员拒绝仍由共享装车验证覆盖,原签收不漂移、源订单完成事件审计及资源分别释放仍须独立核对。离线模拟不能代替匹配部署的真实 HTTP。
固定托盘造数中,`inbound-measurement.mjs` 只复用 `TP-001`、`YW-TP-002` 指定池中的现有托盘;一只可复用,不要求两只同时存在,两只均缺失才阻断,不自动选择池外托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。
@@ -1,6 +1,11 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { prepare } from './phase3-container-customs-fixture.mjs'
test('fixed single customer blocks mixed customs before creating orders or loading fixtures', async () => {
await assert.rejects(prepare({ options: { fixedCustomerId: '123' } }), /停止订单及装柜造数/)
})
import { verifyFormalDeclarationAndExceptions, verifyMixedReceiptIsolation } from './phase3-container-customs.mjs'
import {
@@ -236,6 +236,7 @@ async function verifyFrozenSource(context, fixture) {
}
async function createSecondCustomer(context) {
if (context.options?.fixedCustomerId) throw new Error('缺少第二个固定客户,停止跨客户财务写入')
const originalMarker = context.marker
const originalContext = context.context?.merchantQualificationFixture
const originalResource = context.createdResources?.merchantQualificationFixture
@@ -4,7 +4,30 @@ import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildContainerCostBody, buildSupplierBody, normalizeFinanceFixtureSources, run,
supplierCategoryForFeeItem } from './finance-full-fixtures.mjs'
supplierCategoryForFeeItem, loadSupplier } from './finance-full-fixtures.mjs'
test('fixed customer supplier selection paginates and never creates a fallback', async () => {
const calls = [], failures = []
const supplier = { id: '123', status: 'ACTIVE', category: 'TRUCKING', name: 'Existing' }
const context = {
options: { fixedCustomerId: '456' }, marker: 'REUSE',
async request(method, path) {
calls.push({ method, path })
const data = path.includes('/123?') ? supplier : path.includes('page=2')
? { records: [supplier], total: 101 }
: { records: Array.from({ length: 100 }, () => ({ id: '99', status: 'ACTIVE', category: 'BOOKING' })), total: 101 }
return { body: { data } }
},
apiSuccess(_module, _name, response) { return response.body.data },
addStep(...args) { failures.push(args) },
}
assert.equal((await loadSupplier(context, 'TRUCKING')).id, '123')
assert.equal(calls.length, 3)
assert.ok(calls.every(call => call.method === 'GET'))
context.request = async method => { assert.equal(method, 'GET'); return { body: { data: { records: [], total: 0 } } } }
assert.equal(await loadSupplier(context, 'TRUCKING'), null)
assert.equal(failures.length, 1)
})
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8')
@@ -258,8 +258,27 @@ async function loadPublishedAutomaticFee(context, resource) {
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
}
async function loadSupplier(context, category) {
export async function loadSupplier(context, category) {
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category)
if (context.options?.fixedCustomerId) {
for (let page = 1; ; page += 1) {
const response = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&status=ACTIVE&page=${page}&size=100`)
const data = context.apiSuccess(MODULE, '读取可复用的现有供应商', response, '', OPTIONS)
if (!data) return null
const records = rows(data)
const candidate = records.find(item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category))
if (candidate) {
const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(candidate.id)}?tenantId=0`)
const detail = context.apiSuccess(MODULE, '核对复用供应商状态与类别', detailResponse, '', OPTIONS)
if (id(detail?.id) === id(candidate.id) && detail?.status === 'ACTIVE' && (!category || detail.category === category)) return detail
blocker(context, '复用供应商已变化', '候选详情不匹配,不创建替代供应商')
return null
}
if (!records.length || page * 100 >= Number(data.total ?? records.length)) break
}
blocker(context, '缺少可复用供应商', `需要 ACTIVE ${category || '任意类别'} 供应商;禁止自动创建`)
return null
}
const effectiveCategory = category || 'BOOKING'
const body = buildSupplierBody(context.marker, effectiveCategory)
const response = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', body)
@@ -122,6 +122,7 @@ async function createCustomerWaitSplit(context, suffix, fixedRoute = null) {
}
async function provisionCrossCustomerPlan(context) {
if (context.options?.fixedCustomerId) throw new Error('缺少第二个固定客户,停止跨客户柜费造数')
const originalMarker = context.marker
const originalUsername = process.env.MERCHANT_TEST_USERNAME
const originalPassword = process.env.MERCHANT_TEST_PASSWORD
@@ -143,7 +143,7 @@ export async function runQuoteSession(context, { marker = 'FLEET-QUOTE-20261003'
if (!successful(response) || !login?.accessToken || !login?.refreshToken) throw new Error('Independent TEST login failed')
context.token = login.accessToken; context.refreshToken = login.refreshToken
const prepare = prepareCustomer || (await import('./merchant-qualification-fixture.mjs')).run
await context.executeModule('merchant-qualification-fixture', () => prepare(context, { publishEnvironment: false }))
await context.executeModule('merchant-qualification-fixture', () => prepare(context, { publishEnvironment: false, requireDistinctCustomer: false }))
if (context.steps.some(step => !step.passed)) throw new Error('Current-run customer preparation failed; quotes not executed')
await context.executeModule(MODULE, () => run(context))
} catch (error) { fatal = error instanceof Error ? error.message : String(error) }
@@ -34,7 +34,7 @@ test('real Jackson long counts accept safe decimal strings without changing pall
function server(fault = '') {
const calls = [], assertions = [], saved = new Map(), loadingActors = new Map()
const detail = { dispatchNo: 'DP-A', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: '9007199254740998',
counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {},
counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {}, publishLooseboardDemand: false,
dispatchInfo: { estimatedArrivalAt: '2026-10-21T10:00:00Z', fbaAppointments: [{ appointmentNo: 'FBA-ORIGINAL' }] }, vehicleInfo: {} }
const trip = { tripNo: 'TR-A', status: 'PLANNED', warehouseId: '92', recordVersion: 4, tripPurpose: 'DELIVERY', stops }
let driverVersion = 0, uploadComplete = false, driverReviewed = false, nextPlate = '', checkpointError = false, nextTrip, nextDetail
@@ -103,6 +103,7 @@ function server(fault = '') {
pallets: [pallet()], missingFields: ['PLT-A:palletSpaces'] })
}
if (method === 'PUT' && path === D) {
assert.equal(body.publishLooseboardDemand, detail.publishLooseboardDemand)
if (detail.stage !== 'BEFORE_LOADING') {
if (fault === 'edit-mutates-on-rejection') detail.counts.pickedPackageCount--
return conflict('仅装车前可编辑,请显式退回;已经扫码不得改派')
@@ -180,6 +181,15 @@ function server(fault = '') {
const prepare = s => prepareFleetTrip(s.context, 'native', 'DP-A', '92')
test('review preparation uses the current confirmed appointment when arrival is unset', async () => {
const s = server(), at = new Date(Date.now() + 72 * 3600000).toISOString()
s.detail.dispatchInfo.estimatedArrivalAt = null
s.detail.dispatchInfo.fbaAppointments = [{ status: 'PENDING', confirmedAt: null }, { status: 'SUCCESS', confirmedAt: at }]
const proof = await prepare(s)
assert.equal(proof.updateRequest.estimatedArrivalAt, at)
assert.ok(Date.parse(proof.expectedDriverEndAt) > Date.parse(at))
})
function jacksonCountWire(s) {
const names = new Set(['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', 'scheduledPackages', 'pickedPackages'])
const serialize = value => Array.isArray(value) ? value.map(serialize)
@@ -104,11 +104,14 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
}, '建立本轮TEST车辆主档及容量')
proof.vehicleId = id(vehicle.id)
if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID')
await prepareQualifiedDriver(context, module, proof)
const confirmedTimes = (before.dispatchInfo?.fbaAppointments || []).filter(row => row.status === 'SUCCESS' && row.confirmedAt).map(row => row.confirmedAt).sort()
const arrival = before.dispatchInfo?.estimatedArrivalAt ?? confirmedTimes[0] ?? null
await prepareQualifiedDriver(context, module, proof, arrival)
proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'),
publishLooseboardDemand: before.publishLooseboardDemand,
vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId },
driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt } },
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: before.dispatchInfo?.estimatedArrivalAt ?? null,
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: arrival,
remark: `${context.marker} TEST合成资料正常履约` }
let result = await command(context, module, 'PUT', path, proof.updateRequest, '通过排车主单编辑正式分配车辆司机')
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) {
@@ -127,8 +130,8 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
proof.preparation = 'VERIFIED'; await context.checkpoint?.(); return proof
}
async function prepareQualifiedDriver(context, module, proof) {
proof.expectedDriverEndAt = new Date(Date.now() + 48 * 3600000).toISOString()
async function prepareQualifiedDriver(context, module, proof, arrival) {
proof.expectedDriverEndAt = new Date(Math.max(Date.now() + 48 * 3600000, arrival ? Date.parse(arrival) + 6 * 3600000 : 0)).toISOString()
let driver = await command(context, module, 'POST', `${BASE}/drivers`, {
name: `${proof.marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
licenseNumber: proof.marker, licenseType: 'Class A', licenseZone: 'UTC',
@@ -1,7 +1,96 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { qualificationCreditCode } from './merchant-qualification-fixture.mjs'
import vm from 'node:vm'
import { qualificationCreditCode, normalizeFixedCustomerId, run } from './merchant-qualification-fixture.mjs'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
function fixedContext(profileId = '2100000000000000001') {
const calls = []
return {
calls, marker: 'FIXED-CUSTOMER-TEST', context: {}, createdResources: {},
options: { fixedCustomerId: 'TEN2100000000000000001', merchantUsername: 'fixture-user', merchantPassword: 'fixture-password' },
async request(method, path) {
calls.push({ method, path })
const data = path.endsWith('/login') ? { accessToken: 'fixture-token', refreshToken: 'fixture-refresh' }
: path.endsWith('/profile') ? { id: profileId, status: 'ACTIVE', tenantName: 'Fixture' }
: { status: 'READY' }
return { http: 200, body: { code: 200, data } }
},
apiSuccess(_module, _label, response) { return response.body.data },
assert() {},
}
}
test('fixed customer accepts business number without losing integer precision', () => {
assert.equal(normalizeFixedCustomerId('TEN2100000000000000001'), '2100000000000000001')
assert.throws(() => normalizeFixedCustomerId('TENinvalid'))
})
test('fixed customer preparation only authenticates and reads existing customer', async () => {
const context = fixedContext()
await run(context, { publishEnvironment: false, requireDistinctCustomer: false })
assert.equal(context.context.merchantQualificationFixture.reused, true)
assert.equal(context.context.merchantQualificationFixture.tenantId, '2100000000000000001')
assert.equal(context.context.customerId, '2100000000000000001')
assert.deepEqual(context.calls.filter(call => call.method !== 'GET').map(call => call.path), ['/api/auth/login', '/api/auth/logout'])
assert.equal(context.createdResources.merchantQualificationFixture.password, undefined)
})
test('wrong customer identity stops preparation and logs out', async () => {
const context = fixedContext('2100000000000000002')
await assert.rejects(run(context, { publishEnvironment: false, requireDistinctCustomer: false }), /归属或状态不匹配/)
assert.equal(context.context.merchantQualificationFixture, undefined)
assert.equal(context.calls.at(-1).path, '/api/auth/logout')
assert.equal(context.calls.length, 3)
})
test('second customer fixture cannot silently reuse the first customer', async () => {
const context = fixedContext()
await assert.rejects(run(context, { publishEnvironment: false }), /跨客户场景阻断/)
assert.equal(context.calls.length, 0)
})
test('TEST fixture without fixed customer fails before any request', async () => {
const context = fixedContext()
context.options = { environment: 'test' }
await assert.rejects(run(context), /必须配置固定客户/)
assert.equal(context.calls.length, 0)
})
test('cross-customer financial helpers stop before provisioning or writing', async () => {
for (const [file, name, end] of [
['finance-direct-paid-isolation.mjs', 'createSecondCustomer', 'async function verifyCustomerIsolation'],
['firstleg-container-fee-allocation.mjs', 'provisionCrossCustomerPlan', 'export async function run'],
]) {
const source = readFileSync(new URL(file, import.meta.url), 'utf8')
const helper = source.slice(source.indexOf(`async function ${name}(`), source.indexOf(end))
const scope = vm.createContext({})
vm.runInContext(helper.replaceAll('import.meta.url', "'file:///unused-test-module.mjs'"), scope)
await assert.rejects(scope[name]({ options: { fixedCustomerId: '123' } }), /缺少第二个固定客户/)
}
})
test('fixed resource runner excludes lifecycle handlers without marking them passed', async () => {
const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
let calls = 0
for (const name of ['customers', 'supplemental-boundaries', 'system', 'master-data', 'suppliers', 'notifications', 'pricing',
'phase4-overseas-master-data', 'phase4-overseas-wallet-and-pallet-cost']) {
await runner.executeModule(name, async () => { calls++ })
}
assert.equal(calls, 0)
assert.equal(runner.modules.length, 9)
assert.ok(runner.modules.every(module => module.skipped && !module.success))
})
test('both request entrypoints reject customer creation before network access', async () => {
const runner = new AcceptanceRun({ environment: 'test' })
for (const method of ['request', 'requestWithBody']) {
for (const path of ['/api/platform/v1/iam/tenants', '/api/platform/v1/iam/tenants/123/archive', '/api/platform/v1/iam/tenants/123/submit-review']) {
await assert.rejects(runner[method]('POST', path, {}), /禁止创建、归档客户/)
}
}
})
const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8')
@@ -38,7 +38,46 @@ async function waitForDimensionInitialization(context, tenantId) {
if (status !== 'READY') throw new Error(`客户默认尺寸初始化未就绪: ${status}`)
}
export async function run(context, { publishEnvironment = true } = {}) {
export function normalizeFixedCustomerId(value) {
const text = String(value || '').trim().replace(/^TEN/, '')
if (!/^[1-9]\d{0,19}$/.test(text)) throw new Error('固定客户编号必须为 TEN 编号或数字 ID')
return text
}
async function reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer) {
if (requireDistinctCustomer) throw new Error('固定客户模式未配置第二个客户,跨客户场景阻断;禁止自动建客或用同一客户替代隔离验证')
const tenantId = normalizeFixedCustomerId(context.options.fixedCustomerId)
const username = context.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME
const password = context.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD
if (!username || !password) throw new Error('请配置固定客户的 MERCHANT_TEST_USERNAME / MERCHANT_TEST_PASSWORD;不会新建客户或重置密码')
const loginResponse = await context.request('POST', '/api/auth/login', {loginId:username,password}, '')
const login = context.apiSuccess(MODULE, '登录已配置的固定客户账号', loginResponse)
if (!login?.accessToken) throw new Error('固定客户账号登录失败')
let profile
try {
const response = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, login.accessToken)
profile = context.apiSuccess(MODULE, '核对固定客户账号实际归属和状态', response)
const valid = id(profile?.id) === tenantId && profile?.status === 'ACTIVE'
context.assert(MODULE, '固定客户账号属于指定已审核客户', response, valid,
`expected=${tenantId}, actual=${id(profile?.id)}, status=${profile?.status}`, {deploymentBlocker:true})
if (!valid) throw new Error('固定客户账号归属或状态不匹配,停止造数;不会新建客户')
await waitForDimensionInitialization(context, tenantId)
} finally {
await context.request('POST', '/api/auth/logout', {refreshToken:login.refreshToken}, login.accessToken)
}
if (publishEnvironment) {
process.env.MERCHANT_TEST_USERNAME = username
process.env.MERCHANT_TEST_PASSWORD = password
}
const fixture = {tenantId,tenantName:profile.tenantName,username,marker:context.marker,reused:true}
context.context.merchantQualificationFixture = {...fixture,password}
context.context.customerId = tenantId
context.createdResources.merchantQualificationFixture = fixture
}
export async function run(context, { publishEnvironment = true, requireDistinctCustomer = !publishEnvironment } = {}) {
if (context.options?.fixedCustomerId) return reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer)
if (context.options?.environment === 'test') throw new Error('TEST 必须配置固定客户,禁止自动建客')
const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0')
const customerName = String(context.options?.customerName || '').trim()
const tenantName = customerName || `全量验收下单客户-${suffix}`
@@ -140,7 +140,7 @@ export async function createCompletedOrder(context, runFulfillment = async scope
return run(scope)
}, prepareCustomer = async scope => {
const { run } = await import('./merchant-qualification-fixture.mjs')
return run(scope, { publishEnvironment: false })
return run(scope, { publishEnvironment: false, requireDistinctCustomer: false })
}) {
const scope = Object.create(context)
scope.marker = `${context.marker}-COMPLETED`
@@ -75,6 +75,16 @@ async function createFixtureCustomer(context) {
}
export async function run(context) {
if (context.options?.fixedCustomerId) {
const tenantId = context.context.merchantQualificationFixture?.tenantId
if (!tenantId) throw new Error('固定客户前置未完成,禁止创建海外仓费用客户')
const response = await context.request('GET', `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/overseas-storage-fees`)
const view = context.apiSuccess(MODULE, '读取固定客户海外仓费用配置', response)
context.assert(MODULE, '费用配置属于固定客户', response, id(view?.customerId) === id(tenantId), `customerId=${id(view?.customerId)}`, OPTIONS)
context.addCoverageGap(MODULE, SCENARIO, '固定客户模式排除费用规则破坏性生命周期',
'只读现有费用配置,不覆盖共享客户费率或归档客户', 'not-implemented', {blocking:false})
return
}
const start = context.steps.length
// Always create a module-owned customer. The customers module may archive its
// own fixture, and this lifecycle must never depend on that or on history.
@@ -260,6 +260,7 @@ export async function withPlatformBookingScope(context, runLoading) {
}
export async function prepare(context) {
if (context.options?.fixedCustomerId) throw new Error('混合报关柜需要第二个固定客户,停止订单及装柜造数')
const outerMarker = context.marker
const isolatedResourceKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures']
const isolatedResourceSnapshot = new Map(isolatedResourceKeys.map((name) => [name, {
@@ -3,10 +3,21 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { run } from './phase4-overseas-finance-cross-region.mjs'
import { run, selectCurrentInboundPlan } from './phase4-overseas-finance-cross-region.mjs'
const source = readFileSync(fileURLToPath(new URL('./phase4-overseas-finance-cross-region.mjs', import.meta.url)), 'utf8')
test('inbound fees cannot select historical plans or another customer under a fixed account', () => {
const current = { planId: '4001', planNo: 'CURRENT', customerId: '2001' }
const old = { planId: '3999', planNo: 'OLD', customerId: '2001' }
const pda = { marker: 'RUN', customerId: '2001', generatedPlanIds: ['4001'] }
assert.equal(selectCurrentInboundPlan([old, current], pda, 'RUN'), current)
assert.equal(selectCurrentInboundPlan([old], pda, 'RUN'), null)
assert.equal(selectCurrentInboundPlan([current], pda, 'OTHER'), null)
assert.equal(selectCurrentInboundPlan([{ ...current, customerId: '2002' }], pda, 'RUN'), null)
assert.equal(selectCurrentInboundPlan([current], { ...pda, generatedPlanIds: [] }, 'RUN'), null)
})
function response(data, code = '200', http = 200, region) {
return {
http,
@@ -40,7 +51,7 @@ function fixture() {
truck: { orderNo: 'TK-1', customerId: '1201' },
looseboard: { orderNo: 'LB-1', customerId: '1301' },
},
phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' },
phase4OverseasPda: { marker, generatedPlanIds: ['4001'], customerId: '2001', overseasWarehouseId: '9001' },
},
async request(...args) {
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
@@ -56,6 +56,13 @@ async function resolveListedBusiness(context, base, resource, customerId, label)
return longId(row?.id) ? { businessId: id(row.id), businessNo: row.orderNo, customerId } : null
}
export function selectCurrentInboundPlan(plans, pda, marker) {
if (!pda || pda.marker !== marker || !longId(pda.customerId)) return null
const ids = new Set(rows(pda.generatedPlanIds).map(id))
return rows(plans).find(item => ids.has(id(item?.planId || item?.id))
&& id(item?.customerId) === id(pda.customerId) && item?.planNo) || null
}
async function resolveFixtures(context) {
const business = context.createdResources?.phase4OverseasBusiness
const merchantCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId
@@ -76,7 +83,7 @@ async function resolveFixtures(context) {
if (longId(inboundCustomerId)) {
const listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划',
`/api/platform/v1/last-leg/inbound-plans?page=1&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`)
const plan = rows(listed.data).find((item) => longId(item?.planId || item?.id) && item?.planNo)
const plan = selectCurrentInboundPlan(listed.data, pda, context.marker)
context.assert(MODULE, '海外入仓费用关联真实计划', listed.response,
Boolean(plan) && longId(plan?.planId || plan?.id),
`planId=${id(plan?.planId || plan?.id) || 'missing'}, planNo=${plan?.planNo || 'missing'}; ${trace(listed.response)}`, OPTIONS)
@@ -161,5 +161,23 @@ test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径',
assert.doesNotMatch(source, /创建本轮换标单|verifyRelabelLifecycle/)
assert.match(source, /创建本轮公共库位/)
assert.match(source, /公共托盘旧版本编辑返回冲突/)
assert.doesNotMatch(source, /deploymentBlocker|addCoverageGap|coverageGap/)
assert.doesNotMatch(source, /deploymentBlocker/)
assert.match(source, /固定资源模式缺少海外仓,禁止自动创建仓库/)
})
test('fixed customer mode reuses warehouse without location or pallet lifecycle writes', async () => {
const context = contextFixture()
context.options = { fixedCustomerId: '10001' }
context.context.merchantQualificationFixture = { tenantId: '10001' }
await run(context)
assert.equal(context.context.phase4OverseasWarehouse.customerId, '10001')
assert.equal(context.context.phase4OverseasWarehouse.warehouseId, '20001')
assert.ok(context.calls.filter(call => call.method !== 'GET')
.every(call => call.method === 'POST' && call.path === '/api/platform/v1/files/export-tasks'))
assert.equal(context.gaps.length, 1)
const missing = contextFixture({ warehouseRecords: [] })
missing.options = context.options
missing.context.merchantQualificationFixture = { tenantId: '10001' }
await assert.rejects(run(missing), /禁止自动创建仓库/)
assert.equal(missing.calls.some(call => call.method === 'POST' && call.path === '/api/platform/v1/warehouse-admin'), false)
})
@@ -67,7 +67,10 @@ export async function run(context) {
context.createdResources.phase4OverseasWarehouse = { marker: context.marker, customerId: null, warehouseId: null, palletId: null, terminalStatus: null }
const tenantsResult = await domesticGet(context, '查询可用客户候选', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE')
assertPage(context, '可用客户候选', tenantsResult)
const customer = records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE')
const fixedCustomer = context.context?.merchantQualificationFixture
const customer = context.options?.fixedCustomerId
? { id: fixedCustomer?.tenantId, status: fixedCustomer?.tenantId ? 'ACTIVE' : null }
: records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE')
const customerId = id(customer?.id)
if (!customerId) {
context.assert(MODULE, '海外仓验收必须取得ACTIVE客户', tenantsResult.response, false,
@@ -151,6 +154,7 @@ export async function run(context) {
let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId)
let ownedWarehouse = false
if (!warehouse) {
if (context.options?.fixedCustomerId) throw new Error('固定资源模式缺少海外仓,禁止自动创建仓库')
warehouse = await createWarehouseFixture(context, customerId)
ownedWarehouse = Boolean(warehouse)
}
@@ -185,6 +189,12 @@ export async function run(context) {
`listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
}
if (context.options?.fixedCustomerId) {
context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: null }
context.addCoverageGap(MODULE, 'resource-lifecycle', '复用海外仓资源,仅核验列表和详情',
'不创建或修改公共库位及托盘;业务分配由后续实际库存流程决定', 'not-implemented', { blocking: false })
return
}
try {
await verifyLocationLifecycle(context, warehouseId)
await verifyPalletLifecycle(context, warehouseId)