This commit is contained in:
2026-08-17 13:24:55 +08:00
parent 6a0c0b9229
commit 4a9438315c
20 changed files with 299 additions and 143 deletions
@@ -5,9 +5,10 @@ import { http } from '@/shared/api/http'
const detailResponse = (version = 0, status = 'CONFIRMED') => ({
oceanBooking: {
id: 123, bookingNo: 'OB123', status, version, customerId: 456, customerName: '客户',
containers: [],
},
parties: [], containerRequirements: [], containerAllocations: [], histories: [],
parties: [],
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: '客户' }],
histories: [],
})
describe('ocean bookings api', () => {
@@ -22,15 +23,19 @@ describe('ocean bookings api', () => {
it('uses version zero for create and extracts the id from the returned Detail', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(0, 'CONFIRMED') })
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: 'SEA', warehouseId: '8', warehouseName: '义乌仓', billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00', serviceTerm: null, freightTerm: null, parties: [], containerRequirements: [] } })
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: 'SEA', warehouseId: '8', billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00', serviceTerm: 'CY-CY', freightTerm: 'PREPAID', parties: [], containers: [{ containerNo: 'MSCU1234567', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }] } })
const sentPayload = post.mock.calls[0]?.[1] as { expectedVersion: number; data: Record<string, unknown> }
expect(sentPayload).toMatchObject({ expectedVersion: 0, data: { customerId: null, customerName: null, serviceTerm: null, freightTerm: null } })
expect(sentPayload).toMatchObject({ expectedVersion: 0, data: { customerId: null, customerName: null, serviceTerm: 'CY-CY', freightTerm: 'PREPAID' } })
expect(sentPayload).not.toMatchObject({ data: { customerId: '', customerName: '' } })
expect(sentPayload.data).toMatchObject({ billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' })
expect(sentPayload.data).toMatchObject({ containers: [{ containerNo: 'MSCU1234567', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }] })
expect(sentPayload.data).not.toHaveProperty('containerRequirements')
expect(sentPayload.data).not.toHaveProperty('warehouseName')
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
expect(created.id).toBe('123')
expect(created.version).toBe(0)
expect(created.status).toBe('CONFIRMED')
expect(created.containers[0]).toMatchObject({ containerBookingId: '9007199254740992', requirementId: '9007199254740994', allocationId: '9007199254740996', warehouseId: '9007199254740996', customerId: '456' })
})
it('uses action and change-specific request contracts', async () => {
@@ -23,7 +23,6 @@ function booking(raw: OceanBookingResource): OceanBookingResource {
bookingAgent: raw.bookingAgent ?? raw.bookingAgentName ?? null,
etd: raw.etd ?? raw.estimatedSailingAt ?? null,
eta: raw.eta ?? raw.estimatedArrivalAt ?? null,
containers: (raw.containers || []).map((container) => ({ ...container, id: String(container.id ?? '') })),
}
}
function detail(raw: OceanBookingDetail): OceanBookingDetail {
@@ -34,8 +33,14 @@ function detail(raw: OceanBookingDetail): OceanBookingDetail {
...source,
oceanBooking: source,
parties: (raw.parties || []).map((party) => ({ ...party, id: party.id == null ? undefined : String(party.id) })),
containerRequirements: (raw.containerRequirements || []).map((container) => ({ ...container, id: container.id == null ? undefined : String(container.id) })),
containerAllocations: raw.containerAllocations || [],
containers: (raw.containers || []).map((container) => ({
...container,
containerBookingId: String(container.containerBookingId),
requirementId: String(container.requirementId),
allocationId: String(container.allocationId),
warehouseId: String(container.warehouseId),
customerId: container.customerId == null ? null : String(container.customerId),
})),
histories,
statusHistory: histories,
}
@@ -1,16 +1,26 @@
import { describe, expect, it } from 'vitest'
import { containerRequirementError, createBlankContainerRequirement } from './containerRequirements'
import { bookingContainerError, createBlankBookingContainer, selectBookingContainerType } from './containerRequirements'
describe('ocean booking container requirements', () => {
it('uses the fixed platform specification for a new container type', () => {
expect(createBlankContainerRequirement()).toEqual({
containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, quantity: 1,
describe('ocean booking real containers', () => {
it('starts with one real container and applies the fixed platform specification', () => {
const container = createBlankBookingContainer()
expect(container).toEqual({
containerNo: '', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000,
})
selectBookingContainerType(container, '20GP')
expect(container).toMatchObject({ containerType: '20GP', capacityCbm: 33, maxWeightKg: 28000 })
})
it('rejects missing or non-positive capacity, weight and quantity', () => {
expect(containerRequirementError([{ ...createBlankContainerRequirement(), quantity: 0 }])).toContain('固定规格')
expect(containerRequirementError([{ containerType: 'UNKNOWN', containerKind: 'FCL', capacityCbm: 1, maxWeightKg: 1, quantity: 1 }])).toContain('UNKNOWN')
expect(containerRequirementError([createBlankContainerRequirement()])).toBeNull()
it('requires unique real container numbers and fixed specifications', () => {
const first = { ...createBlankBookingContainer(), containerNo: 'MSCU1234567' }
expect(bookingContainerError([createBlankBookingContainer()])).toContain('尚未填写集装箱号')
expect(bookingContainerError([first, { ...first, containerNo: 'mscu1234567' }])).toContain('重复')
expect(bookingContainerError([{ ...first, capacityCbm: 75 }])).toContain('固定规格')
expect(bookingContainerError([first])).toBeNull()
})
it('limits one booking to twenty real containers', () => {
const containers = Array.from({ length: 21 }, (_, index) => ({ ...createBlankBookingContainer(), containerNo: `MSCU${index}` }))
expect(bookingContainerError(containers)).toContain('最多登记 20 个')
})
})
@@ -1,19 +1,26 @@
import type { OceanBookingContainer } from './types'
import type { OceanBookingContainerInput } from './types'
import { applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
export function createBlankContainerRequirement(): OceanBookingContainer {
return { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, quantity: 1 }
export function createBlankBookingContainer(): OceanBookingContainerInput {
return { containerNo: '', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
}
export function selectContainerRequirementType(requirement: OceanBookingContainer, containerType: string) {
applyContainerSpecification(requirement, containerType)
export function selectBookingContainerType(container: OceanBookingContainerInput, containerType: string) {
applyContainerSpecification(container, containerType)
}
export function containerRequirementError(requirements: OceanBookingContainer[]) {
const invalid = requirements.find((item) => {
export function bookingContainerError(containers: OceanBookingContainerInput[]) {
if (!containers.length) return '请至少登记一个真实柜'
if (containers.length > 20) return '单次订舱最多登记 20 个真实柜'
const normalizedNumbers = containers.map((item) => item.containerNo.trim().toUpperCase())
const missingNumberIndex = normalizedNumbers.findIndex((value) => !value)
if (missingNumberIndex >= 0) return `第 ${missingNumberIndex + 1} 个真实柜尚未填写集装箱号`
const duplicateNumber = normalizedNumbers.find((value, index) => normalizedNumbers.indexOf(value) !== index)
if (duplicateNumber) return `集装箱号 ${duplicateNumber} 在本次订舱中重复`
const invalid = containers.find((item) => {
const specification = containerSpecification(item.containerType)
return !specification || Number(item.capacityCbm) !== specification.capacityCbm
|| Number(item.maxWeightKg) !== specification.maxWeightKg || !(Number(item.quantity) > 0)
|| Number(item.maxWeightKg) !== specification.maxWeightKg
})
return invalid ? `箱型 ${invalid.containerType || '未命名'} 必须使用平台固定规格,请重新选择柜型` : null
return invalid ? `柜型 ${invalid.containerType || '未命名'} 必须使用平台固定规格,请重新选择柜型` : null
}
@@ -86,7 +86,7 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain('登记后直接确认')
})
it('uses source-backed free text terms and keeps capacity snapshots behind one specification control', () => {
it('uses source-backed free text terms and fixed container specifications', () => {
expect(source).not.toContain('serviceTermOptions')
expect(source).not.toContain('freightTermOptions')
expect(source).toContain('const containerTypeOptions = CONTAINER_SPECIFICATIONS')
@@ -96,8 +96,9 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain('<el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term"')
expect(source).toContain('<el-select :model-value="container.containerType"')
expect(source).toContain('<el-select v-model="container.containerKind"')
expect(source).toContain('规格(必填)')
expect(source).toContain('function containerSpecificationLabel(')
expect(source).toContain('<th scope="col">容量</th><th scope="col">限重</th>')
expect(source).toContain('{{ container.capacityCbm }} m³')
expect(source).toContain('{{ container.maxWeightKg }} kg')
expect(source).not.toContain("['PREPAID', 'COLLECT']")
expect(source).not.toContain('<el-input v-model="container.containerType"')
})
@@ -114,30 +115,35 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).not.toContain('v-model="form.data.channelName"')
expect(source).toContain('form.data.channelCode = \'\'')
expect(source).toContain('function selectWarehouse(value: unknown)')
expect(source).toContain('form.data.warehouseName = warehouses.value.find')
expect(source).not.toContain('form.data.warehouseName')
expect(source).toContain('@update:model-value="selectWarehouse"')
expect(source).not.toContain('@update:model-value="(value) =>')
expect(source).toContain('selectContainerRequirementType(container')
expect(source).toContain('selectBookingContainerType(container')
expect(source).not.toContain('v-model="container.capacityCbm"')
expect(source).not.toContain('v-model="container.maxWeightKg"')
expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version')
expect(source).toContain('title="确认放舱"')
expect(source).toContain('createBlankContainerRequirement()')
expect(source).toContain('containerRequirementError(form.data.containerRequirements)')
expect(source).toContain('function validateContainerRequirements()')
expect(source).toContain('containerSpecificationLabel(container)')
expect(source).toContain('createBlankBookingContainer()')
expect(source).toContain('bookingContainerError(form.data.containers)')
expect(source).toContain('function validateBookingContainers()')
expect(source).toContain('form.data.containers.forEach')
})
it('keeps every container requirement removable without collapsing its fields', () => {
it('registers one real container per row and keeps each row removable', () => {
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(':aria-label="`删除真实柜 ${index + 1}`"')
expect(source).toContain("form.data.containers.length === 1 ? '至少保留一个真实柜' : '删除该真实柜'")
expect(source).toContain(':disabled="form.data.containers.length === 1"')
expect(source).toContain('function removeContainer(index: number) { if (form.data.containers.length > 1)')
expect(source).toContain('<table class="container-table">')
expect(source).toContain('.container-table-wrap{overflow-x:auto')
expect(source).toContain('.container-table td :deep(.el-select),.container-table td :deep(.el-input-number){width:100%;min-width:0}')
expect(source).toContain(':controls="false" placeholder="请输入数量"')
expect(source).toContain('v-model="container.containerNo"')
expect(source).toContain('maxlength="64" placeholder="请输入真实集装箱号"')
expect(source).toContain(':disabled="form.data.containers.length >= 20"')
expect(source).toContain('{{ form.data.containers.length }} / 20 个')
expect(source).toContain("detail.containers[index]?.cabinetNo || '—'")
expect(source).not.toContain('container.quantity')
expect(source).not.toContain('form.data.containerRequirements')
expect(source).not.toContain('class="container-row"')
})
@@ -151,13 +157,13 @@ describe('OceanBookingDetailView interaction contract', () => {
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('aria-label="条款与真实柜"')
expect(source).toContain('grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr))')
expect(source).toContain('.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}')
expect(source).toContain('class="booking-field--wide" label="交货地点"')
expect(source).toContain('@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}')
expect(source).toContain('@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}')
expect(source).toContain('min-width:520px')
expect(source).toContain('min-width:780px')
})
it('keeps the three real booking parties in their own three-column section', () => {
@@ -171,7 +177,7 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain(':title="actionError.message"')
expect(source).toContain("actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }")
expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }")
expect(source).toContain('规格(必填)')
expect(source).toContain('aria-label="真实柜登记"')
expect(source).toContain('placeholder="单位名称(必填)"')
})
@@ -35,7 +35,7 @@ import type {
OceanBookingStatus,
} from '../types'
import { oceanBookingStatusLabel } from '../status'
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
import { bookingContainerError, createBlankBookingContainer, selectBookingContainerType } from '../containerRequirements'
const partyTypes = [
{ value: 'SHIPPER', label: '发货人' },
@@ -98,9 +98,9 @@ function blankParties(): OceanBookingParty[] {
}
function blankData(): OceanBookingDraftData {
return {
customerId: null, customerName: null, channelCode: '', warehouseId: '', warehouseName: '', serviceTerm: null, freightTerm: null, parties: blankParties(),
customerId: null, customerName: null, channelCode: '', warehouseId: '', serviceTerm: null, freightTerm: null, parties: blankParties(),
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
containerRequirements: [createBlankContainerRequirement()],
containers: [createBlankBookingContainer()],
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
@@ -162,11 +162,11 @@ function normalizeDraftData() {
form.data.billOfLadingNo = form.data.billOfLadingNo.trim()
form.data.vesselName = form.data.vesselName.trim()
form.data.voyageNo = form.data.voyageNo.trim()
form.data.containers.forEach((container) => { container.containerNo = container.containerNo.trim() })
}
function selectWarehouse(value: unknown) {
const warehouseId = String(value || '')
form.data.warehouseId = warehouseId
form.data.warehouseName = warehouses.value.find((warehouse) => String(warehouse.id) === warehouseId)?.warehouseName || ''
form.data.channelCode = ''
selectedChannelName.value = ''
channels.value = []
@@ -208,7 +208,7 @@ function applyDetail(value: OceanBookingDetail) {
detail.value = value
form.expectedVersion = value.version
form.data = {
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '', warehouseName: value.warehouseName || '',
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '',
billOfLadingNo: value.billOfLadingNo || '', vesselName: value.vesselName || '', voyageNo: value.voyageNo || '',
estimatedSailingAt: value.estimatedSailingAt || null, estimatedArrivalAt: value.estimatedArrivalAt || null,
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
@@ -218,7 +218,7 @@ function applyDetail(value: OceanBookingDetail) {
placeOfDelivery: value.placeOfDelivery, serviceTerm: value.serviceTerm, freightTerm: value.freightTerm,
remark: value.remark,
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) => ({ containerType, containerKind, capacityCbm: capacityCbm ?? null, maxWeightKg: maxWeightKg ?? null, quantity: quantity ?? null })) : [createBlankContainerRequirement()],
containers: value.containers.length ? value.containers.map(({ containerNo, containerType, containerKind, capacityCbm, maxWeightKg }) => ({ containerNo, containerType, containerKind, capacityCbm, maxWeightKg })) : [createBlankBookingContainer()],
}
selectedChannelName.value = value.channelName || ''
channels.value = []
@@ -248,7 +248,7 @@ async function save() {
actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }
return
}
if (!validateContainerRequirements()) return
if (!validateBookingContainers()) return
saving.value = true
actionError.value = null
try {
@@ -319,7 +319,6 @@ async function runAction(action: OceanBookingAction) {
if (!detail.value || action === 'change-request') { if (action === 'change-request') openChangeDialog(); return }
if (action === 'release') { openReleaseDialog(); return }
actionError.value = null
if (!validateContainerRequirements()) return
try {
const reason = await requiredReason(action)
await ElMessageBox.confirm(`确认执行“${actionLabels[action]}”吗?`, actionLabels[action], { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
@@ -330,19 +329,18 @@ async function runAction(action: OceanBookingAction) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
} finally { actionSaving.value = false }
}
function validateContainerRequirements() {
const message = containerRequirementError(form.data.containerRequirements)
function validateBookingContainers() {
const message = bookingContainerError(form.data.containers)
if (message) {
actionError.value = { message }
return false
}
return true
}
function containerSpecificationLabel(container: OceanBookingDraftData['containerRequirements'][number]) {
return `${container.capacityCbm} m³ / ${container.maxWeightKg} kg`
function addContainer() {
if (form.data.containers.length < 20) form.data.containers.push(createBlankBookingContainer())
}
function addContainer() { form.data.containerRequirements.push(createBlankContainerRequirement()) }
function removeContainer(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
function removeContainer(index: number) { if (form.data.containers.length > 1) form.data.containers.splice(index, 1) }
onMounted(() => { void load(); void loadOptions() })
</script>
@@ -397,32 +395,34 @@ onMounted(() => { void load(); void loadOptions() })
<el-form-item label="预计到港" prop="estimatedArrivalAt"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
</div>
</section>
<section class="booking-group" aria-label="条款与柜量">
<h3>条款与柜量</h3><div class="booking-group__body">
<section class="booking-group" aria-label="条款与真实柜">
<h3>条款与真实柜</h3><div class="booking-group__body">
<div class="booking-terms">
<el-form-item label="主要运输条款 Service Term" prop="serviceTerm"><el-input v-model="form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term" /></el-form-item>
<el-form-item label="运费条款 Freight Term" prop="freightTerm"><el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term" /></el-form-item>
</div>
<div class="container-table-wrap" aria-label="箱型箱量">
<div class="container-table-wrap" aria-label="真实柜登记">
<table class="container-table">
<thead><tr><th scope="col">操作</th><th scope="col">箱型</th><th scope="col">箱种</th><th scope="col">规格(必填)</th><th scope="col">数量</th></tr></thead>
<thead><tr><th scope="col">操作</th><th scope="col">集装箱号</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail" scope="col">柜次号</th></tr></thead>
<tbody>
<tr v-for="(container, index) in form.data.containerRequirements" :key="index">
<tr v-for="(container, index) in form.data.containers" :key="detail?.containers[index]?.containerBookingId || index">
<td class="container-table__action">
<el-tooltip v-if="editable" :content="form.data.containerRequirements.length === 1 ? '至少保留一条箱型' : '删除该箱型'" placement="top">
<span><el-button circle plain size="small" type="danger" :aria-label="`删除箱型 ${index + 1}`" :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)"><Trash2 :size="14" /></el-button></span>
<el-tooltip v-if="editable" :content="form.data.containers.length === 1 ? '至少保留一个真实柜' : '删除该真实柜'" placement="top">
<span><el-button circle plain size="small" type="danger" :aria-label="`删除真实柜 ${index + 1}`" :disabled="form.data.containers.length === 1" @click="removeContainer(index)"><Trash2 :size="14" /></el-button></span>
</el-tooltip>
<span v-else aria-hidden="true">—</span>
</td>
<td><el-select :model-value="container.containerType" :disabled="!editable" placeholder="请选择箱型" @update:model-value="selectContainerRequirementType(container, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></el-select></td>
<td><el-input v-model="container.containerNo" :disabled="!editable" maxlength="64" placeholder="请输入真实集装箱号" /></td>
<td><el-select :model-value="container.containerType" :disabled="!editable" placeholder="请选择柜型" @update:model-value="selectBookingContainerType(container, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></el-select></td>
<td><el-select v-model="container.containerKind" :disabled="!editable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></td>
<td class="container-table__specification"><span>{{ containerSpecificationLabel(container) }}</span></td>
<td><el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" :controls="false" placeholder="请输入数量" /></td>
<td class="container-table__specification"><span>{{ container.capacityCbm }} m³</span></td>
<td class="container-table__specification"><span>{{ container.maxWeightKg }} kg</span></td>
<td v-if="detail"><span>{{ detail.containers[index]?.cabinetNo || '—' }}</span></td>
</tr>
</tbody>
</table>
</div>
<el-button v-if="editable" class="add-container" size="small" @click="addContainer"><Plus :size="15" />新增箱型</el-button>
<div v-if="editable" class="container-actions"><el-button class="add-container" size="small" :disabled="form.data.containers.length >= 20" @click="addContainer"><Plus :size="15" />新增真实柜</el-button><span>{{ form.data.containers.length }} / 20 个</span></div>
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" placeholder="请输入订舱备注(可选)" /></el-form-item>
</div>
</section>
@@ -478,5 +478,5 @@ onMounted(() => { void load(); void loadOptions() })
<style scoped>
.channel-code{display:block;margin-left:8px;color:var(--cc-muted);font-size:11px}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:5px;color:var(--cc-muted);font-size:12px;line-height:20px}.field-error{color:var(--el-color-danger)}
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:20px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:18px}.actions{display:flex;flex-wrap:wrap;gap:8px}.booking-groups{display:grid;grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr));gap:16px}.booking-group{min-width:0;overflow:hidden;border:1px solid #e8edf3;border-radius:8px;background:#fff}.booking-group>h3{margin:0;padding:14px 16px;border-bottom:1px solid #eef2f6;color:#1f2937;font-size:14px}.booking-group__body{padding:16px}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.subsection{display:grid;gap:10px;padding:14px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.required-mark{margin-right:4px;color:var(--el-color-danger)}.container-table-wrap{overflow-x:auto;margin-bottom:10px}.container-table{width:100%;min-width:520px;table-layout:fixed;border-collapse:collapse;font-size:12px}.container-table th,.container-table td{padding:7px 6px;border-bottom:1px solid #eef2f6;text-align:left;vertical-align:middle}.container-table th{background:#fafafa;color:var(--cc-muted);font-weight:650;white-space:nowrap}.container-table th:first-child,.container-table td:first-child{width:56px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:110px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:110px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:180px}.container-table th:nth-child(5),.container-table td:nth-child(5){width:78px}.container-table td :deep(.el-select),.container-table td :deep(.el-input-number){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__action :deep(.el-button){width:28px;height:28px}.container-table__specification :deep(.el-button){max-width:100%;overflow:hidden}.container-table__specification :deep(.el-button span){overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.container-specification-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.container-specification-fields label{display:grid;gap:6px;color:var(--cc-muted);font-size:12px}.container-specification-fields :deep(.el-input-number){width:100%}.add-container{margin-bottom:18px}.history-panel strong{font-size:14px}@media(max-width:1599px){.booking-groups{grid-template-columns:repeat(2,minmax(0,1fr))}.booking-group:last-child{grid-column:1/-1}}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:14px}.actions :deep(.el-button){min-height:44px}}
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:20px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:18px}.actions{display:flex;flex-wrap:wrap;gap:8px}.booking-groups{display:grid;grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr));gap:16px}.booking-group{min-width:0;overflow:hidden;border:1px solid #e8edf3;border-radius:8px;background:#fff}.booking-group>h3{margin:0;padding:14px 16px;border-bottom:1px solid #eef2f6;color:#1f2937;font-size:14px}.booking-group__body{padding:16px}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.subsection{display:grid;gap:10px;padding:14px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.required-mark{margin-right:4px;color:var(--el-color-danger)}.container-table-wrap{overflow-x:auto;margin-bottom:10px}.container-table{width:100%;min-width:780px;table-layout:fixed;border-collapse:collapse;font-size:12px}.container-table th,.container-table td{padding:7px 6px;border-bottom:1px solid #eef2f6;text-align:left;vertical-align:middle}.container-table th{background:#fafafa;color:var(--cc-muted);font-weight:650;white-space:nowrap}.container-table th:first-child,.container-table td:first-child{width:56px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.container-table th:nth-child(5),.container-table td:nth-child(5),.container-table th:nth-child(6),.container-table td:nth-child(6){width:100px}.container-table td :deep(.el-select),.container-table td :deep(.el-input){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__action :deep(.el-button){width:28px;height:28px}.container-table__specification{color:var(--cc-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:10px;margin-bottom:18px;color:var(--cc-muted);font-size:12px}.add-container{margin:0}.history-panel strong{font-size:14px}@media(max-width:1599px){.booking-groups{grid-template-columns:repeat(2,minmax(0,1fr))}.booking-group:last-child{grid-column:1/-1}}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:14px}.actions :deep(.el-button){min-height:44px}}
</style>
+11 -6
View File
@@ -1,11 +1,16 @@
export type OceanBookingStatus = 'CONFIRMED' | 'RELEASED' | 'CHANGE_PENDING' | 'CANCELLED'
export type OceanBookingContainerType = '20GP' | '40GP' | '40HC' | '40NR' | '45HC'
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null }
export interface OceanBookingContainer { id?: string; containerType: string; quantity: number | null; containerKind?: string | null; capacityCbm: number | null; maxWeightKg: number | null }
export interface OceanBookingContainerAllocation { id?: string; oceanBookingId?: string; requirementId?: string; containerBookingId?: string | null; allocatedQuantity?: number | null }
export interface OceanBookingContainerInput { containerNo: string; containerType: OceanBookingContainerType; containerKind: string; capacityCbm: number; maxWeightKg: number }
export interface OceanBookingContainerSnapshot extends OceanBookingContainerInput {
containerBookingId: string; cabinetNo: string; requirementId: string; allocationId: string
warehouseId: string; warehouseName: string; channelCode: string; channelName: string
customerId: string | null; customerName: string | null
}
export interface OceanBookingDraftData {
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]
channelCode: string; warehouseId: string; warehouseName: string
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containers: OceanBookingContainerInput[]
channelCode: string; warehouseId: string
billOfLadingNo: string; vesselName: string; voyageNo: string
estimatedSailingAt: string | null; estimatedArrivalAt: string | null
shippingCompanyId?: string | null; shippingCompanyName?: string | null; bookingAgentId?: string | null; bookingAgentName?: string | null
@@ -27,9 +32,9 @@ export interface OceanBookingResource {
orderNo: string | null; carrierName: string | null; bookingAgent: string | null; pol: string | null; pod: string | null
mblShipper: string | null; mblConsignee: string | null; soNo: string | null; cutoffAt: string | null
etd: string | null; atd: string | null; eta: string | null; ata: string | null; containerSummary: string | null
containers: OceanBookingContainer[]; amount: string | null; currency: string | null
amount: string | null; currency: string | null
}
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]; containerAllocations: OceanBookingContainerAllocation[]; histories: OceanBookingStatusEvent[]; statusHistory: OceanBookingStatusEvent[] }
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containers: OceanBookingContainerSnapshot[]; 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 }