2026-08-08 15:15:04 +08:00
|
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
|
import { resolve } from 'node:path'
|
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
|
|
|
|
|
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingDetailView.vue'), 'utf8')
|
2026-08-15 10:03:03 +08:00
|
|
|
const routesSource = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/routes.ts'), 'utf8')
|
2026-08-08 15:15:04 +08:00
|
|
|
|
2026-08-11 18:06:35 +08:00
|
|
|
describe('OceanBookingDetailView interaction contract', () => {
|
2026-08-15 09:47:23 +08:00
|
|
|
it('recognizes the dedicated create route as an editable new booking', () => {
|
|
|
|
|
expect(source).toContain("route.name === 'ocean-booking-create' || id.value === 'new'")
|
|
|
|
|
expect(source).toContain('const editable = computed(() => isNew.value)')
|
2026-08-15 10:03:03 +08:00
|
|
|
expect(source).toContain("const canSave = computed(() => isNew.value && access.can('platform:firstleg:ocean-booking:create'))")
|
|
|
|
|
expect(source).toContain(':disabled="!editable"')
|
2026-08-15 09:47:23 +08:00
|
|
|
expect(source).not.toContain("status === 'DRAFT'")
|
2026-08-15 10:03:03 +08:00
|
|
|
expect(routesSource.indexOf("path: 'ocean-bookings/new'")).toBeLessThan(routesSource.indexOf("path: 'ocean-bookings/:bookingId'"))
|
|
|
|
|
expect(routesSource).toContain("name: 'ocean-booking-create'")
|
|
|
|
|
expect(routesSource).toContain("permission: 'platform:firstleg:ocean-booking:create'")
|
2026-08-15 09:47:23 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-11 18:06:35 +08:00
|
|
|
it('selects business objects and automatically keeps their ids and names together', () => {
|
2026-08-14 19:59:32 +08:00
|
|
|
expect(source).toContain('placeholder="请选择客户(可选)"')
|
|
|
|
|
expect(source).toContain('filterable clearable')
|
|
|
|
|
expect(source).toContain('@update:model-value="selectCustomer"')
|
|
|
|
|
expect(source).toContain('no-data-text="暂无可选客户"')
|
|
|
|
|
expect(source).toContain('customerId: null, customerName: null')
|
|
|
|
|
expect(source).toContain("value == null || value === '' ? null : String(value)")
|
|
|
|
|
expect(source).toContain('form.data.customerName = form.data.customerId ? form.data.customerName : null')
|
|
|
|
|
expect(source).toContain('normalizeDraftData()')
|
|
|
|
|
expect(source).not.toContain("customerId: [{ required: true")
|
2026-08-11 18:06:35 +08:00
|
|
|
expect(source).toContain('placeholder="请选择船公司"')
|
|
|
|
|
expect(source).toContain('placeholder="请选择订舱代理"')
|
2026-08-15 10:35:58 +08:00
|
|
|
expect(source).toContain('listSuppliers({ page: 1, size: 200, category: \'BOOKING\', status: \'ACTIVE\' })')
|
|
|
|
|
expect(source).toContain('selectShippingCompany')
|
|
|
|
|
expect(source).toContain('selectBookingAgent')
|
|
|
|
|
expect(source).toContain('bookingAgentLabel')
|
|
|
|
|
expect(source).toContain('暂无启用的订舱供应商')
|
2026-08-11 18:06:35 +08:00
|
|
|
expect(source).not.toContain('label="客户 ID"')
|
|
|
|
|
expect(source).not.toContain('label="船公司 ID"')
|
|
|
|
|
expect(source).not.toContain('label="订舱代理 ID"')
|
2026-08-08 15:15:04 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-11 18:06:35 +08:00
|
|
|
it('hides unavailable customers instead of filling the selector with disabled options', () => {
|
2026-08-14 13:03:11 +08:00
|
|
|
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
|
2026-08-14 14:27:03 +08:00
|
|
|
expect(source).toContain(':label="customer.tenantName"')
|
2026-08-14 13:03:11 +08:00
|
|
|
expect(source).not.toContain('customer.tenantCode')
|
2026-08-11 18:06:35 +08:00
|
|
|
expect(source).toContain("customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)")
|
|
|
|
|
expect(source).toContain('v-for="customer in selectableCustomers"')
|
|
|
|
|
expect(source).not.toContain(':disabled="customer.status !== \'ACTIVE\'"')
|
2026-08-10 09:12:06 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-12 09:25:55 +08:00
|
|
|
it('keeps historical partner names visible when an existing partner is no longer selectable', () => {
|
2026-08-14 14:27:03 +08:00
|
|
|
expect(source).toContain("form.data.shippingCompanyName || '船公司名称缺失'")
|
|
|
|
|
expect(source).toContain("form.data.bookingAgentName || '订舱代理名称缺失'")
|
2026-08-12 09:25:55 +08:00
|
|
|
expect(source).toContain("from '@/modules/master-data'")
|
2026-08-15 10:35:58 +08:00
|
|
|
expect(source).toContain("from '@/modules/suppliers/api/platformSuppliers'")
|
2026-08-12 09:25:55 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-11 18:43:56 +08:00
|
|
|
it('renders the translated status once in the detail heading', () => {
|
|
|
|
|
expect(source).toContain('<StatusBadge :status="statusLabel(detail.status)" />')
|
|
|
|
|
expect(source).not.toContain('<StatusBadge :status="detail.status" />')
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-11 18:06:35 +08:00
|
|
|
it('provides guidance for every free-text booking field', () => {
|
|
|
|
|
for (const placeholder of [
|
2026-08-14 19:59:32 +08:00
|
|
|
'例如:CNNGB', '例如:宁波港', '例如:USLAX', '例如:洛杉矶港',
|
|
|
|
|
'请输入最终交货地点', '请输入订舱备注(可选)',
|
2026-08-11 18:06:35 +08:00
|
|
|
]) expect(source).toContain(`placeholder="${placeholder}"`)
|
2026-08-08 15:15:04 +08:00
|
|
|
})
|
2026-08-12 16:40:09 +08:00
|
|
|
|
2026-08-15 09:47:23 +08:00
|
|
|
it('captures known offline booking facts during creation and only reviews them during release', () => {
|
2026-08-14 19:59:32 +08:00
|
|
|
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
|
2026-08-15 09:47:23 +08:00
|
|
|
expect(source).toContain(`v-model="form.data.${field}"`)
|
|
|
|
|
expect(source).toContain(`prop="${field}"`)
|
2026-08-14 19:59:32 +08:00
|
|
|
}
|
2026-08-15 09:47:23 +08:00
|
|
|
expect(source).not.toContain('确认放舱时录入')
|
|
|
|
|
expect(source).toContain('billOfLadingNo: value.billOfLadingNo || \'\'')
|
|
|
|
|
expect(source).toContain('estimatedSailingAt: value.estimatedSailingAt || null')
|
|
|
|
|
expect(source).toContain('if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!)')
|
|
|
|
|
expect(source).toContain('请复核已登记的真实订舱资料。放舱不会重新采集或修改这些信息。')
|
|
|
|
|
expect(source).toContain('const billOfLadingNo = detail.value.billOfLadingNo?.trim()')
|
|
|
|
|
expect(source).toContain('const estimatedSailingAt = detail.value.estimatedSailingAt')
|
|
|
|
|
expect(source).toContain('v-model="releaseReason"')
|
|
|
|
|
expect(source).not.toContain('v-model="releaseForm.')
|
|
|
|
|
expect(source).toContain('线下订舱资料已登记并确认')
|
2026-08-15 10:03:03 +08:00
|
|
|
expect(source).toContain('<Save :size="16" />登记订舱')
|
2026-08-15 09:47:23 +08:00
|
|
|
expect(source).toContain('登记后直接确认')
|
2026-08-14 19:59:32 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses source-backed free text terms and keeps capacity snapshots behind one specification control', () => {
|
|
|
|
|
expect(source).not.toContain('serviceTermOptions')
|
|
|
|
|
expect(source).not.toContain('freightTermOptions')
|
2026-08-14 19:02:34 +08:00
|
|
|
expect(source).toContain("const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC']")
|
2026-08-14 19:59:32 +08:00
|
|
|
expect(source).toContain('label="主要运输条款 Service Term" prop="serviceTerm"')
|
|
|
|
|
expect(source).toContain('<el-input v-model="form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term"')
|
|
|
|
|
expect(source).toContain('label="运费条款 Freight Term" prop="freightTerm"')
|
|
|
|
|
expect(source).toContain('<el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term"')
|
2026-08-14 19:02:34 +08:00
|
|
|
expect(source).toContain('<el-select v-model="container.containerType"')
|
|
|
|
|
expect(source).toContain('<el-select v-model="container.containerKind"')
|
|
|
|
|
expect(source).toContain(':aria-label="`设置箱型 ${index + 1} 的容量与限重`"')
|
|
|
|
|
expect(source).toContain('function containerSpecificationLabel(')
|
2026-08-14 19:59:32 +08:00
|
|
|
expect(source).not.toContain("['PREPAID', 'COLLECT']")
|
2026-08-14 19:02:34 +08:00
|
|
|
expect(source).not.toContain('<el-input v-model="container.containerType"')
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-12 18:43:54 +08:00
|
|
|
it('captures channel, warehouse, capacity snapshots and uses the release DTO', () => {
|
2026-08-14 18:50:11 +08:00
|
|
|
expect(source).toContain("listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' })")
|
|
|
|
|
expect(source).toContain("warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC'")
|
|
|
|
|
expect(source).toContain('label="物流渠道" prop="channelCode"')
|
|
|
|
|
expect(source).toContain('<el-select :model-value="form.data.channelCode" filterable')
|
|
|
|
|
expect(source).toContain(':model-value="form.data.channelCode" filterable')
|
|
|
|
|
expect(source).not.toContain('<el-input v-model="form.data.channelCode"')
|
|
|
|
|
expect(source).toContain('listWarehouseLogisticsChannels')
|
|
|
|
|
expect(source).toContain('void loadChannels(warehouseId)')
|
|
|
|
|
expect(source).not.toContain('v-model="form.data.channelName"')
|
|
|
|
|
expect(source).toContain('form.data.channelCode = \'\'')
|
2026-08-12 19:27:55 +08:00
|
|
|
expect(source).toContain('function selectWarehouse(value: unknown)')
|
|
|
|
|
expect(source).toContain('form.data.warehouseName = warehouses.value.find')
|
|
|
|
|
expect(source).toContain('@update:model-value="selectWarehouse"')
|
|
|
|
|
expect(source).not.toContain('@update:model-value="(value) =>')
|
2026-08-12 18:43:54 +08:00
|
|
|
expect(source).toContain('v-model="container.capacityCbm"')
|
|
|
|
|
expect(source).toContain('v-model="container.maxWeightKg"')
|
2026-08-14 14:27:03 +08:00
|
|
|
expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version')
|
|
|
|
|
expect(source).toContain('title="确认放舱"')
|
2026-08-12 18:53:18 +08:00
|
|
|
expect(source).toContain('createBlankContainerRequirement()')
|
|
|
|
|
expect(source).toContain('containerRequirementError(form.data.containerRequirements)')
|
2026-08-12 18:50:03 +08:00
|
|
|
expect(source).toContain('function validateContainerRequirements()')
|
|
|
|
|
expect(source).not.toContain('capacityCbm: 68')
|
|
|
|
|
expect(source).not.toContain('maxWeightKg: 28000')
|
2026-08-12 18:43:54 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-14 18:50:11 +08:00
|
|
|
it('keeps every container requirement removable without collapsing its fields', () => {
|
|
|
|
|
expect(source).toContain('@click="removeContainer(index)"')
|
|
|
|
|
expect(source).toContain(':aria-label="`删除箱型 ${index + 1}`"')
|
|
|
|
|
expect(source).toContain("form.data.containerRequirements.length === 1 ? '至少保留一条箱型' : '删除该箱型'")
|
|
|
|
|
expect(source).toContain(':disabled="form.data.containerRequirements.length === 1"')
|
|
|
|
|
expect(source).toContain('function removeContainer(index: number) { if (form.data.containerRequirements.length > 1)')
|
|
|
|
|
expect(source).toContain('<table class="container-table">')
|
|
|
|
|
expect(source).toContain('.container-table-wrap{overflow-x:auto')
|
2026-08-14 19:02:34 +08:00
|
|
|
expect(source).toContain('.container-table td :deep(.el-select),.container-table td :deep(.el-input-number){width:100%;min-width:0}')
|
2026-08-14 18:50:11 +08:00
|
|
|
expect(source).toContain(':controls="false" placeholder="请输入数量"')
|
|
|
|
|
expect(source).not.toContain('class="container-row"')
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-12 16:40:09 +08:00
|
|
|
it('keeps view, create and edit on the formal detail route with a return action', () => {
|
|
|
|
|
expect(source).toContain("router.push('/ocean-bookings')")
|
|
|
|
|
expect(source).toContain("router.replace(`/ocean-bookings/${created.id}`)")
|
|
|
|
|
expect(source).not.toContain('AppDrawer')
|
|
|
|
|
expect(source).not.toContain('打印')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('groups real detail fields into the frozen three-column prototype structure', () => {
|
|
|
|
|
expect(source).toContain('aria-label="基本信息"')
|
|
|
|
|
expect(source).toContain('aria-label="航线船期"')
|
|
|
|
|
expect(source).toContain('aria-label="条款与柜量"')
|
|
|
|
|
expect(source).toContain('grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr))')
|
2026-08-14 19:02:34 +08:00
|
|
|
expect(source).toContain('.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}')
|
2026-08-14 18:50:11 +08:00
|
|
|
expect(source).toContain('class="booking-field--wide" label="交货地点"')
|
2026-08-12 16:40:09 +08:00
|
|
|
expect(source).toContain('@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}')
|
2026-08-14 19:02:34 +08:00
|
|
|
expect(source).toContain('@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}')
|
|
|
|
|
expect(source).toContain('min-width:520px')
|
2026-08-12 16:40:09 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps the three real booking parties in their own three-column section', () => {
|
|
|
|
|
expect(source).toContain("{ value: 'SHIPPER', label: '发货人' }")
|
|
|
|
|
expect(source).toContain("{ value: 'CONSIGNEE', label: '收货人' }")
|
|
|
|
|
expect(source).toContain("{ value: 'NOTIFY_PARTY', label: '通知人' }")
|
|
|
|
|
expect(source).toContain('.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))')
|
|
|
|
|
})
|
2026-08-14 19:02:34 +08:00
|
|
|
|
|
|
|
|
it('shows client-side validation failures instead of rendering an empty error alert', () => {
|
|
|
|
|
expect(source).toContain(':title="actionError.message"')
|
2026-08-15 09:47:23 +08:00
|
|
|
expect(source).toContain("actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }")
|
2026-08-14 19:02:34 +08:00
|
|
|
expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }")
|
|
|
|
|
expect(source).toContain('规格(必填)')
|
|
|
|
|
expect(source).toContain('placeholder="单位名称(必填)"')
|
|
|
|
|
})
|
2026-08-15 09:47:23 +08:00
|
|
|
|
|
|
|
|
it('does not expose the removed draft submission lifecycle', () => {
|
|
|
|
|
expect(source).not.toContain("submit: '提交'")
|
|
|
|
|
expect(source).not.toContain("confirm: '确认订舱'")
|
|
|
|
|
expect(source).not.toContain("fail: '标记失败'")
|
|
|
|
|
expect(source).not.toContain("status === 'DRAFT'")
|
|
|
|
|
expect(source).not.toContain('updateOceanBooking')
|
|
|
|
|
})
|
2026-08-08 15:15:04 +08:00
|
|
|
})
|