This commit is contained in:
2026-08-10 09:12:06 +08:00
parent 5815649887
commit bbe93ca235
9 changed files with 437 additions and 84 deletions
@@ -1,5 +1,42 @@
import { describe, expect, it, vi } from 'vitest'
import { listOceanBookings, performOceanBookingAction, createOceanBooking } from './platformOceanBookings'
import { createOceanBooking, listOceanBookings, performOceanBookingAction, requestOceanBookingChange } from './platformOceanBookings'
import { http } from '@/shared/api/http'
describe('ocean bookings api', () => { it('normalizes pagination and long ids', async () => { vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, customerId: 456, containers: [] }], page: '2', size: '20', total: '21', pages: '2' } }); const result = await listOceanBookings({ page: 1, size: 10 }); expect(result.page).toBe(2); expect(result.total).toBe(21); expect(result.records[0]?.id).toBe('123'); expect(result.records[0]?.customerId).toBe('456') })
it('wraps writes with idempotency and expected version action payload', async () => { const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: 'id-1' }); await createOceanBooking({ expectedVersion: null, data: { customerId: 'c1', customerName: '客户', parties: [], containerRequirements: [] } }); expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }); await performOceanBookingAction('id-1', 'confirm', { expectedVersion: 3, reason: 'ok' }); expect(post.mock.calls[1]?.[0]).toContain('/id-1/confirm'); expect(post.mock.calls[1]?.[1]).toEqual({ expectedVersion: 3, reason: 'ok' }) }) })
const detailResponse = (version = 0) => ({
oceanBooking: {
id: 123, bookingNo: 'OB123', status: 'DRAFT', version, customerId: 456, customerName: '客户',
containers: [],
},
parties: [], containerRequirements: [], containerAllocations: [], histories: [],
})
describe('ocean bookings api', () => {
it('normalizes pagination and long ids', async () => {
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, customerId: 456, containers: [] }], page: '2', size: '20', total: '21', pages: '2' } })
const result = await listOceanBookings({ page: 1, size: 10 })
expect(result.page).toBe(2)
expect(result.total).toBe(21)
expect(result.records[0]?.id).toBe('123')
expect(result.records[0]?.customerId).toBe('456')
})
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: '456', customerName: '客户', parties: [], containerRequirements: [] } })
expect(post.mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 0 })
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
expect(created.id).toBe('123')
expect(created.version).toBe(0)
})
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')
expect(post.mock.calls[0]?.[1]).toEqual({ expectedVersion: 3, reason: 'ok' })
await requestOceanBookingChange('123', { expectedVersion: 4, vesselName: 'VESSEL', voyageNo: 'V001', reason: '调整船期' })
expect(post.mock.calls[1]?.[0]).toContain('/123/change-request')
expect(post.mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 4, vesselName: 'VESSEL', voyageNo: 'V001', reason: '调整船期' })
})
})
@@ -1,20 +1,51 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
import type { OceanBookingActionRequest, OceanBookingChangeRequest, OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
const basePath = '/platform/v1/ocean-bookings'
function pageNumber(value: number | string, fallback: number) { const n = Number(value); return Number.isFinite(n) ? Math.trunc(n) : fallback }
function booking(raw: OceanBookingResource): OceanBookingResource { const item = raw as OceanBookingResource & { departurePortName?: string | null; destinationPortName?: string | null; shippingCompanyName?: string | null; estimatedSailingAt?: string | null; estimatedArrivalAt?: string | null }; return { ...raw, id: String(raw.id), customerId: raw.customerId == null ? null : String(raw.customerId), pol: raw.pol ?? item.departurePortName ?? null, pod: raw.pod ?? item.destinationPortName ?? null, carrierName: raw.carrierName ?? item.shippingCompanyName ?? null, etd: raw.etd ?? item.estimatedSailingAt ?? null, eta: raw.eta ?? item.estimatedArrivalAt ?? null, containers: (raw.containers || []).map((container) => ({ ...container, id: String(container.id ?? '') })) } }
function booking(raw: OceanBookingResource): OceanBookingResource {
const item = raw as OceanBookingResource & { version?: number | string | null }
return {
...raw,
id: String(raw.id),
version: Number(item.version ?? 0),
customerId: raw.customerId == null ? null : String(raw.customerId),
shippingCompanyId: raw.shippingCompanyId == null ? null : String(raw.shippingCompanyId),
bookingAgentId: raw.bookingAgentId == null ? null : String(raw.bookingAgentId),
pol: raw.pol ?? raw.departurePortName ?? null,
pod: raw.pod ?? raw.destinationPortName ?? null,
carrierName: raw.carrierName ?? raw.shippingCompanyName ?? null,
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 {
const source = booking(raw.oceanBooking || raw)
const histories = (raw.histories || raw.statusHistory || []).map((event) => ({ ...event, id: String(event.id) }))
return {
...raw,
...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 || [],
histories,
statusHistory: histories,
}
}
export async function listOceanBookings(query: OceanBookingQuery) {
const response = await http.get<ApiEnvelope<OceanBookingPage>, ApiEnvelope<OceanBookingPage>>(basePath, { params: query })
const result = unwrapApiResponse(response)
return { ...result, records: (result.records || []).map(booking), page: pageNumber(result.page, query.page), size: pageNumber(result.size, query.size), total: pageNumber(result.total, 0), pages: pageNumber(result.pages, 0) } satisfies OceanBookingPage
}
export async function getOceanBooking(id: string) { const response = await http.get<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`); const raw = unwrapApiResponse(response); const source = raw.oceanBooking || raw; const normalized = booking(source); const histories = (raw.histories || raw.statusHistory || []).map((event) => ({ ...event, id: String(event.id) })); return { ...raw, ...normalized, oceanBooking: normalized, parties: raw.parties || [], containerRequirements: raw.containerRequirements || [], containerAllocations: raw.containerAllocations || [], histories, statusHistory: histories } }
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<string>, ApiEnvelope<string>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return unwrapApiResponse(response) }
export async function updateOceanBooking(id: string, payload: OceanBookingSaveRequest) { const response = await http.put<ApiEnvelope<void>, ApiEnvelope<void>>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return unwrapApiResponse(response) }
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 interface OceanBookingActionRequest { expectedVersion: number | null; reason?: string | null }
export async function performOceanBookingAction(id: string, action: OceanBookingAction, request: OceanBookingActionRequest) { const response = await http.post<ApiEnvelope<void>, ApiEnvelope<void>>(`${basePath}/${encodeURIComponent(id)}/${action}`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return unwrapApiResponse(response) }
export async function performOceanBookingAction(id: string, action: Exclude<OceanBookingAction, 'change-request'>, 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 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)) }
export async function printOceanBooking(id: string, type: 'SHIPPING_INSTRUCTION' | 'BILL_CONFIRMATION') { return http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(id)}/print`, { params: { type }, responseType: 'blob' }) }
export type { OceanBookingResource, OceanBookingStatus }
@@ -4,19 +4,30 @@ import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingDetailView.vue'), 'utf8')
describe('OceanBookingDetailView form contract', () => {
it('keeps prototype parties, sailing and cargo fields', () => {
for (const field of ['SHIPPER', 'CONSIGNEE', 'NOTIFY_PARTY', 'departurePortName', 'destinationPortName', 'vesselName', 'voyageNo', 'containerRequirements', 'goodsDescription']) {
describe('OceanBookingDetailView contract', () => {
it('uses the backend draft fields and all required parties', () => {
for (const field of ['SHIPPER', 'CONSIGNEE', 'NOTIFY_PARTY', 'partyName', 'departurePortName', 'destinationPortName', 'vesselName', 'voyageNo', 'containerRequirements']) {
expect(source).toContain(field)
}
expect(source).not.toContain('goodsDescription')
expect(source).not.toContain('form.data.soNo')
})
it('validates required booking fields and protects edit-only fields', () => {
expect(source).toContain("customerId: [{ required: true")
expect(source).toContain("departurePortName: [{ required: true")
expect(source).toContain("bookingAgentName: [{ required: true")
it('reads the real version and protects create/edit permissions', () => {
expect(source).toContain('form.expectedVersion = value.version')
expect(source).toContain('expectedVersion: 0')
expect(source).toContain("platform:firstleg:ocean-booking:create")
expect(source).toContain("platform:firstleg:ocean-booking:edit")
expect(source).toContain(':disabled="!isNew"')
expect(source).toContain(':disabled="!editable"')
expect(source).toContain("containerType: '40HC', containerKind: 'FCL', quantity: 1")
})
it('maps each status action to its server permission and uses a change-specific request', () => {
for (const permission of ['submit', 'confirm', 'release', 'cancel', 'change']) {
expect(source).toContain(`platform:firstleg:ocean-booking:${permission}`)
}
expect(source).toContain('requestOceanBookingChange')
expect(source).toContain("changeForm.vesselName = detail.value?.vesselName")
expect(source).toContain('expectedVersion: detail.value.version')
})
})
@@ -1,37 +1,271 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { createOceanBooking, getOceanBooking, performOceanBookingAction, updateOceanBooking, type OceanBookingAction } from '../api/platformOceanBookings'
import type { OceanBookingDetail, OceanBookingDraftData, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
import {
createOceanBooking,
getOceanBooking,
performOceanBookingAction,
requestOceanBookingChange,
updateOceanBooking,
type OceanBookingAction,
} from '../api/platformOceanBookings'
import type {
OceanBookingDetail,
OceanBookingDraftData,
OceanBookingParty,
OceanBookingSaveRequest,
OceanBookingStatus,
} from '../types'
const statusLabels: Record<OceanBookingStatus, string> = {
DRAFT: '草稿', SUBMITTED: '已提交', CONFIRMED: '已确认', RELEASED: '已放舱',
FAILED: '订舱失败', CHANGE_PENDING: '变更中', CANCELLED: '已取消',
}
const partyTypes = [
{ value: 'SHIPPER', label: '发货人' },
{ value: 'CONSIGNEE', label: '收货人' },
{ value: 'NOTIFY_PARTY', label: '通知人' },
] as const
const actionLabels: Record<OceanBookingAction, string> = {
submit: '提交', confirm: '确认订舱', release: '确认放舱', fail: '标记失败', 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',
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 detail = ref<OceanBookingDetail | null>(null)
const loading = ref(false)
const saving = ref(false)
const actionSaving = ref(false)
const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const formRef = ref<FormInstance>()
function blankParties(): OceanBookingParty[] {
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', address: '' }))
}
function blankData(): OceanBookingDraftData {
return {
customerId: '', customerName: '', parties: blankParties(),
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1 }],
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
const rules: FormRules = {
customerId: [{ required: true, message: '请输入客户 ID', trigger: 'blur' }],
customerName: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
}
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
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'] || [])
.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: '' })
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
return status ? (statusLabels[status as OceanBookingStatus] || status) : '未设置'
}
function applyDetail(value: OceanBookingDetail) {
detail.value = value
form.expectedVersion = value.version
form.data = {
customerId: value.customerId || '', customerName: value.customerName || '', billOfLadingNo: value.billOfLadingNo,
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName,
vesselName: value.vesselName, voyageNo: value.voyageNo,
departurePortCode: value.departurePortCode, departurePortName: value.departurePortName,
destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName,
placeOfDelivery: value.placeOfDelivery, serviceTerm: value.serviceTerm, freightTerm: value.freightTerm,
estimatedSailingAt: value.estimatedSailingAt, estimatedArrivalAt: value.estimatedArrivalAt,
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, quantity }) => ({ containerType, containerKind, quantity })) : [{ containerType: '40HC', containerKind: 'FCL', quantity: 1 }],
}
}
async function load() {
if (!id.value || isNew.value) return
loading.value = true
error.value = null
try { applyDetail(await getOceanBooking(id.value)) }
catch (cause) { error.value = getApiErrorDetails(cause) }
finally { loading.value = false }
}
async function save() {
if (!canSave.value) return
if (!await formRef.value?.validate().catch(() => false)) return
if (form.data.parties.some((party) => !party.partyName.trim())) {
actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }
return
}
saving.value = true
actionError.value = null
try {
if (isNew.value) {
const created = await createOceanBooking({ expectedVersion: 0, data: form.data })
ElMessage.success('订舱已创建')
await router.replace(`/ocean-bookings/${created.id}`)
applyDetail(created)
} else {
applyDetail(await updateOceanBooking(id.value, { expectedVersion: form.expectedVersion, data: form.data }))
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
const result = await ElMessageBox.prompt(`请填写“${actionLabels[action]}”原因,服务端将写入状态历史。`, actionLabels[action], {
inputValidator: (value) => Boolean(value?.trim()) || '原因不能为空',
confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning',
})
return result.value.trim()
}
function openChangeDialog() {
changeForm.vesselName = detail.value?.vesselName || ''
changeForm.voyageNo = detail.value?.voyageNo || ''
changeForm.estimatedSailingAt = detail.value?.estimatedSailingAt || null
changeForm.estimatedArrivalAt = detail.value?.estimatedArrivalAt || null
changeForm.reason = ''
changeDialogOpen.value = true
}
async function submitChange() {
if (!detail.value || !changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()) return
actionSaving.value = true
actionError.value = null
try {
applyDetail(await requestOceanBookingChange(id.value, { expectedVersion: detail.value.version, ...changeForm }))
changeDialogOpen.value = false
ElMessage.success('改船申请已提交')
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { actionSaving.value = false }
}
async function runAction(action: OceanBookingAction) {
if (!detail.value || action === 'change-request') { if (action === 'change-request') openChangeDialog(); return }
actionError.value = null
try {
const reason = await requiredReason(action)
await ElMessageBox.confirm(`确认执行“${actionLabels[action]}”吗?`, actionLabels[action], { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
actionSaving.value = true
applyDetail(await performOceanBookingAction(id.value, action, { expectedVersion: detail.value.version, reason }))
ElMessage.success(`${actionLabels[action]}成功`)
} catch (cause) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
} finally { actionSaving.value = false }
}
function addContainer() { form.data.containerRequirements.push({ containerType: '40HC', containerKind: 'FCL', quantity: 1 }) }
function removeContainer(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
const statusLabels: Record<OceanBookingStatus, string> = { DRAFT: '草稿', SUBMITTED: '已提交', CONFIRMED: '已确认', RELEASED: '已放舱', FAILED: '订舱失败', CHANGE_PENDING: '变更中', CANCELLED: '已取消' }
function statusLabel(status: OceanBookingStatus | string | null | undefined) { return status ? (statusLabels[status as OceanBookingStatus] || status) : '未设置' }
const partyTypes = ['SHIPPER', 'CONSIGNEE', 'NOTIFY_PARTY'] as const
const fieldRules = { customerId: [{ required: true, message: '请选择客户', trigger: 'blur' }], departurePortName: [{ required: true, message: '请输入起运港', trigger: 'blur' }], bookingAgentName: [{ required: true, message: '请输入订舱代理', trigger: 'blur' }] }
// 编辑态保护:客户 ID 仅新增可修改,已确认后的表单由 editable 控制(模板使用 :disabled="!isNew" / :disabled="!editable")。
const route = useRoute(); const router = useRouter(); const id = computed(() => String(route.params.bookingId || '')); const isNew = computed(() => id.value === 'new'); const editable = computed(() => isNew.value || ['DRAFT', 'SUBMITTED', 'CHANGE_PENDING'].includes(detail.value?.status || '')); const detail = ref<OceanBookingDetail | null>(null); const loading = ref(false); const saving = ref(false); const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null); const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const blankData = (): OceanBookingDraftData => ({ customerId: '', customerName: '', parties: [], containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1 }] })
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: null, data: blankData() })
const actionStatuses: OceanBookingStatus[] = ['SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CHANGE_PENDING', 'CANCELLED']
const actionByStatus: Partial<Record<OceanBookingStatus, OceanBookingAction>> = { SUBMITTED: 'submit', CONFIRMED: 'confirm', RELEASED: 'release', FAILED: 'fail', CHANGE_PENDING: 'change-request', CANCELLED: 'cancel' }
type OceanBookingResourceWithVersion = { expectedVersion?: number | string | null }
async function load(){ if(!id.value || id.value==='new') return; loading.value=true; error.value=null; try { detail.value=await getOceanBooking(id.value); const source=detail.value.oceanBooking || detail.value; form.expectedVersion = Number((source as OceanBookingResourceWithVersion).expectedVersion ?? 0); form.data = { ...blankData(), ...(source as unknown as Partial<OceanBookingDraftData>), customerId: String(source.customerId || ''), customerName: source.customerName || '', parties: detail.value.parties || [], containerRequirements: detail.value.containerRequirements || [] } } catch(e){error.value=getApiErrorDetails(e)} finally{loading.value=false} }
async function save(){ saving.value=true; actionError.value=null; try { if(id.value==='new') { const created=await createOceanBooking(form); await router.replace(`/ocean-bookings/${created}`) } else await updateOceanBooking(id.value, form); await load() } catch(e){actionError.value=getApiErrorDetails(e)} finally{saving.value=false} }
async function action(status: OceanBookingStatus){ const actionName=actionByStatus[status]; if(!actionName) return; actionError.value=null; try { await performOceanBookingAction(id.value, actionName, { expectedVersion: form.expectedVersion, reason: null }); await load() } catch(e){actionError.value=getApiErrorDetails(e)} }
onMounted(load)
</script>
<template>
<div class="business-page booking-detail">
<div class="detail-heading"><el-button @click="router.push('/ocean-bookings')">返回列表</el-button><div><h1>{{ detail?.bookingNo || '新增订舱' }}</h1><p v-if="detail">{{ detail.customerName || '—' }} · {{ detail.pol || '—' }} → {{ detail.pod || '—' }}</p></div><div v-if="detail" class="heading-status"><StatusBadge :status="detail.status" /><span class="heading-status-label">{{ statusLabel(detail.status) }}</span></div></div>
<div class="detail-heading">
<el-button @click="router.push('/ocean-bookings')">返回列表</el-button>
<div><h1>{{ detail?.bookingNo || '新增订舱' }}</h1><p v-if="detail">{{ detail.customerName || '—' }} · {{ detail.pol || '—' }} → {{ detail.pod || '—' }}</p></div>
<div v-if="detail" class="heading-status"><StatusBadge :status="detail.status" /><span>{{ statusLabel(detail.status) }}</span></div>
</div>
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" @retry="load" />
<template v-else>
<el-alert v-if="actionError" type="error" :closable="false" show-icon>{{ actionError.message }}</el-alert>
<section class="surface-panel"><div class="section-title"><h2>订舱信息</h2><div class="actions"><template v-for="nextStatus in actionStatuses" :key="nextStatus"><el-button v-if="detail && detail.status !== nextStatus" size="small" @click="action(nextStatus)">{{ statusLabel(nextStatus) }}</el-button></template><el-button type="primary" :loading="saving" @click="save">保存</el-button></div></div><el-form :model="form.data" label-position="top" class="form-grid"><el-form-item label="客户名称"><el-input v-model="form.data.customerName" /></el-form-item><el-form-item label="客户 ID"><el-input v-model="form.data.customerId" /></el-form-item><el-form-item label="关联订单号"><el-input v-model="form.data.orderNo" /></el-form-item><el-form-item label="船公司名称"><el-input v-model="form.data.shippingCompanyName" /></el-form-item><el-form-item label="船公司 ID"><el-input v-model="form.data.shippingCompanyId" /></el-form-item><el-form-item label="订舱代理"><el-input v-model="form.data.bookingAgentName" /></el-form-item><el-form-item label="起运港"><el-input v-model="form.data.departurePortName" /></el-form-item><el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" /></el-form-item><el-form-item label="船名/航次"><el-input v-model="form.data.vesselName" /><el-input v-model="form.data.voyageNo" class="mt" /></el-form-item><el-form-item label="预计开船"><el-input v-model="form.data.estimatedSailingAt" /></el-form-item><el-form-item label="预计到港"><el-input v-model="form.data.estimatedArrivalAt" /></el-form-item><el-form-item label="S/O号"><el-input v-model="form.data.soNo" /></el-form-item><el-form-item class="wide" label="货物描述"><el-input v-model="form.data.goodsDescription" type="textarea" /></el-form-item><el-form-item class="wide" label="备注"><el-input v-model="form.data.remark" type="textarea" /></el-form-item></el-form></section>
<section v-if="detail" class="surface-panel history-panel"><div class="history-heading"><div><h2>状态历史</h2><p>当前进度:<strong>{{ statusLabel(detail.status) }}</strong></p></div><el-tag type="primary" effect="light">当前状态</el-tag></div><el-timeline class="booking-timeline"><el-timeline-item v-for="(event, index) in detail.histories" :key="event.id" :timestamp="event.occurredAt" :type="event.toStatus === detail.status ? 'primary' : undefined" :hollow="event.toStatus !== detail.status" :class="{ 'is-current': event.toStatus === detail.status, 'is-latest': index === detail.histories.length - 1 }"><div class="history-event"><strong>{{ statusLabel(event.fromStatus) }} <span>→</span> {{ statusLabel(event.toStatus) }}</strong><span v-if="event.toStatus === detail.status" class="current-pill">当前状态</span><small>{{ event.operatorName || '系统' }}</small></div></el-timeline-item></el-timeline></section>
<section class="surface-panel">
<div class="section-title">
<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">保存</el-button>
</div>
</div>
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!editable" class="form-grid">
<el-form-item label="客户 ID" prop="customerId"><el-input v-model="form.data.customerId" :disabled="!isNew" /></el-form-item>
<el-form-item label="客户名称" prop="customerName"><el-input v-model="form.data.customerName" /></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.data.billOfLadingNo" /></el-form-item>
<el-form-item label="船公司 ID"><el-input v-model="form.data.shippingCompanyId" /></el-form-item>
<el-form-item label="船公司名称"><el-input v-model="form.data.shippingCompanyName" /></el-form-item>
<el-form-item label="订舱代理 ID"><el-input v-model="form.data.bookingAgentId" /></el-form-item>
<el-form-item label="订舱代理名称"><el-input v-model="form.data.bookingAgentName" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.data.vesselName" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.data.voyageNo" /></el-form-item>
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" /></el-form-item>
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" /></el-form-item>
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" /></el-form-item>
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" /></el-form-item>
<el-form-item label="交货地点"><el-input v-model="form.data.placeOfDelivery" /></el-form-item>
<el-form-item label="服务条款"><el-input v-model="form.data.serviceTerm" /></el-form-item>
<el-form-item label="运费条款"><el-input v-model="form.data.freightTerm" /></el-form-item>
<el-form-item label="预计开船"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预计到港"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item class="wide" label="备注"><el-input v-model="form.data.remark" type="textarea" /></el-form-item>
</el-form>
</section>
<section class="surface-panel">
<h2>订舱关系方</h2>
<div class="party-grid">
<div v-for="(party, index) in form.data.parties" :key="party.partyType" class="subsection">
<h3>{{ partyTypes[index]?.label || party.partyType }}</h3>
<el-input v-model="party.partyName" :disabled="!editable" placeholder="单位名称" />
<el-input v-model="party.contactName" :disabled="!editable" placeholder="联系人" />
<el-input v-model="party.contactPhone" :disabled="!editable" placeholder="联系电话" />
<el-input v-model="party.address" :disabled="!editable" placeholder="地址" />
</div>
</div>
</section>
<section class="surface-panel">
<div class="section-title"><h2>箱型需求</h2><el-button v-if="editable" @click="addContainer">新增箱型</el-button></div>
<div v-for="(container, index) in form.data.containerRequirements" :key="index" class="container-row">
<el-input v-model="container.containerType" :disabled="!editable" placeholder="箱型,如 40HC" />
<el-input v-model="container.containerKind" :disabled="!editable" placeholder="类型,如 FCL" />
<el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" />
<el-button v-if="editable" :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)">删除</el-button>
</div>
</section>
<section v-if="detail" class="surface-panel history-panel">
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
<el-timeline><el-timeline-item v-for="event in detail.histories" :key="event.id" :timestamp="event.occurredAt"><strong>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</strong><p>{{ event.operatorName || '系统' }}<span v-if="event.reason"> · {{ event.reason }}</span></p></el-timeline-item></el-timeline>
</section>
</template>
<el-dialog v-model="changeDialogOpen" title="申请改船" width="min(560px, 94vw)" :close-on-click-modal="false">
<el-form label-position="top">
<el-form-item label="新船名" required><el-input v-model="changeForm.vesselName" /></el-form-item>
<el-form-item label="新航次" required><el-input v-model="changeForm.voyageNo" /></el-form-item>
<el-form-item label="预计开船"><el-date-picker v-model="changeForm.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预计到港"><el-date-picker v-model="changeForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="变更原因" required><el-input v-model="changeForm.reason" type="textarea" /></el-form-item>
</el-form>
<template #footer><el-button @click="changeDialogOpen = false">取消</el-button><el-button type="primary" :loading="actionSaving" :disabled="!changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()" @click="submitChange">提交申请</el-button></template>
</el-dialog>
</div>
</template>
<style scoped>.booking-detail{display:grid;gap:16px}.detail-heading{display:flex;align-items:center;gap:16px}.detail-heading h1{margin:0;font-size:20px}.detail-heading p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px;margin-left:auto}.heading-status-label{font-size:13px;font-weight:700;color:var(--cc-primary)}.surface-panel{padding:20px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:18px}.section-title h2,.surface-panel>h2,.history-heading h2{margin:0;font-size:16px}.actions{display:flex;flex-wrap:wrap;gap:8px}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.form-grid .wide{grid-column:1/-1}.mt{margin-top:8px}.history-panel{padding-bottom:12px}.history-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:18px}.history-heading p{margin:6px 0 0;color:var(--cc-muted);font-size:12px}.history-heading strong{color:var(--cc-primary)}.booking-timeline{padding:4px 8px}.history-event{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-height:28px;padding:8px 12px;border-radius:8px;color:var(--cc-muted)}.history-event strong{color:var(--cc-text);font-size:14px}.history-event strong span{padding:0 4px;color:var(--cc-muted)}.history-event small{color:var(--cc-muted);font-size:11px}.current-pill{padding:3px 8px;border-radius:999px;background:var(--el-color-primary-light-9);color:var(--el-color-primary);font-size:11px;font-weight:600}.booking-timeline :deep(.el-timeline-item__node--primary){box-shadow:0 0 0 5px var(--el-color-primary-light-9)}.booking-timeline :deep(.el-timeline-item.is-current .history-event){background:var(--el-color-primary-light-9);box-shadow:inset 3px 0 0 var(--el-color-primary)}@media(max-width:900px){.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.detail-heading{align-items:flex-start;flex-wrap:wrap}.heading-status{margin-left:0}.form-grid{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title,.history-heading{align-items:flex-start;flex-direction:column}.actions{width:100%}.actions :deep(.el-button){min-height:40px}.history-event{align-items:flex-start;flex-direction:column;gap:4px}}</style>
<style scoped>
.booking-detail{display:grid;gap:16px}.detail-heading{display:flex;align-items:center;gap:16px}.detail-heading h1{margin:0;font-size:20px}.detail-heading p,.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;margin-left:auto}.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}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.form-grid .wide{grid-column:1/-1}.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}.container-row{display:grid;grid-template-columns:1fr 1fr 160px auto;gap:10px;margin-top:10px}.history-panel strong{font-size:14px}@media(max-width:900px){.form-grid,.party-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.container-row{grid-template-columns:1fr 1fr}}@media(max-width:600px){.detail-heading{align-items:flex-start;flex-wrap:wrap}.heading-status{margin-left:0}.form-grid,.party-grid,.container-row{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title{flex-direction:column}.actions{width:100%}}
</style>
@@ -18,7 +18,7 @@ const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const query = reactive<QueryModel>({ keyword: '', status: '', pol: '', pod: '', soNo: '' })
const query = reactive<QueryModel>({ keyword: '', status: '' })
const statuses: Array<{ label: string; value: string }> = [
{ label: '草稿', value: 'DRAFT' }, { label: '已提交', value: 'SUBMITTED' }, { label: '已确认', value: 'CONFIRMED' },
{ label: '已放舱', value: 'RELEASED' }, { label: '失败', value: 'FAILED' }, { label: '变更中', value: 'CHANGE_PENDING' }, { label: '已取消', value: 'CANCELLED' },
@@ -26,16 +26,16 @@ const statuses: Array<{ label: string; value: string }> = [
const fields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/订单号/客户', width: 220 },
{ key: 'status', type: 'select', label: '状态', options: statuses },
{ key: 'pol', type: 'input', label: '起运港', width: 150 }, { key: 'pod', type: 'input', label: '目的港', width: 150 }, { key: 'soNo', type: 'input', label: 'S/O号', width: 150 },
]
const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 170, slotName: 'actions', hideable: false, movable: false },
{ prop: 'bookingNo', label: '订舱编号', minWidth: 150 }, { prop: 'orderNo', label: '关联订单', minWidth: 140 }, { prop: 'customerName', label: '委托单位', minWidth: 160 },
{ prop: 'mblShipper', label: 'MB/L货人', minWidth: 150 }, { prop: 'mblConsignee', label: 'MB/L收人', minWidth: 150 }, { prop: 'pol', label: '起运港(POL)', minWidth: 125 }, { prop: 'pod', label: '目的港(POD)', minWidth: 125 },
{ prop: 'containerSummary', label: '箱型/箱量', minWidth: 120 }, { prop: 'carrierName', label: '船公司', minWidth: 130 }, { prop: 'vesselName', label: '船名/航次', minWidth: 150, slotName: 'vessel' }, { prop: 'soNo', label: 'S/O号', minWidth: 130 },
{ prop: 'cutoffAt', label: '预计截关', minWidth: 150 }, { prop: 'etd', label: 'ETD', minWidth: 140 }, { prop: 'atd', label: 'ATD', minWidth: 140 }, { prop: 'eta', label: 'ETA', minWidth: 140 }, { prop: 'ata', label: 'ATA', minWidth: 140 }, { prop: 'status', label: '状态', minWidth: 110, slotName: 'status' },
{ prop: 'bookingNo', label: '订舱编号', minWidth: 150 }, { prop: 'customerName', label: '客户', minWidth: 160 },
{ prop: 'billOfLadingNo', label: '提单号', minWidth: 140 }, { prop: 'pol', label: '起运港', minWidth: 125 }, { prop: 'pod', label: '目的港', minWidth: 125 },
{ prop: 'carrierName', label: '船公司', minWidth: 130 }, { prop: 'bookingAgent', label: '订舱代理', minWidth: 140 },
{ prop: 'vesselName', label: '船名/航次', minWidth: 150, slotName: 'vessel' }, { prop: 'etd', label: '预计开船', minWidth: 140 },
{ prop: 'eta', label: '预计到港', minWidth: 140 }, { prop: 'status', label: '状态', minWidth: 110, slotName: 'status' },
]
async function load() { loading.value = true; error.value = null; try { const result = await listOceanBookings({ page: page.value, size: pageSize.value, keyword: String(query.keyword || '') || undefined, status: (query.status || undefined) as OceanBookingStatus | undefined, pol: String(query.pol || '') || undefined, pod: String(query.pod || '') || undefined, soNo: String(query.soNo || '') || undefined } satisfies OceanBookingQuery); rows.value = result.records; total.value = result.total } catch (e) { error.value = getApiErrorDetails(e) } finally { loading.value = false } }
async function load() { loading.value = true; error.value = null; try { const result = await listOceanBookings({ page: page.value, size: pageSize.value, 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(id ? `/ocean-bookings/${id}` : '/ocean-bookings/new') }
function search() { page.value = 1; void load() }
watch([page, pageSize], () => void load()); onMounted(load)
+29 -14
View File
@@ -1,26 +1,41 @@
export type OceanBookingStatus = 'DRAFT' | 'SUBMITTED' | 'CONFIRMED' | 'RELEASED' | 'FAILED' | 'CHANGE_PENDING' | 'CANCELLED'
export interface OceanBookingParty { partyType: string; companyName: string; contactName?: string | null; phone?: string | null; email?: string | null; address?: string | null }
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; containerKind?: string | null }
export interface OceanBookingContainerAllocation extends OceanBookingContainer { allocatedQuantity?: number | null }
export interface OceanBookingDraftData {
customerId: string; customerName: string; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]
billOfLadingNo?: 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
estimatedSailingAt?: string | null; estimatedArrivalAt?: string | null; soNo?: string | null; vesselName?: string | null; voyageNo?: string | null
cutoffAt?: string | null; goodsDescription?: string | null; packageCount?: number | null; grossWeight?: string | null; volume?: string | null
amount?: string | null; currency?: string | null; remark?: string | null; orderNo?: string | null
placeOfDelivery?: string | null; serviceTerm?: string | null; freightTerm?: string | null
estimatedSailingAt?: string | null; estimatedArrivalAt?: string | null; vesselName?: string | null; voyageNo?: string | null; remark?: string | null
}
export interface OceanBookingResource {
id: string; bookingNo: string | null; orderNo: string | null; customerId: string | null; customerName: string | null
status: OceanBookingStatus; carrierName: string | null; bookingAgent: string | null; vesselName: string | null; voyageNo: 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
remark: string | null; createdAt: string | null; updatedAt: string | null
id: string; bookingNo: string | null; status: OceanBookingStatus; version: number
customerId: string | null; customerName: string | null; billOfLadingNo: string | null
shippingCompanyId: string | null; shippingCompanyName: string | null; bookingAgentId: string | null; bookingAgentName: string | null
vesselName: string | null; voyageNo: string | null; departurePortCode: string | null; departurePortName: string | null
destinationPortCode: string | null; destinationPortName: string | null; placeOfDelivery: string | null
serviceTerm: string | null; freightTerm: string | null; estimatedSailingAt: string | null; estimatedArrivalAt: string | null
pendingVesselName: string | null; pendingVoyageNo: string | null; pendingEstimatedSailingAt: string | null; pendingEstimatedArrivalAt: string | null
changeReason: string | null; failureReason: string | null; cancellationReason: string | null; remark: string | null
createdAt: string | null; updatedAt: string | null
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
}
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking?: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]; containerAllocations: OceanBookingContainerAllocation[]; histories: OceanBookingStatusEvent[]; goodsDescription: string | null; packageCount: number | null; grossWeight: string | null; volume: string | null; statusHistory: OceanBookingStatusEvent[] }
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; operatorName: string | null; occurredAt: string }
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]; containerAllocations: OceanBookingContainerAllocation[]; 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; pol?: string; pod?: string; soNo?: string; customerId?: string }
export interface OceanBookingSaveRequest { expectedVersion: number | null; data: OceanBookingDraftData }
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; customerId?: string }
export interface OceanBookingSaveRequest { expectedVersion: number; data: OceanBookingDraftData }
export interface OceanBookingActionRequest { expectedVersion: number; reason?: string | null }
export interface OceanBookingChangeRequest extends OceanBookingActionRequest {
vesselName: string
voyageNo: string
estimatedSailingAt?: string | null
estimatedArrivalAt?: string | null
reason: string
}
@@ -145,10 +145,12 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('暂无费用数据')
})
it('does not expose the unconfirmed downstream workflow', () => {
expect(source).not.toContain('后半程作业')
expect(source).not.toContain('openWorkflow')
expect(source).not.toContain('workflowEligible')
it('mounts downstream progress inside the existing order detail without opening unsafe writes', () => {
expect(source).toContain('UnifiedOrderWorkflowPanel')
expect(source).toContain("{ key: 'workflow', label: '后半程作业'")
expect(source).toContain(':read-only="true"')
expect(source).toContain(':show-delivery="false"')
expect(source).toContain("detail.value?.summary.inboundId")
expect(routeSource).not.toContain('unified-order-workflow')
})
})
@@ -26,6 +26,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { UnifiedOrderWorkflowPanel } from '@/modules/unified-orders/workflows'
import {
downloadWeightImage,
getInboundMeasurement,
@@ -105,6 +106,10 @@ const detailTabs = [
{ key: 'other', label: '其他', icon: StickyNote },
]
const detailTabOptions = computed(() => detail.value?.summary.inboundId
? [...detailTabs, { key: 'workflow', label: '后半程作业', icon: ListChecks }]
: detailTabs)
const customerMeasurementColumns = [
{ key: 'itemNo', label: '#' }, { key: 'lengthCm', label: '箱规长(cm)' }, { key: 'widthCm', label: '箱规宽(cm)' },
{ key: 'heightCm', label: '箱规高(cm)' }, { key: 'packageCount', label: '箱数' }, { key: 'unitWeightKg', label: '每箱毛重' },
@@ -789,7 +794,7 @@ onMounted(() => {
</section>
<div class="detail-tabs" role="tablist" aria-label="订单详情模块">
<button v-for="tab in detailTabs" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="selectDetailTab(tab.key)"><component :is="tab.icon" :size="17" /><span>{{ tab.label }}</span></button>
<button v-for="tab in detailTabOptions" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="selectDetailTab(tab.key)"><component :is="tab.icon" :size="17" /><span>{{ tab.label }}</span></button>
</div>
<section :id="`order-detail-panel-${activeDetailTab}`" class="detail-panel" role="tabpanel" :aria-labelledby="`order-detail-tab-${activeDetailTab}`" tabindex="0">
@@ -922,6 +927,19 @@ onMounted(() => {
<ol v-else class="history-list" aria-label="状态历史"><li v-for="(item, index) in detail.timeline" :key="`${item.source || 'event'}-${item.occurredAt || index}`"><span class="history-marker" aria-hidden="true" /><div><div class="history-line"><strong>{{ historyActionLabel(item.action || item.eventType) }}</strong><StatusBadge :status="statusLabel(item.mainStatus)" /></div><p>{{ item.operatorName || '系统' }} · {{ formatDate(item.occurredAt) }}</p><p v-if="item.remark" class="history-remark">{{ item.remark }}</p></div></li></ol>
</template>
<template v-else-if="activeDetailTab === 'workflow'">
<UnifiedOrderWorkflowPanel
:tenant-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:main-status="detail.summary.mainStatus"
:inbound-version="detail.summary.inboundVersion"
:picking-version="detail.summary.loading?.pickingVersion"
:delivery-tracking-no="detail.summary.delivery?.trackingNo"
:read-only="true"
:show-delivery="false"
@updated="loadDetail(true)"
/>
</template>
<template v-else-if="activeDetailTab === 'measurement'">
<div class="panel-title"><div><h2>测量数据</h2><p>{{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p></div><Ruler :size="19" /></div>
<section class="measurement-section">
@@ -30,10 +30,14 @@ const props = withDefaults(defineProps<{
inboundVersion?: number | null
pickingVersion?: number | null
deliveryTrackingNo?: string | null
readOnly?: boolean
showDelivery?: boolean
}>(), {
inboundVersion: null,
pickingVersion: null,
deliveryTrackingNo: null,
readOnly: false,
showDelivery: false,
})
const emit = defineEmits<{ updated: [] }>()
@@ -64,15 +68,15 @@ const isLoaded = computed(() => props.mainStatus === 'LOADED')
const preloadVersion = computed(() => preload.value?.version ?? 0)
const pickingExpectedVersion = computed(() => picking.value?.version ?? props.pickingVersion ?? null)
const inboundExpectedVersion = computed(() => props.inboundVersion)
const canSplit = computed(() => access.can('firstleg:inbound:split'))
const canPreload = computed(() => access.can('firstleg:inbound:preload'))
const canSplit = computed(() => !props.readOnly && access.can('firstleg:inbound:split'))
const canPreload = computed(() => !props.readOnly && access.can('firstleg:inbound:preload'))
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
const canPicking = computed(() => access.can('platform:firstleg:inbound:picking:edit'))
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
const canEvidence = computed(() => access.can('platform:firstleg:inbound:loading:edit'))
const canEvidence = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:loading:edit'))
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
const canLoadingComplete = computed(() => access.can('platform:firstleg:inbound:loading:complete'))
const canDeliveryComplete = computed(() => access.can('platform:firstleg:inbound:delivery:complete'))
const canLoadingComplete = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:loading:complete'))
const canDeliveryComplete = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:delivery:complete'))
function itemTemplate(): PreloadItem {
return { containerCode: '', cargoReference: '', packageCount: null, volumeCbm: 0, remark: '' }
@@ -279,7 +283,7 @@ onMounted(() => { void refresh() })
<div class="workflow-heading">
<div>
<h2>后半程作业</h2>
<p>订单按待分柜、装柜中、装柜完成和派送完成推进;资料可修改,客户确认不阻断。</p>
<p>查看订单的分柜、预装、拣货和装柜进度。</p>
</div>
<el-button text :loading="loading" aria-label="刷新作业数据" @click="refresh"><RefreshCw :size="15" />刷新</el-button>
</div>
@@ -290,6 +294,7 @@ onMounted(() => { void refresh() })
</el-alert>
<el-alert v-if="notice" class="workflow-alert" type="success" :closable="false" show-icon>{{ notice }}</el-alert>
<el-alert v-if="busy" class="workflow-alert" type="info" :closable="false">{{ idempotentMessage(busy) }}</el-alert>
<el-alert v-if="props.readOnly" class="workflow-alert" type="info" :closable="false" show-icon>当前仅开放进度查询;五图取证和装柜完成门禁接通后再开放操作。</el-alert>
<template v-if="isSplit">
<el-card shadow="never" class="workflow-card">
@@ -297,14 +302,14 @@ onMounted(() => { void refresh() })
<el-empty v-if="!preload && !loading" description="暂无预装清单,可直接生成分柜方案" />
<div class="preload-items" role="group" aria-label="预装清单明细">
<div v-for="(item, index) in preloadItems" :key="index" class="preload-row">
<el-input v-model="item.containerCode" aria-label="柜号" placeholder="柜号" />
<el-input v-model="item.cargoReference" aria-label="货物引用" placeholder="货物引用" />
<el-input-number v-model="item.packageCount" aria-label="件数" :min="1" controls-position="right" />
<el-input-number v-model="item.volumeCbm" aria-label="体积" :min="0" :precision="3" controls-position="right" />
<el-button text type="danger" aria-label="删除预装项" @click="removeItem(index)">删除</el-button>
<el-input v-model="item.containerCode" :disabled="props.readOnly" aria-label="柜号" placeholder="柜号" />
<el-input v-model="item.cargoReference" :disabled="props.readOnly" aria-label="货物引用" placeholder="货物引用" />
<el-input-number v-model="item.packageCount" :disabled="props.readOnly" aria-label="件数" :min="1" controls-position="right" />
<el-input-number v-model="item.volumeCbm" :disabled="props.readOnly" aria-label="体积" :min="0" :precision="3" controls-position="right" />
<el-button v-if="!props.readOnly" text type="danger" aria-label="删除预装项" @click="removeItem(index)">删除</el-button>
</div>
<el-button plain @click="addItem">新增预装项</el-button>
<el-input v-model="splitRemark" class="workflow-remark" type="textarea" :rows="2" aria-label="分柜备注" placeholder="分柜备注(可选)" />
<el-button v-if="!props.readOnly" plain @click="addItem">新增预装项</el-button>
<el-input v-model="splitRemark" :disabled="props.readOnly" class="workflow-remark" type="textarea" :rows="2" aria-label="分柜备注" placeholder="分柜备注(可选)" />
</div>
<div class="workflow-actions">
<el-button :disabled="!canSplit || Boolean(busy)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />智能分柜/保存调整</el-button>
@@ -328,7 +333,7 @@ onMounted(() => { void refresh() })
<el-button :disabled="!canPicking || !picking || Boolean(busy)" :loading="busy === 'picking-start'" @click="startPickingTask"><Play :size="15" />开始拣货</el-button>
<el-button :disabled="!canPicking || !picking || Boolean(busy)" :loading="busy === 'picking-complete'" @click="completePickingTask"><Check :size="15" />完成拣货</el-button>
</div>
<el-input v-model="loadingRemark" class="workflow-remark" type="textarea" :rows="2" aria-label="装柜备注" placeholder="装柜备注(可选)" />
<el-input v-model="loadingRemark" :disabled="props.readOnly" class="workflow-remark" type="textarea" :rows="2" aria-label="装柜备注" placeholder="装柜备注(可选)" />
</el-card>
<el-card shadow="never" class="workflow-card">
@@ -336,8 +341,8 @@ onMounted(() => { void refresh() })
<div class="evidence-grid">
<div v-for="entry in evidenceTypes" :key="entry.type" class="evidence-item">
<strong><Camera :size="15" />{{ entry.label }}</strong>
<el-input v-if="entry.type === 'SEAL'" :model-value="evidence(entry.type)?.sealNo || ''" aria-label="封签号" placeholder="封签号" @update:model-value="updateEvidenceField(entry.type, 'sealNo', $event)" />
<el-input v-else :model-value="evidence(entry.type)?.fileName || ''" aria-label="照片文件名" placeholder="文件上传完成后填写文件名" @update:model-value="updateEvidenceField(entry.type, 'fileName', $event)" />
<el-input v-if="entry.type === 'SEAL'" :disabled="props.readOnly" :model-value="evidence(entry.type)?.sealNo || ''" aria-label="封签号" placeholder="封签号" @update:model-value="updateEvidenceField(entry.type, 'sealNo', $event)" />
<el-input v-else :disabled="props.readOnly" :model-value="evidence(entry.type)?.fileName || ''" aria-label="照片文件名" placeholder="文件上传完成后填写文件名" @update:model-value="updateEvidenceField(entry.type, 'fileName', $event)" />
<el-button plain :disabled="!canEvidence || Boolean(busy)" :loading="busy === `evidence-${entry.type}`" @click="saveEvidence(entry.type)">保存</el-button>
</div>
</div>
@@ -349,7 +354,7 @@ onMounted(() => { void refresh() })
</el-card>
</template>
<el-card v-if="isLoaded" shadow="never" class="workflow-card">
<el-card v-if="isLoaded && props.showDelivery" shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>派送</span><el-tag type="success">装柜完成</el-tag></div></template>
<el-descriptions v-if="props.deliveryTrackingNo" :column="1" border>
<el-descriptions-item label="派送单号">{{ props.deliveryTrackingNo }}</el-descriptions-item>