274 lines
9.5 KiB
TypeScript
274 lines
9.5 KiB
TypeScript
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<MerchantCustomsStatus, string> = {
|
||
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<MerchantCustomsContainer>({
|
||
method: 'GET',
|
||
url: `/merchant/v1/customs/orders/${encodedId(orderId)}`,
|
||
})
|
||
}
|
||
|
||
export function getMerchantClearanceResults(orderId: string) {
|
||
return apiRequest<MerchantClearanceResult[]>({
|
||
method: 'GET',
|
||
url: `/merchant/v1/customs/orders/${encodedId(orderId)}/clearance-results`,
|
||
})
|
||
}
|
||
|
||
export function uploadMerchantCustomsDocument(planId: string, inboundIds: 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: 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: 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, 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 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<MerchantCustomsContainer>({
|
||
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<MerchantCustomsContainer>({
|
||
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')
|
||
}
|