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-09-28 09:49:53 +08:00
|
|
|
|
export interface MerchantCustomsReceiptVersion {
|
|
|
|
|
|
version: number
|
|
|
|
|
|
file: MerchantCustomsFile
|
|
|
|
|
|
operatorName: string | null
|
|
|
|
|
|
createdAt: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
2026-09-26 13:28:33 +08:00
|
|
|
|
receipt: MerchantCustomsFile | null
|
2026-09-24 16:29:58 +08:00
|
|
|
|
receiptStatus: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null
|
|
|
|
|
|
receiptRejectReason: string | null
|
2026-09-26 13:28:33 +08:00
|
|
|
|
finalDocument: MerchantCustomsFile | null
|
2026-09-28 09:49:53 +08:00
|
|
|
|
receiptHistory?: MerchantCustomsReceiptVersion[]
|
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
|
2026-09-26 22:55:32 +08:00
|
|
|
|
brokerSubmittedAt?: string | null
|
2026-08-28 16:54:16 +08:00
|
|
|
|
completedAt?: string | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 20:41:38 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 16:54:16 +08:00
|
|
|
|
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-09-27 20:41:38 +08:00
|
|
|
|
export function getMerchantClearanceResults(orderId: string) {
|
|
|
|
|
|
return apiRequest<MerchantClearanceResult[]>({
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
url: `/merchant/v1/customs/orders/${encodedId(orderId)}/clearance-results`,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-26 13:28:33 +08:00
|
|
|
|
export function downloadMerchantCustomsReceipt(document: MerchantCustomsDocument) {
|
2026-08-28 16:54:16 +08:00
|
|
|
|
return download(
|
2026-09-26 13:28:33 +08:00
|
|
|
|
`/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}`,
|
2026-08-28 16:54:16 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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}`) },
|
2026-09-26 13:28:33 +08:00
|
|
|
|
data: { expectedVersion: document.receipt?.version ?? 0, confirm: true, remark: null },
|
2026-09-24 16:29:58 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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}`) },
|
2026-09-26 13:28:33 +08:00
|
|
|
|
data: { expectedVersion: document.receipt?.version ?? 0, confirm: false, remark: reason },
|
2026-09-24 16:29:58 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
}
|