test: align live acceptance with dispatch photos and return counting
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
`procurement-sample-test-env.mjs` 复用固定客户后只读取钱包,不再创建充值流水;执行前要求现有可用余额至少 3 元(货款 1 元加运费冻结 2 元),不足或余额字段异常即停止。仍会创建本轮采购业务与附件,不复用历史完成单据。
|
||||
|
||||
`dispatch-loading-flow-test-env.mjs` 是 TEST 固定客户两托一车的定向 HTTP 验收入口,验证实收入库、确认预约、审核、扫码拣货、五张装车图片的 Admin 读取、自动出库派送及原请求幂等。默认只预览;显式 `--execute --run-id <唯一标识> --warehouse-id <起运仓> --destination-warehouse-id <FBA仓> --test-image <明确标注TEST的PNG>` 写入业务测试记录,通过 `node --env-file="<统一TEST配置>"` 读取凭据和固定客户。报告位于 `reports/dispatch-loading-<run-id>/`,保留失败前的请求和业务数据;同标识拒绝重跑,先核对原请求再决定恢复。`--resume-report <原报告>` 仅用于已明确收到编辑 A0400、仍为零拣货草稿的原单,保留原车与原司机,回查状态后补齐编辑字段继续;未知写入结果不能使用此恢复入口。默认停用本次临时 PDA 账号;`--keep-pda-for-ui` 仅在成功后将临时登录资料保存到系统临时目录供页面验收,页面结束后必须停用账号。所有 HTTP 会话结束时注销,不执行 SQL、删除、重置或部署。新增入口验证运行语法检查和 `script-documentation-contract.test.mjs`,经 `local-check.ps1`。
|
||||
`dispatch-loading-flow-test-env.mjs` 是 TEST 固定客户两托一车的定向 HTTP 验收入口,验证实收入库、确认预约、审核、扫码拣货、五张装车图片的 Admin 读取、自动出库派送及服务端幂等。默认只预览;显式 `--execute --run-id <唯一标识> --warehouse-id <起运仓> --destination-warehouse-id <FBA仓> --test-image <明确标注TEST的PNG>` 写入业务测试记录,通过 `node --env-file="<统一TEST配置>"` 读取凭据、固定客户、`ADMIN_TEST_OVERSEAS_PDA_ACTORS` 和 `ADMIN_TEST_FLEET_RESOURCES`;不自动创建账号或车辆司机主档。报告位于 `reports/dispatch-loading-<run-id>/`,保留失败前的请求和业务数据;同标识拒绝重跑。`--resume-report <原报告>` 仅接受准备写入前失败或全部准备写入均收到明确拒绝的失败报告,回查原单仍为零拣货草稿后重新准备车辆、三类审核照片与审核,不重复建货或建单;未知或成功写入不能使用此恢复入口。固定PDA账号仅注销会话,不停用;`--keep-pda-for-ui` 在成功后将账号交接信息保存到系统临时目录,页面验收使用私有配置中的既有凭据重新登录,不能将已注销Token当有效会话。所有 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。业务单据及附件仍按本轮证据需要新增;不自动清理历史记录。
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖
|
||||
|
||||
头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。
|
||||
|
||||
`fleet-trip-fulfillment.mjs` 是内部整车验收助手,不是独立入口。一张原生排车单对应一趟车;通过正式 API 准备资源与两级审核,读取唯一自动生成的 Fleet 记录。PDA 拣货及来源核验通过后,读取本车五个装车照片框,复用已保存文件,仅为缺失框上传标明 TEST 的合成 PNG,再绑定、原键重放并回读;上传前保存检查点与文件摘要,未知结果停写,不自动重新上传。照片在 LOADING 保存不得提前发车;AFTER_LOADING 最后一个缺失框保存后须进入 DELIVERING。资料齐全后重新读取主单资格与实载清单,再确认装车并核对自动生成的派送,不再调用手工安排派送或发车。实载数据由服务端返回,重量和体积缺失保持未知,不以请求中的测量值或计费量替代。PDA 上传及绑定始终保留原账号会话;ID 和版本无损保留,资源及文件留在报告。直接调用者须消费当前 DTO;离线合同通过不代表真实 HTTP、实物照片或 PDA 硬件验收。
|
||||
`fleet-trip-fulfillment.mjs` 是内部整车验收助手,不是独立入口。一张原生排车单对应一趟车;通过正式 API 准备资源与两级审核,读取唯一自动生成的 Fleet 记录。固定主档只读复用,送审前经本车证据接口上传标明TEST的车辆、司机及驾驶证三类合成图片并绑定,任何上传失败停止送审,不修改司机主档证照。PDA 拣货及来源核验通过后,读取本车五个装车照片框,复用已保存文件,仅为缺失框上传标明 TEST 的合成 PNG,再绑定、原键重放并回读;上传前保存检查点与文件摘要,未知结果停写,不自动重新上传。照片在 LOADING 保存不得提前发车;AFTER_LOADING 最后一个缺失框保存后须进入 DELIVERING。资料齐全后重新读取主单资格与实载清单,再确认装车并核对自动生成的派送,不再调用手工安排派送或发车。实载数据由服务端返回,重量和体积缺失保持未知,不以请求中的测量值或计费量替代。PDA 上传及绑定始终保留原账号会话;ID 和版本无损保留,资源及文件留在报告。直接调用者须消费当前 DTO;离线合同通过不代表真实 HTTP、实物照片或 PDA 硬件验收。
|
||||
|
||||
`prepareFleetTrip` 对零扫码的装车中排车,须先显式退回装车前再维护资源并重走两级审核;已扫码时不能重排。`confirmFleetLoading` 验证已扫码编辑被拒绝且主单与实际货物零漂移,然后按真实装载预览/实测回执发送装车完成命令;PDA成功后,平台使用同一原键原载荷重放,必须保持原结果和版本。报告`createdResources.fleetTrips[].reassignment`保留原请求、前后快照及拒绝证据;未知写结果停止,不自动换键重造。装车返回版本不替代发车前重新读取的公共版本。
|
||||
|
||||
@@ -178,7 +178,7 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖
|
||||
|
||||
退货专项只运行 `--modules phase4-overseas-return-receipt`,自动准备本轮客户;覆盖非法/重复预报、退货单号和 SKU 查询、非法/未知 SKU 实收、超收及失败不落账、分批签收完成、部分签收禁止合箱、合箱非法箱规/数量/来源、幂等重放前后库存快照一致、逐箱分页无重复遗漏、客户 SKU 数量守恒及标签。通过显式测试环境文件运行总入口,JSON 路径由 `--output` 指定;会创建隔离测试数据,finally 停用临时 PDA,不删除业务记录。失败保留报告,先核对记录再决定是否新批次重跑。
|
||||
|
||||
退货预报创建后为 `PENDING_REVIEW`。签收前必须通过客户详情确认归属,再经平台 `/return-orders/{returnNo}/review` 携带 `X-Customer-Id` 审核,客户回查 `PENDING_RECEIPT` 且实收为零;审核失败立即停止签收。`return-review-contract.test.mjs` 覆盖归属拒绝、审核权限失败和回查未收敛。主签收场景按单 SKU 分甲3件、乙2件两批,使用正式 PDA 退货托盘生成接口返回的 `palletCode`,不复用国内基础托盘;每批对应一个计费批次。按 `PALLET` 提交每 SKU 箱数和托盘长宽高、重量,总箱数由服务端汇总;主场景验证不上传可选照片仍能完成。边界场景继续验证合成 PNG 上传及合法多 SKU 同托签收,不把图片称为真实现场照片。边界扩展与换标后续仍需按新协议独立验证,主签收通过不能证明全部退货链通过。
|
||||
退货预报创建后为 `PENDING_REVIEW`。签收前必须通过客户详情确认归属,再经平台 `/return-orders/{returnNo}/review` 携带 `X-Customer-Id` 审核,客户回查 `PENDING_RECEIPT` 且实收为零;审核失败立即停止签收。`return-review-contract.test.mjs` 覆盖归属拒绝、审核权限失败和回查未收敛。主签收场景按单 SKU 分甲3件、乙2件两批,使用正式 PDA 退货托盘生成接口返回的 `palletCode`,不复用国内基础托盘;每批对应一个计费批次。按 `PALLET` 提交每 SKU 箱数和托盘长宽高、重量,总箱数由服务端汇总;主场景验证不上传可选照片仍能完成。边界场景继续验证合成 PNG 上传及合法多 SKU 同托签收,不把图片称为真实现场照片。边界扩展与换标后续仍需按新协议独立验证,主签收通过不能证明全部退货链通过。主链合箱前通过PDA箱内清点接口确认本轮两只实收箱的SKU数量和箱规;签收及部分合箱成功后,换新键提交旧数量快照必须被拒绝,且立即回查订单、批次、箱和SKU均未变化。
|
||||
|
||||
`return-boundary-scenarios.mjs` 是退货专项自动调用的内部扩展,不单独注册入口:本轮新建第二隔离客户,复用两个有效海外仓,覆盖跨客户单据/箱/标签/列表隔离、跨仓 PDA 隔离、两人争抢最后一件的签收及合箱、同键并发签收与成功后重试、多单同 SKU 全量合箱和实际来源追溯、耗尽箱清零、多单 SKU 候选和输入边界。异常写入逐次前后回查订单、签收批次、箱及 SKU;同键重试只模拟响应丢失后的客户端行为,不代表网络故障注入。每组并发严格为两个请求,不启动批量 worker;缺少第二仓或操作员属于失败,不能跳过后宣称通过。finally 停用新增 PDA,保留测试客户和业务记录;不触发部署、SQL、换标、出库或二次合箱。真实运行须明确授权目标环境及此小规模并发边界。`merchant-qualification-fixture.mjs` 的内部参数 `publishEnvironment: false` 只将第二客户凭据留在独立内存上下文,不覆盖主客户环境凭据,默认主 fixture 行为不变。
|
||||
|
||||
|
||||
@@ -115,6 +115,12 @@ function server(fault = '') {
|
||||
apiSuccess(_module, _label, response) { return response?.body?.code === '200' ? response.body.data : null },
|
||||
async requestMultipart(method, path, file) {
|
||||
calls.push({ method, path, file: { name: file.name, size: file.content.byteLength } })
|
||||
if (path.startsWith(`${D}/evidence/`)) {
|
||||
assert.equal(file.type, 'image/png')
|
||||
const kind = path.split('/').at(-1)
|
||||
assert.match(file.name, new RegExp(`TEST-${kind}`))
|
||||
return ok({ fileId: String(101 + ['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'].indexOf(kind)) })
|
||||
}
|
||||
assert.equal(file.type, 'image/png'); assert.match(file.name, /TEST-LICENSE/)
|
||||
uploadComplete = true; driverVersion++
|
||||
return ok({ id: DRIVER, licenseFileId: '9007199254740999', recordVersion: driverVersion })
|
||||
@@ -178,6 +184,9 @@ function server(fault = '') {
|
||||
assert.ok(driverReviewed); assert.equal(body.recordVersion, detail.recordVersion)
|
||||
assert.equal(body.vehicle.mode, 'MASTER'); assert.equal(body.vehicle.vehicleId, VID)
|
||||
assert.equal(body.driver.driverId, DRIVER); assert.ok(body.driver.evidence.expectedTaskEndAt)
|
||||
assert.deepEqual(body.vehicle.evidenceFileIds, ['101'])
|
||||
assert.deepEqual(body.driver.evidence.photoFileIds, ['102'])
|
||||
assert.deepEqual(body.driver.evidence.fileIds, ['103'])
|
||||
detail.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: VID, plate: nextPlate, carrierName: 'TEST自有车', capacity: { palletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } },
|
||||
driver: { mode: 'MASTER', driverId: DRIVER, name: 'TEST司机', phone: '19900000000' } }
|
||||
Object.assign(trip, { vehicleId: VID, vehiclePlate: nextPlate, driverId: DRIVER })
|
||||
@@ -299,7 +308,7 @@ test('native preparation edits existing master then submits both reviews, discov
|
||||
assert.ok(s.assertions.every(a => a.passed))
|
||||
})
|
||||
|
||||
test('fixed customer prepares a real trip without creating vehicles, drivers or uploading licenses', async () => {
|
||||
test('fixed customer reuses master records and uploads only this dispatch review photos', async () => {
|
||||
const s = server('fixed-master')
|
||||
s.context.options = { fixedCustomerId: '123', fixedFleetResources: [{ vehicleId: VID, driverId: DRIVER }] }
|
||||
const proof = await prepare(s)
|
||||
@@ -307,7 +316,8 @@ test('fixed customer prepares a real trip without creating vehicles, drivers or
|
||||
assert.equal(proof.preparation, 'VERIFIED')
|
||||
assert.equal(proof.vehicleId, VID)
|
||||
assert.equal(proof.driverId, DRIVER)
|
||||
assert.equal(s.calls.some(call => call.file), false)
|
||||
assert.deepEqual(s.calls.filter(call => call.file).map(call => call.path),
|
||||
['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'].map(kind => `${D}/evidence/${kind}`))
|
||||
assert.ok(s.calls.filter(call => call.method !== 'GET').every(call => call.path.startsWith(D)))
|
||||
})
|
||||
|
||||
|
||||
@@ -128,10 +128,28 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId)
|
||||
if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID')
|
||||
await prepareQualifiedDriver(context, module, proof, arrival)
|
||||
}
|
||||
proof.reviewPhotos = {}
|
||||
for (const kind of ['VEHICLE', 'DRIVER_PHOTO', 'DRIVER']) {
|
||||
const upload = { path: `${path}/evidence/${kind}`, name: `${proof.marker}-TEST-${kind}.png`, status: 'PREPARED' }
|
||||
proof.reviewPhotos[kind] = upload
|
||||
await context.checkpoint?.()
|
||||
let response
|
||||
try {
|
||||
response = await context.requestMultipart('POST', upload.path, { name: upload.name, type: 'image/png',
|
||||
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') })
|
||||
} catch (error) { upload.status = 'UNKNOWN'; await context.checkpoint?.(); throw error }
|
||||
Object.assign(upload, { status: 'RESPONDED', http: response?.http, code: response?.body?.code })
|
||||
const file = context.apiSuccess(module, `上传本车TEST合成${kind}审核照片`, response, '', OPTIONS)
|
||||
if (response?.http !== 200 || String(response?.body?.code) !== '200' || !/^[1-9]\d*$/.test(id(file?.fileId)))
|
||||
throw Error(`本车${kind}审核照片上传失败,停止送审`)
|
||||
upload.fileId = id(file.fileId)
|
||||
await context.checkpoint?.()
|
||||
}
|
||||
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 } },
|
||||
vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId, evidenceFileIds: [proof.reviewPhotos.VEHICLE.fileId] },
|
||||
driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt,
|
||||
fileIds: [proof.reviewPhotos.DRIVER.fileId], photoFileIds: [proof.reviewPhotos.DRIVER_PHOTO.fileId] } },
|
||||
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: arrival,
|
||||
remark: `${context.marker} TEST合成资料正常履约` }
|
||||
let result = await command(context, module, 'PUT', path, proof.updateRequest, '通过排车主单编辑正式分配车辆司机')
|
||||
|
||||
@@ -412,6 +412,17 @@ async function runWithSession(context, merchantToken) {
|
||||
label.data?.cartonMark === expected.cartonMark && label.data?.sourceBatchNo === expected.batchNo
|
||||
&& Number(label.data?.totalQuantity) === expected.quantity && nonBlank(label.data?.printableHtml),
|
||||
trace(label.response), OPTIONS)
|
||||
const receiptNo = index === 0 ? firstReceiptNo : secondReceiptNo
|
||||
const countingPath = `/api/pda/v1/last-leg/return-inventory/counting/receipts/${encodeURIComponent(receiptNo)}`
|
||||
const counting = await overseas(context, picker.token, 'POST', `开始第${index + 1}个实收箱箱内清点`, `${countingPath}/begin`)
|
||||
if (!success(counting.response) || counting.data?.boxes?.length !== 1
|
||||
|| counting.data.boxes[0].cartonMark !== expected.cartonMark) throw Error('本轮实收箱清点身份不一致')
|
||||
const counted = await overseas(context, picker.token, 'POST', `确认第${index + 1}个合成实收箱SKU和箱规`, countingPath, {
|
||||
idempotencyKey: key(context, `COUNT-${index + 1}`),
|
||||
boxes: [{ cartonMark: expected.cartonMark, items: [{ skuCode: index === 0 ? skuA : skuB, quantity: expected.quantity }],
|
||||
length: 30, width: 20, height: 10, weight: 2 }],
|
||||
})
|
||||
if (!success(counted.response)) throw Error('本轮实收箱清点未成功,停止合箱')
|
||||
}
|
||||
|
||||
const packing = await overseas(context, picker.token, 'GET', 'PDA查询完成退货单的可合箱库存',
|
||||
|
||||
@@ -117,10 +117,9 @@ export async function runLoadingFlow(config, env=process.env) {
|
||||
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
|
||||
assert.equal(previous.success,false)
|
||||
assert((previous.createdResources.fleetTripCommands||[]).every(c=>c.status==='RESPONDED' && c.http===200 && String(c.code)!=='200'),'Only a pre-write failure or definite rejected preparation may resume')
|
||||
report.trip=await prepareFleetTrip(context,'dispatch-loading-flow',report.dispatchNo,config.warehouseId)
|
||||
}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)
|
||||
|
||||
Reference in New Issue
Block a user