diff --git a/scripts/README.md b/scripts/README.md index 2145c12fd..d5e3f713a 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 --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`。 +`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配置>"` 读取凭据、固定客户、`ADMIN_TEST_OVERSEAS_PDA_ACTORS` 和 `ADMIN_TEST_FLEET_RESOURCES`;不自动创建账号或车辆司机主档。报告位于 `reports/dispatch-loading-/`,保留失败前的请求和业务数据;同标识拒绝重跑。`--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。业务单据及附件仍按本轮证据需要新增;不自动清理历史记录。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 6443b6931..a4ff42202 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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 行为不变。 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 d0d3c9e38..3e231a42b 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 @@ -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))) }) diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index c87cb5cba..102c47fe4 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -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, '通过排车主单编辑正式分配车辆司机') diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 8194cabf3..13c0b1469 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -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查询完成退货单的可合箱库存', diff --git a/scripts/dispatch-loading-flow-test-env.mjs b/scripts/dispatch-loading-flow-test-env.mjs index c9b67bfa0..64de0bd56 100644 --- a/scripts/dispatch-loading-flow-test-env.mjs +++ b/scripts/dispatch-loading-flow-test-env.mjs @@ -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)