From 885cac13a4d9eb8ac7646532866317855aa2adaf Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 10 Oct 2026 08:07:27 +0800 Subject: [PATCH] test(fleet): exercise simplified intake HTTP workflow --- scripts/README.md | 2 +- scripts/fleet-capacity-pool-test-env.mjs | 60 +++++++++++++++++------- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index e2bb151c2..4ea96c75f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -18,7 +18,7 @@ `fleet-capacity-pool-gray.mjs` 是明确 GRAY 的拼车专项入口,复用既有池全流程矩阵与两客户实货前置,不执行旧 FBA 下单、报关或装柜链。默认 `--plan` 零网络、零文件写入;执行必须传 `--execute --environment GRAY --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `。命令前使用 `node --env-file=<独立jenkins.env> --env-file=<明确gray-acceptance.env>`,要求 `CHONGCHONG_ENV=prod` 或 `gray`、`CHONGCHONG_ADMIN_API_BASE_URL`(统一 HTTPS 源站加 `/api`)、`CHONGCHONG_ADMIN_USERNAME/PASSWORD` 和 `JENKINS_URL/USER/API_TOKEN`;不改私有配置、不冒充 TEST、不使用 TEST agent。库存证据由 `createGrayEvidenceReaders` 校验生产容器、profile及数据库身份后只读取得。报告独占 `reports/fleet-pool-gray-<新批次>/`,沿共享 `.fleet-acceptance.lock` 串行执行;每次写入先持久记录原键与意图,结果未知停写,失败保留样本,不自动续跑或覆盖。结束停用本轮临时销售/调度/PDA账号、退出会话;合成客户、司机/车辆、报价、文件和业务样本留存,费用仍由唯一真实派送来源产生,不发起支付。Admin 通用 GRAY CLI 的 F40 白名单不变,专用 transport factory 只能创建空模块且无故障注入的灰度上下文。直接合同 `fleet-capacity-pool-gray.test.mjs` 验证环境隔离、品牌门禁和复用范围;原 TEST CLI 仍拒绝 GRAY。本入口不部署、不写业务 SQL,不将专项通过冒充全部页面验收。 -拼车主入口 `fleet-capacity-pool-test-env.mjs` 除两笔真实履约订单外,还各创建一笔本土散板/卡车的“已确认、未登记来货”取消样本,核对商业单与预订同步取消、三维容量恢复和原键重放;增加来货后取消/实收超量拒绝、绑定后占位转计划不双扣、事实失效须重新核对并显式恢复,以及跨销售写入/跨客户商业单拒绝。拒绝动作须回查原事实与库存不变,不能仅按错误码判定通过;不注入业务数据库故障。 +拼车主入口 `fleet-capacity-pool-test-env.mjs` 除两笔真实履约订单外,还各创建一笔本土散板/卡车的“已确认、未登记来货”取消样本,核对商业单与预订同步取消、三维容量恢复和原键重放;增加来货后取消/实收超量拒绝、绑定后占位转计划不双扣、事实失效须重新核对并显式恢复,以及跨销售写入/跨客户商业单拒绝。正常链通过拼车专属 `save-and-confirm`、`fulfillment`、`incoming`、`receipt-confirmation` 和 `arrange-dispatch` 接口验证简化操作及原命令重放,不再以脚本手工建排车再绑定代替自动关联;另建精确履约权限账号,验证仅有拼车管理权限不能安排来货、确认实收或生成排车,销售仅能读取本人履约。拒绝动作须回查原事实与库存不变,不能仅按错误码判定通过;不注入业务数据库故障。 `fleet-capacity-pool-ui-fixture-test-env.mjs` 仅准备内部拼车池TEST页面验收数据,默认 `--plan` 零网络;实际命令沿 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id <目的仓ID>`,另必须显式传 `--private-credentials-file <用户.codex/env/chongchong-global目录直接子级的独占JSON绝对路径>`。通过明确TEST环境文件读取CHONGCHONG_ADMIN及Jenkins只读取证配置,创建两客户真实来货和计划车次、OPEN发布、两销售各12条不占位草稿及最小调度账号;停在可交互状态,不确认承运、不拣货发车、不声称全量验收。密码只在首次写入前独占创建的私有文件中保存,报告 `reports/fleet-capacity-pool-ui-<新批次>/` 不含密码/Token;失败保留现场和原请求,不自动重跑。退出脚本会话并停用自有fixture PDA,保留UI账号供浏览器验收;不部署、不写业务SQL、不删除业务样本。离线安全与作用域合同为 `fleet-capacity-pool-ui-fixture-test-env.test.mjs`。 diff --git a/scripts/fleet-capacity-pool-test-env.mjs b/scripts/fleet-capacity-pool-test-env.mjs index 43cdd47a9..347c9ac59 100644 --- a/scripts/fleet-capacity-pool-test-env.mjs +++ b/scripts/fleet-capacity-pool-test-env.mjs @@ -35,6 +35,8 @@ export const POOL_COVERAGE = Object.freeze([ 'bound-cargo-reservation-removed-without-double-count', 'other-sales-write-and-sensitive-trip-denied', 'other-customer-commercial-read-denied-with-source-unchanged', 'pool-unspecified-container-and-palletizing-remain-unknown', + 'save-and-confirm-original-command-replay', 'pool-fulfillment-owner-read-isolation', + 'pool-fulfillment-explicit-write-permissions', 'pool-arrange-dispatch-without-manual-number', ]) export const POOL_REMAINING = Object.freeze([ 'out-of-band-assigned-vehicle-drift-not-constructible-through-supported-api', @@ -311,6 +313,9 @@ async function poolScenario(context, fixture, { execute = false, readInventory, const sales = [], dispatchers = [] for (let n = 0; n < 2; n++) sales.push(await createActor('sales', n, ['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(s => PREFIX + s))) for (let n = 0; n < 2; n++) dispatchers.push(await createActor('dispatch', n, [PREFIX + 'manage'])) + const fulfillmentToken = await createActor('fulfillment', 0, [PREFIX + 'manage', + 'platform:fleet:trip:booking-edit', 'platform:fleet:trip:booking-confirm', 'platform:fleet:trip:edit', + 'platform:overseas-warehouse:inbound-order:smart-dispatch']) for (const member of fixture.members) { const credential = credentials?.get(member.customerId) check('Original fixture customer credentials available only in memory', Boolean(credential)) @@ -347,6 +352,11 @@ async function poolScenario(context, fixture, { execute = false, readInventory, proof.covered.push('unknown-load-stays-null') const first = await createInquiry(0, 'LOCAL_LOOSEBOARD', 8, 'LOOSE') await reject('Other sales cannot read this inquiry', 'GET', `${POOL}/inquiries/${first.bookingNo}`, undefined, sales[1]) + const ownFulfillment = await request('GET', `${POOL}/inquiries/${first.bookingNo}/fulfillment`, undefined, sales[0]) + check('Owner reads only the selected booking without trip-list permission', ownFulfillment.booking.bookingNo === first.bookingNo + && ownFulfillment.booking.customerId === first.customerId && !ownFulfillment.dispatches && !ownFulfillment.bookings) + await reject('Other sales cannot read dedicated fulfillment', 'GET', `${POOL}/inquiries/${first.bookingNo}/fulfillment`, undefined, sales[1]) + proof.covered.push('pool-fulfillment-owner-read-isolation') const second = await createInquiry(1, 'US_TRUCK', 4, 'TRUCK-A') check('Same display name does not collapse distinct sales identities', first.salesOwnerUserId && second.salesOwnerUserId && first.salesOwnerUserId !== second.salesOwnerUserId) proof.covered.push('two-sales-owner-isolation') @@ -393,9 +403,13 @@ async function poolScenario(context, fixture, { execute = false, readInventory, command, businessPath, dispatcherToken: dispatchers[index], label: `CANCEL-${type}` })) } proof.covered.push('confirmed-cancel-before-incoming-releases-capacity-and-replays') - let current = await latest(first.bookingNo), firstCommand = command(current, 'CONFIRM-LOOSE') - await reject('Sales cannot confirm capacity', 'POST', `${POOL}/inquiries/${first.bookingNo}/confirm`, firstCommand, sales[0]) - const firstConfirmed = await request('POST', `${POOL}/inquiries/${first.bookingNo}/confirm`, firstCommand, dispatchers[0]) + let current = await latest(first.bookingNo), firstCommand = poolInquiryWrite(current, key('SAVE-CONFIRM-LOOSE')) + await reject('Sales cannot save and confirm capacity', 'POST', `${POOL}/inquiries/${first.bookingNo}/save-and-confirm`, firstCommand, sales[0]) + const firstConfirmed = await request('POST', `${POOL}/inquiries/${first.bookingNo}/save-and-confirm`, firstCommand, dispatchers[0]) + const saveReplay = await request('POST', `${POOL}/inquiries/${first.bookingNo}/save-and-confirm`, firstCommand, dispatchers[0]) + assertSingleCommercial(firstConfirmed.inquiry, saveReplay.inquiry) + check('Save and confirm original request replays one commercial commitment', saveReplay.replayed === true) + proof.covered.push('save-and-confirm-original-command-replay') assertPoolRemaining(firstConfirmed.offer, destinationKey, 4) proof.covered.push('same-segment-plan-18-of-30-reserve-8-leaves-4') const oversized = await createInquiry(1, 'US_TRUCK', 5, 'OVER-CAPACITY') @@ -426,7 +440,7 @@ async function poolScenario(context, fixture, { execute = false, readInventory, && foreignRead.requestId && foreignRead.traceId, foreignRead) check('Denied old status and foreign read preserve commercial source', JSON.stringify(source) === JSON.stringify(await request('GET', businessPath(order)))) poolMembers.push(await receiveAndBindPoolOrder(context, fixture, order, index, { request, reject, check, key, readInventory, - latest, command, businessPath, offerDetail: () => offerDetail(dispatchers[0]), dispatcherToken: dispatchers[0], destinationKey, proof })) + latest, command, businessPath, offerDetail: () => offerDetail(dispatchers[0]), dispatcherToken: dispatchers[0], fulfillmentToken, destinationKey, proof })) } proof.covered.push('old-manual-order-endpoints-rejected', 'one-commercial-order-and-one-incoming', 'both-business-types-real-pda-receipt', 'other-customer-commercial-read-denied-with-source-unchanged', 'incoming-cancel-rejected-with-source-unchanged', @@ -436,7 +450,7 @@ async function poolScenario(context, fixture, { execute = false, readInventory, const closed = await request('POST', `${POOL}/${proof.offerNo}/close`, { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion, idempotencyKey: key('CLOSE'), reason: context.marker }, dispatchers[0]) check('Offer closed without cancelling commitments', closed.offer.status === 'CLOSED') - const replay = await request('POST', `${POOL}/inquiries/${first.bookingNo}/confirm`, firstCommand, dispatchers[0]) + const replay = await request('POST', `${POOL}/inquiries/${first.bookingNo}/save-and-confirm`, firstCommand, dispatchers[0]) assertSingleCommercial(firstConfirmed.inquiry, replay.inquiry) check('Successful confirmation still replays after close', replay.replayed === true) for (const row of beforeClose) check('Old confirmation replay cannot revert bound source', inquiryFact(row) === inquiryFact(await latest(row.bookingNo))) @@ -502,14 +516,16 @@ async function poolScenario(context, fixture, { execute = false, readInventory, } async function receiveAndBindPoolOrder(context, fixture, order, index, { request, reject, check, key, readInventory, - latest, command, businessPath, offerDetail, dispatcherToken, destinationKey, proof }) { + latest, command, businessPath, offerDetail, dispatcherToken, fulfillmentToken, destinationKey, proof }) { const tripPath = `${API}/fleet/trips/${encode(fixture.tripNo)}`, bookingPath = `${tripPath}/bookings/${encode(order.bookingNo)}` + const poolPath = `${POOL}/inquiries/${encode(order.bookingNo)}` let bookingList = await request('GET', `${tripPath}/bookings`), booking = bookingList.records.find(row => row.bookingNo === order.bookingNo) check('Pool commercial association survived into existing booking workflow', booking?.status === 'CONFIRMED') const body = { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion, estimatedArrivalAt: new Date(Date.now() + 300000).toISOString(), deliveryInstructions: `${context.marker} TEST客户自送`, idempotencyKey: key(`INCOMING-${index}`) } - const created = await request('POST', `${bookingPath}/incoming`, body), incomingNo = created.booking.incomingNo - const repeated = await request('POST', `${bookingPath}/incoming`, body) + await reject('Pool management alone cannot arrange incoming', 'POST', `${poolPath}/incoming`, body, dispatcherToken) + const created = await request('POST', `${poolPath}/incoming`, body, fulfillmentToken), incomingNo = created.booking.incomingNo + const repeated = await request('POST', `${poolPath}/incoming`, body, fulfillmentToken) check('One incoming document on exact replay', incomingNo && repeated.replayed === true && repeated.booking.incomingNo === incomingNo) const incomingBeforeCancel = await request('GET', `${bookingPath}/incoming`), sourceBeforeCancel = await request('GET', businessPath(order)) const inquiryBeforeCancel = await latest(order.bookingNo), capacityBeforeCancel = (await offerDetail()).offer @@ -532,14 +548,14 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request check('Real TEST measurement file exists', success(uploaded) && uploaded.body.data?.fileId) const received = await request('POST', `${pdaPath}/palletize`, { idempotencyKey: key(`PALLETIZE-${index}`), photoFileIds: [id(uploaded.body.data.fileId)] }, fixture.pdaToken) check('Actual PDA receipt completed one original pallet', received.status === 'WAIT_CUSTOMER_CONFIRMATION' && received.pallets.length === 1 && received.pallets[0].status === 'COMPLETED') - const evidence = await request('GET', `${bookingPath}/incoming`) + const evidence = (await request('GET', `${poolPath}/fulfillment`, undefined, fulfillmentToken)).preview const beforeOverReceipt = await latest(order.bookingNo), sourceBeforeReceipt = await request('GET', businessPath(order)) const capacityBeforeReceipt = (await offerDetail()).offer, tripBeforeReceipt = await request('GET', tripPath) const inventoryBeforeReceipt = await readInventory({ marker: context.marker, customerId: order.customerId, warehouseId: fixture.warehouseId }) - const overReceipt = await reject('Actual received pallet spaces cannot exceed whole vehicle capacity', 'POST', `${bookingPath}/receipt-confirmation`, + const overReceipt = await reject('Actual received pallet spaces cannot exceed whole vehicle capacity', 'POST', `${poolPath}/receipt-confirmation`, { tripRecordVersion: evidence.tripRecordVersion, recordVersion: evidence.recordVersion, receiptHash: evidence.incoming.receiptHash, pallets: [{ warehousePalletId: id(pallet.warehousePalletId), palletSpaces: Number(tripBeforeReceipt.trip.maxPalletSpaces) + 1 }], - unitPrice: 30, idempotencyKey: key(`OVER-RECEIPT-${index}`) }) + unitPrice: 30, idempotencyKey: key(`OVER-RECEIPT-${index}`) }, fulfillmentToken) check('Receipt overrun is refused by actual capacity calculation', /超过.*限制|容量|载重|容积/.test(id(overReceipt.body?.message))) check('Rejected overreceipt leaves source receipt commercial booking and reservations unchanged', JSON.stringify(evidence) === JSON.stringify(await request('GET', `${bookingPath}/incoming`)) @@ -548,20 +564,28 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request && capacitySnapshot(capacityBeforeReceipt) === capacitySnapshot((await offerDetail()).offer)) check('Rejected overreceipt leaves complete customer warehouse inventory unchanged', JSON.stringify(inventoryBeforeReceipt) === JSON.stringify(await readInventory({ marker: context.marker, customerId: order.customerId, warehouseId: fixture.warehouseId }))) - await request('POST', `${bookingPath}/receipt-confirmation`, { tripRecordVersion: evidence.tripRecordVersion, recordVersion: evidence.recordVersion, - receiptHash: evidence.incoming.receiptHash, pallets: [{ warehousePalletId: id(pallet.warehousePalletId), palletSpaces: 1 }], unitPrice: 30, idempotencyKey: key(`RECEIPT-${index}`) }) + const receiptBody = { tripRecordVersion: evidence.tripRecordVersion, recordVersion: evidence.recordVersion, + receiptHash: evidence.incoming.receiptHash, pallets: [{ warehousePalletId: id(pallet.warehousePalletId), palletSpaces: 1 }], unitPrice: 30, idempotencyKey: key(`RECEIPT-${index}`) } + await reject('Pool management alone cannot confirm receipt', 'POST', `${poolPath}/receipt-confirmation`, receiptBody, dispatcherToken) + await request('POST', `${poolPath}/receipt-confirmation`, receiptBody, fulfillmentToken) const inventory = await readInventory({ marker: context.marker, customerId: order.customerId, warehouseId: fixture.warehouseId }) check('Immutable actual incoming STOCK_IN is unique', inventory.scope?.coverage === 'CUSTOMER_WAREHOUSE_COMPLETE' && inventory.ledger.filter(row => row.businessOrderNo === incomingNo && row.businessType === 'STOCK_IN' && Number(row.deltaPackages) === 1).length === 1) const dispatchBase = `${API}/dispatches`, candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: incomingNo })}`) check('Received candidate exactly belongs to this commercial incoming', candidates.length === 1 && candidates[0].inboundCode === incomingNo && id(candidates[0].customerId) === order.customerId && id(candidates[0].warehousePalletId) === id(pallet.warehousePalletId)) - const draftToken = key(`DRAFT-${index}`), container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken, capacity: 30 }) - await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidates[0].planPalletId), draftToken, draftContainerId: id(container.id) }) - const dispatch = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds: [id(candidates[0].planPalletId)], draftToken }) bookingList = await request('GET', `${tripPath}/bookings`); booking = bookingList.records.find(row => row.bookingNo === order.bookingNo) - await request('POST', `${bookingPath}/dispatch`, { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion, - dispatchNo: dispatch.dispatchNo, idempotencyKey: key(`BIND-DISPATCH-${index}`) }) + const arrangeBody = { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion, + idempotencyKey: key(`ARRANGE-DISPATCH-${index}`) } + await reject('Pool management alone cannot generate and bind dispatch', 'POST', `${poolPath}/arrange-dispatch`, arrangeBody, dispatcherToken) + const arranged = await request('POST', `${poolPath}/arrange-dispatch`, arrangeBody, fulfillmentToken) + const arrangeReplay = await request('POST', `${poolPath}/arrange-dispatch`, arrangeBody, fulfillmentToken) + check('One command generates and binds the actual receipt without a manually supplied number', arranged.booking.status === 'BOUND' + && arranged.booking.dispatchNo && arranged.booking.incomingNo === incomingNo && arranged.booking.customerId === order.customerId) + check('Arrange replay returns the original dispatch and version', arrangeReplay.replayed === true + && arrangeReplay.booking.dispatchNo === arranged.booking.dispatchNo && arrangeReplay.tripRecordVersion === arranged.tripRecordVersion) + const dispatch = { dispatchNo: arranged.booking.dispatchNo } + if (index === 0) proof.covered.push('pool-fulfillment-explicit-write-permissions', 'pool-arrange-dispatch-without-manual-number') let plan = await request('GET', `${tripPath}/capacity-plan`), detail = await request('GET', tripPath) const actualMeasurements = controlledPalletMeasurements(plan.cargo, detail.trip.stops) const restored = await verifyPoolBindingRevalidation({ offerDetail, check,