diff --git a/src/modules/first-leg/api/customs.test.mjs b/src/modules/first-leg/api/customs.test.mjs index 2e780ee..778d96d 100644 --- a/src/modules/first-leg/api/customs.test.mjs +++ b/src/modules/first-leg/api/customs.test.mjs @@ -22,7 +22,7 @@ test('sends multi-inbound scope, optimistic versions and idempotency for every w assert.match(source, /params\.append\('inboundIds', id\)/) assert.match(source, /expectedVersion: document\.source\.version/) assert.match(source, /params: \{ expectedVersion: document\.source\.version \}/) - assert.equal(source.match(/'Idempotency-Key'/g)?.length, 4) + assert.equal(source.match(/'Idempotency-Key'/g)?.length, 6) assert.match(source, /data\.append\('file', file\)/) assert.match(source, /file\.size > MAX_CUSTOMS_FILE_SIZE/) }) diff --git a/src/modules/first-leg/api/customs.ts b/src/modules/first-leg/api/customs.ts index 6e7d15f..6b2d957 100644 --- a/src/modules/first-leg/api/customs.ts +++ b/src/modules/first-leg/api/customs.ts @@ -14,6 +14,24 @@ export interface MerchantCustomsFile { uploadedAt: string } +export interface MerchantCustomsReceiptVersion { + version: number + file: MerchantCustomsFile + operatorName: string | null + createdAt: string +} + +export type MerchantCustomsConfirmationAction = 'CONFIRM' | 'REJECT' + +export interface MerchantCustomsConfirmationHistory { + id: string + receiptVersion: number + action: MerchantCustomsConfirmationAction + reason: string | null + operatorName: string + createdAt: string +} + export interface MerchantCustomsInbound { inboundId: string orderId: string @@ -32,7 +50,12 @@ export interface MerchantCustomsDocument { inboundIds: string[] inboundCodes: string[] frozen: boolean - completion: MerchantCustomsFile | null + receipt: MerchantCustomsFile | null + receiptStatus: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null + receiptRejectReason: string | null + finalDocument: MerchantCustomsFile | null + receiptHistory?: MerchantCustomsReceiptVersion[] + confirmationHistory: MerchantCustomsConfirmationHistory[] } export interface MerchantCustomsContainer { @@ -47,12 +70,32 @@ export interface MerchantCustomsContainer { 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 @@ -100,6 +143,13 @@ export function getMerchantCustomsForOrder(orderId: string) { }) } +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({ @@ -183,13 +233,38 @@ export function downloadMerchantCustomsSource(document: MerchantCustomsDocument) return download(`/merchant/v1/customs/documents/${encodedId(document.documentId)}/download`, document.source.fileName) } -export function downloadMerchantCustomsCompletion(document: MerchantCustomsDocument) { +export function downloadMerchantCustomsReceipt(document: MerchantCustomsDocument) { return download( - `/merchant/v1/customs/documents/${encodedId(document.documentId)}/completion/download`, - document.completion?.fileName || `customs-completion-${document.documentId}`, + `/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 } diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index a65d1f7..1ae92a3 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -441,6 +441,7 @@ export interface MerchantPriceEstimateRequest { destinationWarehouseId?: number | string destinationName?: string pricingTaxMode: ApiPricingTaxMode + chargeMode?: 'KG' | 'CBM' actualWeightKg: number actualVolumeCbm: number } diff --git a/src/modules/first-leg/api/unifiedOrders.ts b/src/modules/first-leg/api/unifiedOrders.ts index 8101851..d1e3d51 100644 --- a/src/modules/first-leg/api/unifiedOrders.ts +++ b/src/modules/first-leg/api/unifiedOrders.ts @@ -48,8 +48,14 @@ export interface MerchantUnifiedTransportSummary { cabinetNo: string | null vesselName: string | null voyageNo: string | null - sailingAt: string | null - portArrivalAt: string | null + estimatedSailingAt: string | null + actualSailingAt: string | null + effectiveSailingAt: string | null + effectiveSailingAtEstimated: boolean | null + estimatedPortArrivalAt: string | null + actualPortArrivalAt: string | null + effectivePortArrivalAt: string | null + effectivePortArrivalAtEstimated: boolean | null } export interface MerchantUnifiedOrderRow { diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue index 0a975dd..606f4e7 100644 --- a/src/modules/first-leg/components/CustomsDocumentsDialog.vue +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue @@ -5,16 +5,21 @@ import { Download, FileText, LockKeyhole, Pencil, Trash2, Upload } from '@lucide import { MERCHANT_CUSTOMS_STATUS_LABELS, deleteMerchantCustomsDocument, - downloadMerchantCustomsCompletion, + confirmMerchantCustomsReceipt, + downloadMerchantCustomsReceipt, + downloadMerchantCustomsFinal, downloadMerchantCustomsSource, getMerchantCustomsForOrder, + getMerchantClearanceResults, merchantCustomsErrorMessage, replaceMerchantCustomsDocument, + rejectMerchantCustomsReceipt, updateMerchantCustomsDocumentInbounds, uploadMerchantCustomsDocument, validateMerchantCustomsFile, type MerchantCustomsContainer, type MerchantCustomsDocument, + type MerchantClearanceResult, } from '@/modules/first-leg/api/customs' const props = defineProps<{ @@ -40,6 +45,7 @@ const uploadInput = ref() const replaceInput = ref() const mutating = ref(false) const downloadingKey = ref('') +const clearanceResults = ref([]) const taxRefundInbounds = computed(() => detail.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND') ?? []) const locked = computed(() => Boolean(detail.value?.startedAt) || detail.value?.status === 'DECLARING' || detail.value?.status === 'COMPLETED') @@ -68,6 +74,7 @@ async function loadDetail() { const result = await getMerchantCustomsForOrder(props.orderId) detail.value = result initializeSelection(result) + try { clearanceResults.value = await getMerchantClearanceResults(props.orderId) } catch { clearanceResults.value = [] } emit('updated', result) } catch (error) { detail.value = null @@ -192,13 +199,13 @@ function saveDownload(blob: Blob, fileName: string) { URL.revokeObjectURL(url) } -async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'completion') { +async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'receipt' | 'final') { const key = `${document.documentId}:${kind}` downloadingKey.value = key try { const result = kind === 'source' ? await downloadMerchantCustomsSource(document) - : await downloadMerchantCustomsCompletion(document) + : kind === 'receipt' ? await downloadMerchantCustomsReceipt(document) : await downloadMerchantCustomsFinal(document) saveDownload(result.blob, result.fileName) } catch (error) { ElMessage.error(merchantCustomsErrorMessage(error, '文件下载失败')) @@ -207,17 +214,58 @@ async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | } } +async function confirmReceipt(document: MerchantCustomsDocument) { + try { + await ElMessageBox.confirm('确认已核对整份报关行回执吗?确认后平台会把确认结果发送给报关行执行。', '确认报关行回执', { type: 'warning' }) + mutating.value = true + await confirmMerchantCustomsReceipt(document) + ElMessage.success('回执确认成功') + await loadDetail() + } catch (error) { + if (error === 'cancel' || error === 'close') return + ElMessage.error(merchantCustomsErrorMessage(error, '回执确认失败')) + } finally { mutating.value = false } +} + +async function rejectReceipt(document: MerchantCustomsDocument) { + try { + const reason = await ElMessageBox.prompt('请填写打回原因', '打回报关行回执', { inputValidator: (value) => value.trim() ? true : '请输入打回原因' }) + mutating.value = true + await rejectMerchantCustomsReceipt(document, reason.value) + ElMessage.success('回执已打回') + await loadDetail() + } catch (error) { + if (error === 'cancel' || error === 'close') return + ElMessage.error(merchantCustomsErrorMessage(error, '回执打回失败')) + } finally { mutating.value = false } +} + function formatDateTime(value: string | null | undefined) { if (!value) return '-' const date = new Date(value) return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) } +function receiptHistoryLabel(document: MerchantCustomsDocument) { + return (document.receiptHistory || []).map((item) => `V${item.version}`).join('、') || '—' +} +function confirmationActionLabel(action: MerchantCustomsDocument['confirmationHistory'][number]['action']) { + return action === 'CONFIRM' ? '确认' : '打回' +} + function formatSize(bytes: number) { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / 1024 / 1024).toFixed(1)} MB` } + +function clearanceResultLabel(value: MerchantClearanceResult) { + return ({ INSPECTION: '查验', SUPPLEMENT_REQUIRED: '补充资料', RELEASED: '放行', DETAINED: '扣留', OTHER: '其他' } as Record)[value.resultType] || value.resultType +} + +function clearanceResultStatusLabel(value: MerchantClearanceResult) { + return value.resultStatus === 'RESOLVED' ? '已处理' : value.resultStatus === 'CANCELLED' ? '已取消' : '待处理' +}