diff --git a/scripts/README.md b/scripts/README.md index 8c1d7baca..7d8447aad 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,9 @@ # 后端脚本管理与全量 HTTP 验收索引 +拼车主入口 `fleet-capacity-pool-test-env.mjs` 除两笔真实履约订单外,还各创建一笔本土散板/卡车的“已确认、未登记来货”取消样本,核对商业单与预订同步取消、三维容量恢复和原键重放;增加来货后取消/实收超量拒绝、绑定后占位转计划不双扣、事实失效须重新核对并显式恢复,以及跨销售写入/跨客户商业单拒绝。拒绝动作须回查原事实与库存不变,不能仅按错误码判定通过;不注入业务数据库故障。 + +`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`。 + `fleet-capacity-pool-test-env.mjs` 是内部拼车需求池 TEST 专项入口,默认 `--plan` 零网络;实际执行须显式 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id <目的仓ID>`,凭据由命令前的 `node --env-file=<明确TEST配置>` 提供,配置同时包含 Jenkins 只读取证凭据与 `FBA_CORE_JENKINS_AGENT`。创建隔离客户、销售、调度、预订及商业订单,验证容量争抢、销售隔离、命令重放、两类本土订单实际到仓/PDA收货/共享车次/出库签收和费用唯一;不部署、不写业务SQL。证据保存在 `reports/fleet-cross-<新批次>/`,包含 `pool-scope.json` 和写请求journal;未知结果停止,保留原样本与请求,不覆盖批次、不自动重造数据。结束退出独立会话并沿fixture清理其自有PDA访问,业务样本保留;页面及未覆盖矩阵单独报告,不将专项通过当成全量验收。离线保护由 `fleet-capacity-pool-test-env.test.mjs` 验证。 `inbound-plan-resource-fault-executor.mjs` 是单柜入仓计划资源确认故障的 TEST 环境执行入口,内部使用 `inbound-plan-resource-fault-runtime.py`。默认 `plan --plan <单柜计划.json>` 零网络;实际动作必须读取明确的TEST/Jenkins外部配置并传 `--confirm-test-runtime yes`。`preflight --routing <私有路由.json>` 只读校验Lastleg与Warehouse Overseas镜像/指纹、实际网络和Nacos common/lastleg配置摘要;`prepare`另要求 `--expected-image <已冻结镜像ID>` 及预检摘要。`activate`只临时改Lastleg的 `baiyue.warehouse.base-url`,配合 `mode --mode CONFIRM_ONCE --ttl 60` 精确注入一次confirm失败;业务转换和关闭重开页面由调用者独立操作,工具没有造数、SQL或业务写接口。`status/health/records`用于观测,结果未知先 `status/reconcile`,禁止因观察超时重复创建容器;`restore/verify-restored/finish`须按原镜像、完整配置/挂载/网络、Nacos及上游健康恢复后才停自有代理、清私有凭据。服务变更先记录意图,受限远端恢复状态目录为 `/tmp/chongchong-inbound-resource-`,由总控持独占Lastleg故障窗口;调用结果和业务证据另存本次独立 `reports/` 目录,失败保留、不覆盖,不与部署或其他共享业务写入并行。`inbound-plan-resource-fault-executor.test.mjs`、`inbound-plan-resource-fault-runtime.test.mjs` 为离线边界及恢复测试,不证明真实故障已跑通。 diff --git a/scripts/fleet-capacity-pool-test-env.mjs b/scripts/fleet-capacity-pool-test-env.mjs index 200917cbf..bc6154d48 100644 --- a/scripts/fleet-capacity-pool-test-env.mjs +++ b/scripts/fleet-capacity-pool-test-env.mjs @@ -30,6 +30,10 @@ export const POOL_COVERAGE = Object.freeze([ 'merchant-cannot-read-pool', 'old-manual-order-endpoints-rejected', 'assigned-vehicle-disable-and-shrink-rejected', 'paused-create-edit-confirm-rejected', 'expired-cutoff-create-edit-confirm-rejected', 'weight-and-volume-independent-capacity-rejections', + 'confirmed-cancel-before-incoming-releases-capacity-and-replays', 'incoming-cancel-rejected-with-source-unchanged', + 'receipt-overcapacity-rejected-with-source-unchanged', 'open-offer-bind-replan-requires-explicit-resume', + 'bound-cargo-reservation-removed-without-double-count', 'other-sales-write-and-sensitive-trip-denied', + 'other-customer-commercial-read-denied-with-source-unchanged', ]) export const POOL_REMAINING = Object.freeze([ 'out-of-band-assigned-vehicle-drift-not-constructible-through-supported-api', @@ -40,7 +44,7 @@ export function parsePoolArguments(args) { return parseCrossCustomerArguments(ar export function poolPlan(config) { return { environment: 'TEST', execute: Boolean(config.execute), runId: config.runId, output: `reports/fleet-cross-${config.runId}`, coveredByThisEntry: POOL_COVERAGE, remainingSeparateAcceptance: POOL_REMAINING, - sample: 'Two fresh qualified customers and owned one-box pallets, one 30-space trip, two pool business orders, one real incoming/pallet each. Planning uses 18 spaces; actual physical load is separately confirmed.', + sample: 'Two fresh qualified customers and owned one-box pallets, one 30-space trip. Each pool business type has one safely cancelled pre-incoming sample and one fulfilled order with one real incoming/pallet. Planning uses 18 spaces; actual physical load is separately confirmed.', prerequisites: ['explicit TEST private env with CHONGCHONG_ENV=test and CHONGCHONG_ADMIN_*', 'current Warehouse/FBA IDs', 'TEST read-only inventory evidence credentials', 'current capacity-offer permission seeds and backend deployed'], boundary: 'Default plan performs no network or filesystem writes. Execute retains every sample and original command, blocks all further writes on an unknown outcome, never deploys or writes business SQL. No automatic rerun or resume.' } @@ -58,6 +62,79 @@ export function assertSingleCommercial(before, after) { && before.customerId === after.customerId, 'COMMERCIAL_REPLAY_IDENTITY_CHANGED') } +export function poolInquiryWrite(inquiry, idempotencyKey) { + return { tripRecordVersion: inquiry.tripRecordVersion, offerRecordVersion: inquiry.offerRecordVersion, + recordVersion: inquiry.recordVersion, customerId: inquiry.customerId, businessType: inquiry.businessType, + deliveryMode: inquiry.deliveryMode, estimatedArrivalAt: inquiry.estimatedArrivalAt, + expectedPalletCount: inquiry.expectedPalletCount, cargo: inquiry.cargo, quoteRef: null, idempotencyKey } +} + +export function poolReadIsDenied(response) { + return ([403, 404].includes(response?.http) || response?.http === 200 && ['A0403', 'A0404'].includes(id(response.body?.code))) + && response.body?.data == null +} + +/** This fixture has exactly one shared unloading segment; verify all dimensions, not only a badge. */ +export function assertPoolBoundCapacity(offer, destinationKey, planned, reserved, capacity) { + requireThat(offer?.status === 'OPEN' && offer.capacityKnown && offer.canConfirm && offer.segments?.length === 1, + 'OPEN_KNOWN_SINGLE_SEGMENT_REQUIRED') + const segment = offer.segments[0] + requireThat(segment.fromIndex === 0 && segment.toIndex === 1, 'EXACT_SHARED_SEGMENT_REQUIRED') + for (const dimension of ['palletSpaces', 'weightKg', 'volumeCbm']) { + const expected = Number(capacity[dimension]) - Number(planned[dimension]) - Number(reserved[dimension]) + requireThat(Number.isFinite(expected) && Number(segment.planned?.[dimension]) === Number(planned[dimension]) + && Number(segment.reserved?.[dimension]) === Number(reserved[dimension]) + && Number(segment.remaining?.[dimension]) === expected, `BOUND_LOAD_DOUBLE_COUNT_OR_MISSING_${dimension}`) + const destinations = offer.destinations?.filter(row => row.destinationKey === destinationKey) + requireThat(destinations?.length === 1 && Number(destinations[0].remaining?.[dimension]) === expected, + `BOUND_DESTINATION_REMAINING_MISMATCH_${dimension}`) + } +} + +export async function verifyPoolConfirmedCancellation({ inquiry, destinationKey, request, check, latest, command, + businessPath, dispatcherToken, label }) { + const path = `${POOL}/inquiries/${inquiry.bookingNo}` + const confirmed = await request('POST', `${path}/confirm`, command(await latest(inquiry.bookingNo), `${label}-CONFIRM`), dispatcherToken) + check('Cancellation sample really reserves one space', confirmed.inquiry.status === 'CONFIRMED' && confirmed.inquiry.businessOrderNo) + assertPoolRemaining(confirmed.offer, destinationKey, 11) + const before = await request('GET', businessPath(confirmed.inquiry)) + check('Confirmed cancellation sample has no incoming', before.summary.status === 'WAITING_INBOUND' + && before.summary.fleetArrangement?.bookingNo === inquiry.bookingNo && !before.summary.fleetArrangement.incomingNo) + const cancelBody = { ...command(await latest(inquiry.bookingNo), `${label}-CANCEL`), reason: 'TEST未登记来货取消并释放承诺' } + const cancelled = await request('POST', `${path}/cancel`, cancelBody, dispatcherToken) + assertSingleCommercial(confirmed.inquiry, cancelled.inquiry) + check('Confirmed booking is cancelled before incoming', cancelled.inquiry.status === 'CANCELLED' && !cancelled.inquiry.incomingNo) + assertPoolRemaining(cancelled.offer, destinationKey, 12) + const commercial = await request('GET', businessPath(cancelled.inquiry)) + check('Commercial cancellation is atomic with reservation release', commercial.summary.status === 'CANCELLED' + && commercial.summary.fleetArrangement?.bookingNo === inquiry.bookingNo && !commercial.summary.fleetArrangement.incomingNo) + const replay = await request('POST', `${path}/cancel`, cancelBody, dispatcherToken) + check('Original cancellation command replays without changing source', replay.replayed === true + && inquiryFact(replay.inquiry) === inquiryFact(cancelled.inquiry)) + check('Cancel replay keeps persisted commercial and booking facts unchanged', + JSON.stringify(commercial) === JSON.stringify(await request('GET', businessPath(cancelled.inquiry))) + && inquiryFact(await latest(inquiry.bookingNo)) === inquiryFact(cancelled.inquiry)) + const current = await request('GET', `${POOL}/${inquiry.offerNo}`, undefined, dispatcherToken) + assertPoolRemaining(current.offer, destinationKey, 12) + return { bookingNo: inquiry.bookingNo, businessOrderNo: cancelled.inquiry.businessOrderNo, businessType: inquiry.businessType, + status: 'CANCELLED', replayed: true, remainingPalletSpaces: 12 } +} + +export async function verifyPoolBindingRevalidation({ offerDetail, replan, resume, blockedConfirm, check }) { + const invalidated = (await offerDetail()).offer + check('Binding invalidates capacity while publication remains OPEN', invalidated.status === 'OPEN' + && !invalidated.capacityKnown && !invalidated.canConfirm && /计划|核对/.test(id(invalidated.blockReason))) + await blockedConfirm('BIND-INVALIDATED') + await replan() + const recalculated = (await offerDetail()).offer + check('Recalculating cargo alone cannot silently resume acceptance', recalculated.status === 'OPEN' + && !recalculated.capacityKnown && !recalculated.canConfirm && /恢复接单|承诺已变化/.test(id(recalculated.blockReason))) + await blockedConfirm('REPLANNED-NOT-RESUMED') + const restored = await resume(recalculated) + check('Only explicit resume makes current capacity confirmable', restored.status === 'OPEN' && restored.capacityKnown && restored.canConfirm) + return restored +} + export function poolPickScope(context, member) { requireThat(context?.marker && /^[1-9]\d*$/.test(id(member?.customerId)) && member?.dispatchNo, 'EXACT_CUSTOMER_DISPATCH_PICK_SCOPE_REQUIRED') @@ -261,6 +338,20 @@ async function poolScenario(context, fixture, { execute = false, readInventory, const latest = no => request('GET', `${POOL}/inquiries/${no}`, undefined, dispatchers[0]) const command = (inquiry, label) => ({ tripRecordVersion: inquiry.tripRecordVersion, offerRecordVersion: inquiry.offerRecordVersion, recordVersion: inquiry.recordVersion, idempotencyKey: key(label) }) + const ownershipBefore = await latest(first.bookingNo), sensitiveTripBefore = await request('GET', tripPath) + const ownerOfferBefore = (await offerDetail(dispatchers[0])).offer + await reject('Other sales cannot edit an owned inquiry', 'PUT', `${POOL}/inquiries/${first.bookingNo}`, + poolInquiryWrite(ownershipBefore, key('OTHER-SALES-EDIT')), sales[1]) + await reject('Other sales cannot cancel an owned inquiry', 'POST', `${POOL}/inquiries/${first.bookingNo}/cancel`, + { ...command(ownershipBefore, 'OTHER-SALES-CANCEL'), reason: 'TEST越权负例' }, sales[1]) + const sensitiveRead = await raw('GET', tripPath, undefined, sales[1]) + check('Minimal sales cannot read sensitive operational trip', poolReadIsDenied(sensitiveRead) + && sensitiveRead.requestId && sensitiveRead.traceId, sensitiveRead) + check('Rejected sales writes and reads preserve original owner draft and trip facts', + inquiryFact(ownershipBefore) === inquiryFact(await latest(first.bookingNo)) + && JSON.stringify(sensitiveTripBefore) === JSON.stringify(await request('GET', tripPath)) + && capacitySnapshot(ownerOfferBefore) === capacitySnapshot((await offerDetail(dispatchers[0])).offer)) + proof.covered.push('other-sales-write-and-sensitive-trip-denied') await verifyAssignedVehicleGuards(context, fixture, tripProof, { request, reject, check, offerDetail: () => offerDetail(dispatchers[0]), proof }) await verifyPauseAndCutoff({ offerNo: proof.offerNo, bookingNo: first.bookingNo, salesToken: sales[0], dispatcherToken: dispatchers[0], request, reject, check, key, proof }) @@ -280,12 +371,20 @@ async function poolScenario(context, fixture, { execute = false, readInventory, proof.dimensionRejections.push({ dimension, bookingNo: inquiry.bookingNo, submitted: inquiry.cargo[dimension], available: available[dimension], status: 'REJECTED_UNCHANGED' }) } proof.covered.push('weight-and-volume-independent-capacity-rejections') + proof.cancelledOrders = [] + for (const [index, type] of ['LOCAL_LOOSEBOARD', 'US_TRUCK'].entries()) { + const inquiry = await createInquiry(index, type, 1, `CANCEL-${type}`) + proof.cancelledOrders.push(await verifyPoolConfirmedCancellation({ inquiry, destinationKey, request, check, latest, + 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]) 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') + proof.revalidationDraftNo = oversized.bookingNo await reject('Five spaces cannot fit last four', 'POST', `${POOL}/inquiries/${oversized.bookingNo}/confirm`, command(await latest(oversized.bookingNo), 'REJECT-FIVE'), dispatchers[1]) const rival = await createInquiry(0, 'US_TRUCK', 4, 'TRUCK-B') const contenders = [await latest(second.bookingNo), await latest(rival.bookingNo)] @@ -295,16 +394,6 @@ async function poolScenario(context, fixture, { execute = false, readInventory, offer = (await offerDetail(dispatchers[0])).offer; assertPoolRemaining(offer, destinationKey, 0) proof.orders = [firstConfirmed.inquiry, winner] proof.covered.push('two-dispatchers-last-four-race') - 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]) - assertSingleCommercial(firstConfirmed.inquiry, replay.inquiry) - check('Successful confirmation still replays after close', replay.replayed === true) - const history = await request('GET', `${POOL}/inquiries?page=1&size=100&scope=MINE`, undefined, sales[0]) - check('Sales retains its own history after close', history.records.some(row => row.bookingNo === first.bookingNo) - && history.records.every(row => row.salesOwnerUserId === first.salesOwnerUserId)) - proof.covered.push('close-and-successful-confirmation-replay') const poolMembers = [] for (const [index, order] of proof.orders.entries()) { const source = await request('GET', businessPath(order)) @@ -313,9 +402,32 @@ async function poolScenario(context, fixture, { execute = false, readInventory, const oldStatusPath = businessPath(order).replace('?', '/status?') await reject('Old commercial status mutation cannot bypass pool workflow', 'PATCH', oldStatusPath, { version: source.summary.version, status: 'CANCELLED', idempotencyKey: key(`OLD-CANCEL-${index}`), remark: context.marker }) - poolMembers.push(await receiveAndBindPoolOrder(context, fixture, order, index, { request, check, key, readInventory })) + const otherCustomer = merchantSessions.find(row => row.customerId !== order.customerId) + check('Cross-customer negative has a genuinely different qualified customer', Boolean(otherCustomer)) + const merchantPath = `/api/merchant/v1/last-leg/${order.businessType === 'US_TRUCK' ? 'us-truck-orders' : 'local-looseboard-orders'}/${encode(order.businessOrderNo)}` + const foreignRead = await raw('GET', merchantPath, undefined, otherCustomer.accessToken) + check('Another customer cannot read this commercial order', poolReadIsDenied(foreignRead) + && 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 })) } - proof.covered.push('old-manual-order-endpoints-rejected', 'one-commercial-order-and-one-incoming', 'both-business-types-real-pda-receipt') + 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', + 'receipt-overcapacity-rejected-with-source-unchanged', 'open-offer-bind-replan-requires-explicit-resume', 'bound-cargo-reservation-removed-without-double-count') + offer = (await offerDetail(dispatchers[0])).offer + const beforeClose = await Promise.all(proof.orders.map(row => latest(row.bookingNo))) + 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]) + 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))) + const history = await request('GET', `${POOL}/inquiries?page=1&size=100&scope=MINE`, undefined, sales[0]) + check('Sales retains its own history after close', history.records.some(row => row.bookingNo === first.bookingNo) + && history.records.every(row => row.salesOwnerUserId === first.salesOwnerUserId)) + proof.covered.push('close-and-successful-confirmation-replay') detail = await request('GET', tripPath) for (const binding of detail.dispatches) detail = await request('POST', `${tripPath}/dispatches/${encode(binding.dispatchNo)}/review`, { recordVersion: detail.trip.recordVersion, decision: 'APPROVED' }) const members = [...fixture.members, ...poolMembers] @@ -371,7 +483,8 @@ async function poolScenario(context, fixture, { execute = false, readInventory, } } -async function receiveAndBindPoolOrder(context, fixture, order, index, { request, check, key, readInventory }) { +async function receiveAndBindPoolOrder(context, fixture, order, index, { request, reject, check, key, readInventory, + latest, command, businessPath, offerDetail, dispatcherToken, destinationKey, proof }) { const tripPath = `${API}/fleet/trips/${encode(fixture.tripNo)}`, bookingPath = `${tripPath}/bookings/${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') @@ -380,6 +493,15 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request const created = await request('POST', `${bookingPath}/incoming`, body), incomingNo = created.booking.incomingNo const repeated = await request('POST', `${bookingPath}/incoming`, body) 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 + await reject('Incoming already registered cannot be cancelled from pool', 'POST', `${POOL}/inquiries/${order.bookingNo}/cancel`, + { ...command(inquiryBeforeCancel, `CANCEL-AFTER-INCOMING-${index}`), reason: 'TEST已来货取消拒绝' }, dispatcherToken) + check('Rejected incoming cancellation preserves incoming commercial booking and capacity', + JSON.stringify(incomingBeforeCancel) === JSON.stringify(await request('GET', `${bookingPath}/incoming`)) + && JSON.stringify(sourceBeforeCancel) === JSON.stringify(await request('GET', businessPath(order))) + && inquiryFact(inquiryBeforeCancel) === inquiryFact(await latest(order.bookingNo)) + && capacitySnapshot(capacityBeforeCancel) === capacitySnapshot((await offerDetail()).offer)) const pdaPath = `/api/pda/v1/last-leg/warehouse-transfers/${encode(incomingNo)}` const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${index}`), cartonCount: 1, lengthCm: 100, widthCm: 100, heightCm: 100, volumeCbm: 1, grossWeightKg: 100 }, fixture.pdaToken) @@ -393,6 +515,21 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request 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 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`, + { 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}`) }) + 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`)) + && inquiryFact(beforeOverReceipt) === inquiryFact(await latest(order.bookingNo)) + && JSON.stringify(sourceBeforeReceipt) === JSON.stringify(await request('GET', businessPath(order))) + && 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 inventory = await readInventory({ marker: context.marker, customerId: order.customerId, warehouseId: fixture.warehouseId }) @@ -408,9 +545,32 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request await request('POST', `${bookingPath}/dispatch`, { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion, dispatchNo: dispatch.dispatchNo, idempotencyKey: key(`BIND-DISPATCH-${index}`) }) let plan = await request('GET', `${tripPath}/capacity-plan`), detail = await request('GET', tripPath) - // Reconfirm actual current cargo after binding; remaining unbound bookings stay separately reserved. - await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, - pallets: controlledPalletMeasurements(plan.cargo, detail.trip.stops), reason: `${context.marker} 实收后重新核对,不重复扣外接预留` }) + const actualMeasurements = controlledPalletMeasurements(plan.cargo, detail.trip.stops) + const restored = await verifyPoolBindingRevalidation({ offerDetail, check, + blockedConfirm: async label => { + const before = await latest(proof.revalidationDraftNo) + const rejected = await reject('Stale OPEN publication cannot confirm another draft', 'POST', `${POOL}/inquiries/${before.bookingNo}/confirm`, + command(before, `${label}-${index}`), dispatcherToken) + check('Confirmation refused specifically for stale plan or publication commitment', /计划|核对|恢复接单|承诺已变化/.test(id(rejected.body?.message))) + check('Revalidation refusal keeps competing draft unchanged', inquiryFact(before) === inquiryFact(await latest(before.bookingNo))) + }, + replan: () => request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, + pallets: actualMeasurements, reason: `${context.marker} 实收后重新核对,不重复扣外接预留` }), + resume: async offer => (await request('POST', `${POOL}/${proof.offerNo}/resume`, { tripRecordVersion: offer.tripRecordVersion, + offerRecordVersion: offer.recordVersion, idempotencyKey: key(`RESUME-AFTER-BIND-${index}`), reason: 'TEST实际绑定核量后显式恢复接单' }, dispatcherToken)).offer }) + const bound = await latest(order.bookingNo) + check('Bound booking points to this actual incoming and dispatch', bound.status === 'BOUND' && bound.incomingNo === incomingNo && bound.dispatchNo === dispatch.dispatchNo) + const allBookings = await request('GET', `${tripPath}/bookings`) + const load = rows => rows.reduce((total, row) => { for (const dimension of ['palletSpaces', 'weightKg', 'volumeCbm']) total[dimension] += Number(row[dimension]); return total }, + { palletSpaces: 0, weightKg: 0, volumeCbm: 0 }) + const plannedLoad = load(actualMeasurements) + const reservedLoad = load(allBookings.records.filter(row => ['CONFIRMED', 'RECEIVED'].includes(row.status)).map(row => row.cargo)) + const capacity = { palletSpaces: detail.trip.maxPalletSpaces, weightKg: detail.trip.maxLoadKg, volumeCbm: detail.trip.maxVolumeCbm } + assertPoolBoundCapacity(restored, destinationKey, plannedLoad, reservedLoad, capacity) + check('BOUND reservation is removed exactly once from all capacity dimensions', Number(plannedLoad.palletSpaces) === 3 + index + && Number(reservedLoad.palletSpaces) === (index === 0 ? 4 : 0)) + ;(proof.bindingCapacity ||= []).push({ bookingNo: order.bookingNo, status: bound.status, planned: plannedLoad, + reserved: reservedLoad, remaining: restored.destinations.find(row => row.destinationKey === destinationKey).remaining }) const path = `${dispatchBase}/${encode(dispatch.dispatchNo)}`, freight = await request('GET', `${path}/external-freight`) check('The one pool commercial source owns the one agreed freight', freight.requiresDeliveryFee === false && freight.quotes.length === 1 && freight.quotes[0].bookingNo === order.bookingNo && Number(freight.quotes[0].amount) === 30) diff --git a/scripts/fleet-capacity-pool-test-env.test.mjs b/scripts/fleet-capacity-pool-test-env.test.mjs index 864e27010..8721311f4 100644 --- a/scripts/fleet-capacity-pool-test-env.test.mjs +++ b/scripts/fleet-capacity-pool-test-env.test.mjs @@ -1,7 +1,8 @@ import test from 'node:test' import assert from 'node:assert/strict' import { parsePoolArguments, poolPlan, runPoolAcceptance, assertPoolRemaining, assertSingleCommercial, - shrinkingVehicleRequest, expiryWaitMs, poolLocalDateTime, poolPickScope, pickPoolDispatch, POOL_COVERAGE, POOL_REMAINING } from './fleet-capacity-pool-test-env.mjs' + shrinkingVehicleRequest, expiryWaitMs, poolLocalDateTime, poolPickScope, pickPoolDispatch, POOL_COVERAGE, POOL_REMAINING, + poolInquiryWrite, poolReadIsDenied, assertPoolBoundCapacity, verifyPoolConfirmedCancellation, verifyPoolBindingRevalidation } from './fleet-capacity-pool-test-env.mjs' test('default and explicit plans make no network requests or invoke the mutating runner', async () => { const original = globalThis.fetch @@ -158,3 +159,104 @@ test('failed helper result stops the pool stage immediately instead of appending const context = { marker: 'R', steps: [] } await assert.rejects(pickPoolDispatch(context, 'test', { customerId: '9', dispatchNo: 'DS-A' }, async () => false), /PDA_PICK_FAILED:DS-A/) }) + +test('foreign read rejection requires actual access/not-found code and no leaked business payload', () => { + for (const response of [{ http: 403, body: { code: 'A0403' } }, { http: 404, body: { code: 'A0404', data: null } }, + { http: 200, body: { code: 'A0403' } }, { http: 200, body: { code: 'A0404' } }]) assert.equal(poolReadIsDenied(response), true) + for (const response of [{ http: 200, body: { code: '200' } }, { http: 401 }, { http: 409 }, { http: 500 }, + { http: 200, body: { code: 'A0409' } }, { http: 403, body: { data: { orderNo: 'PRIVATE' } } }]) assert.equal(poolReadIsDenied(response), false) +}) + +test('owner rejection sends a valid current edit rather than malformed input', () => { + const source = { tripRecordVersion: 5, offerRecordVersion: 7, recordVersion: 9, customerId: '42', businessType: 'US_TRUCK', + deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: '2030-01-01T00:00:00Z', expectedPalletCount: 1, + cargo: { destinationKey: 'FBA:1', packageCount: 1, palletSpaces: 4 }, bookingNo: 'FB-1', actions: { canEdit: false } } + const body = poolInquiryWrite(source, 'CURRENT-KEY') + assert.equal(body.idempotencyKey, 'CURRENT-KEY') + assert.equal(body.tripRecordVersion, 5) + assert.equal(body.offerRecordVersion, 7) + assert.equal(body.recordVersion, 9) + assert.deepEqual(body.cargo, source.cargo) + assert.equal(body.customerId, '42') + assert.equal(body.estimatedArrivalAt, source.estimatedArrivalAt) + assert.equal('actions' in body, false) + assert.equal('bookingNo' in body, false) +}) + +test('BOUND accounting detects stale reservation, omitted actual pallet and wrong destination in every dimension', () => { + const planned = { palletSpaces: 3, weightKg: 300, volumeCbm: 3 }, reserved = { palletSpaces: 4, weightKg: 100, volumeCbm: 1 } + const capacity = { palletSpaces: 30, weightKg: 18000, volumeCbm: 90 } + const remaining = { palletSpaces: 23, weightKg: 17600, volumeCbm: 86 } + const offer = { status: 'OPEN', capacityKnown: true, canConfirm: true, + segments: [{ fromIndex: 0, toIndex: 1, planned, reserved, remaining }], destinations: [{ destinationKey: 'FBA:1', remaining }] } + assert.doesNotThrow(() => assertPoolBoundCapacity(offer, 'FBA:1', planned, reserved, capacity)) + for (const dimension of ['palletSpaces', 'weightKg', 'volumeCbm']) { + for (const section of ['planned', 'reserved', 'remaining']) { + const altered = structuredClone(offer); altered.segments[0][section][dimension]++ + assert.throws(() => assertPoolBoundCapacity(altered, 'FBA:1', planned, reserved, capacity), /DOUBLE_COUNT_OR_MISSING/) + } + } + assert.throws(() => assertPoolBoundCapacity({ ...offer, canConfirm: false }, 'FBA:1', planned, reserved, capacity), /OPEN_KNOWN/) + assert.throws(() => assertPoolBoundCapacity(offer, 'FBA:OTHER', planned, reserved, capacity), /DESTINATION/) +}) + +test('binding check requires OPEN invalidation, replan still blocked, then explicit resume in that order', async () => { + const events = [], check = (label, condition) => assert.ok(condition, label) + let stage = 'INVALID' + const snapshots = { + INVALID: { status: 'OPEN', capacityKnown: false, canConfirm: false, blockReason: '计划容量待调度核对' }, + REPLANNED: { status: 'OPEN', capacityKnown: false, canConfirm: false, blockReason: '发布承诺已变化,请调度重新核对并恢复接单' }, + RESUMED: { status: 'OPEN', capacityKnown: true, canConfirm: true }, + } + const result = await verifyPoolBindingRevalidation({ check, + offerDetail: async () => { events.push(`GET-${stage}`); return { offer: snapshots[stage] } }, + blockedConfirm: async reason => { events.push(reason) }, + replan: async () => { events.push('REPLAN'); stage = 'REPLANNED' }, + resume: async current => { assert.equal(current, snapshots.REPLANNED); events.push('RESUME'); stage = 'RESUMED'; return snapshots.RESUMED }, + }) + assert.equal(result.canConfirm, true) + assert.deepEqual(events, ['GET-INVALID', 'BIND-INVALIDATED', 'REPLAN', 'GET-REPLANNED', 'REPLANNED-NOT-RESUMED', 'RESUME']) +}) + +test('closed publication or automatic resumption cannot masquerade as OPEN binding revalidation', async () => { + const check = (label, condition) => assert.ok(condition, label) + await assert.rejects(verifyPoolBindingRevalidation({ check, offerDetail: async () => ({ offer: { status: 'CLOSED', canConfirm: false } }), + replan: () => assert.fail('must stop before replan') }), /remains OPEN/) + let reads = 0 + await assert.rejects(verifyPoolBindingRevalidation({ check, + offerDetail: async () => ({ offer: ++reads === 1 ? { status: 'OPEN', capacityKnown: false, canConfirm: false, blockReason: '计划容量待调度核对' } + : { status: 'OPEN', capacityKnown: true, canConfirm: true } }), replan: async () => {}, blockedConfirm: async () => {}, + resume: () => assert.fail('must stop before resume') }), /cannot silently resume/) +}) + +test('confirmed cancellation verifies both commercial types and reuses exact successful cancellation request', async () => { + for (const businessType of ['LOCAL_LOOSEBOARD', 'US_TRUCK']) { + let current = { bookingNo: 'FB-1', offerNo: 'FO-1', customerId: '9', businessType, recordVersion: 1, status: 'DRAFT' } + let cancellationBody, cancellationCount = 0, commercialStatus = 'WAITING_INBOUND' + const offer = count => ({ capacityKnown: true, destinations: [{ destinationKey: 'FBA:1', remaining: { palletSpaces: count } }] }) + const request = async (method, path, body) => { + if (method === 'POST' && path.endsWith('/confirm')) { + current = { ...current, status: 'CONFIRMED', businessOrderNo: 'BUSINESS-1', recordVersion: 2 } + return { inquiry: { ...current }, offer: offer(11) } + } + if (method === 'POST' && path.endsWith('/cancel')) { + cancellationCount++ + if (cancellationBody) assert.equal(body, cancellationBody) + else cancellationBody = body + commercialStatus = 'CANCELLED'; current = { ...current, status: 'CANCELLED', recordVersion: 3 } + return { inquiry: { ...current }, offer: offer(12), replayed: cancellationCount > 1 } + } + if (path === '/commercial') return { summary: { status: commercialStatus, fleetArrangement: { bookingNo: 'FB-1' } } } + if (path.endsWith('/FO-1')) return { offer: offer(12) } + assert.fail(`unexpected ${method} ${path}`) + } + const result = await verifyPoolConfirmedCancellation({ inquiry: { ...current }, destinationKey: 'FBA:1', request, + check: (label, condition) => assert.ok(condition, label), latest: async () => ({ ...current }), + command: (row, label) => ({ recordVersion: row.recordVersion, idempotencyKey: label }), businessPath: () => '/commercial', + dispatcherToken: 'offline', label: businessType }) + assert.equal(result.businessType, businessType) + assert.equal(result.status, 'CANCELLED') + assert.equal(cancellationCount, 2) + assert.equal(result.remainingPalletSpaces, 12) + } +}) diff --git a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs new file mode 100644 index 000000000..40f1fe274 --- /dev/null +++ b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs @@ -0,0 +1,316 @@ +import { mkdirSync, openSync, closeSync, writeFileSync, writeSync, ftruncateSync, fsyncSync, unlinkSync, + readFileSync, realpathSync } from 'node:fs' +import { resolve, dirname, join, isAbsolute, extname } from 'node:path' +import { homedir } from 'node:os' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createHash, randomUUID } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { parseCrossCustomerArguments } from './fleet-cross-customer-test-env.mjs' +import { createFleetCrossCustomerFixture } from './admin-test-env-modules/fleet-cross-customer-fixture.mjs' +import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' +import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs' +import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +export const PRIVATE_DIRECTORY = resolve(homedir(), '.codex', 'env', 'chongchong-global') +const API = '/api/platform/v1/last-leg', POOL = `${API}/fleet/capacity-offers`, IAM = '/api/platform/v1/iam' +const PREFIX = 'platform:fleet:capacity-offer:' +const id = value => String(value ?? '') +const demand = (condition, message) => { if (!condition) throw Error(message) } +const ok = response => response?.http === 200 && id(response?.body?.code) === '200' + +export function privateCredentialsPath(value, privateDirectory = PRIVATE_DIRECTORY) { + demand(typeof value === 'string' && isAbsolute(value), 'EXPLICIT_ABSOLUTE_PRIVATE_CREDENTIALS_FILE_REQUIRED') + const file = resolve(value), directory = resolve(privateDirectory) + const same = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b + demand(same(dirname(file), directory) && extname(file).toLowerCase() === '.json', 'CREDENTIALS_FILE_MUST_BE_JSON_DIRECTLY_IN_PRIVATE_ENV_DIRECTORY') + return file +} + +export function parseUiFixtureArguments(args) { + const ordinary = []; let credentialsFile + for (let index = 0; index < args.length; index++) { + if (args[index] !== '--private-credentials-file') { ordinary.push(args[index]); continue } + demand(credentialsFile === undefined && args[index + 1] && !args[index + 1].startsWith('--'), 'INVALID_DUPLICATE_PRIVATE_CREDENTIALS_FILE') + credentialsFile = privateCredentialsPath(args[++index]) + } + const config = parseCrossCustomerArguments(ordinary) + demand(!config.execute || credentialsFile, 'EXPLICIT_ABSOLUTE_PRIVATE_CREDENTIALS_FILE_REQUIRED') + return { ...config, privateCredentialsFile: credentialsFile || null } +} + +export function uiFixturePlan(config) { + return { execute: Boolean(config.execute), environment: 'TEST', runId: config.runId, + output: `reports/fleet-capacity-pool-ui-${config.runId}`, privateCredentialsFile: config.privateCredentialsFile, + purpose: 'UI fixture only: one OPEN 30-space trip with valid 18-space owned plan and 24 non-reserving drafts, two minimal sales logins, one manage login, two qualified customer logins.', + permissions: { sales: ['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(name => PREFIX + name), manager: [PREFIX + 'manage'] }, + stopsAt: 'OPEN_AND_24_DRAFTS_READY_FOR_UI', fullAcceptance: false, + credentials: 'Exclusive private JSON creation before any writes; every generated password is persisted and fsynced there only. Reports and console redact passwords and tokens.', + boundary: 'No automatic rerun/resume, SQL mutations, deployment, pool confirmation, PDA picking, departure, receipt signature or completed-order fabrication. Retain business samples and UI accounts; disable the fixture-only PDA and logout script sessions.' } +} + +/** Private directory override is for isolated offline tests, never exposed as a CLI option. */ +export function createPrivateCredentialStore(file, initial, { privateDirectory = PRIVATE_DIRECTORY } = {}) { + const target = privateCredentialsPath(file, privateDirectory) + demand(realpathSync(dirname(target)) === realpathSync(privateDirectory), 'PRIVATE_DIRECTORY_RESOLUTION_MISMATCH') + const descriptor = openSync(target, 'wx', 0o600) + let closed = false + const save = value => { + demand(!closed, 'PRIVATE_CREDENTIAL_STORE_CLOSED') + const text = JSON.stringify(value, null, 2) + '\n' + writeSync(descriptor, text, 0, 'utf8'); ftruncateSync(descriptor, Buffer.byteLength(text)); fsyncSync(descriptor) + } + try { save(initial) } catch (error) { closeSync(descriptor); throw error } + return { save, close() { if (!closed) { closeSync(descriptor); closed = true } } } +} + +export function publicUiEvidence(value, secrets = []) { + const stripped = item => { + if (Array.isArray(item)) return item.map(stripped) + if (item && typeof item === 'object') return Object.fromEntries(Object.entries(item).map(([key, child]) => + [key, /^(?:base64|bytes|content)$/i.test(key) ? '[BINARY OMITTED]' : stripped(child)])) + return item + } + return stripped(redactFleetWrite(value, secrets)) +} + +export function uiInquiryBody(offer, customerId, index, key) { + const destination = offer.destinations?.find(row => row.destinationKey?.startsWith('FBA:')) + demand(offer.status === 'OPEN' && offer.canCreateInquiry && destination, 'OPEN_FBA_OFFER_REQUIRED') + return { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion, + customerId, businessType: index % 2 === 0 ? 'LOCAL_LOOSEBOARD' : 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', + expectedPalletCount: index === 0 ? null : 1, + cargo: { destinationKey: destination.destinationKey, cargoName: `UI验收意向-${index + 1}`, + packageCount: index === 0 ? null : 1, palletSpaces: index === 0 ? null : 1, weightKg: 100, volumeCbm: 1, + priceBasis: 'PALLET', unitPrice: 30, currency: 'CNY', contactName: 'UI验收联系人', contactPhone: '19900000000', remark: '独立UI验收草稿,不占位' }, + idempotencyKey: key } +} + +export async function runUiFixture(config, { env = process.env, outputRoot = ROOT, readers, + prepare = createFleetCrossCustomerFixture, contextFactory = options => new AcceptanceRun(options) } = {}) { + if (!config.execute) return uiFixturePlan(config) + demand(config.environment === 'TEST' && env.CHONGCHONG_ENV === 'test' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) + && config.runId !== 'plan' && /^[1-9]\d*$/.test(config.warehouseId) && /^[1-9]\d*$/.test(config.destinationWarehouseId) + && env.CHONGCHONG_ADMIN_USERNAME && env.CHONGCHONG_ADMIN_PASSWORD, 'EXPLICIT_TEST_FIXTURE_CONFIGURATION_REQUIRED') + const privateFile = privateCredentialsPath(config.privateCredentialsFile) + const endpoint = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + demand(['http:', 'https:'].includes(endpoint.protocol) && ['/api', '/api/'].includes(endpoint.pathname) + && !endpoint.username && !endpoint.password && !endpoint.hash && !endpoint.search, 'INVALID_TEST_API_BASE') + readers ||= createTestEvidenceReaders(env) + const options = validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), + environment: 'test', gateway: endpoint.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, + markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }) + const secretDocument = { schemaVersion: 1, environment: 'TEST', runId: config.runId, status: 'PREPARING', createdAt: new Date().toISOString(), + gatewayOrigin: endpoint.origin, accounts: [], business: {} } + const vault = createPrivateCredentialStore(privateFile, secretDocument) + const reports = join(outputRoot, 'reports'), output = join(reports, `fleet-capacity-pool-ui-${config.runId}`) + const secrets = Object.entries(env).filter(([key]) => /PASSWORD|TOKEN|SECRET/.test(key)).map(([, value]) => value).filter(Boolean) + const sessions = new Map(), kinds = new Map() + let context, picker, lock, restore, initialized = false, requestNumber = 0, inventoryNumber = 0, report + const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(publicUiEvidence(value, secrets), null, 2) + '\n') + const writePrivate = () => vault.save(secretDocument) + const remember = account => { + if (account.password) secrets.push(account.password) + const existing = secretDocument.accounts.find(row => row.username === account.username) + if (existing) Object.assign(existing, account); else secretDocument.accounts.push(account) + writePrivate() + } + try { + mkdirSync(reports, { recursive: true }) + lock = openSync(join(reports, '.fleet-acceptance.lock'), 'wx') + writeFileSync(lock, JSON.stringify({ runId: config.runId, pid: process.pid, kind: 'UI_FIXTURE' })) + mkdirSync(output); initialized = true; mkdirSync(join(output, 'requests')); mkdirSync(join(output, 'write-journal')); mkdirSync(join(output, 'evidence')) + save('manifest.json', { ...uiFixturePlan(config), sourceSha256: createHash('sha256').update(readFileSync(fileURLToPath(import.meta.url))).digest('hex') }) + await readers.preflight() + context = contextFactory(options) + secretDocument.marker = context.marker; writePrivate() + const proof = context.createdResources.fleetCapacityPoolUi = { marker: context.marker, status: 'PREPARING', actors: [], drafts: [], fullAcceptance: false } + for (const method of ['request', 'requestWithBody']) { + const original = context[method] + context[method] = async function (...args) { + const sequence = ++requestNumber, [verb, path, body] = args + if (verb === 'POST' && path === `${IAM}/users` && body?.username && body.password) { + remember({ kind: kinds.get(body.username) || (body.username.startsWith('pda_overseas_') ? 'FIXTURE_PDA' : 'FIXTURE_USER'), + username: body.username, password: body.password, roleIds: body.roleIds, status: 'PREPARED' }) + } + save(`requests/${sequence}.json`, { stage: 'INTENT', sequence, method: verb, path, body: body instanceof FormData ? '[MULTIPART]' : body }) + const response = await original.apply(this, args) + if (ok(response) && verb === 'POST' && path === `${IAM}/tenants`) { + const data = response.body.data, customer = data.tenant || data.customer || data + remember({ kind: 'CUSTOMER', customerId: id(customer.id || data.tenantId), customerName: customer.tenantName || customer.customerName, + username: data.administratorUsername, password: data.temporaryPassword, status: 'CREATED' }) + } + if (ok(response) && verb === 'POST' && path === `${IAM}/users`) { + const data = response.body.data + remember({ kind: kinds.get(body.username) || (body.username.startsWith('pda_overseas_') ? 'FIXTURE_PDA' : 'FIXTURE_USER'), + username: body.username, password: body.password, membershipId: id(data.membershipId), roleIds: data.roleIds, status: 'ACTIVE' }) + } + if (ok(response) && path === '/api/auth/login' && response.body.data?.accessToken) { + const auth = response.body.data + sessions.set(auth.accessToken, auth.refreshToken); secrets.push(auth.accessToken, auth.refreshToken) + } + save(`requests/${sequence}.json`, { stage: 'RESPONSE', sequence, method: verb, path, body: body instanceof FormData ? '[MULTIPART]' : body, response }) + save('progress.json', { marker: context.marker, createdResources: context.createdResources, steps: context.steps }) + return response + } + } + restore = guardFleetWrites(context, async entry => { + const file = openSync(join(output, 'write-journal', `${entry.sequence}.json`), 'w') + try { writeFileSync(file, JSON.stringify(publicUiEvidence(entry, secrets), null, 2) + '\n'); fsyncSync(file) } finally { closeSync(file) } + }, { guardFetch: true }) + const check = (label, condition, response = null) => { + context.assert('fleet-capacity-pool-ui', label, response, Boolean(condition), '', { scenario: 'UI_FIXTURE', deploymentBlocker: true }) + demand(condition, label) + } + const request = async (method, path, body, token = context.token) => { + const response = await context.request(method, path, body, token) + check(`${method} ${path}`, ok(response) && response.requestId && response.traceId, response) + return response.body.data + } + const login = await request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + context.token = login.accessToken; context.refreshToken = login.refreshToken + const access = await resolvePickerAccess(context) + check('Fixture PDA role exists', Boolean(access)) + picker = await createPicker(context, 93, config.warehouseId, access) + check('Fixture PDA authenticated', Boolean(picker?.token)) + const readInventory = async scope => { + const snapshot = await readers.readFleetInventory(scope); save(`evidence/inventory-${++inventoryNumber}.json`, snapshot); return snapshot + } + const fixture = await prepare(context, { execute: true, warehouseId: config.warehouseId, + destinationWarehouseId: config.destinationWarehouseId, pdaToken: picker.token, readInventory }) + save('fixture.json', fixture) + const trip = context.createdResources.fleetTrips.find(row => row.tripNo === fixture.tripNo), tripPath = `${API}/fleet/trips/${encodeURIComponent(fixture.tripNo)}` + check('Existing producer created exactly two untouched customer dispatches', fixture.members.length === 2 && trip) + let detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`) + check('Owned fixture vehicle has actual 30-space capacity', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2) + // Keep UI logins useful beyond the producer's short smoke-test window, before its existing six-hour delivery ETA. + detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion, + plannedDepartureAt: new Date(Date.now() + 4 * 3600000).toISOString(), expectedDriverEndAt: detail.trip.expectedDriverEndAt, + stops: detail.trip.stops, reason: 'UI验收窗口:四小时后计划发车,不执行发车' }) + plan = await request('GET', `${tripPath}/capacity-plan`) + await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, + pallets: controlledPalletMeasurements(plan.cargo, detail.trip.stops).map(row => ({ ...row, palletSpaces: 9 })), reason: '仅UI验收:自有计划18/总30,不作为实际发车计量' }) + const permissions = await request('GET', `${IAM}/permissions`), actorTokens = [] + const key = suffix => createHash('sha256').update(`${context.marker}:UI:${suffix}`).digest('hex') + for (const kind of ['SALES_A', 'SALES_B', 'MANAGER']) { + const suffix = key(kind).slice(0, 16), username = `pool_ui_${kind.toLowerCase()}_${suffix}` + const expected = kind === 'MANAGER' ? [PREFIX + 'manage'] : ['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(name => PREFIX + name) + const candidates = expected.map(code => permissions.filter(row => row.permissionCode === code && row.status === 'ACTIVE')) + check('Exact fixture permissions are installed', candidates.every(rows => rows.length === 1 && rows[0].id)) + const role = await request('POST', `${IAM}/roles`, { roleCode: `POOL_UI_${kind}_${suffix}`.toUpperCase(), roleName: `${context.marker}-${kind}`, description: '仅本轮UI验收账号' }) + proof.actors.push({ kind, roleId: id(role.id) }) + await request('PUT', `${IAM}/roles/permissions`, { roleId: id(role.id), permissionIds: candidates.map(rows => id(rows[0].id)) }) + kinds.set(username, kind) + const password = `Ui${randomUUID().replaceAll('-', '')}A9`; remember({ kind, username, password, roleIds: [id(role.id)], status: 'PREPARED' }) + const user = await request('POST', `${IAM}/users`, { username, password, displayName: `${context.marker}-同名销售`, + phone: await findFleetActorPhone(context, suffix), email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)], status: 'ACTIVE' }) + proof.actors.at(-1).membershipId = id(user.membershipId) + const auth = await request('POST', '/api/auth/login', { loginId: username, password }, '') + const effective = await request('GET', `${IAM}/authorization/permissions`, undefined, auth.accessToken) + check('UI actor has only its exact minimal permissions', JSON.stringify([...effective].sort()) === JSON.stringify([...expected].sort())) + actorTokens.push(auth.accessToken) + } + detail = await request('GET', tripPath) + const draft = await request('POST', POOL, { tripNo: fixture.tripNo, tripRecordVersion: detail.trip.recordVersion, + cutoffAt: new Date(Math.min(Date.now() + 3 * 3600000, Date.parse(detail.trip.plannedDepartureAt) - 60000)).toISOString(), + contactName: 'UI验收调度', contactPhone: '19900000000', remark: context.marker, idempotencyKey: key('OFFER') }, actorTokens[2]) + let offer = (await request('POST', `${POOL}/${draft.offer.offerNo}/publish`, { tripRecordVersion: draft.offer.tripRecordVersion, + offerRecordVersion: draft.offer.recordVersion, idempotencyKey: key('PUBLISH') }, actorTokens[2])).offer + check('OPEN UI offer is available with exactly 12 planned spaces', offer.status === 'OPEN' && offer.canCreateInquiry && offer.canConfirm + && offer.destinations.length === 1 && Number(offer.destinations[0].remaining.palletSpaces) === 12) + proof.offerNo = offer.offerNo; proof.tripNo = fixture.tripNo + for (let index = 0; index < 24; index++) { + const owner = index < 12 ? 0 : 1, member = fixture.members[owner] + const created = await request('POST', `${POOL}/${offer.offerNo}/inquiries`, uiInquiryBody(offer, member.customerId, index, key(`DRAFT-${index}`)), actorTokens[owner]) + check('UI inquiry is DRAFT with no commercial order or reservation', created.inquiry.status === 'DRAFT' && !created.inquiry.businessOrderNo + && !created.inquiry.incomingNo && !created.inquiry.dispatchNo && Number(created.offer.destinations[0].remaining.palletSpaces) === 12) + proof.drafts.push({ bookingNo: created.inquiry.bookingNo, customerId: member.customerId, owner: owner === 0 ? 'SALES_A' : 'SALES_B' }) + offer = created.offer + } + const firstPage = await request('GET', `${POOL}/inquiries?page=1&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2]) + const secondPage = await request('GET', `${POOL}/inquiries?page=2&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2]) + check('Manager can exercise real page two of 24 drafts', Number(firstPage.total) === 24 && firstPage.records.length === 20 + && secondPage.records.length === 4 && new Set([...firstPage.records, ...secondPage.records].map(row => row.bookingNo)).size === 24) + for (let owner = 0; owner < 2; owner++) { + const mine = await request('GET', `${POOL}/inquiries?page=1&size=20&scope=MINE&offerNo=${offer.offerNo}`, undefined, actorTokens[owner]) + check('Each sales login sees exactly its own twelve drafts', Number(mine.total) === 12 && mine.records.length === 12 + && mine.records.every(row => proof.drafts.some(draft => draft.bookingNo === row.bookingNo && draft.owner === (owner === 0 ? 'SALES_A' : 'SALES_B')))) + } + for (const member of fixture.members) { + const account = secretDocument.accounts.find(row => row.kind === 'CUSTOMER' && row.customerId === member.customerId) + check('Customer UI credentials safely preserved', account?.username && account.password) + const auth = await request('POST', '/api/auth/login', { loginId: account.username, password: account.password }, '') + const profile = await request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken) + check('Customer login belongs to this fixture customer', id(profile.id) === member.customerId) + for (const type of ['local-looseboard-orders', 'us-truck-orders']) { + const page = await request('GET', `/api/merchant/v1/last-leg/${type}?page=1&size=20`, undefined, auth.accessToken) + check('Fresh customer commercial order page provides real empty state before UI confirmation', Number(page.total) === 0 && page.records.length === 0) + } + account.status = 'ACTIVE'; writePrivate() + } + detail = await request('GET', tripPath) + check('UI fixture has not picked departed or signed cargo', detail.trip.status === 'PLANNED') + for (const member of fixture.members) { + const dispatch = await request('GET', `${API}/dispatches/${member.dispatchNo}`) + check('Original real cargo remains unpicked', Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) + } + const final = await request('GET', `${POOL}/${offer.offerNo}`, undefined, actorTokens[2]) + check('UI handoff keeps publication OPEN and 24 drafts untouched', final.offer.status === 'OPEN' && final.offer.canCreateInquiry && Number(final.inquiries.total) === 24) + proof.status = 'OPEN_AND_24_DRAFTS_READY_FOR_UI' + secretDocument.business = { offerNo: proof.offerNo, tripNo: fixture.tripNo, warehouseId: fixture.warehouseId, + destinationWarehouseId: config.destinationWarehouseId, cutoffAt: final.offer.cutoffAt, + plannedDepartureAt: final.offer.plannedDepartureAt, customers: fixture.members.map(row => ({ customerId: row.customerId, incomingNo: row.incomingNo, dispatchNo: row.dispatchNo })) } + secretDocument.status = 'READY_FOR_UI'; writePrivate() + report = { success: true, fixtureReady: true, fullAcceptance: false, proof } + } catch (error) { + report = { success: false, fixtureReady: false, fullAcceptance: false, error: publicUiEvidence(id(error.message), secrets) } + secretDocument.status = 'FAILED_RETAINED'; writePrivate() + } finally { + try { + if (context) { + const pdaAccount = secretDocument.accounts.find(row => row.kind === 'FIXTURE_PDA' && row.membershipId) + if (picker?.membershipId || pdaAccount?.membershipId) { + try { + const previousSteps = context.steps.length + await disablePicker(context, picker?.membershipId ? picker : pdaAccount, 93) + demand(context.steps.length > previousSteps && context.steps.at(-1)?.passed === true, 'PDA_DISABLE_NOT_CONFIRMED') + if (pdaAccount) { pdaAccount.status = 'DISABLED_AFTER_FIXTURE'; writePrivate() } + } catch { + if (pdaAccount) pdaAccount.status = 'DISABLE_REQUIRES_REVIEW' + report.success = false; report.cleanupError = 'PDA_DISABLE_REQUIRES_REVIEW' + } + } + let sessionsClosed = true + for (const [token, refreshToken] of sessions) { + try { + const response = await context.request('POST', '/api/auth/logout', { refreshToken }, token) + if (!ok(response) && response.http !== 401 && id(response.body?.code) !== 'A0401') sessionsClosed = false + } catch { sessionsClosed = false } + } + report.sessionsClosed = sessionsClosed; report.summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) + report.success &&= sessionsClosed && report.summary.failed === 0 + report.fixtureReady &&= report.success + report.marker = context.marker; report.createdResources = context.createdResources; report.steps = context.steps + } + if (!report.success) secretDocument.status = 'FAILED_RETAINED' + writePrivate() + report.privateCredentialsFile = privateFile; report.requestCount = requestNumber; report.inventorySnapshots = inventoryNumber + report.output = output + if (initialized) save('report.json', report) + } finally { + restore?.(); vault.close() + if (lock !== undefined) { closeSync(lock); unlinkSync(join(reports, '.fleet-acceptance.lock')) } + } + } + return report +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const config = parseUiFixtureArguments(process.argv.slice(2)), result = await runUiFixture(config) + console.log(JSON.stringify(config.execute ? { success: result.success, fixtureReady: result.fixtureReady, fullAcceptance: false, + error: result.error, summary: result.summary, output: result.output, privateCredentialsFile: result.privateCredentialsFile } : result, null, 2)) + if (config.execute && !result.success) process.exitCode = 1 + } catch { console.error('UI fixture preparation failed; preserve existing private credentials/report and inspect locally.'); process.exitCode = 1 } +} diff --git a/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs b/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs new file mode 100644 index 000000000..d70fa5185 --- /dev/null +++ b/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs @@ -0,0 +1,114 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, unlinkSync, rmdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' +import { PRIVATE_DIRECTORY, privateCredentialsPath, parseUiFixtureArguments, uiFixturePlan, + createPrivateCredentialStore, publicUiEvidence, uiInquiryBody, runUiFixture } from './fleet-capacity-pool-ui-fixture-test-env.mjs' + +const credentials = join(PRIVATE_DIRECTORY, 'offline-contract-example.json') +const execute = ['--execute', '--environment', 'TEST', '--run-id', 'ui-contract', '--warehouse-id', '10', + '--destination-warehouse-id', '11', '--private-credentials-file', credentials] + +test('default and explicit plan cannot invoke network, private writes or fixture producers', async () => { + const original = globalThis.fetch + globalThis.fetch = async () => assert.fail('unexpected network') + const forbidden = () => assert.fail('unexpected dependency') + try { + for (const args of [[], ['--plan']]) { + const result = await runUiFixture(parseUiFixtureArguments(args), { env: {}, prepare: forbidden, + contextFactory: forbidden, readers: { preflight: forbidden } }) + assert.equal(result.execute, false) + assert.equal(result.fullAcceptance, false) + assert.equal(result.stopsAt, 'OPEN_AND_24_DRAFTS_READY_FOR_UI') + assert.equal(result.privateCredentialsFile, null) + } + } finally { globalThis.fetch = original } +}) + +test('execute requires explicit TEST, fresh run, warehouse IDs and one private absolute file', () => { + const config = parseUiFixtureArguments(execute) + assert.equal(config.privateCredentialsFile, credentials) + assert.equal(config.execute, true) + for (const args of [execute.slice(0, -2), [...execute, '--private-credentials-file', credentials], + [...execute, '--resume', 'old'], execute.map(value => value === 'TEST' ? 'gray' : value), + execute.map(value => value === 'ui-contract' ? '../old' : value), ['--private-credentials-file']]) { + assert.throws(() => parseUiFixtureArguments(args)) + } +}) + +test('private credential path is JSON directly within private env directory', () => { + assert.equal(privateCredentialsPath(credentials), resolve(credentials)) + for (const file of ['password.json', join(PRIVATE_DIRECTORY, '..', 'password.json'), + join(PRIVATE_DIRECTORY, 'nested', 'password.json'), join(PRIVATE_DIRECTORY, 'password.txt')]) { + assert.throws(() => privateCredentialsPath(file)) + } +}) + +test('private store exclusively creates, fsyncs updates and refuses overwriting existing credentials', () => { + const directory = mkdtempSync(join(tmpdir(), 'pool-ui-private-contract-')), file = join(directory, 'credentials.json') + let store + try { + store = createPrivateCredentialStore(file, { accounts: [{ password: 'offline-placeholder-long' }] }, { privateDirectory: directory }) + assert.equal(JSON.parse(readFileSync(file, 'utf8')).accounts[0].password, 'offline-placeholder-long') + assert.throws(() => createPrivateCredentialStore(file, {}, { privateDirectory: directory }), { code: 'EEXIST' }) + assert.equal(JSON.parse(readFileSync(file, 'utf8')).accounts[0].password, 'offline-placeholder-long') + store.save({ status: 'READY' }) + assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')), { status: 'READY' }) + store.close(); store.close() + assert.throws(() => store.save({}), /CLOSED/) + } finally { store?.close(); unlinkSync(file); rmdirSync(directory) } +}) + +test('public evidence removes credentials and binary while retaining business identifiers and failed responses', () => { + const publicValue = publicUiEvidence({ requestId: 'req-1', traceId: 'trace-1', body: { + code: 'A0409', message: 'login offline-secret failed', data: { temporaryPassword: 'generated', accessToken: 'token', + users: [{ username: 'private-login', password: 'nested-password' }], content: 'binary', base64: 'binary', bookingNo: 'FB-1' }, + } }, ['offline-secret']) + const text = JSON.stringify(publicValue) + for (const forbidden of ['offline-secret', 'generated', 'private-login', 'nested-password', 'binary']) assert.equal(text.includes(forbidden), false) + assert.equal(publicValue.body.data.accessToken, '[REDACTED]') + assert.equal(publicValue.body.data.bookingNo, 'FB-1') + assert.equal(publicValue.body.code, 'A0409') + assert.equal(publicValue.requestId, 'req-1') +}) + +test('24 intended drafts preserve source versions, both business types and unknown initial quantities', () => { + const offer = { status: 'OPEN', canCreateInquiry: true, tripRecordVersion: 5, recordVersion: 7, + destinations: [{ destinationKey: 'FBA:11' }] } + const drafts = Array.from({ length: 24 }, (_, index) => uiInquiryBody(offer, index < 12 ? 'A' : 'B', index, `KEY-${index}`)) + assert.equal(drafts.filter(row => row.customerId === 'A').length, 12) + assert.equal(drafts.filter(row => row.customerId === 'B').length, 12) + assert.equal(drafts.filter(row => row.businessType === 'LOCAL_LOOSEBOARD').length, 12) + assert.equal(drafts.filter(row => row.businessType === 'US_TRUCK').length, 12) + assert.equal(drafts[0].expectedPalletCount, null) + assert.equal(drafts[0].cargo.packageCount, null) + assert.equal(drafts[0].cargo.palletSpaces, null) + for (const row of drafts) { + assert.equal(row.tripRecordVersion, 5) + assert.equal(row.offerRecordVersion, 7) + assert.equal(row.deliveryMode, 'SELF_DELIVERY') + assert.equal('businessOrderNo' in row, false) + assert.equal('status' in row, false) + } + assert.throws(() => uiInquiryBody({ ...offer, status: 'CLOSED' }, 'A', 1, 'K'), /OPEN_FBA/) + assert.throws(() => uiInquiryBody({ ...offer, canCreateInquiry: false }, 'A', 1, 'K'), /OPEN_FBA/) + assert.throws(() => uiInquiryBody({ ...offer, destinations: [] }, 'A', 1, 'K'), /OPEN_FBA/) +}) + +test('execute rejects non-TEST and incomplete environment before invoking fixture or writing private file', async () => { + const config = parseUiFixtureArguments(execute) + for (const env of [{}, { CHONGCHONG_ENV: 'prod' }, { CHONGCHONG_ENV: 'test' }]) { + await assert.rejects(runUiFixture(config, { env, prepare: () => assert.fail('unexpected fixture') }), /CONFIGURATION_REQUIRED/) + } +}) + +test('plan lists minimal independent identities and explicitly excludes full acceptance or business completion', () => { + const plan = uiFixturePlan(parseUiFixtureArguments([])) + assert.equal(plan.permissions.sales.length, 4) + assert.deepEqual(plan.permissions.manager, ['platform:fleet:capacity-offer:manage']) + assert.equal(plan.fullAcceptance, false) + assert.match(plan.boundary, /No automatic rerun\/resume/) + assert.match(plan.boundary, /completed-order fabrication/) + assert.match(plan.boundary, /Retain business samples and UI accounts/) +})