优化
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', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']
|
||||
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', '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',
|
||||
@@ -406,8 +406,50 @@ export class AcceptanceRun {
|
||||
this.assert(module, '客户编辑正确回显', editResponse, edited?.tenantName === editedPayload.tenantName && edited?.contactEmail === editedPayload.contactEmail && Number(edited?.paymentTermDays) === 45, `tenantId=${tenantId}`)
|
||||
const persistedResponse = await this.request('GET', detailPath); const persisted = this.apiSuccess(module, '回查客户编辑结果', persistedResponse)
|
||||
this.assert(module, '客户编辑已持久化', persistedResponse, persisted?.tenantName === editedPayload.tenantName && persisted?.internalRemark === editedPayload.internalRemark, `tenantId=${tenantId}`)
|
||||
let customerAuditPage = null
|
||||
let customerAuditResponse = null
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
customerAuditResponse = await this.request('GET', `/api/platform/v1/audit/logs?accountId=${tenantId}&eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId=${tenantId}&page=1&size=20`)
|
||||
customerAuditPage = customerAuditResponse.http === 200 && String(customerAuditResponse.body?.code) === '200' ? customerAuditResponse.body?.data : null
|
||||
if (customerAuditPage?.records?.some((item) => item.eventType === 'PLATFORM_TENANT_UPDATE' && String(item.accountId) === tenantId)) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
const customerAudit = this.apiSuccess(module, '按客户查询操作日志', customerAuditResponse)
|
||||
const customerAuditRow = customerAudit?.records?.find((item) => item.eventType === 'PLATFORM_TENANT_UPDATE' && String(item.accountId) === tenantId)
|
||||
this.assert(module, '客户操作日志包含本轮编辑事件', customerAuditResponse, Boolean(customerAuditRow), `tenantId=${tenantId}, event=${customerAuditRow?.eventType || 'missing'}`)
|
||||
if (customerAuditRow?.id) {
|
||||
const auditDetailResponse = await this.request('GET', `/api/platform/v1/audit/logs/${customerAuditRow.id}`)
|
||||
const auditDetail = this.apiSuccess(module, '查询客户操作日志详情', auditDetailResponse)
|
||||
const snapshotsAreJson = ['beforeData', 'afterData'].every((field) => {
|
||||
if (typeof auditDetail?.[field] !== 'string') return false
|
||||
try { JSON.parse(auditDetail[field]); return true } catch { return false }
|
||||
})
|
||||
const snapshotText = `${auditDetail?.beforeData || ''}${auditDetail?.afterData || ''}`
|
||||
const snapshotsAreRedacted = !/(password|authorization|accessToken|refreshToken)\s*[\"']?\s*:/i.test(snapshotText)
|
||||
this.assert(module, '操作日志详情返回脱敏 JSON 快照', auditDetailResponse,
|
||||
snapshotsAreJson && snapshotsAreRedacted && String(auditDetail.accountId) === tenantId && String(auditDetail.resourceId) === tenantId,
|
||||
`accountId=${auditDetail?.accountId ?? 'missing'}, resourceId=${auditDetail?.resourceId ?? 'missing'}, snapshotsAreJson=${snapshotsAreJson}, snapshotsAreRedacted=${snapshotsAreRedacted}`)
|
||||
}
|
||||
const qualificationPackage = new FormData()
|
||||
const qualificationImage = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
|
||||
qualificationPackage.append('businessLicense', new Blob([qualificationImage], { type: 'image/png' }), `business-license-${suffix}.png`)
|
||||
qualificationPackage.append('legalPersonId', new Blob([qualificationImage], { type: 'image/png' }), `legal-person-id-${suffix}.png`)
|
||||
qualificationPackage.append('unifiedSocialCreditCode', `91440300${suffix.replace(/[^0-9A-Z]/gi, '').toUpperCase().padStart(10, '0').slice(-10)}`)
|
||||
const packageResponse = await this.requestWithBody('POST', `/api/platform/v1/files/qualifications/${tenantId}/required-package`, qualificationPackage)
|
||||
this.apiSuccess(module, '上传审核所需客户资质包', packageResponse, `tenantId=${tenantId}`)
|
||||
const qualificationResponse = await this.request('GET', `${detailPath}/qualifications`); const qualifications = this.apiSuccess(module, '业务概况查询资质', qualificationResponse)
|
||||
this.assert(module, '资质概况结构正确', qualificationResponse, Array.isArray(qualifications), `count=${Array.isArray(qualifications) ? qualifications.length : 'n/a'}`)
|
||||
const requiredQualifications = Array.isArray(qualifications)
|
||||
? qualifications.filter((item) => ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].includes(item.qualificationType)) : []
|
||||
this.assert(module, '审核资质列表包含营业执照和法人身份证', qualificationResponse,
|
||||
requiredQualifications.length === 2 && requiredQualifications.every((item) => typeof item.documentUrl === 'string' && item.documentUrl.length > 0),
|
||||
`types=${requiredQualifications.map((item) => item.qualificationType).join(',') || 'missing'}`)
|
||||
for (const qualification of requiredQualifications) {
|
||||
const fileName = decodeURIComponent(new URL(qualification.documentUrl, 'http://acceptance.local').pathname.split('/').pop() || '')
|
||||
const downloadResponse = await this.requestBinary('GET', `/api/platform/v1/files/qualifications/${tenantId}/${encodeURIComponent(fileName)}`)
|
||||
this.assert(module, `审核人员可下载${qualification.qualificationType}`, downloadResponse,
|
||||
downloadResponse.http === 200 && downloadResponse.bytes.length > 0 && /^image\/|application\/pdf/.test(downloadResponse.contentType),
|
||||
`file=${fileName}, bytes=${downloadResponse.bytes.length}, contentType=${downloadResponse.contentType || 'missing'}`)
|
||||
}
|
||||
const memberResponse = await this.request('GET', `${detailPath}/members`); const members = this.apiSuccess(module, '业务概况查询成员', memberResponse)
|
||||
const owner = members?.find?.((item) => item.memberType === 'OWNER')
|
||||
this.assert(module, '联系电话账号是企业管理员', memberResponse,
|
||||
|
||||
@@ -35,3 +35,17 @@ export async function run(context) {
|
||||
- 测试数据必须带 `context.marker`;可删除的清理,不能删除的最终停用。
|
||||
- 缺少已确认契约时记录失败或 `deploymentBlocker`,不得模拟成功。
|
||||
- 一个模块只修改自己的 `.mjs`,不要修改其他模块文件。
|
||||
|
||||
## 供应商模块
|
||||
|
||||
`suppliers.mjs` 覆盖平台公共供应商的列表、统计、新增、详情、编辑、启停及主要错误边界。执行:
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs --modules suppliers
|
||||
```
|
||||
|
||||
该命令会调用测试环境写 API,只有当前对话明确授权后才允许执行。验收供应商最终停用但不删除;静态合同可安全离线运行:
|
||||
|
||||
```powershell
|
||||
node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs
|
||||
```
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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 acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8')
|
||||
const system = fs.readFileSync(path.join(here, 'system.mjs'), 'utf8')
|
||||
|
||||
test('客户验收覆盖审核资质上传、回查和鉴权下载', () => {
|
||||
assert.match(acceptance, /files\/qualifications\/\$\{tenantId\}\/required-package/)
|
||||
assert.match(acceptance, /BUSINESS_LICENSE/)
|
||||
assert.match(acceptance, /LEGAL_PERSON_ID/)
|
||||
assert.match(acceptance, /requestBinary\('GET', `\/api\/platform\/v1\/files\/qualifications\/\$\{tenantId\}/)
|
||||
assert.match(acceptance, /downloadResponse\.bytes\.length > 0/)
|
||||
})
|
||||
|
||||
test('系统验收覆盖后端四种权限资源类型并清理测试资源', () => {
|
||||
for (const type of ['BUTTON', 'MENU', 'PAGE', 'API']) assert.match(system, new RegExp(`type: '${type}'`))
|
||||
assert.match(system, /created\?\.permissionType === definition\.type/)
|
||||
assert.match(system, /created\?\.httpMethod/)
|
||||
assert.match(system, /DELETE.*permissions\/\$\{additionalPermissionId\}/s)
|
||||
})
|
||||
|
||||
test('客户验收覆盖客户维度操作日志列表和详情快照', () => {
|
||||
assert.match(acceptance, /audit\/logs\?accountId=\$\{tenantId\}&eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId=\$\{tenantId\}/)
|
||||
assert.match(acceptance, /PLATFORM_TENANT_UPDATE/)
|
||||
assert.match(acceptance, /audit\/logs\/\$\{customerAuditRow\.id\}/)
|
||||
assert.match(acceptance, /JSON\.parse\(auditDetail\[field\]\)/)
|
||||
assert.match(acceptance, /snapshotsAreRedacted/)
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
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 moduleSource = fs.readFileSync(path.join(here, 'domestic-container.mjs'), 'utf8')
|
||||
const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8')
|
||||
|
||||
test('国内整柜模块仅由总编排注入 context 后执行', () => {
|
||||
assert.match(moduleSource, /export async function run\(context\)/)
|
||||
assert.doesNotMatch(moduleSource, /https?:\/\//)
|
||||
assert.doesNotMatch(moduleSource, /ADMIN_TEST_GATEWAY|process\.env|fetch\(/)
|
||||
assert.match(acceptance, /import\(new URL\(`\.\/admin-test-env-modules\/\$\{module\}\.mjs`/)
|
||||
})
|
||||
|
||||
test('正式全量验收默认注册国内整柜模块', () => {
|
||||
assert.match(acceptance, /DEFAULT_MODULES\s*=\s*\[[^\]]*['"]domestic-container['"]/s)
|
||||
})
|
||||
|
||||
test('覆盖国内仓、待单、完成入库、部分和全部出库及列表详情流水', () => {
|
||||
for (const token of ['warehouseType=DOMESTIC', "status: 'PENDING'", "status: 'COMPLETED'",
|
||||
'PARTIALLY_OUTBOUND', "inventoryStatus === 'OUTBOUND'", '/inbounds?page=1', '/outbounds?page=1',
|
||||
'/inventories/${inventoryId}', 'transactions?.length === 3']) assert.match(moduleSource, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||
})
|
||||
|
||||
test('覆盖库存不足、版本冲突及两类幂等行为', () => {
|
||||
assert.match(moduleSource, /库存不足禁止出库/)
|
||||
assert.match(moduleSource, /过期库存版本冲突/)
|
||||
assert.match(moduleSource, /同幂等键同请求重放/)
|
||||
assert.match(moduleSource, /同幂等键不同请求冲突/)
|
||||
assert.match(moduleSource, /inventoryVersion/)
|
||||
})
|
||||
|
||||
test('覆盖权限拒绝与 requestId traceId', () => {
|
||||
assert.match(moduleSource, /无整柜权限用户被拒绝/)
|
||||
assert.match(moduleSource, /'A0403'/)
|
||||
assert.match(moduleSource, /Boolean\(response\?\.requestId\).*Boolean\(response\?\.traceId\)/s)
|
||||
assert.match(moduleSource, /status: 'DISABLED'/)
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
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
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const marker = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18)
|
||||
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)
|
||||
|
||||
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 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', { 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' })
|
||||
}
|
||||
}
|
||||
@@ -31,3 +31,16 @@ test('Finance 不借用历史来源且缺 fixture 记录 coverage gap', () => {
|
||||
assert.match(finance, /context\.addCoverageGap\(MODULE, ['"]payable-payment['"]/)
|
||||
assert.match(finance, /context\.addCoverageGap\(MODULE, ['"]receipt-three-settlements['"]/)
|
||||
})
|
||||
|
||||
test('客户详情财务账单验收覆盖客户过滤、详情结构与查看权限链', () => {
|
||||
assert.match(finance, /\/api\/platform\/v1\/iam\/authorization\/permissions/)
|
||||
assert.match(finance, /\/statements\?customerId=\$\{encodeURIComponent\(customerId\)\}/)
|
||||
assert.match(finance, /statementRows\.every\(\(item\) => id\(item\?\.customerId\) === customerId\)/)
|
||||
assert.match(finance, /statementRows\.some\(\(item\) => statementId\(item\) === createdStatementId\)/)
|
||||
assert.match(finance, /\/reconciliation-statements\/\$\{encodeURIComponent\(createdStatementId\)\}/)
|
||||
assert.match(finance, /id\(statementDetail\?\.customerId\) === customerId/)
|
||||
assert.match(finance, /statementId\(statementDetail\) === createdStatementId/)
|
||||
assert.match(finance, /Array\.isArray\(statementDetail\?\.lines\)/)
|
||||
assert.match(finance, /Array\.isArray\(statementDetail\?\.revisions\)/)
|
||||
assert.match(finance, /platform:finance:bill:view/)
|
||||
})
|
||||
|
||||
@@ -79,6 +79,30 @@ export async function run(context) {
|
||||
`first=${createdStatementId || 'missing'}, replay=${statementId(replay) || 'missing'}`, OPTIONS)
|
||||
if (!createdStatementId) return
|
||||
|
||||
const permissionResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions')
|
||||
const permissions = context.apiSuccess(MODULE, '客户详情财务账单查看权限链可用', permissionResponse, '', OPTIONS)
|
||||
const grantedPermissions = new Set(rows(permissions).map((item) => id(item?.permissionCode ?? item)))
|
||||
context.assert(MODULE, '财务账单列表查看权限已授予', permissionResponse,
|
||||
grantedPermissions.has('platform:finance:bill:view') || grantedPermissions.has('platform:finance:customer-statement:list'),
|
||||
`permissions=${[...grantedPermissions].filter((item) => item.includes('finance')).join(',')}`, OPTIONS)
|
||||
|
||||
const statementListResponse = await context.request('GET', `${BASE}/statements?customerId=${encodeURIComponent(customerId)}`)
|
||||
const statementList = context.apiSuccess(MODULE, '客户详情财务账单列表按客户过滤', statementListResponse,
|
||||
`customerId=${customerId}`, OPTIONS)
|
||||
const statementRows = rows(statementList)
|
||||
context.assert(MODULE, '财务账单列表仅返回本轮客户且包含新账单', statementListResponse,
|
||||
statementRows.length > 0 && statementRows.every((item) => id(item?.customerId) === customerId)
|
||||
&& statementRows.some((item) => statementId(item) === createdStatementId),
|
||||
`customerId=${customerId}, statementId=${createdStatementId}, rows=${statementRows.length}`, OPTIONS)
|
||||
|
||||
const statementDetailResponse = await context.request('GET', `${BASE}/reconciliation-statements/${encodeURIComponent(createdStatementId)}`)
|
||||
const statementDetail = context.apiSuccess(MODULE, '读取客户详情财务账单详情', statementDetailResponse,
|
||||
`statementId=${createdStatementId}`, OPTIONS)
|
||||
context.assert(MODULE, '财务账单详情客户与明细修订结构完整', statementDetailResponse,
|
||||
id(statementDetail?.customerId) === customerId && statementId(statementDetail) === createdStatementId
|
||||
&& Array.isArray(statementDetail?.lines) && Array.isArray(statementDetail?.revisions),
|
||||
`customerId=${id(statementDetail?.customerId) || 'missing'}, statementId=${statementId(statementDetail) || 'missing'}, lines=${Array.isArray(statementDetail?.lines)}, revisions=${Array.isArray(statementDetail?.revisions)}`, OPTIONS)
|
||||
|
||||
for (const action of ['SUBMIT', 'APPROVE', 'ISSUE']) {
|
||||
const actionResponse = await write(context, 'POST', `${BASE}/reconciliation-statements/${createdStatementId}/actions`,
|
||||
{ action, expectedVersion: Number(statement.version) }, `statement-${action.toLowerCase()}`)
|
||||
|
||||
@@ -16,6 +16,10 @@ test('retains all clickable container, loading-list and PDA workflow states thro
|
||||
assert.match(source, /prepareInbound/)
|
||||
assert.match(source, /\.\/orders\.mjs/)
|
||||
assert.match(source, /\.\/inbound-measurement\.mjs/)
|
||||
assert.doesNotMatch(source, /context\.request\('POST', CONTAINERS/)
|
||||
assert.match(source, /\['WAIT_SPLIT', 'INBOUNDED'\]\.includes/)
|
||||
const batch = source.slice(source.indexOf('async function pickAll'), source.indexOf('async function complete'))
|
||||
assert.doesNotMatch(batch, /containerNo:/)
|
||||
})
|
||||
|
||||
test('does not mutate historical fixtures or remove retained records', () => {
|
||||
|
||||
@@ -66,7 +66,7 @@ async function prepareInbound(context, label) {
|
||||
await measurement.run(context)
|
||||
const measured = context.createdResources.inboundMeasurement
|
||||
const order = context.createdResources.orders
|
||||
if (!measured?.inboundId || !measured?.tenantId) throw new Error(`${label} 未生成独立 INBOUNDED 入库单`)
|
||||
if (!measured?.inboundId || !measured?.tenantId) throw new Error(`${label} 未生成独立 WAIT_SPLIT 入库单`)
|
||||
return { marker: fixtureMarker, tenantId: id(measured.tenantId), inboundId: id(measured.inboundId),
|
||||
orderId: id(order?.orderId) }
|
||||
} finally {
|
||||
@@ -88,29 +88,13 @@ async function readInbound(context, fixture) {
|
||||
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'
|
||||
['WAIT_SPLIT', 'INBOUNDED'].includes(value?.summary?.processStatus)
|
||||
&& 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
|
||||
@@ -195,7 +179,7 @@ async function pickAll(context, chain, label) {
|
||||
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,
|
||||
containerBookingId: chain.containerId,
|
||||
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,
|
||||
@@ -317,10 +301,10 @@ export async function run(context, matrix = context.createdResources.manualState
|
||||
requiredActions: ['customsDeclaration'], fixtures: {}, actions: {} }
|
||||
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 waitingSource = await readInbound(context, await prepareInbound(context, 'WAIT'))
|
||||
const waitingContainerId = await createConfirmedContainer(context, waitingSource, 'WAIT')
|
||||
matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waitingContainerId, 'WAIT_LOADING',
|
||||
`/container-loading?containerId=${waitingContainerId}`)
|
||||
|
||||
const editing = await createPlanTask(context, 'EDIT')
|
||||
const inProgress = await claim(context, await createPlanTask(context, 'PROGRESS'), 'PROGRESS')
|
||||
|
||||
@@ -112,13 +112,44 @@ export async function run(context) {
|
||||
`candidateFound=${Boolean(candidate)}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
|
||||
if (!candidate?.inboundDetailId) return
|
||||
|
||||
const foreignChannelCode = `${channelCode}-OTHER`.slice(0, 64)
|
||||
const foreignBookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', {
|
||||
expectedVersion: 0,
|
||||
data: { ...bookingDraft, channelCode: foreignChannelCode, channelName: `异渠道-${suffix}`, remark: `${context.marker}-other-channel` },
|
||||
}, { 'Idempotency-Key': `${context.marker}-other-channel-booking-create`.slice(0, 128) })
|
||||
const foreignBookingCreated = context.apiSuccess(MODULE, '创建异渠道隔离订舱', foreignBookingCreate, '', OPTIONS)
|
||||
const foreignBookingId = id(foreignBookingCreated?.oceanBooking?.id)
|
||||
if (!foreignBookingId) return
|
||||
const foreignSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/submit`, {
|
||||
expectedVersion: Number(foreignBookingCreated?.oceanBooking?.version), reason: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-other-channel-booking-submit`.slice(0, 128) })
|
||||
const foreignSubmitted = context.apiSuccess(MODULE, '提交异渠道隔离订舱', foreignSubmit, '', OPTIONS)
|
||||
const foreignConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/confirm`, {
|
||||
expectedVersion: Number(foreignSubmitted?.oceanBooking?.version), reason: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-other-channel-booking-confirm`.slice(0, 128) })
|
||||
const foreignConfirmed = context.apiSuccess(MODULE, '确认异渠道订舱并自动生成柜次', foreignConfirm, '', OPTIONS)
|
||||
const foreignContainerId = id(foreignConfirmed?.containerAllocations?.[0]?.containerBookingId)
|
||||
if (!foreignContainerId) return
|
||||
const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, {
|
||||
bookingId: foreignContainerId, remark: `${context.marker}-other-channel`,
|
||||
})
|
||||
const foreignPlan = context.apiSuccess(MODULE, '创建异渠道智能装柜计划', foreignPlanResponse, '', OPTIONS)
|
||||
const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, {
|
||||
inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
|
||||
expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128),
|
||||
})
|
||||
context.addStep(MODULE, '异渠道入库单拒绝装入当前柜次', foreignAssignResponse,
|
||||
foreignAssignResponse.http === 200 && ['A0400', '400'].includes(codeOf(foreignAssignResponse)),
|
||||
`HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS)
|
||||
|
||||
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
|
||||
const containerDetail = context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
|
||||
(!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode
|
||||
|| containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode)
|
||||
&& containerDetail?.channelCode === channelCode
|
||||
&& ['EDITING', 'WAIT_LOADING'].includes(containerDetail?.planStatus),
|
||||
`planStatus=${containerDetail?.planStatus || 'missing'}`, OPTIONS)
|
||||
`planStatus=${containerDetail?.planStatus || 'missing'}, channel=${containerDetail?.channelCode || 'missing'}`, OPTIONS)
|
||||
|
||||
const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo)
|
||||
const planResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker })
|
||||
@@ -178,6 +209,12 @@ export async function run(context) {
|
||||
&& Number(pushReplay?.version) === Number(pushed.version),
|
||||
`planId=${id(pushReplay?.id) || 'missing'}, version=${pushReplay?.version ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const gatedTaskResponse = await context.request('GET',
|
||||
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
context.addStep(MODULE, '无真实柜号时计划可排柜但 PDA 任务不开放', gatedTaskResponse,
|
||||
failure(gatedTaskResponse) || !id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id),
|
||||
`HTTP=${gatedTaskResponse.http}, code=${codeOf(gatedTaskResponse)}, task=${id(gatedTaskResponse?.body?.id || gatedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
|
||||
|
||||
const preloadPageResponse = await context.request('GET',
|
||||
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
|
||||
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
|
||||
@@ -200,12 +237,61 @@ export async function run(context) {
|
||||
const taskId = id(task?.id)
|
||||
const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id))
|
||||
context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse,
|
||||
Boolean(taskId) && id(task?.loadingPlanId) === planId && task?.taskStatus === 'LOADING'
|
||||
Boolean(taskId) && id(task?.loadingPlanId) === planId
|
||||
&& id(task?.containerBookingId) === containerId && Boolean(task?.cabinetNo)
|
||||
&& task?.currentContainerNo === `TST-${suffix}`.slice(0, 64) && task?.taskStatus === 'LOADING'
|
||||
&& task?.workflowStatus === 'UNASSIGNED'
|
||||
&& Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1,
|
||||
`taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS)
|
||||
if (!taskId || !pickItem?.id) return
|
||||
|
||||
const stableIdentity = {
|
||||
taskId, loadingPlanId: id(task.loadingPlanId), containerBookingId: id(task.containerBookingId),
|
||||
cabinetNo: id(task.cabinetNo),
|
||||
}
|
||||
|
||||
const beforeReplacementSnapshotResponse = await context.request('GET',
|
||||
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const beforeReplacementSnapshot = context.apiSuccess(MODULE, '换柜号前读取 PDA 稳定库存快照',
|
||||
beforeReplacementSnapshotResponse, '', OPTIONS)
|
||||
const replacementContainerNo = `TST-R-${suffix}`.slice(0, 64)
|
||||
const replacementResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, {
|
||||
containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),
|
||||
version: Number(transported?.version), reason: `${context.marker}-replace-container-no`,
|
||||
})
|
||||
const replaced = context.apiSuccess(MODULE, 'PDA 任务创建后替换真实柜号', replacementResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '任务创建后真实柜号替换成功', replacementResponse,
|
||||
replaced?.containerNo === replacementContainerNo,
|
||||
`containerNo=${replaced?.containerNo || 'missing'}`, OPTIONS)
|
||||
|
||||
const clearContainerNoResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, {
|
||||
containerNo: null, voyageNo: `V-${suffix}`.slice(0, 64),
|
||||
version: Number(replaced?.version), reason: `${context.marker}-clear-container-no`,
|
||||
})
|
||||
context.addStep(MODULE, 'PDA 任务创建后清空真实柜号失败', clearContainerNoResponse,
|
||||
clearContainerNoResponse.http === 200 && ['A0400', '400'].includes(codeOf(clearContainerNoResponse)),
|
||||
`HTTP=${clearContainerNoResponse.http}, code=${codeOf(clearContainerNoResponse)}`, OPTIONS)
|
||||
|
||||
const replacedTaskResponse = await context.request('GET',
|
||||
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const replacedTask = context.apiSuccess(MODULE, '换柜号后按稳定计划身份回查 PDA 任务', replacedTaskResponse, '', OPTIONS)
|
||||
const afterReplacementSnapshotResponse = await context.request('GET',
|
||||
`${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const afterReplacementSnapshot = context.apiSuccess(MODULE, '换柜号后读取 PDA 稳定库存快照',
|
||||
afterReplacementSnapshotResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '换柜号不改变任务计划柜次柜次号和库存快照身份', replacedTaskResponse,
|
||||
id(replacedTask?.id) === stableIdentity.taskId
|
||||
&& id(replacedTask?.loadingPlanId) === stableIdentity.loadingPlanId
|
||||
&& id(replacedTask?.containerBookingId) === stableIdentity.containerBookingId
|
||||
&& id(replacedTask?.cabinetNo) === stableIdentity.cabinetNo
|
||||
&& replacedTask?.currentContainerNo === replacementContainerNo
|
||||
&& id(afterReplacementSnapshot?.taskId) === stableIdentity.taskId
|
||||
&& id(afterReplacementSnapshot?.loadingPlanId) === stableIdentity.loadingPlanId
|
||||
&& id(afterReplacementSnapshot?.containerBookingId) === stableIdentity.containerBookingId
|
||||
&& id(afterReplacementSnapshot?.cabinetNo) === stableIdentity.cabinetNo
|
||||
&& afterReplacementSnapshot?.snapshotToken === beforeReplacementSnapshot?.snapshotToken,
|
||||
`taskId=${id(replacedTask?.id) || 'missing'}, cabinetNo=${replacedTask?.cabinetNo || 'missing'}, snapshotStable=${afterReplacementSnapshot?.snapshotToken === beforeReplacementSnapshot?.snapshotToken}`, OPTIONS)
|
||||
|
||||
const wrongWarehouseResponse = await context.request('GET',
|
||||
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=1`)
|
||||
context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse,
|
||||
@@ -242,7 +328,7 @@ export async function run(context) {
|
||||
version: Number(pickItem.version), reason: null }]
|
||||
const staleBatchResponse = await request(context, 'POST', batchPath, {
|
||||
warehouseId, loadingPlanId: planId, containerBookingId: containerId,
|
||||
containerNo: task.currentContainerNo || null, expectedVersion: Number(task.version) + 1,
|
||||
expectedVersion: Number(task.version) + 1,
|
||||
inventorySnapshotToken: inventory.snapshotToken, items: batchItems,
|
||||
}, { 'Idempotency-Key': `${context.marker}-pda-batch-stale`.slice(0, 128) })
|
||||
context.addStep(MODULE, 'PDA batch 旧任务版本返回 A0409', staleBatchResponse,
|
||||
@@ -261,7 +347,7 @@ export async function run(context) {
|
||||
`taskVersion=${afterFailedBatch?.version ?? 'missing'}, itemVersion=${afterFailedItem?.version ?? 'missing'}, picked=${afterFailedItem?.pickedPackageCount ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const batchPayload = { warehouseId, loadingPlanId: planId, containerBookingId: containerId,
|
||||
containerNo: task.currentContainerNo || null, expectedVersion: Number(task.version),
|
||||
expectedVersion: Number(task.version),
|
||||
inventorySnapshotToken: inventory.snapshotToken, items: batchItems }
|
||||
const batchHeaders = { 'Idempotency-Key': `${context.marker}-pda-batch`.slice(0, 128) }
|
||||
const pickResponse = await request(context, 'POST', batchPath, batchPayload, batchHeaders)
|
||||
@@ -351,7 +437,8 @@ export async function run(context) {
|
||||
|
||||
context.createdResources.phase3ContainerLoading = {
|
||||
marker: context.marker, tenantId, inboundId, warehouseId, containerBookingId: containerId,
|
||||
loadingPlanId: planId, pdaTaskId: taskId,
|
||||
loadingPlanId: planId, pdaTaskId: taskId, cabinetNo: stableIdentity.cabinetNo,
|
||||
containerNo: replacementContainerNo, pdaIdentityStable: true,
|
||||
finalStatus: 'COMPLETED',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,28 @@ test('container loading creates and confirms booking before scoped candidates',
|
||||
assert.doesNotMatch(value, /POST', CONTAINERS, containerPayload/)
|
||||
})
|
||||
|
||||
test('container loading enforces channel isolation and stable PDA identity across container number changes', () => {
|
||||
const value = source('./phase3-container-loading.mjs')
|
||||
assertOrder(value, [
|
||||
'确认订舱并自动生成真实柜次',
|
||||
'创建异渠道隔离订舱',
|
||||
'异渠道入库单拒绝装入当前柜次',
|
||||
'无真实柜号时计划可排柜但 PDA 任务不开放',
|
||||
'绑定本轮真实箱号并补建 PDA 任务',
|
||||
'换柜号前读取 PDA 稳定库存快照',
|
||||
'PDA 任务创建后替换真实柜号',
|
||||
'PDA 任务创建后清空真实柜号失败',
|
||||
'换柜号后按稳定计划身份回查 PDA 任务',
|
||||
'换柜号不改变任务计划柜次柜次号和库存快照身份',
|
||||
])
|
||||
assert.match(value, /containerDetail\?\.channelCode === channelCode/)
|
||||
assert.match(value, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/)
|
||||
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
|
||||
assert.match(value, /pdaIdentityStable: true/)
|
||||
const batch = value.slice(value.indexOf('const staleBatchResponse'), value.indexOf('const completePath'))
|
||||
assert.doesNotMatch(batch, /containerNo:/)
|
||||
})
|
||||
|
||||
test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => {
|
||||
const container = source('./phase3-container-loading.mjs')
|
||||
const convergence = source('./phase3-pda-convergence.mjs')
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
const source=readFileSync(new URL('./suppliers.mjs',import.meta.url),'utf8')
|
||||
const orchestrator=readFileSync(new URL('../admin-test-env-acceptance.mjs',import.meta.url),'utf8')
|
||||
|
||||
test('supplier acceptance covers full CRUD-like lifecycle and boundary failures',()=>{
|
||||
for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','非订舱分类携带scope被拒绝','tenantId非0被拒绝','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker))
|
||||
})
|
||||
test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/DEFAULT_MODULES = \[[^\]]*'suppliers'/s))
|
||||
test('supplier fixture is retained only as inactive because API has no delete contract',()=>{assert.match(source,/finalStatus:inactive\?'INACTIVE'/);assert.doesNotMatch(source,/context\.request\('DELETE'/)})
|
||||
@@ -0,0 +1,66 @@
|
||||
const MODULE = 'suppliers'
|
||||
const OPTIONS = { scenario: 'platform-public-suppliers', deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/suppliers'
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
|
||||
function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||
function code(response, expected) { return response?.http === 200 && String(response?.body?.code) === expected }
|
||||
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function traced(response) { return Boolean(response?.requestId && response?.traceId) }
|
||||
function query(values = {}) { const p = new URLSearchParams(); for (const [k,v] of Object.entries(values)) if (v !== '' && v != null) p.set(k,String(v)); return `${BASE}${p.size ? `?${p}` : ''}` }
|
||||
function payload(marker, overrides = {}) { return { name:`供应商验收-${marker}`,category:'BOOKING',scope:'DOMESTIC',region:'宁波',legalRepresentative:'验收法人',contactName:'验收联系人',phone:'13800000000',email:'supplier-acceptance@example.com',address:'验收地址',businessLicenseNo:`ACPT${marker}`.slice(0,64),licenseFileId:null,status:'ACTIVE',settlementPeriod:'MONTHLY_30',remark:marker,...overrides } }
|
||||
|
||||
export async function run(context) {
|
||||
const marker=context.marker.replace(/[^0-9A-Za-z]/g,'').slice(-20)
|
||||
const listResponse=await context.request('GET',query({tenantId:0,category:'BOOKING',page:1,size:20}))
|
||||
const list=context.apiSuccess(MODULE,'列表分页与订舱筛选',listResponse,'category=BOOKING',OPTIONS)
|
||||
context.assert(MODULE,'列表分页结构完整',listResponse,Array.isArray(list?.records)&&Number(list?.page)===1&&Number(list?.size)===20&&Number(list?.total)>=0&&Number(list?.pages)>=0,`records=${records(list).length}, total=${list?.total??'missing'}`,OPTIONS)
|
||||
|
||||
const statsResponse=await context.request('GET',`${BASE}/stats?tenantId=0`)
|
||||
const stats=context.apiSuccess(MODULE,'查询四类供应商统计',statsResponse,'',OPTIONS)
|
||||
context.assert(MODULE,'统计字段完整',statsResponse,['booking','customs','trucking','certificate'].every(k=>Number(stats?.[k])>=0),JSON.stringify(stats),OPTIONS)
|
||||
|
||||
const createBody=payload(marker)
|
||||
const createResponse=await context.request('POST',`${BASE}?tenantId=0`,createBody)
|
||||
const supplierId=id(context.apiSuccess(MODULE,'新增供应商',createResponse,`name=${createBody.name}`,OPTIONS))
|
||||
context.assert(MODULE,'新增返回供应商ID',createResponse,Boolean(supplierId),`supplierId=${supplierId||'missing'}`,OPTIONS)
|
||||
if(!supplierId)return
|
||||
let inactive=false
|
||||
try {
|
||||
const detailResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
|
||||
const detail=context.apiSuccess(MODULE,'查询供应商详情',detailResponse,'',OPTIONS)
|
||||
context.assert(MODULE,'详情字段与平台作用域正确',detailResponse,id(detail?.id)===supplierId&&id(detail?.tenantId)==='0'&&detail?.name===createBody.name&&detail?.scope==='DOMESTIC',`id=${id(detail?.id)}, tenantId=${id(detail?.tenantId)}`,OPTIONS)
|
||||
|
||||
const duplicateResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}D`,{name:createBody.name}))
|
||||
context.assert(MODULE,'重复名称返回冲突并保留链路ID',duplicateResponse,code(duplicateResponse,'A0409')&&traced(duplicateResponse),`code=${duplicateResponse.body?.code}, requestId=${duplicateResponse.requestId||'missing'}, traceId=${duplicateResponse.traceId||'missing'}`,OPTIONS)
|
||||
|
||||
const scopeResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}S`,{name:`非法范围-${marker}`,category:'CUSTOMS',scope:'DOMESTIC'}))
|
||||
context.assert(MODULE,'非订舱分类携带scope被拒绝',scopeResponse,code(scopeResponse,'A0422')&&traced(scopeResponse),`code=${scopeResponse.body?.code}, requestId=${scopeResponse.requestId||'missing'}, traceId=${scopeResponse.traceId||'missing'}`,OPTIONS)
|
||||
|
||||
const tenantResponse=await context.request('GET',query({tenantId:1,page:1,size:20}))
|
||||
context.assert(MODULE,'tenantId非0被拒绝',tenantResponse,code(tenantResponse,'A0400')&&traced(tenantResponse),`code=${tenantResponse.body?.code}, requestId=${tenantResponse.requestId||'missing'}, traceId=${tenantResponse.traceId||'missing'}`,OPTIONS)
|
||||
|
||||
const merchantLogin=await context.request('POST','/api/auth/login',{username:MERCHANT_USERNAME,password:MERCHANT_PASSWORD},'')
|
||||
const merchantToken=ok(merchantLogin)?merchantLogin.body?.data?.accessToken:''
|
||||
const forbidden=merchantToken?await context.request('GET',query({tenantId:0,page:1,size:20}),undefined,merchantToken):merchantLogin
|
||||
context.assert(MODULE,'客户账号调用平台供应商接口被拒绝',forbidden,Boolean(merchantToken)&&(forbidden.http===403||code(forbidden,'A0403'))&&traced(forbidden),`HTTP=${forbidden.http}, code=${forbidden.body?.code??'missing'}, requestId=${forbidden.requestId||'missing'}, traceId=${forbidden.traceId||'missing'}`,OPTIONS)
|
||||
|
||||
const updateBody=payload(marker,{name:`供应商验收已编辑-${marker}`,scope:'INTERNATIONAL',contactName:'编辑联系人',settlementPeriod:'MONTHLY_45'})
|
||||
const updateResponse=await context.request('PUT',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`,updateBody)
|
||||
context.apiSuccess(MODULE,'编辑供应商',updateResponse,'',OPTIONS)
|
||||
const editedResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
|
||||
const edited=context.apiSuccess(MODULE,'回查编辑结果',editedResponse,'',OPTIONS)
|
||||
context.assert(MODULE,'编辑字段已持久化',editedResponse,edited?.name===updateBody.name&&edited?.scope==='INTERNATIONAL'&&edited?.contactName==='编辑联系人'&&edited?.settlementPeriod==='MONTHLY_45',`name=${edited?.name||'missing'}`,OPTIONS)
|
||||
|
||||
const disableResponse=await context.request('PATCH',`${BASE}/${encodeURIComponent(supplierId)}/status?tenantId=0`,{status:'INACTIVE'})
|
||||
context.apiSuccess(MODULE,'停用供应商',disableResponse,'',OPTIONS); inactive=ok(disableResponse)
|
||||
const inactiveResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
|
||||
const inactiveDetail=context.apiSuccess(MODULE,'回查停用状态',inactiveResponse,'',OPTIONS)
|
||||
context.assert(MODULE,'停用状态已持久化',inactiveResponse,inactiveDetail?.status==='INACTIVE',`status=${inactiveDetail?.status||'missing'}`,OPTIONS)
|
||||
} finally {
|
||||
if(!inactive){const cleanup=await context.request('PATCH',`${BASE}/${encodeURIComponent(supplierId)}/status?tenantId=0`,{status:'INACTIVE'}); inactive=ok(cleanup); context.assert(MODULE,'异常路径停用验收供应商',cleanup,inactive,`supplierId=${supplierId}`,OPTIONS)}
|
||||
context.createdResources.suppliers={marker:context.marker,supplierId,finalStatus:inactive?'INACTIVE':'UNKNOWN',retained:true}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,31 @@ export async function run(context) {
|
||||
if (!permissionId) throw new Error('权限资源创建失败,系统模块无法继续')
|
||||
context.createdResources.systemPermission = { id: permissionId, code: permissionPayload.code }
|
||||
|
||||
const additionalPermissionIds = []
|
||||
for (const definition of [
|
||||
{ type: 'MENU', routePath: `/acceptance-${suffix}`, httpMethod: null },
|
||||
{ type: 'PAGE', routePath: `/acceptance-${suffix}/detail`, httpMethod: null },
|
||||
{ type: 'API', routePath: `/api/platform/v1/acceptance/${suffix}`, httpMethod: 'GET' },
|
||||
]) {
|
||||
const payload = {
|
||||
type: definition.type,
|
||||
code: `platform:acceptance:${definition.type.toLowerCase()}:${suffix}`,
|
||||
name: `Admin验收${definition.type}-${suffix}`,
|
||||
parentId: null,
|
||||
routePath: definition.routePath,
|
||||
httpMethod: definition.httpMethod,
|
||||
sortNo: 9999,
|
||||
}
|
||||
const response = await context.request('POST', '/api/platform/v1/iam/permissions', payload)
|
||||
const created = context.apiSuccess(module, `创建 ${definition.type} 权限资源`, response)
|
||||
const createdId = id(created?.id)
|
||||
context.assert(module, `${definition.type} 类型与端点字段正确回显`, response,
|
||||
createdId && created?.permissionType === definition.type && created?.routePath === definition.routePath
|
||||
&& (created?.httpMethod ?? null) === definition.httpMethod,
|
||||
`permissionId=${createdId || 'missing'}, type=${created?.permissionType || 'missing'}, method=${created?.httpMethod ?? 'null'}`)
|
||||
if (createdId) additionalPermissionIds.push(createdId)
|
||||
}
|
||||
|
||||
const updatePermissionResponse = await context.request('PUT', `/api/platform/v1/iam/permissions/${permissionId}`, {
|
||||
name: `${permissionPayload.name}-已编辑`, parentId: null, routePath: null, httpMethod: null, sortNo: 9998,
|
||||
})
|
||||
@@ -140,6 +165,10 @@ export async function run(context) {
|
||||
`status=${finalRole?.status ?? 'missing'}`)
|
||||
const deletePermissionResponse = await context.request('DELETE', `/api/platform/v1/iam/permissions/${permissionId}`)
|
||||
context.apiSuccess(module, '删除临时权限资源', deletePermissionResponse)
|
||||
for (const additionalPermissionId of additionalPermissionIds) {
|
||||
const response = await context.request('DELETE', `/api/platform/v1/iam/permissions/${additionalPermissionId}`)
|
||||
context.apiSuccess(module, '删除四类型验收权限资源', response, `permissionId=${additionalPermissionId}`)
|
||||
}
|
||||
}
|
||||
|
||||
function requiredUserFields() {
|
||||
|
||||
@@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop'
|
||||
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$Workspace = (Resolve-Path (Join-Path $Repo '..')).Path
|
||||
$Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance'
|
||||
$ExpectedSha256 = '585464BE3FA0FB4141F47D0FAC57FA1FCAFCDD4596253256E8759BF930D9C7D3'
|
||||
$ExpectedLines = 14433
|
||||
$ExpectedSha256 = 'EB965FCBCE23B7A6E565530C13188B1C55FAB43B8FD4E3408DED91D57454A7DF'
|
||||
$ExpectedLines = 14456
|
||||
|
||||
if ($TargetHost -notin @('127.0.0.1', 'localhost')) {
|
||||
[Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.')
|
||||
|
||||
Reference in New Issue
Block a user