This commit is contained in:
2026-08-18 16:09:43 +08:00
parent f9ae2d94cb
commit 21249d1dde
52 changed files with 980 additions and 698 deletions
@@ -1,12 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
import { createOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, performOceanBookingAction, releaseOceanBooking, requestOceanBookingChange } from './platformOceanBookings'
import { createOceanBooking, deleteOceanBookingDraft, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings'
import { http } from '@/shared/api/http'
const detailResponse = (version = 0, status = 'CONFIRMED') => ({
const detailResponse = (version = 0, status = 'DRAFT') => ({
oceanBooking: {
id: 123, bookingNo: 'OB123', status, version, customerId: 456, customerName: '客户', shipperName: '发货企业', consigneeName: '收货企业', containerCount: '2',
},
parties: [],
containerRequirements: [{ id: 9007199254740994, oceanBookingId: 123, containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }],
containers: [{ containerBookingId: 9007199254740993, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', requirementId: 9007199254740994, allocationId: 9007199254740995, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, warehouseId: 9007199254740996, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', customerId: 456, customerName: '客户' }],
histories: [],
})
@@ -23,35 +24,33 @@ describe('ocean bookings api', () => {
})
it('uses version zero for create and extracts the id from the returned Detail', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(0, 'CONFIRMED') })
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: 'SEA', warehouseId: '8', billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00', serviceTerm: 'CY-CY', freightTerm: 'PREPAID', parties: [], containers: [{ containerNo: 'MSCU1234567', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }] } })
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse() })
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, serviceTerm: null, freightTerm: null, parties: [], containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }] } })
const sentPayload = post.mock.calls[0]?.[1] as { expectedVersion: number; data: Record<string, unknown> }
expect(sentPayload).toMatchObject({ expectedVersion: 0, data: { customerId: null, customerName: null, serviceTerm: 'CY-CY', freightTerm: 'PREPAID' } })
expect(sentPayload).toMatchObject({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null } })
expect(sentPayload).not.toMatchObject({ data: { customerId: '', customerName: '' } })
expect(sentPayload.data).toMatchObject({ billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' })
expect(sentPayload.data).toMatchObject({ containers: [{ containerNo: 'MSCU1234567', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }] })
expect(sentPayload.data).not.toHaveProperty('containerRequirements')
expect(sentPayload.data).toMatchObject({ containerRequirements: [{ containerType: '40HC', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }] })
expect(sentPayload.data).not.toHaveProperty('containers')
expect(sentPayload.data).not.toHaveProperty('warehouseName')
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
expect(created.id).toBe('123')
expect(created.version).toBe(0)
expect(created.status).toBe('CONFIRMED')
expect(created.containers[0]).toMatchObject({ containerBookingId: '9007199254740992', requirementId: '9007199254740994', allocationId: '9007199254740996', warehouseId: '9007199254740996', customerId: '456' })
expect(created.status).toBe('DRAFT')
expect(created.containerRequirements[0]).toMatchObject({ id: '9007199254740994', requirementId: '9007199254740994', quantity: 2 })
expect(created.containers[0]).toMatchObject({ containerBookingId: '9007199254740992', requirementId: '9007199254740994' })
})
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', 'cancel', { expectedVersion: 3, reason: 'ok' })
expect(post.mock.calls[0]?.[0]).toContain('/123/cancel')
expect(post.mock.calls[0]?.[1]).toEqual({ expectedVersion: 3, reason: 'ok' })
await requestOceanBookingChange('123', { expectedVersion: 4, vesselName: 'VESSEL', voyageNo: 'V001', reason: '调整船期' })
expect(post.mock.calls[1]?.[0]).toContain('/123/change-request')
expect(post.mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 4, vesselName: 'VESSEL', voyageNo: 'V001', reason: '调整船期' })
await releaseOceanBooking('123', { expectedVersion: 4, billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' })
expect(post.mock.calls[2]?.[0]).toContain('/123/release')
expect(post.mock.calls[2]?.[1]).toMatchObject({ billOfLadingNo: 'BL-1', vesselName: 'VESSEL', voyageNo: 'V001', estimatedSailingAt: '2026-08-12T10:00:00', estimatedArrivalAt: '2026-09-01T10:00:00' })
it('uses draft update, delete and release contracts without legacy actions', async () => {
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(4) })
const remove = vi.spyOn(http, 'delete').mockResolvedValue({ code: '200', message: 'ok', data: null })
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(5, 'RELEASED') })
const data = { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, serviceTerm: null, freightTerm: null, parties: [], containerRequirements: [] }
await updateOceanBookingDraft('123', { expectedVersion: 3, data })
await deleteOceanBookingDraft('123', 4)
await releaseOceanBooking('123', { expectedVersion: 4 })
expect(put).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123', { expectedVersion: 3, data }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(remove).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123', { data: { expectedVersion: 4 }, headers: { 'Idempotency-Key': expect.any(String) } })
expect(post).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/release', { expectedVersion: 4 }, { headers: { 'Idempotency-Key': expect.any(String) } })
})
it('loads warehouse-scoped logistics channels from the booking contract', async () => {
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OceanBookingActionRequest, OceanBookingChangeRequest, OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
import type { OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
const basePath = '/platform/v1/ocean-bookings'
@@ -34,13 +34,21 @@ function detail(raw: OceanBookingDetail): OceanBookingDetail {
...source,
oceanBooking: source,
parties: (raw.parties || []).map((party) => ({ ...party, id: party.id == null ? undefined : String(party.id) })),
containerRequirements: (raw.containerRequirements || []).map((requirement) => ({
...requirement,
id: requirement.id == null ? undefined : String(requirement.id),
requirementId: requirement.requirementId == null && requirement.id == null ? undefined : String(requirement.requirementId ?? requirement.id),
quantity: Number(requirement.quantity),
remainingQuantity: requirement.remainingQuantity == null ? undefined : Number(requirement.remainingQuantity),
capacityCbm: Number(requirement.capacityCbm),
maxWeightKg: Number(requirement.maxWeightKg),
})),
containers: (raw.containers || []).map((container) => ({
...container,
containerBookingId: String(container.containerBookingId),
requirementId: String(container.requirementId),
allocationId: String(container.allocationId),
warehouseId: String(container.warehouseId),
customerId: container.customerId == null ? null : String(container.customerId),
capacityCbm: Number(container.capacityCbm),
maxWeightKg: Number(container.maxWeightKg),
})),
histories,
statusHistory: histories,
@@ -54,10 +62,9 @@ export async function listOceanBookings(query: OceanBookingQuery) {
export async function getOceanBooking(id: string) { const response = await http.get<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export type OceanBookingAction = 'release' | 'cancel' | 'change-request' | 'change-confirm' | 'change-fail'
export async function performOceanBookingAction(id: string, action: Exclude<OceanBookingAction, 'change-request' | 'release'>, request: OceanBookingActionRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/${action}`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export async function 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 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)) }
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 async function listWarehouseLogisticsChannels(warehouseId: string): Promise<OceanLogisticsChannel[]> {
const response = await http.get<ApiEnvelope<OceanLogisticsChannel[]>, ApiEnvelope<OceanLogisticsChannel[]>>(`${basePath}/channels`, { params: { originWarehouseId: warehouseId } })
@@ -1,26 +1,24 @@
import { describe, expect, it } from 'vitest'
import { bookingContainerError, createBlankBookingContainer, selectBookingContainerType } from './containerRequirements'
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from './containerRequirements'
describe('ocean booking real containers', () => {
it('starts with one real container and applies the fixed platform specification', () => {
const container = createBlankBookingContainer()
expect(container).toEqual({
containerNo: '', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000,
})
selectBookingContainerType(container, '20GP')
expect(container).toMatchObject({ containerType: '20GP', capacityCbm: 33, maxWeightKg: 28000 })
describe('ocean booking container requirements', () => {
it('starts with one requirement and applies the fixed platform specification', () => {
const requirement = createBlankContainerRequirement()
expect(requirement).toEqual({ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 })
selectContainerRequirementType(requirement, '20GP')
expect(requirement).toMatchObject({ containerType: '20GP', capacityCbm: 33, maxWeightKg: 28000 })
})
it('requires unique real container numbers and fixed specifications', () => {
const first = { ...createBlankBookingContainer(), containerNo: 'MSCU1234567' }
expect(bookingContainerError([createBlankBookingContainer()])).toContain('尚未填写集装箱号')
expect(bookingContainerError([first, { ...first, containerNo: 'mscu1234567' }])).toContain('重复')
expect(bookingContainerError([{ ...first, capacityCbm: 75 }])).toContain('固定规格')
expect(bookingContainerError([first])).toBeNull()
it('requires positive quantities and fixed specifications', () => {
const requirement = createBlankContainerRequirement()
expect(containerRequirementError([])).toContain('至少保留一项')
expect(containerRequirementError([{ ...requirement, quantity: 0 }])).toContain('数量必须')
expect(containerRequirementError([{ ...requirement, capacityCbm: 75 }])).toContain('固定规格')
expect(containerRequirementError([requirement])).toBeNull()
})
it('limits one booking to twenty real containers', () => {
const containers = Array.from({ length: 21 }, (_, index) => ({ ...createBlankBookingContainer(), containerNo: `MSCU${index}` }))
expect(bookingContainerError(containers)).toContain('最多登记 20 个')
it('limits one booking to twenty requirement rows', () => {
const requirements = Array.from({ length: 21 }, () => createBlankContainerRequirement())
expect(containerRequirementError(requirements)).toContain('最多登记 20 项')
})
})
@@ -1,23 +1,20 @@
import type { OceanBookingContainerInput } from './types'
import type { OceanBookingContainerRequirement } from './types'
import { applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
export function createBlankBookingContainer(): OceanBookingContainerInput {
return { containerNo: '', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
export function createBlankContainerRequirement(): OceanBookingContainerRequirement {
return { containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }
}
export function selectBookingContainerType(container: OceanBookingContainerInput, containerType: string) {
applyContainerSpecification(container, containerType)
export function selectContainerRequirementType(requirement: OceanBookingContainerRequirement, containerType: string) {
applyContainerSpecification(requirement, containerType)
}
export function bookingContainerError(containers: OceanBookingContainerInput[]) {
if (!containers.length) return '请至少登记一个真实柜'
if (containers.length > 20) return '单次订舱最多登记 20 个真实柜'
const normalizedNumbers = containers.map((item) => item.containerNo.trim().toUpperCase())
const missingNumberIndex = normalizedNumbers.findIndex((value) => !value)
if (missingNumberIndex >= 0) return `第 ${missingNumberIndex + 1} 个真实柜尚未填写集装箱号`
const duplicateNumber = normalizedNumbers.find((value, index) => normalizedNumbers.indexOf(value) !== index)
if (duplicateNumber) return `集装箱号 ${duplicateNumber} 在本次订舱中重复`
const invalid = containers.find((item) => {
export function containerRequirementError(requirements: OceanBookingContainerRequirement[]) {
if (!requirements.length) return '请至少保留一项柜型需求'
if (requirements.length > 20) return '单次订舱最多登记 20 项柜型需求'
const invalidQuantityIndex = requirements.findIndex((item) => !Number.isInteger(Number(item.quantity)) || Number(item.quantity) < 1)
if (invalidQuantityIndex >= 0) return `第 ${invalidQuantityIndex + 1} 项柜型需求的数量必须是大于 0 的整数`
const invalid = requirements.find((item) => {
const specification = containerSpecification(item.containerType)
return !specification || Number(item.capacityCbm) !== specification.capacityCbm
|| Number(item.maxWeightKg) !== specification.maxWeightKg
@@ -5,195 +5,69 @@ import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingDetailView.vue'), 'utf8')
const routesSource = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/routes.ts'), 'utf8')
describe('OceanBookingDetailView interaction contract', () => {
it('recognizes the dedicated create route as an editable new booking', () => {
describe('OceanBookingDetailView draft and release contract', () => {
it('recognizes create and DRAFT detail routes as editable', () => {
expect(source).toContain("route.name === 'ocean-booking-create' || id.value === 'new'")
expect(source).toContain('const editable = computed(() => isNew.value)')
expect(source).toContain("const canSave = computed(() => isNew.value && access.can('platform:firstleg:ocean-booking:create'))")
expect(source).toContain(':disabled="!editable"')
expect(source).not.toContain("status === 'DRAFT'")
expect(source).toContain("const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')")
expect(source).toContain("platform:firstleg:ocean-booking:edit")
expect(routesSource.indexOf("path: 'ocean-bookings/new'")).toBeLessThan(routesSource.indexOf("path: 'ocean-bookings/:bookingId'"))
expect(routesSource).toContain("name: 'ocean-booking-create'")
expect(routesSource).toContain("permission: 'platform:firstleg:ocean-booking:create'")
})
it('selects business objects and automatically keeps their ids and names together', () => {
expect(source).toContain('placeholder="请选择客户(可选)"')
expect(source).toContain('filterable clearable')
expect(source).toContain('@update:model-value="selectCustomer"')
expect(source).toContain('no-data-text="暂无可选客户"')
expect(source).toContain('customerId: null, customerName: null')
expect(source).toContain("value == null || value === '' ? null : String(value)")
expect(source).toContain('form.data.customerName = form.data.customerId ? form.data.customerName : null')
expect(source).toContain('normalizeDraftData()')
expect(source).not.toContain("customerId: [{ required: true")
expect(source).toContain('placeholder="请选择船公司"')
expect(source).toContain('placeholder="请选择订舱代理"')
expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'BOOKING', status: 'ACTIVE' })")
expect(source).toContain('supplierResult.records.filter(isActiveBookingAgent)')
expect(source).toContain('bookingSuppliers.value.filter(isActiveBookingAgent)')
expect(source).toContain('selectShippingCompany')
expect(source).toContain('selectBookingAgent')
it('uses only DRAFT and RELEASED actions and removes voyage-change/cancel paths', () => {
expect(source).toContain("detail.value?.status === 'DRAFT'")
expect(source).toContain('删除草稿')
expect(source).toContain('放舱')
expect(source).not.toContain('申请改船')
expect(source).not.toContain('取消订舱')
expect(source).not.toContain('change-request')
expect(source).not.toContain('取消订舱')
expect(source).not.toContain('cancelOceanBooking')
})
it('saves incomplete DRAFT data and sends null for blank nullable fields', () => {
expect(source).toContain('保存草稿')
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('containerRequirements: form.data.containerRequirements.map')
expect(source).not.toContain('登记后直接确认')
})
it('renders editable container requirements with fixed specifications', () => {
expect(source).toContain('aria-label="柜型需求"')
expect(source).toContain('containerRequirements')
expect(source).toContain('<th scope="col">数量</th>')
expect(source).toContain('v-model="requirement.quantity"')
expect(source).toContain('selectContainerRequirementType(requirement')
expect(source).toContain('{{ requirement.capacityCbm }} m³')
expect(source).toContain('{{ requirement.maxWeightKg }} kg')
expect(source).not.toContain('v-model="container.containerNo"')
expect(source).not.toContain('新增真实柜')
})
it('performs the complete release gate and presents the irreversible warning', () => {
expect(source).toContain('function releaseValidationError()')
expect(source).toContain('预计到港时间不能早于预计开船时间')
expect(source).toContain("[form.data.customerId, '客户']")
expect(source).toContain('放舱前请填写${missing[1]}')
expect(source).toContain('放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。')
expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version })')
expect(source).not.toContain('releaseReason')
})
it('keeps warehouse-scoped channel and customer/supplier selectors accessible', () => {
expect(source).toContain("warehouseType: 'DOMESTIC', status: 'ACTIVE'")
expect(source).toContain('listWarehouseLogisticsChannels')
expect(source).toContain('placeholder="草稿可暂不选择"')
expect(source).toContain('暂无启用的订舱代理')
expect(source).toContain(':label="supplier.name"')
expect(source).not.toContain('bookingAgentLabel')
expect(source).not.toContain("scope: 'INTERNATIONAL'")
expect(source).toContain('!editable && form.data.bookingAgentId')
expect(source).toContain('form.data.bookingAgentId = supplier ? String(supplier.id) : null')
expect(source).toContain('form.data.bookingAgentName = supplier?.name || null')
expect(source).not.toContain('label="客户 ID"')
expect(source).not.toContain('label="船公司 ID"')
expect(source).not.toContain('label="订舱代理 ID"')
expect(source).toContain('filterable clearable')
})
it('hides unavailable customers instead of filling the selector with disabled options', () => {
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain(':label="customer.tenantName"')
expect(source).not.toContain('customer.tenantCode')
expect(source).toContain("customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)")
expect(source).toContain('v-for="customer in selectableCustomers"')
expect(source).not.toContain(':disabled="customer.status !== \'ACTIVE\'"')
})
it('keeps historical partner names visible when an existing partner is no longer selectable', () => {
expect(source).toContain("form.data.shippingCompanyName || '船公司名称缺失'")
expect(source).toContain("form.data.bookingAgentName || '订舱代理名称缺失'")
expect(source).toContain("from '@/modules/master-data'")
expect(source).toContain("from '@/modules/suppliers'")
})
it('renders the translated status once in the detail heading', () => {
expect(source).toContain('<StatusBadge :status="statusLabel(detail.status)" />')
expect(source).not.toContain('<StatusBadge :status="detail.status" />')
})
it('provides guidance for every free-text booking field', () => {
for (const placeholder of [
'例如:CNNGB', '例如:宁波港', '例如:USLAX', '例如:洛杉矶港',
'请输入最终交货地点', '请输入订舱备注(可选)',
]) expect(source).toContain(`placeholder="${placeholder}"`)
})
it('captures known offline booking facts during creation and only reviews them during release', () => {
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
expect(source).toContain(`v-model="form.data.${field}"`)
expect(source).toContain(`prop="${field}"`)
}
expect(source).not.toContain('确认放舱时录入')
expect(source).toContain('billOfLadingNo: value.billOfLadingNo || \'\'')
expect(source).toContain('estimatedSailingAt: value.estimatedSailingAt || null')
expect(source).toContain('if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!)')
expect(source).toContain('请复核已登记的真实订舱资料。放舱不会重新采集或修改这些信息。')
expect(source).toContain('const billOfLadingNo = detail.value.billOfLadingNo?.trim()')
expect(source).toContain('const estimatedSailingAt = detail.value.estimatedSailingAt')
expect(source).toContain('v-model="releaseReason"')
expect(source).not.toContain('v-model="releaseForm.')
expect(source).toContain('线下订舱资料已登记并确认')
expect(source).toContain('<Save :size="16" />登记订舱')
expect(source).toContain('登记后直接确认')
})
it('uses source-backed free text terms and fixed container specifications', () => {
expect(source).not.toContain('serviceTermOptions')
expect(source).not.toContain('freightTermOptions')
expect(source).toContain('const containerTypeOptions = CONTAINER_SPECIFICATIONS')
expect(source).toContain('label="主要运输条款 Service Term" prop="serviceTerm"')
expect(source).toContain('<el-input v-model="form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term"')
expect(source).toContain('label="运费条款 Freight Term" prop="freightTerm"')
expect(source).toContain('<el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term"')
expect(source).toContain('<el-select :model-value="container.containerType"')
expect(source).toContain('<el-select v-model="container.containerKind"')
expect(source).toContain('<th scope="col">容量</th><th scope="col">限重</th>')
expect(source).toContain('{{ container.capacityCbm }} m³')
expect(source).toContain('{{ container.maxWeightKg }} kg')
expect(source).not.toContain("['PREPAID', 'COLLECT']")
expect(source).not.toContain('<el-input v-model="container.containerType"')
})
it('captures channel, warehouse, capacity snapshots and uses the release DTO', () => {
expect(source).toContain("listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' })")
expect(source).toContain("warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC'")
expect(source).toContain('label="物流渠道" prop="channelCode"')
expect(source).toContain('<el-select :model-value="form.data.channelCode" filterable')
expect(source).toContain(':model-value="form.data.channelCode" filterable')
expect(source).not.toContain('<el-input v-model="form.data.channelCode"')
expect(source).toContain('listWarehouseLogisticsChannels')
expect(source).toContain('void loadChannels(warehouseId)')
expect(source).not.toContain('v-model="form.data.channelName"')
expect(source).toContain('form.data.channelCode = \'\'')
expect(source).toContain('function selectWarehouse(value: unknown)')
expect(source).not.toContain('form.data.warehouseName')
expect(source).toContain('@update:model-value="selectWarehouse"')
expect(source).not.toContain('@update:model-value="(value) =>')
expect(source).toContain('selectBookingContainerType(container')
expect(source).not.toContain('v-model="container.capacityCbm"')
expect(source).not.toContain('v-model="container.maxWeightKg"')
expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version')
expect(source).toContain('title="确认放舱"')
expect(source).toContain('createBlankBookingContainer()')
expect(source).toContain('bookingContainerError(form.data.containers)')
expect(source).toContain('function validateBookingContainers()')
expect(source).toContain('form.data.containers.forEach')
})
it('registers one real container per row and keeps each row removable', () => {
expect(source).toContain('@click="removeContainer(index)"')
expect(source).toContain(':aria-label="`删除真实柜 ${index + 1}`"')
expect(source).toContain("form.data.containers.length === 1 ? '至少保留一个真实柜' : '删除该真实柜'")
expect(source).toContain(':disabled="form.data.containers.length === 1"')
expect(source).toContain('function removeContainer(index: number) { if (form.data.containers.length > 1)')
expect(source).toContain('<table class="container-table">')
expect(source).toContain('.container-table-wrap{overflow-x:auto')
expect(source).toContain('v-model="container.containerNo"')
expect(source).toContain('maxlength="64" placeholder="请输入真实集装箱号"')
expect(source).toContain(':disabled="form.data.containers.length >= 20"')
expect(source).toContain('{{ form.data.containers.length }} / 20 个')
expect(source).toContain("detail.containers[index]?.cabinetNo || '—'")
expect(source).not.toContain('container.quantity')
expect(source).not.toContain('form.data.containerRequirements')
expect(source).not.toContain('class="container-row"')
})
it('keeps view, create and edit on the formal detail route with a return action', () => {
expect(source).toContain("router.push('/ocean-bookings')")
expect(source).toContain("router.replace(`/ocean-bookings/${created.id}`)")
expect(source).not.toContain('AppDrawer')
expect(source).not.toContain('打印')
})
it('groups real detail fields into the frozen three-column prototype structure', () => {
expect(source).toContain('aria-label="基本信息"')
expect(source).toContain('aria-label="航线船期"')
expect(source).toContain('aria-label="条款与真实柜"')
expect(source).toContain('grid-template-columns:minmax(0,1.5fr) repeat(2,minmax(0,1fr))')
expect(source).toContain('.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:10px;row-gap:8px}')
expect(source).toContain('class="booking-field--wide" label="交货地点"')
expect(source).toContain('@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}')
expect(source).toContain('@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}')
expect(source).toContain('min-width:780px')
})
it('keeps the three real booking parties in their own three-column section', () => {
expect(source).toContain("{ value: 'SHIPPER', label: '发货人' }")
expect(source).toContain("{ value: 'CONSIGNEE', label: '收货人' }")
expect(source).toContain("{ value: 'NOTIFY_PARTY', label: '通知人' }")
expect(source).toContain('.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))')
})
it('shows client-side validation failures instead of rendering an empty error alert', () => {
it('keeps dense layout, operation feedback and narrow-screen behavior', () => {
expect(source).toContain('<OperationErrorToast :details="actionError" />')
expect(source).not.toContain(':title="actionError.message"')
expect(source).toContain("actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }")
expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }")
expect(source).toContain('aria-label="真实柜登记"')
expect(source).toContain('placeholder="单位名称(必填)"')
})
it('does not expose the removed draft submission lifecycle', () => {
expect(source).not.toContain("submit: '提交'")
expect(source).not.toContain("confirm: '确认订舱'")
expect(source).not.toContain("fail: '标记失败'")
expect(source).not.toContain("status === 'DRAFT'")
expect(source).not.toContain('updateOceanBooking')
expect(source).toContain('LoadErrorState')
expect(source).toContain('@media(max-width:720px)')
expect(source).toContain('@media(max-width:420px)')
})
})
@@ -20,13 +20,12 @@ import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import {
createOceanBooking,
deleteOceanBookingDraft,
getOceanBooking,
performOceanBookingAction,
releaseOceanBooking,
requestOceanBookingChange,
listWarehouseLogisticsChannels,
updateOceanBookingDraft,
type OceanLogisticsChannel,
type OceanBookingAction,
} from '../api/platformOceanBookings'
import type {
OceanBookingDetail,
@@ -36,7 +35,7 @@ import type {
OceanBookingStatus,
} from '../types'
import { oceanBookingStatusLabel } from '../status'
import { bookingContainerError, createBlankBookingContainer, selectBookingContainerType } from '../containerRequirements'
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
import { isActiveBookingAgent } from '../bookingAgents'
const partyTypes = [
@@ -49,20 +48,6 @@ const containerKindOptions = [
{ value: 'FCL', label: '整箱 FCL' },
{ value: 'LCL', label: '拼箱 LCL' },
]
const actionLabels: Record<OceanBookingAction, string> = {
release: '确认放舱', cancel: '取消订舱',
'change-request': '申请改船', 'change-confirm': '确认改船', 'change-fail': '改船失败',
}
const actionPermissions: Record<OceanBookingAction, string> = {
release: 'platform:firstleg:ocean-booking:release',
cancel: 'platform:firstleg:ocean-booking:cancel', 'change-request': 'platform:firstleg:ocean-booking:change',
'change-confirm': 'platform:firstleg:ocean-booking:change', 'change-fail': 'platform:firstleg:ocean-booking:change',
}
const actionsByStatus: Partial<Record<OceanBookingStatus, OceanBookingAction[]>> = {
CONFIRMED: ['release', 'change-request', 'cancel'],
CHANGE_PENDING: ['change-confirm', 'change-fail', 'cancel'],
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
@@ -100,30 +85,16 @@ function blankData(): OceanBookingDraftData {
return {
customerId: null, customerName: null, channelCode: '', warehouseId: '', serviceTerm: null, freightTerm: null, parties: blankParties(),
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
containers: [createBlankBookingContainer()],
containerRequirements: [createBlankContainerRequirement()],
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
const rules: FormRules = {
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
billOfLadingNo: [{ required: true, message: '请输入提单号', trigger: 'blur' }],
vesselName: [{ required: true, message: '请输入船名', trigger: 'blur' }],
voyageNo: [{ required: true, message: '请输入航次', trigger: 'blur' }],
estimatedSailingAt: [{ required: true, message: '请选择预计开船时间', trigger: 'change' }],
estimatedArrivalAt: [{ required: true, message: '请选择预计到港时间', trigger: 'change' }],
serviceTerm: [{ required: true, message: '请输入主要运输条款 Service Term', trigger: 'blur' }],
freightTerm: [{ required: true, message: '请输入运费条款 Freight Term', trigger: 'blur' }],
}
const editable = computed(() => isNew.value)
const canSave = computed(() => isNew.value && access.can('platform:firstleg:ocean-booking:create'))
const availableActions = computed(() => (actionsByStatus[detail.value?.status || 'CONFIRMED'] || [])
.filter((action) => access.can(actionPermissions[action])))
const changeDialogOpen = ref(false)
const changeForm = reactive({ vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' })
const rules: FormRules = {}
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
const canSave = computed(() => editable.value && access.can(isNew.value ? 'platform:firstleg:ocean-booking:create' : 'platform:firstleg:ocean-booking:edit'))
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
const releaseDialogOpen = ref(false)
const releaseReason = ref('')
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
return oceanBookingStatusLabel(status)
@@ -162,10 +133,10 @@ function normalizeDraftData() {
form.data.billOfLadingNo = form.data.billOfLadingNo.trim()
form.data.vesselName = form.data.vesselName.trim()
form.data.voyageNo = form.data.voyageNo.trim()
form.data.containers.forEach((container) => { container.containerNo = container.containerNo.trim() })
form.data.containerRequirements.forEach((requirement) => { requirement.quantity = Number(requirement.quantity) })
}
function selectWarehouse(value: unknown) {
const warehouseId = String(value || '')
const warehouseId = value == null || value === '' ? null : String(value)
form.data.warehouseId = warehouseId
form.data.channelCode = ''
selectedChannelName.value = ''
@@ -215,7 +186,7 @@ function applyDetail(value: OceanBookingDetail) {
placeOfDelivery: value.placeOfDelivery, serviceTerm: value.serviceTerm, freightTerm: value.freightTerm,
remark: value.remark,
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
containers: value.containers.length ? value.containers.map(({ containerNo, containerType, containerKind, capacityCbm, maxWeightKg }) => ({ containerNo, containerType, containerKind, capacityCbm, maxWeightKg })) : [createBlankBookingContainer()],
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg }) => ({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg })) : [createBlankContainerRequirement()],
}
selectedChannelName.value = value.channelName || ''
channels.value = []
@@ -233,111 +204,77 @@ async function save() {
if (!canSave.value) return
actionError.value = null
normalizeDraftData()
if (!await formRef.value?.validate().catch(() => false)) {
actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }
return
}
if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!) {
actionError.value = { message: '预计到港时间不能早于预计开船时间' }
return
}
if (form.data.parties.some((party) => !party.partyName.trim())) {
actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }
return
}
if (!validateBookingContainers()) return
const requirementMessage = containerRequirementError(form.data.containerRequirements)
if (requirementMessage) { actionError.value = { message: requirementMessage }; return }
saving.value = true
actionError.value = null
try {
const payload = { ...form.data }
const created = await createOceanBooking({ expectedVersion: 0, data: payload })
ElMessage.success('线下订舱资料已登记并确认')
await router.replace(`/ocean-bookings/${created.id}`)
applyDetail(created)
const payload = {
...form.data,
channelCode: form.data.channelCode?.trim() || null,
warehouseId: form.data.warehouseId?.trim() || null,
parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })),
containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity, capacityCbm, maxWeightKg })),
}
const saved = isNew.value
? await createOceanBooking({ expectedVersion: 0, data: payload })
: await updateOceanBookingDraft(id.value, { expectedVersion: detail.value?.version || 0, data: payload })
ElMessage.success('订舱草稿已保存')
if (isNew.value) await router.replace(`/ocean-bookings/${saved.id}`)
applyDetail(saved)
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { saving.value = false }
}
async function requiredReason(action: OceanBookingAction) {
if (!['cancel', 'change-fail'].includes(action)) return null
const result = await ElMessageBox.prompt(`请填写“${actionLabels[action]}”原因,服务端将写入状态历史。`, actionLabels[action], {
inputValidator: (value) => Boolean(value?.trim()) || '原因不能为空',
confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning',
})
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
}
function openReleaseDialog() {
releaseReason.value = ''
normalizeDraftData()
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; return }
releaseDialogOpen.value = true
}
function releaseValidationError() {
const requiredFields: Array<[unknown, string]> = [
[form.data.customerId, '客户'], [form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'],
[form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'],
[form.data.estimatedSailingAt, '预计开船时间'], [form.data.estimatedArrivalAt, '预计到港时间'],
[form.data.serviceTerm, 'Service Term'], [form.data.freightTerm, 'Freight Term'],
[form.data.shippingCompanyId, '船公司'], [form.data.bookingAgentId, '订舱代理'],
]
const missing = requiredFields.find(([value]) => !String(value || '').trim())
if (missing) return `放舱前请填写${missing[1]}`
if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!) return '预计到港时间不能早于预计开船时间'
if (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
return containerRequirementError(form.data.containerRequirements)
}
async function submitRelease() {
if (!detail.value) return
const billOfLadingNo = detail.value.billOfLadingNo?.trim()
const vesselName = detail.value.vesselName?.trim()
const voyageNo = detail.value.voyageNo?.trim()
const estimatedSailingAt = detail.value.estimatedSailingAt
const estimatedArrivalAt = detail.value.estimatedArrivalAt
if (!billOfLadingNo || !vesselName || !voyageNo || !estimatedSailingAt || !estimatedArrivalAt) {
actionError.value = getApiErrorDetails(new Error('当前订舱缺少已确认运输资料,无法放舱'))
return
}
if (estimatedArrivalAt < estimatedSailingAt) {
actionError.value = getApiErrorDetails(new Error('预计到港时间不能早于预计开船时间'))
return
}
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; releaseDialogOpen.value = false; return }
actionSaving.value = true
actionError.value = null
try {
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt, reason: releaseReason.value.trim() || null }))
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version }))
releaseDialogOpen.value = false
ElMessage.success('确认放舱成功')
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { actionSaving.value = false }
}
async function submitChange() {
if (!detail.value || !changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()) return
actionSaving.value = true
async function removeDraft() {
if (!detail.value || !canDelete.value) return
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 }
if (action === 'release') { openReleaseDialog(); return }
actionError.value = null
try {
const reason = await requiredReason(action)
await ElMessageBox.confirm(`确认执行“${actionLabels[action]}”吗?`, actionLabels[action], { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
await ElMessageBox.confirm('确认删除该订舱草稿?删除后无法恢复。', '删除草稿', { confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' })
actionSaving.value = true
applyDetail(await performOceanBookingAction(id.value, action, { expectedVersion: detail.value.version, reason }))
ElMessage.success(`${actionLabels[action]}成功`)
await deleteOceanBookingDraft(id.value, detail.value.version)
ElMessage.success('订舱草稿已删除')
await router.replace('/ocean-bookings')
} catch (cause) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
} finally { actionSaving.value = false }
}
function validateBookingContainers() {
const message = bookingContainerError(form.data.containers)
if (message) {
actionError.value = { message }
return false
}
return true
function addRequirement() {
if (form.data.containerRequirements.length < 20) form.data.containerRequirements.push(createBlankContainerRequirement())
}
function addContainer() {
if (form.data.containers.length < 20) form.data.containers.push(createBlankBookingContainer())
}
function removeContainer(index: number) { if (form.data.containers.length > 1) form.data.containers.splice(index, 1) }
function removeRequirement(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
onMounted(() => { void load(); void loadOptions() })
</script>
@@ -349,7 +286,7 @@ onMounted(() => { void load(); void loadOptions() })
<template #header>
<div class="detail-heading">
<el-button @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱资料'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '登记线下已确认的真实订舱资料'">
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱草稿'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '先保存订舱草稿,资料完整后再确认放舱'">
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
</PageToolbar>
</div>
@@ -359,10 +296,11 @@ onMounted(() => { void load(); void loadOptions() })
<template v-else>
<section class="surface-panel">
<div class="section-title">
<div><h2>订舱信息</h2><p v-if="detail && !editable">已确认订舱资料仅可查看,船期变更请使用“申请改船”。</p></div>
<div><h2>订舱信息</h2><p v-if="detail?.status === 'DRAFT'">草稿可反复保存;放舱后资料同步至关联货柜并锁定。</p><p v-else-if="detail">已放舱资料只读。</p></div>
<div class="actions">
<el-button v-for="item in availableActions" :key="item" :loading="actionSaving" @click="runAction(item)">{{ actionLabels[item] }}</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />登记订舱</el-button>
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" @click="openReleaseDialog">放舱</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />保存草稿</el-button>
</div>
</div>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
@@ -370,10 +308,10 @@ onMounted(() => { void load(); void loadOptions() })
<section class="booking-group" aria-label="基本信息">
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
<el-form-item label="客户(可选)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="请选择客户(可选)" no-data-text="暂无可选客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '登记后直接确认'" disabled /></el-form-item>
<el-form-item label="客户(放舱前必填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="草稿可暂不选择" no-data-text="暂无可选客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '保存后为草稿'" disabled /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="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 placeholder="请选择订舱代理" :loading="optionsLoading" no-data-text="暂无启用的订舱代理" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="!editable && form.data.bookingAgentId && !selectableBookingAgents.some((supplier) => String(supplier.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in selectableBookingAgents" :key="String(supplier.id)" :label="supplier.name" :value="String(supplier.id)" /></el-select></el-form-item>
@@ -392,34 +330,34 @@ onMounted(() => { void load(); void loadOptions() })
<el-form-item label="预计到港" prop="estimatedArrivalAt"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
</div>
</section>
<section class="booking-group" aria-label="条款与真实柜">
<h3>条款与真实柜</h3><div class="booking-group__body">
<section class="booking-group" aria-label="条款与柜型需求">
<h3>条款与柜型需求</h3><div class="booking-group__body">
<div class="booking-terms">
<el-form-item label="主要运输条款 Service Term" prop="serviceTerm"><el-input v-model="form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term" /></el-form-item>
<el-form-item label="运费条款 Freight Term" prop="freightTerm"><el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term" /></el-form-item>
</div>
<div class="container-table-wrap" aria-label="真实柜登记">
<div class="container-table-wrap" aria-label="柜型需求">
<table class="container-table">
<thead><tr><th scope="col">操作</th><th scope="col">集装箱号</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail" scope="col">柜次号</th></tr></thead>
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">数量</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
<tbody>
<tr v-for="(container, index) in form.data.containers" :key="detail?.containers[index]?.containerBookingId || index">
<tr v-for="(requirement, index) in form.data.containerRequirements" :key="requirement.requirementId || index">
<td class="container-table__action">
<el-tooltip v-if="editable" :content="form.data.containers.length === 1 ? '至少保留一个真实柜' : '删除该真实柜'" placement="top">
<span><el-button circle plain size="small" type="danger" :aria-label="`删除真实柜 ${index + 1}`" :disabled="form.data.containers.length === 1" @click="removeContainer(index)"><Trash2 :size="14" /></el-button></span>
<el-tooltip v-if="editable" :content="form.data.containerRequirements.length === 1 ? '至少保留一项柜型需求' : '删除该柜型需求'" placement="top">
<span><el-button circle plain size="small" type="danger" :aria-label="`删除柜型需求 ${index + 1}`" :disabled="form.data.containerRequirements.length === 1" @click="removeRequirement(index)"><Trash2 :size="14" /></el-button></span>
</el-tooltip>
<span v-else aria-hidden="true">—</span>
</td>
<td><el-input v-model="container.containerNo" :disabled="!editable" maxlength="64" placeholder="请输入真实集装箱号" /></td>
<td><el-select :model-value="container.containerType" :disabled="!editable" placeholder="请选择柜型" @update:model-value="selectBookingContainerType(container, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></el-select></td>
<td><el-select v-model="container.containerKind" :disabled="!editable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></td>
<td class="container-table__specification"><span>{{ container.capacityCbm }} m³</span></td>
<td class="container-table__specification"><span>{{ container.maxWeightKg }} kg</span></td>
<td v-if="detail"><span>{{ detail.containers[index]?.cabinetNo || '—' }}</span></td>
<td><el-select :model-value="requirement.containerType" :disabled="!editable" placeholder="请选择柜型" @update:model-value="selectContainerRequirementType(requirement, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></el-select></td>
<td><el-select v-model="requirement.containerKind" :disabled="!editable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></td>
<td><el-input-number v-model="requirement.quantity" :disabled="!editable" :min="1" :max="99" controls-position="right" /></td>
<td class="container-table__specification"><span>{{ requirement.capacityCbm }} m³</span></td>
<td class="container-table__specification"><span>{{ requirement.maxWeightKg }} kg</span></td>
<td v-if="detail?.status === 'RELEASED'"><span>{{ requirement.remainingQuantity ?? requirement.quantity }}</span></td>
</tr>
</tbody>
</table>
</div>
<div v-if="editable" class="container-actions"><el-button class="add-container" size="small" :disabled="form.data.containers.length >= 20" @click="addContainer"><Plus :size="15" />新增真实柜</el-button><span>{{ form.data.containers.length }} / 20 个</span></div>
<div v-if="editable" class="container-actions"><el-button class="add-container" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement"><Plus :size="15" />新增柜型需求</el-button><span>{{ form.data.containerRequirements.length }} / 20 项</span></div>
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" placeholder="请输入订舱备注(可选)" /></el-form-item>
</div>
</section>
@@ -446,18 +384,9 @@ onMounted(() => { void load(); void loadOptions() })
</template>
</DetailPageLayout>
<AppDialog v-model="changeDialogOpen" title="申请改船" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="!changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()" confirm-text="提交申请" @confirm="submitChange">
<el-form label-position="top">
<el-form-item label="新船名" required><el-input v-model="changeForm.vesselName" placeholder="请输入新船名" /></el-form-item>
<el-form-item label="新航次" required><el-input v-model="changeForm.voyageNo" placeholder="请输入新航次" /></el-form-item>
<el-form-item label="预计开船"><el-date-picker v-model="changeForm.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<el-form-item label="预计到港"><el-date-picker v-model="changeForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item label="变更原因" required><el-input v-model="changeForm.reason" type="textarea" placeholder="请说明改船原因" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<el-form label-position="top">
<el-alert title="请复核已登记的真实订舱资料。放舱不会重新采集或修改这些信息。" type="info" :closable="false" show-icon />
<el-alert title="放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。" type="warning" :closable="false" show-icon />
<el-descriptions class="release-summary" :column="2" border>
<el-descriptions-item label="提单号">{{ detail?.billOfLadingNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="船名">{{ detail?.vesselName || '—' }}</el-descriptions-item>
@@ -465,7 +394,6 @@ onMounted(() => { void load(); void loadOptions() })
<el-descriptions-item label="预计开船">{{ detail?.estimatedSailingAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计到港">{{ detail?.estimatedArrivalAt || '—' }}</el-descriptions-item>
</el-descriptions>
<el-form-item label="备注"><el-input v-model="releaseReason" type="textarea" maxlength="1000" placeholder="请输入放舱备注(可选)" /></el-form-item>
</el-form>
</AppDialog>
</AdminPage>
@@ -1,12 +1,37 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps real booking creation available without mixing draft rows into the formal list', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).toContain('新增订舱'); expect(source).toContain("router.push('/ocean-bookings/new')"); expect(source).toContain("access.can('platform:firstleg:ocean-booking:create')"); expect(source).not.toContain('PageToolbar') }); it('shows only the formal booking detail action', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).not.toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }); it('limits status filters to the frozen formal booking states', () => { expect(source).toContain("['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED']"); expect(source).toContain('options: formalStatusOptions'); expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"'); expect(source).not.toContain(':status="row.status"') }) })
describe('OceanBookingsView prototype field matrix', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
it('keeps the frozen business field sequence after the first action column', () => {
describe('OceanBookingsView', () => {
it('keeps the operation column first and left fixed', () => {
expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'"))
expect(source).toContain("fixed: 'left'")
})
it('renders loading, empty and error states and keeps draft creation available', () => {
expect(source).toContain('LoadErrorState')
expect(source).toContain(':loading="loading"')
expect(source).toContain('新增订舱')
expect(source).toContain("router.push('/ocean-bookings/new')")
})
it('edits DRAFT rows and views RELEASED rows', () => {
expect(source).toContain('<Pencil v-if="row.status === \'DRAFT\'"')
expect(source).toContain("row.status === 'DRAFT' ? '编辑' : '查看'")
expect(source).not.toContain('MoreHorizontal')
})
it('uses only the frozen DRAFT and RELEASED status options', () => {
expect(source).toContain('options: oceanBookingStatusOptions')
expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"')
expect(source).not.toContain('CONFIRMED')
expect(source).not.toContain('CHANGE_PENDING')
expect(source).not.toContain('CANCELLED')
})
it('keeps the prototype business field order after actions', () => {
const fields = ['bookingNo', 'customerName', 'billOfLadingNo', 'shipperName', 'consigneeName', 'containerCount', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'status', 'updatedAt']
let previous = source.indexOf("key: 'actions'")
for (const field of fields) {
@@ -16,34 +41,10 @@ describe('OceanBookingsView prototype field matrix', () => {
}
})
it('formats schedule and update timestamps without adding print actions', () => {
expect(source).toContain("prop: 'billOfLadingNo', label: '提单号'")
expect(source).toContain("prop: 'containerCount', label: '柜量'")
expect(source).toContain('<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>')
expect(source).toContain('<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>')
expect(source).not.toContain('打印')
})
})
describe('OceanBookingsView visual action contract', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
it('uses bordered business buttons and stacks them on narrow screens', () => {
expect(source).toContain('size="small" type="primary" plain')
expect(source).not.toContain('link type="primary" @click="open(row.id)')
expect(source).toContain('@media(max-width:420px)')
expect(source).toContain('.table-actions')
expect(source).toContain(':total="total" stripe preference-key="ocean-bookings"')
})
})
describe('OceanBookingsView customer filter contract', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
it('loads active customers, displays names only and submits customerId', () => {
it('loads active customers by name and stays usable on narrow screens', () => {
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("customer.status === 'ACTIVE'")
expect(source).toContain("label: customer.tenantName")
expect(source).toContain("customerId: String(query.customerId || '') || undefined")
expect(source).toContain('label: customer.tenantName')
expect(source).not.toContain('customer.tenantCode')
expect(source).toContain('@media(max-width:420px)')
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Eye, Plus } from '@lucide/vue'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
@@ -29,11 +29,10 @@ const customersLoading = ref(false)
const query = reactive<QueryModel>({ customerId: '', keyword: '', status: '' })
const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE'))
const canCreate = computed(() => access.can('platform:firstleg:ocean-booking:create'))
const formalStatusOptions = oceanBookingStatusOptions.filter((item) => ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED'].includes(item.value))
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/订单号', width: 220 },
{ key: 'status', type: 'select', label: '状态', options: formalStatusOptions },
{ key: 'status', type: 'select', label: '状态', options: oceanBookingStatusOptions },
])
const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 100, slotName: 'actions', hideable: false, movable: false },
@@ -69,7 +68,7 @@ watch([page, pageSize], () => void load()); onMounted(() => { void load(); void
<section v-if="!error" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="createBooking"><Plus :size="16" />新增订舱</el-button></template>
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Eye :size="15" />查看</el-button></div></template>
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Pencil v-if="row.status === 'DRAFT'" :size="15" /><Eye v-else :size="15" />{{ row.status === 'DRAFT' ? '编辑' : '查看' }}</el-button></div></template>
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>
<template #eta="{ row }">{{ formatDateTime(row.eta) }}</template>
+1 -3
View File
@@ -4,10 +4,8 @@ import { oceanBookingStatusLabel, oceanBookingStatusOptions } from './status'
describe('ocean booking status labels', () => {
it('translates every backend status for selectors and badges', () => {
expect(oceanBookingStatusOptions).toEqual([
{ value: 'CONFIRMED', label: '已确认' },
{ value: 'DRAFT', label: '草稿' },
{ value: 'RELEASED', label: '已放舱' },
{ value: 'CHANGE_PENDING', label: '变更中' },
{ value: 'CANCELLED', label: '已取消' },
])
})
+1 -3
View File
@@ -1,10 +1,8 @@
import type { OceanBookingStatus } from './types'
export const oceanBookingStatusLabels: Record<OceanBookingStatus, string> = {
CONFIRMED: '已确认',
DRAFT: '草稿',
RELEASED: '已放舱',
CHANGE_PENDING: '变更中',
CANCELLED: '已取消',
}
export const oceanBookingStatusOptions = Object.entries(oceanBookingStatusLabels).map(([value, label]) => ({
+25 -20
View File
@@ -1,16 +1,30 @@
export type OceanBookingStatus = 'CONFIRMED' | 'RELEASED' | 'CHANGE_PENDING' | 'CANCELLED'
export type OceanBookingStatus = 'DRAFT' | 'RELEASED'
export type OceanBookingContainerType = '20GP' | '40GP' | '40HC' | '40NR' | '45HC'
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null }
export interface OceanBookingContainerInput { containerNo: string; containerType: OceanBookingContainerType; containerKind: string; capacityCbm: number; maxWeightKg: number }
export interface OceanBookingContainerSnapshot extends OceanBookingContainerInput {
containerBookingId: string; cabinetNo: string; requirementId: string; allocationId: string
warehouseId: string; warehouseName: string; channelCode: string; channelName: string
customerId: string | null; customerName: string | null
export interface OceanBookingContainerRequirement {
id?: string
requirementId?: string
containerType: OceanBookingContainerType
containerKind: string
quantity: number
remainingQuantity?: number
capacityCbm: number
maxWeightKg: number
}
export interface OceanBookingAllocatedContainer {
containerBookingId: string
cabinetNo: string
containerNo: string
requirementId: string
containerType: OceanBookingContainerType
containerKind: string
capacityCbm: number
maxWeightKg: number
}
export interface OceanBookingDraftData {
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containers: OceanBookingContainerInput[]
channelCode: string; warehouseId: string
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainerRequirement[]
channelCode: string | null; warehouseId: string | null
billOfLadingNo: string; vesselName: string; voyageNo: string
estimatedSailingAt: string | null; estimatedArrivalAt: string | null
shippingCompanyId?: string | null; shippingCompanyName?: string | null; bookingAgentId?: string | null; bookingAgentName?: string | null
@@ -27,25 +41,16 @@ export interface OceanBookingResource {
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
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
amount: string | null; currency: string | null
}
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containers: OceanBookingContainerSnapshot[]; histories: OceanBookingStatusEvent[]; statusHistory: OceanBookingStatusEvent[] }
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainerRequirement[]; containers: OceanBookingAllocatedContainer[]; histories: OceanBookingStatusEvent[]; statusHistory: OceanBookingStatusEvent[] }
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; occurredAt: string }
export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number }
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; customerId?: string }
export interface OceanBookingSaveRequest { expectedVersion: number; data: OceanBookingDraftData }
export interface OceanBookingActionRequest { expectedVersion: number; reason?: string | null }
export interface OceanBookingReleaseRequest { expectedVersion: number; billOfLadingNo: string; vesselName: string; voyageNo: string; estimatedSailingAt: string; estimatedArrivalAt: string; reason?: string | null }
export interface OceanBookingChangeRequest extends OceanBookingActionRequest {
vesselName: string
voyageNo: string
estimatedSailingAt?: string | null
estimatedArrivalAt?: string | null
reason: string
}
export interface OceanBookingReleaseRequest { expectedVersion: number }