diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts index 37b7dc0d..4f5a374b 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.ts @@ -56,6 +56,42 @@ export interface ContainerCustomsView { brokerSubmittedAt?: string | null } +export type DeclarationBatchStatus = 'READY' | 'SUBMITTED' | 'ACKNOWLEDGED' | 'RESULT_PENDING' | 'CLOSED' | 'CANCELLED' +export type ClearanceResultType = 'INSPECTION' | 'SUPPLEMENT_REQUIRED' | 'RELEASED' | 'DETAINED' | 'OTHER' +export type ClearanceResultStatus = 'OPEN' | 'RESOLVED' | 'CANCELLED' + +export interface ClearanceResult { + id: string + declarationBatchId: string + customsContainerId: string + resultType: ClearanceResultType + resultStatus: ClearanceResultStatus + customsEntryNo: string | null + occurredAt: string + resolution: string | null + evidenceFileId: string | null + recordedByName: string + recordedAt: string + previousResultId: string | null +} + +export interface DeclarationBatch { + id: string + customsContainerId: string + loadingPlanId: string + batchNo: string + status: DeclarationBatchStatus + sourceVersion: number + customsEntryNo: string | null + version: number + submittedAt: string | null + submittedByName: string | null + acknowledgedAt: string | null + acknowledgedByName: string | null + remark: string | null + results: ClearanceResult[] +} + interface RawContainerCustomsView extends Omit { aggregateId: string | number loadingPlanId: string | number @@ -79,6 +115,39 @@ function normalize(value: RawContainerCustomsView): ContainerCustomsView { } } +function normalizeClearanceResult(value: ClearanceResult): ClearanceResult { + return { ...value, id: String(value.id), declarationBatchId: String(value.declarationBatchId), customsContainerId: String(value.customsContainerId), evidenceFileId: value.evidenceFileId == null ? null : String(value.evidenceFileId), previousResultId: value.previousResultId == null ? null : String(value.previousResultId) } +} + +function normalizeDeclarationBatch(value: DeclarationBatch): DeclarationBatch { + return { ...value, id: String(value.id), customsContainerId: String(value.customsContainerId), loadingPlanId: String(value.loadingPlanId), sourceVersion: Number(value.sourceVersion), version: Number(value.version), results: (value.results || []).map(normalizeClearanceResult) } +} + +export async function getDeclarationBatches(loadingPlanId: string) { + const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`) + return (unwrapApiResponse(response) || []).map(normalizeDeclarationBatch) +} + +export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number, batchNo?: string) { + const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, batchNo: batchNo?.trim() || null, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } }) + return normalizeDeclarationBatch(unwrapApiResponse(response)) +} + +export async function submitDeclarationBatch(batch: DeclarationBatch, customsEntryNo: string) { + const response = await http.post, ApiEnvelope>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, batchNo: batch.batchNo, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } }) + return normalizeDeclarationBatch(unwrapApiResponse(response)) +} + +export async function recordClearanceResult(batch: DeclarationBatch, resultType: ClearanceResultType, resolution?: string) { + const response = await http.post, ApiEnvelope>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/results`, { expectedVersion: batch.version, resultType, occurredAt: new Date().toISOString(), resolution: resolution?.trim() || null, evidenceFileId: null, resolved: false }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-record') } }) + return normalizeClearanceResult(unwrapApiResponse(response)) +} + +export async function resolveClearanceResult(result: ClearanceResult, batchVersion: number, resolution: string) { + const response = await http.post, ApiEnvelope>(`${basePath}/clearance-results/${encodeURIComponent(result.id)}/resolve`, { expectedVersion: batchVersion, resolution: resolution.trim() }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-resolve') } }) + return normalizeClearanceResult(unwrapApiResponse(response)) +} + export async function getContainerCustoms(loadingPlanId: string) { const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(loadingPlanId)}`) const value = unwrapApiResponse(response) diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index 23c434b2..8d78aff2 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -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(null) +const declarationBatches = ref([]) const loading = ref(false) const actionLoading = ref(false) const loadError = ref(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 = { 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)[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) +
+

正式申报与海关结果

正式申报批次建立在退税资料、回执确认和真实完税文件完成之后,与船期和 PDA 节点独立。

创建正式申报批次
+ +
+
+
{{ batch.batchNo }}报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}
+ {{ declarationStatusLabel(batch.status) }} +
提交正式申报录入查验结果
+
{{ result.resultType === 'INSPECTION' ? '查验' : result.resultType === 'SUPPLEMENT_REQUIRED' ? '补充资料' : result.resultType === 'RELEASED' ? '放行' : result.resultType }}{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}{{ result.resolution || '—' }}处理
+
+
+
国内离仓状态:{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}
@@ -219,5 +294,5 @@ onMounted(load)