feat(admin): add formal customs result actions

This commit is contained in:
2026-09-27 20:41:39 +08:00
parent 45e1e9b509
commit 29cb0bd57a
2 changed files with 146 additions and 2 deletions
@@ -9,10 +9,15 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import {
completeAllBuyContainerCustoms,
createDeclarationBatch,
downloadContainerCustomsReceipt,
downloadContainerCustomsFinal,
downloadContainerCustomsDocument,
getContainerCustoms,
getDeclarationBatches,
recordClearanceResult,
resolveClearanceResult,
submitDeclarationBatch,
startContainerCustoms,
submitContainerCustomsToBroker,
uploadContainerCustomsReceipt,
@@ -20,6 +25,7 @@ import {
type ContainerCustomsDocument,
type ContainerCustomsStatus,
type ContainerCustomsView,
type DeclarationBatch,
} from '../api/platformContainerCustoms'
const props = defineProps<{
@@ -31,6 +37,7 @@ const props = defineProps<{
const emit = defineEmits<{ refreshed: [] }>()
const customs = ref<ContainerCustomsView | null>(null)
const declarationBatches = ref<DeclarationBatch[]>([])
const loading = ref(false)
const actionLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -43,6 +50,8 @@ const missingInbounds = computed(() => customs.value?.inbounds.filter((item) =>
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)
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
const latestDeclarationBatch = computed(() => declarationBatches.value[0] || null)
const openDeclarationBatch = computed(() => declarationBatches.value.find((item) => ['READY', 'SUBMITTED', 'ACKNOWLEDGED', 'RESULT_PENDING'].includes(item.status)) || null)
const statusText: Record<ContainerCustomsStatus, string> = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
}
@@ -65,11 +74,65 @@ async function load() {
if (!props.canList || !props.loadingPlanId) return
loading.value = true
loadError.value = null
try { customs.value = await getContainerCustoms(props.loadingPlanId) }
try {
customs.value = await getContainerCustoms(props.loadingPlanId)
if (customs.value?.status === 'COMPLETED') {
try { declarationBatches.value = await getDeclarationBatches(props.loadingPlanId) } catch { declarationBatches.value = [] }
} else declarationBatches.value = []
}
catch (error) { customs.value = null; loadError.value = getApiErrorDetails(error, '整柜报关状态加载失败') }
finally { loading.value = false }
}
function declarationStatusLabel(value: DeclarationBatch['status']) {
return ({ READY: '待提交', SUBMITTED: '已提交', ACKNOWLEDGED: '已受理', RESULT_PENDING: '待处理结果', CLOSED: '已关闭', CANCELLED: '已取消' } as Record<string, string>)[value] || value
}
async function createFormalDeclaration() {
if (!customs.value || customs.value.status !== 'COMPLETED' || openDeclarationBatch.value) return
try { await ElMessageBox.confirm('当前柜的资料、回执和真实报关完税文件已完成,确认创建正式申报批次?', '创建正式申报批次', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
finally { actionLoading.value = false }
}
async function submitFormalDeclaration(batch: DeclarationBatch) {
try {
const answer = await ElMessageBox.prompt('请输入报关单号', '提交正式申报', { inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
actionLoading.value = true; actionError.value = null
await submitDeclarationBatch(batch, answer.value)
await load(); ElMessage.success('正式申报已提交')
} catch (error) {
if (error === 'cancel' || error === 'close') return
actionError.value = getApiErrorDetails(error, '正式申报提交失败')
} finally { actionLoading.value = false }
}
async function recordFormalResult(batch: DeclarationBatch) {
try {
const answer = await ElMessageBox.prompt('可填写查验处理说明,没有说明可直接确认', '录入海关查验结果', { inputPlaceholder: '处理说明(可选)', confirmButtonText: '确认录入' })
actionLoading.value = true; actionError.value = null
await recordClearanceResult(batch, 'INSPECTION', answer.value)
await load(); ElMessage.success('海关结果已录入')
} catch (error) {
if (error === 'cancel' || error === 'close') return
actionError.value = getApiErrorDetails(error, '海关结果录入失败')
} finally { actionLoading.value = false }
}
async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationBatch['results'][number]) {
try {
const answer = await ElMessageBox.prompt('请输入处理说明', '处理海关结果', { inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
actionLoading.value = true; actionError.value = null
await resolveClearanceResult(result, batch.version, answer.value)
await load(); ElMessage.success('海关结果已处理')
} catch (error) {
if (error === 'cancel' || error === 'close') return
actionError.value = getApiErrorDetails(error, '海关结果处理失败')
} finally { actionLoading.value = false }
}
async function start() {
if (!customs.value || customs.value.status !== 'READY_TO_START') return
try {
@@ -211,6 +274,18 @@ onMounted(load)
</tr>
</tbody></table>
</div>
<div v-if="customs.status === 'COMPLETED'" class="compact-table-wrap declaration-batches-wrap">
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3><p>正式申报批次建立在退税资料、回执确认和真实完税文件完成之后,与船期和 PDA 节点独立。</p></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
<div v-else class="declaration-batch-list">
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
<div class="declaration-batch-main"><strong>{{ batch.batchNo }}</strong><span>报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}</span></div>
<el-tag size="small" :type="batch.status === 'CLOSED' ? 'success' : batch.status === 'CANCELLED' ? 'info' : 'warning'">{{ declarationStatusLabel(batch.status) }}</el-tag>
<div class="declaration-batch-actions" v-if="canManage"><el-button v-if="batch.status === 'READY'" size="small" type="primary" :loading="actionLoading" @click="submitFormalDeclaration(batch)">提交正式申报</el-button><el-button v-if="['SUBMITTED','ACKNOWLEDGED','RESULT_PENDING'].includes(batch.status)" size="small" plain :loading="actionLoading" @click="recordFormalResult(batch)">录入查验结果</el-button></div>
<div v-if="batch.results.length" class="declaration-result-list"><div v-for="result in batch.results" :key="result.id" class="declaration-result-row"><span>{{ result.resultType === 'INSPECTION' ? '查验' : result.resultType === 'SUPPLEMENT_REQUIRED' ? '补充资料' : result.resultType === 'RELEASED' ? '放行' : result.resultType }}</span><el-tag size="small" :type="result.resultStatus === 'RESOLVED' ? 'success' : 'warning'">{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}</el-tag><small>{{ result.resolution || '—' }}</small><el-button v-if="canManage && result.resultStatus === 'OPEN'" link type="primary" size="small" :loading="actionLoading" @click="resolveFormalResult(batch, result)">处理</el-button></div></div>
</article>
</div>
</div>
</template>
<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>
</template>
@@ -219,5 +294,5 @@ onMounted(load)
</template>
<style scoped>
.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:3px 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:1px 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:5px}.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:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.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:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}
.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:3px 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:1px 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:5px}.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:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.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:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}
</style>