import { ApiError, apiBlobResponseRequest, apiRequest } from '@/api/http' import { createIdempotencyKey } from '@/modules/first-leg/api/firstLeg' 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 MerchantCustomsReceiptVersion { version: number file: MerchantCustomsFile operatorName: string | null createdAt: string } export interface MerchantCustomsInbound { inboundId: string orderId: string orderNo: string inboundCode: string tenantId: string customerName: string taxMode: 'TAX_REFUND' | 'BUY_DECLARATION' covered: boolean } export interface MerchantCustomsDocument { documentId: string tenantId: string source: MerchantCustomsFile inboundIds: string[] inboundCodes: string[] frozen: boolean receipt: MerchantCustomsFile | null receiptStatus: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null receiptRejectReason: string | null finalDocument: MerchantCustomsFile | null receiptHistory?: MerchantCustomsReceiptVersion[] } 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 actualBoxCount: number inbounds: MerchantCustomsInbound[] documents: MerchantCustomsDocument[] startedAt?: string | null brokerSubmittedAt?: string | null completedAt?: string | null } export type MerchantClearanceResultType = 'INSPECTION' | 'SUPPLEMENT_REQUIRED' | 'RELEASED' | 'DETAINED' | 'OTHER' export type MerchantClearanceResultStatus = 'OPEN' | 'RESOLVED' | 'CANCELLED' export interface MerchantClearanceResult { id: string declarationBatchId: string customsContainerId: string resultType: MerchantClearanceResultType resultStatus: MerchantClearanceResultStatus customsEntryNo: string | null occurredAt: string resolution: string | null evidenceFileId: string | null recordedByName: string recordedAt: string previousResultId: 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 = { WAITING_UPLOAD: '待上传资料', DOCUMENT_UPLOADED: '资料已上传', DECLARING: '报关中', CUSTOMS_COMPLETED: '报关完成', } const MAX_CUSTOMS_FILE_SIZE = 50 * 1024 * 1024 function encodedId(value: string) { return encodeURIComponent(value) } function inboundQuery(inboundIds: string[]) { const params = new URLSearchParams() inboundIds.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: string) { return apiRequest({ method: 'GET', url: `/merchant/v1/customs/orders/${encodedId(orderId)}`, }) } export function getMerchantClearanceResults(orderId: string) { return apiRequest({ method: 'GET', url: `/merchant/v1/customs/orders/${encodedId(orderId)}/clearance-results`, }) } export function uploadMerchantCustomsDocument(planId: string, inboundIds: string[], file: File) { validateMerchantCustomsFile(file) return apiRequest({ 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: string[], file: File) { validateMerchantCustomsFile(file) const params = new URLSearchParams(inboundQuery(inboundIds)) params.set('expectedVersion', String(document.source.version)) return apiRequest({ 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: string[]) { return apiRequest({ method: 'PUT', url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/inbounds`, headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-scope-${document.documentId}`) }, data: { inboundIds, expectedVersion: document.source.version }, }) } export function deleteMerchantCustomsDocument(document: MerchantCustomsDocument) { return apiRequest({ 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 { 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 downloadMerchantCustomsReceipt(document: MerchantCustomsDocument) { return download( `/merchant/v1/customs/documents/${encodedId(document.documentId)}/receipt/download`, document.receipt?.fileName || `customs-receipt-${document.documentId}`, ) } export function downloadMerchantCustomsFinal(document: MerchantCustomsDocument) { return download( `/merchant/v1/customs/documents/${encodedId(document.documentId)}/final/download`, document.finalDocument?.fileName || `customs-final-${document.documentId}`, ) } export function confirmMerchantCustomsReceipt(document: MerchantCustomsDocument) { return apiRequest({ method: 'POST', url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/receipt/confirm`, headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-receipt-confirm-${document.documentId}`) }, data: { expectedVersion: document.receipt?.version ?? 0, confirm: true, remark: null }, }) } export function rejectMerchantCustomsReceipt(document: MerchantCustomsDocument, reason: string) { return apiRequest({ method: 'POST', url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/receipt/reject`, headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-receipt-reject-${document.documentId}`) }, data: { expectedVersion: document.receipt?.version ?? 0, confirm: false, remark: reason }, }) } 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') }