This commit is contained in:
2026-08-28 16:54:02 +08:00
parent 94f003153d
commit 813763a02a
16 changed files with 413 additions and 883 deletions
@@ -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>