Release/test 合并上生产 #2
@@ -1,10 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import OceanBookingDocumentsPanel from './OceanBookingDocumentsPanel.vue'
|
||||
import { uploadOceanBookingDocument } from '../api/platformOceanBookings'
|
||||
|
||||
vi.mock('../api/platformOceanBookings', () => ({
|
||||
downloadOceanBookingDocument: vi.fn(),
|
||||
invalidateOceanBookingDocument: vi.fn(),
|
||||
uploadOceanBookingDocument: vi.fn(),
|
||||
}))
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue'), 'utf8')
|
||||
|
||||
describe('OceanBookingDocumentsPanel contract', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
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: '提单文件'")
|
||||
@@ -22,6 +33,31 @@ describe('OceanBookingDocumentsPanel contract', () => {
|
||||
expect(source).toContain('URL.createObjectURL(blob)')
|
||||
})
|
||||
|
||||
it('accepts every file format while rejecting a single file above 50 MiB before upload', () => {
|
||||
expect(source).toContain('const maxFileBytes = 50 * 1024 * 1024')
|
||||
expect(source).toContain('file.size > maxFileBytes')
|
||||
expect(source).toContain('单个订舱资料文件不能超过 50MB,请重新选择')
|
||||
expect(source).toContain('任意格式,单文件不超过 50MB')
|
||||
expect(source).not.toContain('accept="')
|
||||
})
|
||||
|
||||
it('rejects an oversized arbitrary-format file before calling the backend', async () => {
|
||||
const wrapper = mount(OceanBookingDocumentsPanel, {
|
||||
props: { bookingId: '123', bookingVersion: 2, bookingStatus: 'DRAFT', documents: [], canManage: true },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
const file = new File(['data'], 'archive.custom', { type: 'application/x-custom' })
|
||||
Object.defineProperty(file, 'size', { value: 50 * 1024 * 1024 + 1 })
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
|
||||
await input.trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('单个订舱资料文件不能超过 50MB')
|
||||
expect(uploadOceanBookingDocument).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('makes released booking files permanently read-only and preserves traced errors', () => {
|
||||
expect(source).toContain("props.bookingStatus === 'DRAFT' && props.canManage")
|
||||
expect(source).toContain('已放舱 · 永久只读')
|
||||
|
||||
@@ -12,6 +12,7 @@ const emit = defineEmits<{ updated: [documents: OceanBookingDocument[], bookingV
|
||||
const busyType = ref<OceanBookingDocumentType | ''>('')
|
||||
const busyDocumentId = ref('')
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const maxFileBytes = 50 * 1024 * 1024
|
||||
const types: Array<{ value: OceanBookingDocumentType; label: string; description: string }> = [
|
||||
{ value: 'BOOKING_CONFIRMATION', label: '订舱确认文件', description: '用于确认已订妥舱位及放舱前资料完整性' },
|
||||
{ value: 'BILL_OF_LADING', label: '提单文件', description: '用于提单号和拖车派车前资料校验' },
|
||||
@@ -75,6 +76,10 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || !editable.value) return
|
||||
if (file.size > maxFileBytes) {
|
||||
error.value = { message: '单个订舱资料文件不能超过 50MB,请重新选择' }
|
||||
return
|
||||
}
|
||||
const previous = documentsOfType(type)
|
||||
if (previous.length) {
|
||||
try {
|
||||
@@ -113,7 +118,7 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
<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>
|
||||
<div class="booking-document-card__heading"><div><h3>{{ type.label }}</h3><p>{{ type.description }}</p><small class="booking-document-limit">任意格式,单文件不超过 50MB</small></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" :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>
|
||||
@@ -123,5 +128,5 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
</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%}}
|
||||
.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-limit{display:block;margin-top:5px;color:var(--cc-muted);font-size:11px}.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>
|
||||
|
||||
@@ -54,6 +54,9 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
it('performs the complete release gate and presents the irreversible warning', () => {
|
||||
expect(source).toContain('function releaseValidationError()')
|
||||
expect(source).toContain('预计到港时间不能早于预计开船时间')
|
||||
expect(source).toContain('放舱前请填写起运港代码或名称')
|
||||
expect(source).toContain('放舱前请填写目的港代码或名称')
|
||||
expect(source).toContain("[form.data.placeOfDelivery, '交货地点']")
|
||||
expect(source).toContain("[form.data.customerId, '客户']")
|
||||
expect(source).toContain("if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'")
|
||||
expect(source).not.toContain('请先保存草稿后再放舱')
|
||||
|
||||
@@ -299,11 +299,14 @@ async function openReleaseDialog() {
|
||||
}
|
||||
function releaseValidationError() {
|
||||
if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'
|
||||
if (!form.data.departurePortCode?.trim() && !form.data.departurePortName?.trim()) return '放舱前请填写起运港代码或名称'
|
||||
if (!form.data.destinationPortCode?.trim() && !form.data.destinationPortName?.trim()) return '放舱前请填写目的港代码或名称'
|
||||
const requiredFields: Array<[unknown, string]> = [
|
||||
[form.data.customerId, '客户'], [form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'],
|
||||
[form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'],
|
||||
[form.data.estimatedSailingAt, '预计开船时间'], [form.data.estimatedArrivalAt, '预计到港时间'],
|
||||
[form.data.serviceTerm, 'Service Term'], [form.data.freightTerm, 'Freight Term'],
|
||||
[form.data.placeOfDelivery, '交货地点'],
|
||||
[form.data.shippingCompanyId, '船公司'], [form.data.bookingAgentId, '订舱代理'],
|
||||
]
|
||||
const missing = requiredFields.find(([value]) => !String(value || '').trim())
|
||||
|
||||
Reference in New Issue
Block a user