This commit is contained in:
2026-10-21 21:30:24 +08:00
parent 234216b3eb
commit 5356a8c898
28 changed files with 1543 additions and 45 deletions
+21
View File
@@ -1,5 +1,26 @@
# 后端脚本管理与全量 HTTP 验收索引 # 后端脚本管理与全量 HTTP 验收索引
`dispatch-loading-flow-test-env.mjs` 是 TEST 固定客户两托一车的定向 HTTP 验收入口,验证实收入库、确认预约、审核、扫码拣货、五张装车图片的 Admin 读取、自动出库派送及原请求幂等。默认只预览;显式 `--execute --run-id <唯一标识> --warehouse-id <起运仓> --destination-warehouse-id <FBA仓> --test-image <明确标注TEST的PNG>` 写入业务测试记录,通过 `node --env-file="<统一TEST配置>"` 读取凭据和固定客户。报告位于 `reports/dispatch-loading-<run-id>/`,保留失败前的请求和业务数据;同标识拒绝重跑,先核对原请求再决定恢复。`--resume-report <原报告>` 仅用于已明确收到编辑 A0400、仍为零拣货草稿的原单,保留原车与原司机,回查状态后补齐编辑字段继续;未知写入结果不能使用此恢复入口。默认停用本次临时 PDA 账号;`--keep-pda-for-ui` 仅在成功后将临时登录资料保存到系统临时目录供页面验收,页面结束后必须停用账号。所有 HTTP 会话结束时注销,不执行 SQL、删除、重置或部署。新增入口验证运行语法检查和 `script-documentation-contract.test.mjs`,经 `local-check.ps1`。
Admin/Client TEST 回归统一配置 `ADMIN_TEST_FIXED_CUSTOMER_ID` 与 `MERCHANT_TEST_USERNAME/PASSWORD`,复用指定 ACTIVE 客户;独立 Admin runner 同样读取这些显式环境变量。Client 默认不再注册审核、修改固定客户资料/账号、创建成员角色或通知,也不运行余额/渠道共享配置及缺第二客户的隔离场景;这些范围列为 skipped,`fullCoverage=false`,执行范围通过不代表全量覆盖。Admin 排除的主档生命周期范围见模块 README。业务单据及附件仍按本轮证据需要新增;不自动清理历史记录。
## TEST 业务数据重置准备
`test-env-business-reset-plan.mjs` 是**离线预览入口**,读取两份统一空库基线与 `test-env-business-reset-policy.json` 的显式逐表清单;不联网、不读取凭据、不执行 SQL,也没有清库执行模式。策略按全部客户的头程、海外仓、尾程、车队业务整理;资金记录与实际余额清零,保留授信额度/有效期;基础资料、编号序列、自营采集、独立审计、文件元数据和物理文件保留。此策略是候选处理范围,不能直接作为执行白名单。
```powershell
# 默认只打印摘要;无需环境文件
node scripts/test-env-business-reset-plan.mjs
# 输出新的独占报告目录;相同目录再次运行会失败,不覆盖既有证据
node scripts/test-env-business-reset-plan.mjs --out-dir reports/business-reset-<本次唯一标识>
# 人工选择 TEST 连接执行 inspection.sql,将首条查询的 inventory_json 原样保存后再比对
node scripts/test-env-business-reset-plan.mjs --inventory <实际结构清单.json> --out-dir reports/business-reset-<新的唯一标识>
```
报告 `plan.json` 逐表区分 `KEEP`、`CLEAR_CANDIDATE`、`RESET_REVIEW` 和 `REVIEW`,包含源码指纹、外键引用和触发器清单。独立审计保留,订单自身的状态/作业历史按已确认范围随单清理;人工船期计划、车辆运维档案和站内消息保留。业务导出与自营/基础资料导出混在同表,必须按明确类型白名单处理。基线变化、未分类新表、策略残留、实际缺表/多表/视图及保留表引用待清主表都会显式报告;不会根据表前缀自动扩大清理。`inspection.sql` 只查询目标 schema 的表、字段、外键、触发器及索引,并检查指向目标表的外部外键;操作者须保证账号对目标结构的完整可见性,缺少权限不能解释为数据不存在。`TABLE_ROWS` 仅为估算,导入完整且表名吻合的实际清单后才生成 `counts.sql` 精确计数查询;脚本本身仍不连接数据库。结构 JSON 不包含业务行或连接凭据。
退出 0 只表示预览成功;结果始终为 `REVIEW_REQUIRED / executionSupported=false`,不是清库放行。解析失败退出1并保留已生成文件,修正原因后使用新报告目录重跑,不自动删除或覆盖报告。数据库连接由操作者在环境操作阶段明确选择,不能用初始化文件重放代替重置;真实 SQL、删除/重置、维护窗口、备份恢复、缓存及消息重投处理均需按环境规则单独落实。资金可用余额须按有效授信重算,不能与实际余额一并固定归零;停用/报废资源不自动复活,业务过程历史与独立审计、船期计划、车辆运维、混合通知/导出及固定协调锁仍需专项核对。直接离线测试为 `test-env-business-reset-plan.test.mjs`,新增入口同时运行 `script-documentation-contract.test.mjs`,均经 `local-check.ps1`。
## 先看范围,再看证据 ## 先看范围,再看证据
`acceptance-overview.mjs` 是离线只读总览入口,不需要凭据、不联网、不创建报告或测试数据。默认范围直接读取 Admin 实际注册表,不维护另一份模块枚举: `acceptance-overview.mjs` 是离线只读总览入口,不需要凭据、不联网、不创建报告或测试数据。默认范围直接读取 Admin 实际注册表,不维护另一份模块枚举:
+49 -2
View File
@@ -57,6 +57,8 @@ export function planA0LocationIsolation(locations, requiredSlots = 1,
} }
const MODULE_DEPENDENCIES = new Map([ const MODULE_DEPENDENCIES = new Map([
['customers', ['merchant-qualification-fixture']],
['overseas-storage-fees', ['merchant-qualification-fixture']],
['inbound-weight-ocr', ['orders']], ['inbound-weight-ocr', ['orders']],
['orders', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']],
['phase5-root-fulfillment', ['merchant-qualification-fixture']], ['phase5-root-fulfillment', ['merchant-qualification-fixture']],
@@ -406,6 +408,11 @@ export function parseArguments(argv) {
const requestedModules = (values.get('modules') || DEFAULT_MODULES.join(',')) const requestedModules = (values.get('modules') || DEFAULT_MODULES.join(','))
.split(',').map((v) => v.trim()).filter(Boolean) .split(',').map((v) => v.trim()).filter(Boolean)
const modules = normalizeModuleOrder(requestedModules) const modules = normalizeModuleOrder(requestedModules)
if (environment === 'test') {
const fixtureIndex = modules.indexOf('merchant-qualification-fixture')
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
modules.unshift('merchant-qualification-fixture')
}
const merchantUsername = values.get('merchant-username') || envValue('MERCHANT_TEST_USERNAME', 'MERCHANT_GRAY_USERNAME') const merchantUsername = values.get('merchant-username') || envValue('MERCHANT_TEST_USERNAME', 'MERCHANT_GRAY_USERNAME')
const merchantPassword = values.get('merchant-password') || envValue('MERCHANT_TEST_PASSWORD', 'MERCHANT_GRAY_PASSWORD') const merchantPassword = values.get('merchant-password') || envValue('MERCHANT_TEST_PASSWORD', 'MERCHANT_GRAY_PASSWORD')
const requiresMarkerOwnedMerchantFixture = [ const requiresMarkerOwnedMerchantFixture = [
@@ -417,7 +424,7 @@ export function parseArguments(argv) {
'phase4-overseas-finance-cross-region', 'phase4-overseas-finance-cross-region',
'phase4-overseas-wallet-and-pallet-cost', 'phase4-overseas-wallet-and-pallet-cost',
].some((module) => modules.includes(module)) ].some((module) => modules.includes(module))
if (merchantUsername && merchantPassword && !requiresMarkerOwnedMerchantFixture) { if (environment !== 'test' && merchantUsername && merchantPassword && !requiresMarkerOwnedMerchantFixture) {
const fixtureIndex = modules.indexOf('merchant-qualification-fixture') const fixtureIndex = modules.indexOf('merchant-qualification-fixture')
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
} }
@@ -431,6 +438,7 @@ export function parseArguments(argv) {
pdaPassword: values.get('pda-password') || envValue('ADMIN_PDA_PASSWORD', 'ADMIN_GRAY_PDA_PASSWORD'), pdaPassword: values.get('pda-password') || envValue('ADMIN_PDA_PASSWORD', 'ADMIN_GRAY_PDA_PASSWORD'),
modules, modules,
requestedModules, requestedModules,
fixedCustomerId: environment === 'test' ? process.env.ADMIN_TEST_FIXED_CUSTOMER_ID || '' : '',
customerName: values.get('customer-name') || envValue('ADMIN_TEST_CUSTOMER_NAME', 'ADMIN_GRAY_CUSTOMER_NAME'), customerName: values.get('customer-name') || envValue('ADMIN_TEST_CUSTOMER_NAME', 'ADMIN_GRAY_CUSTOMER_NAME'),
merchantUsername, merchantUsername,
merchantPassword, merchantPassword,
@@ -629,7 +637,12 @@ export function buildAcceptanceSummary(steps = [], modules = [], coverageGaps =
export class AcceptanceRun { export class AcceptanceRun {
constructor(options) { constructor(options) {
assertGrayExecutionScope(options) assertGrayExecutionScope(options)
this.options = options this.options = { ...options }
if (options.environment === 'test') {
this.options.fixedCustomerId ||= process.env.ADMIN_TEST_FIXED_CUSTOMER_ID || ''
this.options.merchantUsername ||= process.env.MERCHANT_TEST_USERNAME || ''
this.options.merchantPassword ||= process.env.MERCHANT_TEST_PASSWORD || ''
}
const markerNamespace = String(options.markerNamespace || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12) const markerNamespace = String(options.markerNamespace || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12)
this.marker = `${options.environment === 'gray' ? 'GRAY' : 'ADMIN'}-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}` this.marker = `${options.environment === 'gray' ? 'GRAY' : 'ADMIN'}-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}`
this.startedAt = new Date().toISOString() this.startedAt = new Date().toISOString()
@@ -648,7 +661,17 @@ export class AcceptanceRun {
this.fatal = null this.fatal = null
} }
assertCustomerWriteAllowed(method, path) {
if (this.options.environment !== 'test') return
const pathname = new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, '')
if (String(method).toUpperCase() === 'POST' && (pathname === '/api/platform/v1/iam/tenants'
|| /^\/api\/platform\/v1\/iam\/tenants\/[^/]+\/(archive|submit-review)$/.test(pathname))) {
throw new Error('TEST 固定客户模式禁止创建、归档客户或重提资质;请复用已配置客户')
}
}
async request(method, path, body = undefined, token = this.token, extraHeaders = {}) { async request(method, path, body = undefined, token = this.token, extraHeaders = {}) {
this.assertCustomerWriteAllowed(method, path)
const headers = { Accept: 'application/json', ...extraHeaders } const headers = { Accept: 'application/json', ...extraHeaders }
if (this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) token = this.pdaToken if (this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) token = this.pdaToken
if (token) headers.Authorization = `Bearer ${token}` if (token) headers.Authorization = `Bearer ${token}`
@@ -691,6 +714,7 @@ export class AcceptanceRun {
} }
async requestWithBody(method, path, body, token = this.token, extraHeaders = {}) { async requestWithBody(method, path, body, token = this.token, extraHeaders = {}) {
this.assertCustomerWriteAllowed(method, path)
const headers = { Accept: 'application/json', ...extraHeaders } const headers = { Accept: 'application/json', ...extraHeaders }
if (token) headers.Authorization = `Bearer ${token}` if (token) headers.Authorization = `Bearer ${token}`
if (!(body instanceof FormData) && body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' if (!(body instanceof FormData) && body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
@@ -793,6 +817,14 @@ export class AcceptanceRun {
} }
async executeModule(name, handler) { async executeModule(name, handler) {
if (this.options.fixedCustomerId && new Set(['customers', 'supplemental-boundaries', 'system', 'master-data', 'suppliers', 'notifications',
'pricing', 'phase4-overseas-master-data', 'phase4-overseas-wallet-and-pallet-cost']).has(name)) {
this.addCoverageGap(name, 'fixed-resource-lifecycle', '固定资源模式排除新增主档及账户生命周期',
'业务流程复用现有资源;不执行主档创建、账号创建、价格发布或钱包初始化', 'not-implemented', { blocking: false })
this.modules.push({ name, success: false, skipped: true, passed: 0, failed: 0,
reason: '固定资源模式排除生命周期;不计为验收通过' })
return
}
const startedAt = new Date().toISOString(); const start = this.steps.length; let fatal = null const startedAt = new Date().toISOString(); const start = this.steps.length; let fatal = null
try { await handler() } catch (error) { try { await handler() } catch (error) {
fatal = error instanceof Error ? error.message : String(error) fatal = error instanceof Error ? error.message : String(error)
@@ -1124,6 +1156,12 @@ export class AcceptanceRun {
} }
async runCustomers() { async runCustomers() {
if (this.options.fixedCustomerId) {
this.context.customerId = this.context.merchantQualificationFixture?.tenantId
this.addCoverageGap('customers', 'customer-lifecycle', '按用户要求排除建客及归档生命周期',
'复用固定客户;不改其资质、状态和账户,不把该生命周期列为通过', 'not-implemented', {blocking:false})
return
}
const module = 'customers'; const phone = await this.findAvailablePhone(module); const suffix = this.marker.slice(-12) const module = 'customers'; const phone = await this.findAvailablePhone(module); const suffix = this.marker.slice(-12)
const payload = { const payload = {
tenantName: `后台客户验收-${suffix}`, customerNickname: `后台客户-${suffix}`, legalName: `后台客户验收有限公司-${suffix}`, tenantName: `后台客户验收-${suffix}`, customerNickname: `后台客户-${suffix}`, legalName: `后台客户验收有限公司-${suffix}`,
@@ -1782,6 +1820,9 @@ export class AcceptanceRun {
} }
async run() { async run() {
if (this.options.environment === 'test' && !this.options.fixedCustomerId) {
throw new Error('TEST 验收必须配置 ADMIN_TEST_FIXED_CUSTOMER_ID;禁止自动创建测试客户')
}
if (this.options.merchantUsername && this.options.merchantPassword) { if (this.options.merchantUsername && this.options.merchantPassword) {
process.env.MERCHANT_TEST_USERNAME = this.options.merchantUsername process.env.MERCHANT_TEST_USERNAME = this.options.merchantUsername
process.env.MERCHANT_TEST_PASSWORD = this.options.merchantPassword process.env.MERCHANT_TEST_PASSWORD = this.options.merchantPassword
@@ -1903,6 +1944,11 @@ export class AcceptanceRun {
} finally { } finally {
if (restoreFixture) restoreFixture() if (restoreFixture) restoreFixture()
} }
if (module === 'merchant-qualification-fixture' && this.options.fixedCustomerId
&& !this.context.merchantQualificationFixture?.reused) {
this.fatal = '固定客户身份校验失败,停止后续造数'
break
}
if (module === 'inbound-measurement' if (module === 'inbound-measurement'
&& this.captureWaitSplitFixture(this.options.waitSplitSequence || 1)) { && this.captureWaitSplitFixture(this.options.waitSplitSequence || 1)) {
await this.createAdditionalWaitSplitFixtures() await this.createAdditionalWaitSplitFixtures()
@@ -1962,6 +2008,7 @@ export class AcceptanceRun {
}, },
generatedAt: new Date().toISOString(), startedAt: this.startedAt, generatedAt: new Date().toISOString(), startedAt: this.startedAt,
success: this.fatal === null && summary.failed === 0, fatal: this.fatal, success: this.fatal === null && summary.failed === 0, fatal: this.fatal,
fullCoverage: this.coverageGaps.length === 0 && !this.modules.some(module => module.skipped),
...(this.options.prepareDomesticPdaH5 ? { fullAcceptance: false, purpose: 'DOMESTIC_PDA_H5_FIXTURE' } : {}), ...(this.options.prepareDomesticPdaH5 ? { fullAcceptance: false, purpose: 'DOMESTIC_PDA_H5_FIXTURE' } : {}),
summary, summary,
modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers, modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers,
+6
View File
@@ -1,5 +1,11 @@
# Admin 测试环境 HTTP 模块 # Admin 测试环境 HTTP 模块
固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。
固定客户模式下,默认编排排除 `system`、`master-data`、`suppliers`、`notifications`、`pricing`、`phase4-overseas-master-data`、`phase4-overseas-wallet-and-pallet-cost` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。
Admin TEST 入口必须通过显式环境文件配置 `ADMIN_TEST_FIXED_CUSTOMER_ID`(`TEN` 编号或数字 ID)及该客户的 `MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。每次先核验账号归属、ACTIVE 状态和尺寸初始化,再复用客户造业务数据;校验失败停止后续模块,不自动建客、重提资质或重置密码。客户创建/归档生命周期明确排除,海外仓储费模块只读核验既有规则,不覆盖固定客户配置。需要第二客户的隔离用例在单客户模式下阻断,不用同一客户替代跨客户验证。该限制不代表这些生命周期或跨客户场景验收通过,也不清理历史客户。
`fleet-return-redispatch-acceptance.mjs` 的原出发证明读取本轮 `LOADING_COMPLETE` 回执与 `departureDeliveries`;重派在 PDA 装车完成后回查自动生成的出库/派送及两箱已发运事实,不再调用手工安排派送或确认发车。原键重放和跨操作员拒绝仍由共享装车验证覆盖,原签收不漂移、源订单完成事件审计及资源分别释放仍须独立核对。离线模拟不能代替匹配部署的真实 HTTP。 `fleet-return-redispatch-acceptance.mjs` 的原出发证明读取本轮 `LOADING_COMPLETE` 回执与 `departureDeliveries`;重派在 PDA 装车完成后回查自动生成的出库/派送及两箱已发运事实,不再调用手工安排派送或确认发车。原键重放和跨操作员拒绝仍由共享装车验证覆盖,原签收不漂移、源订单完成事件审计及资源分别释放仍须独立核对。离线模拟不能代替匹配部署的真实 HTTP。
固定托盘造数中,`inbound-measurement.mjs` 只复用 `TP-001`、`YW-TP-002` 指定池中的现有托盘;一只可复用,不要求两只同时存在,两只均缺失才阻断,不自动选择池外托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。 固定托盘造数中,`inbound-measurement.mjs` 只复用 `TP-001`、`YW-TP-002` 指定池中的现有托盘;一只可复用,不要求两只同时存在,两只均缺失才阻断,不自动选择池外托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。
@@ -1,6 +1,11 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { prepare } from './phase3-container-customs-fixture.mjs'
test('fixed single customer blocks mixed customs before creating orders or loading fixtures', async () => {
await assert.rejects(prepare({ options: { fixedCustomerId: '123' } }), /停止订单及装柜造数/)
})
import { verifyFormalDeclarationAndExceptions, verifyMixedReceiptIsolation } from './phase3-container-customs.mjs' import { verifyFormalDeclarationAndExceptions, verifyMixedReceiptIsolation } from './phase3-container-customs.mjs'
import { import {
@@ -236,6 +236,7 @@ async function verifyFrozenSource(context, fixture) {
} }
async function createSecondCustomer(context) { async function createSecondCustomer(context) {
if (context.options?.fixedCustomerId) throw new Error('缺少第二个固定客户,停止跨客户财务写入')
const originalMarker = context.marker const originalMarker = context.marker
const originalContext = context.context?.merchantQualificationFixture const originalContext = context.context?.merchantQualificationFixture
const originalResource = context.createdResources?.merchantQualificationFixture const originalResource = context.createdResources?.merchantQualificationFixture
@@ -4,7 +4,30 @@ import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { buildContainerCostBody, buildSupplierBody, normalizeFinanceFixtureSources, run, import { buildContainerCostBody, buildSupplierBody, normalizeFinanceFixtureSources, run,
supplierCategoryForFeeItem } from './finance-full-fixtures.mjs' supplierCategoryForFeeItem, loadSupplier } from './finance-full-fixtures.mjs'
test('fixed customer supplier selection paginates and never creates a fallback', async () => {
const calls = [], failures = []
const supplier = { id: '123', status: 'ACTIVE', category: 'TRUCKING', name: 'Existing' }
const context = {
options: { fixedCustomerId: '456' }, marker: 'REUSE',
async request(method, path) {
calls.push({ method, path })
const data = path.includes('/123?') ? supplier : path.includes('page=2')
? { records: [supplier], total: 101 }
: { records: Array.from({ length: 100 }, () => ({ id: '99', status: 'ACTIVE', category: 'BOOKING' })), total: 101 }
return { body: { data } }
},
apiSuccess(_module, _name, response) { return response.body.data },
addStep(...args) { failures.push(args) },
}
assert.equal((await loadSupplier(context, 'TRUCKING')).id, '123')
assert.equal(calls.length, 3)
assert.ok(calls.every(call => call.method === 'GET'))
context.request = async method => { assert.equal(method, 'GET'); return { body: { data: { records: [], total: 0 } } } }
assert.equal(await loadSupplier(context, 'TRUCKING'), null)
assert.equal(failures.length, 1)
})
const here = path.dirname(fileURLToPath(import.meta.url)) const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8') const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8')
@@ -258,8 +258,27 @@ async function loadPublishedAutomaticFee(context, resource) {
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm } actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
} }
async function loadSupplier(context, category) { export async function loadSupplier(context, category) {
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category) if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category)
if (context.options?.fixedCustomerId) {
for (let page = 1; ; page += 1) {
const response = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&status=ACTIVE&page=${page}&size=100`)
const data = context.apiSuccess(MODULE, '读取可复用的现有供应商', response, '', OPTIONS)
if (!data) return null
const records = rows(data)
const candidate = records.find(item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category))
if (candidate) {
const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(candidate.id)}?tenantId=0`)
const detail = context.apiSuccess(MODULE, '核对复用供应商状态与类别', detailResponse, '', OPTIONS)
if (id(detail?.id) === id(candidate.id) && detail?.status === 'ACTIVE' && (!category || detail.category === category)) return detail
blocker(context, '复用供应商已变化', '候选详情不匹配,不创建替代供应商')
return null
}
if (!records.length || page * 100 >= Number(data.total ?? records.length)) break
}
blocker(context, '缺少可复用供应商', `需要 ACTIVE ${category || '任意类别'} 供应商;禁止自动创建`)
return null
}
const effectiveCategory = category || 'BOOKING' const effectiveCategory = category || 'BOOKING'
const body = buildSupplierBody(context.marker, effectiveCategory) const body = buildSupplierBody(context.marker, effectiveCategory)
const response = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', body) const response = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', body)
@@ -122,6 +122,7 @@ async function createCustomerWaitSplit(context, suffix, fixedRoute = null) {
} }
async function provisionCrossCustomerPlan(context) { async function provisionCrossCustomerPlan(context) {
if (context.options?.fixedCustomerId) throw new Error('缺少第二个固定客户,停止跨客户柜费造数')
const originalMarker = context.marker const originalMarker = context.marker
const originalUsername = process.env.MERCHANT_TEST_USERNAME const originalUsername = process.env.MERCHANT_TEST_USERNAME
const originalPassword = process.env.MERCHANT_TEST_PASSWORD const originalPassword = process.env.MERCHANT_TEST_PASSWORD
@@ -143,7 +143,7 @@ export async function runQuoteSession(context, { marker = 'FLEET-QUOTE-20261003'
if (!successful(response) || !login?.accessToken || !login?.refreshToken) throw new Error('Independent TEST login failed') if (!successful(response) || !login?.accessToken || !login?.refreshToken) throw new Error('Independent TEST login failed')
context.token = login.accessToken; context.refreshToken = login.refreshToken context.token = login.accessToken; context.refreshToken = login.refreshToken
const prepare = prepareCustomer || (await import('./merchant-qualification-fixture.mjs')).run const prepare = prepareCustomer || (await import('./merchant-qualification-fixture.mjs')).run
await context.executeModule('merchant-qualification-fixture', () => prepare(context, { publishEnvironment: false })) await context.executeModule('merchant-qualification-fixture', () => prepare(context, { publishEnvironment: false, requireDistinctCustomer: false }))
if (context.steps.some(step => !step.passed)) throw new Error('Current-run customer preparation failed; quotes not executed') if (context.steps.some(step => !step.passed)) throw new Error('Current-run customer preparation failed; quotes not executed')
await context.executeModule(MODULE, () => run(context)) await context.executeModule(MODULE, () => run(context))
} catch (error) { fatal = error instanceof Error ? error.message : String(error) } } catch (error) { fatal = error instanceof Error ? error.message : String(error) }
@@ -34,7 +34,7 @@ test('real Jackson long counts accept safe decimal strings without changing pall
function server(fault = '') { function server(fault = '') {
const calls = [], assertions = [], saved = new Map(), loadingActors = new Map() const calls = [], assertions = [], saved = new Map(), loadingActors = new Map()
const detail = { dispatchNo: 'DP-A', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: '9007199254740998', const detail = { dispatchNo: 'DP-A', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: '9007199254740998',
counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {}, counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {}, publishLooseboardDemand: false,
dispatchInfo: { estimatedArrivalAt: '2026-10-21T10:00:00Z', fbaAppointments: [{ appointmentNo: 'FBA-ORIGINAL' }] }, vehicleInfo: {} } dispatchInfo: { estimatedArrivalAt: '2026-10-21T10:00:00Z', fbaAppointments: [{ appointmentNo: 'FBA-ORIGINAL' }] }, vehicleInfo: {} }
const trip = { tripNo: 'TR-A', status: 'PLANNED', warehouseId: '92', recordVersion: 4, tripPurpose: 'DELIVERY', stops } const trip = { tripNo: 'TR-A', status: 'PLANNED', warehouseId: '92', recordVersion: 4, tripPurpose: 'DELIVERY', stops }
let driverVersion = 0, uploadComplete = false, driverReviewed = false, nextPlate = '', checkpointError = false, nextTrip, nextDetail let driverVersion = 0, uploadComplete = false, driverReviewed = false, nextPlate = '', checkpointError = false, nextTrip, nextDetail
@@ -103,6 +103,7 @@ function server(fault = '') {
pallets: [pallet()], missingFields: ['PLT-A:palletSpaces'] }) pallets: [pallet()], missingFields: ['PLT-A:palletSpaces'] })
} }
if (method === 'PUT' && path === D) { if (method === 'PUT' && path === D) {
assert.equal(body.publishLooseboardDemand, detail.publishLooseboardDemand)
if (detail.stage !== 'BEFORE_LOADING') { if (detail.stage !== 'BEFORE_LOADING') {
if (fault === 'edit-mutates-on-rejection') detail.counts.pickedPackageCount-- if (fault === 'edit-mutates-on-rejection') detail.counts.pickedPackageCount--
return conflict('仅装车前可编辑,请显式退回;已经扫码不得改派') return conflict('仅装车前可编辑,请显式退回;已经扫码不得改派')
@@ -180,6 +181,15 @@ function server(fault = '') {
const prepare = s => prepareFleetTrip(s.context, 'native', 'DP-A', '92') const prepare = s => prepareFleetTrip(s.context, 'native', 'DP-A', '92')
test('review preparation uses the current confirmed appointment when arrival is unset', async () => {
const s = server(), at = new Date(Date.now() + 72 * 3600000).toISOString()
s.detail.dispatchInfo.estimatedArrivalAt = null
s.detail.dispatchInfo.fbaAppointments = [{ status: 'PENDING', confirmedAt: null }, { status: 'SUCCESS', confirmedAt: at }]
const proof = await prepare(s)
assert.equal(proof.updateRequest.estimatedArrivalAt, at)
assert.ok(Date.parse(proof.expectedDriverEndAt) > Date.parse(at))
})
function jacksonCountWire(s) { function jacksonCountWire(s) {
const names = new Set(['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', 'scheduledPackages', 'pickedPackages']) const names = new Set(['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', 'scheduledPackages', 'pickedPackages'])
const serialize = value => Array.isArray(value) ? value.map(serialize) const serialize = value => Array.isArray(value) ? value.map(serialize)
@@ -104,11 +104,14 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
}, '建立本轮TEST车辆主档及容量') }, '建立本轮TEST车辆主档及容量')
proof.vehicleId = id(vehicle.id) proof.vehicleId = id(vehicle.id)
if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID') if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID')
await prepareQualifiedDriver(context, module, proof) const confirmedTimes = (before.dispatchInfo?.fbaAppointments || []).filter(row => row.status === 'SUCCESS' && row.confirmedAt).map(row => row.confirmedAt).sort()
const arrival = before.dispatchInfo?.estimatedArrivalAt ?? confirmedTimes[0] ?? null
await prepareQualifiedDriver(context, module, proof, arrival)
proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'), proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'),
publishLooseboardDemand: before.publishLooseboardDemand,
vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId }, vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId },
driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt } }, driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt } },
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: before.dispatchInfo?.estimatedArrivalAt ?? null, plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: arrival,
remark: `${context.marker} TEST合成资料正常履约` } remark: `${context.marker} TEST合成资料正常履约` }
let result = await command(context, module, 'PUT', path, proof.updateRequest, '通过排车主单编辑正式分配车辆司机') let result = await command(context, module, 'PUT', path, proof.updateRequest, '通过排车主单编辑正式分配车辆司机')
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) { for (const action of ['submit-review', 'dispatch-review', 'loading-review']) {
@@ -127,8 +130,8 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
proof.preparation = 'VERIFIED'; await context.checkpoint?.(); return proof proof.preparation = 'VERIFIED'; await context.checkpoint?.(); return proof
} }
async function prepareQualifiedDriver(context, module, proof) { async function prepareQualifiedDriver(context, module, proof, arrival) {
proof.expectedDriverEndAt = new Date(Date.now() + 48 * 3600000).toISOString() proof.expectedDriverEndAt = new Date(Math.max(Date.now() + 48 * 3600000, arrival ? Date.parse(arrival) + 6 * 3600000 : 0)).toISOString()
let driver = await command(context, module, 'POST', `${BASE}/drivers`, { let driver = await command(context, module, 'POST', `${BASE}/drivers`, {
name: `${proof.marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS', name: `${proof.marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
licenseNumber: proof.marker, licenseType: 'Class A', licenseZone: 'UTC', licenseNumber: proof.marker, licenseType: 'Class A', licenseZone: 'UTC',
@@ -1,7 +1,96 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { qualificationCreditCode } from './merchant-qualification-fixture.mjs' import vm from 'node:vm'
import { qualificationCreditCode, normalizeFixedCustomerId, run } from './merchant-qualification-fixture.mjs'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
function fixedContext(profileId = '2100000000000000001') {
const calls = []
return {
calls, marker: 'FIXED-CUSTOMER-TEST', context: {}, createdResources: {},
options: { fixedCustomerId: 'TEN2100000000000000001', merchantUsername: 'fixture-user', merchantPassword: 'fixture-password' },
async request(method, path) {
calls.push({ method, path })
const data = path.endsWith('/login') ? { accessToken: 'fixture-token', refreshToken: 'fixture-refresh' }
: path.endsWith('/profile') ? { id: profileId, status: 'ACTIVE', tenantName: 'Fixture' }
: { status: 'READY' }
return { http: 200, body: { code: 200, data } }
},
apiSuccess(_module, _label, response) { return response.body.data },
assert() {},
}
}
test('fixed customer accepts business number without losing integer precision', () => {
assert.equal(normalizeFixedCustomerId('TEN2100000000000000001'), '2100000000000000001')
assert.throws(() => normalizeFixedCustomerId('TENinvalid'))
})
test('fixed customer preparation only authenticates and reads existing customer', async () => {
const context = fixedContext()
await run(context, { publishEnvironment: false, requireDistinctCustomer: false })
assert.equal(context.context.merchantQualificationFixture.reused, true)
assert.equal(context.context.merchantQualificationFixture.tenantId, '2100000000000000001')
assert.equal(context.context.customerId, '2100000000000000001')
assert.deepEqual(context.calls.filter(call => call.method !== 'GET').map(call => call.path), ['/api/auth/login', '/api/auth/logout'])
assert.equal(context.createdResources.merchantQualificationFixture.password, undefined)
})
test('wrong customer identity stops preparation and logs out', async () => {
const context = fixedContext('2100000000000000002')
await assert.rejects(run(context, { publishEnvironment: false, requireDistinctCustomer: false }), /归属或状态不匹配/)
assert.equal(context.context.merchantQualificationFixture, undefined)
assert.equal(context.calls.at(-1).path, '/api/auth/logout')
assert.equal(context.calls.length, 3)
})
test('second customer fixture cannot silently reuse the first customer', async () => {
const context = fixedContext()
await assert.rejects(run(context, { publishEnvironment: false }), /跨客户场景阻断/)
assert.equal(context.calls.length, 0)
})
test('TEST fixture without fixed customer fails before any request', async () => {
const context = fixedContext()
context.options = { environment: 'test' }
await assert.rejects(run(context), /必须配置固定客户/)
assert.equal(context.calls.length, 0)
})
test('cross-customer financial helpers stop before provisioning or writing', async () => {
for (const [file, name, end] of [
['finance-direct-paid-isolation.mjs', 'createSecondCustomer', 'async function verifyCustomerIsolation'],
['firstleg-container-fee-allocation.mjs', 'provisionCrossCustomerPlan', 'export async function run'],
]) {
const source = readFileSync(new URL(file, import.meta.url), 'utf8')
const helper = source.slice(source.indexOf(`async function ${name}(`), source.indexOf(end))
const scope = vm.createContext({})
vm.runInContext(helper.replaceAll('import.meta.url', "'file:///unused-test-module.mjs'"), scope)
await assert.rejects(scope[name]({ options: { fixedCustomerId: '123' } }), /缺少第二个固定客户/)
}
})
test('fixed resource runner excludes lifecycle handlers without marking them passed', async () => {
const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
let calls = 0
for (const name of ['customers', 'supplemental-boundaries', 'system', 'master-data', 'suppliers', 'notifications', 'pricing',
'phase4-overseas-master-data', 'phase4-overseas-wallet-and-pallet-cost']) {
await runner.executeModule(name, async () => { calls++ })
}
assert.equal(calls, 0)
assert.equal(runner.modules.length, 9)
assert.ok(runner.modules.every(module => module.skipped && !module.success))
})
test('both request entrypoints reject customer creation before network access', async () => {
const runner = new AcceptanceRun({ environment: 'test' })
for (const method of ['request', 'requestWithBody']) {
for (const path of ['/api/platform/v1/iam/tenants', '/api/platform/v1/iam/tenants/123/archive', '/api/platform/v1/iam/tenants/123/submit-review']) {
await assert.rejects(runner[method]('POST', path, {}), /禁止创建、归档客户/)
}
}
})
const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8') const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8')
@@ -38,7 +38,46 @@ async function waitForDimensionInitialization(context, tenantId) {
if (status !== 'READY') throw new Error(`客户默认尺寸初始化未就绪: ${status}`) if (status !== 'READY') throw new Error(`客户默认尺寸初始化未就绪: ${status}`)
} }
export async function run(context, { publishEnvironment = true } = {}) { export function normalizeFixedCustomerId(value) {
const text = String(value || '').trim().replace(/^TEN/, '')
if (!/^[1-9]\d{0,19}$/.test(text)) throw new Error('固定客户编号必须为 TEN 编号或数字 ID')
return text
}
async function reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer) {
if (requireDistinctCustomer) throw new Error('固定客户模式未配置第二个客户,跨客户场景阻断;禁止自动建客或用同一客户替代隔离验证')
const tenantId = normalizeFixedCustomerId(context.options.fixedCustomerId)
const username = context.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME
const password = context.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD
if (!username || !password) throw new Error('请配置固定客户的 MERCHANT_TEST_USERNAME / MERCHANT_TEST_PASSWORD;不会新建客户或重置密码')
const loginResponse = await context.request('POST', '/api/auth/login', {loginId:username,password}, '')
const login = context.apiSuccess(MODULE, '登录已配置的固定客户账号', loginResponse)
if (!login?.accessToken) throw new Error('固定客户账号登录失败')
let profile
try {
const response = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, login.accessToken)
profile = context.apiSuccess(MODULE, '核对固定客户账号实际归属和状态', response)
const valid = id(profile?.id) === tenantId && profile?.status === 'ACTIVE'
context.assert(MODULE, '固定客户账号属于指定已审核客户', response, valid,
`expected=${tenantId}, actual=${id(profile?.id)}, status=${profile?.status}`, {deploymentBlocker:true})
if (!valid) throw new Error('固定客户账号归属或状态不匹配,停止造数;不会新建客户')
await waitForDimensionInitialization(context, tenantId)
} finally {
await context.request('POST', '/api/auth/logout', {refreshToken:login.refreshToken}, login.accessToken)
}
if (publishEnvironment) {
process.env.MERCHANT_TEST_USERNAME = username
process.env.MERCHANT_TEST_PASSWORD = password
}
const fixture = {tenantId,tenantName:profile.tenantName,username,marker:context.marker,reused:true}
context.context.merchantQualificationFixture = {...fixture,password}
context.context.customerId = tenantId
context.createdResources.merchantQualificationFixture = fixture
}
export async function run(context, { publishEnvironment = true, requireDistinctCustomer = !publishEnvironment } = {}) {
if (context.options?.fixedCustomerId) return reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer)
if (context.options?.environment === 'test') throw new Error('TEST 必须配置固定客户,禁止自动建客')
const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0') const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0')
const customerName = String(context.options?.customerName || '').trim() const customerName = String(context.options?.customerName || '').trim()
const tenantName = customerName || `全量验收下单客户-${suffix}` const tenantName = customerName || `全量验收下单客户-${suffix}`
@@ -140,7 +140,7 @@ export async function createCompletedOrder(context, runFulfillment = async scope
return run(scope) return run(scope)
}, prepareCustomer = async scope => { }, prepareCustomer = async scope => {
const { run } = await import('./merchant-qualification-fixture.mjs') const { run } = await import('./merchant-qualification-fixture.mjs')
return run(scope, { publishEnvironment: false }) return run(scope, { publishEnvironment: false, requireDistinctCustomer: false })
}) { }) {
const scope = Object.create(context) const scope = Object.create(context)
scope.marker = `${context.marker}-COMPLETED` scope.marker = `${context.marker}-COMPLETED`
@@ -75,6 +75,16 @@ async function createFixtureCustomer(context) {
} }
export async function run(context) { export async function run(context) {
if (context.options?.fixedCustomerId) {
const tenantId = context.context.merchantQualificationFixture?.tenantId
if (!tenantId) throw new Error('固定客户前置未完成,禁止创建海外仓费用客户')
const response = await context.request('GET', `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/overseas-storage-fees`)
const view = context.apiSuccess(MODULE, '读取固定客户海外仓费用配置', response)
context.assert(MODULE, '费用配置属于固定客户', response, id(view?.customerId) === id(tenantId), `customerId=${id(view?.customerId)}`, OPTIONS)
context.addCoverageGap(MODULE, SCENARIO, '固定客户模式排除费用规则破坏性生命周期',
'只读现有费用配置,不覆盖共享客户费率或归档客户', 'not-implemented', {blocking:false})
return
}
const start = context.steps.length const start = context.steps.length
// Always create a module-owned customer. The customers module may archive its // Always create a module-owned customer. The customers module may archive its
// own fixture, and this lifecycle must never depend on that or on history. // own fixture, and this lifecycle must never depend on that or on history.
@@ -260,6 +260,7 @@ export async function withPlatformBookingScope(context, runLoading) {
} }
export async function prepare(context) { export async function prepare(context) {
if (context.options?.fixedCustomerId) throw new Error('混合报关柜需要第二个固定客户,停止订单及装柜造数')
const outerMarker = context.marker const outerMarker = context.marker
const isolatedResourceKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'] const isolatedResourceKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures']
const isolatedResourceSnapshot = new Map(isolatedResourceKeys.map((name) => [name, { const isolatedResourceSnapshot = new Map(isolatedResourceKeys.map((name) => [name, {
@@ -3,10 +3,21 @@ import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { run } from './phase4-overseas-finance-cross-region.mjs' import { run, selectCurrentInboundPlan } from './phase4-overseas-finance-cross-region.mjs'
const source = readFileSync(fileURLToPath(new URL('./phase4-overseas-finance-cross-region.mjs', import.meta.url)), 'utf8') const source = readFileSync(fileURLToPath(new URL('./phase4-overseas-finance-cross-region.mjs', import.meta.url)), 'utf8')
test('inbound fees cannot select historical plans or another customer under a fixed account', () => {
const current = { planId: '4001', planNo: 'CURRENT', customerId: '2001' }
const old = { planId: '3999', planNo: 'OLD', customerId: '2001' }
const pda = { marker: 'RUN', customerId: '2001', generatedPlanIds: ['4001'] }
assert.equal(selectCurrentInboundPlan([old, current], pda, 'RUN'), current)
assert.equal(selectCurrentInboundPlan([old], pda, 'RUN'), null)
assert.equal(selectCurrentInboundPlan([current], pda, 'OTHER'), null)
assert.equal(selectCurrentInboundPlan([{ ...current, customerId: '2002' }], pda, 'RUN'), null)
assert.equal(selectCurrentInboundPlan([current], { ...pda, generatedPlanIds: [] }, 'RUN'), null)
})
function response(data, code = '200', http = 200, region) { function response(data, code = '200', http = 200, region) {
return { return {
http, http,
@@ -40,7 +51,7 @@ function fixture() {
truck: { orderNo: 'TK-1', customerId: '1201' }, truck: { orderNo: 'TK-1', customerId: '1201' },
looseboard: { orderNo: 'LB-1', customerId: '1301' }, looseboard: { orderNo: 'LB-1', customerId: '1301' },
}, },
phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' }, phase4OverseasPda: { marker, generatedPlanIds: ['4001'], customerId: '2001', overseasWarehouseId: '9001' },
}, },
async request(...args) { async request(...args) {
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1])); const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
@@ -56,6 +56,13 @@ async function resolveListedBusiness(context, base, resource, customerId, label)
return longId(row?.id) ? { businessId: id(row.id), businessNo: row.orderNo, customerId } : null return longId(row?.id) ? { businessId: id(row.id), businessNo: row.orderNo, customerId } : null
} }
export function selectCurrentInboundPlan(plans, pda, marker) {
if (!pda || pda.marker !== marker || !longId(pda.customerId)) return null
const ids = new Set(rows(pda.generatedPlanIds).map(id))
return rows(plans).find(item => ids.has(id(item?.planId || item?.id))
&& id(item?.customerId) === id(pda.customerId) && item?.planNo) || null
}
async function resolveFixtures(context) { async function resolveFixtures(context) {
const business = context.createdResources?.phase4OverseasBusiness const business = context.createdResources?.phase4OverseasBusiness
const merchantCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId const merchantCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId
@@ -76,7 +83,7 @@ async function resolveFixtures(context) {
if (longId(inboundCustomerId)) { if (longId(inboundCustomerId)) {
const listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划', const listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划',
`/api/platform/v1/last-leg/inbound-plans?page=1&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`) `/api/platform/v1/last-leg/inbound-plans?page=1&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`)
const plan = rows(listed.data).find((item) => longId(item?.planId || item?.id) && item?.planNo) const plan = selectCurrentInboundPlan(listed.data, pda, context.marker)
context.assert(MODULE, '海外入仓费用关联真实计划', listed.response, context.assert(MODULE, '海外入仓费用关联真实计划', listed.response,
Boolean(plan) && longId(plan?.planId || plan?.id), Boolean(plan) && longId(plan?.planId || plan?.id),
`planId=${id(plan?.planId || plan?.id) || 'missing'}, planNo=${plan?.planNo || 'missing'}; ${trace(listed.response)}`, OPTIONS) `planId=${id(plan?.planId || plan?.id) || 'missing'}, planNo=${plan?.planNo || 'missing'}; ${trace(listed.response)}`, OPTIONS)
@@ -161,5 +161,23 @@ test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径',
assert.doesNotMatch(source, /创建本轮换标单|verifyRelabelLifecycle/) assert.doesNotMatch(source, /创建本轮换标单|verifyRelabelLifecycle/)
assert.match(source, /创建本轮公共库位/) assert.match(source, /创建本轮公共库位/)
assert.match(source, /公共托盘旧版本编辑返回冲突/) assert.match(source, /公共托盘旧版本编辑返回冲突/)
assert.doesNotMatch(source, /deploymentBlocker|addCoverageGap|coverageGap/) assert.doesNotMatch(source, /deploymentBlocker/)
assert.match(source, /固定资源模式缺少海外仓,禁止自动创建仓库/)
})
test('fixed customer mode reuses warehouse without location or pallet lifecycle writes', async () => {
const context = contextFixture()
context.options = { fixedCustomerId: '10001' }
context.context.merchantQualificationFixture = { tenantId: '10001' }
await run(context)
assert.equal(context.context.phase4OverseasWarehouse.customerId, '10001')
assert.equal(context.context.phase4OverseasWarehouse.warehouseId, '20001')
assert.ok(context.calls.filter(call => call.method !== 'GET')
.every(call => call.method === 'POST' && call.path === '/api/platform/v1/files/export-tasks'))
assert.equal(context.gaps.length, 1)
const missing = contextFixture({ warehouseRecords: [] })
missing.options = context.options
missing.context.merchantQualificationFixture = { tenantId: '10001' }
await assert.rejects(run(missing), /禁止自动创建仓库/)
assert.equal(missing.calls.some(call => call.method === 'POST' && call.path === '/api/platform/v1/warehouse-admin'), false)
}) })
@@ -67,7 +67,10 @@ export async function run(context) {
context.createdResources.phase4OverseasWarehouse = { marker: context.marker, customerId: null, warehouseId: null, palletId: null, terminalStatus: null } context.createdResources.phase4OverseasWarehouse = { marker: context.marker, customerId: null, warehouseId: null, palletId: null, terminalStatus: null }
const tenantsResult = await domesticGet(context, '查询可用客户候选', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE') const tenantsResult = await domesticGet(context, '查询可用客户候选', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE')
assertPage(context, '可用客户候选', tenantsResult) assertPage(context, '可用客户候选', tenantsResult)
const customer = records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE') const fixedCustomer = context.context?.merchantQualificationFixture
const customer = context.options?.fixedCustomerId
? { id: fixedCustomer?.tenantId, status: fixedCustomer?.tenantId ? 'ACTIVE' : null }
: records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE')
const customerId = id(customer?.id) const customerId = id(customer?.id)
if (!customerId) { if (!customerId) {
context.assert(MODULE, '海外仓验收必须取得ACTIVE客户', tenantsResult.response, false, context.assert(MODULE, '海外仓验收必须取得ACTIVE客户', tenantsResult.response, false,
@@ -151,6 +154,7 @@ export async function run(context) {
let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId) let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId)
let ownedWarehouse = false let ownedWarehouse = false
if (!warehouse) { if (!warehouse) {
if (context.options?.fixedCustomerId) throw new Error('固定资源模式缺少海外仓,禁止自动创建仓库')
warehouse = await createWarehouseFixture(context, customerId) warehouse = await createWarehouseFixture(context, customerId)
ownedWarehouse = Boolean(warehouse) ownedWarehouse = Boolean(warehouse)
} }
@@ -185,6 +189,12 @@ export async function run(context) {
`listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) `listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
} }
if (context.options?.fixedCustomerId) {
context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: null }
context.addCoverageGap(MODULE, 'resource-lifecycle', '复用海外仓资源,仅核验列表和详情',
'不创建或修改公共库位及托盘;业务分配由后续实际库存流程决定', 'not-implemented', { blocking: false })
return
}
try { try {
await verifyLocationLifecycle(context, warehouseId) await verifyLocationLifecycle(context, warehouseId)
await verifyPalletLifecycle(context, warehouseId) await verifyPalletLifecycle(context, warehouseId)
@@ -7,6 +7,28 @@ import { inflateRawSync, deflateRawSync } from 'node:zlib'
const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8') const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8')
test('fixed Client authenticates and verifies ownership without registration', async () => {
const calls = []
const scope = vm.createContext({ username: 'fixture', clientPassword: 'test-password', fixedCustomerId: '123',
report: { resources: {} }, assertEqual: assert.equal,
requireSuccess: json => assert.equal(json.code, 200), assertPresent: assert.ok,
async apiStep(_module, _name, request, validate) {
calls.push(request)
const json = { code: 200, data: request.path.endsWith('/login')
? { accessToken: 'test-token', refreshToken: 'test-refresh' }
: { id: '123', status: 'ACTIVE', tenantName: 'Fixture' } }
validate({ json }); return { json }
},
})
const helper = source.slice(source.indexOf('async function reuseFixedClient()'), source.indexOf('async function registrationModule()'))
vm.runInContext(`let merchantToken, merchantRefreshToken, companyName; ${helper}`, scope)
await scope.reuseFixedClient()
assert.deepEqual(calls.map(call => call.path), ['/api/auth/login', '/api/merchant/v1/iam/tenant/profile'])
assert.equal(scope.report.resources.tenantId, '123')
scope.fixedCustomerId = '456'
await assert.rejects(scope.reuseFixedClient(), /fixed customer identity/)
})
test('client registration distinguishes incomplete-registration replay from credential conflicts', () => { test('client registration distinguishes incomplete-registration replay from credential conflicts', () => {
assert.match(source, /same credentials replay the incomplete registration/) assert.match(source, /same credentials replay the incomplete registration/)
assert.match(source, /\['accountId', 'tenantId', 'membershipId'\]/) assert.match(source, /\['accountId', 'tenantId', 'membershipId'\]/)
@@ -510,12 +532,12 @@ test('F01 client bills module covers customer-only read APIs and rejects unknown
assert.match(source, /async function merchantFinanceBillsModule\(\)/) assert.match(source, /async function merchantFinanceBillsModule\(\)/)
assert.match(source, /await merchantFinanceBillsModule\(\)/) assert.match(source, /await merchantFinanceBillsModule\(\)/)
const callOrder = [ const callOrder = [
source.indexOf('await orderSubmissionChannelGateModule()'), source.indexOf('await orderExcelImportModule()'),
source.indexOf('await merchantFinanceBillsModule()'), source.indexOf('await merchantFinanceBillsModule()'),
source.indexOf('await notificationInboxModule()'), source.indexOf('await merchantOverseasWarehouseModule()'),
] ]
assert.ok(callOrder[0] >= 0 && callOrder[1] > callOrder[0] && callOrder[2] > callOrder[1], assert.ok(callOrder[0] >= 0 && callOrder[1] > callOrder[0] && callOrder[2] > callOrder[1],
'bills module must run after channel gate and before notifications') 'fixed customer bills module runs after orders and before overseas queries')
assert.match(source, /const base = '\/api\/client\/finance\/v1'/) assert.match(source, /const base = '\/api\/client\/finance\/v1'/)
for (const path of ['/bills', '/statements', '/receipts', '/receivable-settlements', '/confirm']) { for (const path of ['/bills', '/statements', '/receipts', '/receivable-settlements', '/confirm']) {
assert.ok(source.includes(path), path) assert.ok(source.includes(path), path)
@@ -535,10 +557,10 @@ test('F02 client overseas self-service covers customer-scoped lists and the retu
const callOrder = [ const callOrder = [
source.indexOf('await merchantFinanceBillsModule()'), source.indexOf('await merchantFinanceBillsModule()'),
source.indexOf('await merchantOverseasWarehouseModule()'), source.indexOf('await merchantOverseasWarehouseModule()'),
source.indexOf('await notificationInboxModule()'), source.indexOf('await merchantCrossBorderModule()'),
] ]
assert.ok(callOrder[0] >= 0 && callOrder[1] > callOrder[0] && callOrder[2] > callOrder[1], assert.ok(callOrder[0] >= 0 && callOrder[1] > callOrder[0] && callOrder[2] > callOrder[1],
'overseas self-service must run after bills and before notifications') 'overseas self-service runs after bills and before cross-border flow')
for (const path of ['/api/merchant/v1/warehouses', '/return-orders', '/relabel-applications', for (const path of ['/api/merchant/v1/warehouses', '/return-orders', '/relabel-applications',
'/warehouse-transfers', '/cargo-discards', '/return-inventory', '/return-inventory/summary', '/warehouse-transfers', '/cargo-discards', '/return-inventory', '/return-inventory/summary',
'/return-inventory/cartons']) { '/return-inventory/cartons']) {
@@ -559,10 +581,10 @@ test('F03 client cross-border, warehouse orders and pricing/customs cover curren
assert.match(source, new RegExp(`await ${name}\\(\\)`), name) assert.match(source, new RegExp(`await ${name}\\(\\)`), name)
} }
const order = ['merchantOverseasWarehouseModule', 'merchantCrossBorderModule', const order = ['merchantOverseasWarehouseModule', 'merchantCrossBorderModule',
'merchantWarehouseOrdersModule', 'merchantPricingCustomsModule', 'notificationInboxModule'] 'merchantWarehouseOrdersModule', 'merchantPricingCustomsModule']
.map((name) => source.indexOf(`await ${name}()`)) .map((name) => source.indexOf(`await ${name}()`))
assert.ok(order.every((index) => index >= 0) && order.every((index, position) => position === 0 || index > order[position - 1]), assert.ok(order.every((index) => index >= 0) && order.every((index, position) => position === 0 || index > order[position - 1]),
'new client modules must run in order before notifications') 'fixed customer business modules preserve their dependency order')
for (const path of ['/us-truck-orders', '/local-looseboard-orders', '/overseas-procurements', for (const path of ['/us-truck-orders', '/local-looseboard-orders', '/overseas-procurements',
'/inbound-plans', '/delivery-orders', '/first-leg/pricing/channels', '/customs/pending-summary', '/inbound-plans', '/delivery-orders', '/first-leg/pricing/channels', '/customs/pending-summary',
'/customs/orders/']) { '/customs/orders/']) {
+44 -18
View File
@@ -17,8 +17,10 @@ import { randomUUID, createHash } from 'node:crypto'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { createInterface } from 'node:readline/promises' import { createInterface } from 'node:readline/promises'
import { inflateRawSync, deflateRawSync } from 'node:zlib' import { inflateRawSync, deflateRawSync } from 'node:zlib'
import { normalizeFixedCustomerId } from './admin-test-env-modules/merchant-qualification-fixture.mjs'
const args = parseArgs(process.argv.slice(2)) const args = parseArgs(process.argv.slice(2))
const fixedCustomerId = normalizeFixedCustomerId(process.env.ADMIN_TEST_FIXED_CUSTOMER_ID)
if ((args.environment && args.environment !== 'test') || args['overseas-gateway'] || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Only one TEST gateway is supported; remove regional options') if ((args.environment && args.environment !== 'test') || args['overseas-gateway'] || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Only one TEST gateway is supported; remove regional options')
const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY) const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY)
const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME
@@ -26,9 +28,9 @@ const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USE
const platformPassword = args['platform-password'] ?? process.env.ADMIN_TEST_PASSWORD const platformPassword = args['platform-password'] ?? process.env.ADMIN_TEST_PASSWORD
?? await promptSecret('平台审核密码') ?? await promptSecret('平台审核密码')
const existingClientUsername = args['existing-client-username'] ?? process.env.MERCHANT_TEST_USERNAME const existingClientUsername = args['existing-client-username'] ?? process.env.MERCHANT_TEST_USERNAME
?? await promptText('跨客户隔离测试账号(必须由正式注册/建档产生)', '') ?? await promptText('固定客户测试账号', '')
const existingClientPassword = args['existing-client-password'] ?? process.env.MERCHANT_TEST_PASSWORD const existingClientPassword = args['existing-client-password'] ?? process.env.MERCHANT_TEST_PASSWORD
?? await promptSecret('跨客户隔离测试密码') ?? await promptSecret('固定客户测试密码')
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports') const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports')
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
@@ -43,12 +45,12 @@ if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
const now = new Date() const now = new Date()
const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}` const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}`
const username = `client_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64) const username = existingClientUsername
const temporaryUsername = `renamed_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64) const temporaryUsername = `renamed_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64)
const clientPassword = `Client${randomUUID().replaceAll('-', '').slice(0, 14)}9!` const clientPassword = existingClientPassword
const phoneSuffix = `${Date.now()}`.slice(-8) const phoneSuffix = `${Date.now()}`.slice(-8)
const legalPhone = `139${phoneSuffix}` const legalPhone = `139${phoneSuffix}`
const companyName = `客户端全链路测试企业-${marker}`.slice(0, 128) let companyName = ''
const creditCode = `91${compactTimestamp(now)}${randomUUID().replaceAll('-', '').slice(0, 2).toUpperCase()}`.slice(0, 18) const creditCode = `91${compactTimestamp(now)}${randomUUID().replaceAll('-', '').slice(0, 2).toUpperCase()}`.slice(0, 18)
const reportPath = resolve(reportDir, `client-test-env-${marker}.json`) const reportPath = resolve(reportDir, `client-test-env-${marker}.json`)
@@ -58,6 +60,7 @@ const report = {
generatedAt: now.toISOString(), generatedAt: now.toISOString(),
completedAt: null, completedAt: null,
success: false, success: false,
fullCoverage: false,
fatal: null, fatal: null,
testAccount: { username, companyName, legalPhone }, testAccount: { username, companyName, legalPhone },
resources: { resources: {
@@ -94,33 +97,34 @@ let ownerPasswordNeedsRestore = false
let temporaryOwnerPassword let temporaryOwnerPassword
try { try {
await registrationModule() await reuseFixedClient()
await platformReviewerLogin() await platformReviewerLogin()
await merchantBeforeApprovalModule() for (const module of ['registration', 'qualification-review', 'profile-management', 'login-identifiers',
await qualificationAndReviewModule() 'rbac-members', 'isolation', 'order-submission-balance', 'order-mutation-evidence',
await merchantAfterApprovalModule() 'order-submission-channel', 'notification-inbox', 'account-security']) {
await profileManagementModule() recordSkipped(module, '固定客户模式排除生命周期或共享配置写入',
await loginIdentifierModule() '不注册客户、不修改固定账号资料或余额、不发布通知;跨客户场景需第二个固定客户')
await rbacAndMemberModule() }
await orderLifecycleModule() await orderLifecycleModule()
await isolationModule()
await orderSubmissionBalanceGateModule()
await orderMutationEvidenceModule()
await orderExcelImportModule() await orderExcelImportModule()
await orderSubmissionChannelGateModule()
await merchantFinanceBillsModule() await merchantFinanceBillsModule()
await merchantOverseasWarehouseModule() await merchantOverseasWarehouseModule()
await merchantCrossBorderModule() await merchantCrossBorderModule()
await merchantWarehouseOrdersModule() await merchantWarehouseOrdersModule()
await merchantPricingCustomsModule() await merchantPricingCustomsModule()
await notificationInboxModule()
await accountSecurityModule()
report.success = report.steps.every((step) => step.passed) report.success = report.steps.every((step) => step.passed)
report.fullCoverage = !report.steps.some((step) => step.skipped)
} catch (error) { } catch (error) {
report.fatal = sanitizeError(error) report.fatal = sanitizeError(error)
report.success = false report.success = false
} finally { } finally {
if (ownerPasswordNeedsRestore) await recoverOwnerPassword() if (ownerPasswordNeedsRestore) await recoverOwnerPassword()
if (merchantToken) {
const logout = await softApiStep('fixed-customer', '退出固定客户测试会话', {
method: 'POST', path: '/api/auth/logout', token: merchantToken, body: { refreshToken: merchantRefreshToken },
}, ({ json }) => requireSuccess(json))
if (!logout) report.success = false
}
report.completedAt = new Date().toISOString() report.completedAt = new Date().toISOString()
await mkdir(reportDir, { recursive: true }) await mkdir(reportDir, { recursive: true })
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
@@ -129,6 +133,24 @@ try {
process.exitCode = report.success ? 0 : 1 process.exitCode = report.success ? 0 : 1
async function reuseFixedClient() {
const login = await apiStep('fixed-customer', '登录固定客户', {
method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword },
}, ({ json }) => { requireSuccess(json); assertPresent(json.data?.accessToken, 'accessToken') })
merchantToken = login.json.data.accessToken
merchantRefreshToken = login.json.data.refreshToken
const profile = await apiStep('fixed-customer', '校验固定客户归属及状态', {
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.id), fixedCustomerId, 'fixed customer identity')
assertEqual(json.data?.status, 'ACTIVE', 'fixed customer status')
})
report.resources.tenantId = fixedCustomerId
companyName = profile.json.data.tenantName
report.testAccount = { username, reused: true }
}
async function registrationModule() { async function registrationModule() {
const registrationBody = { const registrationBody = {
username, username,
@@ -3360,6 +3382,10 @@ async function softApiStep(module, name, request, validate, evidenceBuilder = de
} }
async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) { async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) {
if (String(method).toUpperCase() === 'POST'
&& ['/api/auth/register', '/api/platform/v1/iam/tenants'].includes(new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, ''))) {
throw new Error('固定客户模式禁止注册或创建客户')
}
const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers } const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers }
if (token) requestHeaders.Authorization = `Bearer ${token}` if (token) requestHeaders.Authorization = `Bearer ${token}`
let requestBody = body let requestBody = body
+156
View File
@@ -0,0 +1,156 @@
import assert from 'node:assert/strict'
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
import { resolve, join } from 'node:path'
import { tmpdir } from 'node:os'
import { createHash } from 'node:crypto'
import { pathToFileURL } from 'node:url'
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
import { run as fixedCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs'
import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs'
import { prepareFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { prepareBolAppointments } from './admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs'
const ROOT = '/api/platform/v1/last-leg', PDA = '/api/pda/v1/last-leg'
const slots = ['LOADING_SCENE','LOADING_LIST','PRE_DEPARTURE_CHECK','DRIVER_LICENSE','RECEIPT']
const id = value => String(value ?? '')
const hash = bytes => createHash('sha256').update(bytes).digest('hex')
export function parseFlowArguments(args) {
const config = { execute: false, keepPda: false }
for(let i=0;i<args.length;i++) {
if(args[i]==='--execute')config.execute=true
else if(args[i]==='--keep-pda-for-ui')config.keepPda=true
else {
const key={'--run-id':'runId','--warehouse-id':'warehouseId','--destination-warehouse-id':'destinationId','--test-image':'image','--resume-report':'resumeReport'}[args[i]]
if(!key || config[key] || !args[i+1] || args[i+1].startsWith('--'))throw Error('Unsupported or duplicate argument')
config[key]=args[++i]
}
}
if(config.execute && (!/^[A-Za-z0-9_-]{1,48}$/.test(config.runId || '') || !/^[1-9]\d*$/.test(config.warehouseId || '') || !/^[1-9]\d*$/.test(config.destinationId || '') || !config.image))throw Error('Explicit run, warehouses and synthetic image required')
return config
}
export async function runLoadingFlow(config, env=process.env) {
if(!config.execute)return {execute:false,scope:'TEST fixed customer, two received pallets, confirmed FBA appointment, reviews, picks, five photos, automatic outbounds/deliveries; optional UI session handoff. No SQL, reset or deployment.'}
if(env.CHONGCHONG_ENV!=='test' || !env.ADMIN_TEST_FIXED_CUSTOMER_ID)throw Error('Explicit TEST and configured fixed customer required')
const directory=resolve('reports',`dispatch-loading-${config.runId}`)
if(existsSync(directory))throw Error('Existing attempt: inspect original report; never blindly replay')
const image=readFileSync(config.image);assert.equal(image.subarray(1,4).toString(),'PNG')
mkdirSync(directory,{recursive:true});mkdirSync(join(directory,'requests'))
const base=new URL(env.ADMIN_TEST_GATEWAY)
const options=validateExecutionOptions({...parseArguments(['--modules','merchant-qualification-fixture','--quiet','true']),gateway:base.origin,username:env.ADMIN_TEST_USERNAME,password:env.ADMIN_TEST_PASSWORD,environment:'test',output:directory})
const context=new AcceptanceRun(options), sessions=[], secrets=[], report={environment:'TEST',marker:context.marker,syntheticTestMaterials:true,hardwareVerified:false,members:[],checks:[],success:false}
const previous=config.resumeReport ? JSON.parse(readFileSync(config.resumeReport,'utf8')) : null
if(previous){assert.equal(previous.environment,'TEST');assert.equal(previous.customerId,env.ADMIN_TEST_FIXED_CUSTOMER_ID);assert(previous.dispatchNo && previous.members.length===2 && previous.members.every(m=>m.receiptVerified && m.customerId===previous.customerId));report.members=previous.members;report.dispatchNo=previous.dispatchNo;report.resumedFrom=config.resumeReport}
let sequence=0,picker
const clean=value=>Array.isArray(value)?value.map(clean):value && typeof value==='object'?Object.fromEntries(Object.entries(value).map(([key,v])=>[key,/password|token|authorization/i.test(key)?'[REDACTED]':clean(v)])):typeof value==='string' && secrets.includes(value)?'[REDACTED]':value
const save=()=>writeFileSync(join(directory,'report.json'),JSON.stringify(clean({...report,requestCount:sequence,createdResources:context.createdResources}),null,2))
context.checkpoint=async()=>save()
const check=(label,condition)=>{report.checks.push({label,passed:!!condition});save();assert(condition,label)}
const key=action=>hash(`${context.marker}:${config.runId}:${action}`)
for(const method of ['request','requestMultipart']) {
const original=context[method].bind(context)
context[method]=async(...args)=>{
const file=join(directory,'requests',`${++sequence}.json`), record={method:args[0],path:args[1],body:method==='request'?clean(args[2]):{fileName:args[2]?.name},stage:'INTENT'}
writeFileSync(file,JSON.stringify(record,null,2))
try{const response=await original(...args);if(args[1]==='/api/auth/login' && response.body?.data?.accessToken){secrets.push(response.body.data.accessToken,response.body.data.refreshToken);sessions.push(response.body.data)}record.stage='RESPONSE';record.response=clean(response);writeFileSync(file,JSON.stringify(record,null,2));save();return response}
catch(error){record.stage='UNKNOWN';record.error=error.message;writeFileSync(file,JSON.stringify(record,null,2));throw error}
}
}
const send=async(method,path,body,token=context.token,deny=false)=>{
const response=await context.request(method,path,body,token,{})
if(deny){check(`rejected ${path}`,id(response.body?.code)==='A0409');return response.body}
if(response.http!==200 || id(response.body?.code)!=='200')throw Error(`${method} ${path}: HTTP ${response.http}, ${response.body?.code}, ${response.body?.message}, requestId=${response.requestId}`)
return response.body.data
}
const upload=async(path,name,token=context.token)=>{
const r=await context.requestMultipart('POST',path,{name,type:'image/png',content:image},token)
if(r.http!==200 || id(r.body?.code)!=='200')throw Error(`Upload failed ${path}: ${r.body?.message}, requestId=${r.requestId}`)
return r.body.data
}
const phase=value=>{report.phase=value;save();console.log(JSON.stringify({phase:value,dispatchNo:report.dispatchNo}))}
try {
const auth=await send('POST','/api/auth/login',{loginId:options.username,password:options.password},'');context.token=auth.accessToken;context.refreshToken=auth.refreshToken
await fixedCustomer(context,{requireDistinctCustomer:false});const customer=context.context.merchantQualificationFixture
const merchant=await send('POST','/api/auth/login',{loginId:customer.username,password:customer.password},'');delete context.context.merchantQualificationFixture
report.customerId=id(customer.tenantId)
const access=await resolvePickerAccess(context);assert(access,'Existing PDA role missing')
picker=await createPicker(context,82,config.warehouseId,access);assert(picker?.token,'PDA login failed');secrets.push(picker.password)
const master=await send('GET',`/api/platform/v1/warehouse-admin/${config.destinationId}?tenantId=0`)
assert(master.warehouse.status==='ACTIVE' && master.warehouse.warehouseType==='FBA' && master.warehouse.timezone.includes('/'))
const address=master.addresses.find(a=>a.addressLine1), own=`?customerId=${report.customerId}`
if(!previous){
phase('RECEIVING_TWO_TEST_PALLETS')
for(const suffix of ['A','B']) {
const m={customerId:report.customerId,marker:context.marker,receiptVerified:false};report.members.push(m)
const merchantRoot='/api/merchant/v1/last-leg/warehouse-transfers'
const body={sourceLastlegOrderIds:[],operatingWarehouseId:config.warehouseId,fbaWarehouseCode:master.warehouse.warehouseCode,destinationType:'FBA',destinationWarehouseId:config.destinationId,destinationWarehouseCode:master.warehouse.warehouseCode,destinationWarehouseName:master.warehouse.warehouseName,deliveryAddress:[address.countryCode,address.city,address.addressLine1,address.postalCode].filter(Boolean).join(' '),packageCount:1,volumeCbm:1,deliveryMethod:'CUSTOMER_DELIVERY',estimatedArrivalAt:new Date(Date.now()+3600000).toISOString().slice(0,19),needRelabel:false,remark:`${context.marker} TEST synthetic ${suffix}`,idempotencyKey:key(`CREATE-${suffix}`),fbaShipmentNo:`TEST-FBA-${config.runId}-${suffix}`,items:[{skuCode:`TEST-${config.runId}-${suffix}`,productNameZh:'TEST验收货物',packageCount:1,quantity:1}]}
let incoming=await send('POST',merchantRoot,body,merchant.accessToken);m.incomingNo=incoming.transferNo;save()
const mp=`${merchantRoot}/${encodeURIComponent(m.incomingNo)}`, pp=`${PDA}/warehouse-transfers/${encodeURIComponent(m.incomingNo)}`
await send('POST',mp+'/submit',undefined,merchant.accessToken)
await send('POST',`${ROOT}/warehouse-transfers/${encodeURIComponent(m.incomingNo)}/review${own}`,{decision:'APPROVE',remark:context.marker})
const task=await send('GET',pp,undefined,picker.token);assert.equal(task.cartons.length,1)
const draft=await send('POST',pp+'/pallets/drafts',{idempotencyKey:key(`DRAFT-${suffix}`)},picker.token), palletPath=pp+'/pallets/'+draft.pallets[0].id
const generated=await send('POST',palletPath+'/generate',{idempotencyKey:key(`GENERATE-${suffix}`)},picker.token)
m.palletId=id(generated.pallets[0].warehousePalletId);m.palletNo=generated.pallets[0].palletNo
await send('POST',palletPath+'/bind',{palletCode:m.palletNo,idempotencyKey:key(`BIND-${suffix}`)},picker.token)
await send('POST',pp+'/cartons/count',{idempotencyKey:key(`COUNT-${suffix}`),forecastPackageCount:1,boxes:[{cartonMark:task.cartons[0].cartonMark,transferItemId:id(task.items[0].id),transferPalletId:id(draft.pallets[0].id),length:100,width:100,height:100,weight:100,items:[{skuCode:task.items[0].skuCode,quantity:1}]}]},picker.token)
const photo=await upload(pp+'/measurement-photos/files',`${config.runId}-TEST-measurement.png`,picker.token)
await send('POST',pp+'/palletize',{idempotencyKey:key(`PALLETIZE-${suffix}`),photoFileIds:[id(photo.fileId)]},picker.token)
incoming=await send('POST',mp+'/confirm-measurement',{idempotencyKey:key(`CUSTOMER-${suffix}`),remark:context.marker},merchant.accessToken)
check('received '+m.incomingNo,incoming.status==='READY_FOR_DISPATCH')
const grouped=await send('GET',`${ROOT}/inventory/balances?`+new URLSearchParams({customerId:m.customerId,overseasWarehouseId:config.warehouseId,inboundCode:m.incomingNo,page:'1',size:'100'}))
assert.equal(grouped.records.length,1,'One exact receipt balance expected');const balance=grouped.records[0]
m.balanceId=id(balance.id);m.receipt={balance:await send('GET',`${ROOT}/inventory/balances/${m.balanceId}${own}`)}
const positions=await send('GET',`${ROOT}/inventory/balances/${m.balanceId}/positions${own}`);m.positionId=id(positions.find(p=>id(p.palletId)===m.palletId).id)
const candidates=await send('GET',`${ROOT}/dispatches/candidates?`+new URLSearchParams({warehouseId:config.warehouseId,orderNo:m.incomingNo}))
const candidate=candidates.candidates.find(c=>c.palletNo===m.palletNo && id(c.customerId)===m.customerId);assert(candidate?.selection);m.selection=candidate.selection;m.destination=candidate.destination;m.receiptVerified=true;save()
}
phase('CONFIRMED_TEST_APPOINTMENT')
const allocations=await prepareBolAppointments(context,report.members,'bol-same',config.warehouseId)
for(const m of report.members){const fresh=await send('GET',`${ROOT}/dispatches/candidates?`+new URLSearchParams({warehouseId:config.warehouseId,orderNo:m.incomingNo}));m.selection=fresh.candidates.find(c=>c.palletNo===m.palletNo).selection}
const created=await send('POST',ROOT+'/dispatches',{warehouseId:config.warehouseId,vehicle:null,driver:null,plannedDepartureAt:null,estimatedArrivalAt:null,remark:`${context.marker} TEST HTTP/UI`,cargo:report.members.map(m=>m.selection),batchAllocations:allocations,idempotencyKey:key('DISPATCH')})
report.dispatchNo=created.dispatchNo;assert(report.dispatchNo);save()
}
const path=ROOT+'/dispatches/'+report.dispatchNo, pp=PDA+'/dispatches/'+report.dispatchNo
if(!previous){const created=await send('GET',path);await send('POST',path+'/submit-review',{recordVersion:created.recordVersion,idempotencyKey:key('NO_SCHEDULE'),remark:'TEST missing transport schedule must block'},context.token,true)}
phase('REVIEWS_AND_REAL_PICKS')
if(previous){
const current=await send('GET',path), proof=previous.createdResources.fleetTrips[0]
assert.equal(current.stage,'BEFORE_LOADING');assert.equal(current.reviewState,'DRAFT');assert.equal(Number(current.counts.pickedPackageCount),0);assert.equal(id(proof.warehouseId),config.warehouseId)
assert(previous.createdResources.fleetTripCommands.some(c=>c.method==='PUT' && c.path===path && c.status==='RESPONDED' && c.code==='A0400'))
let result=await send('PUT',path,{...proof.updateRequest,recordVersion:current.recordVersion,idempotencyKey:key('FIX_EDIT'),publishLooseboardDemand:current.publishLooseboardDemand})
for(const action of ['submit-review','dispatch-review','loading-review'])result=await send('POST',path+'/'+action,{recordVersion:result.recordVersion,idempotencyKey:key(action),...(action==='submit-review'?{remark:'TEST full flow'}:{decision:'APPROVED',reason:'TEST full flow'})})
report.trip=proof
}else report.trip=await prepareFleetTrip(context,'dispatch-loading-flow',report.dispatchNo,config.warehouseId)
for(const [index,m] of report.members.entries()) {
const task=await send('GET',pp,undefined,picker.token),item=task.items.find(i=>i.palletNo===m.palletNo);assert(item);m.dispatchItemId=id(item.dispatchItemId)
const body={recordVersion:task.recordVersion,positionId:m.positionId,packages:1,productQuantity:1,idempotencyKey:key('PICK-'+index),remark:context.marker}
const picked=await send('POST',`${pp}/items/${m.dispatchItemId}/pick`,body,picker.token)
const replay=await send('POST',`${pp}/items/${m.dispatchItemId}/pick`,body,picker.token);check('pick replay '+index,replay.replayed===true && id(replay.recordVersion)===id(picked.recordVersion))
if(index===0){const preview=await send('GET',pp+'/loading',undefined,picker.token);await send('POST',pp+'/loading-complete',{recordVersion:preview.recordVersion,manifestHash:preview.manifestHash,idempotencyKey:key('INCOMPLETE')},picker.token,true)}
}
const ledger=async()=>Promise.all(report.members.map(m=>send('GET',`${ROOT}/inventory/ledger?`+new URLSearchParams({customerId:m.customerId,overseasWarehouseId:config.warehouseId,inboundCode:m.incomingNo,page:'1',size:'100'}))))
report.stockAfterPick=await ledger()
phase('PDA_PHOTOS_AND_ADMIN_FILE_READBACK')
for(const slot of slots){const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${key('UPLOAD-'+slot)}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId)},picker.token)}
const docs=await send('GET',path+'/loading-documents');check('Admin sees five photos',docs.complete && docs.photos.length===5)
report.photos=[]
for(const photo of docs.photos){const r=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`);check('Admin bytes '+photo.slot,r.http===200 && hash(r.bytes)===hash(image));report.photos.push({slot:photo.slot,fileId:photo.fileId,sha256:hash(r.bytes)})}
phase('AUTOMATIC_OUTBOUND_AND_DELIVERY')
const preview=await send('GET',pp+'/loading',undefined,picker.token),body={recordVersion:preview.recordVersion,manifestHash:preview.manifestHash,idempotencyKey:key('LOAD'),remark:'TEST scanned pallets; no manual measurements'}
const loaded=await send('POST',pp+'/loading-complete',body,picker.token);check('automatic departure',loaded.stage==='DELIVERING')
const after=await send('GET',path),replay=await send('POST',pp+'/loading-complete',body,picker.token)
check('loading replay',replay.replayed && id(replay.recordVersion)===id(loaded.recordVersion))
assert.deepEqual(await send('GET',path),after);assert.deepEqual(await ledger(),report.stockAfterPick)
check('one customer outbound and destination delivery',after.documents.outbounds.length===1 && after.documents.deliveries.length===1 && after.documents.outbounds[0].status==='OUTBOUNDED' && after.documents.deliveries[0].status==='DELIVERING' && Number(after.counts.shippedPackageCount)===2)
report.final=after;report.success=true;phase('HTTP_PASSED_AWAITING_UI')
if(config.keepPda){report.privateUiSession=join(tmpdir(),`dispatch-ui-${config.runId}.json`);writeFileSync(report.privateUiSession,JSON.stringify(picker),{mode:0o600,flag:'wx'})}
} catch(error){report.error=error.message;report.success=false;phase('FAILED')}
finally {
if(picker?.membershipId && !(report.success && config.keepPda))await disablePicker(context,picker,82).catch(e=>{report.cleanupError=e.message})
for(const session of sessions)await context.request('POST','/api/auth/logout',{refreshToken:session.refreshToken},session.accessToken,{}).catch(()=>{})
save()
}
return {success:report.success,error:report.error,dispatchNo:report.dispatchNo,report:join(directory,'report.json'),privateUiSession:report.privateUiSession}
}
if(process.argv[1] && import.meta.url===pathToFileURL(resolve(process.argv[1])).href){try{const result=await runLoadingFlow(parseFlowArguments(process.argv.slice(2)));console.log(JSON.stringify(result));if(result.success===false)process.exitCode=1}catch(error){console.error(error.message);process.exitCode=1}}
+1 -1
View File
@@ -102,7 +102,7 @@ export async function execute(args = process.argv.slice(2), execArgs = process.e
} }
if (!warehouse) throw Error('No active warehouse has unambiguous city/timezone; no business fixtures created') if (!warehouse) throw Error('No active warehouse has unambiguous city/timezone; no business fixtures created')
context.createdResources.quoteWarehouse = warehouse context.createdResources.quoteWarehouse = warehouse
await context.executeModule('merchant-qualification-fixture', () => prepareCustomer(context, { publishEnvironment: false })) await context.executeModule('merchant-qualification-fixture', () => prepareCustomer(context, { publishEnvironment: false, requireDistinctCustomer: false }))
if (context.steps.some(s => !s.passed)) throw Error('Fresh customer fixture failed') if (context.steps.some(s => !s.passed)) throw Error('Fresh customer fixture failed')
await verifyBookingQuotes(context, warehouse, planned) await verifyBookingQuotes(context, warehouse, planned)
} catch (cause) { error = cause.message; if (context.createdResources.bookingQuoteAcceptance) context.createdResources.bookingQuoteAcceptance.status = 'FAILED' } } catch (cause) { error = cause.message; if (context.createdResources.bookingQuoteAcceptance) context.createdResources.bookingQuoteAcceptance.status = 'FAILED' }
+1 -1
View File
@@ -80,7 +80,7 @@ export async function execute(args = process.argv.slice(2), execArgs = process.e
context.token = await login(env.CHONGCHONG_ADMIN_USERNAME,env.CHONGCHONG_ADMIN_PASSWORD,'platform') context.token = await login(env.CHONGCHONG_ADMIN_USERNAME,env.CHONGCHONG_ADMIN_PASSWORD,'platform')
const warehouses = requireSuccess(await context.request('GET','/api/platform/v1/last-leg/overseas-procurements/warehouse-options')) const warehouses = requireSuccess(await context.request('GET','/api/platform/v1/last-leg/overseas-procurements/warehouse-options'))
if (!Array.isArray(warehouses) || !warehouses.some(row => String(row.warehouseId) === config.warehouseId)) throw new Error('指定收货仓不在有效采购仓候选中') if (!Array.isArray(warehouses) || !warehouses.some(row => String(row.warehouseId) === config.warehouseId)) throw new Error('指定收货仓不在有效采购仓候选中')
await prepareCustomer(context,{publishEnvironment:false}) await prepareCustomer(context,{publishEnvironment:false,requireDistinctCustomer:false})
if (context.steps.some(step => !step.passed)) throw new Error('独立客户准备检查未全部通过') if (context.steps.some(step => !step.passed)) throw new Error('独立客户准备检查未全部通过')
const fixture = context.context.merchantQualificationFixture const fixture = context.context.merchantQualificationFixture
const customerId = String(fixture.tenantId), customerToken = await login(fixture.username,fixture.password,'customer') const customerId = String(fixture.tenantId), customerToken = await login(fixture.username,fixture.password,'customer')
+200
View File
@@ -0,0 +1,200 @@
import { createHash } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { tableDefinitions } from './sql-baseline-contract-helpers.mjs'
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
const policyPath = new URL('./test-env-business-reset-policy.json', import.meta.url)
const baselinePaths = [
'deploy/sql/头程数据库统一增量-MySQL8.sql',
'deploy/sql/海外区域统一增量-MySQL8.sql',
]
const actions = new Set(['KEEP', 'CLEAR_CANDIDATE', 'RESET_REVIEW', 'REVIEW'])
/** 对基线正文取指纹;统一换行,避免不同工作区换行规则造成误报。 */
export function fingerprint(text) {
return createHash('sha256').update(text.replaceAll('\r\n', '\n')).digest('hex')
}
/** 只接受当前基线使用的简单标识符,生成的 SQL 不接受外部表达式。 */
function identifier(value) {
if (typeof value !== 'string' || !/^[a-z][a-z0-9_]*$/.test(value)) {
throw new Error('表或 schema 标识符非法')
}
return value
}
/** 从空库建表基线提取范围和约束,不执行初始化 SQL。 */
export function readCatalog(sources) {
const tables = new Map()
for (const source of sources) {
const definitions = tableDefinitions(source.sql)
const declarationCount = [...source.sql.matchAll(/^CREATE\s+TABLE\b/gim)].length
if (definitions.size !== declarationCount) throw new Error(`未能完整解析建表定义:${source.path}`)
for (const [key, definition] of definitions) {
if (tables.has(key)) throw new Error(`重复表:${key}`)
const [schema, name] = key.split('.').map(identifier)
const references = [...definition.matchAll(/\bREFERENCES\s+(?:(\w+)\.)?(\w+)\s*\(/gi)]
.map(match => `${match[1] ?? schema}.${match[2]}`)
tables.set(key, {
key, schema, name, source: source.path,
definitionSha256: fingerprint(definition),
description: definition.match(/\) ENGINE=[^\n]*COMMENT='([^']*)'/)?.[1] ?? '',
references: [...new Set(references)], triggers: [],
})
}
let schema
for (const match of source.sql.matchAll(/^USE (\w+);|^CREATE TRIGGER (\w+) (BEFORE|AFTER) (INSERT|UPDATE|DELETE) ON (\w+)/gm)) {
if (match[1]) schema = match[1]
else {
const table = tables.get(`${schema}.${match[5]}`)
if (!table) throw new Error(`触发器对应表不在清单中:${match[2]}`)
table.triggers.push({ name: match[2], timing: match[3], event: match[4] })
}
}
}
return [...tables.values()].sort((a, b) => a.key.localeCompare(b.key))
}
/** 将已逐表登记的建议与当前基线比对;未知表始终保持待审,不自动加入清理。 */
export function buildPlan(sources, policy, inventory = null) {
if (policy.version !== 1 || policy.environment !== 'TEST' || !Array.isArray(policy.groups)) {
throw new Error('清理策略必须为版本 1 的 TEST 策略')
}
const registered = new Map()
for (const group of policy.groups) {
if (!actions.has(group.action) || !group.reason || !Array.isArray(group.tables)) throw new Error('策略分组格式非法')
for (const key of group.tables) {
if (registered.has(key)) throw new Error(`策略重复登记:${key}`)
registered.set(key, group)
}
}
const blockers = []
const sourcesStatus = sources.map(source => {
const sha256 = fingerprint(source.sql)
if (policy.sourceSha256?.[source.path] !== sha256) blockers.push({ code: 'BASELINE_CHANGED', source: source.path })
return { path: source.path, sha256 }
})
const tables = readCatalog(sources).map(table => {
const group = registered.get(table.key)
if (!group) blockers.push({ code: 'UNCLASSIFIED_TABLE', table: table.key })
return { ...table, action: group?.action ?? 'REVIEW', reason: group?.reason ?? '新表未分类,禁止自动清理',
estimatedRows: null }
})
const byKey = new Map(tables.map(table => [table.key, table]))
for (const key of registered.keys()) {
if (!byKey.has(key)) blockers.push({ code: 'STALE_POLICY_TABLE', table: key })
}
const schemas = [...new Set(tables.map(table => table.schema))].sort()
const inventoryProblems = []
if (inventory === null) blockers.push({ code: 'LIVE_INVENTORY_REQUIRED' })
else {
if (!Array.isArray(inventory)) throw new Error('结构清单应为 inspection.sql 第一条查询导出的 JSON 数组')
const seen = new Set()
for (const row of inventory) {
const key = `${identifier(row.schema)}.${identifier(row.table)}`
if (seen.has(key)) throw new Error(`实际结构清单重复表:${key}`)
seen.add(key)
if (!schemas.includes(row.schema)) throw new Error('结构清单包含目标范围外的 schema')
if (!byKey.has(key)) inventoryProblems.push({ code: 'UNKNOWN_LIVE_TABLE', table: key })
else if (row.type !== 'BASE TABLE') inventoryProblems.push({ code: 'UNEXPECTED_TABLE_TYPE', table: key })
else {
// INFORMATION_SCHEMA.TABLE_ROWS 只是估算;不得将其作为删除数量或精确验收结果。
byKey.get(key).estimatedRows = row.estimatedRows ?? null
}
}
for (const table of tables) if (!seen.has(table.key)) inventoryProblems.push({ code: 'MISSING_LIVE_TABLE', table: table.key })
blockers.push(...inventoryProblems)
}
const retainedReferences = tables.flatMap(table => table.action === 'CLEAR_CANDIDATE' ? [] :
table.references.filter(key => byKey.get(key)?.action === 'CLEAR_CANDIDATE')
.map(parent => ({ child: table.key, parent })))
for (const relation of retainedReferences) blockers.push({ code: 'RETAINED_CHILD_REFERENCES_CLEAR_PARENT', ...relation })
const summary = { total: tables.length, KEEP: 0, CLEAR_CANDIDATE: 0, RESET_REVIEW: 0, REVIEW: 0 }
for (const table of tables) summary[table.action]++
return {
version: 1, environment: 'TEST', status: 'REVIEW_REQUIRED', executionSupported: false,
// 结构清单的表名吻合不证明字段、外键、触发器、运行版本或目标环境已经核对通过。
confirmedScope: policy.confirmedScope,
summary, sources: sourcesStatus, schemas, blockers, tables,
canGenerateCountQueries: inventory !== null && inventoryProblems.length === 0,
pendingReview: policy.pendingReview,
limitations: [
'仅生成候选清单和只读查询;不连接数据库,不生成或执行删除、重置、停机及文件清理命令。',
'基线不是实际部署证明。必须核对实际字段、外键、索引、触发器、当前服务实现及混合表逐行处理条件。',
'保留基础资料、自营数据、独立审计和文件元数据;业务过程历史的保留边界与外键需审阅。',
'执行前须确定维护窗口、停止全部相关写入者及后台重试、备份恢复方案,并对精确目标和操作取得授权。',
'Redis、消息重投、搜索索引和外部采集不在此 SQL 清单内,不可由数据库清理成功推定已处理。',
'TABLE_ROWS 为估算;counts.sql 仅在导入完整且表名吻合的清单后提供,结果仍需在静默窗口重新核对。',
],
}
}
/** 生成只读结构盘点 SQL;不输出业务行、密码、文件路径或连接地址。 */
export function inspectionSql(schemas) {
const scope = schemas.map(schema => `'${identifier(schema)}'`).join(', ')
return `-- 仅在操作者明确选择的 TEST 连接执行;本文件只有只读查询。\n`
+ `-- 将第一条查询的 inventory_json 字段原样保存为 JSON 文件,可交给 --inventory。\n`
+ `-- 只读账号需要能看见全部目标表;缺少权限可能表现为表缺失,必须人工核实。\n`
+ `SELECT JSON_ARRAYAGG(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'estimatedRows', TABLE_ROWS)) AS inventory_json\nFROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${scope});\n\n`
+ `SELECT TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA\nFROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${scope}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION;\n\n`
+ `SELECT TABLE_SCHEMA, TABLE_NAME, CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_SCHEMA, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME\nFROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_NAME IS NOT NULL AND (TABLE_SCHEMA IN (${scope}) OR REFERENCED_TABLE_SCHEMA IN (${scope})) ORDER BY TABLE_SCHEMA, TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION;\n\n`
+ `SELECT TRIGGER_SCHEMA, TRIGGER_NAME, EVENT_OBJECT_TABLE, EVENT_MANIPULATION, ACTION_TIMING, ACTION_STATEMENT\nFROM information_schema.TRIGGERS WHERE EVENT_OBJECT_SCHEMA IN (${scope}) ORDER BY TRIGGER_SCHEMA, EVENT_OBJECT_TABLE, TRIGGER_NAME;\n\n`
+ `SELECT TABLE_SCHEMA, TABLE_NAME, INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME\nFROM information_schema.STATISTICS WHERE TABLE_SCHEMA IN (${scope}) ORDER BY TABLE_SCHEMA, TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;\n`
}
/** 只对已盘点且表名完整吻合的范围生成精确计数查询;仍不执行。 */
export function countSql(plan) {
if (!plan.canGenerateCountQueries) throw new Error('须先导入完整且表名吻合的实际结构清单')
return '-- 只读精确计数;大表可能耗时。须在维护窗口核对,禁止遇错继续。\n'
+ plan.tables.map(table => `SELECT '${table.key}' AS table_key, COUNT(*) AS exact_rows FROM \`${identifier(table.schema)}\`.\`${identifier(table.name)}\`;`).join('\n') + '\n'
}
/** 只解析离线参数,不提供 execute 模式或数据库凭据参数。 */
function parseArgs(args) {
const options = {}
for (let index = 0; index < args.length; index++) {
const name = args[index]
if (name === '--help') { options.help = true; continue }
if (!['--out-dir', '--inventory'].includes(name) || options[name] || !args[index + 1] || args[index + 1].startsWith('--')) {
throw new Error(`未知、重复或缺少值的参数:${name}`)
}
options[name] = args[++index]
}
return options
}
/** 输出到新的报告目录,独占创建,防止覆盖既有证据或源码。 */
export function writeReport(outputDirectory, plan) {
const reportsRoot = path.join(repositoryRoot, 'reports')
const target = path.resolve(repositoryRoot, outputDirectory)
if (path.dirname(target) !== reportsRoot || !/^business-reset-[a-zA-Z0-9_-]+$/.test(path.basename(target))) {
throw new Error('报告目录必须为 reports/business-reset-<本次唯一标识>,且直接位于 reports 下')
}
mkdirSync(reportsRoot, { recursive: true })
mkdirSync(target)
writeFileSync(path.join(target, 'plan.json'), `${JSON.stringify(plan, null, 2)}\n`, { flag: 'wx' })
writeFileSync(path.join(target, 'inspection.sql'), inspectionSql(plan.schemas), { flag: 'wx' })
if (plan.canGenerateCountQueries) writeFileSync(path.join(target, 'counts.sql'), countSql(plan), { flag: 'wx' })
return target
}
/** 命令行入口仅做离线准备;退出 0 只表示报告生成成功,绝不表示允许清库。 */
function main() {
const options = parseArgs(process.argv.slice(2))
if (options.help) {
console.log('离线 TEST 清理预览:node scripts/test-env-business-reset-plan.mjs [--out-dir reports/business-reset-<唯一标识>] [--inventory <结构清单.json>]\n无执行模式;不联网;不需要凭据。')
return
}
const sources = baselinePaths.map(name => ({ path: name, sql: readFileSync(path.join(repositoryRoot, name), 'utf8') }))
const policy = JSON.parse(readFileSync(policyPath, 'utf8'))
const inventory = options['--inventory'] ? JSON.parse(readFileSync(options['--inventory'], 'utf8')) : null
const plan = buildPlan(sources, policy, inventory)
const output = options['--out-dir'] ? writeReport(options['--out-dir'], plan) : null
console.log(JSON.stringify({ status: plan.status, executionSupported: false, summary: plan.summary, blockers: plan.blockers, output }, null, 2))
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try { main() } catch (error) { console.error(error.message); process.exitCode = 1 }
}
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
import { buildPlan, countSql, fingerprint, inspectionSql, readCatalog, writeReport } from './test-env-business-reset-plan.mjs'
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
const sourcePaths = ['deploy/sql/头程数据库统一增量-MySQL8.sql', 'deploy/sql/海外区域统一增量-MySQL8.sql']
const sources = sourcePaths.map(name => ({ path: name, sql: readFileSync(new URL(`../${name}`, import.meta.url), 'utf8') }))
const policy = JSON.parse(readFileSync(new URL('./test-env-business-reset-policy.json', import.meta.url), 'utf8'))
/** 构造少量离线表,覆盖新增表和保留表外键的失败关闭行为。 */
function fixture() {
const sql = `USE baiyue_order;
CREATE TABLE ord_order (
id bigint NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB COMMENT='订单';
CREATE TABLE retained_audit (
order_id bigint,
FOREIGN KEY (order_id) REFERENCES ord_order (id)
) ENGINE=InnoDB COMMENT='保留审计';
CREATE TRIGGER protect_order BEFORE DELETE ON ord_order FOR EACH ROW BEGIN END;
`
const source = { path: 'fixture.sql', sql }
return { sources: [source], policy: { version: 1, environment: 'TEST', sourceSha256: { 'fixture.sql': fingerprint(sql) }, groups: [
{ action: 'CLEAR_CANDIDATE', reason: '业务单据', tables: ['baiyue_order.ord_order'] },
{ action: 'KEEP', reason: '独立审计', tables: ['baiyue_order.retained_audit'] },
] } }
}
/** 结构清单只模拟表名及估算数量,不冒充真实环境验证。 */
function inventoryFor(plan) {
return plan.tables.map(table => ({ schema: table.schema, table: table.name, type: 'BASE TABLE', estimatedRows: 7 }))
}
test('当前每张基线表显式登记,离线结果始终不可执行', () => {
const plan = buildPlan(sources, policy)
assert.equal(plan.summary.total, readCatalog(sources).length)
assert.equal(plan.summary.total, policy.groups.flatMap(group => group.tables).length)
assert.equal(plan.executionSupported, false)
assert.equal(plan.status, 'REVIEW_REQUIRED')
assert.equal(plan.canGenerateCountQueries, false)
assert.ok(plan.blockers.some(item => item.code === 'LIVE_INVENTORY_REQUIRED'))
assert.ok(!plan.blockers.some(item => ['BASELINE_CHANGED', 'UNCLASSIFIED_TABLE', 'STALE_POLICY_TABLE'].includes(item.code)))
assert.ok(plan.tables.some(table => table.triggers.some(trigger => trigger.event === 'DELETE')))
})
test('身份、文件、报价、自营和序列保留,资金与资源混合表进入字段审阅', () => {
const tables = new Map(buildPlan(sources, policy).tables.map(table => [table.key, table]))
for (const key of ['baiyue_auth.auth_account', 'baiyue_iam.iam_tenant', 'baiyue_file.file_object',
'baiyue_file.file_pda_release', 'baiyue_audit.sys_audit_log', 'baiyue_firstleg.fl_price_book_version',
'baiyue_lastleg.ll_selfop_inventory_snapshot', 'baiyue_lastleg.ll_document_sequence', 'baiyue_order.ord_customer_address']) {
assert.equal(tables.get(key).action, 'KEEP', key)
}
for (const key of ['baiyue_finance.fin_wallet_account', 'baiyue_warehouse.wh_location',
'baiyue_lastlag_warehouse.wh_pallet', 'baiyue_lastlag_warehouse.wh_location', 'baiyue_lastleg.ll_vehicle',
'baiyue_lastleg.ll_appointment_work_guard']) assert.equal(tables.get(key).action, 'RESET_REVIEW', key)
assert.match(tables.get('baiyue_finance.fin_wallet_account').reason, /可用余额.*有效授信.*不能固定为零/)
assert.equal(tables.get('baiyue_finance.fin_recharge_request').action, 'CLEAR_CANDIDATE')
assert.equal(tables.get('baiyue_finance.fin_wallet_ledger').action, 'CLEAR_CANDIDATE')
})
test('独立审计保留而订单自身历史随单清理,人工计划与资源档案保留', () => {
const plan = buildPlan(sources, policy)
const tables = new Map(plan.tables.map(table => [table.key, table]))
for (const key of ['baiyue_order.ord_order_status_history', 'baiyue_firstleg.fl_loading_pick_history',
'baiyue_lastleg.ll_dispatch_history', 'baiyue_lastleg.ll_fleet_trip_history']) {
assert.equal(tables.get(key).action, 'CLEAR_CANDIDATE', key)
}
for (const key of ['baiyue_finance.fin_audit_log', 'baiyue_firstleg.fl_sailing',
'baiyue_firstleg.fl_sailing_history', 'baiyue_lastleg.ll_fleet_vehicle_record',
'baiyue_lastleg.ll_fleet_vehicle_record_history', 'baiyue_iam.iam_notification_task']) {
assert.equal(tables.get(key).action, 'KEEP', key)
}
assert.equal(tables.get('baiyue_file.file_export_task').action, 'REVIEW')
assert.equal(tables.get('baiyue_file.file_export_outbox').action, 'REVIEW')
assert.ok(!plan.blockers.some(item => item.code === 'RETAINED_CHILD_REFERENCES_CLEAR_PARENT'))
})
test('基线新增表不会继承相似前缀的删除建议', () => {
const data = fixture()
data.sources[0].sql += `CREATE TABLE ord_order_new (
id bigint
) ENGINE=InnoDB;
`
const plan = buildPlan(data.sources, data.policy)
assert.equal(plan.tables.find(table => table.name === 'ord_order_new').action, 'REVIEW')
assert.ok(plan.blockers.some(item => item.code === 'UNCLASSIFIED_TABLE'))
assert.ok(plan.blockers.some(item => item.code === 'BASELINE_CHANGED'))
})
test('保留表引用待清主表时明确报告,且提取删除保护触发器', () => {
const data = fixture()
const plan = buildPlan(data.sources, data.policy)
assert.ok(plan.blockers.some(item => item.code === 'RETAINED_CHILD_REFERENCES_CLEAR_PARENT'
&& item.child === 'baiyue_order.retained_audit' && item.parent === 'baiyue_order.ord_order'))
assert.deepEqual(plan.tables.find(table => table.name === 'ord_order').triggers,
[{ name: 'protect_order', timing: 'BEFORE', event: 'DELETE' }])
})
test('策略重复项、错误环境和未完整解析的 CREATE TABLE 拒绝处理', () => {
const data = fixture()
const duplicate = structuredClone(data.policy)
duplicate.groups[0].tables.push('baiyue_order.retained_audit')
assert.throws(() => buildPlan(data.sources, duplicate), /重复登记/)
assert.throws(() => buildPlan(data.sources, { ...data.policy, environment: 'PROD' }), /TEST/)
assert.throws(() => readCatalog([{ path: 'unsupported.sql', sql: 'CREATE TABLE `quoted` (id INT);' }]), /未能完整解析/)
})
test('实际清单缺表、多表或视图均阻断计数 SQL 的生成', () => {
const data = fixture()
const initial = buildPlan(data.sources, data.policy)
for (const rows of [
[],
[...inventoryFor(initial), { schema: 'baiyue_order', table: 'unclassified', type: 'BASE TABLE' }],
inventoryFor(initial).map(row => ({ ...row, type: 'VIEW' })),
]) {
const plan = buildPlan(data.sources, data.policy, rows)
assert.equal(plan.canGenerateCountQueries, false)
assert.throws(() => countSql(plan), /完整且表名吻合/)
}
})
test('完整实际表清单仅解锁 SELECT COUNT,估算行数不转为已验证精确数量', () => {
const data = fixture()
const initial = buildPlan(data.sources, data.policy)
const plan = buildPlan(data.sources, data.policy, inventoryFor(initial))
assert.equal(plan.executionSupported, false)
assert.equal(plan.status, 'REVIEW_REQUIRED')
assert.equal(plan.tables[0].estimatedRows, 7)
assert.equal(plan.tables[0].exactRows, undefined)
const sql = countSql(plan).split('\n').filter(line => line && !line.startsWith('--'))
assert.equal(sql.length, plan.tables.length)
assert.ok(sql.every(line => /^SELECT '[a-z_.]+' AS table_key, COUNT\(\*\) AS exact_rows FROM `[a-z_]+`\.`[a-z_]+`;$/.test(line)))
})
test('外部清单拒绝重复、范围外和 SQL 注入标识符', () => {
const data = fixture()
const rows = inventoryFor(buildPlan(data.sources, data.policy))
assert.throws(() => buildPlan(data.sources, data.policy, [...rows, rows[0]]), /重复表/)
assert.throws(() => buildPlan(data.sources, data.policy, [{ ...rows[0], schema: 'other_database' }]), /范围外/)
assert.throws(() => buildPlan(data.sources, data.policy, [{ ...rows[0], table: 'ord_order; DROP TABLE x' }]), /标识符非法/)
assert.throws(() => inspectionSql(["x'); DROP DATABASE x; --"]), /标识符非法/)
})
test('盘点 SQL 只有元数据 SELECT 并包含跨范围外键引用,输出路径不能覆盖源码', () => {
const plan = buildPlan(sources, policy)
const sql = inspectionSql(plan.schemas).split('\n').filter(line => !line.startsWith('--')).join('\n')
const statements = sql.split(';').map(value => value.trim()).filter(Boolean)
assert.equal(statements.length, 5)
assert.ok(statements.every(statement => statement.startsWith('SELECT ')))
assert.match(sql, /OR REFERENCED_TABLE_SCHEMA IN/)
assert.throws(() => writeReport('scripts', plan), /报告目录必须/)
assert.throws(() => writeReport('reports/../scripts/business-reset-test', plan), /报告目录必须/)
})
test('真实 CLI 默认仅打印离线摘要,拒绝 execute 参数', () => {
const script = 'scripts/test-env-business-reset-plan.mjs'
const result = spawnSync(process.execPath, [script], { cwd: repositoryRoot, encoding: 'utf8' })
assert.equal(result.status, 0, result.stderr)
assert.equal(JSON.parse(result.stdout).executionSupported, false)
assert.equal(JSON.parse(result.stdout).output, null)
const execute = spawnSync(process.execPath, [script, '--execute'], { cwd: repositoryRoot, encoding: 'utf8' })
assert.equal(execute.status, 1)
assert.match(execute.stderr, /未知、重复或缺少值/)
})
+605
View File
@@ -0,0 +1,605 @@
{
"version": 1,
"environment": "TEST",
"confirmedScope": {
"customers": "ALL",
"business": "头程、海外仓、尾程、车队全部业务单据及关联库存、财务",
"wallet": "测试充值、资金流水、实际余额清零;保留授信额度和有效期,释放冻结及授信占用",
"selfOperated": "KEEP",
"audit": "KEEP_INDEPENDENT_AUDIT_CLEAR_ORDER_HISTORY",
"physicalFiles": "KEEP",
"numberSequences": "KEEP"
},
"sourceSha256": {
"deploy/sql/头程数据库统一增量-MySQL8.sql": "38013327896214ccc4fe317f507bfcf0b11377b07a66cd4ffa41b109b73558fd",
"deploy/sql/海外区域统一增量-MySQL8.sql": "db307236f5d0275c214db49888fe0d16d8a954802eb50ad5de52ed33ccfab4a7"
},
"pendingReview": [
"精确 TEST 目标与实际结构、触发器定义、保留表外键及行数须在停写窗口重新核对;当前表清单不是维护窗口快照。",
"实际删除保护触发器对应表的具体执行、恢复校验与失败恢复;不得将预览成功等同清库放行。",
"资金账户服务时区和有效授信重算;国内库位、海外真实/虚拟库位、托盘、车辆及固定协调锁的字段重置条件。",
"导出任务按业务类型白名单过滤;关联 Outbox 同步处理。保留消息及车辆运维的重新提交路径核对。",
"基线外的表逐项核实;安全、序列、自营诊断保留,已空的历史业务表不扩大为删表操作。",
"维护窗口、相关服务/任务写入者、消息重投及缓存范围;备份路径、完整性和恢复能力。",
"实际删除/重置前精确目标与操作清单的授权;本策略仅为候选处理规则,无数据库执行能力。"
],
"groups": [
{
"action": "KEEP",
"reason": "保留独立审计及其可靠投递证据,不将其混入业务事件清空。",
"tables": [
"baiyue_audit.sys_audit_dead_letter",
"baiyue_audit.sys_audit_log",
"baiyue_auth.sys_audit_outbox",
"baiyue_finance.event_delivery_attempt",
"baiyue_finance.event_delivery_retry_audit",
"baiyue_finance.fin_audit_log",
"baiyue_firstleg.event_delivery_attempt",
"baiyue_firstleg.event_delivery_retry_audit",
"baiyue_firstleg.sys_audit_outbox",
"baiyue_iam.sys_audit_outbox",
"baiyue_lastleg.event_delivery_attempt",
"baiyue_lastleg.event_delivery_retry_audit",
"baiyue_order.sys_audit_outbox",
"baiyue_warehouse.sys_audit_outbox"
]
},
{
"action": "KEEP",
"reason": "保留身份、客户、权限、基础资料和配置初始化事实;账号会话不在本次清理范围。",
"tables": [
"baiyue_auth.auth_account",
"baiyue_auth.auth_refresh_token",
"baiyue_iam.iam_customer_dimension_initialization_task",
"baiyue_iam.iam_data_scope",
"baiyue_iam.iam_membership",
"baiyue_iam.iam_membership_role",
"baiyue_iam.iam_notification_template",
"baiyue_iam.iam_number_sequence",
"baiyue_iam.iam_permission",
"baiyue_iam.iam_role",
"baiyue_iam.iam_role_permission",
"baiyue_iam.iam_security_subject_version",
"baiyue_iam.iam_security_tenant_version",
"baiyue_iam.iam_tenant",
"baiyue_iam.iam_tenant_address",
"baiyue_iam.iam_tenant_qualification",
"baiyue_iam.iam_tenant_review",
"baiyue_iam.iam_user"
]
},
{
"action": "KEEP",
"reason": "保留物理文件对应元数据、下载记录、PDA发布及固定保护行,不整库清理文件。",
"tables": [
"baiyue_file.file_download_log",
"baiyue_file.file_download_token",
"baiyue_file.file_export_task_guard",
"baiyue_file.file_object",
"baiyue_file.file_pda_release",
"baiyue_file.file_pda_release_channel"
]
},
{
"action": "REVIEW",
"reason": "按明确 export_type 白名单清除订单、库存和财务导出任务及其 Outbox;客户资料、报价、汇率、车辆及自营导出保留。未知导出类型不自动删除,物理文件与 file_object 不动。",
"tables": [
"baiyue_file.file_export_outbox",
"baiyue_file.file_export_task"
]
},
{
"action": "CLEAR_CANDIDATE",
"reason": "全部客户的业务单据、库存、费用资金、关联明细及业务消息/幂等/占用的清空候选;只表示业务范围,须审阅外键、触发器、实际结构和运行写入者后形成执行方案。",
"tables": [
"baiyue_finance.fin_bill",
"baiyue_finance.fin_bill_line",
"baiyue_finance.fin_bill_revision",
"baiyue_finance.fin_cost",
"baiyue_finance.fin_cost_allocation",
"baiyue_finance.fin_fee_source",
"baiyue_finance.fin_idempotency_record",
"baiyue_finance.fin_inbox_event",
"baiyue_finance.fin_receipt",
"baiyue_finance.fin_receipt_bill",
"baiyue_finance.fin_recharge_request",
"baiyue_finance.fin_settlement",
"baiyue_finance.fin_settlement_line",
"baiyue_finance.fin_settlement_payment",
"baiyue_finance.fin_vehicle_cost",
"baiyue_finance.fin_vehicle_cost_payment",
"baiyue_finance.fin_vehicle_cost_revision",
"baiyue_finance.fin_wallet_command_inbox",
"baiyue_finance.fin_wallet_event_outbox",
"baiyue_finance.fin_wallet_hold",
"baiyue_finance.fin_wallet_ledger",
"baiyue_firstleg.fl_carrier_schedule_exception_fact",
"baiyue_firstleg.fl_carrier_schedule_node_fact",
"baiyue_firstleg.fl_carrier_schedule_query",
"baiyue_firstleg.fl_carrier_schedule_query_attempt",
"baiyue_firstleg.fl_carrier_schedule_query_operation",
"baiyue_firstleg.fl_coload_command",
"baiyue_firstleg.fl_coload_group",
"baiyue_firstleg.fl_coload_group_member",
"baiyue_firstleg.fl_consumed_event",
"baiyue_firstleg.fl_container_booking",
"baiyue_firstleg.fl_container_document",
"baiyue_firstleg.fl_container_event_inbox",
"baiyue_firstleg.fl_container_fee_detail",
"baiyue_firstleg.fl_container_fee_source_snapshot",
"baiyue_firstleg.fl_customs_broker_submission",
"baiyue_firstleg.fl_customs_clearance_result",
"baiyue_firstleg.fl_customs_command",
"baiyue_firstleg.fl_customs_completion_document",
"baiyue_firstleg.fl_customs_completion_document_version",
"baiyue_firstleg.fl_customs_container",
"baiyue_firstleg.fl_customs_customer_document",
"baiyue_firstleg.fl_customs_customer_document_inbound",
"baiyue_firstleg.fl_customs_customer_document_version",
"baiyue_firstleg.fl_customs_declaration_batch",
"baiyue_firstleg.fl_customs_final_document",
"baiyue_firstleg.fl_customs_frozen_document_inbound",
"baiyue_firstleg.fl_direct_delivery_unloading",
"baiyue_firstleg.fl_dropship_pallet",
"baiyue_firstleg.fl_dropship_pallet_carton",
"baiyue_firstleg.fl_dropship_pallet_carton_sku",
"baiyue_firstleg.fl_dropship_pallet_task",
"baiyue_firstleg.fl_finance_fee_source_command",
"baiyue_firstleg.fl_fulfillment_task",
"baiyue_firstleg.fl_inbound_carton_label",
"baiyue_firstleg.fl_inbound_carton_label_sku",
"baiyue_firstleg.fl_inbound_destination_snapshot",
"baiyue_firstleg.fl_inbound_fee",
"baiyue_firstleg.fl_inbound_fee_detail",
"baiyue_firstleg.fl_inbound_fee_item",
"baiyue_firstleg.fl_inbound_file",
"baiyue_firstleg.fl_inbound_hold_record",
"baiyue_firstleg.fl_inbound_idempotency",
"baiyue_firstleg.fl_inbound_internal_note",
"baiyue_firstleg.fl_inbound_loading_pick_task",
"baiyue_firstleg.fl_inbound_measurement",
"baiyue_firstleg.fl_inbound_measurement_item",
"baiyue_firstleg.fl_inbound_order",
"baiyue_firstleg.fl_inbound_outbox",
"baiyue_firstleg.fl_inbound_pallet_allocation",
"baiyue_firstleg.fl_inbound_pickup_snapshot",
"baiyue_firstleg.fl_inbound_split_batch",
"baiyue_firstleg.fl_inbound_split_detail",
"baiyue_firstleg.fl_inbound_split_item",
"baiyue_firstleg.fl_inbound_transport_snapshot",
"baiyue_firstleg.fl_inbound_weight_result",
"baiyue_firstleg.fl_inbound_weight_revision",
"baiyue_firstleg.fl_loading_cabinet_evidence",
"baiyue_firstleg.fl_loading_completion_checkpoint",
"baiyue_firstleg.fl_loading_completion_command",
"baiyue_firstleg.fl_loading_operation",
"baiyue_firstleg.fl_loading_pallet_label",
"baiyue_firstleg.fl_loading_pick_assignment",
"baiyue_firstleg.fl_loading_pick_item",
"baiyue_firstleg.fl_loading_pick_item_assignment",
"baiyue_firstleg.fl_loading_pick_operation",
"baiyue_firstleg.fl_loading_pick_review",
"baiyue_firstleg.fl_loading_pick_task",
"baiyue_firstleg.fl_loading_plan",
"baiyue_firstleg.fl_loading_plan_item",
"baiyue_firstleg.fl_measure_review_record",
"baiyue_firstleg.fl_mojia_measurement_receipt",
"baiyue_firstleg.fl_ocean_booking",
"baiyue_firstleg.fl_ocean_booking_container_allocation",
"baiyue_firstleg.fl_ocean_booking_container_requirement",
"baiyue_firstleg.fl_ocean_booking_document",
"baiyue_firstleg.fl_ocean_booking_operation",
"baiyue_firstleg.fl_ocean_booking_party",
"baiyue_firstleg.fl_order_declaration_event",
"baiyue_firstleg.fl_order_declaration_snapshot",
"baiyue_firstleg.fl_outbound_dispatch",
"baiyue_firstleg.fl_outbound_dispatch_operation",
"baiyue_firstleg.fl_port_schedule",
"baiyue_firstleg.fl_port_schedule_operation",
"baiyue_firstleg.fl_preload_loading_plan_link",
"baiyue_firstleg.fl_preload_plan",
"baiyue_firstleg.fl_sailing_assignment",
"baiyue_firstleg.fl_sailing_booking_link",
"baiyue_firstleg.fl_sailing_inbound_volume_forecast",
"baiyue_firstleg.fl_sailing_receipt_batch_fact",
"baiyue_firstleg.fl_split_operation",
"baiyue_firstleg.fl_terminal_drayage",
"baiyue_firstleg.fl_terminal_drayage_mail",
"baiyue_firstleg.fl_terminal_drayage_operation",
"baiyue_firstleg.fl_truck_appointment",
"baiyue_firstleg.fl_truck_appointment_attachment",
"baiyue_firstleg.fl_truck_appointment_operation",
"baiyue_lastlag_warehouse.wh_pallet_batch",
"baiyue_lastlag_warehouse.wh_pallet_batch_item",
"baiyue_lastlag_warehouse.wh_pallet_business_binding",
"baiyue_lastlag_warehouse.wh_pallet_operation",
"baiyue_lastlag_warehouse.wh_resource_lock",
"baiyue_lastleg.ll_appointment_cargo_verification",
"baiyue_lastleg.ll_appointment_followup",
"baiyue_lastleg.ll_appointment_impact",
"baiyue_lastleg.ll_appointment_work",
"baiyue_lastleg.ll_appointment_work_commitment",
"baiyue_lastleg.ll_cargo_discard_application",
"baiyue_lastleg.ll_cargo_discard_carton",
"baiyue_lastleg.ll_cargo_discard_item",
"baiyue_lastleg.ll_cargo_discard_photo",
"baiyue_lastleg.ll_container_arrival_time_operation",
"baiyue_lastleg.ll_container_loading_evidence",
"baiyue_lastleg.ll_container_plan_conversion_command",
"baiyue_lastleg.ll_container_shipping_gate",
"baiyue_lastleg.ll_customer_visit",
"baiyue_lastleg.ll_delivery_document_revision",
"baiyue_lastleg.ll_delivery_facts_changed",
"baiyue_lastleg.ll_delivery_operation",
"baiyue_lastleg.ll_delivery_order",
"baiyue_lastleg.ll_destuff_pallet_cost",
"baiyue_lastleg.ll_destuff_pallet_cost_allocation",
"baiyue_lastleg.ll_dispatch_appointment_allocation",
"baiyue_lastleg.ll_dispatch_command",
"baiyue_lastleg.ll_dispatch_delivery_arrangement",
"baiyue_lastleg.ll_dispatch_draft",
"baiyue_lastleg.ll_dispatch_draft_command",
"baiyue_lastleg.ll_dispatch_draft_container",
"baiyue_lastleg.ll_dispatch_item",
"baiyue_lastleg.ll_dispatch_item_source",
"baiyue_lastleg.ll_dispatch_order",
"baiyue_lastleg.ll_dispatch_pallet_lock",
"baiyue_lastleg.ll_dispatch_pda_operation",
"baiyue_lastleg.ll_dispatch_pda_pick",
"baiyue_lastleg.ll_dispatch_pda_pick_order",
"baiyue_lastleg.ll_dispatch_pda_task",
"baiyue_lastleg.ll_dispatch_physical_pallet_lock",
"baiyue_lastleg.ll_dispatch_transport_command",
"baiyue_lastleg.ll_dispatch_transport_create",
"baiyue_lastleg.ll_dispatch_truck_task",
"baiyue_lastleg.ll_dropship_carton",
"baiyue_lastleg.ll_dropship_carton_ledger",
"baiyue_lastleg.ll_dropship_carton_sku",
"baiyue_lastleg.ll_dropship_order_profile",
"baiyue_lastleg.ll_dropship_sku_inventory",
"baiyue_lastleg.ll_event_conflict",
"baiyue_lastleg.ll_event_inbox",
"baiyue_lastleg.ll_event_outbox",
"baiyue_lastleg.ll_exception_order",
"baiyue_lastleg.ll_exception_order_command",
"baiyue_lastleg.ll_exception_return_item",
"baiyue_lastleg.ll_fba_appointment_command",
"baiyue_lastleg.ll_fba_receiving_agreement",
"baiyue_lastleg.ll_fba_receiving_change",
"baiyue_lastleg.ll_fba_receiving_member",
"baiyue_lastleg.ll_fba_receiving_plan",
"baiyue_lastleg.ll_fba_receiving_revision",
"baiyue_lastleg.ll_finance_fee_command",
"baiyue_lastleg.ll_finance_fee_source",
"baiyue_lastleg.ll_fleet_capacity_offer",
"baiyue_lastleg.ll_fleet_capacity_offer_command",
"baiyue_lastleg.ll_fleet_external_booking",
"baiyue_lastleg.ll_fleet_finance_source",
"baiyue_lastleg.ll_fleet_owned_business_command",
"baiyue_lastleg.ll_fleet_owned_business_ownership",
"baiyue_lastleg.ll_fleet_owned_business_record",
"baiyue_lastleg.ll_fleet_pickup_command",
"baiyue_lastleg.ll_fleet_pickup_plan",
"baiyue_lastleg.ll_fleet_pickup_task",
"baiyue_lastleg.ll_fleet_pickup_upload",
"baiyue_lastleg.ll_fleet_pool_order_link",
"baiyue_lastleg.ll_fleet_resource_assignment",
"baiyue_lastleg.ll_fleet_trip",
"baiyue_lastleg.ll_fleet_trip_cost",
"baiyue_lastleg.ll_fleet_trip_dispatch",
"baiyue_lastleg.ll_fleet_trip_handover",
"baiyue_lastleg.ll_fleet_trip_operations_record",
"baiyue_lastleg.ll_inbound_fba_appointment",
"baiyue_lastleg.ll_inbound_fba_appointment_item",
"baiyue_lastleg.ll_inbound_order",
"baiyue_lastleg.ll_inbound_order_item",
"baiyue_lastleg.ll_inbound_pallet_merge",
"baiyue_lastleg.ll_inbound_pda_operation",
"baiyue_lastleg.ll_inbound_pda_task",
"baiyue_lastleg.ll_inbound_plan",
"baiyue_lastleg.ll_inbound_plan_carton",
"baiyue_lastleg.ll_inbound_plan_line",
"baiyue_lastleg.ll_inbound_plan_pallet",
"baiyue_lastleg.ll_inbound_plan_pallet_allocation",
"baiyue_lastleg.ll_inbound_stock_receipt",
"baiyue_lastleg.ll_inventory_balance",
"baiyue_lastleg.ll_inventory_ledger",
"baiyue_lastleg.ll_inventory_operation",
"baiyue_lastleg.ll_inventory_position",
"baiyue_lastleg.ll_loading_event_inbox",
"baiyue_lastleg.ll_local_looseboard_operation",
"baiyue_lastleg.ll_local_looseboard_order",
"baiyue_lastleg.ll_order",
"baiyue_lastleg.ll_order_command_record",
"baiyue_lastleg.ll_order_detail",
"baiyue_lastleg.ll_order_fee",
"baiyue_lastleg.ll_order_manual_node",
"baiyue_lastleg.ll_order_source_pallet",
"baiyue_lastleg.ll_outbound_order",
"baiyue_lastleg.ll_outbound_pallet_item",
"baiyue_lastleg.ll_outbound_pda_pick",
"baiyue_lastleg.ll_outbound_pda_task",
"baiyue_lastleg.ll_overseas_procurement_fee",
"baiyue_lastleg.ll_overseas_procurement_file",
"baiyue_lastleg.ll_overseas_procurement_order",
"baiyue_lastleg.ll_pda_inventory_move",
"baiyue_lastleg.ll_pda_pallet_operation",
"baiyue_lastleg.ll_pda_stocktake_action",
"baiyue_lastleg.ll_pda_stocktake_item",
"baiyue_lastleg.ll_pda_stocktake_task",
"baiyue_lastleg.ll_physical_pallet_guard",
"baiyue_lastleg.ll_procurement_budget_authorization",
"baiyue_lastleg.ll_procurement_carton",
"baiyue_lastleg.ll_procurement_carton_source",
"baiyue_lastleg.ll_procurement_closure",
"baiyue_lastleg.ll_procurement_closure_item",
"baiyue_lastleg.ll_procurement_command",
"baiyue_lastleg.ll_procurement_control_note",
"baiyue_lastleg.ll_procurement_disposition",
"baiyue_lastleg.ll_procurement_estimate_review",
"baiyue_lastleg.ll_procurement_fee_correction",
"baiyue_lastleg.ll_procurement_fee_correction_item",
"baiyue_lastleg.ll_procurement_flow",
"baiyue_lastleg.ll_procurement_label_item",
"baiyue_lastleg.ll_procurement_label_set",
"baiyue_lastleg.ll_procurement_manifest",
"baiyue_lastleg.ll_procurement_receipt",
"baiyue_lastleg.ll_procurement_repack",
"baiyue_lastleg.ll_procurement_shipment",
"baiyue_lastleg.ll_procurement_shipment_item",
"baiyue_lastleg.ll_procurement_wallet_attempt",
"baiyue_lastleg.ll_receiving_container_operation",
"baiyue_lastleg.ll_receiving_container_session",
"baiyue_lastleg.ll_receiving_fba_pallet_count",
"baiyue_lastleg.ll_receiving_sku_receipt",
"baiyue_lastleg.ll_receiving_warehouse_operation",
"baiyue_lastleg.ll_relabel_application",
"baiyue_lastleg.ll_relabel_item",
"baiyue_lastleg.ll_relabel_pda_operation",
"baiyue_lastleg.ll_relabel_pda_task",
"baiyue_lastleg.ll_relabel_pda_unit_fact",
"baiyue_lastleg.ll_return_carton_count_session",
"baiyue_lastleg.ll_return_carton_stock_receipt",
"baiyue_lastleg.ll_return_inventory_balance",
"baiyue_lastleg.ll_return_inventory_carton",
"baiyue_lastleg.ll_return_inventory_carton_item",
"baiyue_lastleg.ll_return_inventory_operation",
"baiyue_lastleg.ll_return_inventory_outbound_movement",
"baiyue_lastleg.ll_return_order",
"baiyue_lastleg.ll_return_order_item",
"baiyue_lastleg.ll_return_packing_plan",
"baiyue_lastleg.ll_return_packing_trace",
"baiyue_lastleg.ll_return_receipt",
"baiyue_lastleg.ll_return_receipt_batch",
"baiyue_lastleg.ll_return_receipt_item",
"baiyue_lastleg.ll_return_sku_conversion",
"baiyue_lastleg.ll_return_stock_receipt",
"baiyue_lastleg.ll_us_truck_order",
"baiyue_lastleg.ll_us_truck_order_operation",
"baiyue_lastleg.ll_us_truck_order_pallet",
"baiyue_lastleg.ll_wallet_event_inbox",
"baiyue_lastleg.ll_warehouse_transfer_carton",
"baiyue_lastleg.ll_warehouse_transfer_inventory_allocation",
"baiyue_lastleg.ll_warehouse_transfer_item",
"baiyue_lastleg.ll_warehouse_transfer_measurement_photo",
"baiyue_lastleg.ll_warehouse_transfer_order",
"baiyue_lastleg.ll_warehouse_transfer_pallet",
"baiyue_lastleg.ll_warehouse_transfer_pallet_item",
"baiyue_lastleg.ll_warehouse_transfer_receipt",
"baiyue_lastleg.ll_warehouse_transfer_receipt_command",
"baiyue_lastleg.ll_warehouse_transfer_relabel_binding",
"baiyue_lastleg.ll_warehouse_transfer_source_order",
"baiyue_order.ord_idempotency_record",
"baiyue_order.ord_order",
"baiyue_order.ord_order_cargo",
"baiyue_order.ord_order_fulfillment_split",
"baiyue_order.ord_order_internal_note",
"baiyue_order.ord_order_party",
"baiyue_order.ord_outbox_event",
"baiyue_warehouse.wh_domestic_container_idempotency",
"baiyue_warehouse.wh_domestic_container_inventory",
"baiyue_warehouse.wh_domestic_container_order",
"baiyue_warehouse.wh_domestic_container_transaction",
"baiyue_warehouse.wh_location_block",
"baiyue_warehouse.wh_location_block_slot",
"baiyue_warehouse.wh_location_occupancy",
"baiyue_warehouse.wh_location_reservation",
"baiyue_warehouse.wh_pallet_location_release_operation",
"baiyue_warehouse.wh_pallet_location_settlement_operation",
"baiyue_warehouse.wh_pallet_location_transfer_operation"
]
},
{
"action": "KEEP",
"reason": "保留主档、报价、计费规则及配置历史。",
"tables": [
"baiyue_finance.fin_customer_settlement_profile",
"baiyue_finance.fin_subject",
"baiyue_firstleg.fl_carrier_fba_rate",
"baiyue_firstleg.fl_customer_charge_rule",
"baiyue_firstleg.fl_customer_dimension_config",
"baiyue_firstleg.fl_fee_item",
"baiyue_firstleg.fl_price_book_channel",
"baiyue_firstleg.fl_price_book_version",
"baiyue_firstleg.fl_price_channel_catalog",
"baiyue_firstleg.fl_price_origin_alias",
"baiyue_firstleg.fl_price_warehouse_mapping",
"baiyue_firstleg.fl_sailing_channel",
"baiyue_firstleg.fl_sailing_template",
"baiyue_lastleg.ll_fleet_driver",
"baiyue_lastleg.ll_fleet_driver_history",
"baiyue_lastleg.ll_fleet_quote",
"baiyue_lastleg.ll_fleet_quote_history",
"baiyue_lastleg.ll_monthly_exchange_rate",
"baiyue_lastleg.ll_monthly_exchange_rate_history",
"baiyue_lastleg.ll_overseas_quote",
"baiyue_lastleg.ll_overseas_quote_fee",
"baiyue_lastleg.ll_overseas_quote_transit_price",
"baiyue_order.ord_customer_address",
"baiyue_warehouse.wh_express_company",
"baiyue_warehouse.wh_overseas_storage_fee_rule",
"baiyue_warehouse.wh_overseas_storage_fee_tier",
"baiyue_warehouse.wh_pallet",
"baiyue_warehouse.wh_partner",
"baiyue_warehouse.wh_partner_type",
"baiyue_warehouse.wh_service_capability",
"baiyue_warehouse.wh_supplier",
"baiyue_warehouse.wh_supplier_carrier",
"baiyue_warehouse.wh_warehouse",
"baiyue_warehouse.wh_warehouse_address"
]
},
{
"action": "KEEP",
"reason": "保留编号序列,避免既有标签、附件及审计引用与新单重号。",
"tables": [
"baiyue_finance.fin_document_sequence",
"baiyue_firstleg.fl_document_sequence",
"baiyue_lastleg.ll_dispatch_daily_sequence",
"baiyue_lastleg.ll_document_sequence",
"baiyue_order.ord_document_sequence",
"baiyue_order.ord_inbound_code_daily_sequence",
"baiyue_warehouse.wh_document_sequence"
]
},
{
"action": "RESET_REVIEW",
"reason": "保留账户、币种、授信额度及到期日;实际余额、冻结、已用授信、冻结授信归零;可用余额按执行日有效授信重新计算,不能固定为零;核对服务时区及版本更新。",
"tables": [
"baiyue_finance.fin_wallet_account"
]
},
{
"action": "CLEAR_CANDIDATE",
"reason": "已确认订单自身的状态流转、拣货、派送等作业历史随业务单据清除;独立审计另行保留。执行仍须处理外键及不可删除触发器。",
"tables": [
"baiyue_firstleg.fl_assignment_history",
"baiyue_firstleg.fl_carrier_schedule_query_history",
"baiyue_firstleg.fl_coload_group_history",
"baiyue_firstleg.fl_container_exception_history",
"baiyue_firstleg.fl_container_transport_history",
"baiyue_firstleg.fl_customs_completion_action_history",
"baiyue_firstleg.fl_customs_container_history",
"baiyue_firstleg.fl_inbound_split_history",
"baiyue_firstleg.fl_inbound_status_history",
"baiyue_firstleg.fl_loading_pick_history",
"baiyue_firstleg.fl_loading_plan_history",
"baiyue_firstleg.fl_ocean_booking_status_history",
"baiyue_firstleg.fl_outbound_dispatch_history",
"baiyue_firstleg.fl_port_schedule_history",
"baiyue_firstleg.fl_terminal_drayage_history",
"baiyue_firstleg.fl_truck_appointment_history",
"baiyue_lastleg.ll_appointment_impact_history",
"baiyue_lastleg.ll_appointment_work_history",
"baiyue_lastleg.ll_cargo_discard_status_history",
"baiyue_lastleg.ll_dispatch_history",
"baiyue_lastleg.ll_fleet_owned_business_history",
"baiyue_lastleg.ll_fleet_pickup_history",
"baiyue_lastleg.ll_fleet_trip_cost_history",
"baiyue_lastleg.ll_fleet_trip_history",
"baiyue_lastleg.ll_loading_order_status_history",
"baiyue_lastleg.ll_local_looseboard_history",
"baiyue_lastleg.ll_order_hold_history",
"baiyue_lastleg.ll_order_manual_node_history",
"baiyue_lastleg.ll_order_operation_log",
"baiyue_lastleg.ll_order_status_history",
"baiyue_lastleg.ll_overseas_procurement_status_history",
"baiyue_lastleg.ll_pda_operation_history",
"baiyue_lastleg.ll_relabel_pda_history",
"baiyue_lastleg.ll_relabel_status_history",
"baiyue_lastleg.ll_us_truck_order_status_history",
"baiyue_order.ord_order_status_history"
]
},
{
"action": "KEEP",
"reason": "保留人工维护的船期计划及其计划变更历史;订单归班、实测、预报和订舱关联事实在各自业务表清理,不重置计划本身。",
"tables": [
"baiyue_firstleg.fl_sailing",
"baiyue_firstleg.fl_sailing_history"
]
},
{
"action": "RESET_REVIEW",
"reason": "保留真实/虚拟库位及停用状态;现行实现真实库位使用 IDLE/OCCUPIED,虚拟库位保持 ACTIVE,不能依据基线旧注释统一置 ACTIVE。",
"tables": [
"baiyue_lastlag_warehouse.wh_location"
]
},
{
"action": "RESET_REVIEW",
"reason": "保留实物托盘和允许保留的库位关联,核对业务绑定、FBA归属与占用的释放条件;已废弃或逻辑删除托盘不自动复活。",
"tables": [
"baiyue_lastlag_warehouse.wh_pallet"
]
},
{
"action": "RESET_REVIEW",
"reason": "保留固定协调锁初始行,重置同步游标、初始化标记和本次业务派生版本;须按当前服务及实际种子核对,不能直接清空导致后台协调失效。",
"tables": [
"baiyue_lastleg.ll_appointment_work_guard"
]
},
{
"action": "KEEP",
"reason": "保留车辆保养、维修等档案及其版本历史;独立公司费用及费用提交来源随本次财务业务清理,处理前核对重新提交行为。",
"tables": [
"baiyue_lastleg.ll_fleet_vehicle_record",
"baiyue_lastleg.ll_fleet_vehicle_record_history"
]
},
{
"action": "KEEP",
"reason": "按已确认口径保留全部自营采集事实、版本、维度、任务及发布指针。",
"tables": [
"baiyue_lastleg.ll_selfop_inventory_daily",
"baiyue_lastleg.ll_selfop_inventory_day_version",
"baiyue_lastleg.ll_selfop_inventory_flow",
"baiyue_lastleg.ll_selfop_inventory_snapshot",
"baiyue_lastleg.ll_selfop_outbound_discovery_scope",
"baiyue_lastleg.ll_selfop_outbound_item",
"baiyue_lastleg.ll_selfop_outbound_order",
"baiyue_lastleg.ll_selfop_owner",
"baiyue_lastleg.ll_selfop_product",
"baiyue_lastleg.ll_selfop_sku_daily_sales",
"baiyue_lastleg.ll_selfop_sku_profile",
"baiyue_lastleg.ll_selfop_sync_run",
"baiyue_lastleg.ll_selfop_sync_scope",
"baiyue_lastleg.ll_selfop_sync_stage",
"baiyue_lastleg.ll_selfop_sync_task",
"baiyue_lastleg.ll_selfop_warehouse_daily_sales"
]
},
{
"action": "RESET_REVIEW",
"reason": "保留车辆资料、里程和维护/停用状态;核对实际状态机后仅释放业务车次造成的派出状态,不按 ownership 归属字段判断占用。",
"tables": [
"baiyue_lastleg.ll_vehicle"
]
},
{
"action": "RESET_REVIEW",
"reason": "保留库位资料、停用和逻辑删除状态;核对当前实现后只释放本轮业务造成的 RESERVED/OCCUPIED,占用/预占/库位块同步清理。",
"tables": [
"baiyue_warehouse.wh_location"
]
},
{
"action": "KEEP",
"reason": "独立第三方调用留档保留,不作为本次业务重置的删除对象。",
"tables": [
"baiyue_firstleg.fl_third_party_call_log"
]
},
{
"action": "KEEP",
"reason": "站内消息与通知模板保留;执行前核查排队通知是否引用本次待清业务,不按测试样本名称或消息正文批量删除。",
"tables": [
"baiyue_iam.iam_notification_event_inbox",
"baiyue_iam.iam_notification_inbox",
"baiyue_iam.iam_notification_recipient",
"baiyue_iam.iam_notification_task"
]
}
]
}