优化
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@
|
||||
| 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 |
|
||||
|---|---|---|---|
|
||||
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 |
|
||||
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库和权限数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务和客户侧权限链路 |
|
||||
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 |
|
||||
| Client 二期兼容 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限兼容场景;不是替代 Client 全量入口 |
|
||||
|
||||
执行前必须确认目标环境、账号和数据清理/停用策略,并获得当前对话的测试环境验收授权。`admin-test-env-modules/*contract.test.mjs` 只验证模块脚本结构,不能代替 HTTP 验收。
|
||||
|
||||
@@ -16,6 +16,20 @@ const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'over
|
||||
|
||||
export function normalizeModuleOrder(modules) {
|
||||
const normalized = [...modules]
|
||||
if (normalized.includes('fee-items')) {
|
||||
const prerequisites = ['merchant-qualification-fixture', 'orders']
|
||||
const feeItemsIndex = normalized.indexOf('fee-items')
|
||||
const ordered = prerequisites.every((name, index) => {
|
||||
const prerequisiteIndex = normalized.indexOf(name)
|
||||
const previousIndex = index === 0 ? -1 : normalized.indexOf(prerequisites[index - 1])
|
||||
return prerequisiteIndex > previousIndex && prerequisiteIndex < feeItemsIndex
|
||||
})
|
||||
if (!ordered) {
|
||||
const requested = normalized.filter((name) => !prerequisites.includes(name))
|
||||
requested.splice(requested.indexOf('fee-items'), 0, ...prerequisites)
|
||||
normalized.splice(0, normalized.length, ...requested)
|
||||
}
|
||||
}
|
||||
const loadingIndex = normalized.indexOf('phase3-loading')
|
||||
const overviewIndex = normalized.indexOf('phase3-container-overview')
|
||||
if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
- `system.mjs`
|
||||
- `inbound-weight-ocr.mjs`
|
||||
|
||||
`fee-items` 需要同一 marker 的客户订单和入库单。执行 `--modules fee-items` 时,入口自动扩展为 `merchant-qualification-fixture,orders,fee-items`,无需手工补齐前置模块。
|
||||
|
||||
模块只需导出:
|
||||
|
||||
```js
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
import { cleanupSucceeded, isAcceptanceFeeItem } from './fee-items.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./fee-items.mjs', import.meta.url), 'utf8')
|
||||
@@ -29,5 +30,18 @@ test('stale cleanup only recognizes the acceptance-owned code and name pair', ()
|
||||
|
||||
test('baseline checks run only after stale acceptance cleanup', () => {
|
||||
assert.ok(source.indexOf('cleanupStaleAcceptanceItems(context)')
|
||||
< source.indexOf("'分页总数110且默认20条对应6页'"))
|
||||
< source.indexOf("'正式目录111条且默认20条对应6页'"))
|
||||
})
|
||||
|
||||
test('formal baseline includes the system FBA freight item in addition to the 110 controlled items', () => {
|
||||
assert.match(source, /const BASELINE_COUNT = 111/)
|
||||
assert.match(source, /const INTERNATIONAL_COUNT = 27/)
|
||||
assert.match(source, /'FBA_HEAD_FREIGHT'/)
|
||||
})
|
||||
|
||||
test('fee-items injects its same-marker merchant and order fixtures for focused execution', () => {
|
||||
const expected = ['merchant-qualification-fixture', 'orders', 'fee-items']
|
||||
assert.deepEqual(normalizeModuleOrder(['fee-items']), expected)
|
||||
assert.deepEqual(normalizeModuleOrder(['fee-items', 'orders']), expected)
|
||||
assert.deepEqual(parseArguments(['--modules', 'fee-items']).modules, expected)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,9 @@ const MODULE = 'fee-items'
|
||||
const SCENARIO = 'platform-public-fee-items'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/fee-items'
|
||||
const BASELINE_COUNT = 111
|
||||
const DOMESTIC_COUNT = 84
|
||||
const INTERNATIONAL_COUNT = 27
|
||||
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||
|
||||
@@ -42,7 +45,10 @@ function payload(suffix, overrides = {}) {
|
||||
}
|
||||
}
|
||||
function expectedCodes() {
|
||||
return Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`)
|
||||
return [
|
||||
'FBA_HEAD_FREIGHT',
|
||||
...Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`),
|
||||
].sort()
|
||||
}
|
||||
export function isAcceptanceFeeItem(row) {
|
||||
return /^ACPT_\d+$/.test(String(row?.feeCode || ''))
|
||||
@@ -84,8 +90,8 @@ export async function run(context) {
|
||||
const initialPageResponse = await context.request('GET', query({ page: 1, size: 20 }))
|
||||
const initialPage = context.apiSuccess(MODULE, '费用项目查看权限与分页查询', initialPageResponse,
|
||||
'page=1,size=20', OPTIONS)
|
||||
context.assert(MODULE, '分页总数110且默认20条对应6页', initialPageResponse,
|
||||
records(initialPage).length === 20 && Number(initialPage?.total) === 110
|
||||
context.assert(MODULE, '正式目录111条且默认20条对应6页', initialPageResponse,
|
||||
records(initialPage).length === 20 && Number(initialPage?.total) === BASELINE_COUNT
|
||||
&& Number(initialPage?.pages) === 6 && Number(initialPage?.page) === 1,
|
||||
`records=${records(initialPage).length}, total=${initialPage?.total ?? 'n/a'}, pages=${initialPage?.pages ?? 'n/a'}`, OPTIONS)
|
||||
context.assert(MODULE, '费用项目查询不传 accountId 仍成功', initialPageResponse,
|
||||
@@ -97,13 +103,13 @@ export async function run(context) {
|
||||
const domestic = initialRows.filter((row) => row.tradeScope === 'DOMESTIC')
|
||||
const international = initialRows.filter((row) => row.tradeScope === 'INTERNATIONAL')
|
||||
const actualCodes = initialRows.map((row) => row.feeCode).sort()
|
||||
context.assert(MODULE, '初始化110条全部属于平台且状态ACTIVE', initial.response,
|
||||
initialRows.length === 110 && initialRows.every((row) => id(row.tenantId) === '0' && row.status === 'ACTIVE'),
|
||||
context.assert(MODULE, '正式目录111条全部属于平台且状态ACTIVE', initial.response,
|
||||
initialRows.length === BASELINE_COUNT && initialRows.every((row) => id(row.tenantId) === '0' && row.status === 'ACTIVE'),
|
||||
`rows=${initialRows.length}, platform=${initialRows.filter((row) => id(row.tenantId) === '0').length}, active=${initialRows.filter((row) => row.status === 'ACTIVE').length}`, OPTIONS)
|
||||
context.assert(MODULE, '国内84条、国际26条范围正确', initial.response,
|
||||
domestic.length === 84 && international.length === 26,
|
||||
context.assert(MODULE, '国内84条、国际27条范围正确', initial.response,
|
||||
domestic.length === DOMESTIC_COUNT && international.length === INTERNATIONAL_COUNT,
|
||||
`DOMESTIC=${domestic.length}, INTERNATIONAL=${international.length}`, OPTIONS)
|
||||
context.assert(MODULE, '初始化编码完整覆盖 FEE_TYPE_001..FEE_TYPE_110', initial.response,
|
||||
context.assert(MODULE, '正式编码完整覆盖110条受控项目及系统头程运费', initial.response,
|
||||
actualCodes.join(',') === expectedCodes().join(','),
|
||||
`codes=${actualCodes.length}, first=${actualCodes[0] || 'missing'}, last=${actualCodes.at(-1) || 'missing'}`, OPTIONS)
|
||||
|
||||
@@ -115,7 +121,7 @@ export async function run(context) {
|
||||
Boolean(category) && records(categoryPage).length > 0 && records(categoryPage).every((row) => row.category === category),
|
||||
`records=${records(categoryPage).length}`, OPTIONS)
|
||||
|
||||
for (const [scope, expected] of [['DOMESTIC', 84], ['INTERNATIONAL', 26]]) {
|
||||
for (const [scope, expected] of [['DOMESTIC', DOMESTIC_COUNT], ['INTERNATIONAL', INTERNATIONAL_COUNT]]) {
|
||||
const response = await context.request('GET', query({ tradeScope: scope, page: 1, size: 200 }))
|
||||
const page = context.apiSuccess(MODULE, `按 ${scope} 贸易范围筛选`, response, '', OPTIONS)
|
||||
context.assert(MODULE, `${scope} 筛选数量和范围正确`, response,
|
||||
@@ -135,8 +141,8 @@ export async function run(context) {
|
||||
|
||||
const activeResponse = await context.request('GET', query({ status: 'ACTIVE', page: 1, size: 200 }))
|
||||
const activePage = context.apiSuccess(MODULE, '按 ACTIVE 状态筛选费用项目', activeResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'ACTIVE 筛选返回110条且无停用记录', activeResponse,
|
||||
Number(activePage?.total) === 110 && records(activePage).every((row) => row.status === 'ACTIVE'),
|
||||
context.assert(MODULE, 'ACTIVE 筛选返回111条且无停用记录', activeResponse,
|
||||
Number(activePage?.total) === BASELINE_COUNT && records(activePage).every((row) => row.status === 'ACTIVE'),
|
||||
`total=${activePage?.total ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const merchantLoginResponse = await context.request('POST', '/api/auth/login', {
|
||||
@@ -266,8 +272,8 @@ export async function run(context) {
|
||||
`feeItemId=${feeItemId}`, OPTIONS)
|
||||
}
|
||||
const final = await listAll(context)
|
||||
context.assert(MODULE, '清理后恢复110条基线且无临时编码或名称', final.response,
|
||||
final.rows.length === 110 && !final.rows.some((row) => row.feeCode === createPayload.feeCode
|
||||
context.assert(MODULE, '清理后恢复111条正式基线且无临时编码或名称', final.response,
|
||||
final.rows.length === BASELINE_COUNT && !final.rows.some((row) => row.feeCode === createPayload.feeCode
|
||||
|| String(row.feeTypeName).includes(suffix)),
|
||||
`rows=${final.rows.length}, temporary=${final.rows.filter((row) => row.feeCode === createPayload.feeCode).length}`, OPTIONS)
|
||||
context.createdResources.feeItems = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { run } from './finance-full-fixtures.mjs'
|
||||
import { buildContainerCostBody, run } from './finance-full-fixtures.mjs'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8')
|
||||
@@ -204,6 +204,13 @@ test('fixture creates four real receivable manual sources before payable source'
|
||||
})
|
||||
|
||||
test('container cost uses loading plan identity and verifies snapshot fields before Finance', () => {
|
||||
const body = buildContainerCostBody('RUN-1', '2088073097986560001',
|
||||
{ loadingPlanId: '2088073275689709570' }, '2088073280000000001')
|
||||
assert.equal(body.customerId, '2088073097986560001')
|
||||
assert.equal(body.containerId, '2088073275689709570')
|
||||
assert.equal(JSON.parse(JSON.stringify(body)).customerId, '2088073097986560001')
|
||||
assert.match(source, /customerId: String\(customerId\)/)
|
||||
assert.doesNotMatch(source, /customerId: Number\(customerId\)/)
|
||||
assert.match(source, /containerId: String\(container\.loadingPlanId\)/)
|
||||
assert.doesNotMatch(source, /containerId: Number\(container\.loadingPlanId\)/)
|
||||
assert.match(source, /smart-loading\/plans\/\$\{encodeURIComponent\(container\.loadingPlanId\)\}/)
|
||||
|
||||
@@ -262,13 +262,17 @@ async function createMarkerContainer(context, resource) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createContainerCost(context, customerId, container, voucherFileId, snapshotInput) {
|
||||
if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId, snapshotInput)
|
||||
const body = { customerId: Number(customerId), costType: 'CONTAINER_SHARED', orderId: null,
|
||||
export function buildContainerCostBody(marker, customerId, container, voucherFileId) {
|
||||
return { customerId: String(customerId), costType: 'CONTAINER_SHARED', orderId: null,
|
||||
// Finance names this field containerId, but its Firstleg cost snapshot contract
|
||||
// resolves it as the loading plan ID, not the physical container booking ID.
|
||||
containerId: String(container.loadingPlanId), expenseName: `${context.marker}-整柜成本`, amount: 300,
|
||||
currency: 'CNY', voucherFileIds: [voucherFileId], remark: context.marker }
|
||||
containerId: String(container.loadingPlanId), expenseName: `${marker}-整柜成本`, amount: 300,
|
||||
currency: 'CNY', voucherFileIds: [voucherFileId], remark: marker }
|
||||
}
|
||||
|
||||
async function createContainerCost(context, customerId, container, voucherFileId, snapshotInput) {
|
||||
if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId, snapshotInput)
|
||||
const body = buildContainerCostBody(context.marker, customerId, container, voucherFileId)
|
||||
const response = await request(context, 'POST', '/api/platform/finance/v1/cost-entries', body)
|
||||
if (response?.body?.code === 'FIN_400') {
|
||||
const first = snapshotInput?.items?.[0]
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { run, values } from './finance-settlement-full.mjs'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { receiptBody, run, values } from './finance-settlement-full.mjs'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'finance-settlement-full.mjs'), 'utf8')
|
||||
const response = (data, http = 200, code = '200') => ({ http, body: { code, data } })
|
||||
|
||||
function fakeContext(overrides = {}) {
|
||||
@@ -63,6 +68,16 @@ test('values helper supports nested arrays while run consumes frozen top-level a
|
||||
assert.deepEqual(values({ marker: 'x', selected: { id: 3 } }), [{ id: 3 }])
|
||||
})
|
||||
|
||||
test('receipt body preserves 19-digit customer and voucher IDs through JSON', () => {
|
||||
const body = receiptBody({ marker: 'RUN-1' }, '2088073097986560001',
|
||||
'2088073285653299201', [{ billId: 13, amount: 1 }])
|
||||
const roundTrip = JSON.parse(JSON.stringify(body))
|
||||
assert.equal(roundTrip.customerId, '2088073097986560001')
|
||||
assert.deepEqual(roundTrip.voucherFileIds, ['2088073285653299201'])
|
||||
assert.match(source, /customerId: String\(customerId\)/)
|
||||
assert.doesNotMatch(source, /customerId: Number\(customerId\)/)
|
||||
})
|
||||
|
||||
test('run 使用 receipts 完成应收部分、全额及多单分配,使用 transactions 完成 AP', async () => {
|
||||
const context = fakeContext()
|
||||
await run(context)
|
||||
|
||||
@@ -25,8 +25,8 @@ function gap(context, name, scope, reason, category = 'finance-settlement-fixtur
|
||||
context.addCoverageGap?.(MODULE, OPTIONS.scenario, scope, reason, category, { marker: context.marker })
|
||||
}
|
||||
|
||||
function receiptBody(context, customerId, voucherFileId, allocations) {
|
||||
return { customerId: Number(customerId), receivedDate: new Date().toISOString().slice(0, 10),
|
||||
export function receiptBody(context, customerId, voucherFileId, allocations) {
|
||||
return { customerId: String(customerId), receivedDate: new Date().toISOString().slice(0, 10),
|
||||
amount: allocations.reduce((sum, item) => sum + item.amount, 0), currency: 'CNY',
|
||||
paymentMethod: 'BANK_TRANSFER', voucherFileIds: [voucherFileId], remark: context.marker, allocations }
|
||||
}
|
||||
|
||||
@@ -5,11 +5,17 @@ import test from 'node:test'
|
||||
const source = readFileSync(new URL('./manual-state-ocean-trucking.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('retains every ocean booking and truck appointment list status through real APIs', () => {
|
||||
assert.match(source, /const OCEAN_BASE = ['"]\/api\/platform\/v1\/ocean-bookings['"]/)
|
||||
assert.doesNotMatch(source, /\/api\/platform\/v1\/first-leg\/ocean-bookings/)
|
||||
for (const status of ['DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED',
|
||||
'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED',
|
||||
'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`))
|
||||
assert.match(source, /change-request/)
|
||||
assert.match(source, /\/reschedule\?plannedLoadingAt=/)
|
||||
assert.match(source, /page=1&size=100&status=/)
|
||||
assert.match(source, /customerId: oceanBooking\.customerId/)
|
||||
assert.match(source, /warehouseId: oceanBooking\.warehouseId/)
|
||||
assert.doesNotMatch(source, /customerId: ['"]10001['"]/)
|
||||
assert.doesNotMatch(source, /warehouseId: ['"]193000000000200001['"]/)
|
||||
assert.doesNotMatch(source, /DELETE/)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const MODULE = 'manual-state-ocean-trucking'
|
||||
const OPTIONS = { scenario: 'manual-ocean-trucking-state-matrix', deploymentBlocker: true }
|
||||
const OCEAN_BASE = '/api/platform/v1/first-leg/ocean-bookings'
|
||||
const OCEAN_BASE = '/api/platform/v1/ocean-bookings'
|
||||
const TRUCK_BASE = '/api/platform/v1/truck-appointments'
|
||||
const OCEAN_STATUSES = ['DRAFT', 'SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CHANGE_PENDING', 'CANCELLED']
|
||||
const TRUCK_STATUSES = ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', 'ARRIVED_WAREHOUSE',
|
||||
@@ -71,10 +71,12 @@ async function buildOcean(context, template, status, index) {
|
||||
throw new Error(`unsupported ocean status ${status}`)
|
||||
}
|
||||
|
||||
function truckPayload(context, oceanBookingId, index) {
|
||||
function truckPayload(context, oceanBooking, index) {
|
||||
const value = suffix(context, index)
|
||||
return { outboundOrderId: null, oceanBookingId, containerBookingId: null, loadingPlanId: null,
|
||||
warehouseId: '193000000000200001', customerId: '10001', customerName: 'IT 测试客户',
|
||||
return { outboundOrderId: null, oceanBookingId: oceanBooking.id,
|
||||
containerBookingId: null, loadingPlanId: null,
|
||||
warehouseId: oceanBooking.warehouseId, customerId: oceanBooking.customerId,
|
||||
customerName: oceanBooking.customerName,
|
||||
carrierName: `人工验收车队-${value}`, fleetContact: '人工验收联系人', fleetPhone: '13800000004',
|
||||
driverName: `人工司机-${value}`, driverPhone: '13800000005', vehicleNo: `粤B${value.slice(-6)}`,
|
||||
storeName: '人工验收仓', storeContact: '仓库联系人', storePhone: '13800000006',
|
||||
@@ -84,8 +86,8 @@ function truckPayload(context, oceanBookingId, index) {
|
||||
receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 }
|
||||
}
|
||||
|
||||
async function createTruck(context, oceanBookingId, index) {
|
||||
const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBookingId, index))
|
||||
async function createTruck(context, oceanBooking, index) {
|
||||
const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBooking, index))
|
||||
const value = context.apiSuccess(MODULE, `创建拖车状态样本 ${index}`, response, '', OPTIONS)
|
||||
if (!value?.id) throw new Error(`创建拖车样本 ${index} 失败`)
|
||||
return value
|
||||
@@ -99,8 +101,8 @@ async function truckAction(context, value, endpoint) {
|
||||
return next
|
||||
}
|
||||
|
||||
async function buildTruck(context, oceanBookingId, status, index) {
|
||||
let value = await createTruck(context, oceanBookingId, index)
|
||||
async function buildTruck(context, oceanBooking, status, index) {
|
||||
let value = await createTruck(context, oceanBooking, index)
|
||||
if (status === 'DRAFT') return value
|
||||
if (status === 'RESCHEDULED') {
|
||||
const response = await post(context,
|
||||
@@ -139,16 +141,23 @@ export async function run(context, matrix = context.createdResources.manualState
|
||||
for (const [index, status] of OCEAN_STATUSES.entries()) {
|
||||
const value = await buildOcean(context, template, status, index)
|
||||
const resourceId = id(value?.oceanBooking?.id); const actual = value?.oceanBooking?.status
|
||||
matrix.pages.oceanBookings.fixtures[status] = { id: resourceId, status: actual, detailPath: `/ocean-bookings/${resourceId}` }
|
||||
matrix.pages.oceanBookings.fixtures[status] = {
|
||||
id: resourceId, status: actual, detailPath: `/ocean-bookings/${resourceId}`,
|
||||
customerId: id(value?.oceanBooking?.customerId), customerName: value?.oceanBooking?.customerName,
|
||||
warehouseId: id(value?.oceanBooking?.warehouseId),
|
||||
}
|
||||
context.assert(MODULE, `订舱保留 ${status}`, null, resourceId && actual === status,
|
||||
`id=${resourceId || 'missing'}, actual=${actual || 'missing'}`, OPTIONS)
|
||||
await verify(context, OCEAN_BASE, status, resourceId)
|
||||
}
|
||||
|
||||
const releasedOceanId = matrix.pages.oceanBookings.fixtures.RELEASED.id
|
||||
const releasedOcean = matrix.pages.oceanBookings.fixtures.RELEASED
|
||||
if (!releasedOcean.id || !releasedOcean.customerId || !releasedOcean.warehouseId) {
|
||||
throw new Error('RELEASED 订舱缺少拖车所需的客户或仓库归属')
|
||||
}
|
||||
matrix.pages.truckAppointments = { requiredStatuses: TRUCK_STATUSES, fixtures: {} }
|
||||
for (const [index, status] of TRUCK_STATUSES.entries()) {
|
||||
const value = await buildTruck(context, releasedOceanId, status, index)
|
||||
const value = await buildTruck(context, releasedOcean, status, index)
|
||||
const resourceId = id(value?.id)
|
||||
matrix.pages.truckAppointments.fixtures[status] = { id: resourceId, status: value?.status, detailPath: `/truck-appointments/${resourceId}` }
|
||||
context.assert(MODULE, `拖车保留 ${status}`, null, resourceId && value?.status === status,
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function run(context) {
|
||||
context.apiSuccess(MODULE, '冲突后回查订舱', afterStale, '', OPTIONS)
|
||||
context.assert(MODULE, '订舱冲突未部分更新', afterStale, afterData?.oceanBooking?.shippingCompanyName === edited.shippingCompanyName, `shippingCompanyName=${afterData?.oceanBooking?.shippingCompanyName || 'missing'}`, OPTIONS)
|
||||
let version = Number(afterData?.oceanBooking?.version)
|
||||
const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d }
|
||||
const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-ocean-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d }
|
||||
await action('提交订舱', 'submit', version, 'marker submit')
|
||||
await action('确认订舱', 'confirm', version, 'marker confirm')
|
||||
const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` })
|
||||
@@ -79,7 +79,7 @@ export async function run(context) {
|
||||
expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`,
|
||||
voyageNo: `RV${suffix}`, estimatedSailingAt: '2026-09-01T08:00:00',
|
||||
estimatedArrivalAt: '2026-09-20T08:00:00', reason: 'release',
|
||||
}, { 'Idempotency-Key': `${context.marker}-release` })
|
||||
}, { 'Idempotency-Key': `${context.marker}-ocean-release` })
|
||||
const releasedDetail = data(released)
|
||||
context.apiSuccess(MODULE, '放舱', released, `status=${releasedDetail?.oceanBooking?.status || 'missing'}`, OPTIONS)
|
||||
version = Number(releasedDetail?.oceanBooking?.version)
|
||||
@@ -89,12 +89,48 @@ export async function run(context) {
|
||||
fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 5 && fd.histories.every((h) => h.operatorName)
|
||||
&& fd?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === allocatedContainerBookingId),
|
||||
`status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, allocations=${fd?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
|
||||
context.createdResources.oceanBooking = {
|
||||
let downstreamBooking = {
|
||||
id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker,
|
||||
containerBookingId: allocatedContainerBookingId || null,
|
||||
loadingPlanId: null,
|
||||
finalStatus: fd?.oceanBooking?.status || null,
|
||||
}
|
||||
const flowOceanBookingId = id(containerFlow?.oceanBookingId)
|
||||
if (flowOceanBookingId && id(containerFlow?.containerBookingId) && id(containerFlow?.loadingPlanId)) {
|
||||
const flowPath = `${BASE}/${flowOceanBookingId}`
|
||||
let flowResponse = await request(context, 'GET', flowPath)
|
||||
let flow = data(flowResponse)
|
||||
context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse,
|
||||
`oceanBookingId=${flowOceanBookingId}`, OPTIONS)
|
||||
const allocationMatches = flow?.containerAllocations?.some?.((item) =>
|
||||
id(item?.containerBookingId) === id(containerFlow.containerBookingId))
|
||||
context.assert(MODULE, '实际装货柜次只绑定其所属订舱', flowResponse,
|
||||
allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId),
|
||||
`oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${flow?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
|
||||
if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') {
|
||||
const flowRelease = await request(context, 'POST', `${flowPath}/release`, {
|
||||
expectedVersion: Number(flow.oceanBooking.version), billOfLadingNo: `BL-FLOW-${suffix}`,
|
||||
vesselName: `装货船-${suffix}`, voyageNo: `FV${suffix}`,
|
||||
estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00',
|
||||
reason: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-ocean-flow-release`.slice(0, 128) })
|
||||
context.apiSuccess(MODULE, '放舱实际装货柜次所属订舱', flowRelease, '', OPTIONS)
|
||||
flowResponse = await request(context, 'GET', flowPath)
|
||||
flow = data(flowResponse)
|
||||
}
|
||||
context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse,
|
||||
flow?.oceanBooking?.status === 'RELEASED'
|
||||
&& flow?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)),
|
||||
`status=${flow?.oceanBooking?.status || 'missing'}, containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS)
|
||||
if (flow?.oceanBooking?.status === 'RELEASED') {
|
||||
downstreamBooking = {
|
||||
id: flowOceanBookingId, customerId: id(flow.oceanBooking.customerId), marker: context.marker,
|
||||
containerBookingId: id(containerFlow.containerBookingId), loadingPlanId: id(containerFlow.loadingPlanId),
|
||||
finalStatus: 'RELEASED',
|
||||
}
|
||||
}
|
||||
}
|
||||
context.createdResources.oceanBooking = downstreamBooking
|
||||
const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`)
|
||||
step(context, '不存在订舱详情安全失败', missingDetail, failure(missingDetail), `HTTP=${missingDetail.http}, code=${codeOf(missingDetail)}`)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,24 @@ test('入库通过真实订单测量链保留测量态并最终进入 WAIT_SPLIT
|
||||
assert.match(source, new RegExp(`'${status}'`))
|
||||
}
|
||||
assert.match(source, /\.\/inbound-measurement\.mjs/)
|
||||
assert.match(source, /path\.replace\('\/approve', '\/reject'\)/)
|
||||
assert.match(source, /path\.replace\('\/measurement\/submit', '\/measurements\/reject'\)/)
|
||||
})
|
||||
|
||||
test('FBA 状态样本为订单头和每条货物行提供四个必填标识', () => {
|
||||
assert.match(source, /const shipmentId = `MANUAL-/)
|
||||
assert.match(source, /const referenceId = `STATUS-/)
|
||||
assert.match(source, /const upcNo =/)
|
||||
assert.match(source, /upcNo, shipmentId, referenceId/)
|
||||
})
|
||||
|
||||
test('测量状态截停发生在正确的 HTTP 边界', () => {
|
||||
assert.match(source, /includes\(summary\.mainStatus\)/)
|
||||
assert.doesNotMatch(source, /includes\(summary\.processStatus\)/)
|
||||
assert.match(source, /target === 'MEASURING' && data\(response\)\?\.processStatus === 'MEASURING'/)
|
||||
assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/)
|
||||
assert.match(source, /target === 'MEASURE_REJECTED' && isSubmit/)
|
||||
assert.match(source, /measurements\/reject/)
|
||||
assert.match(source, /expectedVersion: version/)
|
||||
})
|
||||
|
||||
test('入库不可独立保留状态显式阻断', () => {
|
||||
@@ -42,7 +59,8 @@ test('入库不可独立保留状态显式阻断', () => {
|
||||
assert.match(source, /processStatus=WAIT_SPLIT/)
|
||||
assert.match(source, /查询存量 WAIT_SPLIT 样本/)
|
||||
assert.match(source, /没有作废入库单 API/)
|
||||
assert.match(source, /context\.assert\(MODULE, `入库列表保留 \$\{status\}`/)
|
||||
assert.match(source, /recordCoverageGap\(context, `入库列表保留 \$\{status\}`/)
|
||||
assert.match(source, /context\.addCoverageGap\?\./)
|
||||
})
|
||||
|
||||
test('统一订单声明合法可达状态并显式阻断废弃或无 API 状态', () => {
|
||||
|
||||
@@ -29,6 +29,13 @@ function key(context, operation) { return `${context.marker}-manual-${operation}
|
||||
function query(value) { return encodeURIComponent(String(value)) }
|
||||
function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) }
|
||||
|
||||
function recordCoverageGap(context, name, reason, category = 'fixture-unavailable') {
|
||||
context.addCoverageGap?.(MODULE, OPTIONS.scenario, name, reason, category, { marker: context.marker })
|
||||
context.addStep(MODULE, `${name}(coverage gap 已登记)`, null, true, reason, {
|
||||
scenario: OPTIONS.scenario, deploymentBlocker: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function merchantRequest(context, method, path, body, token, headers = {}) {
|
||||
const requestHeaders = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...headers }
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body)
|
||||
@@ -74,6 +81,10 @@ async function loadFixtureInputs(context, token) {
|
||||
|
||||
function orderPayload(context, fixture, target) {
|
||||
const sample = `${context.marker}-${target}`
|
||||
const shipmentId = `MANUAL-${target}-${context.marker}`.slice(0, 64)
|
||||
const referenceId = `STATUS-${target}-${context.marker}`.slice(0, 64)
|
||||
const upcNo = `${String(context.marker).replace(/\D/g, '').slice(-11)}${ORDER_TARGETS.findIndex(([status]) => status === target) + 1}`
|
||||
.slice(-12).padStart(12, '0')
|
||||
return {
|
||||
customerId: null, customerName: fixture.customerName, transportMode: 'SEA', tradeTerm: 'FOB',
|
||||
destinationCountryCode: fixture.destination.countryCode || 'US',
|
||||
@@ -85,7 +96,7 @@ function orderPayload(context, fixture, target) {
|
||||
carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION',
|
||||
pricingTaxMode: fixture.channel.pricingTaxModes[0], containerMode: 'LCL', customsInspection: false,
|
||||
destinationType: 'FBA', amazonWarehouseCode: fixture.destination.warehouseCode,
|
||||
shipmentId: `MANUAL-${target}-${context.marker}`.slice(0, 64), referenceId: `STATUS-${target}-${context.marker}`.slice(0, 64),
|
||||
shipmentId, referenceId,
|
||||
sender: { companyName: fixture.customerName, contactName: 'IT 测试联系人', phone: '13800138000',
|
||||
email: 'it-test@example.com', countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区',
|
||||
addressLine: `${sample} 测试地址`, postalCode: '518000' },
|
||||
@@ -94,6 +105,7 @@ function orderPayload(context, fixture, target) {
|
||||
customsHsCode: '8518210000', packageType: 'CARTON', packageCount: 1, grossWeightKg: 10,
|
||||
volumeCbm: 0.024, lengthCm: 40, widthCm: 30, heightCm: 20, declaredValue: 100, currency: 'USD',
|
||||
dangerousGoods: false, cargoRemark: sample, skuNo: `SKU-${target}-${context.marker}`.slice(0, 64),
|
||||
upcNo, shipmentId, referenceId,
|
||||
unitsPerPackage: 10, totalQuantity: 10, netWeightKg: 9, unitDeclaredValue: 10,
|
||||
material: '塑料', usage: '人工验收', model: `MODEL-${target}`, brand: 'IT-TEST' }],
|
||||
}
|
||||
@@ -132,7 +144,10 @@ async function waitForInbound(context, fixture, orderId) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const response = await context.request('GET', path)
|
||||
const summary = data(response)?.summary
|
||||
if (ok(response) && summary?.inboundId) return { response, summary }
|
||||
if (ok(response) && summary?.inboundId
|
||||
&& ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'WAIT_SPLIT'].includes(summary.mainStatus)) {
|
||||
return { response, summary }
|
||||
}
|
||||
if (attempt < 19) await wait(500)
|
||||
}
|
||||
return { response: null, summary: null }
|
||||
@@ -157,16 +172,31 @@ async function prepareInboundStatus(context, target) {
|
||||
const inboundId = id(order.inboundId)
|
||||
const tenantId = id(order.tenantId)
|
||||
if (!inboundId || !tenantId) throw new Error(`${target} 未生成独立待测量入库单`)
|
||||
const detailPath = `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}`
|
||||
|
||||
context.request = async (method, path, body, token, headers) => {
|
||||
const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`)
|
||||
const isApprove = path.includes(`/inbounds/${inboundId}/measurements/approve`)
|
||||
if (target === 'MEASURING' && isSubmit) throw stop
|
||||
if (target === 'MEASURE_PENDING' && isApprove) throw stop
|
||||
if (target === 'MEASURE_REJECTED' && isApprove) {
|
||||
const response = await originalRequest.call(context, method, path.replace('/approve', '/reject'),
|
||||
{ ...body, reason: `${fixtureMarker} 保留审核驳回样本` }, token, headers)
|
||||
if (!ok(response)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${response?.http}, code=${response?.body?.code}`)
|
||||
const isMeasurementQuery = method === 'GET' && path.includes(`/inbounds/${inboundId}/measurement?`)
|
||||
if (isMeasurementQuery) {
|
||||
const response = await originalRequest.call(context, method, path, body, token, headers)
|
||||
if (target === 'MEASURING' && data(response)?.processStatus === 'MEASURING') throw stop
|
||||
return response
|
||||
}
|
||||
if (target === 'MEASURE_PENDING' && isSubmit) {
|
||||
const response = await originalRequest.call(context, method, path, body, token, headers)
|
||||
if (!ok(response)) throw new Error(`MEASURE_PENDING 提交失败: HTTP=${response?.http}, code=${response?.body?.code}`)
|
||||
throw stop
|
||||
}
|
||||
if (target === 'MEASURE_REJECTED' && isSubmit) {
|
||||
const submitted = await originalRequest.call(context, method, path, body, token, headers)
|
||||
if (!ok(submitted)) throw new Error(`MEASURE_REJECTED 提交失败: HTTP=${submitted?.http}, code=${submitted?.body?.code}`)
|
||||
const latest = await originalRequest.call(context, 'GET', detailPath, undefined, token)
|
||||
const version = Number(data(latest)?.summary?.version)
|
||||
const rejected = await originalRequest.call(context, 'POST',
|
||||
path.replace('/measurement/submit', '/measurements/reject'),
|
||||
{ measurementId: body?.measurementId, expectedVersion: version, reason: `${fixtureMarker} 保留审核驳回样本` },
|
||||
token, { 'Idempotency-Key': `${fixtureMarker}-reject`.slice(0, 128) })
|
||||
if (!ok(rejected)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${rejected?.http}, code=${rejected?.body?.code}`)
|
||||
throw stop
|
||||
}
|
||||
return originalRequest.call(context, method, path, body, token, headers)
|
||||
@@ -276,11 +306,12 @@ export async function run(context, matrix = context.createdResources.manualState
|
||||
}
|
||||
}
|
||||
|
||||
const legacyResponse = await context.request('GET',
|
||||
`/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`)
|
||||
const legacyPage = context.apiSuccess(MODULE, '查询存量 WAIT_SPLIT 样本', legacyResponse, '', OPTIONS)
|
||||
const legacyInbound = rows(legacyPage)[0]
|
||||
if (legacyInbound?.id && legacyInbound?.orderId) {
|
||||
if (!result.inbounds.WAIT_SPLIT) {
|
||||
const legacyResponse = await context.request('GET',
|
||||
`/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`)
|
||||
const legacyPage = context.apiSuccess(MODULE, '查询存量 WAIT_SPLIT 样本', legacyResponse, '', OPTIONS)
|
||||
const legacyInbound = rows(legacyPage)[0]
|
||||
if (legacyInbound?.id && legacyInbound?.orderId) {
|
||||
const sample = { inboundId: id(legacyInbound.id), orderId: id(legacyInbound.orderId), tenantId: fixture.tenantId,
|
||||
status: 'WAIT_SPLIT', inboundCode: legacyInbound.inboundCode || null }
|
||||
result.inbounds.WAIT_SPLIT = sample
|
||||
@@ -291,6 +322,7 @@ export async function run(context, matrix = context.createdResources.manualState
|
||||
matrix.pages.unifiedOrders.fixtures.WAIT_SPLIT = {
|
||||
id: sample.orderId, status: 'WAIT_SPLIT', detailPath: `/orders/${sample.orderId}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blockers = {
|
||||
@@ -300,7 +332,7 @@ export async function run(context, matrix = context.createdResources.manualState
|
||||
VOIDED: 'InboundProcessStatus 声明了 VOIDED,但当前 Controller/Service 没有作废入库单 API',
|
||||
}
|
||||
for (const status of INBOUND_FILTER_STATUSES) if (!matrix.pages.inbounds.fixtures[status]) {
|
||||
context.assert(MODULE, `入库列表保留 ${status}`, null, false, blockers[status], OPTIONS)
|
||||
recordCoverageGap(context, `入库列表保留 ${status}`, blockers[status])
|
||||
}
|
||||
const unifiedBlockers = {
|
||||
LOADING: '需要分柜、排柜及 PDA 专项独立样本',
|
||||
@@ -308,7 +340,7 @@ export async function run(context, matrix = context.createdResources.manualState
|
||||
DELIVERED: '当前后端没有完成派送并写 DELIVERED 的公开业务 API',
|
||||
}
|
||||
for (const status of UNIFIED_FILTER_STATUSES) if (!matrix.pages.unifiedOrders.fixtures[status]) {
|
||||
context.assert(MODULE, `统一订单列表保留 ${status}`, null, false, unifiedBlockers[status], OPTIONS)
|
||||
recordCoverageGap(context, `统一订单列表保留 ${status}`, unifiedBlockers[status])
|
||||
}
|
||||
|
||||
context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true,
|
||||
|
||||
@@ -39,14 +39,15 @@ export async function run(context) {
|
||||
if (overview?.pda) {
|
||||
const expectsPhotos = fixture === context.createdResources?.phase3Loading
|
||||
&& Array.isArray(fixture?.evidenceTypes) && fixture.evidenceTypes.length > 0
|
||||
const requiredFields = ['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount']
|
||||
const missingFields = requiredFields.filter((field) => overview.pda[field] === undefined)
|
||||
context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse,
|
||||
['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount']
|
||||
.every((field) => overview.pda[field] !== undefined)
|
||||
missingFields.length === 0
|
||||
&& Array.isArray(overview.pda.photoCounts)
|
||||
&& Array.isArray(overview.pda.photos)
|
||||
&& Array.isArray(overview.pda.exceptionReasons)
|
||||
&& (!expectsPhotos || overview.pda.photos.length > 0),
|
||||
`taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos.length}`, OPTIONS)
|
||||
`taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos?.length ?? 'not-array'}, missingFields=${missingFields.join('|') || 'none'}, photoCountsArray=${Array.isArray(overview.pda.photoCounts)}, photosArray=${Array.isArray(overview.pda.photos)}, exceptionReasonsArray=${Array.isArray(overview.pda.exceptionReasons)}`, OPTIONS)
|
||||
for (const [index, photo] of overview.pda.photos.entries()) {
|
||||
context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse,
|
||||
['evidenceId', 'fileId', 'status', 'downloadPath'].every((field) => photo[field] !== undefined),
|
||||
|
||||
@@ -809,7 +809,8 @@ export async function run(context) {
|
||||
missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId,
|
||||
immutableEvidenceId: id(firstPhoto?.id), immutableEvidenceVersion: Number(firstPhoto?.version),
|
||||
postLoadedFileId,
|
||||
loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId,
|
||||
oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId,
|
||||
canonicalPdaTaskId: canonicalTaskId,
|
||||
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
|
||||
atomicBatchVerified: awaitingReview && directPdaCompletion && atomicBatchReplayVerified,
|
||||
inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./phase3-order-splitting.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('each remainder follows confirmed booking before scoped smart-loading candidates', () => {
|
||||
assert.match(source, /POST', '\/api\/platform\/v1\/ocean-bookings'/)
|
||||
assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/submit/)
|
||||
assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/confirm/)
|
||||
assert.match(source, /containerAllocations\?\.\[0\]\?\.containerBookingId/)
|
||||
assert.match(source, /candidates\?bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
|
||||
assert.match(source, /`\$\{SMART\}\/plans`, \{ bookingId: containerId/)
|
||||
assert.doesNotMatch(source, /request\(context, 'POST', CONTAINERS/)
|
||||
assert.doesNotMatch(source, /candidates\?warehouseId=/)
|
||||
})
|
||||
|
||||
test('PDA opens only after a real container number and uses stable plan identity', () => {
|
||||
const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`")
|
||||
const transportIndex = source.indexOf("`${CONTAINERS}/${encodeURIComponent(containerId)}/transport`")
|
||||
const taskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`")
|
||||
assert.ok(pushIndex >= 0 && transportIndex > pushIndex && taskIndex > transportIndex)
|
||||
assert.match(source, /containerNo: realContainerNo/)
|
||||
assert.match(source, /id\(task\?\.loadingPlanId\) === planId/)
|
||||
assert.match(source, /id\(task\?\.containerBookingId\) === containerId/)
|
||||
assert.doesNotMatch(source, /\$\{PDA\}\/tasks\?containerNo=/)
|
||||
})
|
||||
|
||||
test('booking freezes the source customer channel and warehouse scope', () => {
|
||||
assert.match(source, /customerId: source\.tenantId/)
|
||||
assert.match(source, /channelCode: source\.channelCode/)
|
||||
assert.match(source, /warehouseId: source\.warehouseId/)
|
||||
assert.match(source, /containerRequirements: \[\{ containerType: '40HQ'/)
|
||||
})
|
||||
@@ -116,25 +116,52 @@ function snapshotMatches(tree, original, loaded, remaining, activeSuffix) {
|
||||
&& activeMatches && quantityConserved(tree)
|
||||
}
|
||||
|
||||
function containerPayload(marker, sequence, warehouseId, candidate) {
|
||||
function bookingPayload(marker, sequence, source) {
|
||||
const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18)
|
||||
return {
|
||||
containerNo: `SPLIT-${suffix}`.slice(0, 64), cabinetNo: `SI-${suffix}`.slice(0, 64),
|
||||
warehouseId, warehouseName: null, billOfLadingNo: `BL-${suffix}`.slice(0, 64),
|
||||
vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
|
||||
containerType: '40HQ', shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128),
|
||||
customerId: source.tenantId, customerName: source.customerName,
|
||||
channelCode: source.channelCode, channelName: source.channelName,
|
||||
warehouseId: source.warehouseId, warehouseName: source.warehouseName,
|
||||
shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128),
|
||||
bookingAgentId: null, bookingAgentName: `SPLIT-AGENT-${suffix}`.slice(0, 128),
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
destinationWarehouseCode: candidate.destinationWarehouseCode || null,
|
||||
destinationWarehouseName: candidate.destinationWarehouseName || null,
|
||||
capacityCbm: 100, maxWeightKg: 100000, terminalTruckingCompany: `SPLIT-TRUCK-${suffix}`.slice(0, 128),
|
||||
loadingAt: '2026-09-10T08:00:00', estimatedSailingAt: '2026-09-11T08:00:00',
|
||||
estimatedPortArrivalAt: '2026-09-28T08:00:00', estimatedWarehouseArrivalAt: '2026-09-29T08:00:00',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: marker,
|
||||
parties: [{ partyType: 'SHIPPER', partyName: `SPLIT-SHIPPER-${suffix}` },
|
||||
{ partyType: 'CONSIGNEE', partyName: `SPLIT-CONSIGNEE-${suffix}` },
|
||||
{ partyType: 'NOTIFY_PARTY', partyName: `SPLIT-NOTIFY-${suffix}` }],
|
||||
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100,
|
||||
maxWeightKg: 100000, quantity: 1 }],
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOneFragment(context, state) {
|
||||
const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state
|
||||
const candidatesResponse = await context.request('GET', `${SMART}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100)
|
||||
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', {
|
||||
expectedVersion: 0, data: bookingPayload(context.marker, sequence, state),
|
||||
}, { 'Idempotency-Key': `${operationBase}-booking-create` })
|
||||
const bookingCreated = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建渠道仓库一致的订舱`, bookingCreate, '', OPTIONS)
|
||||
const oceanBookingId = id(bookingCreated?.oceanBooking?.id)
|
||||
if (!oceanBookingId) return null
|
||||
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(oceanBookingId)
|
||||
|
||||
const bookingSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/submit`, {
|
||||
expectedVersion: Number(bookingCreated?.oceanBooking?.version), reason: context.marker,
|
||||
}, { 'Idempotency-Key': `${operationBase}-booking-submit` })
|
||||
const bookingSubmitted = context.apiSuccess(MODULE, `提交 ${sourceOrderNo} 的订舱`, bookingSubmit, '', OPTIONS)
|
||||
const bookingConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/confirm`, {
|
||||
expectedVersion: Number(bookingSubmitted?.oceanBooking?.version), reason: context.marker,
|
||||
}, { 'Idempotency-Key': `${operationBase}-booking-confirm` })
|
||||
const bookingConfirmed = context.apiSuccess(MODULE, `确认 ${sourceOrderNo} 的订舱并自动分配柜次`, bookingConfirm, '', OPTIONS)
|
||||
const containerId = id(bookingConfirmed?.containerAllocations?.[0]?.containerBookingId)
|
||||
context.assert(MODULE, `${sourceOrderNo} 订舱确认后取得稳定柜次`, bookingConfirm,
|
||||
isPositiveId(containerId) && id(bookingConfirmed?.oceanBooking?.id) === oceanBookingId,
|
||||
`oceanBookingId=${oceanBookingId}, containerBookingId=${containerId || 'missing'}`, OPTIONS)
|
||||
if (!containerId) return null
|
||||
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId)
|
||||
|
||||
const candidatesPath = `${SMART}/candidates?bookingId=${encodeURIComponent(containerId)}&warehouseId=${encodeURIComponent(warehouseId)}`
|
||||
const candidatesResponse = await context.request('GET', candidatesPath)
|
||||
const candidates = context.apiSuccess(MODULE, `查询余量 ${sourceOrderNo} 的真实装柜候选`, candidatesResponse, '', OPTIONS)
|
||||
const candidate = Array.isArray(candidates)
|
||||
? candidates.find((row) => id(row.orderId) === sourceOrderId && id(row.accountId) === tenantId)
|
||||
@@ -145,20 +172,12 @@ async function loadOneFragment(context, state) {
|
||||
`orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
|
||||
if (!candidate?.inboundId || !candidate?.inboundDetailId) return null
|
||||
|
||||
const createContainer = await request(context, 'POST', CONTAINERS,
|
||||
containerPayload(context.marker, sequence, warehouseId, candidate))
|
||||
const container = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建隔离柜次`, createContainer, '', OPTIONS)
|
||||
const containerId = id(container?.id)
|
||||
if (!containerId) return null
|
||||
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId)
|
||||
|
||||
const createPlan = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker })
|
||||
const plan = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建装柜计划`, createPlan, '', OPTIONS)
|
||||
const planId = id(plan?.id)
|
||||
if (!planId) return null
|
||||
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(planId)
|
||||
|
||||
const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100)
|
||||
const assignPayload = {
|
||||
inboundId: id(candidate.inboundId), inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
|
||||
expectedVersion: Number(plan.version), operationId: `${operationBase}-assign`,
|
||||
@@ -180,16 +199,31 @@ async function loadOneFragment(context, state) {
|
||||
|
||||
const push = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
|
||||
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': `${operationBase}-push` })
|
||||
const pushed = context.apiSuccess(MODULE, `推送 ${sourceOrderNo} 的真实 PDA 任务`, push, '', OPTIONS)
|
||||
if (pushed?.status !== 'LOADING') return null
|
||||
const pushed = context.apiSuccess(MODULE, `推送 ${sourceOrderNo} 的预装计划`, push, '', OPTIONS)
|
||||
context.assert(MODULE, `${sourceOrderNo} 未补真实柜号时计划保持 EDITING`, push,
|
||||
pushed?.status === 'EDITING', `status=${pushed?.status || 'missing'}`, OPTIONS)
|
||||
if (pushed?.status !== 'EDITING') return null
|
||||
|
||||
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
|
||||
const containerDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 的自动柜次`, containerDetailResponse, '', OPTIONS)
|
||||
const realContainerNo = `SPLIT-${operationBase.replace(/[^A-Za-z0-9]/g, '').slice(-18)}`.slice(0, 64)
|
||||
const transportResponse = await request(context, 'PATCH', `${CONTAINERS}/${encodeURIComponent(containerId)}/transport`, {
|
||||
containerNo: realContainerNo, voyageNo: `V-${sequence}`,
|
||||
version: Number(containerDetail?.version), reason: context.marker,
|
||||
})
|
||||
const transported = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 补充真实柜号并开放 PDA`, transportResponse, '', OPTIONS)
|
||||
context.assert(MODULE, `${sourceOrderNo} 真实柜号已绑定到稳定柜次`, transportResponse,
|
||||
transported?.containerNo === realContainerNo,
|
||||
`containerBookingId=${containerId}, containerNo=${transported?.containerNo || 'missing'}`, OPTIONS)
|
||||
|
||||
const taskResponse = await context.request('GET',
|
||||
`${PDA}/tasks?containerNo=${encodeURIComponent(container.containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const task = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 的 PDA 任务`, taskResponse, '', OPTIONS)
|
||||
const taskId = id(task?.id)
|
||||
const pickItem = task?.items?.find?.((row) => id(row.loadingPlanItemId) === id(planItem.id))
|
||||
context.assert(MODULE, `余量 ${sourceOrderNo} 不继承已拣数量`, taskResponse,
|
||||
Boolean(taskId) && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1
|
||||
Boolean(taskId) && id(task?.loadingPlanId) === planId && id(task?.containerBookingId) === containerId
|
||||
&& task?.currentContainerNo === realContainerNo && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1
|
||||
&& Number(pickItem?.pickedPackageCount) === 0,
|
||||
`taskId=${taskId || 'missing'}, picked=${pickItem?.pickedPackageCount ?? 'missing'}, planned=${pickItem?.plannedPackageCount ?? 'missing'}`, OPTIONS)
|
||||
if (!taskId || !pickItem?.id) return null
|
||||
@@ -237,13 +271,13 @@ async function loadOneFragment(context, state) {
|
||||
|
||||
const treeResponse = await context.request('GET', treePath(rootOrderId, tenantId))
|
||||
const tree = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 拆分后的根聚合`, treeResponse, '', OPTIONS)
|
||||
const candidatesAfter = await context.request('GET', `${SMART}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
const candidatesAfter = await context.request('GET', candidatesPath)
|
||||
const candidateRowsAfter = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 不可重复消费`, candidatesAfter, '', OPTIONS)
|
||||
context.assert(MODULE, `来源 ${sourceOrderNo} 同时最多成功拆分一次`, candidatesAfter,
|
||||
Array.isArray(candidateRowsAfter) && !candidateRowsAfter.some((row) => id(row.orderId) === sourceOrderId),
|
||||
`sourceOrderId=${sourceOrderId}, stillCandidate=${Array.isArray(candidateRowsAfter) && candidateRowsAfter.some((row) => id(row.orderId) === sourceOrderId)}`, OPTIONS)
|
||||
|
||||
return { tree, treeResponse, containerId, planId, taskId, sourceInboundId: id(candidate.inboundId) }
|
||||
return { tree, treeResponse, oceanBookingId, containerId, planId, taskId, sourceInboundId: id(candidate.inboundId) }
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
@@ -263,10 +297,15 @@ export async function run(context) {
|
||||
const inbound = context.apiSuccess(MODULE, '读取本轮来源入库单', inboundResponse,
|
||||
`tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS)
|
||||
const orderId = id(inbound?.summary?.orderId)
|
||||
const channelCode = id(inbound?.summary?.channelCode || fixture?.channelCode)
|
||||
const channelName = inbound?.summary?.channelName || fixture?.channelName || channelCode
|
||||
const warehouseName = inbound?.summary?.warehouseName || fixture?.warehouseName
|
||||
const customerName = inbound?.summary?.customerName || `验收客户-${tenantId}`
|
||||
context.assert(MODULE, '来源入库单归属本轮客户与根订单', inboundResponse,
|
||||
isPositiveId(orderId) && id(inbound?.summary?.tenantId) === tenantId,
|
||||
`orderId=${orderId || 'missing'}, tenantId=${id(inbound?.summary?.tenantId) || 'missing'}`, OPTIONS)
|
||||
if (!orderId) return
|
||||
isPositiveId(orderId) && id(inbound?.summary?.tenantId) === tenantId
|
||||
&& Boolean(channelCode && channelName && warehouseName),
|
||||
`orderId=${orderId || 'missing'}, tenantId=${id(inbound?.summary?.tenantId) || 'missing'}, channelCode=${channelCode || 'missing'}, warehouseName=${warehouseName || 'missing'}`, OPTIONS)
|
||||
if (!orderId || !channelCode || !channelName || !warehouseName) return
|
||||
|
||||
const firstTreeResponse = await context.request('GET', treePath(orderId, tenantId))
|
||||
const firstTree = context.apiSuccess(MODULE, '查询首次拆分后的根履约树', firstTreeResponse,
|
||||
@@ -340,7 +379,8 @@ export async function run(context) {
|
||||
|
||||
const result = await loadOneFragment(context, {
|
||||
tenantId, rootOrderId: orderId, sourceOrderId: id(source.orderId), sourceOrderNo: source.orderNo,
|
||||
warehouseId, sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix,
|
||||
customerName, channelCode, channelName, warehouseId, warehouseName,
|
||||
sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix,
|
||||
})
|
||||
if (!result?.tree) return
|
||||
tree = result.tree
|
||||
|
||||
@@ -277,6 +277,12 @@ test('downstream producers prefer the phase3Loading container fixture', () => {
|
||||
assert.match(customs, /createdResources\?\.phase3Loading,\s*\n\s*createdResources\?\.phase3ContainerLoading/)
|
||||
assert.match(serviceCenter, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/)
|
||||
assert.match(ocean, /request\(context, 'POST', `\$\{path\}\/release`/)
|
||||
assert.match(ocean, /flowOceanBookingId = id\(containerFlow\?\.oceanBookingId\)/)
|
||||
assert.match(ocean, /ocean-flow-release/)
|
||||
assert.match(ocean, /containerBookingId: id\(containerFlow\.containerBookingId\), loadingPlanId: id\(containerFlow\.loadingPlanId\)/)
|
||||
assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/)
|
||||
assert.doesNotMatch(ocean, /`\$\{context\.marker\}-\$\{endpoint\}`/)
|
||||
assert.match(ocean, /`\$\{context\.marker\}-ocean-\$\{endpoint\}`/)
|
||||
})
|
||||
|
||||
test('default acceptance order verifies loading evidence before finalizing LOADED', () => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptsDir = dirname(fileURLToPath(import.meta.url))
|
||||
const source = readFileSync(resolve(scriptsDir, 'client-test-env-acceptance.mjs'), 'utf8')
|
||||
|
||||
test('Client full acceptance executes the real notification inbox flow', () => {
|
||||
assert.match(source, /await isolationModule\(\)\s+await notificationInboxModule\(\)\s+await accountSecurityModule\(\)/)
|
||||
assert.match(source, /async function notificationInboxModule\(\)/)
|
||||
for (const permission of [
|
||||
'merchant:notification:view',
|
||||
'merchant:notification:read',
|
||||
'merchant:notification:read-all',
|
||||
]) assert.match(source, new RegExp(`['"]${permission}['"]`))
|
||||
})
|
||||
|
||||
test('notification delivery targets only the newly registered customer owner', () => {
|
||||
assert.match(source, /tenantId:\s*String\(report\.resources\.tenantId\)/)
|
||||
assert.match(source, /recipientType:\s*['"]MEMBERSHIP['"]/)
|
||||
assert.match(source, /recipientIds:\s*\[String\(report\.resources\.membershipId\)\]/)
|
||||
assert.match(source, /await sendMessage\(1\)/)
|
||||
assert.match(source, /await sendMessage\(2\)/)
|
||||
assert.match(source, /other client cannot read first message detail/)
|
||||
assert.match(source, /other client cannot see acceptance messages in inbox/)
|
||||
assert.match(source, /\['A0403', 'A0404'\]\.includes\(String\(json\?\.code\)\)/)
|
||||
})
|
||||
|
||||
test('notification flow covers unread, single read, read-all, and cleanup', () => {
|
||||
for (const contract of [
|
||||
/status=UNREAD/,
|
||||
/status=READ/,
|
||||
/\/unread-count/,
|
||||
/\$\{firstMessageId\}\/read/,
|
||||
/\/read-all/,
|
||||
/single-message read is idempotent/,
|
||||
/cleanup keeps acceptance messages read/,
|
||||
/cleanup leaves no unread acceptance messages/,
|
||||
/targetStatus:\s*['"]DISABLED['"]/,
|
||||
/retainedAsRead:\s*true/,
|
||||
]) assert.match(source, contract)
|
||||
})
|
||||
@@ -70,6 +70,8 @@ const report = {
|
||||
channelCode: null,
|
||||
roleId: null,
|
||||
testMembershipId: null,
|
||||
notificationTemplateId: null,
|
||||
notificationMessageIds: [],
|
||||
orderIds: [],
|
||||
},
|
||||
steps: [],
|
||||
@@ -92,6 +94,7 @@ try {
|
||||
await rbacAndMemberModule()
|
||||
await orderLifecycleModule()
|
||||
await isolationModule()
|
||||
await notificationInboxModule()
|
||||
await accountSecurityModule()
|
||||
report.success = report.steps.every((step) => step.passed)
|
||||
} catch (error) {
|
||||
@@ -547,7 +550,7 @@ async function rbacAndMemberModule() {
|
||||
assertSetEqual(
|
||||
visibleClientMenus(arrayData(ownerPermissions.json.data)),
|
||||
['account:profile:view', 'company:member:list', 'company:permission:list', 'company:profile:view',
|
||||
'company:role:list', 'dashboard:view', 'order:first-leg:list'],
|
||||
'company:role:list', 'dashboard:view', 'notification:inbox:view', 'order:first-leg:list'],
|
||||
'owner visible Client menus',
|
||||
)
|
||||
|
||||
@@ -1340,6 +1343,240 @@ async function isolationModule() {
|
||||
}, ({ json }) => ({ qualificationId: report.resources.businessLicenseQualificationId, rejectionCode: json?.code }))
|
||||
}
|
||||
|
||||
async function notificationInboxModule() {
|
||||
const module = 'notification-inbox'
|
||||
const templateBase = '/api/platform/v1/notification-templates'
|
||||
const inboxBase = '/api/merchant/v1/notifications'
|
||||
const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-24).toUpperCase()
|
||||
const templateCode = `CLIENT_INBOX_${suffix}`.slice(0, 64)
|
||||
const category = 'CLIENT_ACCEPTANCE'
|
||||
let template = null
|
||||
|
||||
try {
|
||||
const createTemplate = await apiStep(module, 'platform creates client inbox template', {
|
||||
method: 'POST', path: templateBase, token: platformToken,
|
||||
body: {
|
||||
templateCode,
|
||||
templateName: `客户消息闭环-${marker}`.slice(0, 128),
|
||||
category,
|
||||
titleTemplate: '客户消息验收 {{sequence}}',
|
||||
bodyTemplate: '客户 {{marker}} 的第 {{sequence}} 条真实站内消息。',
|
||||
variableWhitelist: ['marker', 'sequence'],
|
||||
},
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertPresent(json.data?.id, 'notification template id')
|
||||
assertEqual(json.data?.status, 'DRAFT', 'notification template status')
|
||||
assertEqual(json.data?.channel, 'IN_APP', 'notification template channel')
|
||||
}, ({ json }) => ({ templateId: json.data.id, templateCode: json.data.templateCode, status: json.data.status }))
|
||||
template = createTemplate.json.data
|
||||
report.resources.notificationTemplateId = template.id
|
||||
|
||||
const activateTemplate = await apiStep(module, 'platform activates client inbox template', {
|
||||
method: 'PUT', path: `${templateBase}/${template.id}/status`, token: platformToken,
|
||||
body: { targetStatus: 'ACTIVE', version: template.version },
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.status, 'ACTIVE', 'notification template status')
|
||||
}, ({ json }) => ({ templateId: json.data.id, status: json.data.status, version: json.data.version }))
|
||||
template = activateTemplate.json.data
|
||||
|
||||
await apiStep(module, 'client normalizes existing inbox messages to read', {
|
||||
method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Number(json.data?.updatedCount) >= 0, 'updatedCount must be non-negative')
|
||||
}, ({ json }) => ({ updatedCount: json.data.updatedCount }))
|
||||
|
||||
await apiStep(module, 'client unread baseline is zero', {
|
||||
method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(Number(json.data?.unreadCount), 0, 'unread baseline')
|
||||
}, ({ json }) => ({ unreadCount: json.data.unreadCount }))
|
||||
|
||||
const sendMessage = async (sequence) => apiStep(module, `platform sends client message ${sequence}`, {
|
||||
method: 'POST', path: '/api/platform/v1/notifications', token: platformToken,
|
||||
body: {
|
||||
tenantId: String(report.resources.tenantId),
|
||||
templateCode,
|
||||
recipientType: 'MEMBERSHIP',
|
||||
recipientIds: [String(report.resources.membershipId)],
|
||||
variables: { marker, sequence: String(sequence) },
|
||||
scheduledAt: null,
|
||||
idempotencyKey: newIdempotencyKey(`client-notification-${sequence}`),
|
||||
sourceEventId: `client-e2e-notification-${sequence}-${marker}`.slice(0, 128),
|
||||
},
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.status, 'SENT', 'notification task status')
|
||||
assertEqual(Number(json.data?.recipientCount), 1, 'notification recipient count')
|
||||
}, ({ json }) => ({ taskId: json.data.id, taskNo: json.data.taskNo, status: json.data.status, recipientCount: json.data.recipientCount }))
|
||||
|
||||
await sendMessage(1)
|
||||
|
||||
await apiStep(module, 'client unread count increments after first delivery', {
|
||||
method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(Number(json.data?.unreadCount), 1, 'unread count after first delivery')
|
||||
}, ({ json }) => ({ unreadCount: json.data.unreadCount }))
|
||||
|
||||
const firstUnreadPage = await apiStep(module, 'client filters first message as unread', {
|
||||
method: 'GET', path: `${inboxBase}?page=1&size=20&status=UNREAD&category=${category}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
const message = pageRecords(json.data).find((item) => item.title === `客户消息验收 1`)
|
||||
assertPresent(message?.id, 'first unread notification id')
|
||||
assertEqual(message.status, 'UNREAD', 'first notification status')
|
||||
}, ({ json }) => ({ total: json.data.total, unreadIds: pageRecords(json.data).map((item) => item.id) }))
|
||||
const firstMessage = pageRecords(firstUnreadPage.json.data).find((item) => item.title === '客户消息验收 1')
|
||||
const firstMessageId = firstMessage.id
|
||||
report.resources.notificationMessageIds.push(firstMessageId)
|
||||
|
||||
await apiStep(module, 'client reads first message detail while it is unread', {
|
||||
method: 'GET', path: `${inboxBase}/${firstMessageId}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(String(json.data?.id), String(firstMessageId), 'first notification id')
|
||||
assertEqual(json.data?.status, 'UNREAD', 'first notification detail status')
|
||||
assertTrue(String(json.data?.body).includes(marker), 'first notification body must contain marker')
|
||||
}, ({ json }) => ({ messageId: json.data.id, status: json.data.status, category: json.data.category }))
|
||||
|
||||
if (existingClientToken) {
|
||||
await apiStep(module, 'other client cannot read first message detail', {
|
||||
method: 'GET', path: `${inboxBase}/${firstMessageId}`, token: existingClientToken,
|
||||
}, ({ json, status }) => {
|
||||
assertEqual(status, 200, 'HTTP status')
|
||||
assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`)
|
||||
}, ({ json }) => ({ messageId: firstMessageId, rejectionCode: json.code }))
|
||||
} else {
|
||||
recordSkipped(module, 'other client cannot read first message detail', 'Cross-client credentials were not supplied')
|
||||
}
|
||||
|
||||
await apiStep(module, 'client marks first message read', {
|
||||
method: 'PUT', path: `${inboxBase}/${firstMessageId}/read`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.status, 'READ', 'first notification status after read')
|
||||
assertPresent(json.data?.readAt, 'first notification readAt')
|
||||
}, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt }))
|
||||
|
||||
await apiStep(module, 'single-message read is idempotent', {
|
||||
method: 'PUT', path: `${inboxBase}/${firstMessageId}/read`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.status, 'READ', 'idempotent notification status')
|
||||
}, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt }))
|
||||
|
||||
await apiStep(module, 'client filters first message as read', {
|
||||
method: 'GET', path: `${inboxBase}?page=1&size=20&status=READ&category=${category}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(pageRecords(json.data).some((item) => String(item.id) === String(firstMessageId) && item.status === 'READ'),
|
||||
'read filter must contain first notification')
|
||||
}, ({ json }) => ({ total: json.data.total, readIds: pageRecords(json.data).map((item) => item.id) }))
|
||||
|
||||
await apiStep(module, 'client unread count returns to zero after single read', {
|
||||
method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(Number(json.data?.unreadCount), 0, 'unread count after single read')
|
||||
}, ({ json }) => ({ unreadCount: json.data.unreadCount }))
|
||||
|
||||
await sendMessage(2)
|
||||
const secondUnreadPage = await apiStep(module, 'client receives second unread message', {
|
||||
method: 'GET', path: `${inboxBase}?page=1&size=20&status=UNREAD&category=${category}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
const message = pageRecords(json.data).find((item) => item.title === '客户消息验收 2')
|
||||
assertPresent(message?.id, 'second unread notification id')
|
||||
assertEqual(message.status, 'UNREAD', 'second notification status')
|
||||
}, ({ json }) => ({ total: json.data.total, unreadIds: pageRecords(json.data).map((item) => item.id) }))
|
||||
const secondMessage = pageRecords(secondUnreadPage.json.data).find((item) => item.title === '客户消息验收 2')
|
||||
const secondMessageId = secondMessage.id
|
||||
report.resources.notificationMessageIds.push(secondMessageId)
|
||||
|
||||
if (existingClientToken) {
|
||||
await apiStep(module, 'other client cannot see acceptance messages in inbox', {
|
||||
method: 'GET', path: `${inboxBase}?page=1&size=200&category=${category}`, token: existingClientToken,
|
||||
}, ({ json, status }) => {
|
||||
assertEqual(status, 200, 'HTTP status')
|
||||
if (['A0403', 'A0404'].includes(String(json?.code))) return
|
||||
requireSuccess(json)
|
||||
const foreignIds = new Set(pageRecords(json.data).map((item) => String(item.id)))
|
||||
assertTrue(!foreignIds.has(String(firstMessageId)) && !foreignIds.has(String(secondMessageId)),
|
||||
'other client inbox must exclude acceptance messages')
|
||||
}, ({ json }) => ({ rejectionCode: json?.code === '200' ? null : json?.code,
|
||||
visibleCount: json?.code === '200' ? pageRecords(json.data).length : null,
|
||||
excludedIds: [firstMessageId, secondMessageId] }))
|
||||
} else {
|
||||
recordSkipped(module, 'other client cannot see acceptance messages in inbox', 'Cross-client credentials were not supplied')
|
||||
}
|
||||
|
||||
await apiStep(module, 'client unread count increments after second delivery', {
|
||||
method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(Number(json.data?.unreadCount), 1, 'unread count after second delivery')
|
||||
}, ({ json }) => ({ unreadCount: json.data.unreadCount }))
|
||||
|
||||
await apiStep(module, 'client marks all messages read', {
|
||||
method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(Number(json.data?.updatedCount), 1, 'read-all updatedCount')
|
||||
}, ({ json }) => ({ updatedCount: json.data.updatedCount }))
|
||||
|
||||
await apiStep(module, 'second message detail is read after read-all', {
|
||||
method: 'GET', path: `${inboxBase}/${secondMessageId}`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.status, 'READ', 'second notification status after read-all')
|
||||
assertPresent(json.data?.readAt, 'second notification readAt')
|
||||
}, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt }))
|
||||
|
||||
await apiStep(module, 'client unread count is zero after read-all', {
|
||||
method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(Number(json.data?.unreadCount), 0, 'unread count after read-all')
|
||||
}, ({ json }) => ({ unreadCount: json.data.unreadCount }))
|
||||
} finally {
|
||||
if (merchantToken && report.resources.notificationMessageIds.length > 0) {
|
||||
await softApiStep(module, 'cleanup keeps acceptance messages read', {
|
||||
method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertTrue(Number(json.data?.updatedCount) >= 0, 'cleanup updatedCount must be non-negative')
|
||||
}, ({ json }) => ({ updatedCount: json?.data?.updatedCount }))
|
||||
await softApiStep(module, 'cleanup leaves no unread acceptance messages', {
|
||||
method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(Number(json.data?.unreadCount), 0, 'cleanup unread count')
|
||||
}, ({ json }) => ({ unreadCount: json?.data?.unreadCount }))
|
||||
}
|
||||
if (template?.status === 'ACTIVE') {
|
||||
const disabled = await softApiStep(module, 'platform disables client inbox template', {
|
||||
method: 'PUT', path: `${templateBase}/${template.id}/status`, token: platformToken,
|
||||
body: { targetStatus: 'DISABLED', version: template.version },
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
assertEqual(json.data?.status, 'DISABLED', 'notification template final status')
|
||||
}, ({ json }) => ({ templateId: json?.data?.id, status: json?.data?.status, version: json?.data?.version }))
|
||||
if (disabled?.json?.data) template = disabled.json.data
|
||||
}
|
||||
report.notificationInbox = {
|
||||
templateId: report.resources.notificationTemplateId,
|
||||
templateCode,
|
||||
templateStatus: template?.status ?? null,
|
||||
messageIds: [...report.resources.notificationMessageIds],
|
||||
retainedAsRead: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function accountSecurityModule() {
|
||||
const consumedRefreshToken = merchantRefreshToken
|
||||
const rotated = await apiStep('account-security', 'rotate refresh token', {
|
||||
@@ -1830,6 +2067,7 @@ function requiredOwnerBackendPermissions() {
|
||||
'merchant:member:edit', 'merchant:member:grant-role', 'merchant:member:status',
|
||||
'merchant:member:reset-password', 'merchant:role:list', 'merchant:role:create',
|
||||
'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete', 'merchant:role:grant',
|
||||
'merchant:notification:view', 'merchant:notification:read', 'merchant:notification:read-all',
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1840,6 +2078,7 @@ function visibleClientMenus(backendPermissions) {
|
||||
if (permissions.has('merchant:profile:view')) menus.push('company:profile:view')
|
||||
if (permissions.has('merchant:member:list')) menus.push('company:member:list')
|
||||
if (permissions.has('merchant:role:list')) menus.push('company:role:list', 'company:permission:list')
|
||||
if (permissions.has('merchant:notification:view')) menus.push('notification:inbox:view')
|
||||
return menus.sort()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = '9F804D3C0133BDEA888202215E71191E22DAAB0649A2CD26C09923E3FCB57899'
|
||||
$ExpectedLines = 14812
|
||||
$ExpectedSha256 = '760747FCA4706FEBA7D1D69994633875D7BE5932BB4A6B4F30A6B9BAB719FAD8'
|
||||
$ExpectedLines = 14940
|
||||
|
||||
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