346 lines
23 KiB
JavaScript
346 lines
23 KiB
JavaScript
import { portScheduleFieldGaps } from './fulfillment-list-field-contract.mjs'
|
|
|
|
const MODULE = 'phase3-port-schedules'
|
|
const OPTIONS = { scenario: 'port-schedule-source-lifecycle', deploymentBlocker: true }
|
|
const BASE = '/api/platform/v1/port-schedules'
|
|
const OCEAN = '/api/platform/v1/ocean-bookings'
|
|
const OUTBOUND = '/api/platform/v1/first-leg/outbound-dispatches'
|
|
import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs'
|
|
import { createHash } from 'node:crypto'
|
|
|
|
export function customsRetentionDigest(value) {
|
|
const canonical = input => Array.isArray(input) ? input.map(canonical)
|
|
: input && typeof input === 'object' ? Object.fromEntries(Object.keys(input).sort().map(key => [key, canonical(input[key])]))
|
|
: input
|
|
return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
|
|
}
|
|
|
|
export async function verifyTaxCustomsAfterShipEdit(context, fixture) {
|
|
if (fixture?.marker !== context.marker || !fixture?.loadingPlanId || !fixture?.queryId || !fixture?.scheduleId) {
|
|
throw new Error('requires current marker owned tax customs, query and schedule fixture')
|
|
}
|
|
const customsPath = `/api/platform/v1/customs/loading-plans/${fixture.loadingPlanId}`
|
|
const queryPath = `/api/platform/v1/carrier-schedule-queries/${fixture.queryId}`
|
|
async function read(path, name) {
|
|
const response = await request(context, 'GET', path)
|
|
const value = context.apiSuccess(MODULE, name, response, '', OPTIONS)
|
|
if (!value) throw new Error(`${name} failed`)
|
|
return value
|
|
}
|
|
const beforeCustoms = await read(customsPath, '改船前冻结同一退税柜完整资料回执确认事实')
|
|
const completedTax = beforeCustoms.status === 'COMPLETED' && Number(beforeCustoms.taxRefundCount) > 0
|
|
&& id(beforeCustoms.loadingPlanId) === id(fixture.loadingPlanId)
|
|
&& id(beforeCustoms.containerBookingId) === id(fixture.containerBookingId)
|
|
&& beforeCustoms.documents?.length > 0
|
|
&& beforeCustoms.documents.every(document => document.source?.fileId && document.receipt?.fileId
|
|
&& document.receiptStatus === 'CONFIRMED' && document.finalDocument?.fileId)
|
|
context.assert(MODULE, '改船样本为本轮已完成且含资料回执确认完成文件的退税柜', null,
|
|
completedTax, `loadingPlanId=${fixture.loadingPlanId}, taxRefundCount=${beforeCustoms.taxRefundCount}`, OPTIONS)
|
|
if (!completedTax) throw new Error('requires completed tax customs evidence')
|
|
const beforeQuery = await read(queryPath, '改船前冻结原柜唯一船任务与正常节点')
|
|
const beforeSchedule = await read(`${BASE}/${fixture.scheduleId}`, '改船前读取原柜船期')
|
|
const schedule = beforeSchedule.portSchedule?.schedule
|
|
const beforeTask = beforeQuery.query?.query
|
|
if (!schedule || id(beforeTask?.id) !== id(fixture.queryId) || id(schedule.containerBookingId) !== id(fixture.containerBookingId)
|
|
|| id(beforeTask?.containerBookingId) !== id(fixture.containerBookingId)) throw new Error('ship fixture identity mismatch')
|
|
const editedData = { ...Object.fromEntries(['customerId', 'oceanBookingId', 'containerBookingId',
|
|
'sourceType', 'sourceName', 'sourceRef', 'departurePortCode', 'departurePortName',
|
|
'destinationPortCode', 'destinationPortName', 'etdAt', 'etaAt', 'bookingCutoffAt',
|
|
'customsCutoffAt', 'portCutoffAt', 'observedAt', 'expiresAt', 'remark']
|
|
.map(key => [key, schedule[key]])), vesselName: `人工改船-${context.marker}`.slice(0, 128),
|
|
voyageNo: `R-${context.marker}`.slice(0, 64) }
|
|
const response = await request(context, 'PUT', `${BASE}/${fixture.scheduleId}`,
|
|
{ expectedVersion: Number(schedule.version), data: editedData, reason: `${context.marker} 原柜人工改船` },
|
|
{ 'Idempotency-Key': `${context.marker}-tax-ship-edit`.slice(0, 128) })
|
|
const edited = context.apiSuccess(MODULE, '退税报关完成后在原柜人工改船', response, '', OPTIONS)
|
|
if (!edited) throw new Error('ship edit failed')
|
|
const afterCustoms = await read(customsPath, '改船后读取同一退税柜完整资料回执确认事实')
|
|
const afterQuery = await read(queryPath, '改船后回查原唯一船任务与正常节点')
|
|
const afterSchedule = await read(`${BASE}/${fixture.scheduleId}`, '改船后读取修改历史')
|
|
const selector = new URLSearchParams({ containerBookingId: fixture.containerBookingId, page: '1', size: '200' })
|
|
const page = await read(`/api/platform/v1/carrier-schedule-queries?${selector}`, '改船后按原柜核对船任务唯一')
|
|
const customsBeforeDigest = customsRetentionDigest(beforeCustoms)
|
|
const customsAfterDigest = customsRetentionDigest(afterCustoms)
|
|
context.assert(MODULE, '原柜改船前后报关资料回执确认完成文件完整摘要零漂移', null,
|
|
customsBeforeDigest === customsAfterDigest, `before=${customsBeforeDigest}, after=${customsAfterDigest}`, OPTIONS)
|
|
const sameTask = ['id', 'containerBookingId', 'oceanBookingId', 'trackingNo', 'latestActualNode', 'latestActualAt']
|
|
.every(key => id(beforeTask[key]) === id(afterQuery.query?.query?.[key]))
|
|
context.assert(MODULE, '原柜改船保留唯一查询任务和全部已记录正常节点', null,
|
|
sameTask && customsRetentionDigest(beforeQuery.nodes) === customsRetentionDigest(afterQuery.nodes)
|
|
&& Number(page.total) === 1 && id(page.records?.[0]?.query?.id) === id(fixture.queryId),
|
|
`queryId=${fixture.queryId}, total=${page.total}`, OPTIONS)
|
|
const latest = afterSchedule.portSchedule?.schedule
|
|
const update = afterSchedule.histories?.at(-1)
|
|
context.assert(MODULE, '人工改船保留原柜订舱及操作人原因版本历史', null,
|
|
id(latest?.id) === id(schedule.id) && id(latest?.containerBookingId) === id(schedule.containerBookingId)
|
|
&& id(latest?.oceanBookingId) === id(schedule.oceanBookingId)
|
|
&& latest?.vesselName === editedData.vesselName && latest?.voyageNo === editedData.voyageNo
|
|
&& update?.action === 'UPDATE' && Boolean(update.operatorId && update.createdAt)
|
|
&& Number(update.toVersion) === Number(schedule.version) + 1,
|
|
`scheduleId=${fixture.scheduleId}, historyId=${update?.id || 'missing'}`, OPTIONS)
|
|
context.createdResources.taxCustomsShipRetention = { fixture, beforeCustoms, afterCustoms,
|
|
customsBeforeDigest, customsAfterDigest, beforeQuery, afterQuery, beforeSchedule, afterSchedule }
|
|
}
|
|
|
|
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 }
|
|
const SHANGHAI_TIME = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
})
|
|
function localIso(value) {
|
|
const parts = Object.fromEntries(SHANGHAI_TIME.formatToParts(value)
|
|
.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]))
|
|
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`
|
|
}
|
|
function at(offsetMinutes) { return localIso(new Date(Date.now() + offsetMinutes * 60_000)) }
|
|
function step(context, name, response, passed, evidence) {
|
|
context.addStep(MODULE, name, response, passed, evidence, { ...OPTIONS, deploymentBlocker: !passed })
|
|
}
|
|
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 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 }
|
|
}
|
|
}
|
|
|
|
function oceanDraft(context, suffix, master) {
|
|
return {
|
|
...oceanBookingDraft(context, suffix, master),
|
|
departurePortCode: 'CNSZX',
|
|
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
|
placeOfDelivery: 'LAX',
|
|
remark: context.marker,
|
|
parties: [
|
|
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
|
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
|
|
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` },
|
|
],
|
|
}
|
|
}
|
|
|
|
async function ensureOceanBooking(context, suffix, master, preferredOceanBookingId = null) {
|
|
const existingId = id(preferredOceanBookingId || context.createdResources?.oceanBooking?.id)
|
|
if (existingId) {
|
|
const response = await request(context, 'GET', `${OCEAN}/${existingId}`)
|
|
const detail = context.apiSuccess(MODULE, '读取船期关联订舱真实柜次分配', response,
|
|
`oceanBookingId=${existingId}`, OPTIONS)
|
|
return detail?.oceanBooking ? {
|
|
id: existingId,
|
|
customerId: id(detail.oceanBooking.customerId),
|
|
allocatedContainerBookingIds: (detail.containers || [])
|
|
.map((item) => id(item?.containerBookingId)).filter(Boolean),
|
|
} : null
|
|
}
|
|
const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) },
|
|
{ 'Idempotency-Key': `${context.marker}-port-ocean`.slice(0, 128) })
|
|
const detail = context.apiSuccess(MODULE, '创建本轮隔离船期关联订舱', response, '', OPTIONS)
|
|
const oceanBookingId = id(detail?.oceanBooking?.id)
|
|
context.assert(MODULE, '船期关联订舱客户归属明确', response,
|
|
Boolean(oceanBookingId) && id(detail?.oceanBooking?.customerId) === id(master.customerId),
|
|
`oceanBookingId=${oceanBookingId || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
|
|
return oceanBookingId ? {
|
|
id: oceanBookingId, customerId: id(detail.oceanBooking.customerId),
|
|
allocatedContainerBookingIds: (detail.containers || [])
|
|
.map((item) => id(item?.containerBookingId)).filter(Boolean),
|
|
} : null
|
|
}
|
|
|
|
function scheduleData(context, suffix, oceanBookingId, containerBookingId, customerId, expired = false) {
|
|
return {
|
|
customerId, oceanBookingId, containerBookingId: containerBookingId || null,
|
|
sourceType: 'CARRIER', sourceName: `验收船司-${suffix}`, sourceRef: `${context.marker}-SOURCE-${expired ? 'EXPIRED' : 'CURRENT'}`,
|
|
vesselName: `港务船-${suffix}`, voyageNo: `PV${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳',
|
|
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', etdAt: at(10 * 24 * 60), etaAt: at(30 * 24 * 60),
|
|
bookingCutoffAt: at(7 * 24 * 60), customsCutoffAt: at(8 * 24 * 60), portCutoffAt: at(9 * 24 * 60),
|
|
observedAt: expired ? at(-120) : at(-1), expiresAt: expired ? at(-60) : at(60), remark: context.marker,
|
|
}
|
|
}
|
|
|
|
export async function run(context) {
|
|
// 官网船期查询任务、频率限制和 FAILED/EXPIRED 终态由
|
|
// phase3-carrier-schedule-queries 模块通过真实 HTTP 契约验收;此处只保留
|
|
// 仍受第三方交互能力限制的 ZIM/COSCO 外部缺口,避免重复报告已实现能力。
|
|
context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM 官网 hCaptcha 自动查询',
|
|
'ZIM hCaptcha 需要第三方交互能力,仓库未公开 provider/task 契约;保留为外部依赖缺口',
|
|
'external-third-party')
|
|
context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO 动态页面自动查询',
|
|
'COSCO 动态页面没有已公开 provider/task 契约或稳定机器接口;保留为外部依赖缺口',
|
|
'external-third-party')
|
|
const suffix = context.marker.replace(/\D/g, '').slice(-10)
|
|
const probe = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`)
|
|
if (probe.http === 404 || probe.http === 405 || probe.http === 503 || probe.http === 0) {
|
|
step(context, '港务船期真实平台契约已部署', probe, false,
|
|
`入口 ${BASE} 不可用:HTTP=${probe.http}, code=${codeOf(probe)}`)
|
|
return
|
|
}
|
|
const page = context.apiSuccess(MODULE, '港务船期分页入口可用', probe, '', OPTIONS)
|
|
context.assert(MODULE, '港务船期分页结构稳定', probe,
|
|
Array.isArray(page?.records) && Number.isFinite(Number(page?.total)),
|
|
`records=${page?.records?.length ?? 'n/a'}, total=${page?.total ?? 'n/a'}`, OPTIONS)
|
|
|
|
const master = await resolveOceanBookingMaster(context, MODULE)
|
|
if (!master) return
|
|
const flow = context.createdResources?.phase3Loading
|
|
|| context.createdResources?.phase3ContainerLoading
|
|
const ocean = await ensureOceanBooking(context, suffix, master, flow?.oceanBookingId)
|
|
if (!ocean?.id) return
|
|
const containerBookingId = id(flow?.containerBookingId) || null
|
|
const knownAllocation = containerBookingId
|
|
&& ocean.allocatedContainerBookingIds?.includes(containerBookingId) ? containerBookingId : null
|
|
if (containerBookingId && !knownAllocation) {
|
|
context.addStep(MODULE, '船期订舱真实分配包含本轮装货柜次', null, false,
|
|
`oceanBookingId=${ocean.id}, containerBookingId=${containerBookingId}, allocations=${ocean.allocatedContainerBookingIds?.join(',') || 'none'}`,
|
|
OPTIONS)
|
|
return
|
|
}
|
|
const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, ocean.customerId), reason: context.marker }
|
|
const createKey = `${context.marker}-port-create`.slice(0, 128)
|
|
const createResponse = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey })
|
|
const created = context.apiSuccess(MODULE, '新增独立港务船期观测', createResponse, '', OPTIONS)
|
|
const scheduleId = id(created?.portSchedule?.schedule?.id)
|
|
const scheduleFieldGaps = portScheduleFieldGaps(created)
|
|
context.assert(MODULE, '船期来源、时效、截单截关截港和关联字段完整', createResponse,
|
|
Boolean(scheduleId) && created?.portSchedule?.expired === false
|
|
&& created?.portSchedule?.schedule?.sourceType === 'CARRIER'
|
|
&& id(created?.portSchedule?.schedule?.oceanBookingId) === ocean.id
|
|
&& Boolean(created?.portSchedule?.schedule?.observedAt)
|
|
&& Boolean(created?.portSchedule?.schedule?.updatedAt)
|
|
&& Boolean(created?.portSchedule?.schedule?.expiresAt)
|
|
&& Boolean(created?.portSchedule?.schedule?.bookingCutoffAt)
|
|
&& Boolean(created?.portSchedule?.schedule?.customsCutoffAt)
|
|
&& Boolean(created?.portSchedule?.schedule?.portCutoffAt),
|
|
`scheduleId=${scheduleId || 'missing'}, expired=${created?.portSchedule?.expired ?? 'missing'}`, OPTIONS)
|
|
context.assert(MODULE, '船期列表来源、船名航次、港口与关键时间字段格式完整', createResponse,
|
|
scheduleFieldGaps.length === 0, `missing=${scheduleFieldGaps.join(',') || 'none'}`, OPTIONS)
|
|
if (!scheduleId) return
|
|
|
|
const replay = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey })
|
|
context.assert(MODULE, '新增船期 Idempotency-Key 重放返回同一资源', replay,
|
|
id(data(replay)?.portSchedule?.schedule?.id) === scheduleId,
|
|
`first=${scheduleId}, replay=${id(data(replay)?.portSchedule?.schedule?.id) || 'missing'}`, OPTIONS)
|
|
|
|
for (const [name, query] of [
|
|
['按客户查询船期', `customerId=${encodeURIComponent(ocean.customerId)}`],
|
|
['按订舱查询船期', `oceanBookingId=${ocean.id}`],
|
|
...(knownAllocation ? [['按柜次查询船期', `containerBookingId=${knownAllocation}`]] : []),
|
|
]) {
|
|
const response = await request(context, 'GET', `${BASE}?page=1&size=20&${query}`)
|
|
const records = data(response)?.records
|
|
step(context, name, response, response.http === 200 && codeOf(response) === '200'
|
|
&& Array.isArray(records) && records.some((item) => id(item?.schedule?.id) === scheduleId),
|
|
`records=${records?.length ?? 'n/a'}, scheduleId=${scheduleId}`)
|
|
}
|
|
|
|
const initialVersion = Number(created.portSchedule.schedule.version)
|
|
const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, true)
|
|
expiredData.sourceRef = payload.data.sourceRef
|
|
// 甩柜只修改原柜的船信息;柜次、订舱关联和船期任务身份保持不变。
|
|
expiredData.vesselName = `甩柜改配船-${suffix}`
|
|
expiredData.voyageNo = `R${suffix}`
|
|
const updateResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
|
{ expectedVersion: initialVersion, data: expiredData, reason: '来源刷新并过期' },
|
|
{ 'Idempotency-Key': `${context.marker}-port-update`.slice(0, 128) })
|
|
const updated = context.apiSuccess(MODULE, '更新船期并计算过期状态', updateResponse, '', OPTIONS)
|
|
context.assert(MODULE, '过期标识由expiresAt服务端计算且版本递增', updateResponse,
|
|
updated?.portSchedule?.expired === true
|
|
&& Number(updated?.portSchedule?.schedule?.version) === initialVersion + 1,
|
|
`expired=${updated?.portSchedule?.expired ?? 'missing'}, version=${updated?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS)
|
|
context.assert(MODULE, '甩柜改船只更新原柜船信息且不改变柜次订舱关联', updateResponse,
|
|
id(updated?.portSchedule?.schedule?.id) === scheduleId
|
|
&& id(updated?.portSchedule?.schedule?.oceanBookingId) === ocean.id
|
|
&& id(updated?.portSchedule?.schedule?.containerBookingId) === (knownAllocation || '')
|
|
&& updated?.portSchedule?.schedule?.vesselName === expiredData.vesselName
|
|
&& updated?.portSchedule?.schedule?.voyageNo === expiredData.voyageNo,
|
|
`schedule=${id(updated?.portSchedule?.schedule?.id)}, ocean=${id(updated?.portSchedule?.schedule?.oceanBookingId)}, container=${id(updated?.portSchedule?.schedule?.containerBookingId)}, vessel=${updated?.portSchedule?.schedule?.vesselName || 'missing'}, voyage=${updated?.portSchedule?.schedule?.voyageNo || 'missing'}`, OPTIONS)
|
|
|
|
const stale = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
|
{ expectedVersion: initialVersion, data: expiredData, reason: '旧版本覆盖' },
|
|
{ 'Idempotency-Key': `${context.marker}-port-stale`.slice(0, 128) })
|
|
step(context, '旧版本更新船期返回乐观锁冲突', stale,
|
|
stale.http === 200 && ['A0409', '409'].includes(codeOf(stale)),
|
|
`HTTP=${stale.http}, code=${codeOf(stale)}`)
|
|
|
|
const expiredQuery = await request(context, 'GET', `${BASE}?page=1&size=20&expired=true&oceanBookingId=${ocean.id}`)
|
|
const expiredRecords = data(expiredQuery)?.records
|
|
step(context, '按过期标识查询船期', expiredQuery,
|
|
expiredQuery.http === 200 && codeOf(expiredQuery) === '200' && Array.isArray(expiredRecords)
|
|
&& expiredRecords.some((item) => id(item?.schedule?.id) === scheduleId && item?.expired === true),
|
|
`records=${expiredRecords?.length ?? 'n/a'}`)
|
|
|
|
const detailResponse = await request(context, 'GET', `${BASE}/${scheduleId}`)
|
|
const detail = context.apiSuccess(MODULE, '回查港务船期详情与历史', detailResponse, '', OPTIONS)
|
|
context.assert(MODULE, '船期详情保留来源更新时间和CREATE/UPDATE历史', detailResponse,
|
|
detail?.portSchedule?.expired === true && detail?.histories?.length >= 2
|
|
&& detail.histories.some((item) => item.action === 'CREATE')
|
|
&& detail.histories.some((item) => item.action === 'UPDATE')
|
|
&& detail.histories.every((item) => item.operatorId && item.createdAt),
|
|
`histories=${detail?.histories?.length ?? 'n/a'}`, OPTIONS)
|
|
const historyResponse = await request(context, 'GET', `${BASE}/${scheduleId}/history`)
|
|
const histories = context.apiSuccess(MODULE, '独立查询船期不可变历史', historyResponse, '', OPTIONS)
|
|
context.assert(MODULE, '船期历史端点顺序和版本字段稳定', historyResponse,
|
|
Array.isArray(histories) && histories.length >= 2
|
|
&& histories[0]?.action === 'CREATE' && histories.at(-1)?.toVersion === initialVersion + 1,
|
|
`histories=${histories?.length ?? 'n/a'}`, OPTIONS)
|
|
|
|
const inboundId = id(flow?.inboundId)
|
|
if (knownAllocation && inboundId) {
|
|
const expiredGateResponse = await request(context, 'GET', `${OUTBOUND}/${encodeURIComponent(containerBookingId)}/gate`)
|
|
const expiredGate = context.apiSuccess(MODULE, '过期船期接入国内出仓统一门禁', expiredGateResponse, '', OPTIONS)
|
|
const gateItems = (expiredGate?.inbounds || []).flatMap((item) => item?.gate?.items || [])
|
|
context.assert(MODULE, '船期独立于当前国内离仓六项门禁', expiredGateResponse,
|
|
Array.isArray(expiredGate?.inbounds) && !gateItems.some((item) => item?.code === 'SAILING_SCHEDULE'),
|
|
`inbounds=${expiredGate?.inbounds?.length ?? 'missing'}, obsoleteScheduleGate=${gateItems.some((item) => item?.code === 'SAILING_SCHEDULE')}`, OPTIONS)
|
|
}
|
|
|
|
const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, false)
|
|
currentData.sourceRef = payload.data.sourceRef
|
|
currentData.sourceType = 'MANUAL'
|
|
currentData.sourceName = `平台人工兜底-${suffix}`
|
|
const refreshResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
|
{ expectedVersion: initialVersion + 1, data: currentData, reason: '刷新为当前有效船期' },
|
|
{ 'Idempotency-Key': `${context.marker}-port-refresh`.slice(0, 128) })
|
|
const refreshed = context.apiSuccess(MODULE, '刷新船期为国内出仓可用终态', refreshResponse, '', OPTIONS)
|
|
context.assert(MODULE, '刷新后船期有效且版本再次递增', refreshResponse,
|
|
refreshed?.portSchedule?.expired === false
|
|
&& refreshed?.portSchedule?.schedule?.sourceType === 'MANUAL'
|
|
&& refreshed?.portSchedule?.schedule?.sourceName === currentData.sourceName
|
|
&& Number(refreshed?.portSchedule?.schedule?.version) === initialVersion + 2,
|
|
`expired=${refreshed?.portSchedule?.expired ?? 'missing'}, sourceType=${refreshed?.portSchedule?.schedule?.sourceType || 'missing'}, version=${refreshed?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS)
|
|
|
|
if (knownAllocation && inboundId) {
|
|
const readyGateResponse = await request(context, 'GET', `${OUTBOUND}/${encodeURIComponent(containerBookingId)}/gate`)
|
|
const readyGate = context.apiSuccess(MODULE, '有效船期重新检查国内出仓统一门禁', readyGateResponse, '', OPTIONS)
|
|
const gateItems = (readyGate?.inbounds || []).flatMap((item) => item?.gate?.items || [])
|
|
context.assert(MODULE, '有效船期不改变当前国内离仓六项门禁集合', readyGateResponse,
|
|
Array.isArray(readyGate?.inbounds) && !gateItems.some((item) => item?.code === 'SAILING_SCHEDULE'),
|
|
`inbounds=${readyGate?.inbounds?.length ?? 'missing'}, obsoleteScheduleGate=${gateItems.some((item) => item?.code === 'SAILING_SCHEDULE')}`, OPTIONS)
|
|
}
|
|
|
|
const forged = await request(context, 'POST', BASE, {
|
|
expectedVersion: 0,
|
|
data: { ...scheduleData(context, `${suffix}X`, ocean.id, null, ocean.customerId), customerId: `${ocean.customerId}999` },
|
|
reason: '伪造客户归属',
|
|
}, { 'Idempotency-Key': `${context.marker}-port-forged-customer`.slice(0, 128) })
|
|
step(context, '伪造船期客户归属被拒绝', forged,
|
|
forged.http === 200 && ['A0400', '400'].includes(codeOf(forged)),
|
|
`HTTP=${forged.http}, code=${codeOf(forged)}`)
|
|
|
|
context.createdResources.portSchedule = {
|
|
id: scheduleId, oceanBookingId: ocean.id, containerBookingId: knownAllocation,
|
|
inboundId: inboundId || null, expired: false, gateReady: Boolean(knownAllocation && inboundId),
|
|
}
|
|
}
|