2026-08-28 16:54:16 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-10-02 12:21:04 +08:00
|
|
|
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
2026-08-28 16:54:16 +08:00
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
|
import { Download, FileText, LockKeyhole, Pencil, Trash2, Upload } from '@lucide/vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
MERCHANT_CUSTOMS_STATUS_LABELS,
|
|
|
|
|
|
deleteMerchantCustomsDocument,
|
2026-09-24 16:29:58 +08:00
|
|
|
|
confirmMerchantCustomsReceipt,
|
2026-09-26 13:28:33 +08:00
|
|
|
|
downloadMerchantCustomsReceipt,
|
|
|
|
|
|
downloadMerchantCustomsFinal,
|
2026-08-28 16:54:16 +08:00
|
|
|
|
downloadMerchantCustomsSource,
|
|
|
|
|
|
getMerchantCustomsForOrder,
|
2026-09-27 20:41:38 +08:00
|
|
|
|
getMerchantClearanceResults,
|
2026-08-28 16:54:16 +08:00
|
|
|
|
merchantCustomsErrorMessage,
|
|
|
|
|
|
replaceMerchantCustomsDocument,
|
2026-09-24 16:29:58 +08:00
|
|
|
|
rejectMerchantCustomsReceipt,
|
2026-08-28 16:54:16 +08:00
|
|
|
|
updateMerchantCustomsDocumentInbounds,
|
|
|
|
|
|
uploadMerchantCustomsDocument,
|
|
|
|
|
|
validateMerchantCustomsFile,
|
|
|
|
|
|
type MerchantCustomsContainer,
|
|
|
|
|
|
type MerchantCustomsDocument,
|
2026-09-27 20:41:38 +08:00
|
|
|
|
type MerchantClearanceResult,
|
2026-08-28 16:54:16 +08:00
|
|
|
|
} from '@/modules/first-leg/api/customs'
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
modelValue: boolean
|
|
|
|
|
|
orderId: string
|
|
|
|
|
|
canEdit: boolean
|
|
|
|
|
|
initialDetail?: MerchantCustomsContainer | null
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
'update:modelValue': [value: boolean]
|
|
|
|
|
|
updated: [value: MerchantCustomsContainer]
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const detail = ref<MerchantCustomsContainer | null>(null)
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const errorMessage = ref('')
|
|
|
|
|
|
const selectedInboundIds = ref<string[]>([])
|
|
|
|
|
|
const editingDocument = ref<MerchantCustomsDocument | null>(null)
|
|
|
|
|
|
const editingInboundIds = ref<string[]>([])
|
|
|
|
|
|
const replacingDocument = ref<MerchantCustomsDocument | null>(null)
|
|
|
|
|
|
const uploadInput = ref<HTMLInputElement>()
|
|
|
|
|
|
const replaceInput = ref<HTMLInputElement>()
|
|
|
|
|
|
const mutating = ref(false)
|
|
|
|
|
|
const downloadingKey = ref('')
|
2026-09-27 20:41:38 +08:00
|
|
|
|
const clearanceResults = ref<MerchantClearanceResult[]>([])
|
2026-10-03 02:35:28 +08:00
|
|
|
|
const clearanceError = ref('')
|
|
|
|
|
|
const clearanceLoading = ref(false)
|
|
|
|
|
|
let clearanceRequest = 0
|
|
|
|
|
|
let disposed = false
|
2026-10-02 12:21:04 +08:00
|
|
|
|
let detailRequest = 0
|
2026-10-03 02:35:28 +08:00
|
|
|
|
onBeforeUnmount(() => { disposed = true; detailRequest += 1; resetClearanceResults() })
|
2026-08-28 16:54:16 +08:00
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
const merchantStatusLabel = computed(() => detail.value?.merchantStatus
|
|
|
|
|
|
? MERCHANT_CUSTOMS_STATUS_LABELS[detail.value.merchantStatus]
|
|
|
|
|
|
: '待上传资料')
|
|
|
|
|
|
const statusTagType = computed(() => ({
|
|
|
|
|
|
WAITING_UPLOAD: 'warning',
|
|
|
|
|
|
DOCUMENT_UPLOADED: 'success',
|
|
|
|
|
|
DECLARING: 'primary',
|
|
|
|
|
|
CUSTOMS_COMPLETED: 'success',
|
|
|
|
|
|
} as const)[detail.value?.merchantStatus ?? 'WAITING_UPLOAD'])
|
|
|
|
|
|
|
|
|
|
|
|
function initializeSelection(value: MerchantCustomsContainer) {
|
|
|
|
|
|
const currentOrderIds = value.inbounds
|
|
|
|
|
|
.filter((item) => item.taxMode === 'TAX_REFUND' && item.orderId === props.orderId)
|
|
|
|
|
|
.map((item) => item.inboundId)
|
|
|
|
|
|
selectedInboundIds.value = currentOrderIds
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 02:35:28 +08:00
|
|
|
|
function resetClearanceResults() {
|
|
|
|
|
|
clearanceRequest += 1
|
|
|
|
|
|
clearanceResults.value = []
|
|
|
|
|
|
clearanceError.value = ''
|
|
|
|
|
|
clearanceLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadClearanceResults() {
|
|
|
|
|
|
if (disposed || !props.modelValue || !props.orderId || !detail.value || clearanceLoading.value) return
|
|
|
|
|
|
const request = ++clearanceRequest
|
|
|
|
|
|
const parentRequest = detailRequest
|
|
|
|
|
|
const orderId = props.orderId
|
|
|
|
|
|
const isCurrent = () => !disposed && request === clearanceRequest && parentRequest === detailRequest && props.modelValue && props.orderId === orderId
|
|
|
|
|
|
clearanceLoading.value = true
|
|
|
|
|
|
clearanceError.value = ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
const results = await getMerchantClearanceResults(orderId)
|
|
|
|
|
|
if (isCurrent()) clearanceResults.value = results
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (isCurrent()) clearanceError.value = merchantCustomsErrorMessage(error, '海关申报结果加载失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (isCurrent()) clearanceLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function retryClearanceResults() {
|
|
|
|
|
|
if (loading.value) return
|
|
|
|
|
|
await loadClearanceResults()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 16:54:16 +08:00
|
|
|
|
async function loadDetail() {
|
2026-10-03 02:35:28 +08:00
|
|
|
|
if (disposed || !props.modelValue || !props.orderId) return
|
2026-10-02 12:21:04 +08:00
|
|
|
|
const request = ++detailRequest
|
2026-10-03 02:35:28 +08:00
|
|
|
|
resetClearanceResults()
|
2026-10-02 12:21:04 +08:00
|
|
|
|
const orderId = props.orderId
|
|
|
|
|
|
const isCurrent = () => request === detailRequest && props.modelValue && props.orderId === orderId
|
2026-08-28 16:54:16 +08:00
|
|
|
|
loading.value = true
|
|
|
|
|
|
errorMessage.value = ''
|
|
|
|
|
|
try {
|
2026-10-02 12:21:04 +08:00
|
|
|
|
const result = await getMerchantCustomsForOrder(orderId)
|
|
|
|
|
|
if (!isCurrent()) return
|
2026-08-28 16:54:16 +08:00
|
|
|
|
detail.value = result
|
|
|
|
|
|
initializeSelection(result)
|
2026-10-03 02:35:28 +08:00
|
|
|
|
await loadClearanceResults()
|
2026-10-02 12:21:04 +08:00
|
|
|
|
if (!isCurrent()) return
|
2026-08-28 16:54:16 +08:00
|
|
|
|
emit('updated', result)
|
|
|
|
|
|
} catch (error) {
|
2026-10-02 12:21:04 +08:00
|
|
|
|
if (!isCurrent()) return
|
2026-08-28 16:54:16 +08:00
|
|
|
|
detail.value = null
|
|
|
|
|
|
errorMessage.value = merchantCustomsErrorMessage(error, '退税报关资料加载失败')
|
|
|
|
|
|
} finally {
|
2026-10-02 12:21:04 +08:00
|
|
|
|
if (isCurrent()) loading.value = false
|
2026-08-28 16:54:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 12:21:04 +08:00
|
|
|
|
watch(() => [props.modelValue, props.orderId] as const, ([open]) => {
|
|
|
|
|
|
detailRequest += 1
|
|
|
|
|
|
detail.value = null
|
2026-10-03 02:35:28 +08:00
|
|
|
|
resetClearanceResults()
|
2026-10-02 12:21:04 +08:00
|
|
|
|
selectedInboundIds.value = []
|
|
|
|
|
|
editingDocument.value = null
|
|
|
|
|
|
replacingDocument.value = null
|
|
|
|
|
|
errorMessage.value = ''
|
|
|
|
|
|
loading.value = false
|
2026-08-28 16:54:16 +08:00
|
|
|
|
if (!open) return
|
2026-10-02 12:21:04 +08:00
|
|
|
|
const initial = props.initialDetail
|
|
|
|
|
|
detail.value = initial?.inbounds.some((item) => item.orderId === props.orderId) ? initial : null
|
2026-08-28 16:54:16 +08:00
|
|
|
|
if (detail.value) initializeSelection(detail.value)
|
|
|
|
|
|
void loadDetail()
|
2026-10-02 12:21:04 +08:00
|
|
|
|
}, { immediate: true })
|
2026-08-28 16:54:16 +08:00
|
|
|
|
|
|
|
|
|
|
function close() {
|
2026-10-03 02:35:28 +08:00
|
|
|
|
detailRequest += 1
|
|
|
|
|
|
resetClearanceResults()
|
2026-08-28 16:54:16 +08:00
|
|
|
|
emit('update:modelValue', false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function chooseUpload() {
|
|
|
|
|
|
if (!selectedInboundIds.value.length) {
|
|
|
|
|
|
ElMessage.warning('请先选择至少一个退税入仓码')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
uploadInput.value?.click()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleUploadFile(event: Event) {
|
|
|
|
|
|
const input = event.target as HTMLInputElement
|
|
|
|
|
|
const file = input.files?.[0]
|
|
|
|
|
|
input.value = ''
|
|
|
|
|
|
if (!file || !detail.value) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
validateMerchantCustomsFile(file)
|
|
|
|
|
|
mutating.value = true
|
|
|
|
|
|
await uploadMerchantCustomsDocument(detail.value.loadingPlanId, selectedInboundIds.value, file)
|
|
|
|
|
|
ElMessage.success('资料上传成功')
|
|
|
|
|
|
await loadDetail()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ElMessage.error(merchantCustomsErrorMessage(error, '资料上传失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
mutating.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function beginEditInbounds(document: MerchantCustomsDocument) {
|
|
|
|
|
|
editingDocument.value = document
|
|
|
|
|
|
editingInboundIds.value = [...document.inboundIds]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveInbounds() {
|
|
|
|
|
|
if (!editingDocument.value || !editingInboundIds.value.length) {
|
|
|
|
|
|
ElMessage.warning('请至少保留一个关联入仓码')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
mutating.value = true
|
|
|
|
|
|
await updateMerchantCustomsDocumentInbounds(editingDocument.value, editingInboundIds.value)
|
|
|
|
|
|
editingDocument.value = null
|
|
|
|
|
|
ElMessage.success('关联入仓码已更新')
|
|
|
|
|
|
await loadDetail()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ElMessage.error(merchantCustomsErrorMessage(error, '关联入仓码更新失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
mutating.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function chooseReplacement(document: MerchantCustomsDocument) {
|
|
|
|
|
|
replacingDocument.value = document
|
|
|
|
|
|
editingInboundIds.value = [...document.inboundIds]
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
replaceInput.value?.click()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleReplacementFile(event: Event) {
|
|
|
|
|
|
const input = event.target as HTMLInputElement
|
|
|
|
|
|
const file = input.files?.[0]
|
|
|
|
|
|
input.value = ''
|
|
|
|
|
|
const document = replacingDocument.value
|
|
|
|
|
|
replacingDocument.value = null
|
|
|
|
|
|
if (!file || !document) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
validateMerchantCustomsFile(file)
|
|
|
|
|
|
mutating.value = true
|
|
|
|
|
|
await replaceMerchantCustomsDocument(document, editingInboundIds.value, file)
|
|
|
|
|
|
ElMessage.success('资料已覆盖并生成新版本')
|
|
|
|
|
|
await loadDetail()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ElMessage.error(merchantCustomsErrorMessage(error, '资料覆盖失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
mutating.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeDocument(document: MerchantCustomsDocument) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await ElMessageBox.confirm(`确认删除误传资料“${document.source.fileName}”吗?历史版本仍会由系统保留。`, '删除资料', {
|
|
|
|
|
|
type: 'warning',
|
|
|
|
|
|
confirmButtonText: '确认删除',
|
|
|
|
|
|
cancelButtonText: '取消',
|
|
|
|
|
|
})
|
|
|
|
|
|
mutating.value = true
|
|
|
|
|
|
await deleteMerchantCustomsDocument(document)
|
|
|
|
|
|
ElMessage.success('资料已删除')
|
|
|
|
|
|
await loadDetail()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (error === 'cancel' || error === 'close') return
|
|
|
|
|
|
ElMessage.error(merchantCustomsErrorMessage(error, '资料删除失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
mutating.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function saveDownload(blob: Blob, fileName: string) {
|
|
|
|
|
|
const url = URL.createObjectURL(blob)
|
|
|
|
|
|
const anchor = document.createElement('a')
|
|
|
|
|
|
anchor.href = url
|
|
|
|
|
|
anchor.download = fileName
|
|
|
|
|
|
anchor.click()
|
|
|
|
|
|
URL.revokeObjectURL(url)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-26 13:28:33 +08:00
|
|
|
|
async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'receipt' | 'final') {
|
2026-08-28 16:54:16 +08:00
|
|
|
|
const key = `${document.documentId}:${kind}`
|
|
|
|
|
|
downloadingKey.value = key
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = kind === 'source'
|
|
|
|
|
|
? await downloadMerchantCustomsSource(document)
|
2026-09-26 13:28:33 +08:00
|
|
|
|
: kind === 'receipt' ? await downloadMerchantCustomsReceipt(document) : await downloadMerchantCustomsFinal(document)
|
2026-08-28 16:54:16 +08:00
|
|
|
|
saveDownload(result.blob, result.fileName)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ElMessage.error(merchantCustomsErrorMessage(error, '文件下载失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (downloadingKey.value === key) downloadingKey.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 16:29:58 +08:00
|
|
|
|
async function confirmReceipt(document: MerchantCustomsDocument) {
|
|
|
|
|
|
try {
|
2026-09-26 13:28:33 +08:00
|
|
|
|
await ElMessageBox.confirm('确认已核对整份报关行回执吗?确认后平台会把确认结果发送给报关行执行。', '确认报关行回执', { type: 'warning' })
|
2026-09-24 16:29:58 +08:00
|
|
|
|
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 }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 16:54:16 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 09:49:53 +08:00
|
|
|
|
function receiptHistoryLabel(document: MerchantCustomsDocument) {
|
|
|
|
|
|
return (document.receiptHistory || []).map((item) => `V${item.version}`).join('、') || '—'
|
|
|
|
|
|
}
|
2026-09-28 13:21:59 +08:00
|
|
|
|
function confirmationActionLabel(action: MerchantCustomsDocument['confirmationHistory'][number]['action']) {
|
|
|
|
|
|
return action === 'CONFIRM' ? '确认' : '打回'
|
|
|
|
|
|
}
|
2026-09-28 09:49:53 +08:00
|
|
|
|
|
2026-08-28 16:54:16 +08:00
|
|
|
|
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`
|
|
|
|
|
|
}
|
2026-09-27 20:41:38 +08:00
|
|
|
|
|
|
|
|
|
|
function clearanceResultLabel(value: MerchantClearanceResult) {
|
|
|
|
|
|
return ({ INSPECTION: '查验', SUPPLEMENT_REQUIRED: '补充资料', RELEASED: '放行', DETAINED: '扣留', OTHER: '其他' } as Record<string, string>)[value.resultType] || value.resultType
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearanceResultStatusLabel(value: MerchantClearanceResult) {
|
|
|
|
|
|
return value.resultStatus === 'RESOLVED' ? '已处理' : value.resultStatus === 'CANCELLED' ? '已取消' : '待处理'
|
|
|
|
|
|
}
|
2026-08-28 16:54:16 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<el-dialog
|
|
|
|
|
|
:model-value="modelValue"
|
|
|
|
|
|
class="customs-documents-dialog"
|
|
|
|
|
|
title="退税报关资料"
|
|
|
|
|
|
width="920px"
|
|
|
|
|
|
append-to-body
|
|
|
|
|
|
destroy-on-close
|
|
|
|
|
|
@close="close"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-skeleton v-if="loading && !detail" :rows="6" animated />
|
|
|
|
|
|
<div v-else-if="errorMessage" class="customs-error" role="alert">
|
|
|
|
|
|
<strong>退税报关资料加载失败</strong>
|
|
|
|
|
|
<span>{{ errorMessage }}</span>
|
|
|
|
|
|
<el-button :loading="loading" @click="loadDetail">重新加载</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<template v-else-if="detail">
|
|
|
|
|
|
<header class="customs-summary">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span>柜次</span>
|
2026-08-30 17:35:52 +08:00
|
|
|
|
<strong>{{ detail.containerNo || detail.cabinetNo || detail.planNo || '—' }}</strong>
|
2026-08-28 16:54:16 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span>资料覆盖</span>
|
|
|
|
|
|
<strong>{{ detail.coveredTaxRefundCount }} / {{ detail.taxRefundCount }} 票</strong>
|
|
|
|
|
|
</div>
|
2026-09-24 16:29:58 +08:00
|
|
|
|
<div>
|
|
|
|
|
|
<span>实际箱数</span>
|
|
|
|
|
|
<strong>{{ detail.actualBoxCount }}</strong>
|
|
|
|
|
|
</div>
|
2026-08-28 16:54:16 +08:00
|
|
|
|
<el-tag :type="statusTagType" effect="light">{{ merchantStatusLabel }}</el-tag>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="locked" class="customs-locked" role="status">
|
|
|
|
|
|
<LockKeyhole :size="18" />
|
2026-09-28 17:01:36 +01:00
|
|
|
|
<div v-if="detail.status === 'COMPLETED'"><strong>报关已完成</strong><span>真实报关完税文件已齐全,可查看和下载资料、回执及完税文件。</span></div>
|
|
|
|
|
|
<div v-else><strong>{{ detail.brokerSubmittedAt ? '已发送报关行执行' : '报关资料已锁定' }}</strong><span>{{ detail.brokerSubmittedAt ? '平台已将客户确认后的回执发送给报关行,等待真实报关完税文件。' : '客户原始资料已冻结,仍可确认或打回待确认的报关行回执。' }}</span></div>
|
2026-08-28 16:54:16 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="customs-section">
|
|
|
|
|
|
<div class="customs-section-title">
|
|
|
|
|
|
<div><h3>上传资料</h3><p>支持任意格式,单个文件不超过 50MB;可关联同柜内当前企业的一票或多票退税入仓码。</p></div>
|
|
|
|
|
|
<el-button v-if="canEdit && !locked" type="primary" :loading="mutating" @click="chooseUpload"><Upload :size="16" />选择文件上传</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-checkbox-group v-model="selectedInboundIds" class="customs-inbound-options" :disabled="!canEdit || locked" aria-label="选择资料关联入仓码">
|
|
|
|
|
|
<el-checkbox v-for="item in taxRefundInbounds" :key="item.inboundId" :value="item.inboundId">
|
|
|
|
|
|
<span class="customs-inbound-code">{{ item.inboundCode }}</span>
|
2026-08-29 11:01:15 +08:00
|
|
|
|
<!-- <small>{{ item.orderNo }}</small> -->
|
2026-08-28 16:54:16 +08:00
|
|
|
|
</el-checkbox>
|
|
|
|
|
|
</el-checkbox-group>
|
|
|
|
|
|
<input ref="uploadInput" class="customs-hidden-input" type="file" @change="handleUploadFile">
|
|
|
|
|
|
<input ref="replaceInput" class="customs-hidden-input" type="file" @change="handleReplacementFile">
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-09-27 20:41:38 +08:00
|
|
|
|
<section class="customs-section clearance-results-section">
|
|
|
|
|
|
<div class="customs-section-title">
|
|
|
|
|
|
<div><h3>海关申报结果</h3><p>平台完成正式申报后,客户可查看与本订单相关的查验、补充资料和放行结果。</p></div>
|
|
|
|
|
|
</div>
|
2026-10-03 02:35:28 +08:00
|
|
|
|
<div v-if="clearanceError" class="customs-error" role="alert">
|
|
|
|
|
|
<strong>海关申报结果加载失败</strong>
|
|
|
|
|
|
<span>{{ clearanceError }}</span>
|
|
|
|
|
|
<el-button :loading="clearanceLoading" @click="retryClearanceResults">重新加载</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-skeleton v-else-if="clearanceLoading" :rows="2" animated />
|
|
|
|
|
|
<el-empty v-else-if="!clearanceResults.length" description="暂无海关结果" :image-size="48" />
|
2026-09-27 20:41:38 +08:00
|
|
|
|
<div v-else class="clearance-result-list">
|
|
|
|
|
|
<div v-for="item in clearanceResults" :key="item.id" class="clearance-result-row">
|
|
|
|
|
|
<div><strong>{{ clearanceResultLabel(item) }}</strong><span>{{ formatDateTime(item.occurredAt) }} · 报关单号 {{ item.customsEntryNo || '—' }}</span></div>
|
|
|
|
|
|
<el-tag size="small" :type="item.resultStatus === 'RESOLVED' ? 'success' : 'warning'">{{ clearanceResultStatusLabel(item) }}</el-tag>
|
|
|
|
|
|
<p v-if="item.resolution">{{ item.resolution }}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-08-28 16:54:16 +08:00
|
|
|
|
<section class="customs-section">
|
|
|
|
|
|
<div class="customs-section-title">
|
|
|
|
|
|
<div><h3>资料版本历史</h3><p>每份当前资料展示服务端版本、关联票和上传时间;覆盖及删除操作由服务端保留不可变历史。</p></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-empty v-if="!detail.documents.length" description="尚未上传退税报关资料" :image-size="54" />
|
|
|
|
|
|
<div v-else class="customs-document-list">
|
|
|
|
|
|
<article v-for="item in detail.documents" :key="item.documentId" class="customs-document-card">
|
|
|
|
|
|
<div class="customs-document-main">
|
|
|
|
|
|
<FileText :size="20" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{{ item.source.fileName }}</strong>
|
|
|
|
|
|
<span>版本 V{{ item.source.version }} · {{ formatSize(item.source.sizeBytes) }} · {{ formatDateTime(item.source.uploadedAt) }}</span>
|
2026-09-28 09:49:53 +08:00
|
|
|
|
<small v-if="item.receiptHistory?.length">报关行回执历史:{{ receiptHistoryLabel(item) }}</small>
|
2026-09-28 13:21:59 +08:00
|
|
|
|
<small v-for="history in item.confirmationHistory" :key="history.id">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small>
|
2026-08-30 17:35:52 +08:00
|
|
|
|
<small>关联入仓码:{{ item.inboundCodes.join('、') || '—' }}</small>
|
2026-08-28 16:54:16 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="customs-document-actions">
|
|
|
|
|
|
<el-button link type="primary" :loading="downloadingKey === `${item.documentId}:source`" @click="downloadFile(item, 'source')"><Download :size="15" />源文件</el-button>
|
2026-09-26 13:28:33 +08:00
|
|
|
|
<el-button v-if="item.receipt" link type="success" :loading="downloadingKey === `${item.documentId}:receipt`" @click="downloadFile(item, 'receipt')"><Download :size="15" />报关行回执</el-button>
|
|
|
|
|
|
<el-button v-if="item.finalDocument" link type="success" :loading="downloadingKey === `${item.documentId}:final`" @click="downloadFile(item, 'final')"><Download :size="15" />报关完税文件</el-button>
|
|
|
|
|
|
<template v-if="item.receipt && item.receiptStatus !== 'CONFIRMED' && detail.status === 'DECLARING'">
|
2026-09-24 16:29:58 +08:00
|
|
|
|
<el-button link type="success" :disabled="mutating" @click="confirmReceipt(item)">确认整份回执</el-button>
|
|
|
|
|
|
<el-button link type="danger" :disabled="mutating" @click="rejectReceipt(item)">打回回执</el-button>
|
|
|
|
|
|
</template>
|
2026-09-26 13:28:33 +08:00
|
|
|
|
<el-tag v-if="item.receipt" size="small" :type="item.receiptStatus === 'CONFIRMED' ? 'success' : item.receiptStatus === 'REJECTED' ? 'danger' : 'warning'">{{ item.receiptStatus === 'CONFIRMED' ? '已确认' : item.receiptStatus === 'REJECTED' ? `已打回:${item.receiptRejectReason || '待补充'}` : '待确认' }}</el-tag>
|
2026-08-28 16:54:16 +08:00
|
|
|
|
<template v-if="canEdit && !locked && !item.frozen">
|
|
|
|
|
|
<el-button link type="primary" :disabled="mutating" @click="beginEditInbounds(item)"><Pencil :size="15" />改关联</el-button>
|
|
|
|
|
|
<el-button link type="primary" :disabled="mutating" @click="chooseReplacement(item)"><Upload :size="15" />覆盖</el-button>
|
|
|
|
|
|
<el-button link type="danger" :disabled="mutating" @click="removeDocument(item)"><Trash2 :size="15" />删除</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="editingDocument?.documentId === item.documentId" class="customs-scope-editor">
|
|
|
|
|
|
<el-checkbox-group v-model="editingInboundIds" aria-label="修改资料关联入仓码">
|
|
|
|
|
|
<el-checkbox v-for="inbound in taxRefundInbounds" :key="inbound.inboundId" :value="inbound.inboundId">{{ inbound.inboundCode }}</el-checkbox>
|
|
|
|
|
|
</el-checkbox-group>
|
|
|
|
|
|
<div><el-button @click="editingDocument = null">取消</el-button><el-button type="primary" :loading="mutating" @click="saveInbounds">保存关联</el-button></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template #footer><el-button @click="close">关闭</el-button></template>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-09-30 16:35:09 +08:00
|
|
|
|
:global(.customs-documents-dialog.el-dialog) { display: flex; max-height: 70vh; flex-direction: column; overflow: hidden; }
|
|
|
|
|
|
:global(.customs-documents-dialog .el-dialog__header), :global(.customs-documents-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
|
|
|
|
|
:global(.customs-documents-dialog .el-dialog__body) { min-height: 0; flex: 1 1 auto; overflow-y: auto; overscroll-behavior: contain; }
|
2026-08-28 16:54:16 +08:00
|
|
|
|
.customs-error { display: grid; gap: 10px; justify-items: start; padding: 22px; border: 1px solid #f1b8b8; background: #fff7f7; color: #9f2d2d; }
|
|
|
|
|
|
.customs-error span { overflow-wrap: anywhere; font-size: 12px; }
|
|
|
|
|
|
.customs-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)) auto; align-items: center; gap: 18px; padding: 16px 18px; border: 1px solid var(--border); background: #f8faf9; }
|
|
|
|
|
|
.customs-summary div { min-width: 0; }
|
|
|
|
|
|
.customs-summary span { display: block; color: var(--muted); font-size: 11px; }
|
|
|
|
|
|
.customs-summary strong { display: block; margin-top: 5px; overflow-wrap: anywhere; color: var(--navy); font-size: 14px; }
|
|
|
|
|
|
.customs-locked { display: flex; align-items: flex-start; gap: 10px; margin-top: 14px; padding: 13px 15px; border: 1px solid #d5dde1; background: #f5f7f8; color: var(--muted); }
|
|
|
|
|
|
.customs-locked strong, .customs-locked span { display: block; }
|
|
|
|
|
|
.customs-locked strong { color: var(--navy); font-size: 12px; }
|
|
|
|
|
|
.customs-locked span { margin-top: 3px; font-size: 11px; }
|
|
|
|
|
|
.customs-section { margin-top: 18px; }
|
2026-09-27 20:41:38 +08:00
|
|
|
|
.clearance-result-list { display:grid; gap:10px; }
|
|
|
|
|
|
.clearance-result-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px 14px; padding:12px 14px; border:1px solid var(--border); background:#fbfcfe; }
|
|
|
|
|
|
.clearance-result-row strong,.clearance-result-row span { display:block; }
|
|
|
|
|
|
.clearance-result-row span { margin-top:4px; color:var(--muted); font-size:11px; }
|
|
|
|
|
|
.clearance-result-row p { grid-column:1 / -1; margin:0; color:var(--navy); font-size:12px; }
|
2026-08-28 16:54:16 +08:00
|
|
|
|
.customs-section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 12px; }
|
|
|
|
|
|
.customs-section-title h3 { margin: 0; color: var(--navy); font-size: 14px; }
|
|
|
|
|
|
.customs-section-title p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
|
|
|
|
|
.customs-inbound-options { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 13px 15px; border: 1px solid var(--border); }
|
|
|
|
|
|
.customs-inbound-options :deep(.el-checkbox) { margin-right: 0; }
|
|
|
|
|
|
.customs-inbound-code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
|
|
|
|
.customs-inbound-options small { margin-left: 7px; color: var(--muted); }
|
|
|
|
|
|
.customs-hidden-input { display: none; }
|
|
|
|
|
|
.customs-document-list { display: grid; gap: 10px; }
|
|
|
|
|
|
.customs-document-card { padding: 14px 16px; border: 1px solid var(--border); }
|
|
|
|
|
|
.customs-document-main { display: flex; align-items: flex-start; gap: 11px; min-width: 0; }
|
|
|
|
|
|
.customs-document-main > svg { flex: 0 0 20px; color: var(--primary); }
|
|
|
|
|
|
.customs-document-main > div { min-width: 0; }
|
|
|
|
|
|
.customs-document-main strong, .customs-document-main span, .customs-document-main small { display: block; overflow-wrap: anywhere; }
|
|
|
|
|
|
.customs-document-main strong { color: var(--navy); font-size: 13px; }
|
|
|
|
|
|
.customs-document-main span, .customs-document-main small { margin-top: 4px; color: var(--muted); font-size: 11px; }
|
|
|
|
|
|
.customs-document-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 2px; margin-top: 8px; }
|
|
|
|
|
|
.customs-scope-editor { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border); }
|
|
|
|
|
|
.customs-scope-editor :deep(.el-checkbox) { margin-right: 16px; }
|
|
|
|
|
|
</style>
|