diff --git a/scripts/README.md b/scripts/README.md index b236798b6..e4065a966 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,26 @@ # 后端脚本管理与全量 HTTP 验收索引 +`dispatch-loading-flow-test-env.mjs` 是 TEST 固定客户两托一车的定向 HTTP 验收入口,验证实收入库、确认预约、审核、扫码拣货、五张装车图片的 Admin 读取、自动出库派送及原请求幂等。默认只预览;显式 `--execute --run-id <唯一标识> --warehouse-id <起运仓> --destination-warehouse-id --test-image <明确标注TEST的PNG>` 写入业务测试记录,通过 `node --env-file="<统一TEST配置>"` 读取凭据和固定客户。报告位于 `reports/dispatch-loading-/`,保留失败前的请求和业务数据;同标识拒绝重跑,先核对原请求再决定恢复。`--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 实际注册表,不维护另一份模块枚举: diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index b1e036567..3f3d79038 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 14a62e2cb..7dc90aa6d 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。 diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs index bc499ab73..50b159a16 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs @@ -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 { diff --git a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs index df2ea3c86..c98617651 100644 --- a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs +++ b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index 26230cb77..db8d2db84 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -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') diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 95257e89b..8e40bdbd0 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs index 5179848e3..0d8d892e5 100644 --- a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs +++ b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs index 683d9bff2..b34cc8404 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs @@ -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) } diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs index b637ea262..2dc86911e 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index f8816b7b7..59e4eca41 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -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', diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index eb9860bcc..0ed2513be 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -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') diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index 0207beac9..10c50ad36 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -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}` diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index ca61aa28b..92a6c89e6 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -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` diff --git a/scripts/admin-test-env-modules/overseas-storage-fees.mjs b/scripts/admin-test-env-modules/overseas-storage-fees.mjs index 62a76d58f..7229d2ade 100644 --- a/scripts/admin-test-env-modules/overseas-storage-fees.mjs +++ b/scripts/admin-test-env-modules/overseas-storage-fees.mjs @@ -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. diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 5507e1853..2fa7bf161 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -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, { diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs index 8102681e4..1076661f4 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs @@ -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])); diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs index 05e834f89..3d2aa93f7 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 94275e0c7..a1784faac 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -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) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 123b0dcc0..0d1e041d6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -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) diff --git a/scripts/client-test-env-acceptance-contract.test.mjs b/scripts/client-test-env-acceptance-contract.test.mjs index 6474dd4a3..0aeb82b71 100644 --- a/scripts/client-test-env-acceptance-contract.test.mjs +++ b/scripts/client-test-env-acceptance-contract.test.mjs @@ -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/']) { diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 24090611b..d039bcd6d 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -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 diff --git a/scripts/dispatch-loading-flow-test-env.mjs b/scripts/dispatch-loading-flow-test-env.mjs new file mode 100644 index 000000000..c9b67bfa0 --- /dev/null +++ b/scripts/dispatch-loading-flow-test-env.mjs @@ -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;im.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}} diff --git a/scripts/fleet-booking-quotes-test-env.mjs b/scripts/fleet-booking-quotes-test-env.mjs index 4ab271d83..74172bcd6 100644 --- a/scripts/fleet-booking-quotes-test-env.mjs +++ b/scripts/fleet-booking-quotes-test-env.mjs @@ -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' } diff --git a/scripts/procurement-sample-test-env.mjs b/scripts/procurement-sample-test-env.mjs index d8e5924c1..d5b4de83f 100644 --- a/scripts/procurement-sample-test-env.mjs +++ b/scripts/procurement-sample-test-env.mjs @@ -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') diff --git a/scripts/test-env-business-reset-plan.mjs b/scripts/test-env-business-reset-plan.mjs new file mode 100644 index 000000000..cf3ea891b --- /dev/null +++ b/scripts/test-env-business-reset-plan.mjs @@ -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 } +} diff --git a/scripts/test-env-business-reset-plan.test.mjs b/scripts/test-env-business-reset-plan.test.mjs new file mode 100644 index 000000000..e94f7cac3 --- /dev/null +++ b/scripts/test-env-business-reset-plan.test.mjs @@ -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, /未知、重复或缺少值/) +}) diff --git a/scripts/test-env-business-reset-policy.json b/scripts/test-env-business-reset-policy.json new file mode 100644 index 000000000..01f22ea86 --- /dev/null +++ b/scripts/test-env-business-reset-policy.json @@ -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" + ] + } + ] +}