优化
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,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]) => ({
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user