This commit is contained in:
2026-08-28 16:54:16 +08:00
parent 22f0528593
commit f28b7f178b
7 changed files with 679 additions and 60 deletions
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./customs.ts', import.meta.url), 'utf8')
const httpTypes = readFileSync(new URL('../../../api/types.ts', import.meta.url), 'utf8')
const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url), 'utf8')
test('maps only the frozen merchant container-customs contract', () => {
assert.match(source, /type MerchantCustomsStatus = 'WAITING_UPLOAD' \| 'DOCUMENT_UPLOADED' \| 'DECLARING' \| 'CUSTOMS_COMPLETED'/)
assert.match(source, /inboundId: string/)
assert.match(source, /orderId: string/)
assert.match(source, /documentId: string/)
assert.match(source, /loadingPlanId: string/)
assert.match(source, /url: `\/merchant\/v1\/customs\/orders\/\$\{encodedId\(orderId\)\}`/)
assert.doesNotMatch(source, /customs\/cases|MerchantCustomsCaseDetail|getMerchantCustomsByOrder/)
})
test('sends multi-inbound scope, optimistic versions and idempotency for every write', () => {
assert.match(source, /params\.append\('inboundIds', id\)/)
assert.match(source, /expectedVersion: document\.source\.version/)
assert.match(source, /params: \{ expectedVersion: document\.source\.version \}/)
assert.equal(source.match(/'Idempotency-Key'/g)?.length, 4)
assert.match(source, /data\.append\('file', file\)/)
assert.match(source, /file\.size > MAX_CUSTOMS_FILE_SIZE/)
})
test('preserves request and trace identifiers on API errors', () => {
assert.match(httpTypes, /readonly requestId\?: string \| null/)
assert.match(httpSource, /envelope\.traceId, envelope\.requestId/)
assert.match(source, /interface ApiErrorMeta/)
assert.match(source, /requestId: error\.requestId \?\? null/)
assert.match(source, /traceId: error\.traceId \?\? null/)
})
+198 -19
View File
@@ -1,31 +1,210 @@
import { ApiError, apiRequest } from '@/api/http'
import { ApiError, apiBlobResponseRequest, apiRequest } from '@/api/http'
import { createIdempotencyKey } from '@/modules/first-leg/api/firstLeg'
export interface MerchantCustomsReceipt {
id: number | string
receiptType: 'RECEIVED' | 'DECLARED' | 'REJECTED' | 'INSPECTION' | 'RELEASED' | 'OTHER' | string
receiptVersion: number | null
fileName: string | null
uploadedAt: string | null
export type MerchantCustomsStatus = 'WAITING_UPLOAD' | 'DOCUMENT_UPLOADED' | 'DECLARING' | 'CUSTOMS_COMPLETED'
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
export interface MerchantCustomsFile {
fileId: string
fileName: string
fileHash: string
contentType: string | null
sizeBytes: number
version: number
uploadedAt: string
}
export interface MerchantCustomsCaseDetail {
customsCase: {
id: number | string
customsNo: string | null
orderId: number | string
status: string | null
updatedAt: string | null
}
receipts: MerchantCustomsReceipt[]
export interface MerchantCustomsInbound {
inboundId: string
orderId: string
orderNo: string
inboundCode: string
tenantId: string
customerName: string
taxMode: 'TAX_REFUND' | 'BUY_DECLARATION'
covered: boolean
}
export function getMerchantCustomsByOrder(orderId: number | string) {
return apiRequest<MerchantCustomsCaseDetail>({
export interface MerchantCustomsDocument {
documentId: string
tenantId: string
source: MerchantCustomsFile
inboundIds: string[]
inboundCodes: string[]
frozen: boolean
completion: MerchantCustomsFile | null
}
export interface MerchantCustomsContainer {
aggregateId: string
loadingPlanId: string
containerBookingId: string
planNo?: string
cabinetNo?: string
containerNo: string | null
status: ContainerCustomsStatus
merchantStatus?: MerchantCustomsStatus | null
version: number
taxRefundCount: number
coveredTaxRefundCount: number
inbounds: MerchantCustomsInbound[]
documents: MerchantCustomsDocument[]
startedAt?: string | null
completedAt?: string | null
}
export interface ApiErrorMeta {
code: string
requestId: string | null
traceId: string | null
}
export interface MerchantCustomsDownload {
blob: Blob
fileName: string
}
export const MERCHANT_CUSTOMS_STATUS_LABELS: Record<MerchantCustomsStatus, string> = {
WAITING_UPLOAD: '待上传资料',
DOCUMENT_UPLOADED: '资料已上传',
DECLARING: '报关中',
CUSTOMS_COMPLETED: '报关完成',
}
const MAX_CUSTOMS_FILE_SIZE = 50 * 1024 * 1024
function encodedId(value: number | string) {
return encodeURIComponent(String(value))
}
function inboundQuery(inboundIds: Array<number | string>) {
const params = new URLSearchParams()
inboundIds.map(String).forEach((id) => params.append('inboundIds', id))
return params.toString()
}
function fileBody(file: File) {
const data = new FormData()
data.append('file', file)
return data
}
export function validateMerchantCustomsFile(file: File) {
if (file.size > MAX_CUSTOMS_FILE_SIZE) throw new Error('单个文件不能超过 50MB')
}
export function getMerchantCustomsForOrder(orderId: number | string) {
return apiRequest<MerchantCustomsContainer>({
method: 'GET',
url: `/merchant/v1/customs/orders/${orderId}`,
url: `/merchant/v1/customs/orders/${encodedId(orderId)}`,
})
}
export function uploadMerchantCustomsDocument(planId: number | string, inboundIds: Array<number | string>, file: File) {
validateMerchantCustomsFile(file)
return apiRequest<MerchantCustomsDocument>({
method: 'POST',
url: `/merchant/v1/customs/loading-plans/${encodedId(planId)}/documents/upload?${inboundQuery(inboundIds)}`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-upload-${planId}`) },
data: fileBody(file),
timeout: 120_000,
})
}
export function replaceMerchantCustomsDocument(document: MerchantCustomsDocument, inboundIds: Array<number | string>, file: File) {
validateMerchantCustomsFile(file)
const params = new URLSearchParams(inboundQuery(inboundIds))
params.set('expectedVersion', String(document.source.version))
return apiRequest<MerchantCustomsDocument>({
method: 'POST',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/upload?${params.toString()}`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-replace-${document.documentId}`) },
data: fileBody(file),
timeout: 120_000,
})
}
export function updateMerchantCustomsDocumentInbounds(document: MerchantCustomsDocument, inboundIds: Array<number | string>) {
return apiRequest<MerchantCustomsDocument>({
method: 'PUT',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/inbounds`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-scope-${document.documentId}`) },
data: { inboundIds: inboundIds.map(String), expectedVersion: document.source.version },
})
}
export function deleteMerchantCustomsDocument(document: MerchantCustomsDocument) {
return apiRequest<MerchantCustomsContainer>({
method: 'DELETE',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-delete-${document.documentId}`) },
params: { expectedVersion: document.source.version },
})
}
function contentDispositionFileName(value: unknown, fallback: string) {
if (typeof value !== 'string') return fallback
const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
if (encoded) {
try { return decodeURIComponent(encoded) } catch { return fallback }
}
return value.match(/filename="?([^";]+)"?/i)?.[1] || fallback
}
async function requireBinaryDownload(blob: Blob) {
if (!blob.type.toLowerCase().includes('json')) return
try {
const body = JSON.parse(await blob.text()) as { code?: unknown; message?: unknown; requestId?: unknown; traceId?: unknown }
if (typeof body.message === 'string') {
throw new ApiError(
body.message,
typeof body.code === 'string' ? body.code : 'DOWNLOAD_FAILED',
undefined,
undefined,
typeof body.traceId === 'string' ? body.traceId : null,
typeof body.requestId === 'string' ? body.requestId : null,
)
}
} catch (error) {
if (error instanceof ApiError) throw error
}
}
async function download(url: string, fallbackName: string): Promise<MerchantCustomsDownload> {
const response = await apiBlobResponseRequest({ method: 'GET', url })
await requireBinaryDownload(response.blob)
return {
blob: response.blob,
fileName: contentDispositionFileName(response.headers['content-disposition'], fallbackName),
}
}
export function downloadMerchantCustomsSource(document: MerchantCustomsDocument) {
return download(`/merchant/v1/customs/documents/${encodedId(document.documentId)}/download`, document.source.fileName)
}
export function downloadMerchantCustomsCompletion(document: MerchantCustomsDocument) {
return download(
`/merchant/v1/customs/documents/${encodedId(document.documentId)}/completion/download`,
document.completion?.fileName || `customs-completion-${document.documentId}`,
)
}
export function merchantCustomsErrorMeta(error: unknown): ApiErrorMeta | null {
if (!(error instanceof ApiError)) return null
return { code: error.code, requestId: error.requestId ?? null, traceId: error.traceId ?? null }
}
export function merchantCustomsErrorMessage(error: unknown, fallback: string) {
if (!(error instanceof ApiError)) return error instanceof Error ? error.message : fallback
const identifiers = [
error.code ? `错误码 ${error.code}` : '',
error.requestId ? `请求号 ${error.requestId}` : '',
error.traceId ? `追踪号 ${error.traceId}` : '',
].filter(Boolean)
return `${error.message || fallback}${identifiers.length ? `(${identifiers.join(',')})` : ''}`
}
export function isMerchantCustomsUnavailable(error: unknown) {
return error instanceof ApiError && (error.status === 404 || error.code === 'A0404')
}