优化
This commit is contained in:
@@ -3,6 +3,8 @@ import { http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
|
||||
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem,
|
||||
getContainerClearance, uploadContainerClearance, completeContainerClearance,
|
||||
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
|
||||
updateContainer, updateContainerTransport,
|
||||
} from './platformContainerLoading'
|
||||
|
||||
@@ -58,4 +60,57 @@ describe('platform container loading API', () => {
|
||||
expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } })
|
||||
expect(vi.mocked(http.post).mock.calls[2]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.stringContaining('push-preload-') } })
|
||||
})
|
||||
|
||||
it('uses stable container id and versioned clearance document actions', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 2 } })
|
||||
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 3 } })
|
||||
expect((await getContainerClearance('7'))?.fileId).toBe('8')
|
||||
const file = new File(['clearance'], 'clearance.pdf', { type: 'application/pdf' })
|
||||
await uploadContainerClearance('7', file, 2, '上传资料')
|
||||
await completeContainerClearance('7', 3, '完成清关')
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/containers/7/clearance')
|
||||
expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe('/platform/v1/containers/7/clearance/document/upload')
|
||||
const body = vi.mocked(http.post).mock.calls[0]?.[1] as FormData
|
||||
expect(body.get('file')).toBe(file)
|
||||
expect(body.get('expectedVersion')).toBe('2')
|
||||
expect(body.get('operationReason')).toBe('上传资料')
|
||||
expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
|
||||
expect(vi.mocked(http.post).mock.calls[1]?.[0]).toBe('/platform/v1/containers/7/clearance/complete')
|
||||
})
|
||||
|
||||
it('normalizes aggregate ids and uses the frozen overview and exception contracts', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: {
|
||||
container: { id: 9007199254740993, warehouseId: 9007199254740994, version: '4' }, loadingPlanId: 9007199254740995,
|
||||
pda: { taskId: 9007199254740996, loadingPlanId: 9007199254740995, containerBookingId: 9007199254740993, taskVersion: '2', itemCount: '4', completedItemCount: '3', unresolvedItemCount: '1', photoCounts: [{ evidenceType: 'EMPTY_CONTAINER', count: '2' }], photos: [{ evidenceId: 20, tenantId: 21, inboundId: 22, fileId: 23, downloadPath: '/api/platform/v1/files/loading-evidence/21/22/23' }], exceptionReasons: [] },
|
||||
documents: [{ id: 9007199254740997, documentType: 'DELIVERY_ORDER', fileId: 9007199254740998, uploadedBy: 9, version: '2' }],
|
||||
exception: { active: false, markedBy: null }, exceptionHistory: [{ id: 10, operatorId: 11 }], timeline: [{ operatorId: 12 }],
|
||||
} })
|
||||
const result = await getContainerOverview('7')
|
||||
expect(result).toMatchObject({ container: { id: expect.any(String), warehouseId: expect.any(String), version: 4 }, loadingPlanId: expect.any(String), pda: { taskId: expect.any(String), itemCount: 4, photoCounts: [{ count: 2 }] }, documents: [{ fileId: expect.any(String), version: 2 }] })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/overview')
|
||||
|
||||
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: null })
|
||||
vi.mocked(http.delete).mockResolvedValueOnce({ code: '200', data: null })
|
||||
await markContainerException('7', 2, '查验')
|
||||
await cancelContainerException('7', 3, '已解除')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/containers/7/exception', { expectedVersion: 2, reason: '查验' }, { headers: { 'Idempotency-Key': expect.stringContaining('container-exception-mark-') } })
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/exception', { data: { expectedVersion: 3, reason: '已解除' }, headers: { 'Idempotency-Key': expect.stringContaining('container-exception-cancel-') } })
|
||||
})
|
||||
|
||||
it('uploads only DO or PU through the logistics endpoint and downloads through the protected proxy', async () => {
|
||||
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: { id: 1, documentType: 'DELIVERY_ORDER', fileId: 2, fileName: 'do.pdf', version: 1 } })
|
||||
vi.mocked(http.get).mockResolvedValueOnce(new Blob(['do']))
|
||||
const file = new File(['do'], 'do.pdf')
|
||||
await uploadContainerDocument('7', 'DELIVERY_ORDER', file, 0, '上传DO')
|
||||
await downloadContainerDocument('7', 'DELIVERY_ORDER', '2')
|
||||
expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe('/platform/v1/containers/7/documents/DELIVERY_ORDER/upload')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/documents/DELIVERY_ORDER/2/download', { responseType: 'blob' })
|
||||
})
|
||||
|
||||
it('downloads PDA photos only from the backend-provided protected evidence path', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(new Blob(['photo']))
|
||||
await downloadContainerPdaPhoto('/api/platform/v1/files/loading-evidence/21/22/23')
|
||||
expect(http.get).toHaveBeenCalledWith('/api/platform/v1/files/loading-evidence/21/22/23', { responseType: 'blob' })
|
||||
expect(() => downloadContainerPdaPhoto('https://example.com/photo.jpg')).toThrow('invalid container photo path')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
SmartLoadingCandidate,
|
||||
SmartLoadingItem,
|
||||
SmartLoadingPlan,
|
||||
BusinessDocument,
|
||||
ContainerDocumentType,
|
||||
ContainerOverview,
|
||||
ContainerOverviewDocument,
|
||||
} from '../types'
|
||||
|
||||
const containerPath = '/platform/v1/containers'
|
||||
@@ -19,6 +23,35 @@ function numberValue(value: unknown, fallback = 0): number {
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
function document(raw: BusinessDocument | null | undefined): BusinessDocument | null {
|
||||
if (!raw) return null
|
||||
return { ...raw, id: raw.id == null ? null : id(raw.id), fileId: id(raw.fileId), uploadedBy: raw.uploadedBy == null ? null : id(raw.uploadedBy), completedBy: raw.completedBy == null ? null : id(raw.completedBy), version: numberValue(raw.version) }
|
||||
}
|
||||
|
||||
function overviewDocument(raw: ContainerOverviewDocument): ContainerOverviewDocument {
|
||||
return { ...raw, id: id(raw.id), fileId: id(raw.fileId), uploadedBy: optionalId(raw.uploadedBy), version: numberValue(raw.version) }
|
||||
}
|
||||
|
||||
function overview(raw: ContainerOverview): ContainerOverview {
|
||||
return {
|
||||
...raw,
|
||||
container: { ...raw.container, id: id(raw.container.id), warehouseId: id(raw.container.warehouseId), version: numberValue(raw.container.version) },
|
||||
loadingPlanId: optionalId(raw.loadingPlanId),
|
||||
pda: raw.pda ? {
|
||||
...raw.pda, taskId: id(raw.pda.taskId), loadingPlanId: id(raw.pda.loadingPlanId),
|
||||
containerBookingId: id(raw.pda.containerBookingId), taskVersion: numberValue(raw.pda.taskVersion),
|
||||
itemCount: numberValue(raw.pda.itemCount), completedItemCount: numberValue(raw.pda.completedItemCount),
|
||||
unresolvedItemCount: numberValue(raw.pda.unresolvedItemCount),
|
||||
photoCounts: (raw.pda.photoCounts || []).map((item) => ({ ...item, count: numberValue(item.count) })),
|
||||
photos: (raw.pda.photos || []).map((item) => ({ ...item, evidenceId: id(item.evidenceId), tenantId: id(item.tenantId), inboundId: id(item.inboundId), fileId: id(item.fileId) })),
|
||||
exceptionReasons: raw.pda.exceptionReasons || [],
|
||||
} : null,
|
||||
documents: (raw.documents || []).map(overviewDocument),
|
||||
exception: { ...raw.exception, markedBy: optionalId(raw.exception?.markedBy) },
|
||||
exceptionHistory: (raw.exceptionHistory || []).map((entry) => ({ ...entry, id: id(entry.id), operatorId: optionalId(entry.operatorId) })),
|
||||
timeline: (raw.timeline || []).map((entry) => ({ ...entry, operatorId: optionalId(entry.operatorId) })),
|
||||
}
|
||||
}
|
||||
|
||||
function container(raw: ContainerBookingResource): ContainerBookingResource {
|
||||
return {
|
||||
@@ -30,6 +63,7 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
|
||||
version: numberValue(raw.version),
|
||||
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
|
||||
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
|
||||
clearanceDocument: document(raw.clearanceDocument),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +95,39 @@ export async function getContainer(containerId: string) {
|
||||
return container(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getContainerOverview(containerId: string) {
|
||||
const response = await http.get<ApiEnvelope<ContainerOverview>, ApiEnvelope<ContainerOverview>>(`${containerPath}/${encodeURIComponent(containerId)}/overview`)
|
||||
return overview(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function markContainerException(containerId: string, expectedVersion: number, reason: string) {
|
||||
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(`${containerPath}/${encodeURIComponent(containerId)}/exception`, { expectedVersion, reason }, { headers: { 'Idempotency-Key': operationId('container-exception-mark') } })
|
||||
unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function cancelContainerException(containerId: string, expectedVersion: number, reason: string) {
|
||||
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${containerPath}/${encodeURIComponent(containerId)}/exception`, { data: { expectedVersion, reason }, headers: { 'Idempotency-Key': operationId('container-exception-cancel') } })
|
||||
unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function uploadContainerDocument(containerId: string, documentType: Extract<ContainerDocumentType, 'DELIVERY_ORDER' | 'PICKUP_ORDER'>, 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>>(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/upload`, body, { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId(`container-${documentType.toLowerCase()}-upload`) } })
|
||||
return document(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export function downloadContainerDocument(containerId: string, documentType: Extract<ContainerDocumentType, 'DELIVERY_ORDER' | 'PICKUP_ORDER'>, fileId: string) {
|
||||
return http.get<Blob, Blob>(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export function downloadContainerPdaPhoto(downloadPath: string) {
|
||||
if (!downloadPath.startsWith('/api/platform/v1/files/loading-evidence/')) throw new Error('invalid container photo path')
|
||||
return http.get<Blob, Blob>(downloadPath, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export async function createContainer(payload: ContainerSaveRequest) {
|
||||
const response = await http.post<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(containerPath, payload)
|
||||
return container(unwrapApiResponse(response))
|
||||
@@ -76,6 +143,25 @@ export async function updateContainerTransport(containerId: string, version: num
|
||||
return container(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getContainerClearance(containerId: string) {
|
||||
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance`)
|
||||
return document(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function uploadContainerClearance(containerId: 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>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance/document/upload`, body, { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-clearance-upload') } })
|
||||
return document(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function completeContainerClearance(containerId: string, expectedVersion: number, operationReason?: string | null) {
|
||||
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance/complete`, { expectedVersion, operationReason }, { headers: { 'Idempotency-Key': operationId('container-clearance-complete') } })
|
||||
return document(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function listSmartLoadingCandidates(bookingId: string, warehouseId: string) {
|
||||
const response = await http.get<ApiEnvelope<SmartLoadingCandidate[]>, ApiEnvelope<SmartLoadingCandidate[]>>(`${smartLoadingPath}/candidates`, { params: { bookingId, warehouseId } })
|
||||
return unwrapApiResponse(response).map((raw) => ({
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ContainerOverviewPanel from './ContainerOverviewPanel.vue'
|
||||
import type { ContainerOverview } from '../types'
|
||||
|
||||
const overview: ContainerOverview = {
|
||||
container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', version: 2 },
|
||||
loadingPlanId: '9',
|
||||
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', taskVersion: 2, itemCount: 4, completedItemCount: 3, unresolvedItemCount: 1, photoCounts: [{ evidenceType: 'EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/files/loading-evidence/21/22/23', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
|
||||
documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }],
|
||||
exception: { active: true, reason: '码头查验', markedBy: '13', markedByName: '操作员', markedAt: '2026-08-13T11:00:00' },
|
||||
exceptionHistory: [{ id: '14', action: 'MARK', reason: '码头查验', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T11:00:00' }],
|
||||
timeline: [{ eventType: 'TRANSPORT_UPDATE', detail: '更新预计到港时间', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T12:00:00' }],
|
||||
}
|
||||
|
||||
describe('ContainerOverviewPanel', () => {
|
||||
it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', () => {
|
||||
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
||||
expect(wrapper.text()).toContain('DO 资料')
|
||||
expect(wrapper.text()).toContain('do.pdf')
|
||||
expect(wrapper.text()).toContain('PDA 作业进度')
|
||||
expect(wrapper.text()).toContain('一项待复核')
|
||||
expect(wrapper.text()).toContain('码头查验')
|
||||
expect(wrapper.text()).toContain('更新预计到港时间')
|
||||
})
|
||||
|
||||
it('emits stable document types and does not expose file ids in visible text', async () => {
|
||||
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
||||
const updateButton = wrapper.findAll('button').find((button) => button.text().includes('更新'))!
|
||||
await updateButton.trigger('click')
|
||||
expect(wrapper.emitted('uploadDocument')?.[0]).toEqual(['DELIVERY_ORDER'])
|
||||
expect(wrapper.text()).not.toContain('9007199254740999')
|
||||
})
|
||||
|
||||
it('emits the backend-provided photo object from an accessible view control', async () => {
|
||||
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
||||
await wrapper.find('button[aria-label="查看装柜照片 empty.jpg"]').trigger('click')
|
||||
expect(wrapper.emitted('previewPhoto')?.[0]?.[0]).toMatchObject({ evidenceId: '20', downloadPath: '/api/platform/v1/files/loading-evidence/21/22/23' })
|
||||
})
|
||||
|
||||
it('renders empty and forbidden/load failure states without exposing credentials', () => {
|
||||
const withoutPhotos = { ...overview, pda: overview.pda ? { ...overview.pda, photos: [] } : null }
|
||||
const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageException: false, photoError: { message: '当前账号无权访问该数据,请联系管理员确认权限', code: 'HTTP_403', requestId: 'req-photo' } }, global: { plugins: [ElementPlus] } })
|
||||
expect(wrapper.text()).toContain('暂无可查看的有效照片')
|
||||
expect(wrapper.text()).toContain('当前账号无权访问该数据')
|
||||
expect(wrapper.text()).toContain('req-photo')
|
||||
expect(wrapper.text()).not.toContain('Authorization')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Download, Eye, FileUp, TriangleAlert } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import type { ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
overview: ContainerOverview
|
||||
canManageDocuments: boolean
|
||||
canManageException: boolean
|
||||
documentBusy?: boolean
|
||||
photoBusyId?: string | null
|
||||
photoError?: ApiErrorDetails | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploadDocument: [documentType: string]
|
||||
downloadDocument: [document: ContainerOverviewDocument]
|
||||
manageException: []
|
||||
previewPhoto: [photo: ContainerPdaPhoto]
|
||||
}>()
|
||||
|
||||
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 downloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
|
||||
const pdaProgress = computed(() => {
|
||||
const pda = props.overview.pda
|
||||
if (!pda || pda.itemCount <= 0) return 0
|
||||
return Math.min(100, Math.round((pda.completedItemCount / pda.itemCount) * 100))
|
||||
})
|
||||
const activePhotos = computed(() => (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath))
|
||||
|
||||
function formatTime(value: string | null | undefined) {
|
||||
return value?.replace('T', ' ').slice(0, 16) || '—'
|
||||
}
|
||||
|
||||
function eventLabel(value: string) {
|
||||
return ({
|
||||
MARK: '标记异常', CANCEL: '取消异常', TRANSPORT_UPDATE: '更新运输信息',
|
||||
} as Record<string, string>)[value] || value || '状态更新'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overview-panel">
|
||||
<section class="overview-section" aria-label="货柜异常状态">
|
||||
<header><h3>异常状态</h3><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview.exception.active ? '取消异常' : '标记异常' }}</el-button></header>
|
||||
<el-alert v-if="overview.exception.active" type="error" :closable="false" show-icon><template #title>货柜已标记异常</template><p>{{ overview.exception.reason || '未填写异常原因' }}</p><small>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</small></el-alert>
|
||||
<el-empty v-else description="当前无货柜异常" :image-size="64" />
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="货柜业务资料">
|
||||
<header><h3>业务资料</h3><span>{{ overview.documents.length }} 份</span></header>
|
||||
<div class="document-grid">
|
||||
<article v-for="item in documentTypes" :key="item.type" class="document-item">
|
||||
<div><strong>{{ item.label }}</strong><span>{{ documentsByType.get(item.type)?.fileName || '未上传' }}</span><small v-if="documentsByType.get(item.type)">{{ documentsByType.get(item.type)?.uploadedByName || '上传人信息缺失' }} · {{ formatTime(documentsByType.get(item.type)?.uploadedAt) }}</small></div>
|
||||
<div class="document-actions"><el-button v-if="documentsByType.get(item.type) && downloadableTypes.has(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canManageDocuments && downloadableTypes.has(item.type)" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新' : '上传' }}</el-button></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="PDA作业摘要">
|
||||
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="overview.pda.taskStatus" /></header>
|
||||
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
|
||||
<template v-else><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div><div v-if="overview.pda.photoCounts.length" class="photo-summary"><span v-for="item in overview.pda.photoCounts" :key="item.evidenceType">{{ item.evidenceType }}:{{ item.count }}</span></div><div v-if="activePhotos.length" class="photo-list"><article v-for="photo in activePhotos" :key="photo.evidenceId"><div><strong>{{ photo.evidenceType }}</strong><span>{{ photo.fileName }}</span><small>{{ formatTime(photo.uploadedAt) }}</small></div><el-button size="small" :loading="photoBusyId === photo.evidenceId" :aria-label="`查看装柜照片 ${photo.fileName}`" @click="emit('previewPhoto', photo)"><Eye :size="15" />查看</el-button></article></div><el-empty v-else description="暂无可查看的有效照片" :image-size="48" /><LoadErrorState v-if="photoError" compact title="照片加载失败" :message="photoError.message" :show-retry="false"><template #details><ApiErrorMeta :details="photoError" /></template></LoadErrorState><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="运输轨迹和操作日志">
|
||||
<header><h3>运输轨迹与操作日志</h3><span>{{ overview.timeline.length }} 条</span></header>
|
||||
<el-empty v-if="overview.timeline.length === 0" description="暂无轨迹或操作记录" :image-size="64" />
|
||||
<el-timeline v-else><el-timeline-item v-for="(entry, index) in overview.timeline" :key="`${entry.eventType}-${entry.occurredAt}-${index}`" :timestamp="formatTime(entry.occurredAt)" placement="top"><strong>{{ eventLabel(entry.eventType) }}</strong><p>{{ entry.detail || '无补充说明' }}</p><small>{{ entry.operatorName || '系统' }}</small></el-timeline-item></el-timeline>
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="异常处理历史">
|
||||
<header><h3>异常处理历史</h3><span>{{ overview.exceptionHistory.length }} 条</span></header>
|
||||
<el-empty v-if="overview.exceptionHistory.length === 0" description="暂无异常处理历史" :image-size="64" />
|
||||
<el-timeline v-else><el-timeline-item v-for="history in overview.exceptionHistory" :key="history.id" :timestamp="formatTime(history.occurredAt)" placement="top"><strong>{{ eventLabel(history.action) }}</strong><p>{{ history.reason }}</p><small>{{ history.operatorName || '操作人信息缺失' }}</small></el-timeline-item></el-timeline>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overview-panel { display: grid; gap: 16px; margin-top: 16px; }
|
||||
.overview-section { min-width: 0; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.overview-section h3 { margin: 0; font-size: 15px; }
|
||||
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; }
|
||||
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: 13px; }
|
||||
.document-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.document-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--cc-border); }
|
||||
.document-item > div:first-child { display: grid; min-width: 0; gap: 4px; }
|
||||
.document-item span { overflow: hidden; color: var(--cc-muted); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.document-actions { display: flex; flex-shrink: 0; gap: 6px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 12px 0; }
|
||||
.metric-grid > div { display: grid; gap: 4px; padding: 10px; background: var(--cc-bg-subtle, #f7f8fa); }
|
||||
.metric-grid span { color: var(--cc-muted); font-size: 12px; }
|
||||
.metric-grid strong { font-size: 18px; }
|
||||
.photo-summary { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }.photo-summary span { padding: 4px 8px; border: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
|
||||
.photo-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin-bottom: 12px; }.photo-list article { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:8px; padding:10px; border:1px solid var(--cc-border); }.photo-list article > div { display:grid; min-width:0; gap:3px; }.photo-list span { overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
@media (max-width: 720px) { .document-grid,.photo-list { grid-template-columns: 1fr; } .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 420px) { .document-item { align-items: stretch; flex-direction: column; } .document-actions :deep(.el-button) { flex: 1; } }
|
||||
</style>
|
||||
@@ -9,14 +9,14 @@ import ContainersView from './ContainersView.vue'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { listPlatformPartners } from '@/modules/master-data'
|
||||
import { listOceanBookings } from '@/modules/ocean-bookings'
|
||||
import { listContainers } from '../api/platformContainerLoading'
|
||||
import { getContainer, getContainerOverview, listContainers } from '../api/platformContainerLoading'
|
||||
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookings: vi.fn() }))
|
||||
const accessCan = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), 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(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({ history: createMemoryHistory(), routes: [
|
||||
@@ -35,6 +35,8 @@ describe('ContainersView', () => {
|
||||
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
||||
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
|
||||
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
|
||||
})
|
||||
|
||||
it('loads the real paged contract and renders core container fields', async () => {
|
||||
@@ -115,25 +117,38 @@ describe('ContainersView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('排柜')
|
||||
expect(wrapper.text()).toContain('查看预装清单')
|
||||
expect(wrapper.text()).toContain('查看装柜清单')
|
||||
expect(wrapper.text()).toContain('预装清单')
|
||||
expect(wrapper.text()).toContain('装柜清单')
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().trim() === '排柜')!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('查看预装清单'))!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('查看装柜清单'))!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('预装清单'))!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('装柜清单'))!.trigger('click')
|
||||
expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } })
|
||||
expect(push).toHaveBeenNthCalledWith(2, { name: 'loading-plans', query: { planId: '9102', warehouseId: '8' } })
|
||||
expect(push).toHaveBeenNthCalledWith(3, { name: 'loading-lists', query: { planId: '9103', warehouseId: '8' } })
|
||||
})
|
||||
|
||||
it('keeps view and edit operations behind their own backend permissions', async () => {
|
||||
accessCan.mockImplementation((permission: string) => permission !== 'platform:firstleg:container:view')
|
||||
it('uses the list permission for detail while keeping edit independent', async () => {
|
||||
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:list')
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[aria-label="更多货柜操作"]').exists()).toBe(true)
|
||||
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not accept the removed standalone view permission', async () => {
|
||||
accessCan.mockReturnValue(false)
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(false)
|
||||
expect(wrapper.find('button[aria-label="更多货柜操作"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -190,4 +205,22 @@ describe('ContainersView', () => {
|
||||
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
|
||||
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
|
||||
})
|
||||
|
||||
it('uploads clearance documents from a file picker without exposing file ids', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain('<el-upload :auto-upload="false" :limit="1"')
|
||||
expect(source).toContain('@confirm="uploadClearance"')
|
||||
expect(source).toContain('uploadContainerClearance(detailTarget.value.id, clearanceFile.value')
|
||||
expect(source).toContain(':show-confirm="!clearanceDocument?.completedAt"')
|
||||
expect(source).toContain('<el-form v-else label-position="top"')
|
||||
expect(source).not.toContain('文件 ID')
|
||||
expect(source).not.toContain('上传入口待')
|
||||
})
|
||||
|
||||
it('does not expose manual container creation without an ocean-booking channel snapshot', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).not.toContain('新增货柜')
|
||||
expect(source).not.toContain('createContainer(')
|
||||
expect(source).not.toContain('openCreate')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Boxes, Eye, ListChecks, Pencil, Plus, Sparkles } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Boxes, Check, Eye, FileText, FileUp, ListChecks, MoreHorizontal, Pencil, Sparkles, TriangleAlert } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||||
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -16,8 +17,9 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createContainer, getContainer, listContainers, updateContainer, updateContainerTransport } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource, ContainerPlanStatus, ContainerSaveRequest } from '../types'
|
||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||
import { cancelContainerException, completeContainerClearance, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, listContainers, markContainerException, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest } from '../types'
|
||||
|
||||
type Filters = QueryModel & { keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
||||
type ContainerForm = ContainerSaveRequest & { id: string; version: number }
|
||||
@@ -45,6 +47,26 @@ const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<ContainerBookingResource | null>(null)
|
||||
const detailTarget = ref<ContainerBookingResource | null>(null)
|
||||
const clearanceDocument = ref<import('../types').BusinessDocument | null>(null)
|
||||
const clearanceLoading = ref(false)
|
||||
const clearanceActionLoading = ref(false)
|
||||
const clearanceError = ref<ApiErrorDetails | null>(null)
|
||||
const clearanceFile = ref<File | null>(null)
|
||||
const clearanceDialogOpen = ref(false)
|
||||
const overview = ref<ContainerOverview | null>(null)
|
||||
const overviewError = ref<ApiErrorDetails | null>(null)
|
||||
const documentDialogOpen = ref(false)
|
||||
const documentType = ref<ContainerDocumentType>('DELIVERY_ORDER')
|
||||
const documentFile = ref<File | null>(null)
|
||||
const documentSaving = ref(false)
|
||||
const documentError = ref<ApiErrorDetails | null>(null)
|
||||
const documentDownloading = ref(false)
|
||||
const photoBusyId = ref<string | null>(null)
|
||||
const photoError = ref<ApiErrorDetails | null>(null)
|
||||
const exceptionDialogOpen = ref(false)
|
||||
const exceptionReason = ref('')
|
||||
const exceptionSaving = ref(false)
|
||||
const exceptionError = ref<ApiErrorDetails | null>(null)
|
||||
const editorOpen = ref(false)
|
||||
const editorError = ref<ApiErrorDetails | null>(null)
|
||||
const saving = ref(false)
|
||||
@@ -70,13 +92,16 @@ const rules: FormRules<ContainerForm> = {
|
||||
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const canCreate = computed(() => access.can('platform:firstleg:container:create'))
|
||||
const canView = computed(() => access.can('platform:firstleg:container:view'))
|
||||
const canView = computed(() => access.can('platform:firstleg:container:list'))
|
||||
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
|
||||
const canCustomsManage = computed(() => access.can('platform:firstleg:customs:manage'))
|
||||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const editorTitle = computed(() => form.id ? '编辑' : '新增')
|
||||
const selectedOverviewDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
||||
const documentDialogTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', DESTINATION_CLEARANCE: '目的港清关资料', CUSTOMS_DECLARATION: '报关资料' })[documentType.value])
|
||||
const exceptionDialogTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
|
||||
const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING')
|
||||
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
|
||||
const truckingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.some((type) => ['CARRIER', 'SUPPLIER'].includes(type))))
|
||||
@@ -118,11 +143,18 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
|
||||
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
|
||||
{ key: 'terminalTruckingCompany', prop: 'terminalTruckingCompany', label: '码头车队', minWidth: 160 },
|
||||
{ key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 150 },
|
||||
{ key: 'route', label: '航线', minWidth: 220, slotName: 'route' },
|
||||
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 100 },
|
||||
{ key: 'capacity', label: '容量上限', minWidth: 150, slotName: 'capacity' },
|
||||
{ key: 'vessel', label: '船名/航次', minWidth: 180, slotName: 'vessel' },
|
||||
{ key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150 },
|
||||
{ key: 'loadingAt', label: '装柜时间', minWidth: 160, slotName: 'loadingAt' },
|
||||
{ key: 'estimatedSailingAt', label: '预计开船', minWidth: 160, slotName: 'estimatedSailingAt' },
|
||||
{ key: 'actualSailingAt', label: '实际开船', minWidth: 160, slotName: 'actualSailingAt' },
|
||||
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
|
||||
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' },
|
||||
]
|
||||
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
@@ -251,13 +283,101 @@ function search() { applied.value = { ...filters.value, sailingRange: [...filter
|
||||
async function openDetail(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
detailTarget.value = row
|
||||
detailOpen.value = true; detail.value = null; detailError.value = null; detailLoading.value = true
|
||||
detailOpen.value = true; detail.value = null; overview.value = null; detailError.value = null; overviewError.value = null; detailLoading.value = true
|
||||
const sequence = ++detailSequence
|
||||
try { const result = await getContainer(row.id); if (sequence === detailSequence) detail.value = result }
|
||||
try { const [result, aggregate] = await Promise.all([getContainer(row.id), getContainerOverview(row.id)]); if (sequence === detailSequence) { detail.value = result; overview.value = aggregate; clearanceDocument.value = null } }
|
||||
catch (error) { if (sequence === detailSequence) detailError.value = getApiErrorDetails(error, '货柜详情加载失败') }
|
||||
finally { if (sequence === detailSequence) detailLoading.value = false }
|
||||
}
|
||||
function openCreate() { detail.value = null; editorError.value = null; replaceForm(null); editorOpen.value = true }
|
||||
function handleMoreAction(command: string, source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
if (command === 'overview') { void openDetail(row); return }
|
||||
detailTarget.value = row
|
||||
void openDetail(row).then(() => {
|
||||
if (command === 'exception') openExceptionDialog()
|
||||
if (command === 'documents') openDocumentDialog('DELIVERY_ORDER')
|
||||
})
|
||||
}
|
||||
function openDocumentDialog(type: string) {
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type)) {
|
||||
if (type === 'DESTINATION_CLEARANCE' && detailTarget.value) void openClearance(detailTarget.value)
|
||||
return
|
||||
}
|
||||
documentType.value = type as ContainerDocumentType
|
||||
documentFile.value = null
|
||||
documentError.value = null
|
||||
documentDialogOpen.value = true
|
||||
}
|
||||
function selectDocumentFile(uploadFile: UploadFile) { documentFile.value = uploadFile.raw ?? null; documentError.value = null }
|
||||
function removeDocumentFile() { documentFile.value = null }
|
||||
async function saveDocument() {
|
||||
if (!detailTarget.value || !overview.value || !documentFile.value) { documentError.value = { message: '请选择需要上传的资料文件', code: 'FILE_REQUIRED' }; return }
|
||||
documentSaving.value = true; documentError.value = null
|
||||
try {
|
||||
const version = selectedOverviewDocument.value?.version || 0
|
||||
if (documentType.value === 'DESTINATION_CLEARANCE') await uploadContainerClearance(detailTarget.value.id, documentFile.value, version, '上传目的港清关资料')
|
||||
else if (documentType.value === 'CUSTOMS_DECLARATION') {
|
||||
if (!overview.value.loadingPlanId) throw new Error('missing loading plan')
|
||||
await uploadLoadingDeclaration(overview.value.loadingPlanId, detailTarget.value.warehouseId, documentFile.value, version, '上传装柜清单报关资料')
|
||||
} else await uploadContainerDocument(detailTarget.value.id, documentType.value, documentFile.value, version, `上传${documentDialogTitle.value}`)
|
||||
overview.value = await getContainerOverview(detailTarget.value.id)
|
||||
documentDialogOpen.value = false; documentFile.value = null; ElMessage.success(`${documentDialogTitle.value}已上传`)
|
||||
} catch (error) { documentError.value = getApiErrorDetails(error, `${documentDialogTitle.value}上传失败`) }
|
||||
finally { documentSaving.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 (!detailTarget.value) return
|
||||
documentDownloading.value = true
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(item.documentType)) return
|
||||
try { await saveBlob(await downloadContainerDocument(detailTarget.value.id, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId), item.fileName); ElMessage.success('资料下载已开始') }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '资料下载失败').message) }
|
||||
finally { documentDownloading.value = false }
|
||||
}
|
||||
async function previewPhoto(photo: ContainerPdaPhoto) {
|
||||
photoBusyId.value = photo.evidenceId; photoError.value = null
|
||||
try {
|
||||
const url = URL.createObjectURL(await downloadContainerPdaPhoto(photo.downloadPath))
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
} catch (error) { photoError.value = getApiErrorDetails(error, '装柜照片加载失败') }
|
||||
finally { photoBusyId.value = null }
|
||||
}
|
||||
function openExceptionDialog() { exceptionReason.value = ''; exceptionError.value = null; exceptionDialogOpen.value = true }
|
||||
async function saveException() {
|
||||
if (!detailTarget.value || !detail.value || !overview.value || !exceptionReason.value.trim()) { exceptionError.value = { message: '请输入异常原因或取消备注', code: 'REASON_REQUIRED' }; return }
|
||||
exceptionSaving.value = true; exceptionError.value = null
|
||||
try {
|
||||
const action = overview.value.exception.active ? cancelContainerException : markContainerException
|
||||
await action(detailTarget.value.id, overview.value.container.version, exceptionReason.value.trim())
|
||||
overview.value = await getContainerOverview(detailTarget.value.id)
|
||||
exceptionDialogOpen.value = false; ElMessage.success(overview.value.exception.active ? '货柜已标记异常' : '货柜异常已取消')
|
||||
} catch (error) { exceptionError.value = getApiErrorDetails(error, '货柜异常处理失败') }
|
||||
finally { exceptionSaving.value = false }
|
||||
}
|
||||
async function openClearance(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
detailTarget.value = row
|
||||
clearanceDialogOpen.value = true; clearanceError.value = null; clearanceFile.value = null; clearanceLoading.value = true
|
||||
try { clearanceDocument.value = await getContainerClearance(row.id) } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关资料加载失败') } finally { clearanceLoading.value = false }
|
||||
}
|
||||
function selectClearanceFile(uploadFile: UploadFile) { clearanceFile.value = uploadFile.raw ?? null; clearanceError.value = null }
|
||||
function removeClearanceFile() { clearanceFile.value = null }
|
||||
async function uploadClearance() {
|
||||
if (!detailTarget.value || !clearanceFile.value) { clearanceError.value = { message: '请选择清关资料文件', code: 'FILE_REQUIRED' }; return }
|
||||
clearanceActionLoading.value = true; clearanceError.value = null
|
||||
try { clearanceDocument.value = await uploadContainerClearance(detailTarget.value.id, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); clearanceFile.value = null; clearanceDialogOpen.value = false; ElMessage.success('清关资料已上传') } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关资料上传失败') } finally { clearanceActionLoading.value = false }
|
||||
}
|
||||
async function completeClearance() {
|
||||
if (!detailTarget.value || !clearanceDocument.value) return
|
||||
clearanceActionLoading.value = true; clearanceError.value = null
|
||||
try { clearanceDocument.value = await completeContainerClearance(detailTarget.value.id, clearanceDocument.value.version, '确认目的港清关完成'); ElMessage.success('已完成清关') } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关完成失败') } finally { clearanceActionLoading.value = false }
|
||||
}
|
||||
async function openEdit(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
editorError.value = null
|
||||
@@ -269,10 +389,9 @@ async function save() {
|
||||
saving.value = true
|
||||
editorError.value = null
|
||||
try {
|
||||
if (!form.id) await createContainer(payload())
|
||||
else if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) })
|
||||
if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) })
|
||||
else await updateContainer(form.id, form.version, payload())
|
||||
ElMessage.success(form.id ? '货柜已更新' : '货柜已新增'); editorOpen.value = false; await loadRows()
|
||||
ElMessage.success('货柜已更新'); editorOpen.value = false; await loadRows()
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '保存失败')
|
||||
if (details.code === 'A0409' && form.id) {
|
||||
@@ -324,24 +443,33 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadBusinessOptions(), loa
|
||||
</template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增货柜</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? '录入集装箱号' : '编辑' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />查看预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />查看装柜清单</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? '录入集装箱号' : '编辑' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>{{ row.bookingNo }}</small></template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
|
||||
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
|
||||
<template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template>
|
||||
<template #vessel="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') || '—' }}</template>
|
||||
<template #loadingAt="{ row }">{{ formatTime(row.loadingAt) }}</template>
|
||||
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
|
||||
<template #actualSailingAt="{ row }">{{ formatTime(row.actualSailingAt) }}</template>
|
||||
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
|
||||
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualPortArrivalAt) }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer v-model="detailOpen" title="货柜详情" size="min(760px, 94vw)" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
|
||||
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<el-descriptions v-else-if="detail" :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions>
|
||||
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDialog v-model="clearanceDialogOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :confirm-loading="clearanceActionLoading" :confirm-disabled="!clearanceFile" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><el-form v-else label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="clearanceError" compact title="操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
||||
|
||||
<AppDialog v-model="documentDialogOpen" :title="documentDialogTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentDialogTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedOverviewDocument ? '上传新文件将替换当前资料并保留操作历史。' : `请选择需要提交的${documentDialogTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
|
||||
<AppDialog v-model="exceptionDialogOpen" :title="exceptionDialogTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="exceptionError" /></template></LoadErrorState></AppDialog>
|
||||
|
||||
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="确定" cancel-text="关闭" @confirm="save">
|
||||
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
||||
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
|
||||
@@ -373,6 +501,7 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadBusinessOptions(), loa
|
||||
|
||||
<style scoped>
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }
|
||||
.document-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 16px; padding: 14px 16px; border: 1px solid var(--cc-border); background: #fff; }.document-panel > div { display: grid; gap: 4px; min-width: 0; }.document-panel span { overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; }
|
||||
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
|
||||
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
|
||||
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
|
||||
|
||||
@@ -34,6 +34,98 @@ export interface ContainerBookingResource {
|
||||
version: number
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
clearanceDocument?: BusinessDocument | null
|
||||
}
|
||||
|
||||
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 type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'CUSTOMS_DECLARATION' | 'DELIVERY_ORDER' | 'PICKUP_ORDER'
|
||||
|
||||
export interface ContainerOverviewDocument {
|
||||
id: string
|
||||
documentType: ContainerDocumentType | string
|
||||
fileId: string
|
||||
fileName: string
|
||||
fileHash: string | null
|
||||
uploadedBy: string | null
|
||||
uploadedByName: string | null
|
||||
uploadedAt: string | null
|
||||
version: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export interface ContainerPdaSummary {
|
||||
taskId: string
|
||||
loadingPlanId: string
|
||||
containerBookingId: string
|
||||
taskStatus: string
|
||||
taskVersion: number
|
||||
itemCount: number
|
||||
completedItemCount: number
|
||||
unresolvedItemCount: number
|
||||
photoCounts: Array<{ evidenceType: string; count: number }>
|
||||
photos: ContainerPdaPhoto[]
|
||||
exceptionReasons: string[]
|
||||
}
|
||||
|
||||
export interface ContainerPdaPhoto {
|
||||
evidenceId: string
|
||||
tenantId: string
|
||||
inboundId: string
|
||||
evidenceType: string
|
||||
fileId: string
|
||||
fileName: string
|
||||
status: string
|
||||
downloadPath: string
|
||||
uploadedAt: string | null
|
||||
}
|
||||
|
||||
export interface ContainerExceptionState {
|
||||
active: boolean
|
||||
reason: string | null
|
||||
markedBy: string | null
|
||||
markedByName: string | null
|
||||
markedAt: string | null
|
||||
}
|
||||
|
||||
export interface ContainerExceptionHistory {
|
||||
id: string
|
||||
action: string
|
||||
reason: string
|
||||
operatorId: string | null
|
||||
operatorName: string | null
|
||||
occurredAt: string | null
|
||||
}
|
||||
|
||||
export interface ContainerTimelineEntry {
|
||||
eventType: string
|
||||
detail: string | null
|
||||
operatorId: string | null
|
||||
operatorName: string | null
|
||||
occurredAt: string | null
|
||||
}
|
||||
|
||||
export interface ContainerOverview {
|
||||
container: Pick<ContainerBookingResource, 'id' | 'bookingNo' | 'cabinetNo' | 'containerNo' | 'warehouseId' | 'warehouseName' | 'voyageNo' | 'vesselName' | 'version'> & { status: string }
|
||||
loadingPlanId: string | null
|
||||
pda: ContainerPdaSummary | null
|
||||
documents: ContainerOverviewDocument[]
|
||||
exception: ContainerExceptionState
|
||||
exceptionHistory: ContainerExceptionHistory[]
|
||||
timeline: ContainerTimelineEntry[]
|
||||
}
|
||||
|
||||
export interface ContainerPage {
|
||||
|
||||
Reference in New Issue
Block a user