258 lines
19 KiB
JavaScript
258 lines
19 KiB
JavaScript
const MODULE = 'domestic-container'
|
|
const BASE = '/api/platform/v1/domestic-containers'
|
|
const PERMISSIONS = ['platform:domestic-container:list', 'platform:domestic-container:inbound:create',
|
|
'platform:domestic-container:outbound:create']
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
function succeeded(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
|
function traced(context, label, response) {
|
|
context.assert(MODULE, `${label}返回链路标识`, response, Boolean(response?.requestId) && Boolean(response?.traceId),
|
|
`requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`)
|
|
}
|
|
function expectCode(context, label, response, code) {
|
|
context.assert(MODULE, label, response, response?.http === 200 && String(response?.body?.code) === code,
|
|
`HTTP=${response?.http}, code=${response?.body?.code || 'missing'}`)
|
|
traced(context, label, response)
|
|
}
|
|
async function api(context, method, path, body, token = context.token) {
|
|
const response = await context.request(method, path, body, token)
|
|
traced(context, `${method} ${path}`, response)
|
|
return response
|
|
}
|
|
|
|
async function verifyValidationBoundaries(context, warehouseId, marker, now) {
|
|
const valid = {
|
|
warehouseId, cargoName: marker, containerNo: `DCV${marker}`.slice(0, 32).toUpperCase(), boxCount: 1,
|
|
volumeCbm: 1, inboundTime: now, status: 'COMPLETED', remark: marker,
|
|
}
|
|
const cases = [
|
|
['集装箱号格式非法被拒绝', { ...valid, containerNo: 'bad!', idempotencyKey: `${marker}-invalid-container` }],
|
|
['零箱入库被拒绝', { ...valid, boxCount: 0, idempotencyKey: `${marker}-zero-boxes` }],
|
|
['零体积入库被拒绝', { ...valid, volumeCbm: 0, idempotencyKey: `${marker}-zero-volume` }],
|
|
['非法入库状态被拒绝', { ...valid, status: 'UNKNOWN', idempotencyKey: `${marker}-invalid-status` }],
|
|
['空幂等键被拒绝', { ...valid, idempotencyKey: ' ' }],
|
|
['超长备注被拒绝', { ...valid, remark: 'R'.repeat(501), idempotencyKey: `${marker}-long-remark` }],
|
|
]
|
|
for (const [label, body] of cases) {
|
|
const response = await api(context, 'POST', `${BASE}/inbounds`, body)
|
|
expectCode(context, label, response, 'A0422')
|
|
}
|
|
expectCode(context, '非法入库分页大小被拒绝',
|
|
await api(context, 'GET', `${BASE}/inbounds?page=1&size=201`), 'A0422')
|
|
expectCode(context, '非法库存状态筛选被拒绝',
|
|
await api(context, 'GET', `${BASE}/inventories?page=1&size=20&status=UNKNOWN`), 'A0422')
|
|
}
|
|
|
|
async function verifyExtendedInventoryScenarios(context, warehouseId, marker, now) {
|
|
const containerNo = `DCX${marker}`.slice(0, 32).toUpperCase()
|
|
const cargoName = `${marker}-extended`.slice(0, 120)
|
|
const firstBody = { warehouseId, cargoName, containerNo, boxCount: 40, volumeCbm: 20,
|
|
inboundTime: now, status: 'COMPLETED', remark: `${marker}-extended-1`, idempotencyKey: `${marker}-extended-1` }
|
|
context.apiSuccess(MODULE, '扩展场景首次完成入库', await api(context, 'POST', `${BASE}/inbounds`, firstBody))
|
|
context.apiSuccess(MODULE, '同仓同柜同货名追加入库', await api(context, 'POST', `${BASE}/inbounds`, {
|
|
...firstBody, cargoName: ` ${cargoName} `, containerNo: containerNo.toLowerCase(), boxCount: 10, volumeCbm: 5,
|
|
remark: `${marker}-extended-2`, idempotencyKey: `${marker}-extended-2`,
|
|
}))
|
|
expectCode(context, '同仓同柜不同货名禁止合并库存', await api(context, 'POST', `${BASE}/inbounds`, {
|
|
...firstBody, cargoName: `${cargoName}-other`, boxCount: 1, volumeCbm: 1,
|
|
idempotencyKey: `${marker}-cargo-conflict`,
|
|
}), 'A0409')
|
|
|
|
const filteredResponse = await api(context, 'GET', `${BASE}/inventories?page=1&size=1&warehouseId=${warehouseId}&containerNo=${containerNo.toLowerCase()}&cargoName=${encodeURIComponent(cargoName)}&status=IN_STOCK`)
|
|
const filtered = context.apiSuccess(MODULE, '组合条件筛选扩展库存', filteredResponse)
|
|
const inventory = filtered?.records?.[0]
|
|
context.assert(MODULE, '筛选汇总不受分页大小影响且柜号已标准化', filteredResponse,
|
|
Number(filtered?.total) === 1 && filtered?.records?.length === 1 && inventory?.containerNo === containerNo
|
|
&& Number(filtered?.summary?.inboundBoxes) === 50 && Number(filtered?.summary?.availableBoxes) === 50
|
|
&& Number(filtered?.summary?.inboundVolumeCbm) === 25,
|
|
`total=${filtered?.total}, records=${filtered?.records?.length}, containerNo=${inventory?.containerNo}, summary=${JSON.stringify(filtered?.summary)}`)
|
|
if (!inventory?.id) throw new Error('扩展库存未创建成功')
|
|
|
|
const inboundListResponse = await api(context, 'GET', `${BASE}/inbounds?page=1&size=1&warehouseId=${warehouseId}&containerNo=${containerNo}&cargoName=${encodeURIComponent(cargoName)}&status=COMPLETED`)
|
|
const inboundList = context.apiSuccess(MODULE, '组合条件筛选扩展入库单', inboundListResponse)
|
|
context.assert(MODULE, '入库单筛选总数与分页记录正确', inboundListResponse,
|
|
Number(inboundList?.total) === 2 && inboundList?.records?.length === 1
|
|
&& inboundList.records[0]?.status === 'COMPLETED' && inboundList.records[0]?.businessType === 'INBOUND',
|
|
`total=${inboundList?.total}, records=${inboundList?.records?.length}, status=${inboundList?.records?.[0]?.status}`)
|
|
|
|
const beforeMissingVersion = await api(context, 'GET', `${BASE}/inventories/${inventory.id}`)
|
|
const beforeDetail = context.apiSuccess(MODULE, '缺失版本测试前读取库存详情', beforeMissingVersion)
|
|
expectCode(context, '完成出库必须携带库存版本', await api(context, 'POST', `${BASE}/outbounds`, {
|
|
warehouseId, containerNo, boxCount: 1, outboundTime: now, status: 'COMPLETED',
|
|
idempotencyKey: `${marker}-missing-version`,
|
|
}), 'A0422')
|
|
const afterMissingVersion = context.apiSuccess(MODULE, '缺失版本请求后回查库存',
|
|
await api(context, 'GET', `${BASE}/inventories/${inventory.id}`))
|
|
context.assert(MODULE, '缺失库存版本的请求不得扣减库存', beforeMissingVersion,
|
|
Number(afterMissingVersion?.inventory?.availableBoxes) === Number(beforeDetail?.inventory?.availableBoxes),
|
|
`before=${beforeDetail?.inventory?.availableBoxes}, after=${afterMissingVersion?.inventory?.availableBoxes}`)
|
|
|
|
const currentVersion = Number(afterMissingVersion?.inventory?.version)
|
|
const availableBeforeRace = Number(afterMissingVersion?.inventory?.availableBoxes)
|
|
const raceBodies = [1, 2].map((sequence) => ({ warehouseId, containerNo, boxCount: 7, outboundTime: now,
|
|
status: 'COMPLETED', inventoryVersion: currentVersion, idempotencyKey: `${marker}-race-${sequence}` }))
|
|
const raceResponses = await Promise.all(raceBodies.map((body) => context.request('POST', `${BASE}/outbounds`, body, context.token)))
|
|
raceResponses.forEach((response, index) => traced(context, `并发出库请求${index + 1}`, response))
|
|
const winners = raceResponses.filter(succeeded)
|
|
const conflicts = raceResponses.filter((response) => response?.http === 200 && String(response?.body?.code) === 'A0409')
|
|
context.assert(MODULE, '同一库存版本并发出库仅一个成功', raceResponses[0], winners.length === 1 && conflicts.length === 1,
|
|
`success=${winners.length}, conflict=${conflicts.length}`)
|
|
const afterRaceResponse = await api(context, 'GET', `${BASE}/inventories/${inventory.id}`)
|
|
const afterRace = context.apiSuccess(MODULE, '并发出库后回查库存', afterRaceResponse)
|
|
context.assert(MODULE, '并发出库只扣减一次并只新增一条流水', afterRaceResponse,
|
|
Number(afterRace?.inventory?.availableBoxes) === availableBeforeRace - 7
|
|
&& afterRace?.transactions?.length === 3 && Number(afterRace?.outboundOrderCount) === 1,
|
|
`before=${availableBeforeRace}, after=${afterRace?.inventory?.availableBoxes}, transactions=${afterRace?.transactions?.length}, outboundOrders=${afterRace?.outboundOrderCount}`)
|
|
const winnerIndex = raceResponses.findIndex(succeeded)
|
|
if (winnerIndex >= 0) {
|
|
const firstWinner = raceResponses[winnerIndex]?.body?.data
|
|
const replay = await api(context, 'POST', `${BASE}/outbounds`, raceBodies[winnerIndex])
|
|
const replayData = context.apiSuccess(MODULE, '并发成功请求使用原幂等键重放', replay)
|
|
context.assert(MODULE, '并发成功请求重放不重复扣减', replay,
|
|
replayData?.replayed === true && id(replayData?.id) === id(firstWinner?.id),
|
|
`replayed=${replayData?.replayed}, firstId=${id(firstWinner?.id)}, replayId=${id(replayData?.id)}`)
|
|
}
|
|
expectCode(context, '不存在的整柜库存详情返回未找到',
|
|
await api(context, 'GET', `${BASE}/inventories/1`), 'A0404')
|
|
}
|
|
|
|
export async function run(context) {
|
|
const marker = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18)
|
|
const initialInboundsResponse = await api(context, 'GET', `${BASE}/inbounds?page=1&size=20`)
|
|
const initialInbounds = context.apiSuccess(MODULE, '无筛选查询整柜入库单列表', initialInboundsResponse)
|
|
context.assert(MODULE, '无筛选整柜入库列表分页结构正确', initialInboundsResponse,
|
|
Array.isArray(initialInbounds?.records) && Number(initialInbounds?.page) === 1 && Number(initialInbounds?.size) === 20,
|
|
`records=${initialInbounds?.records?.length ?? 'missing'}, page=${initialInbounds?.page}, size=${initialInbounds?.size}`)
|
|
const warehousesResponse = await api(context, 'GET', '/api/platform/v1/warehouse-admin?page=1&size=200&warehouseType=DOMESTIC&status=ACTIVE')
|
|
const warehouses = context.apiSuccess(MODULE, '查询平台启用国内仓', warehousesResponse)
|
|
const warehouse = warehouses?.records?.[0]
|
|
context.assert(MODULE, '平台国内仓候选有效', warehousesResponse, id(warehouse?.id) && warehouse?.warehouseType === 'DOMESTIC',
|
|
`warehouseId=${id(warehouse?.id) || 'missing'}, type=${warehouse?.warehouseType || 'missing'}`)
|
|
if (!warehouse) throw new Error('缺少启用的平台国内仓,整柜验收无法继续')
|
|
const warehouseId = id(warehouse.id)
|
|
const containerNo = `DCA${marker}`.slice(0, 32).toUpperCase()
|
|
const now = new Date().toISOString().slice(0, 19)
|
|
|
|
await verifyValidationBoundaries(context, warehouseId, marker, now)
|
|
|
|
const invalidWarehouse = await api(context, 'POST', `${BASE}/inbounds`, {
|
|
warehouseId: '1', cargoName: marker, containerNo: `${containerNo}X`, boxCount: 1, volumeCbm: 1,
|
|
inboundTime: now, status: 'COMPLETED', remark: marker, idempotencyKey: `${marker}-invalid-warehouse`,
|
|
})
|
|
expectCode(context, '非平台国内仓禁止整柜入库', invalidWarehouse, 'A0404')
|
|
|
|
const pendingInboundBody = { warehouseId, cargoName: marker, containerNo, boxCount: 100, volumeCbm: 50,
|
|
inboundTime: now, status: 'PENDING', remark: `${marker}-pending-in`, idempotencyKey: `${marker}-pending-in` }
|
|
const pendingInbound = await api(context, 'POST', `${BASE}/inbounds`, pendingInboundBody)
|
|
context.apiSuccess(MODULE, '创建待入库整柜单', pendingInbound)
|
|
const beforeCompleted = await api(context, 'GET', `${BASE}/inventories?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`)
|
|
const beforePage = context.apiSuccess(MODULE, '待入库后查询库存', beforeCompleted)
|
|
context.assert(MODULE, 'PENDING 入库不记账', beforeCompleted, Number(beforePage?.total) === 0 && Number(beforePage?.summary?.availableBoxes) === 0,
|
|
`total=${beforePage?.total}, available=${beforePage?.summary?.availableBoxes}`)
|
|
|
|
const inboundBody = { ...pendingInboundBody, status: 'COMPLETED', remark: `${marker}-completed-in`, idempotencyKey: `${marker}-completed-in` }
|
|
const inbound = await api(context, 'POST', `${BASE}/inbounds`, inboundBody)
|
|
const inboundCreated = context.apiSuccess(MODULE, '完成整柜入库', inbound)
|
|
context.assert(MODULE, 'COMPLETED 入库返回新单', inbound, id(inboundCreated?.id) && inboundCreated?.replayed === false,
|
|
`orderId=${id(inboundCreated?.id) || 'missing'}`)
|
|
const replayInbound = await api(context, 'POST', `${BASE}/inbounds`, inboundBody)
|
|
const replayed = context.apiSuccess(MODULE, '同幂等键同请求重放', replayInbound)
|
|
context.assert(MODULE, '幂等重放返回首次单据', replayInbound,
|
|
id(replayed?.id) === id(inboundCreated?.id) && replayed?.replayed === true, `replayed=${replayed?.replayed}`)
|
|
const conflictingInbound = await api(context, 'POST', `${BASE}/inbounds`, { ...inboundBody, boxCount: 101 })
|
|
expectCode(context, '同幂等键不同请求冲突', conflictingInbound, 'A0409')
|
|
|
|
const inventoryResponse = await api(context, 'GET', `${BASE}/inventories?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`)
|
|
const inventoryPage = context.apiSuccess(MODULE, '查询完成入库库存', inventoryResponse)
|
|
let inventory = inventoryPage?.records?.[0]
|
|
context.assert(MODULE, '库存及汇总与入库一致', inventoryResponse,
|
|
Number(inventory?.inboundBoxes) === 100 && Number(inventory?.availableBoxes) === 100
|
|
&& inventory?.inventoryStatus === 'IN_STOCK' && Number(inventoryPage?.summary?.inboundBoxes) === 100,
|
|
`in=${inventory?.inboundBoxes}, available=${inventory?.availableBoxes}, status=${inventory?.inventoryStatus}`)
|
|
const inventoryId = id(inventory?.id)
|
|
if (!inventoryId) throw new Error('完成入库后未形成库存记录')
|
|
|
|
const pendingOutbound = await api(context, 'POST', `${BASE}/outbounds`, { warehouseId, containerNo, boxCount: 5,
|
|
outboundTime: now, status: 'PENDING', remark: `${marker}-pending-out`, idempotencyKey: `${marker}-pending-out` })
|
|
context.apiSuccess(MODULE, '创建待出库整柜单', pendingOutbound)
|
|
const afterPending = await api(context, 'GET', `${BASE}/inventories/${inventoryId}`)
|
|
const afterPendingDetail = context.apiSuccess(MODULE, '待出库后查询详情', afterPending)
|
|
context.assert(MODULE, 'PENDING 出库不记账', afterPending,
|
|
Number(afterPendingDetail?.inventory?.availableBoxes) === 100 && afterPendingDetail?.transactions?.length === 1,
|
|
`available=${afterPendingDetail?.inventory?.availableBoxes}, transactions=${afterPendingDetail?.transactions?.length}`)
|
|
|
|
const partial = await api(context, 'POST', `${BASE}/outbounds`, { warehouseId, containerNo, boxCount: 30,
|
|
outboundTime: now, status: 'COMPLETED', remark: `${marker}-partial`, idempotencyKey: `${marker}-partial`,
|
|
inventoryVersion: Number(afterPendingDetail.inventory.version) })
|
|
context.apiSuccess(MODULE, '完成部分出库', partial)
|
|
let detailResponse = await api(context, 'GET', `${BASE}/inventories/${inventoryId}`)
|
|
let detail = context.apiSuccess(MODULE, '查询部分出库详情和流水', detailResponse)
|
|
context.assert(MODULE, '部分出库库存和交易流水正确', detailResponse,
|
|
detail?.inventory?.inventoryStatus === 'PARTIALLY_OUTBOUND' && Number(detail?.inventory?.availableBoxes) === 70
|
|
&& detail?.transactions?.length === 2 && detail?.transactions?.[0]?.businessType === 'OUTBOUND',
|
|
`available=${detail?.inventory?.availableBoxes}, status=${detail?.inventory?.inventoryStatus}, tx=${detail?.transactions?.length}`)
|
|
|
|
const staleVersion = await api(context, 'POST', `${BASE}/outbounds`, { warehouseId, containerNo, boxCount: 1,
|
|
outboundTime: now, status: 'COMPLETED', idempotencyKey: `${marker}-stale`,
|
|
inventoryVersion: Number(afterPendingDetail.inventory.version) })
|
|
expectCode(context, '过期库存版本冲突', staleVersion, 'A0409')
|
|
const insufficient = await api(context, 'POST', `${BASE}/outbounds`, { warehouseId, containerNo, boxCount: 71,
|
|
outboundTime: now, status: 'COMPLETED', idempotencyKey: `${marker}-insufficient`,
|
|
inventoryVersion: Number(detail.inventory.version) })
|
|
expectCode(context, '库存不足禁止出库', insufficient, 'A0409')
|
|
|
|
const full = await api(context, 'POST', `${BASE}/outbounds`, { warehouseId, containerNo, boxCount: 70,
|
|
outboundTime: now, status: 'COMPLETED', remark: `${marker}-full`, idempotencyKey: `${marker}-full`,
|
|
inventoryVersion: Number(detail.inventory.version) })
|
|
context.apiSuccess(MODULE, '完成全部出库', full)
|
|
detailResponse = await api(context, 'GET', `${BASE}/inventories/${inventoryId}`)
|
|
detail = context.apiSuccess(MODULE, '查询全部出库详情', detailResponse)
|
|
context.assert(MODULE, '全部出库库存和三条交易流水正确', detailResponse,
|
|
detail?.inventory?.inventoryStatus === 'OUTBOUND' && Number(detail?.inventory?.availableBoxes) === 0
|
|
&& Number(detail?.inventory?.outboundBoxes) === 100 && detail?.transactions?.length === 3,
|
|
`available=${detail?.inventory?.availableBoxes}, outbound=${detail?.inventory?.outboundBoxes}, tx=${detail?.transactions?.length}`)
|
|
|
|
const inboundsList = await api(context, 'GET', `${BASE}/inbounds?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`)
|
|
const inboundRows = context.apiSuccess(MODULE, '筛选整柜入库单列表', inboundsList)
|
|
const outboundsList = await api(context, 'GET', `${BASE}/outbounds?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`)
|
|
const outboundRows = context.apiSuccess(MODULE, '筛选整柜出库单列表', outboundsList)
|
|
context.assert(MODULE, '列表覆盖待单和已完成单', outboundsList,
|
|
inboundRows?.records?.length === 2 && outboundRows?.records?.length === 3,
|
|
`inbounds=${inboundRows?.records?.length}, outbounds=${outboundRows?.records?.length}`)
|
|
|
|
await verifyExtendedInventoryScenarios(context, warehouseId, marker, now)
|
|
|
|
await verifyPermissionDenial(context, marker, warehouseId, containerNo)
|
|
context.createdResources.domesticContainer = { warehouseId, inventoryId, containerNo, permissions: PERMISSIONS }
|
|
}
|
|
|
|
async function verifyPermissionDenial(context, marker, warehouseId, containerNo) {
|
|
const phone = await context.findAvailablePhone(MODULE)
|
|
const roleCode = `DC_DENY_${marker}`.slice(0, 64)
|
|
const username = `dc_deny_${marker}`.slice(0, 64).toLowerCase()
|
|
const password = `Dc${marker.slice(-8)}A1!`
|
|
const roleResponse = await context.request('POST', '/api/platform/v1/iam/roles', {
|
|
roleCode, roleName: `整柜权限拒绝-${marker}`, description: marker,
|
|
})
|
|
const role = context.apiSuccess(MODULE, '创建无整柜权限角色', roleResponse)
|
|
const roleId = id(role?.id)
|
|
if (!roleId) throw new Error('无法创建权限拒绝验收角色')
|
|
const userResponse = await context.request('POST', '/api/platform/v1/iam/users', {
|
|
username, displayName: `整柜权限拒绝-${marker}`, phone, email: `${username}@example.com`, tenantId: '0',
|
|
roleIds: [roleId], password, status: 'ACTIVE',
|
|
})
|
|
const user = context.apiSuccess(MODULE, '创建无整柜权限用户', userResponse)
|
|
const membershipId = id(user?.membershipId)
|
|
if (!membershipId) throw new Error('无法创建权限拒绝验收用户')
|
|
try {
|
|
const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password }, '')
|
|
const token = context.apiSuccess(MODULE, '无整柜权限用户登录', loginResponse)?.accessToken
|
|
if (!token) throw new Error('权限拒绝验收用户登录失败')
|
|
const denied = await context.request('GET', `${BASE}/inventories?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`, undefined, token)
|
|
expectCode(context, '无整柜权限用户被拒绝', denied, 'A0403')
|
|
} finally {
|
|
await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/status`, { status: 'DISABLED' })
|
|
await context.request('PUT', `/api/platform/v1/iam/roles/${roleId}/status`, { status: 'DISABLED' })
|
|
}
|
|
}
|