优化
This commit is contained in:
@@ -20,12 +20,14 @@
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 订舱草稿按当前版本维护资料;放舱后资料与表单只读,不允许绕过版本直接改字段。
|
||||
- 打印对草稿与已放舱订舱均开放,按当前**已保存**资料生成,未填写字段显示“—”,不用未保存的表单值。
|
||||
- 订舱草稿按当前版本维护资料;放舱后运输、关系方、柜型需求、预报货量和附件只读;仅实货量可由订舱编辑权限通过 `/actual-cargo` 按版本、幂等键和原因补录并审计,禁止绕过运输冻结边界。
|
||||
- 打印对草稿与已放舱订舱均开放,按当前**已保存**资料生成,未填写字段显示“—”,不用未保存的表单值。托运单取委值,提单确认件取实值;实值由客服手工录入,不回退为委值,不生成正式提单。
|
||||
- 关联候选只消费服务端 `/link-candidates` 分页结果,不在浏览器重排或本地拼接候选。
|
||||
- 订舱是拖车预约的唯一归属:拖车由 `../trucking/` 创建,本模块不直接建拖车单。
|
||||
- 出仓前船期只展示订舱级计划,实际节点归 `../port-schedules/` 与海外回写,本模块不写实际时间。
|
||||
|
||||
- 本轮布局与操作证据维护在 `PROTOTYPE_MATRIX.md`,缺截图时保留待人工确认。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/ocean-bookings/<目标测试>`;资料字段或打印变更同时核对 `print/` 模板与 `file/` 附件契约。
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 订舱页面受影响操作核对
|
||||
|
||||
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后仅补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
|
||||
|
||||
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已完成节点勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不推进,也不声称实际打印完成。预览标记随本次页面保留,刷新后按服务端保存/放舱/实值恢复进度,打印仍可重新预览;单据消费的数据改变后,该单据重新进入待预览。
|
||||
|
||||
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 人工结论 |
|
||||
|---|---|---|---|---|
|
||||
| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 独立页面;按产品稿重排视觉层级,保留仓库、渠道、预计到港、多柜型和附件;港代码与港名成组、委实成对输入,新增保存留在详情 | 待人工截图确认 |
|
||||
| `/ocean-bookings/:bookingId` | 已放舱补实 | 客服补录实际货量 | 运输及预报只读,实值可按编辑权限补录,原因必填并版本审计 | 待人工截图确认 |
|
||||
| `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有PDF预览;读已保存委/实数据,缺失显示“—” | 待人工截图确认 |
|
||||
|
||||
人工按 1920×1080、100% 缩放核对三方长名称/地址、两类货量、保存失败与版本冲突、冻结/无编辑权限,以及两类 PDF 预览的打开、关闭和重开。自动检查结果在交付中单独报告,不能代替人工截图。
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
|
||||
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingActualCargo, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
|
||||
import { http } from '@/shared/api/http'
|
||||
|
||||
const detailResponse = (version = 0, status = 'DRAFT') => ({
|
||||
@@ -14,6 +14,17 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({
|
||||
})
|
||||
|
||||
describe('ocean bookings api', () => {
|
||||
it('sends actual cargo only to the versioned supplement endpoint and normalizes decimal responses', async () => {
|
||||
const payload = { expectedVersion: 3, actualPackageCount: 480, actualGrossWeightKg: 14000.125, actualVolumeCbm: 65.321, reason: '客服补录' }
|
||||
const response = detailResponse(4, 'RELEASED')
|
||||
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', data: {
|
||||
...response, oceanBooking: { ...response.oceanBooking, actualPackageCount: 480, actualGrossWeightKg: '14000.125', actualVolumeCbm: '65.321', declaredPackageCount: 500 },
|
||||
} })
|
||||
const saved = await updateOceanBookingActualCargo('123', payload)
|
||||
expect(put).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/actual-cargo', payload, { headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
expect(saved).toMatchObject({ status: 'RELEASED', version: 4, actualPackageCount: 480, actualGrossWeightKg: 14000.125, actualVolumeCbm: 65.321, declaredPackageCount: 500 })
|
||||
})
|
||||
|
||||
it('preserves the backend history creation timestamp when normalizing a detail response', async () => {
|
||||
const createdAt = '2026-10-20T09:12:34'
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingDocument, OceanBookingDocumentInvalidation, OceanBookingDocumentMutation, OceanBookingDocumentType, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingPrintType, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
|
||||
import type { OceanBookingActualCargoRequest, OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingDocument, OceanBookingDocumentInvalidation, OceanBookingDocumentMutation, OceanBookingDocumentType, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingPrintType, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
|
||||
|
||||
const basePath = '/platform/v1/ocean-bookings'
|
||||
const maxErrorEnvelopeBytes = 1024 * 1024
|
||||
@@ -21,6 +21,12 @@ function booking(raw: OceanBookingResource): OceanBookingResource {
|
||||
carrierCode: raw.carrierCode ?? null,
|
||||
bookingAgentId: raw.bookingAgentId == null ? null : String(raw.bookingAgentId),
|
||||
containerCount: Number(raw.containerCount ?? 0),
|
||||
declaredPackageCount: raw.declaredPackageCount == null ? null : Number(raw.declaredPackageCount),
|
||||
declaredGrossWeightKg: raw.declaredGrossWeightKg == null ? null : Number(raw.declaredGrossWeightKg),
|
||||
declaredVolumeCbm: raw.declaredVolumeCbm == null ? null : Number(raw.declaredVolumeCbm),
|
||||
actualPackageCount: raw.actualPackageCount == null ? null : Number(raw.actualPackageCount),
|
||||
actualGrossWeightKg: raw.actualGrossWeightKg == null ? null : Number(raw.actualGrossWeightKg),
|
||||
actualVolumeCbm: raw.actualVolumeCbm == null ? null : Number(raw.actualVolumeCbm),
|
||||
pol: raw.pol ?? raw.departurePortName ?? null,
|
||||
pod: raw.pod ?? raw.destinationPortName ?? null,
|
||||
carrierName: raw.carrierName ?? raw.shippingCompanyName ?? null,
|
||||
@@ -88,6 +94,10 @@ export async function getOceanBooking(id: string) { const response = await http.
|
||||
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 updateOceanBookingDraft(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 async function updateOceanBookingActualCargo(id: string, payload: OceanBookingActualCargoRequest) {
|
||||
const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/actual-cargo`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } })
|
||||
return detail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function deleteOceanBookingDraft(id: string, expectedVersion: number) { await http.delete(`${basePath}/${encodeURIComponent(id)}`, { data: { expectedVersion }, headers: { 'Idempotency-Key': idempotencyKey() } }) }
|
||||
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)) }
|
||||
const printPaths: Record<OceanBookingPrintType, string> = {
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('OceanBookingPrintDialog', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(downloadOceanBookingPrint).toHaveBeenCalledWith('123', 'SHIPPING_INSTRUCTION')
|
||||
expect(wrapper.emitted('previewed')).toEqual([[{ bookingId: '123', type: 'SHIPPING_INSTRUCTION' }]])
|
||||
expect(document.body.querySelector('iframe')?.getAttribute('src')).toBe('blob:booking-print')
|
||||
expect(document.body.querySelector('.el-dialog__title')?.textContent).toBe('出口货物托运单 · OB-123')
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click()
|
||||
@@ -144,4 +145,24 @@ describe('OceanBookingPrintDialog identity and resource lifecycle', () => {
|
||||
wrapper.unmount()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledExactlyOnceWith('blob:current-print')
|
||||
})
|
||||
it('opens the selected document directly and reloads the other type after closing', async () => {
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:booking-print'), revokeObjectURL: vi.fn() })
|
||||
vi.mocked(downloadOceanBookingPrint).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
|
||||
const wrapper = mount(OceanBookingPrintDialog, {
|
||||
props: { modelValue: true, bookingId: '123', bookingNo: 'OB-123', allowed: true, initialType: 'SHIPPING_INSTRUCTION' },
|
||||
global: { plugins: [ElementPlus] }, attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
expect(downloadOceanBookingPrint).toHaveBeenLastCalledWith('123', 'SHIPPING_INSTRUCTION')
|
||||
expect(document.body.querySelector('iframe')).not.toBeNull()
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
await flushPromises()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:booking-print')
|
||||
await wrapper.setProps({ initialType: 'BILL_OF_LADING_CONFIRMATION', modelValue: true })
|
||||
await flushPromises()
|
||||
expect(downloadOceanBookingPrint).toHaveBeenLastCalledWith('123', 'BILL_OF_LADING_CONFIRMATION')
|
||||
expect(document.body.querySelector('.el-dialog__title')?.textContent).toBe('提单确认件 · OB-123')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -10,7 +10,8 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { downloadOceanBookingPrint } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingPrintType } from '../types'
|
||||
|
||||
const props = defineProps<{ bookingId: string; bookingNo?: string | null; allowed: boolean }>()
|
||||
const props = defineProps<{ bookingId: string; bookingNo?: string | null; allowed: boolean; initialType?: OceanBookingPrintType }>()
|
||||
const emit = defineEmits<{ previewed: [event: { bookingId: string; type: OceanBookingPrintType }] }>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const selectedType = ref<OceanBookingPrintType | ''>('')
|
||||
const loading = ref(false)
|
||||
@@ -42,15 +43,18 @@ function reset() {
|
||||
}
|
||||
async function preview() {
|
||||
if (!props.allowed || !selectedType.value || loading.value) return
|
||||
const bookingId = props.bookingId
|
||||
const type = selectedType.value
|
||||
const request = ++previewRequest
|
||||
loading.value = true
|
||||
error.value = null
|
||||
releasePreview()
|
||||
try {
|
||||
const blob = await downloadOceanBookingPrint(props.bookingId, selectedType.value)
|
||||
const blob = await downloadOceanBookingPrint(bookingId, type)
|
||||
if (request !== previewRequest || !open.value) return
|
||||
printBlob.value = blob
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
emit('previewed', { bookingId, type })
|
||||
} catch (cause) {
|
||||
if (request === previewRequest) error.value = getApiErrorDetails(cause, '打印单据加载失败,请稍后重试')
|
||||
} finally {
|
||||
@@ -71,7 +75,13 @@ function download() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
watch(open, (value) => { if (!value) reset() })
|
||||
watch(open, (value) => {
|
||||
if (!value) reset()
|
||||
else if (props.initialType) {
|
||||
selectedType.value = props.initialType
|
||||
void preview()
|
||||
}
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => { previewRequest += 1; releasePreview() })
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
listWarehouseLogisticsChannels,
|
||||
releaseOceanBooking,
|
||||
updateOceanBookingDraft,
|
||||
updateOceanBookingActualCargo,
|
||||
} from '../api/platformOceanBookings'
|
||||
import type { OceanBookingDetail, OceanBookingStatus } from '../types'
|
||||
import OceanBookingDetailView from './OceanBookingDetailView.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
|
||||
|
||||
const routerReplace = vi.hoisted(() => vi.fn())
|
||||
const routeState = vi.hoisted(() => ({ name: 'ocean-booking-detail', params: { bookingId: '123' } }))
|
||||
@@ -23,7 +25,8 @@ vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
useRouter: () => ({ push: vi.fn(), replace: routerReplace }),
|
||||
}))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
const deniedPermissions = vi.hoisted(() => new Set<string>())
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !deniedPermissions.has(permission) }) }))
|
||||
vi.mock('@/core/workspace-tabs/workspaceTabs', () => ({ useWorkspaceTabsStore: () => ({ removeByIds: vi.fn(), bumpCacheEpoch: vi.fn() }) }))
|
||||
vi.mock('@/modules/iam', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@/modules/iam')>(), listPlatformTenants: vi.fn() }))
|
||||
@@ -32,7 +35,7 @@ vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformOceanBookings', () => ({
|
||||
createOceanBooking: vi.fn(), deleteOceanBookingDraft: vi.fn(), getOceanBooking: vi.fn(),
|
||||
listOceanBookingAgents: vi.fn(), listWarehouseLogisticsChannels: vi.fn(),
|
||||
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(),
|
||||
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(), updateOceanBookingActualCargo: vi.fn(),
|
||||
}))
|
||||
|
||||
function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLadingNo = 'BL-001'): OceanBookingDetail {
|
||||
@@ -66,6 +69,11 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading
|
||||
|
||||
type ViewModel = {
|
||||
form: { data: import('../types').OceanBookingDraftData }
|
||||
saveActualCargo: () => Promise<void>
|
||||
actualReason: string
|
||||
cargoWritable: boolean
|
||||
hasUnsavedActualChanges: boolean
|
||||
printDisabledReason: string
|
||||
save: () => Promise<void>
|
||||
selectCustomer: (value: unknown) => void
|
||||
canManageDocuments: boolean
|
||||
@@ -95,6 +103,7 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
beforeEach(() => {
|
||||
routeState.name = 'ocean-booking-detail'; routeState.params.bookingId = '123'
|
||||
vi.clearAllMocks()
|
||||
deniedPermissions.clear()
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking())
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
@@ -103,6 +112,23 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
vi.mocked(listOceanBookingAgents).mockResolvedValue([{ supplierId: '20', name: '订舱代理', carrierCodes: ['MATSON'] }])
|
||||
})
|
||||
|
||||
it('renders actual workflow progress and advances only on a successful matching preview', async () => {
|
||||
const { wrapper } = await mountView()
|
||||
const states = () => wrapper.findAll('.booking-workflow li').map(item => item.attributes('data-state'))
|
||||
expect(states()).toEqual(['complete', 'current', 'pending', 'pending'])
|
||||
expect(wrapper.get('[aria-current="step"]').text()).toContain('托运单')
|
||||
const print = wrapper.findComponent(OceanBookingPrintDialog)
|
||||
print.vm.$emit('previewed', { bookingId: 'another', type: 'SHIPPING_INSTRUCTION' })
|
||||
await flushPromises()
|
||||
expect(states()).toEqual(['complete', 'current', 'pending', 'pending'])
|
||||
print.vm.$emit('previewed', { bookingId: '123', type: 'SHIPPING_INSTRUCTION' })
|
||||
await flushPromises()
|
||||
expect(states()).toEqual(['complete', 'complete', 'current', 'pending'])
|
||||
expect(wrapper.findAll('.booking-workflow li')[1]?.text()).toContain('已预览')
|
||||
expect(wrapper.get('[aria-current="step"]').text()).toContain('确认放舱')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('finds a customer outside the initial page and saves its actual ID and name in a new booking', async () => {
|
||||
routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new'
|
||||
const customer = { id: '9007199254740999', tenantName: '柏悦物流', status: 'ACTIVE' } as PlatformTenantResource
|
||||
@@ -137,7 +163,7 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
expect(wrapper.get('.release-summary').text()).toContain('EDITED-BL')
|
||||
expect(wrapper.get('.release-summary').element.closest('section')?.hasAttribute('data-tone')).toBe(false)
|
||||
expect(wrapper.find('.el-alert--warning').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('不能修改、不能删除')
|
||||
expect(dialog.find('.density-dialog-note').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('处理数据库')
|
||||
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
|
||||
expect(releaseOceanBooking).not.toHaveBeenCalled()
|
||||
@@ -281,4 +307,80 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
await Promise.all([first, duplicate])
|
||||
expect(vm.actionSaving).toBe(false)
|
||||
})
|
||||
it('keeps newly saved booking open and preserves partial parties and manual cargo', async () => {
|
||||
routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new'
|
||||
vi.mocked(createOceanBooking).mockResolvedValue(booking('DRAFT', 0))
|
||||
const { vm, wrapper } = await mountView()
|
||||
vm.form.data.parties[0]!.contactEmail = 'ops@example.test'
|
||||
vm.form.data.declaredPackageCount = 500
|
||||
vm.form.data.declaredGrossWeightKg = 15000
|
||||
vm.form.data.declaredVolumeCbm = 68
|
||||
await vm.save()
|
||||
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({
|
||||
freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS',
|
||||
declaredPackageCount: 500, actualPackageCount: null,
|
||||
parties: expect.arrayContaining([expect.objectContaining({ partyType: 'SHIPPER', partyName: '', contactEmail: 'ops@example.test' })]),
|
||||
}) }))
|
||||
expect(routerReplace).toHaveBeenCalledWith('/ocean-bookings/123')
|
||||
expect(vm.detail?.bookingNo).toBe('OB-123')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('saves released actual cargo alone with version and reason while transport remains frozen', async () => {
|
||||
vi.mocked(getOceanBooking).mockResolvedValue({ ...booking('RELEASED'), declaredPackageCount: 500 })
|
||||
vi.mocked(updateOceanBookingActualCargo).mockResolvedValue({ ...booking('RELEASED', 4), declaredPackageCount: 500, actualPackageCount: 480, actualGrossWeightKg: 14320.125, actualVolumeCbm: 65.321 })
|
||||
const { vm, wrapper } = await mountView()
|
||||
expect(wrapper.find('input[placeholder="请输入船名"]').attributes('disabled')).toBeDefined()
|
||||
const actualInput = wrapper.find('[aria-label="实际件数"]')
|
||||
expect(actualInput.attributes('disabled')).toBeUndefined()
|
||||
vm.form.data.actualPackageCount = 480
|
||||
vm.form.data.actualGrossWeightKg = 14320.125
|
||||
vm.form.data.actualVolumeCbm = 65.321
|
||||
vm.actualReason = ' 实装后补录 '
|
||||
await flushPromises()
|
||||
expect(vm.printDisabledReason).toContain('请先保存')
|
||||
await vm.saveActualCargo()
|
||||
expect(updateOceanBookingActualCargo).toHaveBeenCalledWith('123', { expectedVersion: 3, actualPackageCount: 480, actualGrossWeightKg: 14320.125, actualVolumeCbm: 65.321, reason: '实装后补录' })
|
||||
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
|
||||
expect(vm.detail?.version).toBe(4)
|
||||
expect(vm.actualReason).toBe('')
|
||||
expect(vm.hasUnsavedActualChanges).toBe(false)
|
||||
expect(vm.form.data.declaredPackageCount).toBe(500)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves actual inputs and reason on a stale-version failure and blocks duplicate writes', async () => {
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
|
||||
let rejectSave!: (cause: unknown) => void
|
||||
vi.mocked(updateOceanBookingActualCargo).mockReturnValue(new Promise((_, reject) => { rejectSave = reject }))
|
||||
const { vm, wrapper } = await mountView()
|
||||
vm.form.data.actualPackageCount = 480
|
||||
await vm.saveActualCargo()
|
||||
expect(updateOceanBookingActualCargo).not.toHaveBeenCalled()
|
||||
expect(vm.actionError?.message).toContain('原因')
|
||||
vm.actualReason = '修正'
|
||||
const first = vm.saveActualCargo()
|
||||
await vm.saveActualCargo()
|
||||
expect(updateOceanBookingActualCargo).toHaveBeenCalledTimes(1)
|
||||
rejectSave(new ApiResponseError('版本已变化', 'A0409', 409))
|
||||
await first
|
||||
expect(vm.form.data.actualPackageCount).toBe(480)
|
||||
expect(vm.actualReason).toBe('修正')
|
||||
expect(vm.detail?.version).toBe(3)
|
||||
expect(vm.actionError?.code).toBe('A0409')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps actual cargo read-only without edit permission', async () => {
|
||||
deniedPermissions.add('platform:firstleg:ocean-booking:edit')
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
|
||||
const { vm, wrapper } = await mountView()
|
||||
expect(vm.cargoWritable).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('保存实际货量')
|
||||
vm.actualReason = '补录'
|
||||
await vm.saveActualCargo()
|
||||
expect(updateOceanBookingActualCargo).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).toContain('function draftPayload(): OceanBookingDraftData')
|
||||
expect(source).toContain('channelCode: form.data.channelCode?.trim() || null')
|
||||
expect(source).toContain('warehouseId: form.data.warehouseId?.trim() || null')
|
||||
expect(source).toContain('parties: form.data.parties.filter((party) => party.partyName.trim())')
|
||||
expect(source).toContain('parties: form.data.parties.map')
|
||||
expect(source).toContain('containerRequirements: form.data.containerRequirements.map')
|
||||
expect(source).not.toContain('登记后直接确认')
|
||||
})
|
||||
@@ -51,7 +51,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).not.toContain('新增真实柜')
|
||||
})
|
||||
|
||||
it('performs the complete release gate and presents the irreversible warning', () => {
|
||||
it('performs the complete release gate without the extra explanatory line', () => {
|
||||
expect(source).toContain('function releaseValidationError()')
|
||||
expect(source).toContain('预计到港时间不能早于预计开船时间')
|
||||
expect(source).toContain('放舱前请填写起运港代码或名称')
|
||||
@@ -61,7 +61,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).toContain("if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'")
|
||||
expect(source).not.toContain('请先保存草稿后再放舱')
|
||||
expect(source).toContain('放舱前请填写${missing[1]}')
|
||||
expect(source).toContain('放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。')
|
||||
expect(source).not.toContain('放舱后运输资料和预报货量不能修改、不能删除;实际货量可另行补录并记录原因。请确认运输资料无误。')
|
||||
expect(source).toContain('if (hasUnsavedDraftChanges.value)')
|
||||
expect(source).toContain('const saved = await persistDraft(false)')
|
||||
expect(source).toContain('releaseVersion = saved.version')
|
||||
@@ -70,7 +70,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).toContain('if (!saved) return')
|
||||
expect(source).toContain("{{ form.data.billOfLadingNo || '—' }}")
|
||||
expect(source).not.toContain("{{ detail?.billOfLadingNo || '—' }}")
|
||||
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value)')
|
||||
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value)')
|
||||
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
|
||||
expect(source).not.toContain('releaseReason')
|
||||
expect(source).toContain('放舱前请上传有效的订舱确认文件或提单文件')
|
||||
@@ -112,15 +112,16 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).toContain('@media(max-width:720px)')
|
||||
expect(source).toContain('@media(max-width:420px)')
|
||||
expect(source).toContain('OceanBookingPrintDialog')
|
||||
expect(source).toContain('打印单据')
|
||||
expect(source).toContain('打印托运单')
|
||||
expect(source).toContain('打印提单确认件')
|
||||
})
|
||||
|
||||
it('keeps a DRAFT detail read-only when the user lacks write permission', () => {
|
||||
expect(source).toContain('const formWritable = computed(() => canSave.value)')
|
||||
expect(source).toContain(':disabled="!formWritable" class="booking-groups density-form"')
|
||||
expect(source).toContain('.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:var(--admin-space-5)}')
|
||||
expect(source).toContain('.booking-group:last-child{grid-column:1/-1}')
|
||||
expect(source).toContain('.container-table-wrap{max-width:100%;overflow-x:auto')
|
||||
expect(source).toMatch(/\.booking-core\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1\.2fr\) minmax\(0,\s*1fr\)/)
|
||||
expect(source).toMatch(/\.booking-group:last-child\s*\{\s*grid-column:\s*1\s*\/\s*-1/)
|
||||
expect(source).toMatch(/\.container-table-wrap\s*\{[^}]*max-width:\s*100%;\s*overflow-x:\s*auto/)
|
||||
expect(source).toContain('v-if="formWritable" class="container-actions"')
|
||||
expect(source).toContain(':disabled="!formWritable"')
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import '@/styles/adminWorkbench.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Printer, Save, Trash2 } from '@lucide/vue'
|
||||
import { ArrowLeft, Check, Printer, Save, Ship, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
releaseOceanBooking,
|
||||
listWarehouseLogisticsChannels,
|
||||
updateOceanBookingDraft,
|
||||
updateOceanBookingActualCargo,
|
||||
type OceanLogisticsChannel,
|
||||
} from '../api/platformOceanBookings'
|
||||
import type {
|
||||
@@ -41,10 +42,12 @@ import type {
|
||||
OceanBookingParty,
|
||||
OceanBookingSaveRequest,
|
||||
OceanBookingStatus,
|
||||
OceanBookingPrintType,
|
||||
} from '../types'
|
||||
import { oceanBookingStatusLabel } from '../status'
|
||||
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
|
||||
import { shouldClearBookingAgent } from '../bookingAgents'
|
||||
import { oceanBookingPrintSignature, oceanBookingWorkflow, type OceanBookingPreviewSignatures } from '../workflow'
|
||||
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
|
||||
import OceanBookingDocumentsPanel from '../components/OceanBookingDocumentsPanel.vue'
|
||||
|
||||
@@ -95,10 +98,13 @@ const channelGroups = computed(() => {
|
||||
const channelUnavailable = computed(() => Boolean(form.data.warehouseId) && !channelsLoading.value && !channelsError.value && channelGroups.value.length === 0)
|
||||
|
||||
function blankParties(): OceanBookingParty[] {
|
||||
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', address: '' }))
|
||||
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', contactEmail: '', taxNo: '', address: '' }))
|
||||
}
|
||||
function blankData(): OceanBookingDraftData {
|
||||
return {
|
||||
freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS',
|
||||
cargoDescription: '', marksAndNos: '', declaredPackageCount: null, declaredGrossWeightKg: null, declaredVolumeCbm: null,
|
||||
actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
|
||||
customerId: null, customerName: null, channelCode: '', warehouseId: '', parties: blankParties(),
|
||||
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
|
||||
shippingCompanyId: null, shippingCompanyName: null, carrierCode: null,
|
||||
@@ -117,13 +123,22 @@ const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can
|
||||
const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:ocean-booking:list'))
|
||||
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit'))
|
||||
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '')
|
||||
const workflowBusy = computed(() => saving.value || actionSaving.value)
|
||||
const actualSaving = ref(false)
|
||||
const actualReason = ref('')
|
||||
const cargoWritable = computed(() => formWritable.value || (detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit')))
|
||||
const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value)
|
||||
const releaseDialogOpen = ref(false)
|
||||
const printOpen = ref(false)
|
||||
const printType = ref<OceanBookingPrintType>('SHIPPING_INSTRUCTION')
|
||||
const previewSignatures = ref<OceanBookingPreviewSignatures>({})
|
||||
|
||||
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
|
||||
return oceanBookingStatusLabel(status)
|
||||
}
|
||||
function historyActionLabel(action: string) {
|
||||
const labels: Record<string, string> = { CREATE: '创建草稿', UPDATE_DRAFT: '编辑草稿', RELEASE: '确认放舱', UPDATE_ACTUAL_CARGO: '实货量补录/修正', UPLOAD_DOCUMENT: '上传文件', ATTACH_DOCUMENT: '关联文件', INVALIDATE_DOCUMENT: '作废文件' }
|
||||
return labels[action] || '资料维护'
|
||||
}
|
||||
async function loadOptions() {
|
||||
optionsLoading.value = true
|
||||
optionsError.value = ''
|
||||
@@ -163,12 +178,49 @@ function draftPayload(): OceanBookingDraftData {
|
||||
billOfLadingNo: form.data.billOfLadingNo.trim(),
|
||||
vesselName: form.data.vesselName.trim(),
|
||||
voyageNo: form.data.voyageNo.trim(),
|
||||
parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })),
|
||||
parties: form.data.parties.map((party) => ({ ...party, partyName: party.partyName.trim() })),
|
||||
containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity: Number(quantity), capacityCbm, maxWeightKg })),
|
||||
}
|
||||
}
|
||||
function draftSignature() { return JSON.stringify(draftPayload()) }
|
||||
const hasUnsavedDraftChanges = computed(() => Boolean(detail.value?.status === 'DRAFT' && savedDraftSignature.value && draftSignature() !== savedDraftSignature.value))
|
||||
const hasUnsavedActualChanges = computed(() => detail.value?.status === 'RELEASED' && (
|
||||
(form.data.actualPackageCount ?? null) !== (detail.value.actualPackageCount ?? null)
|
||||
|| (form.data.actualGrossWeightKg ?? null) !== (detail.value.actualGrossWeightKg ?? null)
|
||||
|| (form.data.actualVolumeCbm ?? null) !== (detail.value.actualVolumeCbm ?? null)
|
||||
))
|
||||
const printDisabledReason = computed(() => !detail.value ? '请先保存草稿' : hasUnsavedDraftChanges.value || hasUnsavedActualChanges.value ? '请先保存修改,再打印已保存资料' : !canPrint.value ? '暂无订舱单据打印权限' : '')
|
||||
const workflowSteps = computed(() => oceanBookingWorkflow(detail.value, hasUnsavedDraftChanges.value, hasUnsavedActualChanges.value, previewSignatures.value))
|
||||
function onPrintPreviewed(event: { bookingId: string; type: OceanBookingPrintType }) {
|
||||
if (!detail.value || event.bookingId !== detail.value.id || printDisabledReason.value) return
|
||||
previewSignatures.value[event.type] = oceanBookingPrintSignature(detail.value, event.type)
|
||||
}
|
||||
function openPrint(type: OceanBookingPrintType) {
|
||||
if (printDisabledReason.value || workflowBusy.value) return
|
||||
printType.value = type
|
||||
printOpen.value = true
|
||||
}
|
||||
async function saveActualCargo() {
|
||||
if (detail.value?.status !== 'RELEASED' || !cargoWritable.value || workflowBusy.value) return
|
||||
const validationError = cargoValidationError()
|
||||
if (validationError || !actualReason.value.trim()) {
|
||||
actionError.value = { message: validationError || '请填写实货量补录或修正原因' }
|
||||
return
|
||||
}
|
||||
actualSaving.value = true
|
||||
actionError.value = null
|
||||
try {
|
||||
applyDetail(await updateOceanBookingActualCargo(detail.value.id, {
|
||||
expectedVersion: detail.value.version,
|
||||
actualPackageCount: form.data.actualPackageCount ?? null,
|
||||
actualGrossWeightKg: form.data.actualGrossWeightKg ?? null,
|
||||
actualVolumeCbm: form.data.actualVolumeCbm ?? null,
|
||||
reason: actualReason.value.trim(),
|
||||
}))
|
||||
ElMessage.success('实际货量已保存')
|
||||
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
|
||||
finally { actualSaving.value = false }
|
||||
}
|
||||
function selectWarehouse(value: unknown) {
|
||||
const warehouseId = value == null || value === '' ? null : String(value)
|
||||
form.data.warehouseId = warehouseId
|
||||
@@ -248,9 +300,18 @@ function applyDetail(value: OceanBookingDetail) {
|
||||
destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName,
|
||||
placeOfDelivery: value.placeOfDelivery,
|
||||
remark: value.remark,
|
||||
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
|
||||
freightTerm: value.freightTerm, serviceTerm: value.serviceTerm, packageUnit: value.packageUnit,
|
||||
cargoDescription: value.cargoDescription, marksAndNos: value.marksAndNos,
|
||||
declaredPackageCount: value.declaredPackageCount ?? null,
|
||||
declaredGrossWeightKg: value.declaredGrossWeightKg == null ? null : Number(value.declaredGrossWeightKg),
|
||||
declaredVolumeCbm: value.declaredVolumeCbm == null ? null : Number(value.declaredVolumeCbm),
|
||||
actualPackageCount: value.actualPackageCount ?? null,
|
||||
actualGrossWeightKg: value.actualGrossWeightKg == null ? null : Number(value.actualGrossWeightKg),
|
||||
actualVolumeCbm: value.actualVolumeCbm == null ? null : Number(value.actualVolumeCbm),
|
||||
parties: blankParties().map((blank) => ({ ...blank, ...value.parties.find((party) => party.partyType === blank.partyType) })),
|
||||
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg }) => ({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg })) : [createBlankContainerRequirement()],
|
||||
}
|
||||
actualReason.value = ''
|
||||
selectedChannelName.value = value.channelName || ''
|
||||
savedDraftSignature.value = draftSignature()
|
||||
channels.value = []
|
||||
@@ -265,9 +326,24 @@ async function load() {
|
||||
catch (cause) { error.value = getApiErrorDetails(cause) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function cargoValidationError() {
|
||||
for (const kind of ['declared', 'actual'] as const) {
|
||||
const count = form.data[`${kind}PackageCount`]
|
||||
if (count != null && (!Number.isInteger(count) || count < 1 || count > 999999999)) return '货物件数必须为1到999999999的整数'
|
||||
for (const number of [form.data[`${kind}GrossWeightKg`], form.data[`${kind}VolumeCbm`]]) {
|
||||
if (number != null && (!Number.isFinite(number) || number <= 0 || number > 999999999999.999)) return '货物重量和体积必须为有效正数'
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
async function validateDraftForm() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (valid === false) { actionError.value = { message: '请检查表单中的必填项和格式' }; return false }
|
||||
const cargoMessage = cargoValidationError()
|
||||
if (cargoMessage) { actionError.value = { message: cargoMessage }; return false }
|
||||
if (form.data.parties.some((party) => party.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(party.contactEmail))) {
|
||||
actionError.value = { message: '请填写有效的关系方邮箱' }; return false
|
||||
}
|
||||
const requirementMessage = containerRequirementError(form.data.containerRequirements)
|
||||
if (requirementMessage) { actionError.value = { message: requirementMessage }; return false }
|
||||
return true
|
||||
@@ -283,10 +359,13 @@ async function persistDraft(showSuccess: boolean) {
|
||||
if (showSuccess) ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || '未生成订舱号'}` : '订舱草稿已保存')
|
||||
if (isNew.value) {
|
||||
workspace.removeByIds(['ocean-booking-create'])
|
||||
await router.replace('/ocean-bookings')
|
||||
applyDetail(saved)
|
||||
workspace.bumpCacheEpoch('ocean-bookings')
|
||||
await router.replace(`/ocean-bookings/${encodeURIComponent(saved.id)}`)
|
||||
return saved
|
||||
}
|
||||
applyDetail(saved)
|
||||
workspace.bumpCacheEpoch('ocean-bookings')
|
||||
return saved
|
||||
} catch (cause) { actionError.value = getApiErrorDetails(cause); return null }
|
||||
}
|
||||
@@ -377,53 +456,122 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<template #header>
|
||||
<div class="detail-heading">
|
||||
<el-button link class="density-back" @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.customerName} · ` : ''}${detail.pol || '—'} → ${detail.pod || '—'}` : ''">
|
||||
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
|
||||
<span class="booking-brand" aria-hidden="true"><Ship :size="22" /></span>
|
||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱信息管理'" :description="detail ? `${detail.customerName ? `${detail.customerName} · ` : ''}${detail.pol || '—'} → ${detail.pod || '—'}` : '出口订舱作业'">
|
||||
<template #actions>
|
||||
<div class="actions booking-page-actions">
|
||||
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
|
||||
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
|
||||
<el-button v-if="canSave" plain :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</PageToolbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else>
|
||||
<section class="workbench-panel is-compact">
|
||||
<div class="section-title">
|
||||
<h2>订舱信息</h2>
|
||||
<div class="actions">
|
||||
<el-tooltip :content="canPrint ? '按当前已保存资料打印出口货物托运单或提单确认件' : '暂无订舱单据打印权限'" placement="top"><span><el-button v-if="detail" plain :disabled="!canPrint || workflowBusy" @click="printOpen = true"><Printer :size="16" />打印单据</el-button></span></el-tooltip>
|
||||
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
|
||||
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" :disabled="workflowBusy" @click="openReleaseDialog">放舱</el-button>
|
||||
<el-button v-if="canSave" type="primary" :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
|
||||
</div>
|
||||
<ol class="booking-workflow" aria-label="订舱作业流程">
|
||||
<li v-for="(step, index) in workflowSteps" :key="step.label" :data-state="step.state" :aria-current="step.state === 'current' ? 'step' : undefined">
|
||||
<span class="booking-workflow__index" aria-hidden="true"><Check v-if="step.state === 'complete'" :size="16" /><template v-else>{{ index + 1 }}</template></span>
|
||||
<span class="booking-workflow__label">{{ step.label }}</span>
|
||||
<span class="booking-workflow__status">{{ step.status }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<section class="workbench-panel is-compact booking-section booking-section--core">
|
||||
<div class="section-title booking-section__heading">
|
||||
<h2><span class="booking-section__number" aria-hidden="true">1</span>一、核心订舱信息</h2>
|
||||
</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>
|
||||
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!formWritable" class="booking-groups density-form">
|
||||
<div class="booking-core">
|
||||
<div class="booking-core__main">
|
||||
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!formWritable" class="booking-groups density-form">
|
||||
<section class="booking-group" aria-label="基本信息">
|
||||
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
|
||||
<h3 class="booking-group__title"><span>基础信息</span></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="customersLoading" remote :remote-method="loadCustomers" @end-reached="customerEndReached" 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><LoadErrorState v-if="customersError" title="客户选项加载失败" :message="customersError.message" :loading="customersLoading" compact @retry="retryCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState></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="!formWritable || !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 && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></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="selectShippingCompany(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 :disabled="!editable || !form.data.carrierCode || bookingAgentsLoading || !!bookingAgentsError" :placeholder="form.data.carrierCode ? '请选择订舱代理' : '请先选择船公司'" :loading="bookingAgentsLoading" no-data-text="当前船公司暂无可用订舱代理" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="!editable && form.data.bookingAgentId && !bookingAgents.some((supplier) => supplier.supplierId === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in bookingAgents" :key="supplier.supplierId" :label="supplier.name" :value="supplier.supplierId" /></el-select><div v-if="bookingAgentsError" class="field-error"><ApiErrorMeta :details="bookingAgentsError" /><el-button link type="primary" :loading="bookingAgentsLoading" @click="form.data.carrierCode && loadBookingAgents(form.data.carrierCode)">重新加载</el-button></div></el-form-item>
|
||||
<el-form-item label="客户(选填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="单客户可选择,多客户可留空" no-data-text="暂无可选客户" :loading="customersLoading" remote :remote-method="loadCustomers" @end-reached="customerEndReached" 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><LoadErrorState v-if="customersError" title="客户选项加载失败" :message="customersError.message" :loading="customersLoading" compact @retry="retryCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState></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="!formWritable || !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 && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></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="船名" 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>
|
||||
<h3 class="booking-group__title"><span>港口航线</span></h3>
|
||||
<div class="booking-group__body booking-group__body--fields">
|
||||
<el-form-item label="起运港"><div class="booking-port-fields"><el-input v-model="form.data.departurePortCode" placeholder="港代码" aria-label="起运港代码" /><el-input v-model="form.data.departurePortName" placeholder="港口名称" aria-label="起运港名称" /></div></el-form-item>
|
||||
<el-form-item label="目的港"><div class="booking-port-fields"><el-input v-model="form.data.destinationPortCode" placeholder="港代码" aria-label="目的港代码" /><el-input v-model="form.data.destinationPortName" placeholder="港口名称" aria-label="目的港名称" /></div></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>
|
||||
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
|
||||
<el-form-item label="运费条款"><el-input v-model="form.data.freightTerm" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="运输条款"><el-input v-model="form.data.serviceTerm" maxlength="64" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
</el-form>
|
||||
<section class="booking-group" aria-label="货物信息">
|
||||
<h3 class="booking-group__title"><span>货物信息</span></h3>
|
||||
<el-form label-position="top" class="density-form booking-group__body">
|
||||
<div class="cargo-metrics" role="group" aria-label="货物预报与实际数量">
|
||||
<div class="cargo-metric">
|
||||
<span class="cargo-metric__label">件数(委 / 实)</span>
|
||||
<div class="cargo-metric__inputs">
|
||||
<el-input-number v-model="form.data.declaredPackageCount" :disabled="!formWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="预报量" aria-label="预报件数" />
|
||||
<span class="cargo-metric__separator" aria-hidden="true">/</span>
|
||||
<el-input-number v-model="form.data.actualPackageCount" :disabled="!cargoWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="待录实值" aria-label="实际件数" />
|
||||
<span class="cargo-metric__unit">{{ form.data.packageUnit || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cargo-metric">
|
||||
<span class="cargo-metric__label">毛重(委 / 实)</span>
|
||||
<div class="cargo-metric__inputs">
|
||||
<el-input-number v-model="form.data.declaredGrossWeightKg" :disabled="!formWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="预报量" aria-label="预报毛重" />
|
||||
<span class="cargo-metric__separator" aria-hidden="true">/</span>
|
||||
<el-input-number v-model="form.data.actualGrossWeightKg" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际毛重" />
|
||||
<span class="cargo-metric__unit">KGS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cargo-metric">
|
||||
<span class="cargo-metric__label">体积(委 / 实)</span>
|
||||
<div class="cargo-metric__inputs">
|
||||
<el-input-number v-model="form.data.declaredVolumeCbm" :disabled="!formWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="预报量" aria-label="预报体积" />
|
||||
<span class="cargo-metric__separator" aria-hidden="true">/</span>
|
||||
<el-input-number v-model="form.data.actualVolumeCbm" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际体积" />
|
||||
<span class="cargo-metric__unit">CBM</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="booking-group__body--fields">
|
||||
<el-form-item label="包装单位"><el-select v-model="form.data.packageUnit" :disabled="!formWritable" placeholder="请选择包装单位"><el-option label="箱 CARTONS" value="CARTONS" /><el-option label="件 PCS" value="PCS" /><el-option label="托 PALLETS" value="PALLETS" /></el-select></el-form-item>
|
||||
<el-form-item label="唛头和号码"><el-input v-model="form.data.marksAndNos" :disabled="!formWritable" maxlength="1000" /></el-form-item>
|
||||
<el-form-item class="booking-field--wide" label="品名及托运要求"><el-input v-model="form.data.cargoDescription" :disabled="!formWritable" type="textarea" :rows="2" maxlength="1000" placeholder="填写实际品名及免用箱等托运要求" /></el-form-item>
|
||||
</div>
|
||||
<template v-if="detail?.status === 'RELEASED' && cargoWritable">
|
||||
<el-form-item label="补录或修正原因" required><el-input v-model="actualReason" type="textarea" :rows="2" maxlength="1000" placeholder="请填写本次补录或修正原因" /></el-form-item>
|
||||
<div class="actions"><el-button type="primary" :loading="actualSaving" :disabled="workflowBusy || !hasUnsavedActualChanges" @click="saveActualCargo">保存实际货量</el-button></div>
|
||||
</template>
|
||||
</el-form>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="booking-parties" aria-label="订舱关系方"><h3 class="booking-group__title"><span>订舱关系方</span></h3>
|
||||
<el-form class="party-grid density-form" label-position="top" :disabled="!formWritable">
|
||||
<section v-for="(party, index) in form.data.parties" :key="party.partyType" class="subsection booking-party-card">
|
||||
<h3>{{ partyTypes[index]?.label || party.partyType }}</h3>
|
||||
<el-form-item label="单位名称" :required="true"><el-input v-model="party.partyName" :disabled="!formWritable" maxlength="255" :aria-label="`${partyTypes[index]?.label || party.partyType}单位名称(必填)`" placeholder="单位名称" /></el-form-item>
|
||||
<el-form-item label="联系人" ><el-input v-model="party.contactName" :disabled="!formWritable" maxlength="128" :aria-label="`${partyTypes[index]?.label || party.partyType}联系人`" placeholder="联系人" /></el-form-item>
|
||||
<el-form-item label="邮箱" ><el-input v-model="party.contactEmail" :disabled="!formWritable" maxlength="255" :aria-label="`${partyTypes[index]?.label || party.partyType}邮箱`" placeholder="邮箱" /></el-form-item>
|
||||
<el-form-item label="联系电话" ><el-input v-model="party.contactPhone" :disabled="!formWritable" maxlength="64" :aria-label="`${partyTypes[index]?.label || party.partyType}联系电话`" placeholder="联系电话" /></el-form-item>
|
||||
<el-form-item label="税号" ><el-input v-model="party.taxNo" :disabled="!formWritable" maxlength="128" :aria-label="`${partyTypes[index]?.label || party.partyType}税号`" placeholder="税号" /></el-form-item>
|
||||
<el-form-item label="地址" ><el-input v-model="party.address" :disabled="!formWritable" maxlength="1000" :aria-label="`${partyTypes[index]?.label || party.partyType}地址`" placeholder="地址" type="textarea" :rows="2" /></el-form-item>
|
||||
</section>
|
||||
</el-form>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-requirements">
|
||||
<section class="booking-group" aria-label="柜型需求">
|
||||
<h3>柜型需求</h3><div class="booking-group__body">
|
||||
<h3 class="booking-group__title"><span>柜型需求与备注</span></h3><div class="booking-group__body">
|
||||
<div class="container-table-wrap" aria-label="柜型需求">
|
||||
<div class="workbench-evidence"><table class="container-table density-table">
|
||||
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">数量</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
|
||||
@@ -450,19 +598,21 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</div>
|
||||
</section>
|
||||
</el-form>
|
||||
<footer class="booking-section__footer">
|
||||
<el-tooltip :content="printDisabledReason || '按已保存预报货量打印托运单'" placement="top"><span><el-button type="primary" :disabled="!!printDisabledReason || workflowBusy" @click="openPrint('SHIPPING_INSTRUCTION')"><Printer :size="16" />打印托运单</el-button></span></el-tooltip>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<section class="workbench-panel is-compact">
|
||||
<h2>订舱关系方</h2>
|
||||
<div class="party-grid">
|
||||
<div v-for="(party, index) in form.data.parties" :key="party.partyType" class="subsection">
|
||||
<h3><span class="required-mark" aria-hidden="true">*</span>{{ partyTypes[index]?.label || party.partyType }}</h3>
|
||||
<el-input v-model="party.partyName" :disabled="!formWritable" :aria-label="`${partyTypes[index]?.label || party.partyType}单位名称(必填)`" placeholder="单位名称(必填)" />
|
||||
<el-input v-model="party.contactName" :disabled="!formWritable" placeholder="联系人" />
|
||||
<el-input v-model="party.contactPhone" :disabled="!formWritable" placeholder="联系电话" />
|
||||
<el-input v-model="party.address" :disabled="!formWritable" placeholder="地址" />
|
||||
</div>
|
||||
</div>
|
||||
<section class="workbench-panel is-compact booking-section booking-section--release" aria-label="放舱信息">
|
||||
<div class="section-title booking-section__heading"><h2><span class="booking-section__number" aria-hidden="true">3</span>二、放舱信息</h2></div>
|
||||
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-group__body--fields">
|
||||
<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 class="booking-field--wide" label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
|
||||
</el-form>
|
||||
<footer class="booking-section__footer">
|
||||
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" :disabled="workflowBusy" @click="openReleaseDialog">放舱</el-button>
|
||||
<el-tooltip :content="printDisabledReason || '按已保存实际货量打印提单确认件'" placement="top"><span><el-button type="primary" :disabled="!!printDisabledReason || workflowBusy" @click="openPrint('BILL_OF_LADING_CONFIRMATION')"><Printer :size="16" />打印提单确认件</el-button></span></el-tooltip>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-no="detail.bookingNo" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
|
||||
@@ -471,15 +621,14 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
|
||||
<section v-if="detail" class="workbench-panel is-compact history-panel">
|
||||
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
|
||||
<div class="workbench-evidence"><table class="density-table booking-history-table"><thead><tr><th>时间</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="event in detail.histories" :key="event.id"><td>{{ event.createdAt || '—' }}</td><td>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</td><td>{{ event.operatorName || '系统' }}</td><td>{{ event.reason || '—' }}</td></tr><tr v-if="!detail.histories.length"><td colspan="4">暂无状态历史</td></tr></tbody></table></div>
|
||||
<div class="workbench-evidence"><table class="density-table booking-history-table"><thead><tr><th>时间</th><th>操作</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="event in detail.histories" :key="event.id"><td>{{ event.createdAt || '—' }}</td><td>{{ historyActionLabel(event.action) }}</td><td>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</td><td>{{ event.operatorName || '系统' }}</td><td>{{ event.reason || '—' }}</td></tr><tr v-if="!detail.histories.length"><td colspan="5">暂无状态历史</td></tr></tbody></table></div>
|
||||
</section>
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" />
|
||||
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" :initial-type="printType" @previewed="onPrintPreviewed" />
|
||||
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="releaseDialogOpen" :title="bookingDialogTitle('确认放舱')" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||||
<div class="density-dialog-sections">
|
||||
<p class="density-dialog-note">放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。</p>
|
||||
<section class="density-dialog-section"><el-descriptions class="release-summary density-descriptions" :column="2" border>
|
||||
<el-descriptions-item label="提单号">{{ form.data.billOfLadingNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="船名">{{ form.data.vesselName || '—' }}</el-descriptions-item>
|
||||
@@ -493,6 +642,196 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.channel-code{display:block;margin-left:var(--admin-space-3);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-3);margin-top:5px;color:var(--workbench-muted);font-size:var(--admin-font-caption);line-height:20px}.field-error{color:var(--el-color-danger)}
|
||||
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;gap:var(--admin-space-5);padding:var(--admin-space-3) var(--admin-space-5);background:var(--admin-surface);border:1px solid var(--workbench-border);align-items:center}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-body)}.heading-status{display:flex;align-items:center;gap:var(--admin-space-3)}.surface-panel h2{margin:0;font-size:var(--admin-font-section)}.section-title{display:flex;justify-content:space-between;gap:var(--admin-space-5);align-items:flex-start;margin-bottom:var(--admin-space-5)}.actions{display:flex;flex-wrap:wrap;gap:var(--admin-space-3)}.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:var(--admin-space-5)}.booking-group{min-width:0;overflow:hidden;background:var(--admin-surface);border:0}.booking-group:last-child{grid-column:1/-1}.booking-group>h3{margin:0;color:#1f2937;font-size:var(--admin-font-section);padding:var(--admin-space-3) 0;border-bottom:1px solid var(--workbench-border)}.booking-group__body{padding:var(--admin-space-3) 0 0}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:var(--admin-space-4);row-gap:var(--admin-space-3)}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4);margin-top:var(--admin-space-5)}.subsection{display:grid;gap:var(--admin-space-4);border:0;padding:0}.subsection h3{margin:0;font-size:var(--admin-font-section)}.required-mark{margin-right:var(--admin-space-1);color:var(--el-color-danger)}.container-table-wrap{max-width:100%;overflow-x:auto;margin-bottom:var(--admin-space-4)}.container-table{width:100%;min-width:780px;table-layout:fixed}.container-table th:first-child,.container-table td:first-child{width:80px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.container-table th:nth-child(5),.container-table td:nth-child(5),.container-table th:nth-child(6),.container-table td:nth-child(6){width:100px}.container-table td :deep(.el-select),.container-table td :deep(.el-input){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__specification{color:var(--workbench-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:var(--admin-space-4);margin-bottom:var(--admin-space-5);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.add-container{margin:0}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column;padding:var(--admin-space-3) var(--admin-space-5);background:var(--admin-surface);border:1px solid var(--workbench-border);align-items:center}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.booking-group__body{padding:var(--admin-space-3) 0 0}.actions :deep(.el-button){min-height:44px}}.booking-history-table th:nth-child(1){width:180px}.booking-history-table th:nth-child(2){width:220px}.booking-history-table th:nth-child(3){width:140px}.booking-page-toolbar :deep(h1){font-size:var(--admin-font-title)}
|
||||
.booking-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
.detail-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--admin-space-5);
|
||||
min-width: 0;
|
||||
padding: var(--admin-space-3) 0 var(--admin-space-5);
|
||||
border-bottom: 1px solid var(--workbench-border);
|
||||
}
|
||||
.booking-brand {
|
||||
display: grid;
|
||||
flex: 0 0 36px;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--admin-dialog-section-radius);
|
||||
color: #fff;
|
||||
background: var(--workbench-primary);
|
||||
box-shadow: 0 3px 8px rgb(55 132 250 / 24%);
|
||||
}
|
||||
.booking-page-toolbar { min-width: 0; flex: 1; }
|
||||
.booking-page-toolbar :deep(h1) { font-size: var(--admin-font-title); }
|
||||
.booking-page-toolbar :deep(.page-toolbar__description) { margin-top: var(--admin-space-1); font-size: var(--admin-font-caption); color: var(--workbench-muted); }
|
||||
.booking-page-actions { align-items: center; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.booking-workflow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
list-style: none;
|
||||
margin: 0 0 var(--admin-space-6);
|
||||
padding: var(--admin-space-6) var(--admin-space-5);
|
||||
border: 1px solid var(--workbench-border);
|
||||
border-radius: var(--admin-dialog-section-radius);
|
||||
background: var(--workbench-surface-tint);
|
||||
}
|
||||
.booking-workflow li { position: relative; display: grid; justify-items: center; align-content: start; gap: var(--admin-space-3); min-width: 0; }
|
||||
.booking-workflow li:not(:last-child)::after {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: calc(50% + 16px);
|
||||
width: calc(100% - 32px);
|
||||
height: 2px;
|
||||
background: var(--workbench-border);
|
||||
content: '';
|
||||
}
|
||||
.booking-workflow__index {
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
color: var(--workbench-muted);
|
||||
background: var(--workbench-surface-soft);
|
||||
border: 1px solid var(--workbench-border);
|
||||
box-sizing: border-box;
|
||||
font-size: var(--admin-font-section);
|
||||
font-weight: 600;
|
||||
}
|
||||
.booking-workflow__label { padding: 0 var(--admin-space-2); color: var(--workbench-text); font-size: var(--admin-font-caption); font-weight: 600; text-align: center; line-height: 1.5; }
|
||||
.booking-workflow__status { color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 18px; text-align: center; }
|
||||
.booking-workflow li[data-state='current'] .booking-workflow__index { border-color: var(--workbench-primary); color: #fff; background: var(--workbench-primary); box-shadow: 0 0 0 4px var(--workbench-accent-soft); }
|
||||
.booking-workflow li[data-state='current'] .booking-workflow__label,
|
||||
.booking-workflow li[data-state='current'] .booking-workflow__status { color: var(--workbench-primary-active); }
|
||||
.booking-workflow li[data-state='complete'] .booking-workflow__index { color: var(--workbench-primary-active); background: var(--workbench-accent-soft); border-color: var(--workbench-accent-border); }
|
||||
.booking-workflow li[data-state='complete']::after { background: var(--workbench-primary); }
|
||||
.booking-workflow li[data-state='pending'] .booking-workflow__label { color: var(--workbench-muted); font-weight: 500; }
|
||||
.booking-detail.admin-workbench .booking-section {
|
||||
padding: var(--admin-space-6);
|
||||
margin-bottom: var(--admin-space-6);
|
||||
border-radius: var(--admin-dialog-section-radius);
|
||||
}
|
||||
.section-title { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); }
|
||||
.booking-section__heading { margin-bottom: var(--admin-space-6); }
|
||||
.booking-section__heading h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--admin-space-3);
|
||||
margin: 0;
|
||||
color: var(--workbench-text);
|
||||
font-size: var(--admin-font-section);
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
}
|
||||
.booking-section__number {
|
||||
display: inline-grid;
|
||||
flex: 0 0 24px;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--admin-action-radius);
|
||||
background: var(--workbench-primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 5px rgb(55 132 250 / 20%);
|
||||
font-size: var(--admin-font-caption);
|
||||
}
|
||||
.booking-core { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--admin-space-7); }
|
||||
.booking-core__main, .booking-parties, .booking-group { min-width: 0; }
|
||||
.booking-groups { display: grid; grid-template-columns: 1fr; gap: var(--admin-space-6); }
|
||||
.booking-group:last-child { grid-column: 1 / -1; }
|
||||
.booking-group__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--admin-space-3);
|
||||
margin: 0 0 var(--admin-space-5);
|
||||
color: var(--workbench-muted);
|
||||
font-size: var(--admin-font-caption);
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
.booking-group__title::before { flex: 0 0 5px; width: 5px; height: 5px; border-radius: 50%; background: var(--workbench-accent-border); content: ''; }
|
||||
.booking-group__title::after { flex: 1; height: 1px; background: var(--workbench-border); content: ''; }
|
||||
.booking-group__body--fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--admin-space-5); row-gap: var(--admin-space-3); }
|
||||
.booking-field--wide { grid-column: 1 / -1; }
|
||||
.booking-port-fields { display: grid; grid-template-columns: minmax(76px, .65fr) minmax(0, 1fr); gap: var(--admin-space-2); width: 100%; }
|
||||
.booking-core__main > .booking-group { margin-top: var(--admin-space-6); }
|
||||
.booking-parties { padding-left: var(--admin-space-7); border-left: 1px solid var(--workbench-border); }
|
||||
.party-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); align-items: start; }
|
||||
.booking-party-card { display: grid; min-width: 0; gap: var(--admin-space-3); padding: var(--admin-space-4); border: 1px solid var(--workbench-border); border-radius: var(--admin-dialog-section-radius); background: var(--workbench-surface-tint); }
|
||||
.booking-party-card h3 { margin: 0 0 var(--admin-space-1); padding-bottom: var(--admin-space-3); border-bottom: 1px dashed var(--workbench-border); color: var(--workbench-text); font-size: var(--admin-font-body); font-weight: 600; line-height: 20px; }
|
||||
/* 网格间距负责排版,避免再叠加 Element 表单项的默认下边距。 */
|
||||
.booking-detail :deep(.density-form .el-form-item) { margin-bottom: 0; }
|
||||
.booking-detail :deep(.density-form .el-form-item__label) { height: auto; margin-bottom: var(--admin-space-1); padding: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); font-weight: 500; line-height: 18px; }
|
||||
.booking-detail :deep(.density-form .el-input),
|
||||
.booking-detail :deep(.density-form .el-select),
|
||||
.booking-detail :deep(.density-form .el-textarea) {
|
||||
--el-input-bg-color: var(--workbench-surface);
|
||||
--el-fill-color-blank: var(--workbench-surface);
|
||||
--el-input-text-color: var(--workbench-text);
|
||||
--el-input-border-color: var(--el-border-color-darker);
|
||||
--el-input-hover-border-color: var(--workbench-primary-hover);
|
||||
--el-input-focus-border-color: var(--workbench-primary);
|
||||
--el-border-color: var(--el-border-color-darker);
|
||||
--el-border-color-hover: var(--workbench-primary-hover);
|
||||
}
|
||||
/* 白底和清晰文字表示可编辑;禁用控件继续使用组件原生灰底、弱文字及禁止光标。 */
|
||||
.booking-detail :deep(.density-form .el-input:not(.is-disabled)),
|
||||
.booking-detail :deep(.density-form .el-textarea:not(.is-disabled)),
|
||||
.booking-detail :deep(.density-form .el-select__wrapper:not(.is-disabled)) {
|
||||
--el-input-placeholder-color: var(--workbench-muted);
|
||||
--el-text-color-placeholder: var(--workbench-muted);
|
||||
}
|
||||
.booking-detail :deep(.density-form .el-input__inner),
|
||||
.booking-detail :deep(.density-form .el-textarea__inner),
|
||||
.booking-detail :deep(.density-form .el-select__wrapper) { font-size: var(--admin-font-body); }
|
||||
.booking-detail :deep(.density-form .el-input__wrapper),
|
||||
.booking-detail :deep(.density-form .el-select__wrapper) { min-height: var(--admin-control-height); box-sizing: border-box; }
|
||||
.cargo-metrics { display: grid; gap: var(--admin-space-3); margin-bottom: var(--admin-space-5); }
|
||||
.cargo-metric { display: grid; gap: var(--admin-space-1); }
|
||||
.cargo-metric__label { color: var(--workbench-muted); font-size: var(--admin-font-caption); font-weight: 500; line-height: 18px; }
|
||||
.cargo-metric__inputs { display: grid; grid-template-columns: minmax(0, 1fr) 12px minmax(0, 1fr) 64px; align-items: center; gap: var(--admin-space-2); }
|
||||
.cargo-metric__separator { color: var(--workbench-muted); text-align: center; }
|
||||
.cargo-metric__unit { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.cargo-metric__inputs :deep(.el-input__inner) { text-align: left; }
|
||||
.booking-requirements { margin-top: var(--admin-space-6); }
|
||||
.container-table-wrap { max-width: 100%; overflow-x: auto; margin-bottom: var(--admin-space-4); }
|
||||
.container-table { width: 100%; min-width: 780px; table-layout: fixed; }
|
||||
.container-table th:first-child, .container-table td:first-child { width: 80px; }
|
||||
.container-table th:nth-child(2), .container-table td:nth-child(2) { width: 180px; }
|
||||
.container-table th:nth-child(3), .container-table td:nth-child(3) { width: 90px; }
|
||||
.container-table th:nth-child(4), .container-table td:nth-child(4) { width: 110px; }
|
||||
.container-table th:nth-child(5), .container-table td:nth-child(5), .container-table th:nth-child(6), .container-table td:nth-child(6) { width: 100px; }
|
||||
.container-table td :deep(.el-select), .container-table td :deep(.el-input) { width: 100%; min-width: 0; }
|
||||
.container-table__action { text-align: center !important; }
|
||||
.container-table__specification { color: var(--workbench-muted); white-space: nowrap; }
|
||||
.container-actions { display: flex; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-5); color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.add-container { margin: 0; }
|
||||
.booking-section__footer { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--admin-space-3); margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px dashed var(--workbench-border); }
|
||||
.field-error, .field-hint { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); margin-top: var(--admin-space-1); color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 20px; }
|
||||
.field-error { color: var(--el-color-danger); }
|
||||
.history-panel h2 { margin: 0; font-size: var(--admin-font-section); }
|
||||
.history-panel p { margin: var(--admin-space-1) 0 var(--admin-space-5); color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||||
.booking-history-table th:nth-child(1) { width: 180px; }
|
||||
.booking-history-table th:nth-child(2) { width: 160px; }
|
||||
.booking-history-table th:nth-child(3) { width: 200px; }
|
||||
.booking-history-table th:nth-child(4) { width: 140px; }
|
||||
@media(max-width:720px) {
|
||||
.booking-core, .booking-workflow, .party-grid { grid-template-columns: 1fr; }
|
||||
.booking-parties { padding-left: 0; border-left: 0; }
|
||||
.detail-heading { flex-wrap: wrap; }
|
||||
.booking-page-toolbar { width: 100%; }
|
||||
.booking-workflow li:not(:last-child)::after { display: none; }
|
||||
}
|
||||
@media(max-width:520px) {
|
||||
.booking-group__body--fields { grid-template-columns: 1fr; }
|
||||
.booking-field--wide { grid-column: auto; }
|
||||
}
|
||||
@media(max-width:420px) {
|
||||
.actions :deep(.el-button) { min-height: 44px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface OceanBookingAgentOption {
|
||||
carrierCodes: OceanBookingCarrierCode[]
|
||||
}
|
||||
|
||||
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null }
|
||||
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null; contactEmail?: string | null; taxNo?: string | null }
|
||||
export interface OceanBookingContainerRequirement {
|
||||
id?: string
|
||||
requirementId?: string
|
||||
@@ -53,7 +53,27 @@ export interface OceanBookingAllocatedContainer {
|
||||
capacityCbm: number
|
||||
maxWeightKg: number
|
||||
}
|
||||
export interface OceanBookingDraftData {
|
||||
export interface OceanBookingCargoData {
|
||||
freightTerm?: string | null
|
||||
serviceTerm?: string | null
|
||||
packageUnit?: 'CARTONS' | 'PCS' | 'PALLETS' | null
|
||||
cargoDescription?: string | null
|
||||
marksAndNos?: string | null
|
||||
declaredPackageCount?: number | null
|
||||
declaredGrossWeightKg?: number | null
|
||||
declaredVolumeCbm?: number | null
|
||||
actualPackageCount?: number | null
|
||||
actualGrossWeightKg?: number | null
|
||||
actualVolumeCbm?: number | null
|
||||
}
|
||||
export interface OceanBookingActualCargoRequest {
|
||||
expectedVersion: number
|
||||
actualPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
reason: string
|
||||
}
|
||||
export interface OceanBookingDraftData extends OceanBookingCargoData {
|
||||
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainerRequirement[]
|
||||
channelCode: string | null; warehouseId: string | null
|
||||
billOfLadingNo: string; vesselName: string; voyageNo: string
|
||||
@@ -63,7 +83,7 @@ export interface OceanBookingDraftData {
|
||||
placeOfDelivery?: string | null
|
||||
remark?: string | null
|
||||
}
|
||||
export interface OceanBookingResource {
|
||||
export interface OceanBookingResource extends OceanBookingCargoData {
|
||||
id: string; bookingNo: string | null; status: OceanBookingStatus; version: number
|
||||
customerId: string | null; customerName: string | null; billOfLadingNo: string | null
|
||||
/** 订舱柜型需求 quantity 的总和,不是已关联真实柜数。 */
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { oceanBookingPrintSignature, oceanBookingWorkflow } from './workflow'
|
||||
import type { OceanBookingDetail, OceanBookingResource } from './types'
|
||||
|
||||
function detail(): OceanBookingDetail {
|
||||
// 流程只消费订舱已保存事实,不依赖权限、选项请求或页面临时输入。
|
||||
const resource: OceanBookingResource = {
|
||||
id: '123', bookingNo: 'OB-123', status: 'DRAFT', version: 1,
|
||||
customerId: null, customerName: null, billOfLadingNo: null,
|
||||
shipperName: null, consigneeName: null, containerCount: 1,
|
||||
channelCode: null, channelName: null, warehouseId: null, warehouseName: null,
|
||||
shippingCompanyId: null, shippingCompanyName: null, carrierCode: null, bookingAgentId: null, bookingAgentName: null,
|
||||
vesselName: null, voyageNo: null, departurePortCode: null, departurePortName: null,
|
||||
destinationPortCode: null, destinationPortName: null, placeOfDelivery: null,
|
||||
estimatedSailingAt: null, estimatedArrivalAt: null, remark: null, createdAt: null, updatedAt: null,
|
||||
orderNo: null, carrierName: null, bookingAgent: null, pol: null, pod: null,
|
||||
mblShipper: null, mblConsignee: null, soNo: null, cutoffAt: null,
|
||||
etd: null, atd: null, eta: null, ata: null, containerSummary: null, amount: null, currency: null,
|
||||
declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68,
|
||||
actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
|
||||
}
|
||||
return { ...resource, oceanBooking: resource, parties: [], containerRequirements: [], containers: [], documents: [], histories: [], statusHistory: [] }
|
||||
}
|
||||
|
||||
describe('ocean booking workflow milestones', () => {
|
||||
it('starts on the first step and does not mark an unsaved form as complete', () => {
|
||||
expect(oceanBookingWorkflow(null, false, false, {}).map(step => step.state))
|
||||
.toEqual(['current', 'pending', 'pending', 'pending'])
|
||||
expect(oceanBookingWorkflow(detail(), true, false, {})[0]).toMatchObject({ state: 'current', status: '修改待保存' })
|
||||
})
|
||||
|
||||
it('advances from saved draft to preview, then to release without inventing a print record', () => {
|
||||
const saved = detail()
|
||||
const initial = oceanBookingWorkflow(saved, false, false, {})
|
||||
expect(initial.map(step => step.state)).toEqual(['complete', 'current', 'pending', 'pending'])
|
||||
expect(initial[0]?.status).toBe('已保存')
|
||||
const previews = { SHIPPING_INSTRUCTION: oceanBookingPrintSignature(saved, 'SHIPPING_INSTRUCTION') }
|
||||
const previewed = oceanBookingWorkflow(saved, false, false, previews)
|
||||
expect(previewed.map(step => step.state)).toEqual(['complete', 'complete', 'current', 'pending'])
|
||||
expect(previewed[1]?.status).toBe('已预览')
|
||||
expect(previewed.some(step => step.status === '已打印')).toBe(false)
|
||||
})
|
||||
|
||||
it('restores released progress from server data and requires all saved actual values before final completion', () => {
|
||||
const saved = detail()
|
||||
saved.status = 'RELEASED'
|
||||
let steps = oceanBookingWorkflow(saved, false, false, {})
|
||||
expect(steps[2]).toMatchObject({ state: 'complete', status: '已放舱' })
|
||||
expect(steps[3]).toMatchObject({ state: 'current', status: '待补齐实值' })
|
||||
saved.actualPackageCount = 480
|
||||
saved.actualGrossWeightKg = 14320
|
||||
saved.actualVolumeCbm = 65
|
||||
const previews = { BILL_OF_LADING_CONFIRMATION: oceanBookingPrintSignature(saved, 'BILL_OF_LADING_CONFIRMATION') }
|
||||
expect(oceanBookingWorkflow(saved, false, true, previews)[3]).toMatchObject({ state: 'current', status: '实值待保存' })
|
||||
steps = oceanBookingWorkflow(saved, false, false, {})
|
||||
expect(steps[3]).toMatchObject({ state: 'current', status: '可预览 / 打印' })
|
||||
expect(oceanBookingWorkflow(saved, false, false, previews)[3]).toMatchObject({ state: 'complete', status: '已预览' })
|
||||
})
|
||||
|
||||
it('invalidates only previews whose actual data changed', () => {
|
||||
const saved = detail()
|
||||
saved.status = 'RELEASED'
|
||||
saved.actualPackageCount = 480; saved.actualGrossWeightKg = 14320; saved.actualVolumeCbm = 65
|
||||
const previews = {
|
||||
SHIPPING_INSTRUCTION: oceanBookingPrintSignature(saved, 'SHIPPING_INSTRUCTION'),
|
||||
BILL_OF_LADING_CONFIRMATION: oceanBookingPrintSignature(saved, 'BILL_OF_LADING_CONFIRMATION'),
|
||||
}
|
||||
saved.version = 100 // 附件等引起的订舱版本变化不会伪造或抹去相同单据的预览事实。
|
||||
saved.actualPackageCount = 490
|
||||
let steps = oceanBookingWorkflow(saved, false, false, previews)
|
||||
expect(steps[1]?.state).toBe('complete')
|
||||
expect(steps[3]).toMatchObject({ state: 'current', status: '可预览 / 打印' })
|
||||
saved.declaredPackageCount = 510
|
||||
steps = oceanBookingWorkflow(saved, false, false, previews)
|
||||
expect(steps[1]?.state).toBe('pending')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { OceanBookingDetail, OceanBookingPrintType } from './types'
|
||||
|
||||
export interface OceanBookingWorkflowStep {
|
||||
label: string
|
||||
status: string
|
||||
state: 'complete' | 'current' | 'pending'
|
||||
}
|
||||
|
||||
export type OceanBookingPreviewSignatures = Partial<Record<OceanBookingPrintType, string>>
|
||||
|
||||
// 预览只对应其实际消费的数据;补录实值不会作废已经预览的托运单。
|
||||
export function oceanBookingPrintSignature(detail: OceanBookingDetail, type: OceanBookingPrintType): string {
|
||||
return JSON.stringify([
|
||||
detail.id, detail.billOfLadingNo, detail.vesselName, detail.voyageNo, detail.estimatedSailingAt,
|
||||
detail.shippingCompanyName, detail.bookingAgentName,
|
||||
detail.departurePortCode, detail.departurePortName, detail.destinationPortCode, detail.destinationPortName,
|
||||
detail.placeOfDelivery, detail.serviceTerm, detail.freightTerm, detail.packageUnit,
|
||||
detail.cargoDescription, detail.marksAndNos, detail.remark,
|
||||
detail.parties.map(party => [party.partyType, party.partyName, party.address, party.contactName, party.contactPhone, party.contactEmail, party.taxNo]).sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
|
||||
detail.containerRequirements.map(item => [item.containerType, item.containerKind, item.quantity]),
|
||||
type === 'SHIPPING_INSTRUCTION'
|
||||
? [detail.declaredPackageCount, detail.declaredGrossWeightKg, detail.declaredVolumeCbm]
|
||||
: [detail.actualPackageCount, detail.actualGrossWeightKg, detail.actualVolumeCbm],
|
||||
])
|
||||
}
|
||||
|
||||
export function oceanBookingWorkflow(
|
||||
detail: OceanBookingDetail | null,
|
||||
draftDirty: boolean,
|
||||
actualDirty: boolean,
|
||||
previews: OceanBookingPreviewSignatures,
|
||||
): OceanBookingWorkflowStep[] {
|
||||
const saved = Boolean(detail && !draftDirty)
|
||||
const released = detail?.status === 'RELEASED'
|
||||
const actualComplete = Boolean(detail && [detail.actualPackageCount, detail.actualGrossWeightKg, detail.actualVolumeCbm]
|
||||
.every(value => value != null && Number(value) > 0))
|
||||
const previewed = (type: OceanBookingPrintType) => Boolean(detail && previews[type] === oceanBookingPrintSignature(detail, type))
|
||||
const instructionPreviewed = saved && previewed('SHIPPING_INSTRUCTION')
|
||||
const confirmationPreviewed = saved && released && actualComplete && !actualDirty && previewed('BILL_OF_LADING_CONFIRMATION')
|
||||
const current = !saved ? 0 : !released ? instructionPreviewed ? 2 : 1 : confirmationPreviewed ? -1 : 3
|
||||
const step = (index: number, label: string, complete: boolean, status: string): OceanBookingWorkflowStep => ({
|
||||
label, status, state: complete ? 'complete' : current === index ? 'current' : 'pending',
|
||||
})
|
||||
return [
|
||||
step(0, '填写并保存订舱信息', saved, saved ? '已保存' : detail ? '修改待保存' : '待填写并保存'),
|
||||
step(1, '托运单预览 / 打印', instructionPreviewed, instructionPreviewed ? '已预览' : saved ? '可预览 / 打印' : '待保存资料'),
|
||||
step(2, '补齐资料并确认放舱', released, released ? '已放舱' : saved ? '待确认放舱' : '待补齐资料'),
|
||||
step(3, '补实并预览提单确认件', confirmationPreviewed, !released ? '待放舱' : actualDirty ? '实值待保存' : !actualComplete ? '待补齐实值' : confirmationPreviewed ? '已预览' : '可预览 / 打印'),
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user