From 34cac9c42a56f30deb2942894838e87e6b639862 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 11:53:58 +0800 Subject: [PATCH] test(fleet): verify driver qualification lifecycle --- scripts/admin-test-env-acceptance.mjs | 2 +- scripts/admin-test-env-modules/README.md | 2 + ...eet-driver-qualification-contract.test.mjs | 58 +++++++++ .../fleet-driver-qualification.mjs | 115 ++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 scripts/admin-test-env-modules/fleet-driver-qualification-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-driver-qualification.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index bfa5d390e..9d150880c 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', 'fleet-vehicle-care'] + 'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification'] 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 4d8ddada4..0c865f61d 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,7 @@ # Admin 测试环境 HTTP 模块 +`fleet-driver-qualification.mjs` 是司机证照专项可选模块,从 `admin-test-env-acceptance.mjs --modules fleet-driver-qualification --output <报告路径>` 执行,凭据由调用者显式 `node --env-file=<授权TEST环境文件>` 提供。经正式API创建两名本轮受控司机、上传非真实证照PNG,验证缺件/审核/打回/重提、30天内提醒、任务结束跨到期日禁止、非必需资质不阻断、关键资料及附件变化重审、旧版本拒绝、附件跨司机归属和历史下载。仅操作本轮司机及文件,不改排车/库存/财务、不执行SQL或部署;成功失败均按最新版本停用本轮司机,保留样本和报告 `createdResources.fleetDriverQualification`。失败重跑使用新样本,原失败证据不改写;该专项不代替整车实际发车链和浏览器验收。 + `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` 只证明生成编排及拒绝边界,真实运行另行留证。 diff --git a/scripts/admin-test-env-modules/fleet-driver-qualification-contract.test.mjs b/scripts/admin-test-env-modules/fleet-driver-qualification-contract.test.mjs new file mode 100644 index 000000000..63bd544b1 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-driver-qualification-contract.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { run } from './fleet-driver-qualification.mjs' +const copy = value => structuredClone(value) +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') +function fixture(fault) { + const rows = new Map(), history = new Map(), calls = [], files = new Map() + const response = (data, code='200', http=200) => ({ http, body: { code, data: copy(data) }, requestId: 'test-request', traceId: 'test-trace' }) + function qualification(row, expectedEnd) { + const now = Date.now(), expiry = Date.parse(row.details.licenseExpiresOn + 'T00:00:00Z') + const expiringSoon = expiry >= now - 86400000 && expiry - now <= 30 * 86400000 + let reason = !row.active ? 'INACTIVE' : !row.licenseFileId ? 'MISSING_LICENSE' : row.reviewStatus === 'REJECTED' ? 'REJECTED' + : row.reviewStatus !== 'APPROVED' ? 'PENDING_REVIEW' : !expectedEnd ? 'MISSING_TRIP_END' + : Date.parse(expectedEnd) <= now ? 'INVALID_TRIP_END' : Date.parse(expectedEnd) >= expiry + 86400000 ? 'EXPIRES_DURING_TRIP' : 'ELIGIBLE' + if (fault === 'allows expiry boundary' && reason === 'EXPIRES_DURING_TRIP') reason = 'ELIGIBLE' + return { canDispatch: reason === 'ELIGIBLE', reason, expiringSoon: fault === 'missing warning' ? false : expiringSoon } + } + function present(row) { return { ...copy(row), qualification: qualification(row), licenseState: row.details.licenseExpiresOn < new Date().toISOString().slice(0,10) ? 'EXPIRED' : 'EXPIRING' } } + function record(row, action) { history.get(row.id).unshift({ action, recordVersion: row.recordVersion, snapshot: present(row) }) } + const context = { marker: 'CONTROLLED-DRIVER-TEST', createdResources: {}, + assert(_module, label, _response, condition) { assert.ok(condition, label) }, + apiSuccess(_module, label, value) { assert.equal(value.http, 200, label); assert.equal(value.body?.code, '200', label); return value.body.data }, + async request(method, raw, body) { + calls.push({ method, raw, body: copy(body) }); const url = new URL(raw, 'https://fixture.invalid'), parts = url.pathname.split('/drivers'), suffix = parts[1] + if (method === 'POST' && suffix === '') { const id = String(rows.size+1); const row = { id, driverNo: 'DR-'+id, details: copy(body), active: true, reviewStatus: 'PENDING', licenseFileId: null, recordVersion: 0 }; rows.set(id,row); history.set(id,[]); record(row,'CREATE'); return response(present(row)) } + const [, id, action, fileId] = suffix.split('/'), row = rows.get(id); assert.ok(row) + if (action === 'license') return fault === 'file outage' ? response(null, 'S0503', 503) : response(null, fault === 'cross-driver leak' ? '200' : 'A0404') + if (method === 'GET' && !action) return response(present(row)) + if (action === 'qualification') return response(qualification(row, url.searchParams.get('expectedEnd'))) + if (action === 'history') return response({ records: history.get(id) }) + if (Number(body.recordVersion) !== row.recordVersion) return response(null, 'A0409') + if (action === 'review') { + if (body.decision === 'APPROVED' && (!row.licenseFileId || row.details.licenseExpiresOn < new Date().toISOString().slice(0,10))) return response(null, 'A0400') + if (body.decision === 'REJECTED' && !body.reason) return response(null, 'A0400') + row.reviewStatus = body.decision + } else if (action === 'submit') row.reviewStatus = 'PENDING' + else if (action === 'status') row.active = body.active + else if (method === 'PUT') { if (body.details.licenseExpiresOn !== row.details.licenseExpiresOn && fault !== 'renewal skips review') row.reviewStatus='PENDING'; row.details=copy(body.details) } + else throw new Error(`Unexpected request ${method} ${raw}`) + row.recordVersion++; record(row, action === 'review' ? body.decision : action === 'submit' ? 'RESUBMIT' : action === 'status' ? 'DEACTIVATE' : 'UPDATE'); return response(present(row)) + }, + async requestMultipart(method, raw) { calls.push({ method, raw }); const id = raw.split('/drivers/')[1].split('/')[0], row = rows.get(id); row.licenseFileId=String(files.size+1);files.set(row.licenseFileId,id);row.reviewStatus='PENDING';row.recordVersion++;record(row,'UPLOAD_LICENSE');return response(present(row)) }, + async requestBinary(method, raw) { calls.push({ method, raw });return { http: 200, contentType: 'image/png', bytes: fault === 'corrupt image' ? Buffer.from('wrong') : PNG } }, + } + return { context, rows, calls } +} +test('full driver fixture checks lifecycle and deactivates both owned drivers', async () => { + const { context, rows } = fixture(); await run(context) + assert.equal(context.createdResources.fleetDriverQualification.status, 'VERIFIED') + assert.equal(rows.size, 2); assert.ok([...rows.values()].every(row=>!row.active)) +}) +for (const fault of ['allows expiry boundary', 'missing warning', 'cross-driver leak', 'file outage', 'renewal skips review', 'corrupt image']) { + test(`driver verifier rejects ${fault} and still deactivates created drivers`, async () => { + const { context, rows } = fixture(fault); await assert.rejects(run(context)) + assert.equal(context.createdResources.fleetDriverQualification.status, 'FAILED') + assert.ok(rows.size>0); assert.ok([...rows.values()].every(row=>!row.active)) + }) +} diff --git a/scripts/admin-test-env-modules/fleet-driver-qualification.mjs b/scripts/admin-test-env-modules/fleet-driver-qualification.mjs new file mode 100644 index 000000000..dfdc32a86 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-driver-qualification.mjs @@ -0,0 +1,115 @@ +import { randomUUID } from 'node:crypto' + +const MODULE = 'fleet-driver-qualification' +const BASE = '/api/platform/v1/last-leg/fleet/drivers' +const OPTIONS = { scenario: 'driver-license-and-qualification', deploymentBlocker: true } +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') + +/** Uses only new controlled TEST drivers, never real licence documents or existing driver assignments. */ +export async function run(context) { + const proof = { status: 'CHECKING', drivers: [], requests: [], qualifications: [] } + context.createdResources.fleetDriverQualification = proof + const marker = `DQ-${randomUUID().slice(0, 12)}` + const day = offset => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10) + const expiry = day(15), nextDay = new Date(Date.parse(`${expiry}T00:00:00Z`) + 86400000).toISOString() + const endBefore = new Date(Date.parse(nextDay) - 1).toISOString() + const soonEnd = new Date(Date.now() + 86400000).toISOString() + const details = { name: `${marker}受控司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS', + licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC', licenseExpiresOn: expiry, + remark: `${context.marker} TEST image, not a real driving licence`, + profile: { vehicleModels: 'Box Truck', languagesAndArea: 'TEST', medicalExpiresOn: day(-1), trainingExpiresOn: day(-1), insuranceExpiresOn: day(-1) } } + function check(label, response, condition) { + context.assert(MODULE, label, response, Boolean(condition), '', OPTIONS) + if (!condition) throw new Error(label) + } + async function request(method, path, body) { + const response = await context.request(method, path, body) + proof.requests.push({ method, path, http: response.http, code: response.body?.code, requestId: response.requestId, traceId: response.traceId }) + return response + } + async function command(method, path, body, label) { + const response = await 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 + } + async function reject(method, path, body, code, label) { + const response = await request(method, path, body) + check(label, response, response.http === 200 && String(response.body?.code) === code) + } + async function qualification(id, end, reason, expiringSoon = undefined) { + const result = await command('GET', `${BASE}/${id}/qualification?expectedEnd=${encodeURIComponent(end)}`, undefined, `读取司机资格:${reason}`) + proof.qualifications.push({ driverId: String(id), expectedEnd: end, result }) + check(`司机资格准确判定${reason}`, null, result.reason === reason && result.canDispatch === (reason === 'ELIGIBLE') + && (expiringSoon === undefined || result.expiringSoon === expiringSoon)) + return result + } + async function download(id, fileId, label) { + const result = await context.requestBinary('GET', `${BASE}/${id}/license/${fileId}`) + check(label, result, result.http === 200 && result.contentType?.startsWith('image/png') && result.bytes instanceof Uint8Array && Buffer.from(result.bytes).equals(PNG)) + } + let failure + try { + let driver = await command('POST', BASE, details, '建立本轮临近到期司机') + const own = { id: String(driver.id), driverNo: driver.driverNo, initialDetails: details, files: [] } + proof.drivers.push(own) + const id = own.id, path = `${BASE}/${id}` + await qualification(id, soonEnd, 'MISSING_LICENSE') + await reject('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED' }, 'A0400', '缺驾驶证附件不能审核通过') + const uploaded = await context.requestMultipart('POST', `${path}/license?recordVersion=${driver.recordVersion}`, { name: `${marker}-TEST.png`, type: 'image/png', content: PNG }) + driver = context.apiSuccess(MODULE, '上传本轮受控驾驶证图片', uploaded, '', OPTIONS) + check('上传图片保留待审而非直接通过', uploaded, driver?.licenseFileId && driver.reviewStatus === 'PENDING') + const originalFile = String(driver.licenseFileId); own.files.push(originalFile) + await qualification(id, soonEnd, 'PENDING_REVIEW') + await reject('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'REJECTED' }, 'A0400', '打回不能缺少原因') + driver = await command('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'REJECTED', reason: '核对本轮测试资料' }, '正式打回司机资料') + await qualification(id, soonEnd, 'REJECTED') + driver = await command('POST', `${path}/submit`, { recordVersion: driver.recordVersion }, '正式重提司机资料') + const beforeApproval = driver.recordVersion + driver = await command('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED' }, '通过已补齐驾驶证的测试司机') + await qualification(id, soonEnd, 'ELIGIBLE', true) + await qualification(id, endBefore, 'ELIGIBLE', true) + await qualification(id, nextDay, 'EXPIRES_DURING_TRIP', true) + const missingEnd = await command('GET', path, undefined, '不填任务结束时间读取详情') + check('没有预计结束时间不默认为可派', null, !missingEnd.qualification.canDispatch && missingEnd.qualification.reason === 'MISSING_TRIP_END') + await qualification(id, new Date(Date.now() - 60000).toISOString(), 'INVALID_TRIP_END') + await reject('PUT', path, { recordVersion: beforeApproval, details }, 'A0409', '旧司机版本不能覆盖审核结果') + await download(id, originalFile, '本司机驾驶证下载字节一致') + const other = await command('POST', BASE, { ...details, name: `${marker}隔离司机`, licenseNumber: `${marker}-B` }, '建立附件归属隔离司机') + proof.drivers.push({ id: String(other.id), driverNo: other.driverNo, files: [] }) + await reject('GET', `${BASE}/${other.id}/license/${originalFile}`, undefined, 'A0404', '其他司机不能通过自身路径下载本司机证照') + driver = await command('PUT', path, { recordVersion: driver.recordVersion, details: { ...details, remark: '仅补充备注' } }, '更正非资格信息') + check('备注更正不清除已通过的驾驶资格', null, driver.reviewStatus === 'APPROVED') + await qualification(id, soonEnd, 'ELIGIBLE', true) + const renewed = { ...details, licenseExpiresOn: day(90) } + driver = await command('PUT', path, { recordVersion: driver.recordVersion, details: renewed }, '更新证照有效期') + check('关键证照变化回到待审', null, driver.reviewStatus === 'PENDING') + await qualification(id, soonEnd, 'PENDING_REVIEW', false) + driver = await command('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED' }, '复核新证照有效期') + await qualification(id, soonEnd, 'ELIGIBLE', false) + const replacement = await context.requestMultipart('POST', `${path}/license?recordVersion=${driver.recordVersion}`, { name: `${marker}-REPLACEMENT-TEST.png`, type: 'image/png', content: PNG }) + driver = context.apiSuccess(MODULE, '替换本轮测试证照', replacement, '', OPTIONS) + check('替换附件必须重新审核', replacement, driver?.reviewStatus === 'PENDING' && driver.licenseFileId) + own.files.push(String(driver.licenseFileId)) + await download(id, originalFile, '原证照仍可按本司机历史归属下载') + await download(id, driver.licenseFileId, '当前证照可下载') + driver = await command('PUT', path, { recordVersion: driver.recordVersion, details: { ...details, licenseExpiresOn: day(-1) } }, '登记已过期测试证照') + check('详情明确显示驾驶证已过期', null, driver.licenseState === 'EXPIRED' && !driver.qualification.canDispatch) + await reject('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED' }, 'A0400', '过期驾驶证不能通过审核') + const history = await command('GET', `${path}/history?page=1&size=200`, undefined, '读取司机完整操作历史') + check('历史保留上传审核打回重提和旧证照', null, ['CREATE', 'UPLOAD_LICENSE', 'REJECTED', 'RESUBMIT', 'APPROVED', 'UPDATE'].every(action => history.records.some(row => row.action === action)) + && history.records.some(row => String(row.snapshot?.licenseFileId) === originalFile)) + proof.status = 'VERIFIED' + } catch (error) { proof.status = 'FAILED'; proof.error = error instanceof Error ? error.message : String(error); failure = error } + finally { + for (const resource of proof.drivers) { + try { + const current = await command('GET', `${BASE}/${resource.id}`, undefined, '停用前读取本轮司机最新版本') + const ended = current.active ? await command('PATCH', `${BASE}/${resource.id}/status`, { recordVersion: current.recordVersion, active: false, reason: '本轮测试结束,停用专用司机并保留证据' }, '停用本轮测试司机') : current + check('测试司机已停用', null, ended.active === false); resource.cleanup = 'INACTIVE' + await qualification(resource.id, soonEnd, 'INACTIVE') + } catch (error) { resource.cleanup = 'FAILED'; proof.status = 'FAILED'; resource.cleanupError = error instanceof Error ? error.message : String(error); failure ??= error } + } + } + if (failure) throw failure +}