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