This commit is contained in:
2026-08-15 09:47:23 +08:00
parent 086b4304c6
commit a26e2e6c3e
17 changed files with 123 additions and 104 deletions
@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
import { createOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, performOceanBookingAction, releaseOceanBooking, requestOceanBookingChange } from './platformOceanBookings'
import { http } from '@/shared/api/http'
const detailResponse = (version = 0) => ({
const detailResponse = (version = 0, status = 'CONFIRMED') => ({
oceanBooking: {
id: 123, bookingNo: 'OB123', status: 'DRAFT', version, customerId: 456, customerName: '客户',
id: 123, bookingNo: 'OB123', status, version, customerId: 456, customerName: '客户',
containers: [],
},
parties: [], containerRequirements: [], containerAllocations: [], histories: [],
@@ -21,23 +21,22 @@ 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() })
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: 'SEA', warehouseId: '8', warehouseName: '义乌仓', serviceTerm: null, freightTerm: null, parties: [], containerRequirements: [] } })
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 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).not.toMatchObject({ data: { customerId: '', customerName: '' } })
for (const releaseOnlyField of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
expect(sentPayload.data).not.toHaveProperty(releaseOnlyField)
}
expect(sentPayload.data).toMatchObject({ billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' })
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')
})
it('uses action and change-specific request contracts', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(4) })
await performOceanBookingAction('123', 'confirm', { expectedVersion: 3, reason: 'ok' })
expect(post.mock.calls[0]?.[0]).toContain('/123/confirm')
await performOceanBookingAction('123', 'cancel', { expectedVersion: 3, reason: 'ok' })
expect(post.mock.calls[0]?.[0]).toContain('/123/cancel')
expect(post.mock.calls[0]?.[1]).toEqual({ expectedVersion: 3, reason: 'ok' })
await requestOceanBookingChange('123', { expectedVersion: 4, vesselName: 'VESSEL', voyageNo: 'V001', reason: '调整船期' })
@@ -48,8 +48,7 @@ export async function listOceanBookings(query: OceanBookingQuery) {
export async function getOceanBooking(id: string) { const response = await http.get<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export async function updateOceanBooking(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 type OceanBookingAction = 'submit' | 'confirm' | 'release' | 'fail' | 'cancel' | 'change-request' | 'change-confirm' | 'change-fail'
export type OceanBookingAction = 'release' | 'cancel' | 'change-request' | 'change-confirm' | 'change-fail'
export async function performOceanBookingAction(id: string, action: Exclude<OceanBookingAction, 'change-request' | 'release'>, request: OceanBookingActionRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/${action}`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
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 requestOceanBookingChange(id: string, request: OceanBookingChangeRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/change-request`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
@@ -5,6 +5,13 @@ import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingDetailView.vue'), 'utf8')
describe('OceanBookingDetailView interaction contract', () => {
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)')
expect(source).not.toContain("status === 'DRAFT'")
expect(source).toContain("? access.can('platform:firstleg:ocean-booking:create')")
})
it('selects business objects and automatically keeps their ids and names together', () => {
expect(source).toContain('placeholder="请选择客户(可选)"')
expect(source).toContain('filterable clearable')
@@ -51,17 +58,23 @@ describe('OceanBookingDetailView interaction contract', () => {
]) expect(source).toContain(`placeholder="${placeholder}"`)
})
it('keeps release-only transport facts out of draft editing and saving', () => {
it('captures known offline booking facts during creation and only reviews them during release', () => {
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
expect(source).not.toContain(`v-model="form.data.${field}"`)
expect(source).toContain(`v-model="form.data.${field}"`)
expect(source).toContain(`prop="${field}"`)
}
expect(source).toContain(':model-value="detail?.billOfLadingNo || \'\'" disabled placeholder="确认放舱时录入"')
expect(source).toContain(':model-value="detail?.vesselName || \'\'" disabled placeholder="确认放舱时录入"')
expect(source).toContain('v-model="releaseForm.billOfLadingNo"')
expect(source).toContain('v-model="releaseForm.vesselName"')
expect(source).toContain('v-model="releaseForm.voyageNo"')
expect(source).toContain('v-model="releaseForm.estimatedSailingAt"')
expect(source).toContain('v-model="releaseForm.estimatedArrivalAt"')
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('线下订舱资料已登记并确认')
expect(source).toContain("{{ isNew ? '登记订舱' : '保存' }}")
expect(source).toContain('登记后直接确认')
})
it('uses source-backed free text terms and keeps capacity snapshots behind one specification control', () => {
@@ -147,9 +160,17 @@ describe('OceanBookingDetailView interaction contract', () => {
it('shows client-side validation failures instead of rendering an empty error alert', () => {
expect(source).toContain(':title="actionError.message"')
expect(source).toContain("actionError.value = { message: '请完善装柜仓库、物流渠道和运输条款等必填信息' }")
expect(source).toContain("actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }")
expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }")
expect(source).toContain('规格(必填)')
expect(source).toContain('placeholder="单位名称(必填)"')
})
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')
})
})
@@ -21,7 +21,6 @@ import {
performOceanBookingAction,
releaseOceanBooking,
requestOceanBookingChange,
updateOceanBooking,
listWarehouseLogisticsChannels,
type OceanLogisticsChannel,
type OceanBookingAction,
@@ -47,28 +46,24 @@ const containerKindOptions = [
{ value: 'LCL', label: '拼箱 LCL' },
]
const actionLabels: Record<OceanBookingAction, string> = {
submit: '提交', confirm: '确认订舱', release: '确认放舱', fail: '标记失败', cancel: '取消订舱',
release: '确认放舱', cancel: '取消订舱',
'change-request': '申请改船', 'change-confirm': '确认改船', 'change-fail': '改船失败',
}
const actionPermissions: Record<OceanBookingAction, string> = {
submit: 'platform:firstleg:ocean-booking:submit', confirm: 'platform:firstleg:ocean-booking:confirm',
release: 'platform:firstleg:ocean-booking:release', fail: 'platform:firstleg:ocean-booking:confirm',
release: 'platform:firstleg:ocean-booking:release',
cancel: 'platform:firstleg:ocean-booking:cancel', 'change-request': 'platform:firstleg:ocean-booking:change',
'change-confirm': 'platform:firstleg:ocean-booking:change', 'change-fail': 'platform:firstleg:ocean-booking:change',
}
const actionsByStatus: Partial<Record<OceanBookingStatus, OceanBookingAction[]>> = {
DRAFT: ['submit', 'cancel'],
SUBMITTED: ['confirm', 'fail', 'cancel'],
CONFIRMED: ['release', 'change-request', 'cancel'],
CHANGE_PENDING: ['change-confirm', 'change-fail', 'cancel'],
FAILED: ['cancel'],
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const id = computed(() => String(route.params.bookingId || ''))
const isNew = computed(() => id.value === 'new')
const isNew = computed(() => route.name === 'ocean-booking-create' || id.value === 'new')
const detail = ref<OceanBookingDetail | null>(null)
const loading = ref(false)
const saving = ref(false)
@@ -99,6 +94,7 @@ function blankParties(): OceanBookingParty[] {
function blankData(): OceanBookingDraftData {
return {
customerId: null, customerName: null, channelCode: '', warehouseId: '', warehouseName: '', serviceTerm: null, freightTerm: null, parties: blankParties(),
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
containerRequirements: [createBlankContainerRequirement()],
}
}
@@ -106,20 +102,25 @@ const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blank
const rules: FormRules = {
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
billOfLadingNo: [{ required: true, message: '请输入提单号', trigger: 'blur' }],
vesselName: [{ required: true, message: '请输入船名', trigger: 'blur' }],
voyageNo: [{ required: true, message: '请输入航次', trigger: 'blur' }],
estimatedSailingAt: [{ required: true, message: '请选择预计开船时间', trigger: 'change' }],
estimatedArrivalAt: [{ required: true, message: '请选择预计到港时间', trigger: 'change' }],
serviceTerm: [{ required: true, message: '请输入主要运输条款 Service Term', trigger: 'blur' }],
freightTerm: [{ required: true, message: '请输入运费条款 Freight Term', trigger: 'blur' }],
}
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
const editable = computed(() => isNew.value)
const canSave = computed(() => isNew.value
? access.can('platform:firstleg:ocean-booking:create')
: editable.value && access.can('platform:firstleg:ocean-booking:edit'))
const availableActions = computed(() => (actionsByStatus[detail.value?.status || 'DRAFT'] || [])
const availableActions = computed(() => (actionsByStatus[detail.value?.status || 'CONFIRMED'] || [])
.filter((action) => access.can(actionPermissions[action])))
const changeDialogOpen = ref(false)
const changeForm = reactive({ vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' })
const releaseDialogOpen = ref(false)
const releaseForm = reactive({ billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' })
const releaseReason = ref('')
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
return oceanBookingStatusLabel(status)
@@ -153,6 +154,9 @@ function normalizeDraftData() {
form.data.customerName = form.data.customerId ? form.data.customerName : null
form.data.serviceTerm = form.data.serviceTerm?.trim() || null
form.data.freightTerm = form.data.freightTerm?.trim() || null
form.data.billOfLadingNo = form.data.billOfLadingNo.trim()
form.data.vesselName = form.data.vesselName.trim()
form.data.voyageNo = form.data.voyageNo.trim()
}
function selectWarehouse(value: unknown) {
const warehouseId = String(value || '')
@@ -197,6 +201,8 @@ function applyDetail(value: OceanBookingDetail) {
form.expectedVersion = value.version
form.data = {
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '', warehouseName: value.warehouseName || '',
billOfLadingNo: value.billOfLadingNo || '', vesselName: value.vesselName || '', voyageNo: value.voyageNo || '',
estimatedSailingAt: value.estimatedSailingAt || null, estimatedArrivalAt: value.estimatedArrivalAt || null,
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName,
departurePortCode: value.departurePortCode, departurePortName: value.departurePortName,
@@ -223,7 +229,11 @@ async function save() {
actionError.value = null
normalizeDraftData()
if (!await formRef.value?.validate().catch(() => false)) {
actionError.value = { message: '请完善装柜仓库、物流渠道和运输条款等必填信息' }
actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }
return
}
if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!) {
actionError.value = { message: '预计到港时间不能早于预计开船时间' }
return
}
if (form.data.parties.some((party) => !party.partyName.trim())) {
@@ -237,18 +247,15 @@ async function save() {
const payload = { ...form.data }
if (isNew.value) {
const created = await createOceanBooking({ expectedVersion: 0, data: payload })
ElMessage.success('订舱已创建')
ElMessage.success('线下订舱资料已登记并确认')
await router.replace(`/ocean-bookings/${created.id}`)
applyDetail(created)
} else {
applyDetail(await updateOceanBooking(id.value, { expectedVersion: form.expectedVersion, data: payload }))
ElMessage.success('订舱草稿已保存')
}
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { saving.value = false }
}
async function requiredReason(action: OceanBookingAction) {
if (!['fail', 'cancel', 'change-fail'].includes(action)) return null
if (!['cancel', 'change-fail'].includes(action)) return null
const result = await ElMessageBox.prompt(`请填写“${actionLabels[action]}”原因,服务端将写入状态历史。`, actionLabels[action], {
inputValidator: (value) => Boolean(value?.trim()) || '原因不能为空',
confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning',
@@ -264,31 +271,28 @@ function openChangeDialog() {
changeDialogOpen.value = true
}
function openReleaseDialog() {
releaseForm.billOfLadingNo = detail.value?.billOfLadingNo || ''
releaseForm.vesselName = detail.value?.vesselName || ''
releaseForm.voyageNo = detail.value?.voyageNo || ''
releaseForm.estimatedSailingAt = detail.value?.estimatedSailingAt || null
releaseForm.estimatedArrivalAt = detail.value?.estimatedArrivalAt || null
releaseForm.reason = ''
releaseReason.value = ''
releaseDialogOpen.value = true
}
async function submitRelease() {
if (!detail.value) return
const billOfLadingNo = releaseForm.billOfLadingNo.trim()
const vesselName = releaseForm.vesselName.trim()
const voyageNo = releaseForm.voyageNo.trim()
if (!billOfLadingNo || !vesselName || !voyageNo || !releaseForm.estimatedSailingAt || !releaseForm.estimatedArrivalAt) {
actionError.value = getApiErrorDetails(new Error('请完整填写提单号、船名、航次、预计开船和预计到港时间'))
const billOfLadingNo = detail.value.billOfLadingNo?.trim()
const vesselName = detail.value.vesselName?.trim()
const voyageNo = detail.value.voyageNo?.trim()
const estimatedSailingAt = detail.value.estimatedSailingAt
const estimatedArrivalAt = detail.value.estimatedArrivalAt
if (!billOfLadingNo || !vesselName || !voyageNo || !estimatedSailingAt || !estimatedArrivalAt) {
actionError.value = getApiErrorDetails(new Error('当前订舱缺少已确认运输资料,无法放舱'))
return
}
if (releaseForm.estimatedArrivalAt < releaseForm.estimatedSailingAt) {
if (estimatedArrivalAt < estimatedSailingAt) {
actionError.value = getApiErrorDetails(new Error('预计到港时间不能早于预计开船时间'))
return
}
actionSaving.value = true
actionError.value = null
try {
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt: releaseForm.estimatedSailingAt, estimatedArrivalAt: releaseForm.estimatedArrivalAt, reason: releaseForm.reason.trim() || null }))
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt, reason: releaseReason.value.trim() || null }))
releaseDialogOpen.value = false
ElMessage.success('确认放舱成功')
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
@@ -344,7 +348,7 @@ onMounted(() => { void load(); void loadOptions() })
<template #header>
<div class="detail-heading">
<el-button @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱资料'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '填写并保存订舱资料'">
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱资料'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '登记线下已确认的真实订舱资料'">
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
</PageToolbar>
</div>
@@ -355,10 +359,10 @@ onMounted(() => { void load(); void loadOptions() })
<el-alert v-if="actionError" type="error" :closable="false" show-icon :title="actionError.message"><ApiErrorMeta :details="actionError" /></el-alert>
<section class="surface-panel">
<div class="section-title">
<div><h2>订舱信息</h2><p v-if="detail && !editable">当前状态仅可查看,草稿状态才允许编辑。</p></div>
<div><h2>订舱信息</h2><p v-if="detail && !editable">已确认订舱资料仅可查看,船期变更请使用“申请改船”。</p></div>
<div class="actions">
<el-button v-for="item in availableActions" :key="item" :loading="actionSaving" @click="runAction(item)">{{ actionLabels[item] }}</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />{{ isNew ? '登记订舱' : '保存' }}</el-button>
</div>
</div>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
@@ -367,25 +371,25 @@ onMounted(() => { void load(); void loadOptions() })
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
<el-form-item label="客户(可选)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="请选择客户(可选)" no-data-text="暂无可选客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '待创建'" disabled /></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '登记后直接确认'" disabled /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
<el-form-item label="提单号"><el-input :model-value="detail?.billOfLadingNo || ''" disabled placeholder="确认放舱时录入" /></el-form-item>
<el-form-item label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-if="form.data.bookingAgentId && !bookingAgents.some((partner) => String(partner.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
</div>
</section>
<section class="booking-group" aria-label="航线船期">
<h3>航线船期</h3><div class="booking-group__body booking-group__body--fields">
<el-form-item label="船名"><el-input :model-value="detail?.vesselName || ''" disabled placeholder="确认放舱时录入" /></el-form-item>
<el-form-item label="航次"><el-input :model-value="detail?.voyageNo || ''" disabled placeholder="确认放舱时录入" /></el-form-item>
<el-form-item label="船名" prop="vesselName"><el-input v-model="form.data.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="form.data.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" placeholder="例如:CNNGB" /></el-form-item>
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" placeholder="例如:宁波港" /></el-form-item>
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" placeholder="例如:USLAX" /></el-form-item>
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" placeholder="例如:洛杉矶港" /></el-form-item>
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
<el-form-item label="预计开船"><el-date-picker :model-value="detail?.estimatedSailingAt || null" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" disabled placeholder="确认放舱时录入" style="width:100%" /></el-form-item>
<el-form-item label="预计到港"><el-date-picker :model-value="detail?.estimatedArrivalAt || null" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" disabled placeholder="确认放舱时录入" style="width:100%" /></el-form-item>
<el-form-item label="预计开船" prop="estimatedSailingAt"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<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="条款与柜量">
@@ -459,14 +463,17 @@ onMounted(() => { void load(); void loadOptions() })
</el-form>
<LoadErrorState v-if="actionError" compact title="提交失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="!releaseForm.billOfLadingNo.trim() || !releaseForm.vesselName.trim() || !releaseForm.voyageNo.trim() || !releaseForm.estimatedSailingAt || !releaseForm.estimatedArrivalAt" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<el-form label-position="top">
<el-form-item label="提单号" required><el-input v-model="releaseForm.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
<el-form-item label="船名" required><el-input v-model="releaseForm.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次" required><el-input v-model="releaseForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="预计开船" required><el-date-picker v-model="releaseForm.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<el-form-item label="预计到港" required><el-date-picker v-model="releaseForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item label="备注"><el-input v-model="releaseForm.reason" type="textarea" maxlength="1000" placeholder="请输入放舱备注(可选)" /></el-form-item>
<el-alert title="请复核已登记的真实订舱资料。放舱不会重新采集或修改这些信息。" type="info" :closable="false" show-icon />
<el-descriptions class="release-summary" :column="2" border>
<el-descriptions-item label="提单号">{{ detail?.billOfLadingNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="船名">{{ detail?.vesselName || '—' }}</el-descriptions-item>
<el-descriptions-item label="航次">{{ detail?.voyageNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计开船">{{ detail?.estimatedSailingAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计到港">{{ detail?.estimatedArrivalAt || '—' }}</el-descriptions-item>
</el-descriptions>
<el-form-item label="备注"><el-input v-model="releaseReason" type="textarea" maxlength="1000" placeholder="请输入放舱备注(可选)" /></el-form-item>
</el-form>
<LoadErrorState v-if="actionError" compact title="放舱失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
</AppDialog>
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('does not expose internal draft creation from the formal booking list', () => { expect(source).not.toContain('<template #toolbar-actions>'); expect(source).not.toContain('新增订舱'); expect(source).not.toContain("'/ocean-bookings/new'"); expect(source).not.toContain('PageToolbar') }); it('shows only the formal booking detail action', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).not.toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }); it('limits status filters to the frozen formal booking states', () => { expect(source).toContain("['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED']"); expect(source).toContain('options: formalStatusOptions'); expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"'); expect(source).not.toContain(':status="row.status"') }) })
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps real booking creation available without mixing draft rows into the formal list', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).toContain('新增订舱'); expect(source).toContain("router.push('/ocean-bookings/new')"); expect(source).toContain("access.can('platform:firstleg:ocean-booking:create')"); expect(source).not.toContain('PageToolbar') }); it('shows only the formal booking detail action', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).not.toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }); it('limits status filters to the frozen formal booking states', () => { expect(source).toContain("['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED']"); expect(source).toContain('options: formalStatusOptions'); expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"'); expect(source).not.toContain(':status="row.status"') }) })
describe('OceanBookingsView prototype field matrix', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Eye } from '@lucide/vue'
import { Eye, Plus } 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'
import StatusBadge from '@/shared/components/StatusBadge.vue'
@@ -16,6 +17,7 @@ import type { OceanBookingQuery, OceanBookingStatus } from '../types'
import { oceanBookingStatusLabel, oceanBookingStatusOptions } from '../status'
const router = useRouter()
const access = useAccessPreviewStore()
const rows = ref<OceanBookingResource[]>([])
const loading = ref(false)
const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
@@ -26,6 +28,7 @@ const customers = ref<PlatformTenantResource[]>([])
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 formalStatusOptions = oceanBookingStatusOptions.filter((item) => ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED'].includes(item.value))
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 } },
@@ -50,6 +53,7 @@ function formatDateTime(value?: string | null) { return value ? value.replace('T
async function loadCustomers() { customersLoading.value = true; try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records.filter((customer) => customer.status === 'ACTIVE') } catch { customers.value = [] } finally { customersLoading.value = false } }
async function load() { loading.value = true; error.value = null; try { const result = await listOceanBookings({ page: page.value, size: pageSize.value, customerId: String(query.customerId || '') || undefined, keyword: String(query.keyword || '') || undefined, status: (query.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 search() { if (page.value === 1) void load(); else page.value = 1 }
watch([page, pageSize], () => void load()); onMounted(() => { void load(); void loadCustomers() })
</script>
@@ -60,6 +64,7 @@ watch([page, pageSize], () => void load()); onMounted(() => { void load(); void
<template v-if="error" #state><LoadErrorState title="订舱列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<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)"><Eye :size="15" />查看</el-button></div></template>
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>
+1 -1
View File
@@ -1,2 +1,2 @@
import type { RouteRecordRaw } from 'vue-router'
export const oceanBookingRoutes = [{ path: 'ocean-bookings', name: 'ocean-bookings', component: () => import('./pages/OceanBookingsView.vue'), meta: { title: '海运订舱', description: '维护海运订舱申请、船期与放舱状态', permission: 'platform:firstleg:ocean-booking:list' } }, { path: 'ocean-bookings/:bookingId', name: 'ocean-booking-detail', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '订舱详情', description: '查看和维护订舱详情', permission: 'platform:firstleg:ocean-booking:list' } }] satisfies RouteRecordRaw[]
export const oceanBookingRoutes = [{ path: 'ocean-bookings', name: 'ocean-bookings', component: () => import('./pages/OceanBookingsView.vue'), meta: { title: '海运订舱', description: '维护海运订舱申请、船期与放舱状态', permission: 'platform:firstleg:ocean-booking:list' } }, { path: 'ocean-bookings/new', name: 'ocean-booking-create', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '新增订舱', description: '登记线下已确认的真实海运订舱', permission: 'platform:firstleg:ocean-booking:create' } }, { path: 'ocean-bookings/:bookingId', name: 'ocean-booking-detail', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '订舱详情', description: '查看和维护订舱详情', permission: 'platform:firstleg:ocean-booking:list' } }] satisfies RouteRecordRaw[]
@@ -4,11 +4,8 @@ import { oceanBookingStatusLabel, oceanBookingStatusOptions } from './status'
describe('ocean booking status labels', () => {
it('translates every backend status for selectors and badges', () => {
expect(oceanBookingStatusOptions).toEqual([
{ value: 'DRAFT', label: '草稿' },
{ value: 'SUBMITTED', label: '已提交' },
{ value: 'CONFIRMED', label: '已确认' },
{ value: 'RELEASED', label: '已放舱' },
{ value: 'FAILED', label: '订舱失败' },
{ value: 'CHANGE_PENDING', label: '变更中' },
{ value: 'CANCELLED', label: '已取消' },
])
-3
View File
@@ -1,11 +1,8 @@
import type { OceanBookingStatus } from './types'
export const oceanBookingStatusLabels: Record<OceanBookingStatus, string> = {
DRAFT: '草稿',
SUBMITTED: '已提交',
CONFIRMED: '已确认',
RELEASED: '已放舱',
FAILED: '订舱失败',
CHANGE_PENDING: '变更中',
CANCELLED: '已取消',
}
+3 -1
View File
@@ -1,4 +1,4 @@
export type OceanBookingStatus = 'DRAFT' | 'SUBMITTED' | 'CONFIRMED' | 'RELEASED' | 'FAILED' | 'CHANGE_PENDING' | 'CANCELLED'
export type OceanBookingStatus = 'CONFIRMED' | 'RELEASED' | 'CHANGE_PENDING' | 'CANCELLED'
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 }
@@ -6,6 +6,8 @@ export interface OceanBookingContainerAllocation { id?: string; oceanBookingId?:
export interface OceanBookingDraftData {
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]
channelCode: string; warehouseId: string; warehouseName: 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
departurePortCode?: string | null; departurePortName?: string | null; destinationPortCode?: string | null; destinationPortName?: string | null
placeOfDelivery?: string | null; serviceTerm: string | null; freightTerm: string | null