feat(admin): bind booking documents and truck containers
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createOceanBooking, deleteOceanBookingDraft, listOceanBookingAgents, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings'
|
||||
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
|
||||
import { http } from '@/shared/api/http'
|
||||
|
||||
const detailResponse = (version = 0, status = 'DRAFT') => ({
|
||||
@@ -9,6 +9,7 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({
|
||||
parties: [],
|
||||
containerRequirements: [{ id: 9007199254740994, oceanBookingId: 123, containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }],
|
||||
containers: [{ containerBookingId: 9007199254740993, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', requirementId: 9007199254740994, allocationId: 9007199254740995, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, warehouseId: 9007199254740996, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', customerId: 456, customerName: '客户' }],
|
||||
documents: [{ id: 9007199254740997, oceanBookingId: 123, fileId: 9007199254740998, documentType: 'BOOKING_CONFIRMATION', fileName: '订舱确认.pdf', fileHash: null, sizeBytes: '128', contentType: 'application/pdf', valid: true, uploadedBy: 7, uploadedByName: '平台', uploadedAt: '2026-08-31T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null }],
|
||||
histories: [],
|
||||
})
|
||||
|
||||
@@ -48,6 +49,7 @@ describe('ocean bookings api', () => {
|
||||
expect(created.status).toBe('DRAFT')
|
||||
expect(created.containerRequirements[0]).toMatchObject({ id: '9007199254740994', requirementId: '9007199254740994', quantity: 2 })
|
||||
expect(created.containers[0]).toMatchObject({ containerBookingId: '9007199254740992', requirementId: '9007199254740994' })
|
||||
expect(created.documents[0]).toMatchObject({ id: '9007199254740996', oceanBookingId: '123', fileId: '9007199254740998', sizeBytes: 128, uploadedBy: '7' })
|
||||
})
|
||||
|
||||
it('uses draft update, delete and release contracts without legacy actions', async () => {
|
||||
@@ -79,4 +81,45 @@ describe('ocean bookings api', () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] })
|
||||
await expect(listOceanBookingAgents('HEDE')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('downloads both booking PDF documents from their dedicated public endpoints', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
|
||||
await downloadOceanBookingPrint('123/456', 'SHIPPING_INSTRUCTION')
|
||||
await downloadOceanBookingPrint('123/456', 'BILL_OF_LADING_CONFIRMATION')
|
||||
expect(get).toHaveBeenNthCalledWith(1, '/platform/v1/ocean-bookings/123%2F456/prints/shipping-instruction', { responseType: 'blob' })
|
||||
expect(get).toHaveBeenNthCalledWith(2, '/platform/v1/ocean-bookings/123%2F456/prints/bill-of-lading-confirmation', { responseType: 'blob' })
|
||||
})
|
||||
|
||||
it('uses the booking-owned document list, upload, reference, invalidate and download contracts', async () => {
|
||||
const rawDocument = { id: 9007199254740993n, oceanBookingId: 123, fileId: 9007199254740995n, documentType: 'BOOKING_CONFIRMATION', fileName: '订舱确认.pdf', fileHash: null, sizeBytes: '128', contentType: 'application/pdf', valid: true, uploadedBy: 7, uploadedByName: '平台', uploadedAt: '2026-08-31T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null }
|
||||
const get = vi.spyOn(http, 'get')
|
||||
.mockResolvedValueOnce({ code: '200', data: [rawDocument] })
|
||||
.mockResolvedValueOnce(new Blob(['pdf'], { type: 'application/pdf' }))
|
||||
const post = vi.spyOn(http, 'post')
|
||||
.mockResolvedValueOnce({ code: '200', data: { document: rawDocument, bookingVersion: '3' } })
|
||||
.mockResolvedValueOnce({ code: '200', data: { document: rawDocument, bookingVersion: '4' } })
|
||||
const remove = vi.spyOn(http, 'delete').mockResolvedValue({ code: '200', data: { documentId: 9007199254740993n, bookingVersion: '5' } })
|
||||
|
||||
await expect(listOceanBookingDocuments('123')).resolves.toMatchObject([{ id: '9007199254740993', oceanBookingId: '123', fileId: '9007199254740995', sizeBytes: 128, uploadedBy: '7' }])
|
||||
await expect(referenceOceanBookingDocument('123', 2, '9007199254740995', 'BOOKING_CONFIRMATION')).resolves.toMatchObject({ bookingVersion: 3 })
|
||||
const file = new File(['bill'], '提单.pdf', { type: 'application/pdf' })
|
||||
await expect(uploadOceanBookingDocument('123', 3, 'BILL_OF_LADING', file)).resolves.toMatchObject({ bookingVersion: 4 })
|
||||
const form = post.mock.calls[1]?.[1] as FormData
|
||||
expect(form.get('expectedVersion')).toBe('3')
|
||||
expect(form.get('documentType')).toBe('BILL_OF_LADING')
|
||||
expect(form.get('file')).toBe(file)
|
||||
await expect(invalidateOceanBookingDocument('123', '9007199254740993', 4)).resolves.toEqual({ documentId: '9007199254740993', bookingVersion: 5 })
|
||||
await downloadOceanBookingDocument('123', '9007199254740993')
|
||||
|
||||
expect(get).toHaveBeenNthCalledWith(1, '/platform/v1/ocean-bookings/123/documents')
|
||||
expect(post).toHaveBeenNthCalledWith(1, '/platform/v1/ocean-bookings/123/documents', { expectedVersion: 2, fileId: '9007199254740995', documentType: 'BOOKING_CONFIRMATION' }, { headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/platform/v1/ocean-bookings/123/documents/upload', expect.any(FormData), { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
|
||||
expect(remove).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/documents/9007199254740993', { data: { expectedVersion: 4 }, headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
expect(get).toHaveBeenNthCalledWith(2, '/platform/v1/ocean-bookings/123/documents/9007199254740993/download', { responseType: 'blob' })
|
||||
})
|
||||
|
||||
it('preserves request and trace ids when a PDF endpoint returns a JSON business envelope as a blob', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'B0409', message: '订舱资料不完整', requestId: 'req-pdf', traceId: 'trace-pdf' })], { type: 'application/json' }))
|
||||
await expect(downloadOceanBookingPrint('123', 'SHIPPING_INSTRUCTION')).rejects.toMatchObject({ code: 'B0409', message: '订舱资料不完整', requestId: 'req-pdf', traceId: 'trace-pdf' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
|
||||
import axios from 'axios'
|
||||
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingDocument, OceanBookingDocumentInvalidation, OceanBookingDocumentMutation, OceanBookingDocumentType, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingPrintType, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
|
||||
|
||||
const basePath = '/platform/v1/ocean-bookings'
|
||||
const maxErrorEnvelopeBytes = 1024 * 1024
|
||||
|
||||
export interface OceanLogisticsChannel {
|
||||
channelCode: string
|
||||
@@ -51,6 +53,7 @@ function detail(raw: OceanBookingDetail): OceanBookingDetail {
|
||||
capacityCbm: Number(container.capacityCbm),
|
||||
maxWeightKg: Number(container.maxWeightKg),
|
||||
})),
|
||||
documents: (raw.documents || []).map(document),
|
||||
histories,
|
||||
statusHistory: histories,
|
||||
}
|
||||
@@ -87,7 +90,74 @@ export async function createOceanBooking(payload: OceanBookingSaveRequest) { con
|
||||
export async function updateOceanBookingDraft(id: string, payload: OceanBookingSaveRequest) { const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
export async function deleteOceanBookingDraft(id: string, expectedVersion: number) { await http.delete(`${basePath}/${encodeURIComponent(id)}`, { data: { expectedVersion }, headers: { 'Idempotency-Key': idempotencyKey() } }) }
|
||||
export async function releaseOceanBooking(id: string, request: OceanBookingReleaseRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/release`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
|
||||
export async function printOceanBooking(id: string, type: 'SHIPPING_INSTRUCTION' | 'BILL_CONFIRMATION') { return http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(id)}/print`, { params: { type }, responseType: 'blob' }) }
|
||||
const printPaths: Record<OceanBookingPrintType, string> = {
|
||||
SHIPPING_INSTRUCTION: 'shipping-instruction',
|
||||
BILL_OF_LADING_CONFIRMATION: 'bill-of-lading-confirmation',
|
||||
}
|
||||
function document(raw: OceanBookingDocument): OceanBookingDocument {
|
||||
return {
|
||||
...raw,
|
||||
id: String(raw.id),
|
||||
oceanBookingId: String(raw.oceanBookingId),
|
||||
fileId: String(raw.fileId),
|
||||
sizeBytes: raw.sizeBytes == null ? null : Number(raw.sizeBytes),
|
||||
uploadedBy: String(raw.uploadedBy),
|
||||
invalidatedBy: raw.invalidatedBy == null ? null : String(raw.invalidatedBy),
|
||||
}
|
||||
}
|
||||
export async function downloadOceanBookingPrint(id: string, type: OceanBookingPrintType) {
|
||||
return requestBlob(`${basePath}/${encodeURIComponent(id)}/prints/${printPaths[type]}`)
|
||||
}
|
||||
async function parseBlobError(blob: Blob, status?: number) {
|
||||
if (!blob.type.toLowerCase().includes('json')) return null
|
||||
if (blob.size > maxErrorEnvelopeBytes) return new ApiResponseError('文件接口返回了超出限制的 JSON 响应', 'INVALID_FILE_RESPONSE', status)
|
||||
try {
|
||||
const payload = JSON.parse(await blob.text()) as Partial<ApiEnvelope<unknown>>
|
||||
return new ApiResponseError(payload.code === '200' ? '文件接口未返回文件内容' : payload.message || '文件请求失败', payload.code === '200' ? 'INVALID_FILE_RESPONSE' : payload.code || (status ? `HTTP_${status}` : 'FILE_REQUEST_FAILED'), status, payload.data, payload.requestId, payload.traceId)
|
||||
} catch {
|
||||
return new ApiResponseError('文件接口返回了无法识别的 JSON 响应', 'INVALID_FILE_RESPONSE', status)
|
||||
}
|
||||
}
|
||||
async function requestBlob(path: string) {
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(path, { responseType: 'blob' })
|
||||
const responseError = await parseBlobError(blob, 200)
|
||||
if (responseError) throw responseError
|
||||
return blob
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
|
||||
const responseError = await parseBlobError(error.response.data, error.response.status)
|
||||
if (responseError) throw responseError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
export async function listOceanBookingDocuments(id: string) {
|
||||
const response = await http.get<ApiEnvelope<OceanBookingDocument[]>, ApiEnvelope<OceanBookingDocument[]>>(`${basePath}/${encodeURIComponent(id)}/documents`)
|
||||
return (unwrapApiResponse(response) || []).map(document)
|
||||
}
|
||||
export async function referenceOceanBookingDocument(id: string, expectedVersion: number, fileId: string, documentType: OceanBookingDocumentType) {
|
||||
const response = await http.post<ApiEnvelope<OceanBookingDocumentMutation>, ApiEnvelope<OceanBookingDocumentMutation>>(`${basePath}/${encodeURIComponent(id)}/documents`, { expectedVersion, fileId, documentType }, { headers: { 'Idempotency-Key': idempotencyKey() } })
|
||||
const result = unwrapApiResponse(response)
|
||||
return { document: document(result.document), bookingVersion: Number(result.bookingVersion) }
|
||||
}
|
||||
export async function uploadOceanBookingDocument(id: string, expectedVersion: number, documentType: OceanBookingDocumentType, file: File) {
|
||||
const form = new FormData()
|
||||
form.append('expectedVersion', String(expectedVersion))
|
||||
form.append('documentType', documentType)
|
||||
form.append('file', file)
|
||||
const response = await http.post<ApiEnvelope<OceanBookingDocumentMutation>, ApiEnvelope<OceanBookingDocumentMutation>>(`${basePath}/${encodeURIComponent(id)}/documents/upload`, form, { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': idempotencyKey() } })
|
||||
const result = unwrapApiResponse(response)
|
||||
return { document: document(result.document), bookingVersion: Number(result.bookingVersion) }
|
||||
}
|
||||
export async function invalidateOceanBookingDocument(id: string, documentId: string, expectedVersion: number) {
|
||||
const response = await http.delete<ApiEnvelope<OceanBookingDocumentInvalidation>, ApiEnvelope<OceanBookingDocumentInvalidation>>(`${basePath}/${encodeURIComponent(id)}/documents/${encodeURIComponent(documentId)}`, { data: { expectedVersion }, headers: { 'Idempotency-Key': idempotencyKey() } })
|
||||
const result = unwrapApiResponse(response)
|
||||
return { documentId: String(result.documentId), bookingVersion: Number(result.bookingVersion) }
|
||||
}
|
||||
export async function downloadOceanBookingDocument(id: string, documentId: string) {
|
||||
return requestBlob(`${basePath}/${encodeURIComponent(id)}/documents/${encodeURIComponent(documentId)}/download`)
|
||||
}
|
||||
export async function listWarehouseLogisticsChannels(warehouseId: string): Promise<OceanLogisticsChannel[]> {
|
||||
const response = await http.get<ApiEnvelope<OceanLogisticsChannel[]>, ApiEnvelope<OceanLogisticsChannel[]>>(`${basePath}/channels`, { params: { originWarehouseId: warehouseId } })
|
||||
return (unwrapApiResponse(response) || []).map((item) => ({ channelCode: String(item.channelCode), channelName: String(item.channelName) }))
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue'), 'utf8')
|
||||
|
||||
describe('OceanBookingDocumentsPanel contract', () => {
|
||||
it('owns the two confirmed document types under the booking', () => {
|
||||
expect(source).toContain("value: 'BOOKING_CONFIRMATION', label: '订舱确认文件'")
|
||||
expect(source).toContain("value: 'BILL_OF_LADING', label: '提单文件'")
|
||||
expect(source).toContain('文件统一归属当前订舱')
|
||||
expect(source).not.toContain('TRUCK_APPOINTMENT_ATTACHMENT')
|
||||
})
|
||||
|
||||
it('supports upload, replacement, logical invalidation and download with version chaining', () => {
|
||||
expect(source).toContain('uploadOceanBookingDocument(props.bookingId, props.bookingVersion, type, file)')
|
||||
expect(source).toContain('invalidateOceanBookingDocument(props.bookingId, item.id, version)')
|
||||
expect(source).toContain('version = invalidated.bookingVersion')
|
||||
expect(source).toContain('确认替换')
|
||||
expect(source).toContain('确认作废')
|
||||
expect(source).toContain('downloadOceanBookingDocument')
|
||||
expect(source).toContain('URL.createObjectURL(blob)')
|
||||
})
|
||||
|
||||
it('makes released booking files permanently read-only and preserves traced errors', () => {
|
||||
expect(source).toContain("props.bookingStatus === 'DRAFT' && props.canManage")
|
||||
expect(source).toContain('已放舱 · 永久只读')
|
||||
expect(source).toContain('manageReason')
|
||||
expect(source).toContain('<ApiErrorMeta :details="error" />')
|
||||
expect(source).toContain('订舱资料上传失败,请稍后重试')
|
||||
expect(source).toContain('订舱资料下载失败,请稍后重试')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Download, FileUp, Trash2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { downloadOceanBookingDocument, invalidateOceanBookingDocument, uploadOceanBookingDocument } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingDocument, OceanBookingDocumentType, OceanBookingStatus } from '../types'
|
||||
|
||||
const props = defineProps<{ bookingId: string; bookingVersion: number; bookingStatus: OceanBookingStatus; documents: OceanBookingDocument[]; canManage: boolean; manageReason?: string }>()
|
||||
const emit = defineEmits<{ updated: [documents: OceanBookingDocument[], bookingVersion: number] }>()
|
||||
const busyType = ref<OceanBookingDocumentType | ''>('')
|
||||
const busyDocumentId = ref('')
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const types: Array<{ value: OceanBookingDocumentType; label: string; description: string }> = [
|
||||
{ value: 'BOOKING_CONFIRMATION', label: '订舱确认文件', description: '用于确认已订妥舱位及放舱前资料完整性' },
|
||||
{ value: 'BILL_OF_LADING', label: '提单文件', description: '用于提单号和拖车派车前资料校验' },
|
||||
]
|
||||
const editable = computed(() => props.bookingStatus === 'DRAFT' && props.canManage)
|
||||
const validDocuments = computed(() => props.documents.filter((item) => item.valid))
|
||||
const invalidDocuments = computed(() => props.documents.filter((item) => !item.valid))
|
||||
|
||||
function documentsOfType(type: OceanBookingDocumentType) { return validDocuments.value.filter((item) => item.documentType === type) }
|
||||
function fileSize(value: number | null) {
|
||||
if (value == null) return '大小未知'
|
||||
if (value < 1024) return `${value} B`
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
function saveBlob(blob: Blob, name: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = name || '订舱资料'
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
async function download(item: OceanBookingDocument) {
|
||||
busyDocumentId.value = item.id
|
||||
error.value = null
|
||||
try {
|
||||
saveBlob(await downloadOceanBookingDocument(props.bookingId, item.id), item.fileName)
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '订舱资料下载失败,请稍后重试')
|
||||
} finally {
|
||||
busyDocumentId.value = ''
|
||||
}
|
||||
}
|
||||
async function invalidate(item: OceanBookingDocument, expectedVersion = props.bookingVersion) {
|
||||
const result = await invalidateOceanBookingDocument(props.bookingId, item.id, expectedVersion)
|
||||
const nextDocuments = props.documents.map((document) => document.id === item.id ? { ...document, valid: false, invalidatedAt: new Date().toISOString() } : document)
|
||||
emit('updated', nextDocuments, result.bookingVersion)
|
||||
return { documents: nextDocuments, version: result.bookingVersion }
|
||||
}
|
||||
async function remove(item: OceanBookingDocument) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认作废“${item.fileName}”?文件历史仍会保留。`, '作废订舱资料', { confirmButtonText: '确认作废', cancelButtonText: '取消', type: 'warning' })
|
||||
} catch (cause) {
|
||||
if (cause === 'cancel' || cause === 'close') return
|
||||
throw cause
|
||||
}
|
||||
busyDocumentId.value = item.id
|
||||
error.value = null
|
||||
try {
|
||||
await invalidate(item)
|
||||
ElMessage.success('订舱资料已作废')
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '订舱资料作废失败,请稍后重试')
|
||||
} finally {
|
||||
busyDocumentId.value = ''
|
||||
}
|
||||
}
|
||||
async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || !editable.value) return
|
||||
const previous = documentsOfType(type)
|
||||
if (previous.length) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`将上传“${file.name}”并作废当前 ${previous.length} 份同类型文件,是否继续?`, '替换订舱资料', { confirmButtonText: '确认替换', cancelButtonText: '取消', type: 'warning' })
|
||||
} catch (cause) {
|
||||
if (cause === 'cancel' || cause === 'close') return
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
busyType.value = type
|
||||
error.value = null
|
||||
try {
|
||||
const uploaded = await uploadOceanBookingDocument(props.bookingId, props.bookingVersion, type, file)
|
||||
let documents = [...props.documents, uploaded.document]
|
||||
let version = uploaded.bookingVersion
|
||||
emit('updated', documents, version)
|
||||
for (const item of previous) {
|
||||
const invalidated = await invalidateOceanBookingDocument(props.bookingId, item.id, version)
|
||||
version = invalidated.bookingVersion
|
||||
documents = documents.map((document) => document.id === item.id ? { ...document, valid: false, invalidatedAt: new Date().toISOString() } : document)
|
||||
emit('updated', documents, version)
|
||||
}
|
||||
ElMessage.success(previous.length ? '订舱资料已替换' : '订舱资料已上传')
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '订舱资料上传失败,请稍后重试')
|
||||
} finally {
|
||||
busyType.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="surface-panel booking-documents" aria-label="订舱资料">
|
||||
<div class="booking-documents__heading"><div><h2>订舱资料</h2><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></div><el-tag v-if="bookingStatus === 'RELEASED'" type="info">已放舱 · 永久只读</el-tag></div>
|
||||
<el-alert v-if="bookingStatus === 'DRAFT' && !editable && manageReason" type="warning" :closable="false" show-icon :title="manageReason" />
|
||||
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
|
||||
<div class="booking-document-types">
|
||||
<article v-for="type in types" :key="type.value" class="booking-document-card">
|
||||
<div class="booking-document-card__heading"><div><h3>{{ type.label }}</h3><p>{{ type.description }}</p></div><label v-if="editable" class="booking-document-upload" :class="{ 'is-disabled': Boolean(busyType || busyDocumentId) }"><FileUp :size="15" /><span>{{ busyType === type.value ? '上传处理中' : (documentsOfType(type.value).length ? '替换文件' : '上传文件') }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="Boolean(busyType || busyDocumentId)" @change="selectFile($event, type.value)" /></label></div>
|
||||
<el-empty v-if="!documentsOfType(type.value).length" description="暂无有效文件" :image-size="48" />
|
||||
<ul v-else class="booking-document-list"><li v-for="item in documentsOfType(type.value)" :key="item.id"><div><strong :title="item.fileName">{{ item.fileName }}</strong><small>{{ fileSize(item.sizeBytes) }} · {{ item.uploadedByName || item.uploadedBy }} · {{ item.uploadedAt }}</small></div><div class="booking-document-actions"><el-button size="small" plain :loading="busyDocumentId === item.id" @click="download(item)"><Download :size="14" />下载</el-button><el-button v-if="editable" size="small" type="danger" plain :loading="busyDocumentId === item.id" @click="remove(item)"><Trash2 :size="14" />作废</el-button></div></li></ul>
|
||||
</article>
|
||||
</div>
|
||||
<details v-if="invalidDocuments.length" class="booking-document-history"><summary>查看已作废资料({{ invalidDocuments.length }})</summary><ul><li v-for="item in invalidDocuments" :key="item.id"><span>{{ item.fileName }}</span><small>{{ item.invalidatedByName || item.invalidatedBy || '系统' }} · {{ item.invalidatedAt || '—' }}</small></li></ul></details>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.booking-documents{padding:16px}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li,.booking-document-actions{display:flex;align-items:center}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{justify-content:space-between;gap:16px}.booking-documents__heading{margin-bottom:14px}.booking-documents h2,.booking-document-card h3{margin:0}.booking-documents__heading h2{font-size:16px}.booking-documents__heading p,.booking-document-card__heading p{margin:4px 0 0;color:var(--cc-muted);font-size:12px}.booking-document-types{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.booking-document-card{min-width:0;padding:14px;border:1px solid var(--cc-border);border-radius:8px}.booking-document-card h3{font-size:14px}.booking-document-upload{display:flex;flex:0 0 auto;align-items:center;gap:6px;min-height:32px;padding:0 12px;border:1px solid var(--cc-primary);border-radius:6px;color:var(--cc-primary);font-size:12px;cursor:pointer}.booking-document-upload.is-disabled{cursor:not-allowed;opacity:.55}.booking-document-upload input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}.booking-document-list,.booking-document-history ul{display:grid;gap:8px;margin:12px 0 0;padding:0;list-style:none}.booking-document-list li{min-width:0;padding:9px 10px;border-radius:6px;background:var(--el-fill-color-lighter)}.booking-document-list li>div:first-child{display:grid;min-width:0;gap:3px}.booking-document-list strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.booking-document-list small,.booking-document-history small{color:var(--cc-muted);font-size:11px}.booking-document-actions{flex:0 0 auto;gap:6px}.booking-document-actions :deep(.el-button){margin-left:0}.booking-document-history{margin-top:14px;color:var(--cc-muted);font-size:12px}.booking-document-history summary{cursor:pointer}.booking-document-history li{display:flex;justify-content:space-between;gap:12px;padding:7px 10px;border-bottom:1px solid var(--cc-border)}@media(max-width:900px){.booking-document-types{grid-template-columns:1fr}}@media(max-width:560px){.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{align-items:flex-start;flex-direction:column}.booking-document-actions{width:100%}}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OceanBookingPrintDialog from './OceanBookingPrintDialog.vue'
|
||||
import { downloadOceanBookingPrint } from '../api/platformOceanBookings'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
|
||||
vi.mock('../api/platformOceanBookings', () => ({ downloadOceanBookingPrint: vi.fn() }))
|
||||
|
||||
describe('OceanBookingPrintDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:booking-print'), revokeObjectURL: vi.fn() })
|
||||
})
|
||||
|
||||
it('offers the two confirmed documents and previews the selected PDF', async () => {
|
||||
vi.mocked(downloadOceanBookingPrint).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
|
||||
const wrapper = mount(OceanBookingPrintDialog, {
|
||||
props: { modelValue: true, bookingId: '123', bookingNo: 'OB-123', allowed: true },
|
||||
global: { plugins: [ElementPlus] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('出口货物托运单')
|
||||
expect(document.body.textContent).toContain('提单确认件')
|
||||
await wrapper.findAll('.booking-print-option')[0]!.trigger('click')
|
||||
await flushPromises()
|
||||
await Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.includes('预览 PDF'))?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(downloadOceanBookingPrint).toHaveBeenCalledWith('123', 'SHIPPING_INSTRUCTION')
|
||||
expect(document.body.querySelector('iframe')?.getAttribute('src')).toBe('blob:booking-print')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows permission and API errors without attempting a download', async () => {
|
||||
const denied = mount(OceanBookingPrintDialog, { props: { modelValue: true, bookingId: '123', allowed: false }, global: { plugins: [ElementPlus] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('暂无订舱单据打印权限')
|
||||
expect(downloadOceanBookingPrint).not.toHaveBeenCalled()
|
||||
denied.unmount()
|
||||
|
||||
vi.mocked(downloadOceanBookingPrint).mockRejectedValue(new ApiResponseError('缺少提单号', 'B0409', 200, undefined, 'req-print', 'trace-print'))
|
||||
const failed = mount(OceanBookingPrintDialog, { props: { modelValue: true, bookingId: '123', allowed: true }, global: { plugins: [ElementPlus] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
await failed.findAll('.booking-print-option')[1]!.trigger('click')
|
||||
await flushPromises()
|
||||
await Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.includes('预览 PDF'))?.click()
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('缺少提单号')
|
||||
expect(document.body.textContent).toContain('req-print')
|
||||
expect(document.body.textContent).toContain('trace-print')
|
||||
failed.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Download, FileText } from '@lucide/vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { downloadOceanBookingPrint } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingPrintType } from '../types'
|
||||
|
||||
const props = defineProps<{ bookingId: string; bookingNo?: string | null; allowed: boolean }>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const selectedType = ref<OceanBookingPrintType | ''>('')
|
||||
const loading = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const printBlob = ref<Blob | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
|
||||
const options: Array<{ value: OceanBookingPrintType; label: string; description: string }> = [
|
||||
{ value: 'SHIPPING_INSTRUCTION', label: '出口货物托运单', description: 'Sea Freight Shipping Instructions · PDF' },
|
||||
{ value: 'BILL_OF_LADING_CONFIRMATION', label: '提单确认件', description: '提单内容确认及打印 · PDF' },
|
||||
]
|
||||
const selectedOption = computed(() => options.find((item) => item.value === selectedType.value) || null)
|
||||
const previewTitle = computed(() => `${selectedOption.value?.label || '打印单据'} · ${props.bookingNo || props.bookingId}`)
|
||||
|
||||
function releasePreview() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
printBlob.value = null
|
||||
}
|
||||
function reset() {
|
||||
releasePreview()
|
||||
selectedType.value = ''
|
||||
error.value = null
|
||||
loading.value = false
|
||||
}
|
||||
async function preview() {
|
||||
if (!props.allowed || !selectedType.value || loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
releasePreview()
|
||||
try {
|
||||
const blob = await downloadOceanBookingPrint(props.bookingId, selectedType.value)
|
||||
printBlob.value = blob
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '打印单据加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function backToSelection() {
|
||||
releasePreview()
|
||||
error.value = null
|
||||
}
|
||||
function download() {
|
||||
if (!printBlob.value || !selectedOption.value) return
|
||||
const url = URL.createObjectURL(printBlob.value)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `${props.bookingNo || props.bookingId}-${selectedOption.value.label}.pdf`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
watch(open, (value) => { if (!value) reset() })
|
||||
onBeforeUnmount(releasePreview)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
v-model="open"
|
||||
:title="previewUrl ? previewTitle : '选择打印单据'"
|
||||
width="min(1120px, calc(100vw - 48px))"
|
||||
:loading="loading"
|
||||
:show-confirm="false"
|
||||
:cancel-text="previewUrl ? '关闭预览' : '取消'"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-alert v-if="!allowed" title="暂无订舱单据打印权限" type="warning" :closable="false" show-icon />
|
||||
<template v-else-if="!previewUrl">
|
||||
<p class="booking-print-current">当前订舱:<strong>{{ bookingNo || bookingId }}</strong></p>
|
||||
<div class="booking-print-options" role="radiogroup" aria-label="打印单据类型">
|
||||
<label v-for="item in options" :key="item.value" class="booking-print-option" :class="{ 'is-selected': selectedType === item.value }">
|
||||
<el-radio v-model="selectedType" :value="item.value"><span class="sr-only">{{ item.label }}</span></el-radio>
|
||||
<FileText :size="22" />
|
||||
<span><strong>{{ item.label }}</strong><small>{{ item.description }}</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<LoadErrorState v-if="error" title="打印单据加载失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</template>
|
||||
<div v-else class="booking-print-preview" :aria-label="previewTitle">
|
||||
<iframe :src="previewUrl" :title="`${previewTitle} PDF 预览`" />
|
||||
</div>
|
||||
<template #footer="{ close }">
|
||||
<div class="app-dialog__footer booking-print-footer">
|
||||
<el-button v-if="previewUrl" :disabled="loading" @click="backToSelection">返回选择</el-button>
|
||||
<el-button v-else :disabled="loading" @click="close">取消</el-button>
|
||||
<el-button v-if="previewUrl" type="primary" :disabled="!printBlob" @click="download"><Download :size="16" />下载 PDF</el-button>
|
||||
<el-button v-else type="primary" :loading="loading" :disabled="!allowed || !selectedType" @click="preview">预览 PDF</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.booking-print-current{margin:0 0 16px;color:var(--cc-muted);font-size:13px}.booking-print-current strong{color:var(--cc-text)}.booking-print-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.booking-print-option{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:center;gap:12px;min-height:92px;padding:16px;border:1px solid var(--cc-border);border-radius:10px;background:#fff;cursor:pointer}.booking-print-option.is-selected{border-color:var(--cc-primary);background:var(--cc-primary-soft)}.booking-print-option span{display:grid;min-width:0;gap:4px}.booking-print-option strong{color:var(--cc-text);font-size:15px}.booking-print-option small{color:var(--cc-muted);font-size:12px}.booking-print-preview{min-height:0;overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#f3f5f8}.booking-print-preview iframe{display:block;width:100%;height:min(72vh,760px);border:0;background:#fff}.booking-print-footer{width:100%}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:720px){.booking-print-options{grid-template-columns:1fr}.booking-print-preview iframe{height:68vh}}
|
||||
</style>
|
||||
@@ -54,7 +54,9 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading
|
||||
{ partyType: 'NOTIFY_PARTY', partyName: '通知人' },
|
||||
],
|
||||
containerRequirements: [{ requirementId: '31', containerType: '40HC', containerKind: 'FCL', quantity: 2, remainingQuantity: 2, capacityCbm: 76, maxWeightKg: 28000 }],
|
||||
containers: [], histories: [], statusHistory: [],
|
||||
containers: [],
|
||||
documents: [{ id: '41', oceanBookingId: '123', fileId: '51', documentType: 'BOOKING_CONFIRMATION', fileName: '订舱确认.pdf', fileHash: null, sizeBytes: 128, contentType: 'application/pdf', valid: true, uploadedBy: '7', uploadedByName: '平台', uploadedAt: '2026-08-31T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null }],
|
||||
histories: [], statusHistory: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value)')
|
||||
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
|
||||
expect(source).not.toContain('releaseReason')
|
||||
expect(source).toContain('放舱前请上传有效的订舱确认文件或提单文件')
|
||||
expect(source).toContain('OceanBookingDocumentsPanel')
|
||||
expect(source).toContain('@updated="updateDocuments"')
|
||||
})
|
||||
|
||||
it('freezes the selected carrier standard code into the booking draft', () => {
|
||||
@@ -105,6 +108,8 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).toContain('LoadErrorState')
|
||||
expect(source).toContain('@media(max-width:720px)')
|
||||
expect(source).toContain('@media(max-width:420px)')
|
||||
expect(source).toContain('OceanBookingPrintDialog')
|
||||
expect(source).toContain('打印单据')
|
||||
})
|
||||
|
||||
it('carries the existing read-only CarrierScheduleQuery on the booking detail page', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
|
||||
import { ArrowLeft, Plus, Printer, Save, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
OceanBookingAgentOption,
|
||||
OceanBookingCarrierCode,
|
||||
OceanBookingDraftData,
|
||||
OceanBookingDocument,
|
||||
OceanBookingParty,
|
||||
OceanBookingSaveRequest,
|
||||
OceanBookingStatus,
|
||||
@@ -41,6 +42,8 @@ import type {
|
||||
import { oceanBookingStatusLabel } from '../status'
|
||||
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
|
||||
import { shouldClearBookingAgent } from '../bookingAgents'
|
||||
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
|
||||
import OceanBookingDocumentsPanel from '../components/OceanBookingDocumentsPanel.vue'
|
||||
|
||||
const partyTypes = [
|
||||
{ value: 'SHIPPER', label: '发货人' },
|
||||
@@ -103,8 +106,12 @@ const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
|
||||
const canSave = computed(() => editable.value && access.can(isNew.value ? 'platform:firstleg:ocean-booking:create' : 'platform:firstleg:ocean-booking:edit'))
|
||||
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
|
||||
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
|
||||
const canPrint = computed(() => detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:list'))
|
||||
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && Boolean(detail.value.customerId) && access.can('platform:firstleg:ocean-booking:edit'))
|
||||
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : !detail.value?.customerId ? '请先选择客户并保存订舱草稿,再上传订舱资料' : '')
|
||||
const workflowBusy = computed(() => saving.value || actionSaving.value)
|
||||
const releaseDialogOpen = ref(false)
|
||||
const printOpen = ref(false)
|
||||
|
||||
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
|
||||
return oceanBookingStatusLabel(status)
|
||||
@@ -303,8 +310,16 @@ function releaseValidationError() {
|
||||
if (missing) return `放舱前请填写${missing[1]}`
|
||||
if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!) return '预计到港时间不能早于预计开船时间'
|
||||
if (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
|
||||
if (!detail.value?.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '放舱前请上传有效的订舱确认文件或提单文件'
|
||||
return containerRequirementError(form.data.containerRequirements)
|
||||
}
|
||||
function updateDocuments(documents: OceanBookingDocument[], bookingVersion: number) {
|
||||
if (!detail.value) return
|
||||
detail.value.documents = documents
|
||||
detail.value.version = bookingVersion
|
||||
detail.value.oceanBooking.version = bookingVersion
|
||||
form.expectedVersion = bookingVersion
|
||||
}
|
||||
async function submitRelease() {
|
||||
if (!detail.value || workflowBusy.value) return
|
||||
actionSaving.value = true
|
||||
@@ -366,6 +381,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<div class="section-title">
|
||||
<div><h2>订舱信息</h2><p v-if="detail?.status === 'DRAFT'">草稿可反复保存;放舱后资料同步至关联货柜并锁定。</p><p v-else-if="detail">已放舱资料只读。</p></div>
|
||||
<div class="actions">
|
||||
<el-tooltip :content="canPrint ? '选择出口货物托运单或提单确认件' : detail?.status === 'DRAFT' ? '订舱放舱后方可打印' : '暂无订舱单据打印权限'" placement="top"><span><el-button v-if="detail" plain :disabled="!canPrint || workflowBusy" @click="printOpen = true"><Printer :size="16" />打印单据</el-button></span></el-tooltip>
|
||||
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
|
||||
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" :disabled="workflowBusy" @click="openReleaseDialog">放舱</el-button>
|
||||
<el-button v-if="canSave" type="primary" :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
|
||||
@@ -445,6 +461,8 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
|
||||
|
||||
<div v-if="detail" class="surface-panel"><CarrierScheduleQueryPanel :ocean-booking-id="detail.id" /></div>
|
||||
|
||||
<section v-if="detail" class="surface-panel history-panel">
|
||||
@@ -453,6 +471,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</section>
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" />
|
||||
|
||||
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||||
<el-form label-position="top">
|
||||
|
||||
@@ -35,6 +35,9 @@ describe('OceanBookingsView', () => {
|
||||
expect(source).toContain('<Pencil v-if="row.status === \'DRAFT\'"')
|
||||
expect(source).toContain("row.status === 'DRAFT' ? '编辑' : '查看'")
|
||||
expect(source).not.toContain('MoreHorizontal')
|
||||
expect(source).toContain('打印单据')
|
||||
expect(source).toContain('OceanBookingPrintDialog')
|
||||
expect(source).toContain("access.can('platform:firstleg:ocean-booking:list')")
|
||||
})
|
||||
|
||||
it('uses only the frozen DRAFT and RELEASED status options', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||
import { Eye, Pencil, Plus, Printer } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
@@ -15,6 +15,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listOceanBookings, type OceanBookingResource } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingQuery, OceanBookingStatus } from '../types'
|
||||
import { oceanBookingStatusLabel, oceanBookingStatusOptions } from '../status'
|
||||
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -29,13 +30,16 @@ const customersLoading = ref(false)
|
||||
const query = reactive<QueryModel>({ customerId: '', keyword: '', status: '' })
|
||||
const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE'))
|
||||
const canCreate = computed(() => access.can('platform:firstleg:ocean-booking:create'))
|
||||
const canPrint = computed(() => access.can('platform:firstleg:ocean-booking:list'))
|
||||
const printOpen = ref(false)
|
||||
const printBooking = ref<{ id: string; bookingNo?: string | null } | null>(null)
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
||||
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/提单号/船名/航次', width: 260 },
|
||||
{ key: 'status', type: 'select', label: '状态', options: oceanBookingStatusOptions },
|
||||
])
|
||||
const columns = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 100, slotName: 'actions', hideable: false, movable: false },
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 196, slotName: 'actions', hideable: false, movable: false },
|
||||
{ prop: 'bookingNo', label: '订舱号', minWidth: 150 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 160 },
|
||||
{ prop: 'billOfLadingNo', label: '提单号', minWidth: 155, formatter: (row: OceanBookingResource) => row.billOfLadingNo || '—' },
|
||||
@@ -57,6 +61,8 @@ async function loadCustomers() { customersLoading.value = true; try { const resu
|
||||
async function load() { loading.value = true; error.value = null; try { const keyword = String(query.keyword ?? '').trim(); const customerId = String(query.customerId ?? '').trim(); const status = String(query.status ?? '').trim(); const result = await listOceanBookings({ page: page.value, size: pageSize.value, customerId: customerId || undefined, keyword: keyword || undefined, status: (status || undefined) as OceanBookingStatus | undefined } satisfies OceanBookingQuery); rows.value = result.records; total.value = result.total } catch (e) { error.value = getApiErrorDetails(e) } finally { loading.value = false } }
|
||||
function open(id: string) { void router.push(`/ocean-bookings/${id}`) }
|
||||
function createBooking() { void router.push('/ocean-bookings/new') }
|
||||
function openPrint(row: Record<string, unknown>) { printBooking.value = { id: String(row.id), bookingNo: typeof row.bookingNo === 'string' ? row.bookingNo : null }; printOpen.value = true }
|
||||
function printHint(row: Record<string, unknown>) { return !canPrint.value ? '暂无订舱单据打印权限' : row.status !== 'RELEASED' ? '订舱放舱后方可打印' : '选择出口货物托运单或提单确认件' }
|
||||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||||
watch([page, pageSize], () => void load()); onMounted(() => void loadCustomers())
|
||||
onActivated(() => void load())
|
||||
@@ -69,7 +75,7 @@ onActivated(() => void load())
|
||||
<section v-if="!error" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="createBooking"><Plus :size="16" />新增订舱</el-button></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Pencil v-if="row.status === 'DRAFT'" :size="15" /><Eye v-else :size="15" />{{ row.status === 'DRAFT' ? '编辑' : '查看' }}</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Pencil v-if="row.status === 'DRAFT'" :size="15" /><Eye v-else :size="15" />{{ row.status === 'DRAFT' ? '编辑' : '查看' }}</el-button><el-tooltip :content="printHint(row)" placement="top"><span><el-button size="small" plain :disabled="!canPrint || row.status !== 'RELEASED'" @click="openPrint(row)"><Printer :size="15" />打印单据</el-button></span></el-tooltip></div></template>
|
||||
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
|
||||
<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>
|
||||
<template #eta="{ row }">{{ formatDateTime(row.eta) }}</template>
|
||||
@@ -78,6 +84,7 @@ onActivated(() => void load())
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<OceanBookingPrintDialog v-if="printBooking" v-model="printOpen" :booking-id="printBooking.id" :booking-no="printBooking.bookingNo" :allowed="canPrint" />
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>.ocean-bookings-page{min-width:0}.table-actions{display:flex;align-items:center;flex-wrap:wrap;gap:6px}.table-actions :deep(.el-button){margin-left:0}@media(max-width:420px){.table-actions{flex-direction:column;align-items:stretch}.table-actions :deep(.el-button){width:100%;min-height:40px}}</style>
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
export type OceanBookingStatus = 'DRAFT' | 'RELEASED'
|
||||
export type OceanBookingPrintType = 'SHIPPING_INSTRUCTION' | 'BILL_OF_LADING_CONFIRMATION'
|
||||
export type OceanBookingDocumentType = 'BOOKING_CONFIRMATION' | 'BILL_OF_LADING'
|
||||
|
||||
export interface OceanBookingDocument {
|
||||
id: string
|
||||
oceanBookingId: string
|
||||
fileId: string
|
||||
documentType: OceanBookingDocumentType
|
||||
fileName: string
|
||||
fileHash: string | null
|
||||
sizeBytes: number | null
|
||||
contentType: string | null
|
||||
valid: boolean
|
||||
uploadedBy: string
|
||||
uploadedByName: string | null
|
||||
uploadedAt: string
|
||||
invalidatedBy: string | null
|
||||
invalidatedByName: string | null
|
||||
invalidatedAt: string | null
|
||||
}
|
||||
export interface OceanBookingDocumentMutation { document: OceanBookingDocument; bookingVersion: number }
|
||||
export interface OceanBookingDocumentInvalidation { documentId: string; bookingVersion: number }
|
||||
export type OceanBookingContainerType = '20GP' | '40GP' | '40HC' | '40NR' | '45HC'
|
||||
export type OceanBookingCarrierCode = 'MATSON' | 'SML' | 'ZIM' | 'COSCO' | 'EMC' | 'HEDE' | 'OTHER'
|
||||
|
||||
@@ -56,7 +78,7 @@ export interface OceanBookingResource {
|
||||
etd: string | null; atd: string | null; eta: string | null; ata: string | null; containerSummary: string | null
|
||||
amount: string | null; currency: string | null
|
||||
}
|
||||
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainerRequirement[]; containers: OceanBookingAllocatedContainer[]; histories: OceanBookingStatusEvent[]; statusHistory: OceanBookingStatusEvent[] }
|
||||
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainerRequirement[]; containers: OceanBookingAllocatedContainer[]; documents: OceanBookingDocument[]; histories: OceanBookingStatusEvent[]; statusHistory: OceanBookingStatusEvent[] }
|
||||
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; occurredAt: string }
|
||||
export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number }
|
||||
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; customerId?: string }
|
||||
|
||||
@@ -18,8 +18,8 @@ describe('truck appointment API', () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: '9001' })
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } })
|
||||
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
|
||||
await expect(createTruckAppointment({ customerId: '100', carrierId: '200', oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { customerId: '100', carrierId: '200', oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })
|
||||
await expect(createTruckAppointment({ customerId: '100', carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { customerId: '100', carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })
|
||||
await expect(performTruckAppointmentAction('9001', 'DISPATCH', 2)).resolves.toMatchObject({ status: 'DISPATCHED' })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-dispatch-9001-') } })
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
TruckAppointmentResource,
|
||||
TruckAppointmentStatusHistory,
|
||||
TruckAttachmentReference,
|
||||
TruckAttachmentType,
|
||||
} from '../types'
|
||||
|
||||
const basePath = '/platform/v1/truck-appointments'
|
||||
@@ -71,12 +72,12 @@ export async function listTruckAppointmentAttachments(id: string | number) {
|
||||
return unwrapApiResponse(response).map((item) => ({ ...item, id: item.id == null ? item.id : String(item.id), fileId: String(item.fileId), uploadedBy: item.uploadedBy == null ? item.uploadedBy : String(item.uploadedBy) }))
|
||||
}
|
||||
|
||||
export async function referenceTruckAppointmentAttachment(id: string | number, payload: { fileId: string; attachmentType?: string | null; fileName?: string | null; fileHash?: string | null }) {
|
||||
export async function referenceTruckAppointmentAttachment(id: string | number, payload: { fileId: string; attachmentType?: TruckAttachmentType | null; fileName?: string | null; fileHash?: string | null }) {
|
||||
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments`, payload)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function uploadTruckAppointmentAttachment(id: string | number, file: File, attachmentType?: string | null) {
|
||||
export async function uploadTruckAppointmentAttachment(id: string | number, file: File, attachmentType?: TruckAttachmentType | null) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
if (attachmentType) form.append('attachmentType', attachmentType)
|
||||
|
||||
@@ -5,85 +5,52 @@ import { resolve } from 'node:path'
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/components/TruckAppointmentForm.vue'), 'utf8')
|
||||
|
||||
describe('TruckAppointmentForm interaction contract', () => {
|
||||
it('uses searchable business selectors for shared resources', () => {
|
||||
expect(source).toContain('listPlatformTenants')
|
||||
expect(source).toContain('listSuppliers')
|
||||
expect(source).toContain('listContainers')
|
||||
expect(source).toContain('listOceanBookings')
|
||||
expect(source).toContain('listAllPlatformWarehouses')
|
||||
expect(source).not.toContain('label="承运商 ID"')
|
||||
expect(source).not.toContain('label="客户关联 ID"')
|
||||
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
|
||||
expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })")
|
||||
expect(source).toContain('function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }')
|
||||
expect(source).not.toContain('customer.tenantCode')
|
||||
it('requires a released booking before a real-numbered allocated container', () => {
|
||||
expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })")
|
||||
expect(source).toContain("oceanBookingId: [{ required: true, message: '请选择已放舱订舱'")
|
||||
expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下已录真实集装箱号的柜次'")
|
||||
expect(source.indexOf('label="订舱"')).toBeLessThan(source.indexOf('label="柜次(真实集装箱号)"'))
|
||||
expect(source).toContain(':disabled="!draft.oceanBookingId || bookingContainersLoading"')
|
||||
expect(source).not.toContain('关联订舱(可选)')
|
||||
expect(source).not.toContain('关联柜次(可选)')
|
||||
})
|
||||
|
||||
it('synchronizes ids and names and recovers failed option loading', () => {
|
||||
expect(source).toContain('selectCarrier')
|
||||
expect(source).toContain("item.category === 'TRUCKING' && item.status === 'ACTIVE'")
|
||||
expect(source).toContain('请选择有效的合作中拖车供应商')
|
||||
expect(source).toContain('draft.carrierName')
|
||||
expect(source).toContain('selectCustomer')
|
||||
expect(source).toContain('draft.customerName')
|
||||
expect(source).toContain('selectContainer')
|
||||
expect(source).toContain('draft.loadingPlanId = item.planId || null')
|
||||
expect(source).toContain('draft.loadingPlanId = null; draft.warehouseId = null')
|
||||
expect(source).toContain("derivedCustomerSource.value === 'container'")
|
||||
expect(source).toContain("derivedCustomerSource.value === 'booking'")
|
||||
it('loads only containers allocated to the selected booking and filters missing or malformed physical numbers', () => {
|
||||
expect(source).toContain('getOceanBooking(bookingId)')
|
||||
expect(source).toContain('containers.value = booking.containers || []')
|
||||
expect(source).toContain('bookingDocuments.value = booking.documents || []')
|
||||
expect(source).toContain('isIso6346ContainerNumber(item.containerNo)')
|
||||
expect(source).toContain('该订舱暂无已录真实集装箱号的柜次')
|
||||
expect(source).not.toContain('listContainers')
|
||||
})
|
||||
|
||||
it('clears an incompatible container whenever the booking changes', () => {
|
||||
expect(source).toContain('draft.containerBookingId = null')
|
||||
expect(source).toContain('draft.loadingPlanId = null')
|
||||
expect(source).toContain('if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId))')
|
||||
})
|
||||
|
||||
it('freezes customer, warehouse and bill of lading snapshots from the booking', () => {
|
||||
expect(source).toContain('draft.customerId = item.customerId')
|
||||
expect(source).toContain('draft.customerName = item.customerName')
|
||||
expect(source).toContain('draft.warehouseId = item.warehouseId')
|
||||
expect(source).toContain('draft.billOfLadingNo = item.billOfLadingNo')
|
||||
expect(source).toContain('已从放舱订舱冻结资料带入')
|
||||
expect(source).toContain(':model-value="draft.billOfLadingNo || \'\'" disabled')
|
||||
})
|
||||
|
||||
it('keeps server-side validation authoritative and preserves recoverable option errors', () => {
|
||||
expect(source).toContain('最终资格仍由服务端校验')
|
||||
expect(source).toContain('所选订舱缺少有效的订舱确认文件或提单文件')
|
||||
expect(source).toContain('bookingContainersError')
|
||||
expect(source).toContain('重新加载选项')
|
||||
})
|
||||
|
||||
it('keeps historical snapshots visible but requires active customer and carrier choices', () => {
|
||||
expect(source).toContain("item.status === 'ACTIVE'")
|
||||
expect(source).toContain("item.status === 'ACTIVE' || String(item.id) === String(draft.warehouseId)")
|
||||
expect(source).toContain('v-for="item in selectableCustomers"')
|
||||
expect(source).toContain('v-for="item in selectableWarehouses"')
|
||||
expect(source).toContain("customerId: [{ required: true, message: '请选择客户'")
|
||||
expect(source).toContain("carrierId: [{ required: true, message: '请选择承运商'")
|
||||
expect(source).toContain("plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间'")
|
||||
expect(source).toContain("draft.customerName || '历史客户(需重新选择)'")
|
||||
expect(source).toContain(':value="String(draft.customerId)" disabled')
|
||||
expect(source).not.toContain('const relationText = computed')
|
||||
})
|
||||
|
||||
it('loads readonly detail options and preserves relation snapshots outside the first page', () => {
|
||||
expect(source).toContain('onMounted(() => { void loadOptions() })')
|
||||
expect(source).toContain('const selectedContainerFallback = computed')
|
||||
expect(source).toContain('const selectedBookingFallback = computed')
|
||||
expect(source).toContain('const selectedWarehouseFallback = computed')
|
||||
expect(source).toContain('v-if="selectedContainerFallback"')
|
||||
expect(source).toContain('v-if="selectedBookingFallback"')
|
||||
expect(source).toContain('v-if="selectedWarehouseFallback"')
|
||||
})
|
||||
|
||||
it('keeps booking, cabinet and loading plan optional and only uses them for completion hints', () => {
|
||||
expect(source).toContain('订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约')
|
||||
expect(source).toContain('class="relation-note"')
|
||||
expect(source).toContain('<slot name="relation-note-actions" />')
|
||||
expect(source).toContain('justify-content:space-between')
|
||||
expect(source).not.toContain('class="relation-alert">历史关联快照')
|
||||
expect(source).toContain('<el-divider content-position="left">可选关联</el-divider>')
|
||||
expect(source).toContain('label="关联柜次(可选)"')
|
||||
expect(source).toContain('label="关联订舱(可选)"')
|
||||
expect(source).toContain("relationWarning.value = '所选关联资源与当前客户不一致")
|
||||
expect(source).toContain("item.status === 'RELEASED'")
|
||||
expect(source).toContain('已从放舱订舱回填并锁定')
|
||||
expect(source).toContain(':disabled="Boolean(selectedReleasedBooking)"')
|
||||
expect(source).toContain('草稿可暂不填写,派车前必填')
|
||||
expect(source).not.toContain('尚未建立装柜计划')
|
||||
})
|
||||
|
||||
it('gives editable fields clear placeholders', () => {
|
||||
expect(source).toContain('placeholder="请输入司机姓名"')
|
||||
expect(source).toContain('placeholder="请选择预计装箱时间"')
|
||||
expect(source).toContain('placeholder="请输入目的门店地址"')
|
||||
expect(source).toContain('placeholder="请输入拖车预约备注(可选)"')
|
||||
expect(source).toContain('重新加载')
|
||||
expect(source).toContain('请选择有效的合作中拖车供应商')
|
||||
})
|
||||
|
||||
it('labels the generated cabinet reference and physical container number separately', () => {
|
||||
expect(source).toContain('柜次号:${item.cabinetNo')
|
||||
expect(source).toContain('集装箱号:${item.containerNo')
|
||||
expect(source).toContain('集装箱号:${item.containerNo}')
|
||||
})
|
||||
|
||||
it('keeps the form usable at 390px', () => {
|
||||
|
||||
@@ -3,18 +3,17 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Eye, Save } from '@lucide/vue'
|
||||
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
|
||||
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { getOceanBooking, listOceanBookings, type OceanBookingAllocatedContainer, type OceanBookingDocument, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||||
import { isIso6346ContainerNumber } from '../iso6346'
|
||||
|
||||
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const relationWarning = ref('')
|
||||
const derivedCustomerSource = ref<'booking' | 'container' | null>(null)
|
||||
const billOfLadingFromBooking = ref(false)
|
||||
const bookingContainersLoading = ref(false)
|
||||
const bookingContainersError = ref('')
|
||||
const bookingDocuments = ref<OceanBookingDocument[]>([])
|
||||
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', receivableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
|
||||
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: value.currency?.trim().toUpperCase() || 'RMB' } }
|
||||
const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue))
|
||||
@@ -30,7 +29,13 @@ const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unkno
|
||||
else callback(new Error(`${label}格式不正确`))
|
||||
}, trigger: 'blur' })
|
||||
const rules: FormRules = {
|
||||
customerId: [{ required: true, message: '请选择客户', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCustomers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的启用客户')), trigger: 'change' }],
|
||||
oceanBookingId: [{ required: true, message: '请选择已放舱订舱', trigger: 'change' }, { validator: (_rule, value, callback) => {
|
||||
if (!(bookings.value.some((item) => item.id === String(value) && item.status === 'RELEASED') || selectedBookingFallback.value?.id === String(value))) return callback(new Error('请选择有效的已放舱订舱'))
|
||||
if (bookingContainersError.value) return callback(new Error('订舱资料状态加载失败,请重试'))
|
||||
return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
|
||||
}, trigger: 'change' }],
|
||||
containerBookingId: [{ required: true, message: '请选择该订舱下已录真实集装箱号的柜次', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableContainers.value.some((item) => item.containerBookingId === String(value)) || (props.readonly && selectedContainerFallback.value?.id === String(value)) ? undefined : new Error('请选择该订舱下有效的真实柜号柜次')), trigger: 'change' }],
|
||||
customerId: [{ required: true, message: '所选订舱缺少客户信息', trigger: 'change' }],
|
||||
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
|
||||
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
|
||||
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
|
||||
@@ -38,25 +43,21 @@ const rules: FormRules = {
|
||||
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], receivableAmount: [nonNegative('应收拖车费')],
|
||||
currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }],
|
||||
}
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const carriers = ref<Supplier[]>([])
|
||||
const containers = ref<ContainerBookingResource[]>([])
|
||||
const containers = ref<OceanBookingAllocatedContainer[]>([])
|
||||
const bookings = ref<OceanBookingResource[]>([])
|
||||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
const optionsLoading = ref(false)
|
||||
const optionsError = ref('')
|
||||
const selectableCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const selectableWarehouses = computed(() => warehouses.value.filter((item) => (
|
||||
item.status === 'ACTIVE' || String(item.id) === String(draft.warehouseId)
|
||||
)))
|
||||
const selectableCarriers = computed(() => carriers.value.filter((item) => (
|
||||
item.status === 'ACTIVE'
|
||||
)))
|
||||
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null)
|
||||
const bookingDocumentReady = computed(() => bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType)))
|
||||
const selectableContainers = computed(() => containers.value.filter((item) => isIso6346ContainerNumber(item.containerNo)))
|
||||
const selectedContainerFallback = computed(() => {
|
||||
const id = draft.containerBookingId
|
||||
const snapshot = props.resource
|
||||
if (!id || containers.value.some((item) => String(item.id) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null
|
||||
if (!id || selectableContainers.value.some((item) => String(item.containerBookingId) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null
|
||||
return {
|
||||
id: String(id),
|
||||
label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 集装箱号:${snapshot?.containerNo || '待录入'}`,
|
||||
@@ -68,93 +69,96 @@ const selectedBookingFallback = computed(() => {
|
||||
if (!id || bookings.value.some((item) => String(item.id) === String(id)) || String(snapshot?.oceanBookingId) !== String(id)) return null
|
||||
return { id: String(id), label: snapshot?.oceanBookingNo || '订舱号缺失' }
|
||||
})
|
||||
const selectedWarehouseFallback = computed(() => {
|
||||
const id = draft.warehouseId
|
||||
const snapshot = props.resource
|
||||
if (!id || selectableWarehouses.value.some((item) => String(item.id) === String(id)) || String(snapshot?.warehouseId) !== String(id)) return null
|
||||
return { id: String(id), label: snapshot?.warehouseName || '当前仓库(需重新选择)' }
|
||||
})
|
||||
function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }
|
||||
function selectCustomer(customerId: string) { const customer = selectableCustomers.value.find((item) => String(item.id) === customerId); draft.customerId = customer?.id ? String(customer.id) : null; draft.customerName = customer ? customerLabel(customer) : null; derivedCustomerSource.value = null; relationWarning.value = '' }
|
||||
function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null }
|
||||
function applyRelatedCustomer(customerId: string | null | undefined, customerName: string | null | undefined, source: 'booking' | 'container') {
|
||||
if (!customerId) return
|
||||
if (!draft.customerId) { draft.customerId = String(customerId); draft.customerName = customerName || null; derivedCustomerSource.value = source; return }
|
||||
if (String(draft.customerId) !== String(customerId)) relationWarning.value = '所选关联资源与当前客户不一致,请重新选择客户或清空该关联'
|
||||
}
|
||||
function selectContainer(containerId: string) {
|
||||
const item = containers.value.find((entry) => entry.id === containerId)
|
||||
draft.containerBookingId = item?.id || null
|
||||
const item = selectableContainers.value.find((entry) => entry.containerBookingId === containerId)
|
||||
draft.containerBookingId = item?.containerBookingId || null
|
||||
relationWarning.value = ''
|
||||
if (!item) {
|
||||
draft.loadingPlanId = null; draft.warehouseId = null
|
||||
if (derivedCustomerSource.value === 'container') { draft.customerId = null; draft.customerName = null; derivedCustomerSource.value = null }
|
||||
draft.loadingPlanId = null
|
||||
return
|
||||
}
|
||||
draft.loadingPlanId = item.planId || null; draft.warehouseId = item.warehouseId || null
|
||||
applyRelatedCustomer(item.customerId, item.customerName, 'container')
|
||||
draft.loadingPlanId = null
|
||||
}
|
||||
function selectBooking(bookingId: string) {
|
||||
async function loadBookingContainers(bookingId: string) {
|
||||
bookingContainersLoading.value = true
|
||||
bookingContainersError.value = ''
|
||||
try {
|
||||
const booking = await getOceanBooking(bookingId)
|
||||
if (!bookings.value.some((item) => item.id === booking.id)) bookings.value.push(booking)
|
||||
containers.value = booking.containers || []
|
||||
bookingDocuments.value = booking.documents || []
|
||||
if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId)) {
|
||||
draft.containerBookingId = null
|
||||
draft.loadingPlanId = null
|
||||
}
|
||||
} catch {
|
||||
containers.value = []
|
||||
bookingDocuments.value = []
|
||||
draft.containerBookingId = null
|
||||
draft.loadingPlanId = null
|
||||
bookingContainersError.value = '该订舱柜次加载失败,请重试'
|
||||
} finally {
|
||||
bookingContainersLoading.value = false
|
||||
}
|
||||
}
|
||||
async function selectBooking(bookingId: string) {
|
||||
const item = bookings.value.find((entry) => entry.id === bookingId)
|
||||
const previouslyDerivedBillOfLading = billOfLadingFromBooking.value
|
||||
draft.oceanBookingId = item?.id || null
|
||||
draft.containerBookingId = null
|
||||
draft.loadingPlanId = null
|
||||
containers.value = []
|
||||
bookingDocuments.value = []
|
||||
relationWarning.value = ''
|
||||
if (!item) {
|
||||
if (billOfLadingFromBooking.value) draft.billOfLadingNo = null
|
||||
billOfLadingFromBooking.value = false
|
||||
if (derivedCustomerSource.value === 'booking') { draft.customerId = null; draft.customerName = null; derivedCustomerSource.value = null }
|
||||
draft.customerId = null
|
||||
draft.customerName = null
|
||||
draft.warehouseId = null
|
||||
draft.billOfLadingNo = null
|
||||
return
|
||||
}
|
||||
applyRelatedCustomer(item.customerId, item.customerName, 'booking')
|
||||
if (item.status === 'RELEASED') {
|
||||
if (draft.billOfLadingNo && draft.billOfLadingNo !== item.billOfLadingNo) relationWarning.value = '已放舱订舱的提单号与当前填写值不一致,已使用订舱冻结快照'
|
||||
draft.billOfLadingNo = item.billOfLadingNo || null
|
||||
billOfLadingFromBooking.value = true
|
||||
} else {
|
||||
if (previouslyDerivedBillOfLading) draft.billOfLadingNo = null
|
||||
billOfLadingFromBooking.value = false
|
||||
}
|
||||
draft.customerId = item.customerId
|
||||
draft.customerName = item.customerName
|
||||
draft.warehouseId = item.warehouseId
|
||||
draft.billOfLadingNo = item.billOfLadingNo
|
||||
await loadBookingContainers(item.id)
|
||||
}
|
||||
async function loadOptions() {
|
||||
optionsLoading.value = true; optionsError.value = ''
|
||||
const results = await Promise.allSettled([
|
||||
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
|
||||
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
|
||||
listContainers({ page: 1, size: 200 }),
|
||||
listOceanBookings({ page: 1, size: 200 }),
|
||||
listAllPlatformWarehouses(),
|
||||
listOceanBookings({ page: 1, size: 200, status: 'RELEASED' }),
|
||||
])
|
||||
if (results[0].status === 'fulfilled') customers.value = results[0].value.records.filter((customer) => customer.status === 'ACTIVE')
|
||||
if (results[1].status === 'fulfilled') carriers.value = results[1].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE')
|
||||
if (results[2].status === 'fulfilled') containers.value = results[2].value.records
|
||||
if (results[3].status === 'fulfilled') bookings.value = results[3].value.records
|
||||
if (results[4].status === 'fulfilled') warehouses.value = results[4].value
|
||||
const failed = ['客户', '承运商', '柜次', '订舱', '仓库'].filter((_, index) => results[index]?.status === 'rejected')
|
||||
if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE')
|
||||
if (results[1].status === 'fulfilled') bookings.value = results[1].value.records.filter((item) => item.status === 'RELEASED')
|
||||
const failed = ['承运商', '已放舱订舱'].filter((_, index) => results[index]?.status === 'rejected')
|
||||
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
|
||||
optionsLoading.value = false
|
||||
if (draft.oceanBookingId) await loadBookingContainers(String(draft.oceanBookingId))
|
||||
}
|
||||
async function validate() { return formRef.value?.validate() }
|
||||
async function submitForm() { if (await formRef.value?.validate()) emit('submit') }
|
||||
defineExpose({ validate })
|
||||
onMounted(() => { void loadOptions() })
|
||||
watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.length) void loadOptions() })
|
||||
watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.length) void loadOptions() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
||||
<div class="relation-note"><span>订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约;选择后仅用于补全快照和一致性检查。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
|
||||
<div class="relation-note"><span>拖车预约必须先选择已放舱订舱,再选择该订舱下已录入真实 ISO 集装箱号的柜次;客户、仓库和提单号由订舱冻结资料带入,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
|
||||
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
|
||||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24"><el-divider content-position="left">订舱与柜次(必填)</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable placeholder="请先选择已放舱订舱" :loading="optionsLoading" no-data-text="暂无可用的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'}${item.billOfLadingNo ? ` / 提单号:${item.billOfLadingNo}` : ''}`" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="柜次(真实集装箱号)" prop="containerBookingId"><el-select :model-value="draft.containerBookingId" filterable :disabled="!draft.oceanBookingId || bookingContainersLoading" placeholder="请先选择订舱,再选择真实柜号柜次" :loading="bookingContainersLoading" :no-data-text="draft.oceanBookingId ? '该订舱暂无已录真实集装箱号的柜次' : '请先选择订舱'" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" :disabled="!readonly" /><el-option v-for="item in selectableContainers" :key="item.containerBookingId" :label="`柜次号:${item.cabinetNo || '待生成'} / 集装箱号:${item.containerNo}`" :value="item.containerBookingId" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError }}</span><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadBookingContainers(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !bookingContainersLoading && !selectableContainers.length" class="field-hint">只有已分配至该订舱且已录入真实 ISO 集装箱号的柜次可以预约拖车。</div></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" no-data-text="暂无启用的客户" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="draft.customerId && !selectableCustomers.some((item) => String(item.id) === String(draft.customerId))" :label="draft.customerName || '历史客户(需重新选择)'" :value="String(draft.customerId)" disabled /><el-option v-for="item in selectableCustomers" :key="String(item.id)" :label="customerLabel(item)" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !bookingDocumentReady" :xs="24"><el-alert title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-input :model-value="draft.customerName || ''" disabled placeholder="选择订舱后自动带入" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="承运商" prop="carrierId"><el-select :model-value="draft.carrierId" filterable clearable placeholder="请选择承运商" :loading="optionsLoading" no-data-text="暂无可用拖车供应商" style="width:100%" @update:model-value="selectCarrier(String($event || ''))"><el-option v-if="draft.carrierId && !selectableCarriers.some((item) => String(item.id) === String(draft.carrierId))" :label="draft.carrierName || '当前承运商(需重新选择)'" :value="String(draft.carrierId)" disabled /><el-option v-for="item in selectableCarriers" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装箱时间" style="width:100%" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="提单号"><el-input v-model="draft.billOfLadingNo" :disabled="Boolean(selectedReleasedBooking)" maxlength="128" placeholder="草稿可暂不填写,派车前必填" /><div v-if="selectedReleasedBooking" class="field-hint">已从放舱订舱回填并锁定</div></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">可选关联</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次(可选)"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="可选择柜次辅助补全" :loading="optionsLoading" no-data-text="暂无可关联柜次" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" /><el-option v-for="item in containers" :key="item.id" :label="`柜次号:${item.cabinetNo || item.bookingNo || '待生成'} / 集装箱号:${item.containerNo || '待录入'}`" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱(可选)"><el-select :model-value="draft.oceanBookingId" filterable clearable placeholder="可选择订舱辅助补全" :loading="optionsLoading" no-data-text="暂无可关联订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" /><el-option v-for="item in bookings" :key="item.id" :label="item.bookingNo || '订舱号缺失'" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-if="selectedWarehouseFallback" :key="selectedWarehouseFallback.id" :label="selectedWarehouseFallback.label" :value="selectedWarehouseFallback.id" /><el-option v-for="item in selectableWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="提单号"><el-input :model-value="draft.billOfLadingNo || ''" disabled placeholder="选择订舱后自动带入" /><div v-if="selectedReleasedBooking" class="field-hint">已从放舱订舱冻结资料带入</div></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-input :model-value="selectedReleasedBooking?.warehouseName || resource?.warehouseName || ''" disabled placeholder="选择订舱后自动带入" /></el-form-item></el-col>
|
||||
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="派车单号"><el-input :model-value="resource.appointmentNo || ''" disabled /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">运输与时间计划</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" placeholder="请输入车队联系人" /></el-form-item></el-col>
|
||||
@@ -198,7 +202,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
|
||||
:deep(.el-divider) { margin: 14px 0 10px; }
|
||||
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
|
||||
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
|
||||
.field-hint { margin-top:4px; color:var(--cc-muted); font-size:12px; }
|
||||
.field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;color:var(--cc-muted);font-size:12px;line-height:20px }.field-error{color:var(--el-color-danger)}
|
||||
@media (max-width: 480px) {
|
||||
.truck-form { padding-inline: 2px; }
|
||||
.relation-note { align-items:flex-start; flex-direction:column; }
|
||||
|
||||
@@ -13,7 +13,8 @@ describe('TruckAttachmentDialog interaction governance', () => {
|
||||
})
|
||||
|
||||
it('provides scoped upload and deletion without exposing manual file ids', () => {
|
||||
expect(source).toContain("BILL_OF_LADING: '提单文件'")
|
||||
expect(source).not.toContain("BILL_OF_LADING: '提单文件'")
|
||||
expect(source).toContain("DISPATCH_ORDER: '派车单'")
|
||||
expect(source).not.toContain('referenceTruckAppointmentAttachment')
|
||||
expect(source).not.toContain('输入文件服务 ID')
|
||||
expect(source).toContain('uploadTruckAppointmentAttachment')
|
||||
|
||||
@@ -3,14 +3,14 @@ import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { FileUp, Trash2 } from '@lucide/vue'
|
||||
import { deleteTruckAppointmentAttachment, uploadTruckAppointmentAttachment } from '../api/platformTrucking'
|
||||
import type { TruckAttachmentReference } from '../types'
|
||||
import type { TruckAttachmentReference, TruckAttachmentType } from '../types'
|
||||
const props = defineProps<{ modelValue: boolean; appointmentId?: string; attachments: TruckAttachmentReference[]; canManage?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [boolean]; changed: [] }>()
|
||||
const busy = ref(false)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const selectedType = ref('OTHER')
|
||||
const attachmentTypeLabels: Record<string, string> = { BILL_OF_LADING: '提单文件', DISPATCH_ORDER: '派车单', DRIVER_DOCUMENT: '司机证件', VEHICLE_DOCUMENT: '车辆证件', OTHER: '其他附件' }
|
||||
function attachmentTypeLabel(value: string | null | undefined) { return value ? attachmentTypeLabels[value] || '业务附件' : '业务附件' }
|
||||
const selectedType = ref<TruckAttachmentType>('OTHER')
|
||||
const attachmentTypeLabels: Record<TruckAttachmentType, string> = { DISPATCH_ORDER: '派车单', DRIVER_DOCUMENT: '司机证件', VEHICLE_DOCUMENT: '车辆证件', OTHER: '其他附件' }
|
||||
function attachmentTypeLabel(value: string | null | undefined) { return value && value in attachmentTypeLabels ? attachmentTypeLabels[value as TruckAttachmentType] : '业务附件' }
|
||||
function selectFile(upload: { raw?: File }) { selectedFile.value = upload.raw || null }
|
||||
async function upload() { if (!props.appointmentId || !selectedFile.value) return; busy.value = true; try { await uploadTruckAppointmentAttachment(props.appointmentId, selectedFile.value, selectedType.value); selectedFile.value = null; emit('changed'); ElMessage.success('附件上传成功') } catch { ElMessage.error('上传失败') } finally { busy.value = false } }
|
||||
async function remove(item: TruckAttachmentReference) { if (!props.appointmentId || !item.id) return; await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); busy.value = true; try { await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch { ElMessage.error('删除失败') } finally { busy.value = false } }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isIso6346ContainerNumber, normalizeIso6346ContainerNumber } from './iso6346'
|
||||
|
||||
describe('ISO 6346 container numbers', () => {
|
||||
it('normalizes whitespace and validates the equipment category and check digit', () => {
|
||||
expect(normalizeIso6346ContainerNumber(' mscu 663987 0 ')).toBe('MSCU6639870')
|
||||
expect(isIso6346ContainerNumber('MSCU6639870')).toBe(true)
|
||||
expect(isIso6346ContainerNumber('MSCA6639870')).toBe(false)
|
||||
expect(isIso6346ContainerNumber('MSCU6639871')).toBe(false)
|
||||
expect(isIso6346ContainerNumber('MSCU663987')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38]
|
||||
|
||||
export function normalizeIso6346ContainerNumber(value: string | null | undefined) {
|
||||
return value?.replace(/[\s\u00a0\u3000]+/g, '').toUpperCase() || ''
|
||||
}
|
||||
|
||||
export function isIso6346ContainerNumber(value: string | null | undefined) {
|
||||
const normalized = normalizeIso6346ContainerNumber(value)
|
||||
if (!/^[A-Z]{3}[UJZ][0-9]{7}$/.test(normalized)) return false
|
||||
let sum = 0
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const current = normalized[index]!
|
||||
const number = /[0-9]/.test(current) ? Number(current) : letterValues[current.charCodeAt(0) - 65]!
|
||||
sum += number * (2 ** index)
|
||||
}
|
||||
return Number(normalized[10]) === (sum % 11) % 10
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentCreateView.vue'), 'utf8')
|
||||
|
||||
describe('TruckAppointmentCreateView independent creation contract', () => {
|
||||
it('starts with optional associations cleared and keeps required business fields explicit', () => {
|
||||
describe('TruckAppointmentCreateView booking-bound creation contract', () => {
|
||||
it('starts with required booking and container choices empty until the user selects them', () => {
|
||||
expect(source).toContain('customerId: null, carrierId: null')
|
||||
expect(source).toContain('oceanBookingId: null, containerBookingId: null, loadingPlanId: null')
|
||||
expect(source).toContain('plannedLoadingAt: null')
|
||||
|
||||
@@ -32,29 +32,32 @@ describe('TruckAppointmentDetailView status contract', () => {
|
||||
expect(source).toContain("statuses.indexOf(status) === index")
|
||||
})
|
||||
|
||||
it('initializes independently required fields without inventing associations', () => {
|
||||
expect(source).toContain("customerId: null, carrierId: null, plannedLoadingAt: null")
|
||||
it('initializes the required booking and container associations explicitly', () => {
|
||||
expect(source).toContain("customerId: null, carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null")
|
||||
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
|
||||
})
|
||||
|
||||
it('blocks dispatch until a bill number and bill attachment are present', () => {
|
||||
expect(source).toContain("return '确认派车前请先填写提单号'")
|
||||
expect(source).toContain("item.attachmentType === 'BILL_OF_LADING'")
|
||||
expect(source).toContain("return '确认派车前请上传“提单文件”附件'")
|
||||
it('blocks dispatch until the released booking, allocated real container and frozen bill number are present', () => {
|
||||
expect(source).toContain("return '确认派车前必须关联已放舱订舱'")
|
||||
expect(source).toContain("return '确认派车前必须关联该订舱下的柜次'")
|
||||
expect(source).toContain("return '确认派车前关联柜次必须录入真实 ISO 集装箱号'")
|
||||
expect(source).toContain("return '确认派车前所选订舱必须填写提单号'")
|
||||
expect(source).toContain("return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'")
|
||||
expect(source).toContain('getOceanBooking(resource.value.oceanBookingId)')
|
||||
expect(source).toContain("status === 'DISPATCHED' && dispatchBlockReason.value")
|
||||
expect(source).toContain('attachmentOpen.value = true')
|
||||
expect(source).not.toContain("item.attachmentType === 'BILL_OF_LADING'")
|
||||
})
|
||||
|
||||
it('keeps the main detail available when attachments or history fail', () => {
|
||||
expect(source).toContain('Promise.allSettled([')
|
||||
expect(source).toContain("attachments.status === 'fulfilled'")
|
||||
expect(source).toContain("histories.status === 'fulfilled'")
|
||||
expect(source).toContain('主详情数据不受影响')
|
||||
expect(source).toContain('主详情数据不受影响;派车时仍由服务端最终校验')
|
||||
expect(source).toContain('v-if="relatedError"')
|
||||
})
|
||||
|
||||
it('reloads a cached detail after an error without showing stale resource data', () => {
|
||||
expect(source).toContain("resource.value = null; history.value = []; relatedError.value = ''")
|
||||
expect(source).toContain("resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''")
|
||||
expect(source).toContain('onActivated(() => {')
|
||||
expect(source).toContain('if (error.value || resource.value?.id !== id.value) void load()')
|
||||
})
|
||||
|
||||
@@ -9,12 +9,15 @@ import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getOceanBooking, type OceanBookingDetail } from '@/modules/ocean-bookings'
|
||||
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
|
||||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||
import { truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, customerId: null, carrierId: null, plannedLoadingAt: null })
|
||||
import { isIso6346ContainerNumber } from '../iso6346'
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, customerId: null, carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null })
|
||||
const history = ref<TruckAppointmentStatusHistory[]>([])
|
||||
const bookingReadiness = ref<OceanBookingDetail | null>(null)
|
||||
const relatedError = ref('')
|
||||
let activatedOnce = false
|
||||
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
|
||||
@@ -22,15 +25,17 @@ const relationSnapshot = computed(() => resource.value ? [resource.value.custome
|
||||
async function loadRelated() {
|
||||
if (!resource.value) return
|
||||
relatedError.value = ''
|
||||
const [attachments, histories] = await Promise.allSettled([
|
||||
const [attachments, histories, booking] = await Promise.allSettled([
|
||||
listTruckAppointmentAttachments(id.value),
|
||||
listTruckAppointmentHistory(id.value),
|
||||
resource.value.oceanBookingId ? getOceanBooking(resource.value.oceanBookingId) : Promise.reject(new Error('拖车预约缺少订舱关联')),
|
||||
])
|
||||
if (attachments.status === 'fulfilled') resource.value.attachments = attachments.value
|
||||
if (histories.status === 'fulfilled') history.value = histories.value
|
||||
if (attachments.status === 'rejected' || histories.status === 'rejected') relatedError.value = '附件或状态历史暂时未能加载,主详情数据不受影响,可稍后刷新重试。'
|
||||
if (booking.status === 'fulfilled') bookingReadiness.value = booking.value
|
||||
if (attachments.status === 'rejected' || histories.status === 'rejected' || booking.status === 'rejected') relatedError.value = '附件、状态历史或订舱资料状态暂时未能加载,主详情数据不受影响;派车时仍由服务端最终校验。'
|
||||
}
|
||||
async function load() { loading.value = true; error.value = null; resource.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
async function load() { loading.value = true; error.value = null; resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
async function submit() { saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
|
||||
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
|
||||
const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppointmentStatus>> = { DRAFT: 'PENDING_DISPATCH', PENDING_DISPATCH: 'DISPATCHED', RESCHEDULED: 'DISPATCHED', DISPATCHED: 'PICKUP_IN_PROGRESS', PICKUP_IN_PROGRESS: 'ARRIVED_WAREHOUSE', ARRIVED_WAREHOUSE: 'LOADING', LOADING: 'DEPARTED', DEPARTED: 'COMPLETED' }
|
||||
@@ -43,13 +48,17 @@ const availableTargets = computed<TruckAppointmentStatus[]>(() => {
|
||||
})
|
||||
const dispatchBlockReason = computed(() => {
|
||||
if (!resource.value || !['PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value.status)) return ''
|
||||
if (!form.billOfLadingNo?.trim()) return '确认派车前请先填写提单号'
|
||||
if (!(resource.value.attachments || []).some((item) => item.attachmentType === 'BILL_OF_LADING')) return '确认派车前请上传“提单文件”附件'
|
||||
if (!form.oceanBookingId) return '确认派车前必须关联已放舱订舱'
|
||||
if (bookingReadiness.value && bookingReadiness.value.status !== 'RELEASED') return '确认派车前必须关联已放舱订舱'
|
||||
if (!form.containerBookingId) return '确认派车前必须关联该订舱下的柜次'
|
||||
if (!isIso6346ContainerNumber(resource.value.containerNo)) return '确认派车前关联柜次必须录入真实 ISO 集装箱号'
|
||||
if (!form.billOfLadingNo?.trim()) return '确认派车前所选订舱必须填写提单号'
|
||||
if (bookingReadiness.value && !bookingReadiness.value.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'
|
||||
return ''
|
||||
})
|
||||
async function changeStatus(status: TruckAppointmentStatus) {
|
||||
if (!resource.value || status === resource.value.status) return
|
||||
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; if (!form.billOfLadingNo?.trim()) return; attachmentOpen.value = true; return }
|
||||
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; return }
|
||||
try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
@@ -52,11 +52,13 @@ export function truckAppointmentActionLabel(action: string | null | undefined) {
|
||||
return truckAppointmentActionLabels[action] || '状态更新'
|
||||
}
|
||||
|
||||
export type TruckAttachmentType = 'DISPATCH_ORDER' | 'DRIVER_DOCUMENT' | 'VEHICLE_DOCUMENT' | 'OTHER'
|
||||
|
||||
export interface TruckAttachmentReference {
|
||||
id?: string
|
||||
fileId: string
|
||||
fileName: string
|
||||
attachmentType?: string | null
|
||||
attachmentType?: TruckAttachmentType | null
|
||||
fileHash?: string | null
|
||||
visibility?: string | null
|
||||
uploadedBy?: string | null
|
||||
@@ -139,8 +141,8 @@ export interface TruckAppointmentResource {
|
||||
|
||||
export interface SaveTruckAppointmentRequest {
|
||||
outboundOrderId?: string | null
|
||||
oceanBookingId?: string | null
|
||||
containerBookingId?: string | null
|
||||
oceanBookingId: string | null
|
||||
containerBookingId: string | null
|
||||
loadingPlanId?: string | null
|
||||
warehouseId?: string | null
|
||||
customerId: string | null
|
||||
|
||||
Reference in New Issue
Block a user