644 lines
56 KiB
JavaScript
644 lines
56 KiB
JavaScript
import { mkdirSync, openSync, writeFileSync, fsyncSync, closeSync, readFileSync } from 'node:fs'
|
|
import { resolve, dirname, join } from 'node:path'
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
import { createHash, randomUUID } from 'node:crypto'
|
|
import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
|
|
import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-customer-test-env.mjs'
|
|
import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs'
|
|
import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
|
|
import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const API = '/api/platform/v1/last-leg', POOL = `${API}/fleet/capacity-offers`, IAM = '/api/platform/v1/iam'
|
|
const MODULE = 'fleet-capacity-pool', PREFIX = 'platform:fleet:capacity-offer:'
|
|
const id = value => String(value ?? ''), requireThat = (ok, reason) => { if (!ok) throw Error(reason) }
|
|
const success = response => response?.http === 200 && id(response.body?.code) === '200'
|
|
const denied = response => [403, 409].includes(response?.http) || response?.http === 200 && ['A0403', 'A0409'].includes(id(response.body?.code))
|
|
const encode = encodeURIComponent
|
|
// DeliveryRequests.Transfer uses LocalDateTime. Fleet offer/booking Instant fields below retain Z.
|
|
export function poolLocalDateTime(date) {
|
|
requireThat(date instanceof Date && Number.isFinite(date.getTime()), 'VALID_LOCAL_DATE_REQUIRED')
|
|
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
|
|
}
|
|
const localTime = ms => poolLocalDateTime(new Date(Date.now() + ms))
|
|
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
|
|
|
export const POOL_COVERAGE = Object.freeze([
|
|
'same-segment-plan-18-of-30-reserve-8-leaves-4', 'two-sales-owner-isolation', 'two-dispatchers-last-four-race',
|
|
'unknown-load-stays-null', 'close-and-successful-confirmation-replay', 'one-commercial-order-and-one-incoming',
|
|
'both-business-types-real-pda-receipt', 'shared-real-departure-and-signature', 'business-completion-and-fee-uniqueness',
|
|
'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',
|
|
'pool-unspecified-container-and-palletizing-remain-unknown',
|
|
])
|
|
export const POOL_REMAINING = Object.freeze([
|
|
'out-of-band-assigned-vehicle-drift-not-constructible-through-supported-api',
|
|
'full-UI-1920x1080-and-network-request-budget',
|
|
])
|
|
|
|
export function parsePoolArguments(args) { return parseCrossCustomerArguments(args) }
|
|
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. 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.' }
|
|
}
|
|
|
|
/** Strict quantities are based on the selected unloading segment, never a global displayed total. */
|
|
export function assertPoolRemaining(offer, destinationKey, expected) {
|
|
const destination = offer?.destinations?.filter(row => row.destinationKey === destinationKey)
|
|
requireThat(offer?.capacityKnown === true && destination?.length === 1
|
|
&& Number(destination[0].remaining?.palletSpaces) === expected, `EXPECTED_EXACT_SEGMENT_REMAINING_${expected}`)
|
|
}
|
|
export function assertSingleCommercial(before, after) {
|
|
requireThat(before?.bookingNo && before.businessOrderNo && before.businessOrderNo === after?.businessOrderNo
|
|
&& before.bookingNo === after.bookingNo && before.businessType === after.businessType
|
|
&& before.customerId === after.customerId, 'COMMERCIAL_REPLAY_IDENTITY_CHANGED')
|
|
}
|
|
|
|
export function assertPoolUnknownCommercialFacts(businessType, summary) {
|
|
requireThat(['LOCAL_LOOSEBOARD', 'US_TRUCK'].includes(businessType) && summary && typeof summary === 'object',
|
|
'POOL_COMMERCIAL_SUMMARY_REQUIRED')
|
|
requireThat(summary.containerType == null, 'POOL_CONTAINER_TYPE_MUST_REMAIN_UNKNOWN')
|
|
if (businessType === 'LOCAL_LOOSEBOARD') requireThat(summary.palletRequired == null, 'POOL_PALLETIZING_SERVICE_MUST_REMAIN_UNKNOWN')
|
|
return true
|
|
}
|
|
|
|
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')
|
|
const scoped = Object.create(context)
|
|
// pickAll keys only include its context marker and item/position indexes; the PDA key scope is customer-wide.
|
|
scoped.marker = `POOL-PICK-${createHash('sha256').update(JSON.stringify([context.marker, id(member.customerId), member.dispatchNo])).digest('hex').slice(0, 32)}`
|
|
return scoped
|
|
}
|
|
|
|
export async function pickPoolDispatch(context, token, member, pick = pickAll) {
|
|
const scoped = poolPickScope(context, member)
|
|
const passed = await pick(scoped, token, member.dispatchNo)
|
|
requireThat(passed === true, `PDA_PICK_FAILED:${member.dispatchNo}`)
|
|
return scoped.marker
|
|
}
|
|
|
|
export function shrinkingVehicleRequest(vehicle) {
|
|
requireThat(vehicle?.id && Number.isInteger(Number(vehicle.recordVersion)) && Number(vehicle.maxPalletSpaces) > 1
|
|
&& Number(vehicle.maxLoadKg) > 0 && Number(vehicle.maxVolumeCbm) > 0, 'COMPLETE_OWNED_VEHICLE_SNAPSHOT_REQUIRED')
|
|
return { recordVersion: vehicle.recordVersion, plateNumber: vehicle.plateNumber, model: vehicle.model,
|
|
fuelConsumptionPer100Km: vehicle.fuelConsumptionPer100Km, mileageKm: vehicle.mileageKm,
|
|
driverOrDispatchCompany: vehicle.driverOrDispatchCompany ?? null, phone: vehicle.phone ?? null,
|
|
region: vehicle.region, ownership: vehicle.ownership, remark: vehicle.remark ?? null,
|
|
maxPalletSpaces: Number(vehicle.maxPalletSpaces) - 1, maxLoadKg: vehicle.maxLoadKg, maxVolumeCbm: vehicle.maxVolumeCbm }
|
|
}
|
|
export function expiryWaitMs(cutoffAt, now = Date.now()) {
|
|
const cutoff = Date.parse(cutoffAt)
|
|
requireThat(Number.isFinite(cutoff) && Number.isFinite(now), 'INVALID_SERVER_CUTOFF')
|
|
const delay = Math.max(0, cutoff + 300 - now)
|
|
requireThat(delay <= 15000, 'CUTOFF_SCENARIO_WAIT_EXCEEDS_BOUNDED_WINDOW')
|
|
return delay
|
|
}
|
|
const capacitySnapshot = offer => JSON.stringify({ capacityKnown: offer.capacityKnown, destinations: offer.destinations, segments: offer.segments })
|
|
const inquiryFact = row => JSON.stringify({ bookingNo: row.bookingNo, customerId: row.customerId, businessType: row.businessType,
|
|
recordVersion: row.recordVersion, status: row.status, cargo: row.cargo, expectedPalletCount: row.expectedPalletCount,
|
|
businessOrderNo: row.businessOrderNo ?? null, incomingNo: row.incomingNo ?? null, dispatchNo: row.dispatchNo ?? null })
|
|
|
|
/** Current supported vehicle API prevents this state from being created on an assigned trip.
|
|
* Verify the real rejection; never write SQL or claim an impossible live-drift fixture succeeded. */
|
|
async function verifyAssignedVehicleGuards(context, fixture, tripProof, { request, reject, check, offerDetail, proof }) {
|
|
requireThat(context.createdResources.fleetTrips.includes(tripProof) && tripProof.tripNo === fixture.tripNo && tripProof.vehicleId,
|
|
'ONLY_CURRENT_FIXTURE_VEHICLE_MAY_BE_TESTED')
|
|
const path = `${API}/master-data/vehicles/${encode(tripProof.vehicleId)}`
|
|
const before = await request('GET', path), offerBefore = (await offerDetail()).offer
|
|
check('Vehicle guard targets the original fixture assigned vehicle', id(before.id) === id(tripProof.vehicleId) && before.status === 'IN_SERVICE')
|
|
const disabled = await reject('Assigned vehicle cannot be disabled through master-data API', 'PATCH', `${path}/status`,
|
|
{ recordVersion: before.recordVersion, status: 'INACTIVE' })
|
|
check('Disable rejected specifically because the vehicle is assigned', /正在车次|改派|取消安排/.test(id(disabled.body?.message)))
|
|
const shrunk = await reject('Assigned vehicle cannot shrink capacity through master-data API', 'PUT', path, shrinkingVehicleRequest(before))
|
|
check('Shrink rejected specifically because the vehicle is assigned', /正在车次|改派|取消安排/.test(id(shrunk.body?.message)))
|
|
const after = await request('GET', path), offerAfter = (await offerDetail()).offer
|
|
check('Rejected vehicle commands leave master version status and all capacities unchanged', JSON.stringify(after) === JSON.stringify(before))
|
|
check('Rejected vehicle commands do not alter pool capacity or acceptance eligibility', capacitySnapshot(offerBefore) === capacitySnapshot(offerAfter)
|
|
&& offerAfter.canConfirm === offerBefore.canConfirm)
|
|
proof.vehicleGuards = { status: 'VERIFIED_REJECTION', vehicleId: id(before.id), originalVersion: before.recordVersion,
|
|
liveDrift: 'NOT_CONSTRUCTIBLE_THROUGH_SUPPORTED_API', explanation: 'VehicleApplicationService requires unassigned before status/capacity changes; no database bypass used.' }
|
|
proof.covered.push('assigned-vehicle-disable-and-shrink-rejected')
|
|
}
|
|
|
|
async function verifyPauseAndCutoff({ offerNo, bookingNo, salesToken, dispatcherToken, request, reject, check, key, proof,
|
|
wait = ms => new Promise(resolve => setTimeout(resolve, ms)) }) {
|
|
const detail = async () => (await request('GET', `${POOL}/${offerNo}`, undefined, dispatcherToken)).offer
|
|
const latest = () => request('GET', `${POOL}/inquiries/${bookingNo}`, undefined, dispatcherToken)
|
|
const control = async (action, suffix) => {
|
|
const offer = await detail()
|
|
return (await request('POST', `${POOL}/${offerNo}/${action}`, { tripRecordVersion: offer.tripRecordVersion,
|
|
offerRecordVersion: offer.recordVersion, idempotencyKey: key(suffix), reason: 'TEST暂停与截止门禁' }, dispatcherToken)).offer
|
|
}
|
|
const editCutoff = async (cutoffAt, suffix) => {
|
|
const offer = await detail()
|
|
return (await request('PUT', `${POOL}/${offerNo}`, { tripNo: offer.tripNo, tripRecordVersion: offer.tripRecordVersion,
|
|
offerRecordVersion: offer.recordVersion, cutoffAt, contactName: offer.contactName, contactPhone: offer.contactPhone,
|
|
remark: offer.remark, idempotencyKey: key(suffix) }, dispatcherToken)).offer
|
|
}
|
|
const refusedCommands = async (label, reason) => {
|
|
const current = await latest(), before = inquiryFact(current), beforeOffer = await detail()
|
|
const body = { tripRecordVersion: current.tripRecordVersion, offerRecordVersion: current.offerRecordVersion,
|
|
recordVersion: current.recordVersion, customerId: current.customerId, businessType: current.businessType,
|
|
deliveryMode: current.deliveryMode, estimatedArrivalAt: current.estimatedArrivalAt, expectedPalletCount: current.expectedPalletCount,
|
|
cargo: current.cargo, quoteRef: null, idempotencyKey: key(`${label}-EDIT`) }
|
|
for (const [method, path, command, token] of [
|
|
['POST', `${POOL}/${offerNo}/inquiries`, { ...body, recordVersion: null, idempotencyKey: key(`${label}-CREATE`) }, salesToken],
|
|
['PUT', `${POOL}/inquiries/${bookingNo}`, body, salesToken],
|
|
['POST', `${POOL}/inquiries/${bookingNo}/confirm`, { tripRecordVersion: current.tripRecordVersion,
|
|
offerRecordVersion: current.offerRecordVersion, recordVersion: current.recordVersion, idempotencyKey: key(`${label}-CONFIRM`) }, dispatcherToken],
|
|
]) {
|
|
const response = await reject(`${label} ${method} cannot mutate business`, method, path, command, token)
|
|
check(`${label} rejection comes from expected offer gate`, reason.test(id(response.body?.message)))
|
|
}
|
|
check(`${label} preserves original draft and does not create a commercial order`, before === inquiryFact(await latest()))
|
|
check(`${label} preserves actual reservations`, capacitySnapshot(beforeOffer) === capacitySnapshot(await detail()))
|
|
}
|
|
const original = await detail()
|
|
const paused = await control('pause', 'PAUSE-MATRIX')
|
|
check('Pause blocks both create and confirm affordances', paused.status === 'PAUSED' && !paused.canCreateInquiry && !paused.canConfirm)
|
|
await refusedCommands('PAUSED', /暂停|开放|接单|发布/)
|
|
const resumed = await control('resume', 'RESUME-MATRIX')
|
|
check('Explicit resume restores the same capacity', resumed.status === 'OPEN' && resumed.canCreateInquiry && resumed.canConfirm
|
|
&& capacitySnapshot(original) === capacitySnapshot(resumed))
|
|
proof.covered.push('paused-create-edit-confirm-rejected')
|
|
const expiring = await editCutoff(new Date(Date.now() + 8000).toISOString(), 'SHORT-CUTOFF')
|
|
const delay = expiryWaitMs(expiring.cutoffAt)
|
|
await wait(delay)
|
|
const expired = await detail()
|
|
check('Server marks cutoff expiry without mutating the stored OPEN status', expired.status === 'OPEN' && !expired.canCreateInquiry
|
|
&& !expired.canConfirm && /截止|发车时间/.test(id(expired.blockReason)))
|
|
await refusedCommands('EXPIRED', /截止|发车时间/)
|
|
const restoreAt = Math.min(Date.now() + 1800000, Date.parse(expired.plannedDepartureAt) - 60000)
|
|
requireThat(restoreAt > Date.now() + 10000, 'TRIP_NO_LONGER_HAS_TIME_TO_RESTORE_CUTOFF')
|
|
await editCutoff(new Date(restoreAt).toISOString(), 'RESTORE-CUTOFF')
|
|
const restored = await control('resume', 'RESTORE-AFTER-CUTOFF')
|
|
check('Deadline scenario ends with explicit valid acceptance restored', restored.canCreateInquiry && restored.canConfirm)
|
|
proof.cutoffMatrix = { status: 'VERIFIED', cutoffAt: expiring.cutoffAt, waitedMs: delay, restoredCutoffAt: restored.cutoffAt }
|
|
proof.covered.push('expired-cutoff-create-edit-confirm-rejected')
|
|
}
|
|
|
|
async function poolScenario(context, fixture, { execute = false, readInventory, credentials }) {
|
|
if (!execute) return poolPlan({ execute: false, runId: 'plan' })
|
|
requireThat(fixture?.marker === context.marker && fixture.members?.length === 2 && fixture.pdaToken
|
|
&& typeof readInventory === 'function', 'CURRENT_PHYSICAL_FIXTURE_REQUIRED')
|
|
const proof = context.createdResources.fleetCapacityPool = { status: 'CHECKING', marker: context.marker,
|
|
tripNo: fixture.tripNo, covered: [], remaining: [...POOL_REMAINING], actors: [], orders: [], snapshots: {} }
|
|
const tripProof = context.createdResources.fleetTrips.find(row => row.tripNo === fixture.tripNo)
|
|
const tripPath = `${API}/fleet/trips/${encode(fixture.tripNo)}`
|
|
const key = label => createHash('sha256').update(`${context.marker}:POOL:${label}`).digest('hex')
|
|
const check = (label, ok, response = null) => { context.assert(MODULE, label, response, Boolean(ok), '', { scenario: MODULE, deploymentBlocker: true }); requireThat(ok, label) }
|
|
const raw = (method, path, body, token = context.token) => context.request(method, path, body, token)
|
|
const request = async (method, path, body, token = context.token) => {
|
|
const response = await raw(method, path, body, token)
|
|
check(`${method} ${path}`, success(response) && response.requestId && response.traceId, response)
|
|
return response.body.data
|
|
}
|
|
const reject = async (label, method, path, body, token) => {
|
|
const response = await raw(method, path, body, token)
|
|
check(label, denied(response) && response.requestId && response.traceId, response)
|
|
return response
|
|
}
|
|
const permissions = await request('GET', `${IAM}/permissions`)
|
|
const actorSessions = [], merchantSessions = []
|
|
const businessPath = order => `${API}/${order.businessType === 'US_TRUCK' ? 'us-truck-orders' : 'local-looseboard-orders'}/${encode(order.businessOrderNo)}?customerId=${encode(order.customerId)}`
|
|
const createActor = async (kind, index, codes) => {
|
|
const suffix = createHash('sha256').update(`${context.marker}:${kind}:${index}`).digest('hex').slice(0, 16)
|
|
const found = codes.map(code => permissions.filter(p => p.permissionCode === code && p.status === 'ACTIVE'))
|
|
check('Exact pool permissions exist', found.every(rows => rows.length === 1 && rows[0].id))
|
|
const role = await request('POST', `${IAM}/roles`, { roleCode: `POOL_${kind}_${suffix}`.toUpperCase(), roleName: `${context.marker}-${kind}-${index}`, description: context.marker })
|
|
const actor = { kind, index, roleId: id(role.id) }; proof.actors.push(actor)
|
|
await request('PUT', `${IAM}/roles/permissions`, { roleId: actor.roleId, permissionIds: found.map(rows => id(rows[0].id)) })
|
|
const username = `pool_${kind}_${suffix}`, password = `Pool${randomUUID().replaceAll('-', '')}A9`
|
|
const user = await request('POST', `${IAM}/users`, { username, displayName: `${context.marker}-同名测试人员`,
|
|
phone: await findFleetActorPhone(context, suffix), email: `${username}@example.com`, tenantId: '0', roleIds: [actor.roleId], password, status: 'ACTIVE' })
|
|
actor.membershipId = id(user.membershipId)
|
|
const login = await request('POST', '/api/auth/login', { loginId: username, password }, '')
|
|
actorSessions.push({ accessToken: login.accessToken, refreshToken: login.refreshToken })
|
|
const effective = await request('GET', `${IAM}/authorization/permissions`, undefined, login.accessToken)
|
|
check('Temporary actor has exact permissions', Array.isArray(effective) && JSON.stringify([...effective].sort()) === JSON.stringify([...codes].sort()))
|
|
return login.accessToken
|
|
}
|
|
try {
|
|
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']))
|
|
for (const member of fixture.members) {
|
|
const credential = credentials?.get(member.customerId)
|
|
check('Original fixture customer credentials available only in memory', Boolean(credential))
|
|
const login = await request('POST', '/api/auth/login', { loginId: credential.username, password: credential.password }, '')
|
|
merchantSessions.push({ customerId: member.customerId, accessToken: login.accessToken, refreshToken: login.refreshToken })
|
|
await reject('Merchant token cannot read internal capacity pool', 'GET', `${POOL}?page=1&size=20`, undefined, login.accessToken)
|
|
}
|
|
proof.covered.push('merchant-cannot-read-pool')
|
|
let detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`)
|
|
check('One actual thirty-space trip and two original real pallets', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2)
|
|
const planned = controlledPalletMeasurements(plan.cargo, detail.trip.stops).map(p => ({ ...p, palletSpaces: 9 }))
|
|
await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, pallets: planned, reason: 'TEST容量计划18托位;实际装载另行实测确认' })
|
|
detail = await request('GET', tripPath)
|
|
const offerDraft = await request('POST', POOL, { tripNo: fixture.tripNo, tripRecordVersion: detail.trip.recordVersion,
|
|
cutoffAt: new Date(Date.now() + 1800000).toISOString(), contactName: 'TEST调度', contactPhone: '19900000000', remark: context.marker, idempotencyKey: key('OFFER') }, dispatchers[0])
|
|
let offer = (await request('POST', `${POOL}/${offerDraft.offer.offerNo}/publish`, { tripRecordVersion: offerDraft.offer.tripRecordVersion,
|
|
offerRecordVersion: offerDraft.offer.recordVersion, idempotencyKey: key('PUBLISH') }, dispatchers[0])).offer
|
|
proof.offerNo = offer.offerNo
|
|
const destinationKey = plan.cargo[0].destinationKey
|
|
check('Owned physical sources share exact destination', plan.cargo.every(row => row.destinationKey === destinationKey))
|
|
assertPoolRemaining(offer, destinationKey, 12)
|
|
const offerDetail = async token => request('GET', `${POOL}/${proof.offerNo}`, undefined, token)
|
|
const createInquiry = async (index, type, spaces, label, incomplete = false, cargoOverrides = {}) => {
|
|
offer = (await offerDetail(sales[index])).offer
|
|
const body = { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion,
|
|
customerId: fixture.members[index].customerId, businessType: type, deliveryMode: 'SELF_DELIVERY', expectedPalletCount: incomplete ? null : 1,
|
|
estimatedArrivalAt: new Date(Date.now() + 900000).toISOString(), idempotencyKey: key(label),
|
|
cargo: { destinationKey, cargoName: `${context.marker}-${label}`, packageCount: incomplete ? null : 1, palletSpaces: spaces,
|
|
weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 30, currency: 'CNY', contactName: 'TEST货主', contactPhone: '19900000000', remark: context.marker, ...cargoOverrides } }
|
|
return (await request('POST', `${POOL}/${proof.offerNo}/inquiries`, body, sales[index])).inquiry
|
|
}
|
|
const incomplete = await createInquiry(0, 'US_TRUCK', null, 'UNKNOWN', true)
|
|
check('Unknown box/pallet counts are null instead of fabricated one', incomplete.cargo.packageCount == null && incomplete.expectedPalletCount == null)
|
|
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 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')
|
|
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 })
|
|
proof.dimensionRejections = []
|
|
for (const dimension of ['weightKg', 'volumeCbm']) {
|
|
const available = (await offerDetail(dispatchers[0])).offer.destinations.find(row => row.destinationKey === destinationKey)?.remaining
|
|
check('Independent dimension test has ordinary spare pallet weight and volume capacity', Number(available?.palletSpaces) >= 1
|
|
&& Number(available?.weightKg) > 100 && Number(available?.volumeCbm) > 1)
|
|
const inquiry = await createInquiry(1, 'US_TRUCK', 1, `OVER-${dimension}`, false, { [dimension]: Number(available[dimension]) + 1 })
|
|
const before = await latest(inquiry.bookingNo), capacityBefore = (await offerDetail(dispatchers[0])).offer
|
|
const response = await reject(`${dimension} overrun cannot be hidden by spare pallet spaces`, 'POST', `${POOL}/inquiries/${inquiry.bookingNo}/confirm`,
|
|
command(before, `REJECT-${dimension}`), dispatchers[1])
|
|
check('Independent load rejection is an actual capacity-limit conflict', /超过.*限制|容量|载重|容积/.test(id(response.body?.message)))
|
|
const after = await latest(inquiry.bookingNo)
|
|
check('Dimension refusal leaves draft source and all prior reservations unchanged', inquiryFact(before) === inquiryFact(after)
|
|
&& after.status === 'DRAFT' && !after.businessOrderNo && capacitySnapshot(capacityBefore) === capacitySnapshot((await offerDetail(dispatchers[0])).offer))
|
|
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)]
|
|
const race = await Promise.all(contenders.map((row, index) => raw('POST', `${POOL}/inquiries/${row.bookingNo}/confirm`, command(row, `RACE-${index}`), dispatchers[index])))
|
|
check('Two dispatcher race confirms exactly one remaining reservation', race.filter(success).length === 1 && race.filter(denied).length === 1)
|
|
const winner = race.find(success).body.data.inquiry
|
|
offer = (await offerDetail(dispatchers[0])).offer; assertPoolRemaining(offer, destinationKey, 0)
|
|
proof.orders = [firstConfirmed.inquiry, winner]
|
|
proof.covered.push('two-dispatchers-last-four-race')
|
|
const poolMembers = []
|
|
for (const [index, order] of proof.orders.entries()) {
|
|
const source = await request('GET', businessPath(order))
|
|
check('New pool commercial order retains unknown container and palletizing facts', assertPoolUnknownCommercialFacts(order.businessType, source.summary))
|
|
check('Pool commercial order has source arrangement and waits for real inbound', source.summary.status === 'WAITING_INBOUND'
|
|
&& source.summary.fleetArrangement?.bookingNo === order.bookingNo && !source.summary.fleetArrangement.incomingNo)
|
|
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 })
|
|
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',
|
|
'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]
|
|
proof.pickScopes = []
|
|
for (const member of members) {
|
|
const namespace = await pickPoolDispatch(context, fixture.pdaToken, member)
|
|
proof.pickScopes.push({ customerId: member.customerId, dispatchNo: member.dispatchNo, namespace })
|
|
const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(member.dispatchNo)}`, undefined, fixture.pdaToken)
|
|
check('Every owned and external pallet was actually picked', task.items.every(row => Number(row.pickedPackages) === Number(row.scheduledPackages)))
|
|
}
|
|
const loading = await request('GET', `${tripPath}/loading`)
|
|
check('All four original physical pallets present for actual loading', loading.cargo.length === 4 && !loading.blockReason)
|
|
const loaded = await request('POST', `${tripPath}/loading`, { recordVersion: loading.recordVersion, manifestHash: loading.manifestHash,
|
|
pallets: controlledPalletMeasurements(loading.cargo, detail.trip.stops), reason: 'TEST受控实测每实托1托位;不同于之前18托位接单计划' })
|
|
detail = await request('POST', `${tripPath}/loading-review`, { recordVersion: loaded.recordVersion, decision: 'APPROVED' })
|
|
const departBody = { recordVersion: detail.trip.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker }
|
|
const departPath = `/api/pda/v1/last-leg/dispatches/${encode(fixture.members[0].dispatchNo)}/depart`
|
|
const departed = await request('POST', departPath, departBody, fixture.pdaToken)
|
|
check('One whole-trip actual departure includes exactly four dispatches', departed.status === 'IN_TRANSIT' && Number(departed.shippedPackages) === 4
|
|
&& departed.dispatches.length === 4 && members.every(member => departed.dispatches.some(row => row.dispatchNo === member.dispatchNo)))
|
|
check('Departure original-key replay is explicit', (await request('POST', departPath, departBody, fixture.pdaToken)).replayed === true)
|
|
for (const [index, member] of members.entries()) {
|
|
const path = `${API}/delivery-orders/${encode(member.deliveryNo)}?customerId=${encode(member.customerId)}`
|
|
const currentDelivery = await request('GET', path)
|
|
await request('PUT', path.replace('?', '/status?'), { status: 'DELIVERING', expectedVersion: currentDelivery.order.version, remark: context.marker })
|
|
const signed = await saveReceiptAndComplete(context, { deliveryNo: member.deliveryNo }, member.customerId, index)
|
|
check('Receipt actually completes this delivery', signed?.order?.status === 'DELIVERED')
|
|
}
|
|
proof.covered.push('shared-real-departure-and-signature')
|
|
for (const [index, order] of proof.orders.entries()) {
|
|
const commercial = await request('GET', businessPath(order))
|
|
check('Completed commercial order retains unknown container and palletizing facts', assertPoolUnknownCommercialFacts(order.businessType, commercial.summary))
|
|
check('Each business type completes from actual signature', commercial.summary.status === 'COMPLETED'
|
|
&& commercial.summary.fleetArrangement?.fulfillmentStage === 'COMPLETED' && Number(commercial.summary.fleetArrangement.signedPackages) === 1)
|
|
const member = poolMembers[index], actual = await request('GET', `${API}/delivery-orders/${member.deliveryNo}?customerId=${order.customerId}`)
|
|
const fees = actual.fees.filter(row => row.feeType === 'DELIVERY_FEE' && row.feeDirection === 'RECEIVABLE')
|
|
check('One agreed receivable remains, no order/receipt duplicate fee', fees.length === 1 && Number(fees[0].amount) === 30 && fees[0].currency === 'CNY')
|
|
const merchant = merchantSessions.find(session => session.customerId === order.customerId)
|
|
const merchantPath = `/api/merchant/v1/last-leg/${order.businessType === 'US_TRUCK' ? 'us-truck-orders' : 'local-looseboard-orders'}/${encode(order.businessOrderNo)}`
|
|
const own = await request('GET', merchantPath, undefined, merchant.accessToken)
|
|
check('Client completed order retains unknown container and palletizing facts', assertPoolUnknownCommercialFacts(order.businessType, own.summary))
|
|
check('Client sees only own complete commercial arrangement', own.summary.orderNo === order.businessOrderNo && own.summary.fleetArrangement?.businessOrderNo === order.businessOrderNo)
|
|
}
|
|
proof.covered.push('business-completion-and-fee-uniqueness', 'pool-unspecified-container-and-palletizing-remain-unknown')
|
|
proof.poolMembers = poolMembers; proof.status = 'VERIFIED'
|
|
return proof
|
|
} catch (error) { proof.status = 'FAILED'; throw error }
|
|
finally {
|
|
for (const actor of proof.actors) {
|
|
if (actor.membershipId) await request('PUT', `${IAM}/users/${actor.membershipId}/status`, { status: 'DISABLED' })
|
|
if (actor.roleId) await request('PUT', `${IAM}/roles/${actor.roleId}/status`, { status: 'DISABLED' })
|
|
}
|
|
// The outer runner tracks and logs out all successful login sessions, including these.
|
|
actorSessions.length = 0; merchantSessions.length = 0
|
|
}
|
|
}
|
|
|
|
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')
|
|
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)
|
|
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)
|
|
check('One actual measured draft only', measured.pallets.length === 1 && measured.pallets[0].status === 'DRAFT')
|
|
const palletPath = `${pdaPath}/pallets/${measured.pallets[0].id}`
|
|
const generated = await request('POST', `${palletPath}/generate`, { idempotencyKey: key(`GENERATE-${index}`) }, fixture.pdaToken)
|
|
const pallet = generated.pallets[0]
|
|
await request('POST', `${palletPath}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key(`BIND-PALLET-${index}`) }, fixture.pdaToken)
|
|
const uploaded = await context.requestMultipart('POST', `${pdaPath}/measurement-photos/files`, { name: `${context.marker}-pool-${index}-TEST.png`, type: 'image/png', content: PNG }, fixture.pdaToken)
|
|
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 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 })
|
|
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}`) })
|
|
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,
|
|
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)
|
|
detail = await request('GET', tripPath)
|
|
const transfer = { idempotencyKey: key(`TRANSFER-${index}`), plannedDeliveryAt: localTime(21600000), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP',
|
|
loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', vehiclePlate: detail.trip.vehiclePlate, driverName: detail.trip.driverName,
|
|
driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', externalFreightHash: freight.snapshotHash, fees: [], remark: context.marker }
|
|
const converted = await request('POST', `${path}/transfer-delivery`, transfer)
|
|
check('Pool source produces exactly one outbound and delivery', converted.outboundNo && converted.deliveries.length === 1)
|
|
const replay = await request('POST', `${path}/transfer-delivery`, transfer)
|
|
check('Transfer replay reuses outbound and delivery', replay.replayed === true && replay.outboundNo === converted.outboundNo && replay.deliveries[0].deliveryNo === converted.deliveries[0].deliveryNo)
|
|
return { customerId: order.customerId, bookingNo: order.bookingNo, businessOrderNo: order.businessOrderNo, incomingNo, dispatchNo: dispatch.dispatchNo,
|
|
outboundNo: converted.outboundNo, deliveryNo: converted.deliveries[0].deliveryNo, palletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo }
|
|
}
|
|
|
|
export async function runPoolAcceptance(config, { env = process.env, outputRoot = ROOT, runner = runCrossCustomer } = {}) {
|
|
if (!config.execute) return poolPlan(config)
|
|
const credentials = new Map(); let restoreWrites
|
|
try {
|
|
const result = await runner(config, { env, outputRoot,
|
|
contextFactory: options => {
|
|
const context = new AcceptanceRun(options), original = context.request
|
|
context.request = async function (...args) {
|
|
const response = await original.apply(this, args)
|
|
if (args[0] === 'POST' && args[1] === `${IAM}/tenants` && success(response)) {
|
|
const data = response.body.data, customer = data.tenant || data.customer || data
|
|
credentials.set(id(customer.id || data.tenantId), { username: data.administratorUsername, password: data.temporaryPassword })
|
|
}
|
|
return response
|
|
}
|
|
const directory = join(outputRoot, 'reports', `fleet-cross-${config.runId}`, 'pool-write-journal')
|
|
mkdirSync(directory)
|
|
restoreWrites = guardFleetWrites(context, async entry => {
|
|
const file = openSync(join(directory, `${entry.sequence}.json`), 'w')
|
|
try { writeFileSync(file, JSON.stringify(redactFleetWrite(entry), null, 2) + '\n'); fsyncSync(file) } finally { closeSync(file) }
|
|
}, { guardFetch: true })
|
|
return context
|
|
},
|
|
verify: (context, fixture, options) => poolScenario(context, fixture, { ...options, credentials }) })
|
|
result.scope = 'FLEET_CAPACITY_POOL_HTTP'; result.fullAcceptance = false
|
|
result.remainingSeparateAcceptance = [...POOL_REMAINING]
|
|
const output = join(outputRoot, 'reports', `fleet-cross-${config.runId}`)
|
|
const sources = ['scripts/fleet-capacity-pool-test-env.mjs'].map(path => ({ path, sha256: createHash('sha256').update(readFileSync(join(ROOT, path))).digest('hex') }))
|
|
writeFileSync(join(output, 'pool-scope.json'), JSON.stringify({ plan: poolPlan(config), sources, success: result.success,
|
|
fullAcceptance: false, covered: result.createdResources?.fleetCapacityPool?.covered || [], remaining: POOL_REMAINING }, null, 2) + '\n', { flag: 'wx' })
|
|
return result
|
|
} finally { restoreWrites?.(); credentials.clear() }
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
try {
|
|
const config = parsePoolArguments(process.argv.slice(2)), result = await runPoolAcceptance(config)
|
|
console.log(JSON.stringify(config.execute ? { success: result.success, fullAcceptance: result.fullAcceptance, error: result.error,
|
|
summary: result.summary, covered: result.createdResources?.fleetCapacityPool?.covered, remaining: result.remainingSeparateAcceptance,
|
|
output: poolPlan(config).output } : result, null, 2))
|
|
if (config.execute && !result.success) process.exitCode = 1
|
|
} catch (error) { console.error(error.message); process.exitCode = 1 }
|
|
}
|