288 lines
20 KiB
JavaScript
288 lines
20 KiB
JavaScript
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
|
|
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
|
|
|
|
const MODULE = 'truck-appointments'
|
|
const OPTIONS = { scenario: 'booking-level-truck-appointment-lifecycle', deploymentBlocker: true }
|
|
const TRUCKS = '/api/platform/v1/truck-appointments'
|
|
const BOOKINGS = '/api/platform/v1/ocean-bookings'
|
|
const FORBIDDEN_TRUCK_FIELDS = ['containerBookingId', 'loadingPlanId', 'containerNo', 'cabinetNo', 'containers']
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
|
function data(response) { return codeOf(response) === '200' ? response.body.data : null }
|
|
function failure(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') }
|
|
function records(value) { return Array.isArray(value) ? value : value?.records || [] }
|
|
function hasForbiddenTruckFields(value) { return FORBIDDEN_TRUCK_FIELDS.some((field) => Object.hasOwn(value || {}, field)) }
|
|
|
|
export function minimalPdfBytes(label = 'truck-appointment-fixture') {
|
|
const safeLabel = String(label).replace(/[^\x20-\x7E]/g, '_').slice(0, 64)
|
|
return new TextEncoder().encode(`%PDF-1.4
|
|
% ${safeLabel}
|
|
1 0 obj
|
|
<< /Type /Catalog /Pages 2 0 R >>
|
|
endobj
|
|
2 0 obj
|
|
<< /Type /Pages /Kids [] /Count 0 >>
|
|
endobj
|
|
trailer
|
|
<< /Root 1 0 R >>
|
|
%%EOF
|
|
`)
|
|
}
|
|
|
|
export function platformWarehouseIdentity(item) {
|
|
return { warehouseId: id(item?.warehouseId || item?.id || item?.warehouse?.id), platformTenantId: '0' }
|
|
}
|
|
|
|
export function activeCustomerIdentity(item) {
|
|
const customerId = id(item?.id || item?.tenantId)
|
|
const customerName = String(item?.tenantName || item?.customerName || item?.name || '').trim()
|
|
return customerId && customerName ? { customerId, customerName } : null
|
|
}
|
|
|
|
async function request(context, method, path, body, headers = {}) {
|
|
try {
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
method, headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
|
...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers },
|
|
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000),
|
|
})
|
|
const raw = await response.text(); let parsed = null
|
|
try { parsed = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
|
return { method, path, http: response.status, body: parsed, raw: parsed == null ? raw.slice(0, 500) : null,
|
|
requestId: parsed?.requestId || response.headers.get('x-request-id'), traceId: parsed?.traceId || response.headers.get('x-trace-id') }
|
|
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
|
|
}
|
|
|
|
export function inspectOceanBookingIdentity(marker, resource, detail = null) {
|
|
if (!resource?.id || resource.marker !== marker) return { identity: null, reason: '订舱不属于本轮' }
|
|
const booking = detail?.oceanBooking || resource
|
|
if (id(booking.id) !== id(resource.id)) return { identity: null, reason: '订舱详情资源归属不一致' }
|
|
return { identity: { oceanBookingId: id(resource.id), warehouseId: id(booking.warehouseId) }, reason: null, missingFields: [] }
|
|
}
|
|
|
|
export function truckAppointmentPayload(context, suffix, identity, version = 0, overrides = {}) {
|
|
const supplier = identity.truckSupplier || context.createdResources?.truckSupplier || {}
|
|
return { outboundOrderId: null, oceanBookingId: identity.oceanBookingId, warehouseId: identity.warehouseId,
|
|
carrierId: id(supplier.supplierId), carrierName: supplier.supplierName,
|
|
billOfLadingNo: identity.billOfLadingNo, fleetContact: '车队联系人', fleetPhone: '13800000004',
|
|
driverName: '司机验收', driverPhone: '13800000005', vehicleNo: `粤B-${suffix.slice(-6)}`,
|
|
storeName: `装柜点-${suffix}`, storeContact: '仓库联系人', storePhone: '13800000006',
|
|
storeAddress: '深圳市南山区验收地址', storeCity: '深圳', storeZipcode: '518000',
|
|
packageCount: 0, grossWeightKg: 0, volumeCbm: 0, palletCount: 0, poNo: `PO-${suffix}`,
|
|
plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0,
|
|
currency: 'RMB', remark: `订舱级拖车-${suffix}`, expectedVersion: version, ...overrides }
|
|
}
|
|
|
|
async function activeSupplier(context, category, suffix) {
|
|
const carrierFilter = category === 'BOOKING' ? '&carrierCode=MATSON' : ''
|
|
const list = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&category=${category}&status=ACTIVE${carrierFilter}&page=1&size=100`)
|
|
const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE'
|
|
&& (category !== 'BOOKING' || item?.carrierCodes?.includes?.('MATSON')))
|
|
if (existing) return { supplierId: id(existing.id), supplierName: existing.name }
|
|
const created = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', {
|
|
name: `${category}-验收-${suffix}`, category, carrierCodes: category === 'BOOKING' ? ['MATSON'] : [],
|
|
region: '深圳', legalRepresentative: '验收法人', contactName: '验收联系人', phone: '13800000000',
|
|
email: 'truck-acceptance@example.com', address: '深圳市验收地址', businessLicenseNo: `TRUCK-${suffix}`.slice(0, 64),
|
|
licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker,
|
|
})
|
|
const value = data(created); const supplierId = id(value?.id || value)
|
|
context.apiSuccess(MODULE, `创建 ACTIVE ${category} 供应商`, created, '', OPTIONS)
|
|
return supplierId ? { supplierId, supplierName: value?.name || `${category}-验收-${suffix}` } : null
|
|
}
|
|
|
|
async function bookingMaster(context, suffix) {
|
|
const fixtureCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId
|
|
|| context.context?.customerId || context.customerId)
|
|
const customersResponse = await request(context, 'GET', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE')
|
|
const candidates = records(data(customersResponse))
|
|
const customer = activeCustomerIdentity(candidates.find((item) =>
|
|
fixtureCustomerId && id(item?.id || item?.tenantId) === fixtureCustomerId) || candidates[0])
|
|
if (!customer) {
|
|
context.addStep(MODULE, '通过正式平台客户查询解析 ACTIVE 订舱客户', customersResponse, false,
|
|
`preferredCustomerId=${fixtureCustomerId || 'missing'}`, OPTIONS)
|
|
return null
|
|
}
|
|
let lastWarehousesResponse = null
|
|
for (let page = 1; page <= 100; page++) {
|
|
const warehousesResponse = await request(context, 'GET',
|
|
`/api/platform/v1/warehouse-admin?tenantId=0&warehouseType=DOMESTIC&status=ACTIVE&page=${page}&size=100`)
|
|
lastWarehousesResponse = warehousesResponse
|
|
const warehousePage = data(warehousesResponse); const warehouseRows = records(warehousePage)
|
|
for (const item of warehouseRows) {
|
|
const { warehouseId } = platformWarehouseIdentity(item)
|
|
if (!warehouseId) continue
|
|
const channelsResponse = await request(context, 'GET', `${BOOKINGS}/channels?originWarehouseId=${warehouseId}`)
|
|
const channelRows = records(data(channelsResponse)); const channel = channelRows[0]
|
|
if (channel?.channelCode) return { ...customer, warehouseId, channelCode: channel.channelCode, channelName: channel.channelName }
|
|
}
|
|
const total = Number(warehousePage?.total ?? warehouseRows.length)
|
|
if (warehouseRows.length === 0 || page * 100 >= total) break
|
|
}
|
|
context.addStep(MODULE, '解析具备有效海运渠道的平台仓库', lastWarehousesResponse, false,
|
|
`platformTenantId=0, customerId=${customer.customerId}`, OPTIONS)
|
|
return null
|
|
}
|
|
|
|
async function candidate(context, identity) {
|
|
const response = await request(context, 'GET', `${TRUCKS}/candidates?keyword=${encodeURIComponent(identity.oceanBookingNo)}&page=1&size=200`)
|
|
const page = data(response)
|
|
return { response, item: records(page).find((value) => id(value?.oceanBookingId) === identity.oceanBookingId) }
|
|
}
|
|
|
|
async function fixture(context) {
|
|
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-10) || 'TRUCK'
|
|
const master = await bookingMaster(context, suffix); if (!master?.customerId) return null
|
|
const bookingAgent = await activeSupplier(context, 'BOOKING', suffix)
|
|
const truckSupplier = await activeSupplier(context, 'TRUCKING', suffix)
|
|
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
|
|
if (!bookingAgent || !truckSupplier || !carrier?.id) return null
|
|
const draft = oceanBookingDraft(context, suffix, master, {
|
|
bookingAgentId: bookingAgent.supplierId, bookingAgentName: bookingAgent.supplierName,
|
|
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON',
|
|
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }],
|
|
})
|
|
const createdResponse = await request(context, 'POST', BOOKINGS, { expectedVersion: 0, data: draft },
|
|
{ 'Idempotency-Key': `${context.marker}-truck-booking-create`.slice(0, 128) })
|
|
const created = context.apiSuccess(MODULE, '自包含创建 DRAFT 订舱', createdResponse, '', OPTIONS)
|
|
const bookingId = id(created?.oceanBooking?.id); if (!bookingId) return null
|
|
const identity = { suffix, oceanBookingId: bookingId, oceanBookingNo: created.oceanBooking.bookingNo,
|
|
warehouseId: master.warehouseId, billOfLadingNo: created.oceanBooking.billOfLadingNo, truckSupplier }
|
|
const draftCandidate = await candidate(context, identity)
|
|
context.assert(MODULE, 'DRAFT订舱不进入拖车候选', draftCandidate.response, !draftCandidate.item,
|
|
`oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS)
|
|
|
|
const form = new FormData(); form.append('expectedVersion', String(created.oceanBooking.version))
|
|
form.append('documentType', 'BILL_OF_LADING')
|
|
form.append('file', new Blob([minimalPdfBytes(`${context.marker}-BILL_OF_LADING`)], { type: 'application/pdf' }), `bill-${suffix}.pdf`)
|
|
const uploadResponse = await context.requestMultipart('POST', `${BOOKINGS}/${bookingId}/documents/upload`, form,
|
|
context.token, { 'Idempotency-Key': `${context.marker}-truck-booking-file`.slice(0, 128) })
|
|
const uploaded = context.apiSuccess(MODULE, '上传当前有效 BILL_OF_LADING 文件', uploadResponse, '', OPTIONS)
|
|
const releaseResponse = await request(context, 'POST', `${BOOKINGS}/${bookingId}/release`,
|
|
{ expectedVersion: Number(uploaded?.bookingVersion), reason: context.marker },
|
|
{ 'Idempotency-Key': `${context.marker}-truck-booking-release`.slice(0, 128) })
|
|
const released = context.apiSuccess(MODULE, '放舱自包含订舱', releaseResponse, '', OPTIONS)
|
|
if (released?.oceanBooking?.status !== 'RELEASED') return null
|
|
identity.billOfLadingNo = released.oceanBooking.billOfLadingNo
|
|
const releasedCandidate = await candidate(context, identity)
|
|
context.assert(MODULE, 'RELEASED且资料有效的订舱进入候选', releasedCandidate.response,
|
|
Boolean(releasedCandidate.item) && !hasForbiddenTruckFields(releasedCandidate.item),
|
|
`oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS)
|
|
return identity
|
|
}
|
|
|
|
async function action(context, path, appointment, name, reason) {
|
|
const response = await request(context, 'POST', `${path}/${name.toLowerCase()}`,
|
|
{ expectedVersion: Number(appointment?.version), reason },
|
|
{ 'Idempotency-Key': `${context.marker}-truck-${name.toLowerCase()}`.slice(0, 128) })
|
|
return context.apiSuccess(MODULE, `整单推进${name}`, response, '', OPTIONS)
|
|
}
|
|
|
|
export async function run(context) {
|
|
const identity = await fixture(context)
|
|
context.assert(MODULE, '订舱级拖车模块自包含前置链完整', null,
|
|
Boolean(identity?.oceanBookingId && identity?.oceanBookingNo && identity?.truckSupplier?.supplierId),
|
|
`oceanBookingNo=${identity?.oceanBookingNo || 'missing'}`, OPTIONS)
|
|
if (!identity) return
|
|
context.createdResources.truckSupplier = identity.truckSupplier
|
|
const base = truckAppointmentPayload(context, identity.suffix, identity)
|
|
context.assert(MODULE, '创建请求完全不含柜次协议字段', null,
|
|
!hasForbiddenTruckFields(base) && !Object.hasOwn(base, 'customerId') && !Object.hasOwn(base, 'customerName'),
|
|
Object.keys(base).join(','), OPTIONS)
|
|
for (const [label, payload] of [
|
|
['错误订舱拒绝创建', { ...base, oceanBookingId: (BigInt(identity.oceanBookingId) + 1n).toString() }],
|
|
['错误仓库拒绝创建', { ...base, warehouseId: (BigInt(identity.warehouseId) + 1n).toString() }],
|
|
['错误提单拒绝创建', { ...base, billOfLadingNo: `WRONG-${identity.suffix}` }],
|
|
['错误承运商拒绝创建', { ...base, carrierId: (BigInt(identity.truckSupplier.supplierId) + 1n).toString() }],
|
|
['非法拖车币种拒绝创建', { ...base, currency: 'CNY' }],
|
|
]) {
|
|
const response = await request(context, 'POST', TRUCKS, payload)
|
|
context.addStep(MODULE, label, response, failure(response), `code=${codeOf(response)}`, OPTIONS)
|
|
}
|
|
|
|
const createdResponse = await request(context, 'POST', TRUCKS, base)
|
|
let appointment = context.apiSuccess(MODULE, '创建合法订舱级拖车预约', createdResponse, '', OPTIONS)
|
|
const appointmentId = id(appointment?.id); if (!appointmentId) return
|
|
const path = `${TRUCKS}/${appointmentId}`
|
|
context.assert(MODULE, '创建响应完全无柜次协议且费用正确', createdResponse,
|
|
!hasForbiddenTruckFields(appointment) && appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0,
|
|
`appointmentNo=${appointment?.appointmentNo || 'missing'}`, OPTIONS)
|
|
const afterCreateCandidate = await candidate(context, identity)
|
|
context.assert(MODULE, '创建后订舱立即退出候选', afterCreateCandidate.response, !afterCreateCandidate.item,
|
|
`appointmentNo=${appointment.appointmentNo}`, OPTIONS)
|
|
const duplicateBefore = await request(context, 'POST', TRUCKS, base)
|
|
context.addStep(MODULE, '同订舱第二张非终态拖车单冲突', duplicateBefore, failure(duplicateBefore),
|
|
`code=${codeOf(duplicateBefore)}, appointmentNo=${appointment.appointmentNo}`, OPTIONS)
|
|
const unchangedResponse = await request(context, 'GET', path)
|
|
const unchanged = context.apiSuccess(MODULE, '重复创建冲突后回查零漂移', unchangedResponse, '', OPTIONS)
|
|
context.assert(MODULE, '重复创建未改变版本和状态', unchangedResponse,
|
|
Number(unchanged?.version) === Number(appointment.version) && unchanged?.status === 'DRAFT', '', OPTIONS)
|
|
|
|
const attachmentForm = new FormData(); attachmentForm.append('attachmentType', 'DISPATCH_ORDER')
|
|
attachmentForm.append('file', new Blob([minimalPdfBytes(`${context.marker}-DISPATCH_ORDER`)], { type: 'application/pdf' }),
|
|
`dispatch-order-${identity.suffix}.pdf`)
|
|
const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`,
|
|
attachmentForm, context.token)
|
|
const attachment = context.apiSuccess(MODULE, '上传DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS)
|
|
const attachmentListResponse = await request(context, 'GET', `${path}/attachments`)
|
|
const attachmentList = context.apiSuccess(MODULE, '查询拖车附件列表', attachmentListResponse, '', OPTIONS)
|
|
context.assert(MODULE, '附件列表包含本轮运营附件', attachmentListResponse,
|
|
records(attachmentList).some((item) => id(item?.id) === id(attachment?.id) && item?.attachmentType === 'DISPATCH_ORDER'),
|
|
`attachmentId=${id(attachment?.id) || 'missing'}`, OPTIONS)
|
|
|
|
const editedResponse = await request(context, 'PUT', path,
|
|
truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'USD', payableAmount: 12.34 }))
|
|
appointment = context.apiSuccess(MODULE, '编辑订舱级拖车为USD应付费用', editedResponse, '', OPTIONS)
|
|
context.assert(MODULE, '编辑响应费用正确且无柜次协议', editedResponse,
|
|
appointment?.currency === 'USD' && Number(appointment?.payableAmount) === 12.34 && !hasForbiddenTruckFields(appointment), '', OPTIONS)
|
|
|
|
const submitKey = `${context.marker}-truck-submit`.slice(0, 128)
|
|
const submitBody = { expectedVersion: Number(appointment.version), reason: '幂等提交' }
|
|
const submitResponse = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey })
|
|
appointment = context.apiSuccess(MODULE, '整单推进SUBMIT', submitResponse, '', OPTIONS)
|
|
const replayResponse = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey })
|
|
const replay = context.apiSuccess(MODULE, 'SUBMIT同键同载荷重放', replayResponse, '', OPTIONS)
|
|
context.assert(MODULE, 'SUBMIT重放版本零漂移', replayResponse,
|
|
Number(replay?.version) === Number(appointment.version) && replay?.status === appointment.status, '', OPTIONS)
|
|
const conflictResponse = await request(context, 'POST', `${path}/submit`, { ...submitBody, reason: '异载荷' },
|
|
{ 'Idempotency-Key': submitKey })
|
|
context.addStep(MODULE, 'SUBMIT同键异载荷A0409', conflictResponse,
|
|
codeOf(conflictResponse) === 'A0409', `code=${codeOf(conflictResponse)}`, OPTIONS)
|
|
|
|
for (const [name, reason] of [['DISPATCH', '派车'], ['PICKUP', '提柜'], ['ARRIVE', '到仓'],
|
|
['LOAD', '装货'], ['DEPART', '离仓'], ['COMPLETE', '完成']]) {
|
|
appointment = await action(context, path, appointment, name, reason)
|
|
context.assert(MODULE, `${name}响应无柜次协议`, null, Boolean(appointment) && !hasForbiddenTruckFields(appointment),
|
|
`status=${appointment?.status || 'missing'}`, OPTIONS)
|
|
}
|
|
const finalDetailResponse = await request(context, 'GET', path)
|
|
const finalDetail = context.apiSuccess(MODULE, '回查COMPLETED订舱级拖车详情', finalDetailResponse, '', OPTIONS)
|
|
context.assert(MODULE, '完整生命周期完成且详情无柜次协议', finalDetailResponse,
|
|
finalDetail?.status === 'COMPLETED' && !hasForbiddenTruckFields(finalDetail)
|
|
&& id(finalDetail?.oceanBookingId) === identity.oceanBookingId,
|
|
`appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}`, OPTIONS)
|
|
const afterCompleteCandidate = await candidate(context, identity)
|
|
context.assert(MODULE, 'COMPLETED后订舱恢复候选', afterCompleteCandidate.response, Boolean(afterCompleteCandidate.item),
|
|
`oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS)
|
|
const rebuiltResponse = await request(context, 'POST', TRUCKS, base)
|
|
let rebuilt = context.apiSuccess(MODULE, 'COMPLETED后同订舱可新建下一张拖车单', rebuiltResponse, '', OPTIONS)
|
|
context.assert(MODULE, '重建拖车单保持订舱级响应', rebuiltResponse,
|
|
rebuilt?.status === 'DRAFT' && !hasForbiddenTruckFields(rebuilt)
|
|
&& id(rebuilt?.oceanBookingId) === identity.oceanBookingId,
|
|
`appointmentNo=${rebuilt?.appointmentNo || 'missing'}`, OPTIONS)
|
|
const cancelled = await action(context, `${TRUCKS}/${id(rebuilt?.id)}`, rebuilt, 'CANCEL', '取消测试单')
|
|
const afterCancelCandidate = await candidate(context, identity)
|
|
context.assert(MODULE, 'CANCELLED后订舱恢复候选', afterCancelCandidate.response,
|
|
cancelled?.status === 'CANCELLED' && Boolean(afterCancelCandidate.item), '', OPTIONS)
|
|
const afterCancelResponse = await request(context, 'POST', TRUCKS, base)
|
|
rebuilt = context.apiSuccess(MODULE, 'CANCELLED后同订舱可再次新建', afterCancelResponse, '', OPTIONS)
|
|
const excepted = await action(context, `${TRUCKS}/${id(rebuilt?.id)}`, rebuilt, 'EXCEPTION', '异常测试单')
|
|
const afterExceptionCandidate = await candidate(context, identity)
|
|
context.assert(MODULE, 'EXCEPTION后订舱恢复候选', afterExceptionCandidate.response,
|
|
excepted?.status === 'EXCEPTION' && Boolean(afterExceptionCandidate.item), '', OPTIONS)
|
|
const finalDraftResponse = await request(context, 'POST', TRUCKS, base)
|
|
const finalDraft = context.apiSuccess(MODULE, 'EXCEPTION后同订舱可再次新建', finalDraftResponse, '', OPTIONS)
|
|
context.createdResources.truckAppointment = { id: id(finalDraft?.id), appointmentNo: finalDraft?.appointmentNo,
|
|
marker: context.marker, finalStatus: finalDraft?.status, oceanBookingId: identity.oceanBookingId }
|
|
}
|