优化
This commit is contained in:
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
getConfirmedLoadingList,
|
||||
uploadLoadingDeclaration,
|
||||
completeLoadingDeclaration,
|
||||
getPreloadPlan,
|
||||
listConfirmedLoadingLists,
|
||||
listPreloadPlans,
|
||||
@@ -89,4 +91,18 @@ describe('platform loading lists API', () => {
|
||||
expect(page.records[0]?.containerBookingId).toBe('1900000000000000004')
|
||||
expect(loaded.summary.status).toBe('COMPLETED')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,10 +95,26 @@ 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> {
|
||||
@@ -189,6 +205,10 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory {
|
||||
operatorId: requiredId(raw.operatorId),
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -222,6 +242,7 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,9 +257,29 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
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 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 { Pencil } from '@lucide/vue'
|
||||
import { Check, FileUp, 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'
|
||||
@@ -17,9 +17,11 @@ const props = defineProps<{
|
||||
detail: LoadingDetail | null
|
||||
error: ApiErrorDetails | null
|
||||
canEdit: boolean
|
||||
canManageCustoms?: boolean
|
||||
customsSaving?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ retry: []; edit: [] }>()
|
||||
const emit = defineEmits<{ retry: []; edit: []; attachCustoms: []; completeCustoms: [] }>()
|
||||
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false },
|
||||
@@ -37,6 +39,7 @@ const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'fbaTrackingNo', prop: 'fbaTrackingNo', label: 'FBA 追踪编码', minWidth: 155 },
|
||||
{ key: 'shipmentNo', prop: 'shipmentNo', label: '货件号', minWidth: 140 },
|
||||
{ key: 'shippingMark', prop: 'shippingMark', label: '唛头', minWidth: 130 },
|
||||
{ key: 'customsDocument', label: '报关资料', minWidth: 170, slotName: 'customsDocument' },
|
||||
{ key: 'frozenAt', label: '冻结时间', minWidth: 150, slotName: 'frozenAt' },
|
||||
]
|
||||
|
||||
@@ -99,7 +102,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<div v-if="mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)" class="detail-actions">
|
||||
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />编辑运输信息</el-button>
|
||||
</div>
|
||||
<el-alert v-else-if="mode === 'confirmed'" type="info" :closable="false" show-icon title="装柜完成后清单、明细和运输信息永久只读" />
|
||||
<div v-else-if="mode === 'confirmed'" class="detail-actions"><el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button><el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" type="primary" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button><StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" /></div>
|
||||
|
||||
<section class="detail-section surface-panel">
|
||||
<header class="detail-section__header"><h2>装柜货物明细</h2><span>{{ detail.items.length }} 条</span></header>
|
||||
@@ -117,6 +120,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
|
||||
<template #volume="{ row }">{{ formatNumber(row.volumeCbm) }}</template>
|
||||
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
|
||||
<template #customsDocument><span class="document-cell">{{ detail.customsDocument?.fileName || '未上传' }}</span></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ describe('LoadingListsView', () => {
|
||||
|
||||
it('only exposes transport editing for LOADING preload details', () => {
|
||||
expect(detailSource).toContain("mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)")
|
||||
expect(detailSource).toContain('装柜完成后清单、明细和运输信息永久只读')
|
||||
expect(detailSource).toContain('报关资料')
|
||||
expect(detailSource).toContain('完成报关')
|
||||
expect(source).toContain('expectedVersion: detail.value.summary.version')
|
||||
})
|
||||
|
||||
@@ -116,4 +117,14 @@ describe('LoadingListsView', () => {
|
||||
expect(detailSource).not.toContain('item.customerName || item.accountId')
|
||||
expect(detailSource).not.toContain('history.operatorName || history.operatorId')
|
||||
})
|
||||
|
||||
it('uploads declaration documents from a file picker and freezes them after completion', () => {
|
||||
expect(source).toContain('<el-upload :auto-upload="false" :limit="1"')
|
||||
expect(source).toContain('@confirm="uploadCustomsDocument"')
|
||||
expect(source).toContain('uploadLoadingDeclaration(selectedPlanId.value, selectedWarehouseId.value, customsFile.value')
|
||||
expect(detailSource).toContain("canManageCustoms && !detail.customsDocument?.completedAt")
|
||||
expect(source).not.toContain('文件 ID')
|
||||
expect(source).not.toContain('fileId')
|
||||
expect(source).not.toContain('上传入口待')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, Pencil } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
|
||||
import { Eye, FileUp, Pencil } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -16,6 +16,8 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { loadingListStatusLabel } from '../status'
|
||||
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
|
||||
import {
|
||||
uploadLoadingDeclaration,
|
||||
completeLoadingDeclaration,
|
||||
getConfirmedLoadingList,
|
||||
getPreloadPlan,
|
||||
listConfirmedLoadingLists,
|
||||
@@ -51,14 +53,18 @@ const editError = ref<ApiErrorDetails | null>(null)
|
||||
const editFormRef = ref<FormInstance>()
|
||||
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
|
||||
const handledDeepLink = ref('')
|
||||
const customsDialogOpen = ref(false)
|
||||
const customsFile = ref<File | null>(null)
|
||||
const customsSaving = ref(false)
|
||||
const customsError = ref<ApiErrorDetails | null>(null)
|
||||
|
||||
const isPreload = computed(() => props.mode === 'preload')
|
||||
const title = computed(() => isPreload.value ? '预装清单' : '装柜清单')
|
||||
const listPermission = computed(() => isPreload.value ? 'platform:firstleg:preload:list' : 'platform:firstleg:loading-list:list')
|
||||
const viewPermission = computed(() => isPreload.value ? 'platform:firstleg:preload:view' : 'platform:firstleg:loading-list:view')
|
||||
const canList = computed(() => access.can(listPermission.value))
|
||||
const canView = computed(() => access.can(viewPermission.value))
|
||||
const canView = computed(() => canList.value)
|
||||
const canEdit = computed(() => isPreload.value && access.can('platform:firstleg:preload:edit'))
|
||||
const canManageCustoms = computed(() => !isPreload.value && access.can('platform:firstleg:customs:manage'))
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/集装箱号/柜号' : '装柜清单号/集装箱号/柜号', width: 250 },
|
||||
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 260 },
|
||||
@@ -264,6 +270,19 @@ async function saveTransport() {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
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(selectedPlanId.value, selectedWarehouseId.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(selectedPlanId.value, selectedWarehouseId.value, detail.value.customsDocument.version, '确认装柜清单报关完成'); ElMessage.success('已完成报关') } catch (cause) { customsError.value = getApiErrorDetails(cause, '报关完成失败') } finally { customsSaving.value = false }
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => void load())
|
||||
watch(() => [route.query.planId, route.query.warehouseId], () => void openDeepLinkedDetail())
|
||||
@@ -332,10 +351,16 @@ onMounted(() => {
|
||||
:detail="detail"
|
||||
:error="detailError"
|
||||
:can-edit="canEdit"
|
||||
:can-manage-customs="canManageCustoms"
|
||||
:customs-saving="customsSaving"
|
||||
@retry="loadDetail"
|
||||
@edit="openEdit"
|
||||
@attach-customs="openCustomsDocument"
|
||||
@complete-customs="completeCustomsDocument"
|
||||
/>
|
||||
|
||||
<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><LoadErrorState v-if="customsError" compact title="操作失败" :message="customsError.message" :show-retry="false"><template #details><ApiErrorMeta :details="customsError" /></template></LoadErrorState></AppDialog>
|
||||
|
||||
<AppDialog
|
||||
v-model="editOpen"
|
||||
class="loading-transport-dialog"
|
||||
|
||||
@@ -5,7 +5,7 @@ describe('loading list routes', () => {
|
||||
it('declares preload list/detail and completed list entries with backend permissions', () => {
|
||||
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
|
||||
['loading-plans', 'platform:firstleg:preload:list'],
|
||||
['loading-plans/:planId', 'platform:firstleg:preload:view'],
|
||||
['loading-plans/:planId', 'platform:firstleg:preload:list'],
|
||||
['loading-lists', 'platform:firstleg:loading-list:list'],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ export const loadingListRoutes = [
|
||||
meta: {
|
||||
title: '预装清单详情',
|
||||
description: '查看预装清单基本信息、装柜货物明细和操作历史',
|
||||
permission: 'platform:firstleg:preload:view',
|
||||
permission: 'platform:firstleg:preload:list',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user