Files
client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue
T

422 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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,
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<{
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 clearanceResults = ref<MerchantClearanceResult[]>([])
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)
try { clearanceResults.value = await getMerchantClearanceResults(props.orderId) } catch { clearanceResults.value = [] }
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' | 'receipt' | 'final') {
const key = `${document.documentId}:${kind}`
downloadingKey.value = key
try {
const result = kind === 'source'
? await downloadMerchantCustomsSource(document)
: kind === 'receipt' ? await downloadMerchantCustomsReceipt(document) : await downloadMerchantCustomsFinal(document)
saveDownload(result.blob, result.fileName)
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '文件下载失败'))
} finally {
if (downloadingKey.value === key) downloadingKey.value = ''
}
}
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<string, string>)[value.resultType] || value.resultType
}
function clearanceResultStatusLabel(value: MerchantClearanceResult) {
return value.resultStatus === 'RESOLVED' ? '已处理' : value.resultStatus === 'CANCELLED' ? '已取消' : '待处理'
}
</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 || '—' }}</strong>
</div>
<div>
<span>资料覆盖</span>
<strong>{{ detail.coveredTaxRefundCount }} / {{ detail.taxRefundCount }} 票</strong>
</div>
<div>
<span>实际箱数</span>
<strong>{{ detail.actualBoxCount }}</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 v-if="detail.status === 'COMPLETED'"><strong>报关已完成</strong><span>真实报关完税文件已齐全,可查看和下载资料、回执及完税文件。</span></div>
<div v-else><strong>{{ detail.brokerSubmittedAt ? '已发送报关行执行' : '报关资料已锁定' }}</strong><span>{{ detail.brokerSubmittedAt ? '平台已将客户确认后的回执发送给报关行,等待真实报关完税文件。' : '客户原始资料已冻结,仍可确认或打回待确认的报关行回执。' }}</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>
<!-- <small>{{ item.orderNo }}</small> -->
</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 clearance-results-section">
<div class="customs-section-title">
<div><h3>海关申报结果</h3><p>平台完成正式申报后,客户可查看与本订单相关的查验、补充资料和放行结果。</p></div>
</div>
<el-empty v-if="!clearanceResults.length" description="暂无海关结果" :image-size="48" />
<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>
<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 v-if="item.receiptHistory?.length">报关行回执历史:{{ receiptHistoryLabel(item) }}</small>
<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>
<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="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'">
<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>
<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>
<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; }
.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; }
.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>