2026-08-08 15:25:46 +08:00
|
|
|
const MODULE = 'service-center-flow'
|
|
|
|
|
const OPTIONS = { scenario: 'service-center-cross-flow', deploymentBlocker: true }
|
|
|
|
|
const OCEAN = '/api/platform/v1/ocean-bookings'
|
|
|
|
|
const TRUCK = '/api/platform/v1/truck-appointments'
|
|
|
|
|
|
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
|
|
|
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
|
|
|
|
|
2026-09-02 16:48:05 +08:00
|
|
|
async function requestWithTransientRetry(context, method, path, body) {
|
|
|
|
|
let response = null
|
|
|
|
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
|
|
|
response = await context.request(method, path, body)
|
|
|
|
|
if (response?.http !== 0) return response
|
|
|
|
|
if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, 500 * attempt))
|
|
|
|
|
}
|
|
|
|
|
return response
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 12:23:43 +08:00
|
|
|
async function actionRequest(context, path, endpoint, version) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${context.options.gateway}${path}/${endpoint}`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
|
|
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
|
|
|
'Idempotency-Key': `${context.marker}-flow-${endpoint}`.slice(0, 128),
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ expectedVersion: version, reason: context.marker }),
|
|
|
|
|
signal: AbortSignal.timeout(60_000),
|
|
|
|
|
})
|
|
|
|
|
const raw = await response.text(); let payload = null
|
|
|
|
|
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
|
|
|
|
return { method: 'POST', path: `${path}/${endpoint}`, http: response.status, body: payload,
|
|
|
|
|
raw: payload === null ? raw.slice(0, 500) : null,
|
|
|
|
|
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
|
|
|
|
traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return { method: 'POST', path: `${path}/${endpoint}`, http: 0, body: null,
|
|
|
|
|
raw: String(error), requestId: null, traceId: null }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:26:24 +08:00
|
|
|
async function uploadDispatchOrder(context, appointmentId) {
|
|
|
|
|
const path = `${TRUCK}/${appointmentId}/attachments/upload?attachmentType=DISPATCH_ORDER`
|
2026-08-24 14:01:02 +08:00
|
|
|
const form = new FormData()
|
|
|
|
|
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
|
|
|
|
|
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-bill.png`)
|
|
|
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
|
|
|
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
|
|
|
|
|
body: form, signal: AbortSignal.timeout(60_000),
|
|
|
|
|
})
|
|
|
|
|
const body = await response.json()
|
|
|
|
|
return { method: 'POST', path, http: response.status, body,
|
|
|
|
|
requestId: body?.requestId || response.headers.get('x-request-id'),
|
|
|
|
|
traceId: body?.traceId || response.headers.get('x-trace-id') }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 15:25:46 +08:00
|
|
|
export async function run(context) {
|
2026-08-09 11:21:38 +08:00
|
|
|
const containerFlow = context.createdResources?.phase3Loading
|
|
|
|
|
|| context.createdResources?.phase3ContainerLoading
|
2026-08-24 13:48:16 +08:00
|
|
|
const ocean = containerFlow?.oceanBookingId
|
|
|
|
|
? { id: id(containerFlow.oceanBookingId), finalStatus: 'RELEASED' }
|
|
|
|
|
: context.createdResources?.oceanBooking
|
2026-08-08 15:25:46 +08:00
|
|
|
if (!ocean?.id || ocean.finalStatus !== 'RELEASED') {
|
|
|
|
|
context.addStep(MODULE, '跨服务链路前置订舱终态可用', null, false,
|
|
|
|
|
'本轮海运订舱模块未返回 RELEASED 隔离记录,未伪造关联拖车预约', OPTIONS)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const suffix = context.marker.replace(/\D/g, '').slice(-10)
|
2026-08-08 20:47:26 +08:00
|
|
|
const oceanResponse = await context.request('GET', `${OCEAN}/${ocean.id}`)
|
2026-09-01 09:51:59 +08:00
|
|
|
const oceanDetail = context.apiSuccess(MODULE, '读取跨服务链路已放舱订舱', oceanResponse, `oceanBookingId=${ocean.id}`, OPTIONS)
|
2026-09-02 13:26:24 +08:00
|
|
|
const booking = oceanDetail?.oceanBooking || oceanDetail
|
|
|
|
|
context.assert(MODULE, '已放舱订舱可直接创建订舱级拖车预约', oceanResponse,
|
|
|
|
|
id(booking?.id) === id(ocean.id) && booking?.status === 'RELEASED',
|
|
|
|
|
`oceanBookingId=${id(booking?.id) || 'missing'}, status=${booking?.status || 'missing'}`, OPTIONS)
|
2026-09-02 16:48:05 +08:00
|
|
|
const carrierResponse = await requestWithTransientRetry(context, 'GET',
|
2026-09-16 11:06:33 +08:00
|
|
|
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&truckingScope=DOMESTIC&page=1&size=20')
|
2026-08-24 13:53:10 +08:00
|
|
|
const carrierPage = context.apiSuccess(MODULE, '读取 ACTIVE TRUCKING 承运商事实源', carrierResponse, '', OPTIONS)
|
2026-09-16 11:06:33 +08:00
|
|
|
const carrier = carrierPage?.records?.find?.((item) => item?.id && item?.name && item?.truckingScope === 'DOMESTIC')
|
2026-08-24 13:53:10 +08:00
|
|
|
context.assert(MODULE, '跨服务拖车使用正式 ACTIVE TRUCKING 承运商', carrierResponse,
|
|
|
|
|
Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS)
|
|
|
|
|
if (!carrier) return
|
2026-08-08 15:25:46 +08:00
|
|
|
const payload = {
|
2026-09-02 13:26:24 +08:00
|
|
|
outboundOrderId: null,
|
2026-08-08 21:34:39 +08:00
|
|
|
oceanBookingId: ocean.id,
|
2026-09-08 23:11:49 +08:00
|
|
|
warehouseId: id(booking?.warehouseId),
|
2026-09-01 09:51:59 +08:00
|
|
|
carrierId: id(carrier.id), carrierName: carrier.name,
|
2026-08-08 15:25:46 +08:00
|
|
|
fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008',
|
|
|
|
|
driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人',
|
|
|
|
|
storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000',
|
|
|
|
|
packageCount: 1, grossWeightKg: 1, volumeCbm: 0.1, palletCount: 0, poNo: `FLOW-PO-${suffix}`,
|
2026-09-01 10:38:15 +08:00
|
|
|
plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1,
|
2026-08-08 15:25:46 +08:00
|
|
|
currency: 'RMB', remark: context.marker, expectedVersion: 0,
|
|
|
|
|
}
|
|
|
|
|
const response = await context.request('POST', TRUCK, payload)
|
|
|
|
|
const appointment = context.apiSuccess(MODULE, '创建关联海运订舱的拖车预约', response, `oceanBookingId=${ocean.id}`, OPTIONS)
|
|
|
|
|
const appointmentId = id(appointment?.id)
|
2026-09-02 13:26:24 +08:00
|
|
|
context.assert(MODULE, '拖车预约仅关联订舱且版本和审计字段完整', response,
|
2026-08-08 15:25:46 +08:00
|
|
|
Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version))
|
2026-09-04 15:19:45 +08:00
|
|
|
&& !Object.hasOwn(appointment || {}, 'containerBookingId')
|
|
|
|
|
&& !Object.hasOwn(appointment || {}, 'loadingPlanId')
|
2026-08-08 15:25:46 +08:00
|
|
|
&& Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName),
|
2026-08-08 21:34:39 +08:00
|
|
|
`appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS)
|
2026-08-08 15:25:46 +08:00
|
|
|
if (!appointmentId) return
|
2026-08-10 12:23:43 +08:00
|
|
|
const path = `${TRUCK}/${appointmentId}`
|
2026-09-20 19:48:49 +08:00
|
|
|
// This consumer can run after all booking containers have departed. Creation then
|
|
|
|
|
// completes the appointment transactionally; trying SUBMIT would be an illegal reversal.
|
|
|
|
|
if (appointment.status === 'COMPLETED') {
|
|
|
|
|
const currentResponse = await context.request('GET', path)
|
|
|
|
|
const current = context.apiSuccess(MODULE, '回查离仓后创建拖车的自动完成状态', currentResponse, '', OPTIONS)
|
|
|
|
|
const historyResponse = await context.request('GET', `${path}/history`)
|
|
|
|
|
const history = context.apiSuccess(MODULE, '回查拖车自动完成权威历史', historyResponse, '', OPTIONS)
|
|
|
|
|
const verified = current?.status === 'COMPLETED' && id(current.oceanBookingId) === id(ocean.id)
|
|
|
|
|
&& Array.isArray(history) && history.some(row => row.action === 'CREATE' && id(row.appointmentId) === appointmentId)
|
|
|
|
|
&& history.some(row => row.action === 'AUTO_COMPLETE' && row.toStatus === 'COMPLETED'
|
|
|
|
|
&& id(row.appointmentId) === appointmentId && Boolean(row.createdAt))
|
|
|
|
|
context.assert(MODULE, '订舱已全部离仓时拖车自动完成且不逆向提交', currentResponse, verified,
|
|
|
|
|
`appointmentId=${appointmentId}, status=${current?.status || 'missing'}`, OPTIONS)
|
|
|
|
|
if (verified) context.createdResources.serviceCenterFlow = {
|
|
|
|
|
marker: context.marker, appointmentId, oceanBookingId: ocean.id, finalStatus: 'COMPLETED',
|
|
|
|
|
completionMode: 'AUTO_COMPLETE_AFTER_DEPARTURE',
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-02 13:26:24 +08:00
|
|
|
const dispatchUpload = await uploadDispatchOrder(context, appointmentId)
|
|
|
|
|
const dispatchAttachment = context.apiSuccess(MODULE, '跨服务拖车上传有效派车单附件', dispatchUpload, '', OPTIONS)
|
|
|
|
|
if (!dispatchAttachment?.id) return
|
2026-08-10 12:23:43 +08:00
|
|
|
let version = Number(appointment.version)
|
|
|
|
|
for (const [name, endpoint, status] of [
|
|
|
|
|
['提交跨链路预约', 'submit', 'PENDING_DISPATCH'],
|
|
|
|
|
['派发跨链路拖车', 'dispatch', 'DISPATCHED'],
|
|
|
|
|
['跨链路开始提货', 'pickup', 'PICKUP_IN_PROGRESS'],
|
|
|
|
|
['跨链路拖车到仓', 'arrive', 'ARRIVED_WAREHOUSE'],
|
|
|
|
|
['跨链路开始装货', 'load', 'LOADING'],
|
|
|
|
|
]) {
|
|
|
|
|
const action = await actionRequest(context, path, endpoint, version)
|
|
|
|
|
const changed = context.apiSuccess(MODULE, name, action, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, `${name}状态正确`, action, changed?.status === status,
|
|
|
|
|
`status=${changed?.status || 'missing'}, version=${changed?.version ?? 'missing'}`, OPTIONS)
|
|
|
|
|
if (!changed) return
|
|
|
|
|
version = Number(changed.version)
|
2026-08-08 21:34:39 +08:00
|
|
|
}
|
2026-08-08 15:25:46 +08:00
|
|
|
const detail = await context.request('GET', `${TRUCK}/${appointmentId}`)
|
2026-09-14 20:03:22 +08:00
|
|
|
const final = context.apiSuccess(MODULE, '回查跨服务链路离仓前状态', detail, '', OPTIONS)
|
|
|
|
|
context.assert(MODULE, '跨服务链路离仓前状态持久化且关联订舱未漂移', detail,
|
|
|
|
|
final?.status === 'LOADING' && id(final?.oceanBookingId) === id(ocean.id)
|
2026-09-04 15:19:45 +08:00
|
|
|
&& !Object.hasOwn(final || {}, 'containerBookingId')
|
|
|
|
|
&& !Object.hasOwn(final || {}, 'loadingPlanId'),
|
2026-08-08 21:34:39 +08:00
|
|
|
`status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS)
|
2026-08-08 20:36:23 +08:00
|
|
|
|
|
|
|
|
const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`)
|
|
|
|
|
const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS)
|
2026-08-10 12:23:43 +08:00
|
|
|
const actions = Array.isArray(history) ? history.filter((item) => item?.action !== 'CREATE') : []
|
2026-09-14 20:03:22 +08:00
|
|
|
const expectedActions = ['SUBMIT', 'DISPATCH', 'PICKUP', 'ARRIVE', 'LOAD']
|
2026-08-10 12:23:43 +08:00
|
|
|
context.assert(MODULE, '跨服务链路历史包含完整状态链、操作者、时间和备注', historyResponse,
|
|
|
|
|
expectedActions.every((action) => actions.some((item) => item.action === action
|
|
|
|
|
&& id(item.appointmentId) === appointmentId && Boolean(item.operatorId)
|
|
|
|
|
&& Boolean(item.operatorName) && Boolean(item.createdAt) && item.reason === context.marker)),
|
|
|
|
|
`history=${history?.length ?? 'n/a'}, actions=${actions.map((item) => item.action).join(',')}`,
|
2026-08-08 20:36:23 +08:00
|
|
|
OPTIONS)
|
2026-09-14 20:03:22 +08:00
|
|
|
if (final?.status === 'LOADING') {
|
2026-08-17 15:41:42 +08:00
|
|
|
context.createdResources.serviceCenterFlow = {
|
|
|
|
|
appointmentId, oceanBookingId: ocean.id,
|
2026-09-02 13:26:24 +08:00
|
|
|
containerBookingId: null,
|
|
|
|
|
loadingPlanId: null,
|
2026-09-14 20:03:22 +08:00
|
|
|
marker: context.marker, finalStatus: 'LOADING',
|
2026-08-17 15:41:42 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-08 15:25:46 +08:00
|
|
|
}
|