Files
client-web/src/modules/first-leg/api/customs.ts
T

274 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
}