Files
admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue
T

196 lines
14 KiB
Vue
Raw Normal View History

2026-08-28 16:54:02 +08:00
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Download, FileUp } from '@lucide/vue'
2026-08-28 16:54:02 +08:00
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { createPlatformExportTask } from '@/modules/downloads'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import {
completeAllBuyContainerCustoms,
downloadContainerCustomsCompletion,
downloadContainerCustomsDocument,
2026-08-28 16:54:02 +08:00
getContainerCustoms,
startContainerCustoms,
uploadContainerCustomsCompletion,
type ContainerCustomsDocument,
type ContainerCustomsStatus,
type ContainerCustomsView,
} from '../api/platformContainerCustoms'
const props = defineProps<{
loadingPlanId: string
canList: boolean
canManage: boolean
departureState: 'DEPARTED' | 'WAITING_OTHER_GATES'
}>()
const emit = defineEmits<{ refreshed: [] }>()
const customs = ref<ContainerCustomsView | null>(null)
const loading = ref(false)
const actionLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const downloadingKey = ref('')
2026-08-28 16:54:02 +08:00
const completionFiles = reactive<Record<string, File | null>>({})
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
2026-08-28 16:54:02 +08:00
const missingCompletionCount = computed(() => customs.value?.documents.filter((item) => !item.completion).length || 0)
const statusText: Record<ContainerCustomsStatus, string> = {
2026-09-01 20:41:30 +08:00
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
2026-08-28 16:54:02 +08:00
}
function documentCustomers(document: ContainerCustomsDocument) {
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
}
function selectCompletion(documentId: string, upload: UploadFile) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
2026-08-28 16:54:02 +08:00
completionFiles[documentId] = upload.raw || null
actionError.value = null
}
async function load() {
if (!props.canList || !props.loadingPlanId) return
loading.value = true
loadError.value = null
try { customs.value = await getContainerCustoms(props.loadingPlanId) }
catch (error) { customs.value = null; loadError.value = getApiErrorDetails(error, '整柜报关状态加载失败') }
finally { loading.value = false }
}
async function start() {
if (!customs.value || customs.value.status !== 'READY_TO_START') return
try {
2026-09-01 20:41:30 +08:00
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { type: 'warning', confirmButtonText: '确认开始' })
2026-08-28 16:54:02 +08:00
} catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
catch (error) { actionError.value = getApiErrorDetails(error, '开始整柜报关失败') }
finally { actionLoading.value = false }
}
async function uploadCompletion(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
2026-08-28 16:54:02 +08:00
const selected = completionFiles[document.documentId]
2026-09-01 20:41:30 +08:00
if (!selected) { ElMessage.warning('请选择对应的报关完成文件'); return }
2026-08-28 16:54:02 +08:00
const finalConfirmation = !document.completion && missingCompletionCount.value === 1
if (finalConfirmation) {
try {
2026-09-01 20:41:30 +08:00
await ElMessageBox.confirm('这是最后一份待上传的报关完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { type: 'warning', confirmButtonText: '确认上传并完成' })
2026-08-28 16:54:02 +08:00
} catch { return }
}
actionLoading.value = true; actionError.value = null
try {
await uploadContainerCustomsCompletion(document.documentId, selected, document.completion?.version || 0, finalConfirmation)
completionFiles[document.documentId] = null
await load()
emit('refreshed')
2026-09-01 20:41:30 +08:00
ElMessage.success(finalConfirmation ? '报关完成文件已上传,整柜报关已完成' : '报关完成文件已上传')
} catch (error) { actionError.value = getApiErrorDetails(error, '报关完成文件上传失败') }
2026-08-28 16:54:02 +08:00
finally { actionLoading.value = false }
}
function saveBlob(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: ContainerCustomsDocument, kind: 'source' | 'completion') {
const target = kind === 'source' ? document.source : document.completion
if (!target) return
const key = `${kind}:${document.documentId}`
downloadingKey.value = key
actionError.value = null
try {
const blob = kind === 'source'
? await downloadContainerCustomsDocument(document.documentId)
: await downloadContainerCustomsCompletion(document.documentId)
saveBlob(blob, target.fileName)
2026-09-01 20:41:30 +08:00
ElMessage.success(`${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载已开始`)
} catch (error) {
2026-09-01 20:41:30 +08:00
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`)
} finally {
downloadingKey.value = ''
}
}
2026-08-28 16:54:02 +08:00
async function completeAllBuy() {
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { type: 'warning', confirmButtonText: '确认完成' }) }
catch { return }
actionLoading.value = true; actionError.value = null
try {
customs.value = await completeAllBuyContainerCustoms(props.loadingPlanId, customs.value.version)
emit('refreshed')
ElMessage.success('全买单柜报关已完成')
} catch (error) { actionError.value = getApiErrorDetails(error, '完成全买单柜报关失败') }
finally { actionLoading.value = false }
}
async function exportAllBuy() {
actionLoading.value = true; actionError.value = null
try {
const task = await createPlatformExportTask({
2026-08-30 17:43:03 +08:00
exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX', taskName: `买单报关-${customs.value?.planNo || '柜级明细'}`,
2026-08-28 16:54:02 +08:00
parameters: { loadingPlanId: String(props.loadingPlanId) },
})
ElMessage.success(`买单报关导出任务已创建:${task.taskNo}`)
} catch (error) { actionError.value = getApiErrorDetails(error, '买单报关导出任务创建失败') }
finally { actionLoading.value = false }
}
watch(() => props.loadingPlanId, () => { void load() })
onMounted(load)
</script>
<template>
<section class="container-customs surface-panel" aria-label="整柜报关">
<OperationErrorToast :details="actionError" />
<header class="customs-header">
<div><h2>整柜报关</h2><p>客户退税资料与平台买单事实按柜冻结;报关完成和国内离仓分别展示。</p></div>
<div v-if="customs" class="customs-actions">
<el-tag :type="!customs.hasActualCargo ? 'info' : customs.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }}</el-tag>
2026-08-28 16:54:02 +08:00
<el-button v-if="canManage && allBuy" plain :loading="actionLoading" @click="exportAllBuy">导出买单 XLSX</el-button>
<el-button v-if="canManage && customs.hasActualCargo && customs.status === 'READY_TO_START'" type="primary" :loading="actionLoading" @click="start">开始报关</el-button>
2026-08-28 16:54:02 +08:00
<el-button v-if="canManage && allBuy && customs.status === 'DECLARING'" type="primary" :loading="actionLoading" @click="completeAllBuy">确认报关完成</el-button>
</div>
</header>
<div v-if="actionError" class="action-error" role="alert"><span>{{ actionError.message }}</span><ApiErrorMeta :details="actionError" /></div>
2026-08-28 16:54:02 +08:00
<LoadErrorState v-if="loadError" compact title="整柜报关加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else-if="loading"><el-skeleton :rows="3" animated /></div>
<template v-else-if="customs">
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
<template v-else>
2026-08-28 16:54:02 +08:00
<div class="customs-metrics"><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
<div v-if="missingInbounds.length" class="compact-table-wrap">
2026-09-01 20:41:30 +08:00
<h3>缺退税资料清单</h3>
2026-08-28 16:54:02 +08:00
<table class="compact-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table>
</div>
<div v-if="customs.documents.length" class="compact-table-wrap">
2026-09-01 20:41:30 +08:00
<h3>已冻结退税资料与报关完成文件</h3>
<table class="compact-table document-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关完成文件</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody>
2026-08-28 16:54:02 +08:00
<tr v-for="document in customs.documents" :key="document.documentId">
2026-09-01 20:41:30 +08:00
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button size="small"><FileUp :size="14" />{{ document.completion ? '覆盖报关完成文件' : '上传报关完成文件' }}</el-button></el-upload><el-button size="small" type="primary" :disabled="!completionFiles[document.documentId]" :loading="actionLoading" @click="uploadCompletion(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')"><Download :size="14" />下载</el-button></div></td>
<td><div v-if="document.completion" class="file-cell"><span :title="document.completion.fileName">{{ document.completion.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `completion:${document.documentId}`" :aria-label="`下载报关完成文件 ${document.completion.fileName}`" @click="downloadFile(document, 'completion')"><Download :size="14" />下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.completion"> / 报关完成 v{{ document.completion.version }}</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.completion ? '已上传报关完成文件' : '待上传报关完成文件' }}</td>
2026-08-28 16:54:02 +08:00
</tr>
</tbody></table>
</div>
</template>
2026-08-28 16:54:02 +08:00
<div v-if="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : '报关已完成,待其他离仓条件' }}</el-tag></div>
</template>
</section>
</template>
<style scoped>
2026-09-01 20:18:13 +08:00
.container-customs{padding:12px 16px}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:15px}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.customs-actions{display:flex;align-items:center;gap:8px}.action-error{margin-top:8px;padding:8px 10px;border:1px solid var(--el-color-danger-light-5);border-radius:4px;background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:12px}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:20px;margin-top:10px;padding:8px 10px;background:var(--cc-surface-muted);font-size:12px}.customs-metrics strong{margin-left:4px;font-size:16px}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:10px}.compact-table-wrap h3{margin-bottom:6px}.compact-table{width:100%;border-collapse:collapse;font-size:12px}.compact-table th,.compact-table td{padding:7px 9px;border:1px solid var(--cc-border);text-align:left;vertical-align:middle}.compact-table th{background:var(--cc-surface-muted);color:var(--cc-muted)}.document-table th:first-child{width:200px}.document-actions,.file-cell{display:flex;align-items:center;gap:6px}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--cc-border);font-size:13px}
2026-08-28 16:54:02 +08:00
</style>