169 lines
9.5 KiB
JavaScript
169 lines
9.5 KiB
JavaScript
const MODULE = 'phase3-customs'
|
|
const OPTIONS = { scenario: 'phase3-container-customs-producer', deploymentBlocker: true }
|
|
const PLATFORM_CUSTOMS = '/api/platform/v1/customs/loading-plans'
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
|
function key(context, operation) { return `${context.marker}-container-customs-${operation}`.slice(0, 128) }
|
|
|
|
async function request(context, method, path, body, headers = {}) {
|
|
try {
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
method,
|
|
headers: {
|
|
Accept: 'application/json', Authorization: context.token ? `Bearer ${context.token}` : '',
|
|
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json; charset=utf-8' }),
|
|
...headers,
|
|
},
|
|
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
|
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, path, 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, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
|
}
|
|
}
|
|
|
|
async function download(context, path) {
|
|
try {
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
headers: { Accept: '*/*', Authorization: `Bearer ${context.token}` }, signal: AbortSignal.timeout(60_000),
|
|
})
|
|
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
return { method: 'GET', path, http: response.status, bytes,
|
|
contentType: response.headers.get('content-type') || '',
|
|
requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') }
|
|
} catch (error) {
|
|
return { method: 'GET', path, http: 0, bytes: new Uint8Array(), contentType: '', raw: String(error) }
|
|
}
|
|
}
|
|
|
|
export function selectCustomsFlow(createdResources) {
|
|
const fixtures = [
|
|
createdResources?.orders,
|
|
createdResources?.orchestrationFixtures?.inboundMeasurement,
|
|
createdResources?.orchestrationFixtures?.phase3Container,
|
|
].filter(Boolean)
|
|
const containers = [
|
|
createdResources?.phase3Loading,
|
|
createdResources?.phase3ContainerLoading,
|
|
].filter(Boolean)
|
|
for (const container of containers) {
|
|
const fixture = fixtures.find((candidate) => id(candidate?.tenantId) === id(container?.tenantId)
|
|
&& id(candidate?.inboundId) === id(container?.inboundId))
|
|
if (fixture) return { fixture, container }
|
|
}
|
|
return { fixture: null, container: null }
|
|
}
|
|
|
|
async function ensureReleasedOceanBooking(context, container) {
|
|
const containerBookingId = id(container?.containerBookingId)
|
|
const oceanBookingId = id(container?.oceanBookingId)
|
|
if (!oceanBookingId) {
|
|
context.addStep(MODULE, '报关前订舱已放舱并绑定实际装柜柜次', null, false,
|
|
`oceanBookingId=missing, containerBookingId=${containerBookingId || 'missing'}`, OPTIONS)
|
|
return false
|
|
}
|
|
const response = await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`)
|
|
const detail = context.apiSuccess(MODULE, '报关前回读装柜链正式订舱', response, '', OPTIONS)
|
|
const allocated = detail?.containers?.some?.((item) => id(item?.containerBookingId) === containerBookingId)
|
|
const passed = detail?.oceanBooking?.status === 'RELEASED' && allocated
|
|
context.addStep(MODULE, '报关前订舱已放舱并绑定实际装柜柜次', response, passed,
|
|
`oceanStatus=${detail?.oceanBooking?.status || 'missing'}, oceanBookingId=${oceanBookingId}, containerBookingId=${containerBookingId}, allocated=${allocated === true}`, OPTIONS)
|
|
if (passed) context.createdResources.oceanBooking = {
|
|
id: oceanBookingId, marker: context.marker, finalStatus: 'RELEASED', containerBookingId,
|
|
loadingPlanId: id(container?.loadingPlanId),
|
|
}
|
|
return passed
|
|
}
|
|
|
|
async function verifyContainerOperationalDocuments(context, containerBookingId) {
|
|
const overviewResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}/overview`)
|
|
const overview = context.apiSuccess(MODULE, '只读复核前置模块已完成的整柜作业资料', overviewResponse, '', OPTIONS)
|
|
const documents = Array.isArray(overview?.documents) ? overview.documents : []
|
|
const photos = Array.isArray(overview?.pda?.photos) ? overview.pda.photos.filter((item) => item?.status === 'ACTIVE') : []
|
|
const passed = ['DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER']
|
|
.every((type) => documents.some((item) => item?.documentType === type))
|
|
&& documents.some((item) => item?.documentType === 'DESTINATION_CLEARANCE' && item?.completed === true)
|
|
&& photos.length >= 5
|
|
context.assert(MODULE, '整柜聚合回显三类当前作业资料与真实PDA照片证据', overviewResponse, passed,
|
|
`documents=${documents.map((item) => item?.documentType).join('|') || 'none'}, activePhotos=${photos.length}`, OPTIONS)
|
|
for (const [index, photo] of photos.entries()) {
|
|
const response = await download(context, photo.downloadPath)
|
|
context.assert(MODULE, `下载整柜PDA照片证据${index + 1}`, response,
|
|
response.http === 200 && response.bytes.length > 0 && !response.contentType.includes('application/json'),
|
|
`fileId=${id(photo?.fileId) || 'missing'}, bytes=${response.bytes.length}`, OPTIONS)
|
|
}
|
|
return passed
|
|
}
|
|
|
|
async function completeContainerCustoms(context, loadingPlanId) {
|
|
const base = `${PLATFORM_CUSTOMS}/${encodeURIComponent(loadingPlanId)}`
|
|
let response = await request(context, 'GET', base)
|
|
let detail = context.apiSuccess(MODULE, '读取正式整柜报关聚合', response,
|
|
`loadingPlanId=${loadingPlanId}`, OPTIONS)
|
|
if (!detail?.aggregateId) return null
|
|
if (detail.status === 'COMPLETED') return detail
|
|
if (detail.status === 'READY_TO_START') {
|
|
response = await request(context, 'POST', `${base}/start`, {
|
|
expectedVersion: Number(detail.version), confirm: true, remark: context.marker,
|
|
}, { 'Idempotency-Key': key(context, 'start') })
|
|
detail = context.apiSuccess(MODULE, '正式整柜报关开始并冻结资料', response, '', OPTIONS)
|
|
context.assert(MODULE, '正式整柜报关进入DECLARING', response,
|
|
detail?.status === 'DECLARING', `status=${detail?.status || 'missing'}`, OPTIONS)
|
|
}
|
|
const allBuy = Array.isArray(detail?.inbounds) && detail.inbounds.length > 0
|
|
&& detail.inbounds.every((item) => item?.taxMode === 'BUY_DECLARATION')
|
|
context.assert(MODULE, '本轮生产者整柜为全买单正式完成路径', response, allBuy,
|
|
`taxModes=${(detail?.inbounds || []).map((item) => item?.taxMode).join('|') || 'missing'}`, OPTIONS)
|
|
if (!allBuy || detail?.status !== 'DECLARING') return null
|
|
response = await request(context, 'POST', `${base}/complete-all-buy`, {
|
|
expectedVersion: Number(detail.version), confirm: true, remark: context.marker,
|
|
}, { 'Idempotency-Key': key(context, 'complete-all-buy') })
|
|
const completed = context.apiSuccess(MODULE, '正式全买单整柜报关完成', response, '', OPTIONS)
|
|
context.assert(MODULE, '正式整柜报关完成并满足出仓报关门禁', response,
|
|
completed?.status === 'COMPLETED' && Boolean(completed?.completedAt),
|
|
`status=${completed?.status || 'missing'}, completedAt=${completed?.completedAt || 'missing'}`, OPTIONS)
|
|
return completed?.status === 'COMPLETED' ? completed : null
|
|
}
|
|
|
|
export async function run(context) {
|
|
const { fixture, container } = selectCustomsFlow(context.createdResources)
|
|
const tenantId = id(fixture?.tenantId)
|
|
const inboundId = id(fixture?.inboundId)
|
|
const containerBookingId = id(container?.containerBookingId)
|
|
const loadingPlanId = id(container?.loadingPlanId)
|
|
if (!tenantId || !inboundId || !containerBookingId || !loadingPlanId
|
|
|| !['AWAITING_REVIEW', 'LOADED'].includes(container?.finalStatus)
|
|
|| (container?.finalStatus === 'AWAITING_REVIEW'
|
|
&& (container?.loadingPlanStatus !== 'LOADING' || !container?.canonicalPdaTaskId))) {
|
|
context.addStep(MODULE, '取得本轮客户、入仓单与柜次隔离fixture', null, false,
|
|
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`,
|
|
OPTIONS)
|
|
return
|
|
}
|
|
if (!await ensureReleasedOceanBooking(context, container)) return
|
|
if (!await verifyContainerOperationalDocuments(context, containerBookingId)) {
|
|
context.addStep(MODULE, '三类整柜作业资料必须完成后才允许进入出仓确认', null, false,
|
|
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS)
|
|
return
|
|
}
|
|
const customs = await completeContainerCustoms(context, loadingPlanId)
|
|
if (!customs) {
|
|
context.addStep(MODULE, '正式整柜报关聚合必须完成后才允许进入出仓确认', null, false,
|
|
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS)
|
|
return
|
|
}
|
|
context.createdResources.phase3Customs = {
|
|
marker: context.marker, tenantId, orderId: id(fixture?.orderId), inboundId,
|
|
containerBookingId, loadingPlanId, customsContainerId: id(customs.aggregateId),
|
|
finalStatus: 'COMPLETED',
|
|
}
|
|
}
|