feat(admin): bind booking documents and truck containers
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user