2026-08-28 16:54:02 +08:00
< script setup lang = "ts" >
2026-09-14 18:34:34 +08:00
import '@/styles/adminDensity.css'
2026-08-28 16:54:02 +08:00
import { computed , onMounted , reactive , ref , watch } from 'vue'
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 ,
2026-08-29 10:19:35 +08:00
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
2026-09-09 13:00:45 +08:00
departureState : 'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'
2026-08-28 16:54:02 +08:00
} > ()
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 )
2026-08-29 10:19:35 +08:00
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 )))
2026-09-10 14:02:37 +08:00
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 ) {
2026-08-29 10:19:35 +08:00
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-14 18:34:34 +08:00
await ElMessageBox . confirm ( '开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?' , '开始整柜报关' , { customClass : 'admin-density-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 ) {
2026-08-29 10:19:35 +08:00
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-14 18:34:34 +08:00
await ElMessageBox . confirm ( '这是最后一份待上传的报关完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?' , '完成整柜报关' , { customClass : 'admin-density-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 }
}
2026-08-29 10:19:35 +08:00
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' ? '客户退税资料' : '报关完成文件' } 下载已开始` )
2026-08-29 10:19:35 +08:00
} catch ( error ) {
2026-09-01 20:41:30 +08:00
actionError . value = getApiErrorDetails ( error , ` ${ kind === 'source' ? '客户退税资料' : '报关完成文件' } 下载失败` )
2026-08-29 10:19:35 +08:00
} finally {
downloadingKey . value = ''
}
}
2026-08-28 16:54:02 +08:00
async function completeAllBuy () {
if ( ! customs . value || customs . value . status !== 'DECLARING' || ! allBuy . value ) return
2026-09-14 18:34:34 +08:00
try { await ElMessageBox . confirm ( '确认全买单柜报关已经完成?报关完成不等同于国内离仓。' , '完成全买单柜报关' , { customClass : 'admin-density-confirm' , type : 'warning' , confirmButtonText : '确认完成' }) }
2026-08-28 16:54:02 +08:00
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 >
2026-09-14 18:34:34 +08:00
< section class = "container-customs surface-panel admin-density" aria -label =" 整柜报关 " >
2026-08-28 16:54:02 +08:00
< OperationErrorToast :details = "actionError" />
< header class = "customs-header" >
< div >< h2 > 整柜报关 </ h2 >< p > 客户退税资料与平台买单事实按柜冻结 ; 报关完成和国内离仓分别展示 。 </ p ></ div >
< div v-if = "customs" class="customs-actions" >
2026-09-10 14:02:37 +08:00
< 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 >
2026-09-10 14:02:37 +08:00
< 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 >
2026-08-29 10:19:35 +08:00
< 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" >
2026-09-10 14:02:37 +08:00
< 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-09-14 18:34:34 +08:00
< table class = "compact-table density-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 >
2026-08-28 16:54:02 +08:00
</ div >
< div v-if = "customs.documents.length" class="compact-table-wrap" >
2026-09-01 20:41:30 +08:00
< h3 > 已冻结退税资料与报关完成文件 </ h3 >
2026-09-14 18:34:34 +08:00
< table class = "compact-table document-table density-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-14 18:34:34 +08:00
< td >< div v-if = "canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button class="density-action" size="small">{{ document.completion ? '覆盖报关完成文件' : '上传报关完成文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" 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 class = "density-action" type = "primary" : loading = "downloadingKey === `source:${document.documentId}`" : aria -label ="` 下载客户退税资料 $ { document.source.fileName } `" @click ="downloadFile(document, 'source')" > 下载 </ el -button ></ div ></ td >
< td >< div v-if = "document.completion" class="file-cell"><span :title="document.completion.fileName">{{ document.completion.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `completion:${document.documentId}`" :aria-label="`下载报关完成文件 ${document.completion.fileName}`" @click="downloadFile(document, 'completion')" > 下载 </ el -button ></ div >< span v-else > 未上传 </ span ></ td >
2026-09-01 20:41:30 +08:00
< 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 >
2026-09-10 14:02:37 +08:00
</ template >
2026-09-09 13:00:45 +08:00
< div v-if = "customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'" >{{ departureState = == ' DEPARTED ' ? ' 已自动离仓 ' : departureState = == ' UNKNOWN ' ? ' 离仓状态尚未读取 ' : ' 报关已完成 , 待其他离仓条件 ' }} </ el -tag ></ div >
2026-08-28 16:54:02 +08:00
</ template >
2026-09-10 14:15:44 +08:00
< el-empty v-else-if = "canList" description="当前装柜计划尚未完成,完成装柜后可处理报关" :image-size="56" />
2026-08-28 16:54:02 +08:00
</ section >
</ template >
< style scoped >
2026-09-14 18:34:34 +08:00
. container - customs { padding : 0 ; border : 0 }. customs - header { display : flex ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 5 )}. customs - header h2 ,. compact - table - wrap h3 { margin : 0 ; font - size : var ( -- admin - font - section )}. customs - header p { margin : 3 px 0 0 ; color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption )}. customs - actions { display : flex ; align - items : center ; gap : var ( -- admin - space - 3 )}. action - error { margin - top : var ( -- admin - space - 3 ); padding : var ( -- admin - space - 3 ) var ( -- admin - space - 4 ); border : 1 px solid var ( -- el - color - danger - light - 5 ); border - radius : var ( -- admin - radius ); background : var ( -- el - color - danger - light - 9 ); color : var ( -- el - color - danger ); font - size : var ( -- admin - font - caption )}. action - error : deep (. api - error - meta ){ margin - top : 5 px }. customs - metrics { display : flex ; gap : var ( -- admin - space - 6 ); margin - top : var ( -- admin - space - 4 ); padding : var ( -- admin - space - 3 ) var ( -- admin - space - 4 ); background : var ( -- cc - surface - muted ); font - size : var ( -- admin - font - caption )}. customs - metrics strong { margin - left : var ( -- admin - space - 1 ); font - size : var ( -- admin - font - body )}. customs - metrics . danger strong { color : var ( -- el - color - danger )}. compact - table - wrap { margin - top : var ( -- admin - space - 4 )}. compact - table - wrap h3 { margin - bottom : var ( -- admin - space - 2 )}. compact - table { width : 100 % }. document - table th : first - child { width : 270 px }. document - actions ,. file - cell { display : flex ; align - items : center ; gap : var ( -- admin - space - 2 )}. file - cell span { display : block ; max - width : 210 px ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap }. readonly - label { color : var ( -- cc - muted )}. departure - state { display : flex ; align - items : center ; gap : var ( -- admin - space - 3 ); margin - top : var ( -- admin - space - 4 ); padding - top : var ( -- admin - space - 4 ); border - top : 1 px solid var ( -- cc - border ); font - size : var ( -- admin - font - body )}
2026-08-28 16:54:02 +08:00
</ style >