2026-08-28 16:54:16 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, nextTick, ref, watch } from 'vue'
|
|
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
|
import { Download, FileText, LockKeyhole, Pencil, Trash2, Upload } from '@lucide/vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
MERCHANT_CUSTOMS_STATUS_LABELS,
|
|
|
|
|
|
deleteMerchantCustomsDocument,
|
|
|
|
|
|
downloadMerchantCustomsCompletion,
|
|
|
|
|
|
downloadMerchantCustomsSource,
|
|
|
|
|
|
getMerchantCustomsForOrder,
|
|
|
|
|
|
merchantCustomsErrorMessage,
|
|
|
|
|
|
replaceMerchantCustomsDocument,
|
|
|
|
|
|
updateMerchantCustomsDocumentInbounds,
|
|
|
|
|
|
uploadMerchantCustomsDocument,
|
|
|
|
|
|
validateMerchantCustomsFile,
|
|
|
|
|
|
type MerchantCustomsContainer,
|
|
|
|
|
|
type MerchantCustomsDocument,
|
|
|
|
|
|
} 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('')
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadDetail() {
|
|
|
|
|
|
if (!props.orderId) return
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
errorMessage.value = ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await getMerchantCustomsForOrder(props.orderId)
|
|
|
|
|
|
detail.value = result
|
|
|
|
|
|
initializeSelection(result)
|
|
|
|
|
|
emit('updated', result)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
detail.value = null
|
|
|
|
|
|
errorMessage.value = merchantCustomsErrorMessage(error, '退税报关资料加载失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(() => props.modelValue, (open) => {
|
|
|
|
|
|
if (!open) return
|
|
|
|
|
|
detail.value = props.initialDetail ?? null
|
|
|
|
|
|
if (detail.value) initializeSelection(detail.value)
|
|
|
|
|
|
void loadDetail()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
function close() {
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'completion') {
|
|
|
|
|
|
const key = `${document.documentId}:${kind}`
|
|
|
|
|
|
downloadingKey.value = key
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = kind === 'source'
|
|
|
|
|
|
? await downloadMerchantCustomsSource(document)
|
|
|
|
|
|
: await downloadMerchantCustomsCompletion(document)
|
|
|
|
|
|
saveDownload(result.blob, result.fileName)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ElMessage.error(merchantCustomsErrorMessage(error, '文件下载失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (downloadingKey.value === key) downloadingKey.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 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`
|
|
|
|
|
|
}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<strong>{{ detail.containerNo || detail.cabinetNo || detail.planNo || detail.loadingPlanId }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span>资料覆盖</span>
|
|
|
|
|
|
<strong>{{ detail.coveredTaxRefundCount }} / {{ detail.taxRefundCount }} 票</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-tag :type="statusTagType" effect="light">{{ merchantStatusLabel }}</el-tag>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="locked" class="customs-locked" role="status">
|
|
|
|
|
|
<LockKeyhole :size="18" />
|
|
|
|
|
|
<div><strong>报关资料已锁定</strong><span>报关开始后仅可查看和下载,不提供退回或解锁。</span></div>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<small>关联入仓码:{{ item.inboundCodes.join('、') || '-' }}</small>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<el-button v-if="detail.status === 'COMPLETED' && item.completion" link type="success" :loading="downloadingKey === `${item.documentId}:completion`" @click="downloadFile(item, 'completion')"><Download :size="15" />完成文件</el-button>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
.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; }
|
|
|
|
|
|
.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>
|