优化
This commit is contained in:
@@ -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/)
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user