优化
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { completeAllBuyContainerCustoms, getContainerCustoms, startContainerCustoms, uploadContainerCustomsCompletion } from './platformContainerCustoms'
|
||||
|
||||
const customs = {
|
||||
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
|
||||
planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1,
|
||||
inbounds: [{ inboundId: 2092438843630391404n, orderId: 2092438843630391405n, orderNo: 'O-1', inboundCode: 'RK-1', tenantId: 2092438843630391406n, customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
|
||||
documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, completion: null }],
|
||||
startedAt: null, completedAt: null,
|
||||
}
|
||||
|
||||
describe('platform container customs API', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('uses the frozen customs-v1 paths and preserves long ids as strings', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: customs })
|
||||
const result = await getContainerCustoms('2092438843630391402')
|
||||
expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/2092438843630391402')
|
||||
expect(result).toMatchObject({ aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403' })
|
||||
expect(result.inbounds[0]).toMatchObject({ inboundId: '2092438843630391404', orderId: '2092438843630391405', tenantId: '2092438843630391406' })
|
||||
expect(result.documents[0]?.documentId).toBe('2092438843630391407')
|
||||
})
|
||||
|
||||
it('sends confirmed start and all-buy completion with optimistic version and idempotency', async () => {
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: customs })
|
||||
await startContainerCustoms('2092438843630391402', 3)
|
||||
await completeAllBuyContainerCustoms('2092438843630391402', 4)
|
||||
expect(post.mock.calls[0]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/start', { expectedVersion: 3, confirm: true, remark: null }])
|
||||
expect(post.mock.calls[1]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/complete-all-buy', { expectedVersion: 4, confirm: true, remark: null }])
|
||||
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
})
|
||||
|
||||
it('uploads one completion file with final-confirmation query parameters', async () => {
|
||||
const document = { ...customs.documents[0], documentId: '2092438843630391407', tenantId: '2092438843630391406' }
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: document })
|
||||
const completion = new File(['done'], 'done.pdf')
|
||||
await uploadContainerCustomsCompletion('2092438843630391407', completion, 0, true)
|
||||
expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/upload')
|
||||
expect((post.mock.calls[0]?.[1] as FormData).get('file')).toBe(completion)
|
||||
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0, finalConfirmation: true }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
|
||||
export type ContainerCustomsTaxMode = 'TAX_REFUND' | 'BUY_DECLARATION'
|
||||
|
||||
export interface ContainerCustomsFile {
|
||||
fileId: string
|
||||
fileName: string
|
||||
fileHash: string
|
||||
sizeBytes: number
|
||||
version: number
|
||||
uploadedAt: string
|
||||
}
|
||||
|
||||
export interface ContainerCustomsInbound {
|
||||
inboundId: string
|
||||
orderId: string
|
||||
orderNo: string
|
||||
inboundCode: string
|
||||
tenantId: string
|
||||
customerName: string
|
||||
taxMode: ContainerCustomsTaxMode
|
||||
covered: boolean
|
||||
}
|
||||
|
||||
export interface ContainerCustomsDocument {
|
||||
documentId: string
|
||||
tenantId: string
|
||||
source: ContainerCustomsFile
|
||||
inboundIds: string[]
|
||||
inboundCodes: string[]
|
||||
frozen: boolean
|
||||
completion: ContainerCustomsFile | null
|
||||
}
|
||||
|
||||
export interface ContainerCustomsView {
|
||||
aggregateId: string
|
||||
loadingPlanId: string
|
||||
containerBookingId: string
|
||||
planNo: string
|
||||
cabinetNo: string
|
||||
containerNo: string | null
|
||||
status: ContainerCustomsStatus
|
||||
version: number
|
||||
taxRefundCount: number
|
||||
coveredTaxRefundCount: number
|
||||
inbounds: ContainerCustomsInbound[]
|
||||
documents: ContainerCustomsDocument[]
|
||||
startedAt: string | null
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
interface RawContainerCustomsView extends Omit<ContainerCustomsView, 'aggregateId' | 'loadingPlanId' | 'containerBookingId' | 'version' | 'inbounds' | 'documents'> {
|
||||
aggregateId: string | number
|
||||
loadingPlanId: string | number
|
||||
containerBookingId: string | number
|
||||
version: string | number
|
||||
inbounds: Array<Omit<ContainerCustomsInbound, 'inboundId' | 'orderId' | 'tenantId'> & { inboundId: string | number; orderId: string | number; tenantId: string | number }>
|
||||
documents: Array<Omit<ContainerCustomsDocument, 'documentId' | 'tenantId' | 'source' | 'completion'> & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; completion: ContainerCustomsFile | null }>
|
||||
}
|
||||
|
||||
const basePath = '/platform/v1/customs/loading-plans'
|
||||
function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
function file(value: ContainerCustomsFile | null): ContainerCustomsFile | null {
|
||||
return value ? { ...value, fileId: String(value.fileId), sizeBytes: Number(value.sizeBytes), version: Number(value.version) } : null
|
||||
}
|
||||
function normalize(value: RawContainerCustomsView): ContainerCustomsView {
|
||||
return {
|
||||
...value,
|
||||
aggregateId: String(value.aggregateId), loadingPlanId: String(value.loadingPlanId), containerBookingId: String(value.containerBookingId), version: Number(value.version),
|
||||
inbounds: value.inbounds.map((item) => ({ ...item, inboundId: String(item.inboundId), orderId: String(item.orderId), tenantId: String(item.tenantId) })),
|
||||
documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, completion: file(item.completion) })),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerCustoms(loadingPlanId: string) {
|
||||
const response = await http.get<ApiEnvelope<RawContainerCustomsView>, ApiEnvelope<RawContainerCustomsView>>(`${basePath}/${encodeURIComponent(loadingPlanId)}`)
|
||||
return normalize(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
async function action(loadingPlanId: string, name: 'start' | 'complete-all-buy', expectedVersion: number, remark?: string) {
|
||||
const response = await http.post<ApiEnvelope<RawContainerCustomsView>, ApiEnvelope<RawContainerCustomsView>>(
|
||||
`${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`,
|
||||
{ expectedVersion, confirm: true, remark: remark?.trim() || null },
|
||||
{ headers: { 'Idempotency-Key': operationId(`container-customs-${name}`) } },
|
||||
)
|
||||
return normalize(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export function startContainerCustoms(loadingPlanId: string, expectedVersion: number) {
|
||||
return action(loadingPlanId, 'start', expectedVersion)
|
||||
}
|
||||
|
||||
export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVersion: number) {
|
||||
return action(loadingPlanId, 'complete-all-buy', expectedVersion)
|
||||
}
|
||||
|
||||
export async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number, finalConfirmation: boolean) {
|
||||
const body = new FormData()
|
||||
body.append('file', fileValue)
|
||||
const response = await http.post<ApiEnvelope<ContainerCustomsDocument>, ApiEnvelope<ContainerCustomsDocument>>(
|
||||
`${basePath}/documents/${encodeURIComponent(documentId)}/completion/upload`,
|
||||
body,
|
||||
{ params: { expectedVersion, finalConfirmation }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-customs-completion') } },
|
||||
)
|
||||
const result = unwrapApiResponse(response)
|
||||
return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, completion: file(result.completion) }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerCustomsPanel.vue'), 'utf8')
|
||||
|
||||
describe('ContainerCustomsPanel contract', () => {
|
||||
it('renders compact coverage, missing inbound and frozen document facts with operation first', () => {
|
||||
expect(source).toContain('退税总票')
|
||||
expect(source).toContain('缺资料清单')
|
||||
expect(source).toContain('<th>操作</th><th>关联入仓码</th><th>版本</th><th>客户</th>')
|
||||
expect(source).toContain("item.taxMode === 'TAX_REFUND' && !item.covered")
|
||||
})
|
||||
|
||||
it('gates start by server status and confirms frozen facts', () => {
|
||||
expect(source).toContain("customs.status === 'READY_TO_START'")
|
||||
expect(source).toContain('冻结客户当前资料版本、关联入仓码覆盖范围及平台买单准备事实')
|
||||
})
|
||||
|
||||
it('requires a second confirmation for the final missing completion and keeps departure independent', () => {
|
||||
expect(source).toContain('missingCompletionCount.value === 1')
|
||||
expect(source).toContain('确认上传并完成')
|
||||
expect(source).toContain("departureState === 'DEPARTED' ? '已自动离仓' : '报关已完成,待其他离仓条件'")
|
||||
})
|
||||
|
||||
it('creates the all-buy XLSX task with loadingPlanId as its only parameter', () => {
|
||||
expect(source).toContain("exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX'")
|
||||
expect(source).toContain('parameters: { loadingPlanId: String(props.loadingPlanId) }')
|
||||
expect(source).not.toContain('fileType')
|
||||
expect(source).not.toContain('documentType')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { FileUp } from '@lucide/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,
|
||||
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 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?.taxRefundCount === 0)
|
||||
const missingCompletionCount = computed(() => customs.value?.documents.filter((item) => !item.completion).length || 0)
|
||||
const statusText: Record<ContainerCustomsStatus, string> = {
|
||||
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
await ElMessageBox.confirm('开始后将冻结客户当前资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { type: 'warning', confirmButtonText: '确认开始' })
|
||||
} 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) {
|
||||
const selected = completionFiles[document.documentId]
|
||||
if (!selected) { ElMessage.warning('请选择对应完成文件'); return }
|
||||
const finalConfirmation = !document.completion && missingCompletionCount.value === 1
|
||||
if (finalConfirmation) {
|
||||
try {
|
||||
await ElMessageBox.confirm('这是最后一份待上传完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { type: 'warning', confirmButtonText: '确认上传并完成' })
|
||||
} 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')
|
||||
ElMessage.success(finalConfirmation ? '完成文件已上传,整柜报关已完成' : '完成文件已上传')
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '完成文件上传失败') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
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({
|
||||
exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX', taskName: `买单报关-${customs.value?.planNo || props.loadingPlanId}`,
|
||||
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.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ statusText[customs.status] }}</el-tag>
|
||||
<el-button v-if="canManage && allBuy" plain :loading="actionLoading" @click="exportAllBuy">导出买单 XLSX</el-button>
|
||||
<el-button v-if="canManage && customs.status === 'READY_TO_START'" type="primary" :loading="actionLoading" @click="start">开始报关</el-button>
|
||||
<el-button v-if="canManage && allBuy && customs.status === 'DECLARING'" type="primary" :loading="actionLoading" @click="completeAllBuy">确认报关完成</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<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">
|
||||
<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">
|
||||
<h3>缺资料清单</h3>
|
||||
<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">
|
||||
<h3>冻结资料与完成文件</h3>
|
||||
<table class="compact-table document-table"><thead><tr><th>操作</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody>
|
||||
<tr v-for="document in customs.documents" :key="document.documentId">
|
||||
<td><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></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>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
<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>
|
||||
.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}.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:230px}.document-table td:first-child{display:flex;gap:6px}.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}
|
||||
</style>
|
||||
@@ -47,11 +47,11 @@ describe('ContainerOverviewPanel', () => {
|
||||
expect(wrapper.text()).not.toContain('9007199254740999')
|
||||
})
|
||||
|
||||
it('renders four compact document table rows with the operation column first', () => {
|
||||
it('renders three compact non-customs document rows with the operation column first', () => {
|
||||
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
||||
const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text())
|
||||
expect(headers).toEqual(['操作', '资料类型', '文件名', '上传人', '上传时间', '状态'])
|
||||
expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(4)
|
||||
expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(3)
|
||||
expect(wrapper.findAll('.document-item')).toHaveLength(0)
|
||||
expect(wrapper.find('.document-table-wrap').exists()).toBe(true)
|
||||
const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))!
|
||||
@@ -158,11 +158,4 @@ describe('ContainerOverviewPanel', () => {
|
||||
expect(wrapper.text()).not.toContain('Authorization')
|
||||
})
|
||||
|
||||
it('keeps a completed customs document labelled as an attachment, not formal customs release', () => {
|
||||
const customs = { ...overview.documents[0], documentType: 'CUSTOMS_DECLARATION', completed: true, fileName: 'customs.pdf' }
|
||||
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, documents: [customs] }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
||||
expect(wrapper.text()).toContain('国内出口报关附件')
|
||||
expect(wrapper.text()).not.toContain('已完成报关')
|
||||
expect(wrapper.text()).not.toContain('已放行')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, watch } from 'vue'
|
||||
import { Check, Download, FileUp, TriangleAlert } from '@lucide/vue'
|
||||
import { Download, FileUp, TriangleAlert } from '@lucide/vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import type { ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto } from '../types'
|
||||
@@ -10,36 +10,30 @@ const props = defineProps<{
|
||||
overview: ContainerOverview
|
||||
canManageDocuments: boolean
|
||||
canManageCustoms: boolean
|
||||
canDownloadCustoms?: boolean
|
||||
canManageException: boolean
|
||||
documentBusy?: boolean
|
||||
customsCompleting?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploadDocument: [documentType: string]
|
||||
downloadDocument: [document: ContainerOverviewDocument]
|
||||
completeCustoms: [document: ContainerOverviewDocument]
|
||||
manageException: []
|
||||
}>()
|
||||
|
||||
const documentTypes = [
|
||||
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
|
||||
{ type: 'CUSTOMS_DECLARATION', label: '国内出口报关附件' },
|
||||
{ type: 'DELIVERY_ORDER', label: 'DO 资料' },
|
||||
{ type: 'PICKUP_ORDER', label: 'PU 资料' },
|
||||
] as const
|
||||
|
||||
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
|
||||
const containerDownloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
|
||||
const customsTypes = new Set(['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION'])
|
||||
function canDownloadDocument(documentType: string) {
|
||||
return containerDownloadableTypes.has(documentType)
|
||||
|| (documentType === 'CUSTOMS_DECLARATION' && props.canDownloadCustoms)
|
||||
|| (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms)
|
||||
}
|
||||
function canUploadDocument(documentType: string) {
|
||||
return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : customsTypes.has(documentType) && props.canManageCustoms
|
||||
return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms
|
||||
}
|
||||
function hasDocumentAction(documentType: string) {
|
||||
const document = documentsByType.value.get(documentType)
|
||||
@@ -47,9 +41,7 @@ function hasDocumentAction(documentType: string) {
|
||||
}
|
||||
function documentStatusText(documentType: string) {
|
||||
const document = documentsByType.value.get(documentType)
|
||||
if (documentType !== 'CUSTOMS_DECLARATION') return document ? formatTime(document.uploadedAt) : '等待上传'
|
||||
if (!document) return '第一步上传附件,第二步确认报关完成'
|
||||
return document.completed ? '已确认报关完成' : '附件已上传,待确认报关完成'
|
||||
return document ? formatTime(document.uploadedAt) : '等待上传'
|
||||
}
|
||||
function documentStatusLabel(documentType: string) {
|
||||
const document = documentsByType.value.get(documentType)
|
||||
@@ -129,8 +121,8 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
<table class="document-table">
|
||||
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>文件名</th><th>上传人</th><th>上传时间</th><th>状态</th></tr></thead>
|
||||
<tbody><tr v-for="item in documentTypes" :key="item.type">
|
||||
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><el-button v-if="item.type === 'CUSTOMS_DECLARATION' && canManageCustoms && documentsByType.get(item.type) && !documentsByType.get(item.type)?.completed" size="small" type="primary" :loading="customsCompleting" @click="emit('completeCustoms', documentsByType.get(item.type)!)"><Check :size="15" />确认报关完成</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
|
||||
<td><strong>{{ item.label }}</strong><small v-if="item.type === 'CUSTOMS_DECLARATION'">{{ documentStatusText(item.type) }}</small></td>
|
||||
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
|
||||
<td><strong>{{ item.label }}</strong></td>
|
||||
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span></td>
|
||||
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
|
||||
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
|
||||
|
||||
@@ -1,559 +1,22 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ContainerDetailView from './ContainerDetailView.vue'
|
||||
import { completeContainerClearance, downloadContainerClearance, downloadContainerDocument, getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
|
||||
import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
|
||||
import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const { accessCan, routerPush, carrierMounts } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn(), carrierMounts: { value: 0 } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
|
||||
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div data-test="carrier-panel" />', mounted: () => { carrierMounts.value += 1 } } }))
|
||||
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn(), completeLoadingDeclaration: vi.fn(), downloadLoadingDeclaration: vi.fn(), getLoadingDeclaration: vi.fn() }))
|
||||
vi.mock('@/modules/outbound-dispatch/api/platformOutboundDispatch', () => ({ getContainerOutboundDispatch: vi.fn(), confirmContainerOutboundDispatch: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({
|
||||
getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(),
|
||||
uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(),
|
||||
downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(),
|
||||
downloadContainerClearance: vi.fn(),
|
||||
}))
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
|
||||
const overviewSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerOverviewPanel.vue'), 'utf8')
|
||||
|
||||
const detail = { id: '2092438843630391297', planId: '2092438843630391401', planNo: 'LP-260825-0181', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', warehouseId: '193000000000200002', warehouseName: '东莞仓', containerType: '45HC', planStatus: 'COMPLETED', version: 8 } as never
|
||||
const overview = {
|
||||
container: { id: '2092438843630391297', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', status: 'COMPLETED', warehouseId: '193000000000200002', warehouseName: '东莞仓', voyageNo: null, vesselName: null, version: 8 },
|
||||
loadingPlanId: '2092438843630391401', pda: null,
|
||||
documents: [{ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'old.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T10:00:00', version: 5, completed: false }],
|
||||
exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [],
|
||||
}
|
||||
const outboundDetail = {
|
||||
aggregate: {
|
||||
containerBookingId: '2092438843630391297', cabinetNo: 'CAB-1', containerNo: 'C11111111',
|
||||
loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', truckAppointmentStatus: 'ARRIVED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4,
|
||||
inbounds: [{ orderId: '11', orderNo: 'O-1', inboundId: '2092438843630391501', inboundCode: 'IN-1', tenantId: '22', inboundStatus: 'LOADED' }],
|
||||
gate: { passed: true, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }] },
|
||||
},
|
||||
timeline: [],
|
||||
} as ContainerOutboundDispatchDetail
|
||||
|
||||
function mountView(customs = true, outbound = true) {
|
||||
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit'
|
||||
|| (customs && permission === 'platform:firstleg:customs:manage')
|
||||
|| permission === 'platform:firstleg:loading-list:list'
|
||||
|| (outbound && ['platform:firstleg:outbound-dispatch:view', 'platform:firstleg:outbound-dispatch:confirm'].includes(permission)))
|
||||
return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } })
|
||||
}
|
||||
function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-table tbody tr').find((item) => item.text().includes(label))! }
|
||||
function documentButton(wrapper: VueWrapper, label: string, buttonText: string) { return documentArticle(wrapper, label).findAll('button').find((button) => button.text().includes(buttonText))! }
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
|
||||
return { promise, resolve }
|
||||
}
|
||||
type ClearanceVm = { clearanceOpen: boolean; clearanceFile: File | null; clearanceError: { code?: string; requestId?: string } | null; documentRefreshWarning: unknown; selectClearanceFile: (file: { raw: File }) => void; uploadClearance: () => Promise<void>; completeClearance: () => Promise<void> }
|
||||
function clearanceVm(wrapper: VueWrapper) { return wrapper.vm as unknown as ClearanceVm }
|
||||
type DocumentVm = { documentOpen: boolean; documentFile: File | null; documentError: { code?: string; requestId?: string } | null; documentUploadKey: number; documentRefreshWarning: { code?: string } | null; selectDocumentFile: (file: { raw: File }) => void; saveDocument: () => Promise<void> }
|
||||
function documentVm(wrapper: VueWrapper) { return wrapper.vm as unknown as DocumentVm }
|
||||
|
||||
describe('ContainerDetailView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:document') })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
||||
carrierMounts.value = 0
|
||||
vi.mocked(getContainer).mockResolvedValue(detail)
|
||||
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
|
||||
vi.mocked(getContainerClearance).mockResolvedValue(null)
|
||||
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'customs-new.pdf', fileHash: 'hash', uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T12:00:00', completedBy: null, completedByName: null, completedAt: null, version: 6 } as never)
|
||||
vi.mocked(completeLoadingDeclaration).mockResolvedValue({} as never)
|
||||
vi.mocked(downloadLoadingDeclaration).mockResolvedValue(new Blob(['customs']))
|
||||
vi.mocked(downloadContainerClearance).mockResolvedValue(new Blob(['clearance']))
|
||||
vi.mocked(getLoadingDeclaration).mockResolvedValue(null)
|
||||
vi.mocked(uploadContainerDocument).mockResolvedValue({} as never)
|
||||
vi.mocked(uploadContainerClearance).mockResolvedValue({} as never)
|
||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValue(outboundDetail)
|
||||
vi.mocked(confirmContainerOutboundDispatch).mockResolvedValue({ ...outboundDetail, aggregate: { ...outboundDetail.aggregate, dispatchStatus: 'WAITING_FOR_LOADING', version: 5 } } as never)
|
||||
describe('ContainerDetailView customs integration', () => {
|
||||
it('mounts the new container customs panel only after loading completes', () => {
|
||||
expect(source).toContain("detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms")
|
||||
expect(source).toContain('<ContainerCustomsPanel')
|
||||
expect(source).toContain(':loading-plan-id="overview.loadingPlanId"')
|
||||
expect(source).toContain(':can-manage="canManageCustoms"')
|
||||
})
|
||||
|
||||
it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => {
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('国内出口报关附件')
|
||||
const file = new File(['customs'], 'customs.pdf', { type: 'application/pdf' })
|
||||
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
|
||||
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument()
|
||||
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
|
||||
expect(uploadContainerDocument).not.toHaveBeenCalled(); expect(uploadContainerClearance).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows a readable error when the container has no loading plan', async () => {
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法上传国内出口报关附件')
|
||||
expect(uploadLoadingDeclaration).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides customs and clearance upload without customs manage permission while keeping DO and PU', async () => {
|
||||
const wrapper = mountView(false); await flushPromises()
|
||||
expect(documentArticle(wrapper, '国内出口报关附件').findAll('button').map((button) => button.text())).toEqual(['下载'])
|
||||
expect(documentArticle(wrapper, '目的港清关资料').find('button').exists()).toBe(false)
|
||||
expect(documentArticle(wrapper, 'DO 资料').find('button').exists()).toBe(true)
|
||||
expect(documentArticle(wrapper, 'PU 资料').find('button').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps destination clearance on its independent container clearance API', async () => {
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentArticle(wrapper, '目的港清关资料').find('button').trigger('click'); await flushPromises()
|
||||
expect(getContainerClearance).toHaveBeenCalledWith('2092438843630391297')
|
||||
expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('downloads customs through the dedicated loading-list API and keeps DO PU isolated', async () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '国内出口报关附件', '下载').trigger('click'); await flushPromises()
|
||||
expect(downloadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', '2092438843630391502')
|
||||
expect(downloadContainerDocument).not.toHaveBeenCalled()
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||
expect(click).toHaveBeenCalledOnce()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps customs download after completion while hiding update and completion actions', async () => {
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
const row = documentArticle(wrapper, '国内出口报关附件')
|
||||
expect(row.findAll('button').map((button) => button.text())).toEqual(['下载'])
|
||||
expect(row.text()).toContain('已完成')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the optimistic customs upload when overview refresh fails and shows a readable warning', async () => {
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockRejectedValueOnce(new Error('overview unavailable'))
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
|
||||
const file = new File(['customs-new'], 'customs-new.pdf', { type: 'application/pdf' })
|
||||
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
|
||||
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument(); await flushPromises()
|
||||
const row = documentArticle(wrapper, '国内出口报关附件')
|
||||
expect(row.text()).toContain('customs-new.pdf')
|
||||
expect(row.text()).toContain('操作员')
|
||||
expect(row.text()).toContain('2026-08-26 12:00')
|
||||
expect(wrapper.text()).toContain('附件已上传,但最新资料刷新失败;当前已保留本次上传结果')
|
||||
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('refreshes a hidden customs version after A0409 and preserves error evidence without auto retry', async () => {
|
||||
const hidden = { id: '2092438843630392501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630392502', fileName: 'server-customs.pdf', fileHash: 'server-hash', uploadedBy: '2', uploadedByName: '服务端操作员', uploadedAt: '2026-08-26T12:30:00', completedBy: null, completedByName: null, completedAt: null, version: 7 }
|
||||
const withoutCustoms = { ...overview, documents: [] }
|
||||
vi.mocked(getContainerOverview).mockResolvedValue(withoutCustoms as never)
|
||||
vi.mocked(getLoadingDeclaration).mockResolvedValueOnce(hidden as never)
|
||||
vi.mocked(uploadLoadingDeclaration).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-upload-409', 'trace-upload-409'))
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '国内出口报关附件', '上传附件').trigger('click'); await flushPromises()
|
||||
const file = new File(['next'], 'next.pdf', { type: 'application/pdf' })
|
||||
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
|
||||
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument(); await flushPromises()
|
||||
expect(uploadLoadingDeclaration).toHaveBeenCalledOnce()
|
||||
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 0, '上传国内出口报关附件')
|
||||
expect(getLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002')
|
||||
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('server-customs.pdf')
|
||||
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('服务端操作员')
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).toContain('request-upload-409')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closes immediately after a valid destination upload response, clears dialog state and refreshes after optimistic table update', async () => {
|
||||
const existing = { id: '301', documentType: 'DESTINATION_CLEARANCE', fileId: '302', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
|
||||
const updated = { ...existing, fileId: '303', fileName: 'clearance-v2.pdf', uploadedAt: '2026-08-26T13:00:00', version: 2 }
|
||||
const refresh = deferred<typeof overview>()
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockReturnValueOnce(refresh.promise as never)
|
||||
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
|
||||
vi.mocked(uploadContainerClearance).mockResolvedValueOnce(updated as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
|
||||
const file = new File(['clearance'], 'clearance-v2.pdf', { type: 'application/pdf' })
|
||||
clearanceVm(wrapper).selectClearanceFile({ raw: file })
|
||||
const request = clearanceVm(wrapper).uploadClearance(); await flushPromises()
|
||||
expect(uploadContainerClearance).toHaveBeenCalledWith('2092438843630391297', file, 1, '上传目的港清关资料')
|
||||
expect(clearanceVm(wrapper).clearanceOpen).toBe(false)
|
||||
expect(clearanceVm(wrapper).clearanceFile).toBeNull()
|
||||
expect(clearanceVm(wrapper).clearanceError).toBeNull()
|
||||
expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('clearance-v2.pdf')
|
||||
refresh.resolve(overview)
|
||||
await request; await flushPromises()
|
||||
expect(clearanceVm(wrapper).documentRefreshWarning).toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['DELIVERY_ORDER', 'DO 资料'],
|
||||
['PICKUP_ORDER', 'PU 资料'],
|
||||
] as const)('optimistically replaces %s, closes before overview refresh and preserves the returned file over a stale overview', async (documentType, label) => {
|
||||
const existing = { id: `old-${documentType}`, documentType, fileId: `old-file-${documentType}`, fileName: `old-${documentType}.pdf`, fileHash: null, uploadedBy: '1', uploadedByName: '旧操作员', uploadedAt: '2026-08-26T10:00:00', version: 1, completed: false }
|
||||
const initial = { ...overview, documents: [...overview.documents, existing] }
|
||||
const uploaded = { id: `new-${documentType}`, documentType, fileId: `new-file-${documentType}`, fileName: `new-${documentType}.pdf`, fileHash: 'hash', uploadedBy: '2', uploadedByName: '新操作员', uploadedAt: '2026-08-27T13:00:00', completedBy: null, completedByName: null, completedAt: null, version: 2 }
|
||||
const refresh = deferred<typeof initial>()
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce(initial as never).mockReturnValueOnce(refresh.promise as never)
|
||||
vi.mocked(uploadContainerDocument).mockResolvedValueOnce(uploaded as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, label, '更新附件').trigger('click'); await flushPromises()
|
||||
const file = new File([documentType], `new-${documentType}.pdf`, { type: 'application/pdf' })
|
||||
documentVm(wrapper).selectDocumentFile({ raw: file })
|
||||
const request = documentVm(wrapper).saveDocument(); await flushPromises()
|
||||
expect(uploadContainerDocument).toHaveBeenCalledWith('2092438843630391297', documentType, file, 1, `上传${label}`)
|
||||
expect(documentVm(wrapper).documentOpen).toBe(false)
|
||||
expect(documentVm(wrapper).documentFile).toBeNull()
|
||||
expect(documentArticle(wrapper, label).text()).toContain(`new-${documentType}.pdf`)
|
||||
expect(documentArticle(wrapper, label).text()).toContain('新操作员')
|
||||
refresh.resolve(initial); await request; await flushPromises()
|
||||
expect(documentArticle(wrapper, label).text()).toContain(`new-${documentType}.pdf`)
|
||||
expect(documentVm(wrapper).documentRefreshWarning).toMatchObject({ code: 'DOCUMENT_OVERVIEW_STALE' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('rebuilds the upload picker on reopen and keeps DO failures open with the selected file and API evidence', async () => {
|
||||
const existing = { id: 'do-old', documentType: 'DELIVERY_ORDER', fileId: 'do-file-old', fileName: 'old-do.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '旧操作员', uploadedAt: '2026-08-26T10:00:00', version: 1, completed: false }
|
||||
vi.mocked(getContainerOverview).mockResolvedValue({ ...overview, documents: [...overview.documents, existing] } as never)
|
||||
vi.mocked(uploadContainerDocument)
|
||||
.mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-do-409', 'trace-do-409'))
|
||||
.mockResolvedValueOnce(null)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, 'DO 资料', '更新附件').trigger('click'); await flushPromises()
|
||||
const firstKey = documentVm(wrapper).documentUploadKey
|
||||
const file = new File(['do'], 'next-do.pdf', { type: 'application/pdf' })
|
||||
documentVm(wrapper).selectDocumentFile({ raw: file })
|
||||
await documentVm(wrapper).saveDocument(); await flushPromises()
|
||||
expect(documentVm(wrapper).documentOpen).toBe(true)
|
||||
expect(documentVm(wrapper).documentFile).toBe(file)
|
||||
expect(documentVm(wrapper).documentError).toMatchObject({ code: 'A0409', requestId: 'request-do-409' })
|
||||
expect(wrapper.text()).toContain('trace-do-409')
|
||||
documentVm(wrapper).documentOpen = false; await flushPromises()
|
||||
await documentButton(wrapper, 'PU 资料', '上传附件').trigger('click'); await flushPromises()
|
||||
expect(documentVm(wrapper).documentUploadKey).toBeGreaterThan(firstKey)
|
||||
expect(documentVm(wrapper).documentFile).toBeNull()
|
||||
expect(documentVm(wrapper).documentError).toBeNull()
|
||||
expect(wrapper.text()).not.toContain('request-do-409')
|
||||
const puFile = new File(['pu'], 'next-pu.pdf', { type: 'application/pdf' })
|
||||
documentVm(wrapper).selectDocumentFile({ raw: puFile })
|
||||
await documentVm(wrapper).saveDocument(); await flushPromises()
|
||||
expect(documentVm(wrapper).documentOpen).toBe(true)
|
||||
expect(documentVm(wrapper).documentFile).toBe(puFile)
|
||||
expect(uploadContainerDocument).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('downloads destination clearance through its dedicated endpoint and keeps completed rows download-only', async () => {
|
||||
const completed = { id: '351', documentType: 'DESTINATION_CLEARANCE', fileId: '2092438843630393502', fileName: 'destination-clearance.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', version: 2, completed: true }
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [...overview.documents, completed] } as never)
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
const row = documentArticle(wrapper, '目的港清关资料')
|
||||
expect(row.findAll('button').map((button) => button.text())).toEqual(['下载'])
|
||||
await documentButton(wrapper, '目的港清关资料', '下载').trigger('click'); await flushPromises()
|
||||
expect(downloadContainerClearance).toHaveBeenCalledWith('2092438843630391297', '2092438843630393502')
|
||||
expect(downloadLoadingDeclaration).not.toHaveBeenCalled()
|
||||
expect(downloadContainerDocument).not.toHaveBeenCalled()
|
||||
expect(click).toHaveBeenCalledOnce()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('refreshes destination clearance after A0409 and preserves the server file and error metadata', async () => {
|
||||
const existing = { id: '401', documentType: 'DESTINATION_CLEARANCE', fileId: '402', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
|
||||
const server = { ...existing, fileId: '403', fileName: 'server-clearance-v2.pdf', uploadedByName: '服务端清关员', version: 2 }
|
||||
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never).mockResolvedValueOnce(server as never).mockResolvedValueOnce(server as never)
|
||||
vi.mocked(uploadContainerClearance).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-clearance-409', 'trace-clearance-409'))
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
|
||||
const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' })
|
||||
clearanceVm(wrapper).selectClearanceFile({ raw: file })
|
||||
await clearanceVm(wrapper).uploadClearance(); await flushPromises()
|
||||
expect(uploadContainerClearance).toHaveBeenCalledOnce()
|
||||
expect(clearanceVm(wrapper).clearanceOpen).toBe(true)
|
||||
expect(clearanceVm(wrapper).clearanceFile).toBe(file)
|
||||
expect(clearanceVm(wrapper).clearanceError).toMatchObject({ code: 'A0409', requestId: 'request-clearance-409' })
|
||||
expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('server-clearance-v2.pdf')
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).toContain('request-clearance-409')
|
||||
clearanceVm(wrapper).clearanceOpen = false; await flushPromises()
|
||||
await documentButton(wrapper, '目的港清关资料', '更新附件').trigger('click'); await flushPromises()
|
||||
expect(clearanceVm(wrapper).clearanceOpen).toBe(true)
|
||||
expect(clearanceVm(wrapper).clearanceFile).toBeNull()
|
||||
expect(clearanceVm(wrapper).clearanceError).toBeNull()
|
||||
expect(wrapper.text()).not.toContain('request-clearance-409')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the destination dialog and selected file for network and empty-response upload failures', async () => {
|
||||
const existing = { id: '501', documentType: 'DESTINATION_CLEARANCE', fileId: '502', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
|
||||
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
|
||||
vi.mocked(uploadContainerClearance)
|
||||
.mockRejectedValueOnce(new ApiResponseError('网络请求失败', 'NETWORK_ERROR', undefined, undefined, 'request-network', 'trace-network'))
|
||||
.mockResolvedValueOnce(null)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
|
||||
const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' })
|
||||
clearanceVm(wrapper).selectClearanceFile({ raw: file })
|
||||
await clearanceVm(wrapper).uploadClearance(); await flushPromises()
|
||||
expect(clearanceVm(wrapper).clearanceOpen).toBe(true)
|
||||
expect(clearanceVm(wrapper).clearanceFile).toBe(file)
|
||||
expect(wrapper.text()).toContain('request-network')
|
||||
await clearanceVm(wrapper).uploadClearance(); await flushPromises()
|
||||
expect(clearanceVm(wrapper).clearanceOpen).toBe(true)
|
||||
expect(clearanceVm(wrapper).clearanceFile).toBe(file)
|
||||
expect(uploadContainerClearance).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closes after successful destination clearance completion but keeps the dialog open on completion failure', async () => {
|
||||
const existing = { id: '601', documentType: 'DESTINATION_CLEARANCE', fileId: '602', fileName: 'clearance.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
|
||||
const completed = { ...existing, completedBy: '4', completedByName: '复核员', completedAt: '2026-08-27T12:00:00', version: 2 }
|
||||
const refresh = deferred<typeof overview>()
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockReturnValueOnce(refresh.promise as never)
|
||||
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
|
||||
vi.mocked(completeContainerClearance).mockResolvedValueOnce(completed as never)
|
||||
let wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
|
||||
const request = clearanceVm(wrapper).completeClearance(); await flushPromises()
|
||||
expect(clearanceVm(wrapper).clearanceOpen).toBe(false)
|
||||
expect(clearanceVm(wrapper).clearanceError).toBeNull()
|
||||
expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('已完成')
|
||||
refresh.resolve(overview); await request; await flushPromises()
|
||||
wrapper.unmount()
|
||||
|
||||
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
|
||||
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
|
||||
vi.mocked(completeContainerClearance).mockRejectedValueOnce(new ApiResponseError('完成失败', 'A0409', undefined, undefined, 'request-complete-409', 'trace-complete-409'))
|
||||
wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
|
||||
await clearanceVm(wrapper).completeClearance(); await flushPromises()
|
||||
expect(clearanceVm(wrapper).clearanceOpen).toBe(true)
|
||||
expect(clearanceVm(wrapper).clearanceError).toMatchObject({ code: 'A0409', requestId: 'request-complete-409' })
|
||||
expect(wrapper.text()).toContain('trace-complete-409')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps customs attachment upload and completion as two explicit steps with exact payload', async () => {
|
||||
const completedOverview = { ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) }
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockResolvedValue(completedOverview as never)
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
const customs = documentArticle(wrapper, '国内出口报关附件')
|
||||
expect(customs.text()).toContain('附件已上传,待确认报关完成')
|
||||
expect(customs.text()).toContain('更新附件')
|
||||
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
|
||||
expect(completeLoadingDeclaration).toHaveBeenCalledOnce()
|
||||
expect(completeLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', 5, '货柜详情确认国内出口报关完成')
|
||||
expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
|
||||
expect(getContainerOverview).toHaveBeenCalledTimes(2)
|
||||
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('已确认报关完成')
|
||||
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows customs completion only for an existing unfinished attachment with manage permission', async () => {
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [] } as never)
|
||||
let wrapper = mountView(); await flushPromises()
|
||||
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('第一步上传附件,第二步确认报关完成')
|
||||
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never)
|
||||
wrapper = mountView(); await flushPromises()
|
||||
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
|
||||
wrapper = mountView(false); await flushPromises()
|
||||
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows a readable completion error without a loading plan and does not call the API', async () => {
|
||||
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法确认报关完成')
|
||||
expect(completeLoadingDeclaration).not.toHaveBeenCalled()
|
||||
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('prevents repeated customs completion and preserves A0409 evidence after refreshing facts', async () => {
|
||||
let releasePrompt!: () => void
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise<void>((resolve) => { releasePrompt = resolve }) as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
const button = documentButton(wrapper, '国内出口报关附件', '确认报关完成')
|
||||
await button.trigger('click'); await button.trigger('click')
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
|
||||
releasePrompt(); await flushPromises()
|
||||
vi.mocked(completeLoadingDeclaration).mockRejectedValueOnce(new ApiResponseError('数据已变更', 'A0409', undefined, undefined, 'request-customs-409', 'trace-customs-409'))
|
||||
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({ action: 'confirm' } as never)
|
||||
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
|
||||
expect(completeLoadingDeclaration).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).toContain('request-customs-409')
|
||||
expect(getContainerOverview).toHaveBeenCalledTimes(3)
|
||||
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(3)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('loads and displays the whole-container gate without querying an inbound id', async () => {
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
expect(getContainerOutboundDispatch).toHaveBeenCalledOnce()
|
||||
expect(getContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297')
|
||||
expect(wrapper.text()).toContain('国内出仓门禁')
|
||||
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1')
|
||||
expect(wrapper.find('.gate-summary').text()).toContain('已通过 1')
|
||||
expect(wrapper.find('.gate-summary').text()).toContain('阻断 0')
|
||||
expect(wrapper.find('.gate-list').text()).not.toContain('报关协同完成')
|
||||
await wrapper.find('.gate-summary button').trigger('click')
|
||||
expect(wrapper.find('.gate-list').text()).toContain('报关协同完成')
|
||||
expect(wrapper.find('.gate-list').text()).toContain('覆盖 1 个入库码')
|
||||
expect(wrapper.find('.gate-list').text()).toContain('IN-1')
|
||||
expect(wrapper.text()).toContain('确认离仓')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('confirms the physical container once with version and no inboundId then refreshes the carrier panel', async () => {
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
await wrapper.find('.outbound-panel__actions button').trigger('click'); await flushPromises()
|
||||
expect(confirmContainerOutboundDispatch).toHaveBeenCalledOnce()
|
||||
expect(confirmContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' })
|
||||
expect(vi.mocked(confirmContainerOutboundDispatch).mock.calls[0]?.[1]).not.toHaveProperty('inboundId')
|
||||
expect(getContainerOverview).toHaveBeenCalledTimes(2)
|
||||
expect(carrierMounts.value).toBe(2)
|
||||
expect(wrapper.text()).toContain('已确认离仓')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('prevents a repeated confirmation wave while the first prompt is open', async () => {
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise(() => {}) as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
const button = wrapper.find('.outbound-panel__actions button')
|
||||
await button.trigger('click')
|
||||
await button.trigger('click')
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
|
||||
expect(confirmContainerOutboundDispatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides the outbound gate and never calls its API without view permission', async () => {
|
||||
const wrapper = mountView(true, false); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('国内出仓门禁')
|
||||
expect(getContainerOutboundDispatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows gate failure reasons and does not expose confirm when any gate is blocked', async () => {
|
||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
|
||||
...outboundDetail,
|
||||
aggregate: {
|
||||
...outboundDetail.aggregate,
|
||||
gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } }] },
|
||||
},
|
||||
} as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('报关协同完成')
|
||||
expect(wrapper.text()).toContain('MISSING')
|
||||
expect(wrapper.text()).toContain('COMPLETED')
|
||||
expect(wrapper.find('.outbound-panel__actions button').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('deduplicates identical gates across inbounds and shows compact coverage counts', async () => {
|
||||
const blocked = { code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }
|
||||
const passed = { code: 'LOADING_PLAN_COMPLETED', name: '柜次装柜计划完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }
|
||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
|
||||
...outboundDetail,
|
||||
aggregate: {
|
||||
...outboundDetail.aggregate,
|
||||
gate: {
|
||||
passed: false,
|
||||
checkedAt: '2026-08-26T12:00:00',
|
||||
inbounds: [
|
||||
{ inboundId: '101', inboundCode: 'IN-101', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } },
|
||||
{ inboundId: '102', inboundCode: 'IN-102', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 2')
|
||||
expect(wrapper.find('.gate-summary').text()).toContain('已通过 1')
|
||||
expect(wrapper.find('.gate-summary').text()).toContain('阻断 1')
|
||||
expect(wrapper.findAll('.gate-row')).toHaveLength(1)
|
||||
expect(wrapper.find('.gate-row').text()).toContain('影响 2 个入库码')
|
||||
expect(wrapper.find('.gate-row').text()).toContain('IN-101')
|
||||
expect(wrapper.find('.gate-row').text()).toContain('IN-102')
|
||||
await wrapper.find('.gate-summary button').trigger('click')
|
||||
expect(wrapper.findAll('.gate-row')).toHaveLength(2)
|
||||
expect(wrapper.findAll('.gate-row')[1]?.text()).toContain('覆盖 2 个入库码')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps different facts for the same gate code separate and labels the affected inbound', async () => {
|
||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
|
||||
...outboundDetail,
|
||||
aggregate: {
|
||||
...outboundDetail.aggregate,
|
||||
gate: {
|
||||
passed: false,
|
||||
checkedAt: '2026-08-26T12:00:00',
|
||||
inbounds: [
|
||||
{ inboundId: '201', inboundCode: 'IN-MISSING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } },
|
||||
{ inboundId: '202', inboundCode: 'IN-PENDING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'PENDING', requirement: 'COMPLETED' }] } },
|
||||
{ inboundId: '203', inboundCode: 'IN-DONE', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
expect(wrapper.findAll('.gate-row')).toHaveLength(2)
|
||||
expect(wrapper.find('.gate-list').text()).toContain('MISSING')
|
||||
expect(wrapper.find('.gate-list').text()).toContain('IN-MISSING')
|
||||
expect(wrapper.find('.gate-list').text()).toContain('PENDING')
|
||||
expect(wrapper.find('.gate-list').text()).toContain('IN-PENDING')
|
||||
await wrapper.find('.gate-summary button').trigger('click')
|
||||
expect(wrapper.findAll('.gate-row')).toHaveLength(3)
|
||||
expect(wrapper.find('.gate-list').text()).toContain('IN-DONE')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps truck and sailing schedule as reference only and never counts legacy reference gates as blocking', async () => {
|
||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
|
||||
...outboundDetail,
|
||||
aggregate: {
|
||||
...outboundDetail.aggregate,
|
||||
truckAppointmentStatus: 'ARRIVED',
|
||||
gate: {
|
||||
passed: false,
|
||||
checkedAt: '2026-08-26T12:00:00',
|
||||
inbounds: [{ inboundId: '301', inboundCode: 'IN-301', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [
|
||||
{ code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' },
|
||||
{ code: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: false, actual: 'EXPIRED', requirement: 'VALID' },
|
||||
{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' },
|
||||
] } }],
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
const wrapper = mountView(); await flushPromises()
|
||||
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1')
|
||||
expect(wrapper.find('.gate-summary').text()).toContain('阻断 0')
|
||||
expect(wrapper.find('.gate-summary').text()).toContain('拖车状态(参考):已到仓')
|
||||
expect(wrapper.text()).not.toContain('关联拖车已离仓')
|
||||
expect(wrapper.text()).not.toContain('船期有效且未过期')
|
||||
expect(wrapper.find('.outbound-panel__actions button').text()).toContain('确认离仓')
|
||||
wrapper.unmount()
|
||||
it('removes the retired loading-list customs UI and calls', () => {
|
||||
for (const retired of ['getLoadingDeclaration', 'uploadLoadingDeclaration', 'completeLoadingDeclaration', 'downloadLoadingDeclaration', 'CUSTOMS_DECLARATION']) {
|
||||
expect(source).not.toContain(retired)
|
||||
expect(overviewSource).not.toContain(retired)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,9 +11,9 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
||||
import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
|
||||
import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types'
|
||||
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
|
||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||
import {
|
||||
cancelContainerException,
|
||||
@@ -35,7 +35,7 @@ const access = useAccessPreviewStore()
|
||||
const containerId = computed(() => String(route.params.containerId || ''))
|
||||
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
|
||||
const canDownloadCustoms = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
|
||||
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
|
||||
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
|
||||
const loading = ref(false)
|
||||
@@ -44,15 +44,13 @@ const overview = ref<ContainerOverview | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const overviewError = ref<ApiErrorDetails | null>(null)
|
||||
const documentOpen = ref(false)
|
||||
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER' | 'CUSTOMS_DECLARATION'
|
||||
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER'
|
||||
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
|
||||
const documentFile = ref<File | null>(null)
|
||||
const documentUploadKey = ref(0)
|
||||
const documentSaving = ref(false)
|
||||
const documentDownloading = ref(false)
|
||||
const documentError = ref<ApiErrorDetails | null>(null)
|
||||
const customsCompleting = ref(false)
|
||||
const customsActionError = ref<ApiErrorDetails | null>(null)
|
||||
const documentRefreshWarning = ref<ApiErrorDetails | null>(null)
|
||||
const clearanceOpen = ref(false)
|
||||
const clearanceDocument = ref<BusinessDocument | null>(null)
|
||||
@@ -72,7 +70,7 @@ const showPassedGates = ref(false)
|
||||
const carrierRefreshKey = ref(0)
|
||||
|
||||
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
||||
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value])
|
||||
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料' })[documentType.value])
|
||||
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
|
||||
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
|
||||
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
|
||||
@@ -100,6 +98,8 @@ const outboundGateSummary = computed(() => ({
|
||||
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
|
||||
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
|
||||
&& outboundGateSummary.value.blocked === 0)
|
||||
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES'>(() =>
|
||||
outboundDetail.value?.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
|
||||
|
||||
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
|
||||
return {
|
||||
@@ -113,16 +113,14 @@ function replaceOverviewDocument(document: ContainerOverviewDocument) {
|
||||
if (!overview.value) return
|
||||
overview.value = { ...overview.value, documents: [...overview.value.documents.filter((item) => item.documentType !== document.documentType), document] }
|
||||
}
|
||||
async function refreshOverviewDocument(documentType: 'CUSTOMS_DECLARATION' | 'DESTINATION_CLEARANCE') {
|
||||
async function refreshOverviewDocument(documentType: 'DESTINATION_CLEARANCE') {
|
||||
let refreshCause: unknown
|
||||
let refreshed: ContainerOverview | null = null
|
||||
try { refreshed = await getContainerOverview(containerId.value) } catch (cause) { refreshCause = cause }
|
||||
let current = refreshed?.documents.find((item) => item.documentType === documentType) || null
|
||||
if (!current) {
|
||||
try {
|
||||
const business = documentType === 'CUSTOMS_DECLARATION'
|
||||
? overview.value?.loadingPlanId && detail.value ? await getLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId) : null
|
||||
: await getContainerClearance(containerId.value)
|
||||
const business = await getContainerClearance(containerId.value)
|
||||
if (business) {
|
||||
if (documentType === 'DESTINATION_CLEARANCE') clearanceDocument.value = business
|
||||
current = toOverviewDocument(business, documentType, overview.value?.documents.find((item) => item.documentType === documentType))
|
||||
@@ -158,6 +156,10 @@ async function loadOutboundDispatch() {
|
||||
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
|
||||
finally { outboundLoading.value = false }
|
||||
}
|
||||
async function refreshAfterCustoms() {
|
||||
if (canViewOutboundDispatch.value) await loadOutboundDispatch()
|
||||
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ }
|
||||
}
|
||||
|
||||
async function confirmDeparture() {
|
||||
const aggregate = outboundDetail.value?.aggregate
|
||||
@@ -192,13 +194,9 @@ function truckReferenceText(status?: string | null) {
|
||||
|
||||
function openDocument(type: string) {
|
||||
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return
|
||||
if (type === 'CUSTOMS_DECLARATION' ? !canManageCustoms.value : !canEdit.value) return
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type) || !canEdit.value) return
|
||||
documentType.value = type as UploadDocumentType
|
||||
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
|
||||
if (type === 'CUSTOMS_DECLARATION' && !overview.value?.loadingPlanId) {
|
||||
documentError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法上传国内出口报关附件' }
|
||||
}
|
||||
}
|
||||
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
|
||||
function removeDocumentFile() { documentFile.value = null }
|
||||
@@ -207,100 +205,41 @@ async function saveDocument() {
|
||||
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
|
||||
try {
|
||||
const expectedVersion = selectedDocument.value?.version || 0
|
||||
if (documentType.value === 'CUSTOMS_DECLARATION') {
|
||||
if (!overview.value.loadingPlanId) throw new Error('当前货柜没有装柜清单,无法上传国内出口报关附件')
|
||||
const uploaded = await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件')
|
||||
if (!uploaded) throw new Error('报关附件上传成功但未返回附件信息')
|
||||
const optimistic = toOverviewDocument(uploaded, 'CUSTOMS_DECLARATION', selectedDocument.value)
|
||||
replaceOverviewDocument(optimistic)
|
||||
try {
|
||||
const refreshed = await getContainerOverview(containerId.value)
|
||||
if (refreshed.documents.some((item) => item.documentType === 'CUSTOMS_DECLARATION')) overview.value = refreshed
|
||||
else {
|
||||
overview.value = { ...refreshed, documents: [...refreshed.documents, optimistic] }
|
||||
documentRefreshWarning.value = { code: 'CUSTOMS_DOCUMENT_STALE', message: '附件已上传,但资料汇总尚未同步;当前已保留本次上传结果' }
|
||||
}
|
||||
const targetType = documentType.value
|
||||
const targetTitle = documentTitle.value
|
||||
const uploaded = await uploadContainerDocument(containerId.value, targetType, documentFile.value, expectedVersion, `上传${targetTitle}`)
|
||||
if (!uploaded) throw new Error(`${targetTitle}上传成功但未返回附件信息`)
|
||||
const optimistic = toOverviewDocument(uploaded, targetType, selectedDocument.value)
|
||||
replaceOverviewDocument(optimistic)
|
||||
documentOpen.value = false; documentFile.value = null; documentError.value = null; documentUploadKey.value += 1
|
||||
ElMessage.success(`${targetTitle}已上传`)
|
||||
try {
|
||||
const refreshed = await getContainerOverview(containerId.value)
|
||||
const current = refreshed.documents.find((item) => item.documentType === targetType)
|
||||
if (current && (current.fileId === optimistic.fileId || current.version > optimistic.version)) overview.value = refreshed
|
||||
else {
|
||||
overview.value = { ...refreshed, documents: [...refreshed.documents.filter((item) => item.documentType !== targetType), optimistic] }
|
||||
documentRefreshWarning.value = { code: 'DOCUMENT_OVERVIEW_STALE', message: `${targetTitle}已上传,但资料汇总尚未同步;当前已保留本次上传结果` }
|
||||
}
|
||||
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '附件已上传,但最新资料刷新失败;当前已保留本次上传结果') }
|
||||
} else {
|
||||
const targetType = documentType.value
|
||||
const targetTitle = documentTitle.value
|
||||
const uploaded = await uploadContainerDocument(containerId.value, targetType, documentFile.value, expectedVersion, `上传${targetTitle}`)
|
||||
if (!uploaded) throw new Error(`${targetTitle}上传成功但未返回附件信息`)
|
||||
const optimistic = toOverviewDocument(uploaded, targetType, selectedDocument.value)
|
||||
replaceOverviewDocument(optimistic)
|
||||
documentOpen.value = false; documentFile.value = null; documentError.value = null; documentUploadKey.value += 1
|
||||
ElMessage.success(`${targetTitle}已上传`)
|
||||
try {
|
||||
const refreshed = await getContainerOverview(containerId.value)
|
||||
const current = refreshed.documents.find((item) => item.documentType === targetType)
|
||||
if (current && (current.fileId === optimistic.fileId || current.version > optimistic.version)) overview.value = refreshed
|
||||
else {
|
||||
overview.value = { ...refreshed, documents: [...refreshed.documents.filter((item) => item.documentType !== targetType), optimistic] }
|
||||
documentRefreshWarning.value = { code: 'DOCUMENT_OVERVIEW_STALE', message: `${targetTitle}已上传,但资料汇总尚未同步;当前已保留本次上传结果` }
|
||||
}
|
||||
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, `${targetTitle}已上传,但最新资料刷新失败;当前已保留本次上传结果`) }
|
||||
return
|
||||
}
|
||||
documentOpen.value = false; documentFile.value = null; documentUploadKey.value += 1; ElMessage.success(`${documentTitle.value}已上传`)
|
||||
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, `${targetTitle}已上传,但最新资料刷新失败;当前已保留本次上传结果`) }
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, `${documentTitle.value}上传失败`)
|
||||
if (details.code === 'A0409') {
|
||||
if (documentType.value === 'CUSTOMS_DECLARATION') {
|
||||
try { await refreshOverviewDocument('CUSTOMS_DECLARATION') } catch { /* 保留原并发错误证据 */ }
|
||||
} else {
|
||||
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
|
||||
}
|
||||
}
|
||||
if (details.code === 'A0409') try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
|
||||
documentError.value = details
|
||||
}
|
||||
finally { documentSaving.value = false }
|
||||
}
|
||||
async function refreshCustomsFacts() {
|
||||
overview.value = await getContainerOverview(containerId.value)
|
||||
await loadOutboundDispatch()
|
||||
}
|
||||
async function completeCustomsDeclaration(document: ContainerOverviewDocument) {
|
||||
if (!canManageCustoms.value || customsCompleting.value || document.completed) return
|
||||
if (!overview.value?.loadingPlanId || !detail.value) {
|
||||
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法确认报关完成' }
|
||||
return
|
||||
}
|
||||
customsCompleting.value = true; customsActionError.value = null; documentRefreshWarning.value = null
|
||||
try {
|
||||
await ElMessageBox.confirm('请确认装柜清单的国内出口报关协同已经完成。附件上传与确认报关完成是两个步骤;上传附件本身不代表正式报关放行。', '确认报关完成', { type: 'warning', confirmButtonText: '确认完成' })
|
||||
} catch { customsCompleting.value = false; return }
|
||||
try {
|
||||
await completeLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, document.version, '货柜详情确认国内出口报关完成')
|
||||
try { await refreshCustomsFacts() }
|
||||
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '报关完成已确认,但最新资料刷新失败,请稍后重新加载') }
|
||||
ElMessage.success('已确认装柜清单报关协同完成;正式报关放行仍由报关业务事实核验')
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '确认报关完成失败')
|
||||
if (details.code === 'A0409') {
|
||||
try { await refreshCustomsFacts() } catch { /* 保留原并发错误证据 */ }
|
||||
}
|
||||
customsActionError.value = details
|
||||
} finally { customsCompleting.value = false }
|
||||
}
|
||||
async 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 downloadDocument(item: ContainerOverviewDocument) {
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
|
||||
if (item.documentType === 'CUSTOMS_DECLARATION' && (!overview.value?.loadingPlanId || !detail.value)) {
|
||||
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法下载国内出口报关附件' }
|
||||
return
|
||||
}
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
|
||||
documentDownloading.value = true
|
||||
try {
|
||||
const blob = item.documentType === 'CUSTOMS_DECLARATION'
|
||||
? await downloadLoadingDeclaration(overview.value!.loadingPlanId!, detail.value!.warehouseId, item.fileId)
|
||||
: item.documentType === 'DESTINATION_CLEARANCE'
|
||||
const blob = item.documentType === 'DESTINATION_CLEARANCE'
|
||||
? await downloadContainerClearance(containerId.value, item.fileId)
|
||||
: await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId)
|
||||
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '资料下载失败')
|
||||
if (item.documentType === 'CUSTOMS_DECLARATION') customsActionError.value = details
|
||||
if (item.documentType === 'DESTINATION_CLEARANCE') clearanceError.value = details
|
||||
ElMessage.error(details.message)
|
||||
}
|
||||
@@ -383,9 +322,9 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
|
||||
<template v-else-if="detail">
|
||||
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></section>
|
||||
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
||||
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-download-customs="canDownloadCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" :customs-completing="customsCompleting" @upload-document="openDocument" @download-document="downloadDocument" @complete-customs="completeCustomsDeclaration" @manage-exception="openException" />
|
||||
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" @upload-document="openDocument" @download-document="downloadDocument" @manage-exception="openException" />
|
||||
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
|
||||
<LoadErrorState v-if="customsActionError" compact title="国内出口报关附件操作失败" :message="customsActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="customsActionError" /></template></LoadErrorState>
|
||||
<ContainerCustomsPanel v-if="detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
|
||||
<section v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel surface-panel" aria-label="国内出仓门禁">
|
||||
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
|
||||
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
|
||||
|
||||
@@ -244,7 +244,8 @@ describe('ContainersView', () => {
|
||||
expect(source).not.toContain('uploadLoadingDeclaration')
|
||||
expect(source).not.toContain('uploadContainerClearance')
|
||||
expect(source).not.toContain('uploadContainerDocument')
|
||||
expect(detailSource).toContain('uploadLoadingDeclaration')
|
||||
expect(detailSource).not.toContain('uploadLoadingDeclaration')
|
||||
expect(detailSource).toContain('ContainerCustomsPanel')
|
||||
expect(detailSource).toContain('uploadContainerClearance')
|
||||
expect(detailSource).toContain('uploadContainerDocument')
|
||||
})
|
||||
@@ -426,7 +427,7 @@ describe('ContainersView', () => {
|
||||
describe('ContainersView trucking supplier contract', () => {
|
||||
it('shows the frozen plan, exception and document aggregates without deriving them client-side', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'customsDeclarationDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupOrderDocumentStatus']
|
||||
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupOrderDocumentStatus']
|
||||
let previous = source.indexOf("key: 'actions'")
|
||||
for (const field of fields) {
|
||||
const current = source.indexOf(`key: '${field}'`, previous + 1)
|
||||
|
||||
@@ -146,7 +146,6 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常' },
|
||||
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
|
||||
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) },
|
||||
{ key: 'customsDeclarationDocumentStatus', label: '国内出口报关附件', minWidth: 150, formatter: (row: ContainerBookingResource) => documentStatusText(row.customsDeclarationDocumentStatus) },
|
||||
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
|
||||
{ key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) },
|
||||
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
|
||||
|
||||
@@ -88,7 +88,7 @@ export interface BusinessDocument {
|
||||
version: number
|
||||
}
|
||||
|
||||
export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'CUSTOMS_DECLARATION' | 'DELIVERY_ORDER' | 'PICKUP_ORDER'
|
||||
export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'DELIVERY_ORDER' | 'PICKUP_ORDER'
|
||||
export type ContainerDocumentStatus = 'MISSING' | 'UPLOADED' | 'COMPLETED'
|
||||
|
||||
export interface ContainerOverviewDocument {
|
||||
|
||||
@@ -3,9 +3,6 @@ import { http } from '@/shared/api/http'
|
||||
import {
|
||||
getConfirmedLoadingList,
|
||||
confirmPreloadPlan,
|
||||
uploadLoadingDeclaration,
|
||||
completeLoadingDeclaration,
|
||||
downloadLoadingDeclaration,
|
||||
getPreloadPlan,
|
||||
listConfirmedLoadingLists,
|
||||
listPreloadPlans,
|
||||
@@ -162,28 +159,4 @@ describe('platform loading lists API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses stable plan and warehouse ids for customs document actions', async () => {
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { id: 6, documentType: 'CUSTOMS_DECLARATION', fileId: 9, fileName: 'declaration.pdf', version: 2 } })
|
||||
const file = new File(['declaration'], 'declaration.pdf', { type: 'application/pdf' })
|
||||
await uploadLoadingDeclaration('100', '300', file, 1, '上传资料')
|
||||
await completeLoadingDeclaration('100', '300', 2, '完成报关')
|
||||
expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100/customs/document/upload')
|
||||
const body = post.mock.calls[0]?.[1] as FormData
|
||||
expect(body.get('file')).toBe(file)
|
||||
expect(body.get('expectedVersion')).toBe('1')
|
||||
expect(body.get('operationReason')).toBe('上传资料')
|
||||
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { warehouseId: '300' }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
|
||||
expect(post.mock.calls[1]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100/customs/complete')
|
||||
})
|
||||
|
||||
it('downloads the current customs attachment through the exact loading-list endpoint with string ids', async () => {
|
||||
const blob = new Blob(['declaration'], { type: 'application/pdf' })
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue(blob)
|
||||
const result = await downloadLoadingDeclaration('2092438843630391401', '193000000000200002', '2092438843630391502')
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/loading-lists/2092438843630391401/customs/document/2092438843630391502/download',
|
||||
{ params: { warehouseId: '193000000000200002' }, responseType: 'blob' },
|
||||
)
|
||||
expect(result).toBe(blob)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,26 +126,10 @@ export interface LoadingHistory {
|
||||
occurredAt: string
|
||||
}
|
||||
|
||||
export interface BusinessDocument {
|
||||
id: string | null
|
||||
documentType: string
|
||||
fileId: string
|
||||
fileName: string
|
||||
fileHash: string | null
|
||||
uploadedBy: string | null
|
||||
uploadedByName: string | null
|
||||
uploadedAt: string | null
|
||||
completedBy: string | null
|
||||
completedByName: string | null
|
||||
completedAt: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface LoadingDetail<T extends LoadingListSummary = LoadingListSummary> {
|
||||
summary: T
|
||||
items: LoadingItem[]
|
||||
histories: LoadingHistory[]
|
||||
customsDocument?: BusinessDocument | null
|
||||
}
|
||||
|
||||
export interface LoadingListPage<T extends LoadingListSummary = LoadingListSummary> {
|
||||
@@ -271,11 +255,6 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDocument(raw: BusinessDocument | null | undefined): BusinessDocument | null {
|
||||
if (!raw) return null
|
||||
return { ...raw, id: raw.id == null ? null : requiredId(raw.id), fileId: requiredId(raw.fileId), uploadedBy: optionalId(raw.uploadedBy), completedBy: optionalId(raw.completedBy), version: Number(raw.version ?? 0) }
|
||||
}
|
||||
|
||||
function normalizePage<T extends LoadingListSummary>(raw: RawPage<T>, normalize: (row: T) => T): LoadingListPage<T> {
|
||||
return {
|
||||
records: (raw.records || []).map(normalize),
|
||||
@@ -308,7 +287,6 @@ export async function getPreloadPlan(planId: string, warehouseId: string) {
|
||||
summary: normalizePreloadSummary(result.summary),
|
||||
items: (result.items || []).map(normalizeItem),
|
||||
histories: (result.histories || []).map(normalizeHistory),
|
||||
customsDocument: normalizeDocument(result.customsDocument),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +301,6 @@ export async function updatePreloadTransport(planId: string, warehouseId: string
|
||||
summary: normalizePreloadSummary(result.summary),
|
||||
items: (result.items || []).map(normalizeItem),
|
||||
histories: (result.histories || []).map(normalizeHistory),
|
||||
customsDocument: normalizeDocument(result.customsDocument),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,32 +332,6 @@ export async function confirmPreloadPlan(planId: string, expectedVersion: number
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLoadingDeclaration(planId: string, warehouseId: string) {
|
||||
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs`, { params: { warehouseId } })
|
||||
return normalizeDocument(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function uploadLoadingDeclaration(planId: string, warehouseId: string, file: File, expectedVersion: number, operationReason?: string | null) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
body.append('expectedVersion', String(expectedVersion))
|
||||
if (operationReason) body.append('operationReason', operationReason)
|
||||
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/document/upload`, body, { params: { warehouseId }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': `loading-customs-upload-${planId}-${Date.now()}` } })
|
||||
return normalizeDocument(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function downloadLoadingDeclaration(planId: string, warehouseId: string, fileId: string) {
|
||||
return http.get<Blob, Blob>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/document/${encodeURIComponent(fileId)}/download`, {
|
||||
params: { warehouseId },
|
||||
responseType: 'blob',
|
||||
})
|
||||
}
|
||||
|
||||
export async function completeLoadingDeclaration(planId: string, warehouseId: string, expectedVersion: number, operationReason?: string | null) {
|
||||
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/complete`, { expectedVersion, operationReason }, { params: { warehouseId }, headers: { 'Idempotency-Key': `loading-customs-complete-${planId}-${Date.now()}` } })
|
||||
return normalizeDocument(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function listConfirmedLoadingLists(query: LoadingListQuery) {
|
||||
const response = await http.get<ApiEnvelope<RawPage<ConfirmedSummary>>, ApiEnvelope<RawPage<ConfirmedSummary>>>(
|
||||
`${basePath}/loading-lists`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ArrowLeft, Check, FileUp, Pencil } from '@lucide/vue'
|
||||
import { ArrowLeft, Check, Pencil } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -18,11 +18,9 @@ const props = defineProps<{
|
||||
error: ApiErrorDetails | null
|
||||
canEdit: boolean
|
||||
confirmSaving?: boolean
|
||||
canManageCustoms?: boolean
|
||||
customsSaving?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ back: []; retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
|
||||
const emit = defineEmits<{ back: []; retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: [] }>()
|
||||
|
||||
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 132, slotName: 'actions', hideable: false, movable: false }
|
||||
const baseItemColumns: DataTableColumn[] = [
|
||||
@@ -49,7 +47,6 @@ const baseItemColumns: DataTableColumn[] = [
|
||||
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 190, slotName: 'customerRemark' },
|
||||
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(内部)', minWidth: 190, slotName: 'internalRemark' },
|
||||
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 210, slotName: 'warehouseRemark' },
|
||||
{ key: 'customsDocument', label: '报关资料', minWidth: 170, slotName: 'customsDocument' },
|
||||
{ key: 'frozenAt', label: '冻结时间', minWidth: 150, slotName: 'frozenAt' },
|
||||
]
|
||||
|
||||
@@ -118,18 +115,13 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<el-button link type="primary" class="detail-page-header__back" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<div class="detail-page-header__identity">
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细、报关资料和柜次费用' }}</p>
|
||||
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细和柜次费用' }}</p>
|
||||
</div>
|
||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||
<template v-if="canEditItemRemark">
|
||||
<el-button @click="emit('edit')"><Pencil :size="16" />{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</el-button>
|
||||
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
|
||||
</template>
|
||||
<template v-else-if="mode === 'confirmed'">
|
||||
<el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" type="primary" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button>
|
||||
<el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button>
|
||||
<StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" />
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
<section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息">
|
||||
@@ -181,7 +173,6 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<template #internalRemark="{ row }"><div class="remark-cell"><span>{{ row.internalRemark || '—' }}</span><small>来源:订单内部备注</small></div></template>
|
||||
<template #warehouseRemark="{ row }"><div class="remark-cell"><span>{{ row.warehouseRemark || '—' }}</span><small>{{ mode === 'preload' ? '由仓库维护' : '装柜完成时冻结' }}</small></div></template>
|
||||
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
|
||||
<template #customsDocument><span class="document-cell">{{ detail.customsDocument?.fileName || '未上传' }}</span></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('LoadingListsView', () => {
|
||||
expect(source).toContain('LoadErrorState')
|
||||
expect(source).toContain('ApiErrorMeta')
|
||||
expect(preloadDetailSource).toContain('AppDialog')
|
||||
expect(confirmedDetailSource).toContain('AppDialog')
|
||||
expect(confirmedDetailSource).not.toContain('AppDialog')
|
||||
expect(detailSource).not.toContain('AppDrawer')
|
||||
expect(detailSource).toContain('ContainerFeesPanel')
|
||||
})
|
||||
@@ -81,8 +81,8 @@ describe('LoadingListsView', () => {
|
||||
it('only exposes transport editing for LOADING preload details', () => {
|
||||
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
|
||||
expect(detailSource).toContain('const itemColumns = computed(() => canEditItemRemark.value ? [actionColumn, ...baseItemColumns] : baseItemColumns)')
|
||||
expect(detailSource).toContain('报关资料')
|
||||
expect(detailSource).toContain('完成报关')
|
||||
expect(detailSource).not.toContain('报关资料')
|
||||
expect(detailSource).not.toContain('完成报关')
|
||||
expect(preloadDetailSource).toContain('expectedVersion: detail.value.summary.version')
|
||||
})
|
||||
|
||||
@@ -168,7 +168,6 @@ describe('LoadingListsView', () => {
|
||||
'customerRemark',
|
||||
'internalRemark',
|
||||
'warehouseRemark',
|
||||
'customsDocument',
|
||||
]
|
||||
let previous = detailSource.indexOf("key: 'customerName'")
|
||||
for (const field of fields) {
|
||||
@@ -234,15 +233,10 @@ describe('LoadingListsView', () => {
|
||||
expect(detailSource).not.toContain('history.operatorName || history.operatorId')
|
||||
})
|
||||
|
||||
it('uploads declaration documents from a file picker and freezes them after completion', () => {
|
||||
expect(confirmedDetailSource).toContain('<el-upload :auto-upload="false" :limit="1"')
|
||||
expect(confirmedDetailSource).toContain('@confirm="uploadCustomsDocument"')
|
||||
expect(confirmedDetailSource).toContain('uploadLoadingDeclaration(')
|
||||
expect(confirmedDetailSource).toContain('getLoadingDeclaration(planId.value, warehouseId.value)')
|
||||
expect(confirmedDetailSource).toContain("details.code === 'A0404'")
|
||||
expect(detailSource).toContain("canManageCustoms && !detail.customsDocument?.completedAt")
|
||||
expect(confirmedDetailSource).not.toContain('文件 ID')
|
||||
expect(confirmedDetailSource).not.toContain('fileId')
|
||||
expect(confirmedDetailSource).not.toContain('上传入口待')
|
||||
it('does not retain the retired loading-list customs entry', () => {
|
||||
expect(confirmedDetailSource).not.toContain('LoadingDeclaration')
|
||||
expect(confirmedDetailSource).not.toContain('customsDocument')
|
||||
expect(detailSource).not.toContain('customsDocument')
|
||||
expect(detailSource).not.toContain('报关资料')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { FileUp } from '@lucide/vue'
|
||||
import { ElMessage, type UploadFile } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||
import {
|
||||
completeLoadingDeclaration,
|
||||
getConfirmedLoadingList,
|
||||
getLoadingDeclaration,
|
||||
uploadLoadingDeclaration,
|
||||
type ConfirmedSummary,
|
||||
type LoadingDetail,
|
||||
} from '../api/platformLoadingLists'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const detail = ref<LoadingDetail<ConfirmedSummary> | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const customsDialogOpen = ref(false)
|
||||
const customsFile = ref<File | null>(null)
|
||||
const customsSaving = ref(false)
|
||||
const customsError = ref<ApiErrorDetails | null>(null)
|
||||
let loadSequence = 0
|
||||
const planId = computed(() => String(route.params.planId || ''))
|
||||
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
|
||||
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
|
||||
|
||||
async function loadDetail() {
|
||||
const sequence = ++loadSequence
|
||||
@@ -44,20 +30,13 @@ async function loadDetail() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [result, customsDocument] = await Promise.all([
|
||||
getConfirmedLoadingList(planId.value, warehouseId.value),
|
||||
getLoadingDeclaration(planId.value, warehouseId.value).catch((cause) => {
|
||||
const details = getApiErrorDetails(cause)
|
||||
if (details.code === 'A0404') return null
|
||||
throw cause
|
||||
}),
|
||||
])
|
||||
const result = await getConfirmedLoadingList(planId.value, warehouseId.value)
|
||||
if (sequence !== loadSequence) return
|
||||
if (result.summary.status !== 'COMPLETED') {
|
||||
error.value = { code: 'MODE_STATUS_MISMATCH', message: '该计划尚未形成正式装柜清单,请从预装清单入口查看。' }
|
||||
return
|
||||
}
|
||||
detail.value = { ...result, customsDocument }
|
||||
detail.value = result
|
||||
} catch (cause) {
|
||||
if (sequence !== loadSequence) return
|
||||
detail.value = null
|
||||
@@ -72,66 +51,6 @@ function goBack() {
|
||||
else void router.push({ name: 'loading-lists' })
|
||||
}
|
||||
|
||||
function openCustomsDocument() {
|
||||
customsFile.value = null
|
||||
customsError.value = null
|
||||
customsDialogOpen.value = true
|
||||
}
|
||||
|
||||
function selectCustomsFile(uploadFile: UploadFile) {
|
||||
customsFile.value = uploadFile.raw ?? null
|
||||
customsError.value = null
|
||||
}
|
||||
|
||||
function removeCustomsFile() {
|
||||
customsFile.value = null
|
||||
}
|
||||
|
||||
async function uploadCustomsDocument() {
|
||||
if (!detail.value || !customsFile.value) {
|
||||
customsError.value = { code: 'FILE_REQUIRED', message: '请选择报关资料文件' }
|
||||
return
|
||||
}
|
||||
customsSaving.value = true
|
||||
customsError.value = null
|
||||
try {
|
||||
detail.value.customsDocument = await uploadLoadingDeclaration(
|
||||
planId.value,
|
||||
warehouseId.value,
|
||||
customsFile.value,
|
||||
detail.value.customsDocument?.version || 0,
|
||||
'上传装柜清单报关资料',
|
||||
)
|
||||
customsFile.value = null
|
||||
customsDialogOpen.value = false
|
||||
ElMessage.success('报关资料已上传')
|
||||
} catch (cause) {
|
||||
customsError.value = getApiErrorDetails(cause, '报关资料上传失败')
|
||||
} finally {
|
||||
customsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function completeCustomsDocument() {
|
||||
if (!detail.value?.customsDocument) return
|
||||
customsSaving.value = true
|
||||
customsError.value = null
|
||||
try {
|
||||
detail.value.customsDocument = await completeLoadingDeclaration(
|
||||
planId.value,
|
||||
warehouseId.value,
|
||||
detail.value.customsDocument.version,
|
||||
'确认装柜清单报关完成',
|
||||
)
|
||||
ElMessage.success('已完成报关')
|
||||
} catch (cause) {
|
||||
customsError.value = getApiErrorDetails(cause, '报关完成失败')
|
||||
ElMessage.error(customsError.value.message)
|
||||
} finally {
|
||||
customsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void loadDetail())
|
||||
watch([planId, warehouseId], () => void loadDetail())
|
||||
</script>
|
||||
@@ -139,40 +58,16 @@ watch([planId, warehouseId], () => void loadDetail())
|
||||
<template>
|
||||
<AdminPage>
|
||||
<div class="confirmed-loading-detail-page">
|
||||
<OperationErrorToast :details="customsError" />
|
||||
<LoadingListDetailDrawer
|
||||
mode="confirmed"
|
||||
:loading="loading"
|
||||
:detail="detail"
|
||||
:error="error"
|
||||
:can-edit="false"
|
||||
:can-manage-customs="canManageCustoms"
|
||||
:customs-saving="customsSaving"
|
||||
@back="goBack"
|
||||
@retry="loadDetail"
|
||||
@attach-customs="openCustomsDocument"
|
||||
@complete-customs="completeCustomsDocument"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AppDialog
|
||||
v-model="customsDialogOpen"
|
||||
title="装柜清单报关资料"
|
||||
width="min(520px, calc(100vw - 28px))"
|
||||
confirm-text="上传资料"
|
||||
:confirm-loading="customsSaving"
|
||||
:confirm-disabled="!customsFile"
|
||||
@confirm="uploadCustomsDocument"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="报关资料">
|
||||
<el-upload :auto-upload="false" :limit="1" :on-change="selectCustomsFile" :on-remove="removeCustomsFile">
|
||||
<el-button><FileUp :size="16" />选择文件</el-button>
|
||||
<template #tip><div class="el-upload__tip">{{ detail?.customsDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的装柜清单报关资料。' }}</div></template>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user