优化
This commit is contained in:
@@ -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