This commit is contained in:
2026-09-30 17:13:11 +08:00
parent babc7de4b6
commit 32fb9ec7b3
26 changed files with 1484 additions and 70 deletions
@@ -6,6 +6,81 @@ 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) }