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

232 lines
8.2 KiB
TypeScript
Raw Normal View History

2026-08-28 16:54:16 +08:00
import { ApiError, apiBlobResponseRequest, apiRequest } from '@/api/http'
import { createIdempotencyKey } from '@/modules/first-leg/api/firstLeg'
2026-08-10 12:05:43 +08:00
2026-08-28 16:54:16 +08:00
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
2026-08-10 12:05:43 +08:00
}
2026-08-28 16:54:16 +08:00
export interface MerchantCustomsInbound {
inboundId: string
orderId: string
orderNo: string
inboundCode: string
tenantId: string
customerName: string
taxMode: 'TAX_REFUND' | 'BUY_DECLARATION'
covered: boolean
2026-08-10 12:05:43 +08:00
}
2026-08-28 16:54:16 +08:00
export interface MerchantCustomsDocument {
documentId: string
tenantId: string
source: MerchantCustomsFile
inboundIds: string[]
inboundCodes: string[]
frozen: boolean
completion: MerchantCustomsFile | null
2026-09-24 16:29:58 +08:00
receiptStatus: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null
receiptRejectReason: string | null
2026-08-28 16:54:16 +08:00
}
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
2026-09-24 16:29:58 +08:00
actualBoxCount: number
2026-08-28 16:54:16 +08:00
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
2026-08-28 17:27:12 +08:00
function encodedId(value: string) {
return encodeURIComponent(value)
2026-08-28 16:54:16 +08:00
}
2026-08-28 17:27:12 +08:00
function inboundQuery(inboundIds: string[]) {
2026-08-28 16:54:16 +08:00
const params = new URLSearchParams()
2026-08-28 17:27:12 +08:00
inboundIds.forEach((id) => params.append('inboundIds', id))
2026-08-28 16:54:16 +08:00
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')
}
2026-08-28 17:27:12 +08:00
export function getMerchantCustomsForOrder(orderId: string) {
2026-08-28 16:54:16 +08:00
return apiRequest<MerchantCustomsContainer>({
2026-08-10 12:05:43 +08:00
method: 'GET',
2026-08-28 16:54:16 +08:00
url: `/merchant/v1/customs/orders/${encodedId(orderId)}`,
2026-08-10 12:05:43 +08:00
})
}
2026-08-28 17:27:12 +08:00
export function uploadMerchantCustomsDocument(planId: string, inboundIds: string[], file: File) {
2026-08-28 16:54:16 +08:00
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,
})
}
2026-08-28 17:27:12 +08:00
export function replaceMerchantCustomsDocument(document: MerchantCustomsDocument, inboundIds: string[], file: File) {
2026-08-28 16:54:16 +08:00
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,
})
}
2026-08-28 17:27:12 +08:00
export function updateMerchantCustomsDocumentInbounds(document: MerchantCustomsDocument, inboundIds: string[]) {
2026-08-28 16:54:16 +08:00
return apiRequest<MerchantCustomsDocument>({
method: 'PUT',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/inbounds`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-scope-${document.documentId}`) },
2026-08-28 17:27:12 +08:00
data: { inboundIds, expectedVersion: document.source.version },
2026-08-28 16:54:16 +08:00
})
}
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}`,
)
}
2026-09-24 16:29:58 +08:00
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.completion?.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.completion?.version ?? 0, confirm: false, remark: reason },
})
}
2026-08-28 16:54:16 +08:00
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(',')})` : ''}`
}
2026-08-10 12:05:43 +08:00
export function isMerchantCustomsUnavailable(error: unknown) {
return error instanceof ApiError && (error.status === 404 || error.code === 'A0404')
}