diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 1cce9dd2c..bfa5d390e 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-fol 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', 'manual-loading-complete-fixture', 'manual-loading-complete-verify', - 'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression'] + 'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 4d610f410..4d8ddada4 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,7 @@ # Admin 测试环境 HTTP 模块 +`fleet-vehicle-care.mjs` 是可选车辆运维专项,通过正式入口 `admin-test-env-acceptance.mjs --modules fleet-vehicle-care --output <报告路径>` 执行,不单独运行。仅用于获授权 TEST,凭据由调用者显式 `node --env-file=<指定环境文件>` 提供;复用总入口 gateway/username/password 参数。经正式 API 创建本轮专用车辆,再复用 `vehicle-care.mjs` 验证六类记录、版本、更正历史、照片幂等及跨车隔离;不依赖订单、库存、报价或历史样本。写入仅涉及测试车辆、运维记录和图片;成功/失败均保留报告 `createdResources.fleetVehicleCare` 和样本,finally 读取最新版本停用本轮主车辆,内部助手停用隔离车辆,不删除记录或文件。失败重跑创建新样本,不修改原失败证据;不执行 SQL 或部署。该场景不证明费用已经入账或付款。 + `fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收、客户确认及预排接口生成两套独立本轮货物,再共用一个真实车次并各自转派送,停在尚未拣货。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 `fleet-cross-customer-acceptance.mjs` 是跨客户共享车次的内部后半链验收函数,不是独立入口。默认调用只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收并分别预排/转派送、关联同一合格车次且未拣货,以及调用方提供的完整库存只读取证器。逐客户审核和拣货,验证第二单未就绪时整车拒绝且全部快照不变,再验证统一发车、原键重放和分别签收。前置门禁证据与事务中途故障回滚严格区分,后者保持 `NOT_TESTED`。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 diff --git a/scripts/admin-test-env-modules/fleet-vehicle-care.mjs b/scripts/admin-test-env-modules/fleet-vehicle-care.mjs new file mode 100644 index 000000000..2b13f590c --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-vehicle-care.mjs @@ -0,0 +1,52 @@ +import { randomUUID } from 'node:crypto' +import { verifyVehicleCare } from './vehicle-care.mjs' + +const MODULE = 'fleet-vehicle-care' +const BASE = '/api/platform/v1/last-leg/master-data/vehicles' +const OPTIONS = { scenario: 'vehicle-care-lifecycle', deploymentBlocker: true } + +/** Fresh TEST fixtures only. No dispatch, inventory or financial writes. */ +export async function run(context) { + const proof = { status: 'CHECKING', care: {}, cleanup: 'PENDING' } + context.createdResources.fleetVehicleCare = proof + async function command(method, path, body, label) { + const response = await context.request(method, path, body) + const data = context.apiSuccess(MODULE, label, response, '', OPTIONS) + if (response?.http !== 200 || String(response?.body?.code) !== '200' || data == null) throw new Error(label) + return data + } + let failure + try { + const vehicle = await command('POST', BASE, { + plateNumber: `VC-${randomUUID().slice(0, 12)}`, model: 'Vehicle care TEST', + fuelConsumptionPer100Km: 10, mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', + status: 'IN_SERVICE', remark: context.marker, + }, '建立本轮车辆运维专用档案') + proof.vehicleId = String(vehicle.id) + proof.plateNumber = vehicle.plateNumber + await verifyVehicleCare(context, MODULE, vehicle, proof.care) + proof.status = 'VERIFIED' + } catch (error) { + failure = error + proof.status = 'FAILED' + proof.error = error instanceof Error ? error.message : String(error) + } finally { + if (proof.vehicleId) { + try { + const path = `${BASE}/${encodeURIComponent(proof.vehicleId)}` + const current = await command('GET', path, undefined, '结束前读取本轮车辆当前版本') + const ended = current.status === 'INACTIVE' ? current : await command('PATCH', `${path}/status`, { + recordVersion: current.recordVersion, status: 'INACTIVE', + }, '停用本轮运维专用车辆并保留证据') + if (ended.status !== 'INACTIVE') throw new Error('本轮运维车辆未停用') + proof.cleanup = 'INACTIVE' + } catch (error) { + proof.cleanup = 'FAILED' + proof.cleanupError = error instanceof Error ? error.message : String(error) + proof.status = 'FAILED' + failure ??= error + } + } + } + if (failure) throw failure +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs index 8d2840a89..782c279e6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -2,6 +2,43 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { run, vehicleCapacityMatches } from './phase4-overseas-master-data.mjs' +import { run as runCare } from './fleet-vehicle-care.mjs' + +test('运维专项接受NON_NULL真实响应但仍完成全部六类记录和两车停用', async () => { + const { context, state } = fakeContext() + const request = context.request.bind(context) + context.request = async (...args) => JSON.parse(JSON.stringify(await request(...args), (_key, value) => value === null ? undefined : value)) + await runCare(context) + assert.equal(context.createdResources.fleetVehicleCare.status, 'VERIFIED') + assert.equal(context.createdResources.fleetVehicleCare.care.records.length, 6) + assert.equal(context.createdResources.fleetVehicleCare.cleanup, 'INACTIVE') + assert.equal(state.vehicle.status, 'INACTIVE') + assert.equal(state.spareVehicle.status, 'INACTIVE') +}) + +test('省略实际维修项目仍失败,失败后停用主车辆并保留报告', async () => { + const { context, state } = fakeContext({ softAssertions: true }) + const request = context.request.bind(context) + context.request = async (...args) => { + const response = await request(...args) + if (response.body?.data?.details?.recordType === 'REPAIR') delete response.body.data.details.incident.item + return response + } + await assert.rejects(runCare(context), /事件项目地点和分类完整回显/) + assert.equal(context.createdResources.fleetVehicleCare.status, 'FAILED') + assert.equal(context.createdResources.fleetVehicleCare.cleanup, 'INACTIVE') + assert.equal(state.vehicle.status, 'INACTIVE') +}) + +test('运维专项停用失败不得标记整个运行通过', async () => { + const { context } = fakeContext() + const request = context.request.bind(context) + context.request = async (...args) => args[0] === 'PATCH' && args[1].endsWith('/930000000000000001/status') + ? { http: 503, body: { code: 'S0503' } } : request(...args) + await assert.rejects(runCare(context)) + assert.equal(context.createdResources.fleetVehicleCare.status, 'FAILED') + assert.equal(context.createdResources.fleetVehicleCare.cleanup, 'FAILED') +}) test('vehicle capacity evidence rejects absent, zero and guessed dimensions', () => { const expected = { maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } diff --git a/scripts/admin-test-env-modules/vehicle-care.mjs b/scripts/admin-test-env-modules/vehicle-care.mjs index fb336e83a..2e45f666a 100644 --- a/scripts/admin-test-env-modules/vehicle-care.mjs +++ b/scripts/admin-test-env-modules/vehicle-care.mjs @@ -56,19 +56,23 @@ export async function verifyVehicleCare(context, module, vehicle, proof) { fuelQuantity: null, fuelUnit: null, verificationStatus: null, remark: context.marker, incident: { description: `${context.marker} 受控故障处置记录`, item: priced ? '更换轮胎' : null, place: priced ? '本轮测试作业点' : null, handler: null, processingStatus: 'PENDING', exceptionType: type === 'EXCEPTION' ? 'VEHICLE_BREAKDOWN' : null, - reportedCost: priced ? 0 : null, currency: priced ? 'USD' : null } } + reportedCost: type === 'REPAIR' ? 125.5 : priced ? 0 : null, currency: priced ? 'USD' : null } } const initial = { idempotencyKey: `${context.marker}:${type}`, details } let incident = await command('POST', base, initial, `创建本轮${type}记录`) proof.records.push({ recordNo: incident.recordNo, createRequest: initial }) - check('事件项目地点和分类完整回显', null, incident.vehicleId === id && incident.details?.incident?.item === details.incident.item - && incident.details.incident.place === details.incident.place && incident.details.incident.exceptionType === details.incident.exceptionType) + check('事件项目地点和分类完整回显', null, incident.vehicleId === id && (incident.details?.incident?.item ?? null) === details.incident.item + && (incident.details.incident.place ?? null) === details.incident.place && (incident.details.incident.exceptionType ?? null) === details.incident.exceptionType) incident = await command('PUT', `${base}/${incident.recordNo}`, { recordVersion: Number(incident.recordVersion), reason: '受控任务实际处理完成', details: { ...details, incident: { ...details.incident, handler: '本轮测试处理人', processingStatus: 'COMPLETED' } } }, `完成本轮${type}处理记录`) check('处理完成保持独立的费用及付款事实', null, incident.details.incident.processingStatus === 'COMPLETED' - && (!priced || (Number(incident.details.incident.reportedCost) === 0 && incident.paymentStatus === 'UNLINKED'))) + && (!priced || (Number(incident.details.incident.reportedCost) === details.incident.reportedCost && incident.paymentStatus === 'UNLINKED'))) const refreshed = await command('GET', `${base}/${incident.recordNo}`, undefined, `重新读取本轮${type}记录`) - check('处理记录重新读取保持全部现场字段', null, refreshed.details.incident.item === details.incident.item - && refreshed.details.incident.place === details.incident.place && refreshed.details.incident.handler === '本轮测试处理人') + check('处理记录重新读取保持全部现场字段', null, (refreshed.details.incident.item ?? null) === details.incident.item + && (refreshed.details.incident.place ?? null) === details.incident.place && refreshed.details.incident.handler === '本轮测试处理人') + const changes = await command('GET', `${base}/${incident.recordNo}/history?page=1&size=20`, undefined, `核对本轮${type}处理历史`) + check('处理历史保留原待处理事实及完成结果', null, changes.records?.length === 2 + && changes.records[0].snapshot?.details?.incident?.processingStatus === 'COMPLETED' + && changes.records[1].snapshot?.details?.incident?.processingStatus === 'PENDING') } let other try {