Merge remote-tracking branch 'origin/release/test' into dev
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, rescheduleTruckAppointment, uploadTruckAppointmentAttachment } from './platformTrucking'
|
||||
import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentCandidates, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, rescheduleTruckAppointment, uploadTruckAppointmentAttachment } from './platformTrucking'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data }))
|
||||
|
||||
@@ -8,9 +8,18 @@ describe('truck appointment API', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('normalizes page values and maps filters', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: 1, oceanBookingId: 2, containerBookingId: 3, warehouseId: 4, shippingCompanyId: 5, bookingAgentId: 6, oceanBookingNo: 'OB-1', cabinetNo: 'CAB-1', containerNo: 'MSCU-1', warehouseName: '义乌仓', shippingCompanyName: '船公司', bookingAgentName: '订舱代理', attachmentCount: '2' }], page: '2', size: '20', total: '21', pages: '2' } })
|
||||
await expect(listTruckAppointments({ page: 2, size: 20, keyword: ' BY ', status: 'DISPATCHED', containerBookingId: '77' })).resolves.toMatchObject({ page: 2, total: 21, pages: 2, records: [{ id: '1', oceanBookingId: '2', containerBookingId: '3', warehouseId: '4', shippingCompanyId: '5', bookingAgentId: '6', oceanBookingNo: 'OB-1', cabinetNo: 'CAB-1', containerNo: 'MSCU-1', attachmentCount: 2 }] })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', containerBookingId: '77' } })
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: 1, oceanBookingId: 2, warehouseId: 4, shippingCompanyId: 5, bookingAgentId: 6, oceanBookingNo: 'OB-1', warehouseName: '义乌仓', shippingCompanyName: '船公司', bookingAgentName: '订舱代理', attachmentCount: '2' }], page: '2', size: '20', total: '21', pages: '2' } })
|
||||
await expect(listTruckAppointments({ page: 2, size: 20, keyword: ' BY ', status: 'DISPATCHED', oceanBookingId: '77' })).resolves.toMatchObject({ page: 2, total: 21, pages: 2, records: [{ id: '1', oceanBookingId: '2', warehouseId: '4', shippingCompanyId: '5', bookingAgentId: '6', oceanBookingNo: 'OB-1', attachmentCount: 2 }] })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', oceanBookingId: '77' } })
|
||||
})
|
||||
|
||||
it('uses the server-owned create candidate contract without rebuilding availability', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ oceanBookingId: 11, oceanBookingNo: 'OB-11', billOfLadingNo: 'BL-11', warehouseId: 12, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true }], page: '1', size: '200', total: '1', pages: '1' } })
|
||||
await expect(listTruckAppointmentCandidates({ keyword: ' OB ', page: 1, size: 200 })).resolves.toMatchObject({
|
||||
records: [{ oceanBookingId: '11', warehouseId: '12', bookingDocumentReady: true }],
|
||||
total: 1,
|
||||
})
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments/candidates', { params: { keyword: 'OB', page: 1, size: 200 } })
|
||||
})
|
||||
|
||||
it('uses detail, create, and status contracts', async () => {
|
||||
@@ -18,18 +27,36 @@ describe('truck appointment API', () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: '9001' })
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } })
|
||||
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
|
||||
await expect(createTruckAppointment({ carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })
|
||||
await expect(performTruckAppointmentAction('9001', 'DISPATCH', 2)).resolves.toMatchObject({ status: 'DISPATCHED' })
|
||||
await expect(createTruckAppointment({ carrierId: '200', oceanBookingId: '300', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { carrierId: '200', oceanBookingId: '300', plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0 })
|
||||
await expect(performTruckAppointmentAction('9001', 'DISPATCH', { expectedVersion: 2 })).resolves.toMatchObject({ status: 'DISPATCHED' })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-dispatch-9001-') } })
|
||||
})
|
||||
|
||||
it('keeps pickup booking-scoped without sending container identity', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'PICKUP_IN_PROGRESS' } })
|
||||
await performTruckAppointmentAction('9001', 'PICKUP', { expectedVersion: 3 })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/pickup', {
|
||||
expectedVersion: 3, reason: null,
|
||||
}, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-pickup-9001-') } })
|
||||
})
|
||||
|
||||
it.each(['SUBMIT', 'DISPATCH', 'PICKUP'] as const)('sends %s without any container field', async (action) => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'PENDING_DISPATCH' } })
|
||||
await performTruckAppointmentAction('9001', action, { expectedVersion: 4, reason: '演示流转' })
|
||||
const body = vi.mocked(http.post).mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(body).toEqual({ expectedVersion: 4, reason: '演示流转' })
|
||||
expect(body).not.toHaveProperty('containerNo')
|
||||
expect(body).not.toHaveProperty('containerBookingId')
|
||||
expect(body).not.toHaveProperty('loadingPlanId')
|
||||
})
|
||||
|
||||
it('drops legacy customer fields instead of forwarding hidden compatibility data', async () => {
|
||||
vi.mocked(http.post).mockResolvedValueOnce({ data: { id: '9001', status: 'DRAFT', attachmentCount: 0 } })
|
||||
const payload = { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, customerId: 'legacy-customer', customerName: '旧客户快照' }
|
||||
const payload = { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: '500', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, currency: 'EUR', receivableAmount: '999.00', customerId: 'legacy-customer', customerName: '旧客户快照' }
|
||||
await createTruckAppointment(payload as never)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments', {
|
||||
carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0,
|
||||
carrierId: '200', oceanBookingId: '300', plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,4 +96,16 @@ describe('truck appointment API', () => {
|
||||
expect(form.get('file')).toBe(file)
|
||||
expect(form.get('attachmentType')).toBe('DISPATCH_ORDER')
|
||||
})
|
||||
|
||||
it('never forwards a legacy booking document type into truck attachments', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { id: '1', fileId: '2', fileName: 'legacy.pdf' } })
|
||||
await referenceTruckAppointmentAttachment('77', { fileId: '2', fileName: 'legacy.pdf', attachmentType: 'BILL_OF_LADING' as never })
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/truck-appointments/77/attachments', { fileId: '2', fileName: 'legacy.pdf' })
|
||||
|
||||
const file = new File(['legacy'], 'legacy.pdf', { type: 'application/pdf' })
|
||||
await uploadTruckAppointmentAttachment('77', file, 'BILL_OF_LADING' as never)
|
||||
const form = vi.mocked(http.post).mock.calls.at(-1)?.[1] as FormData
|
||||
expect(form.get('file')).toBe(file)
|
||||
expect(form.has('attachmentType')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { TRUCK_APPOINTMENT_SAVE_FIELDS } from '../types'
|
||||
import { TRUCK_APPOINTMENT_SAVE_FIELDS, isTruckAttachmentType, normalizeTruckAppointmentCurrency } from '../types'
|
||||
import type {
|
||||
SaveTruckAppointmentRequest,
|
||||
TruckAppointmentPage,
|
||||
TruckAppointmentQuery,
|
||||
TruckAppointmentResource,
|
||||
TruckAppointmentActionRequest,
|
||||
TruckAppointmentCandidate,
|
||||
TruckAppointmentCandidatePage,
|
||||
TruckAppointmentCandidateQuery,
|
||||
TruckAppointmentStatusHistory,
|
||||
TruckAttachmentReference,
|
||||
TruckAttachmentType,
|
||||
@@ -17,7 +21,7 @@ function operationId(action: string, id: string | number) {
|
||||
|
||||
function dtoPayload(payload: SaveTruckAppointmentRequest) {
|
||||
return Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.flatMap((key) => {
|
||||
const value = payload[key]
|
||||
const value = key === 'currency' ? normalizeTruckAppointmentCurrency(payload[key]) : payload[key]
|
||||
return value === undefined ? [] : [[key, value === '' ? null : value]]
|
||||
})) as unknown as SaveTruckAppointmentRequest
|
||||
}
|
||||
@@ -37,7 +41,7 @@ function normalizePage(payload: PagePayload): TruckAppointmentPage {
|
||||
}
|
||||
|
||||
function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource {
|
||||
const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'shippingCompanyId', 'bookingAgentId', 'carrierId']
|
||||
const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'warehouseId', 'shippingCompanyId', 'bookingAgentId', 'carrierId']
|
||||
const normalized = { ...resource } as Record<string, unknown>
|
||||
for (const key of idKeys) {
|
||||
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
|
||||
@@ -52,13 +56,46 @@ export async function listTruckAppointments(query: TruckAppointmentQuery = {}) {
|
||||
...query,
|
||||
keyword: query.keyword?.trim() || undefined,
|
||||
status: query.status || undefined,
|
||||
containerBookingId: query.containerBookingId || undefined,
|
||||
containerType: query.containerType || undefined,
|
||||
oceanBookingId: query.oceanBookingId || undefined,
|
||||
},
|
||||
})
|
||||
return normalizePage(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
type CandidatePagePayload = Omit<TruckAppointmentCandidatePage, 'records' | 'page' | 'size' | 'total' | 'pages'> & {
|
||||
records?: Array<TruckAppointmentCandidate & Record<string, unknown>>
|
||||
page: number | string
|
||||
size: number | string
|
||||
total: number | string
|
||||
pages?: number | string
|
||||
}
|
||||
|
||||
function normalizeCandidate(candidate: TruckAppointmentCandidate & Record<string, unknown>): TruckAppointmentCandidate {
|
||||
const normalized = { ...candidate } as Record<string, unknown>
|
||||
for (const key of ['oceanBookingId', 'warehouseId']) {
|
||||
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
|
||||
}
|
||||
normalized.bookingDocumentReady = normalized.bookingDocumentReady === true
|
||||
return normalized as unknown as TruckAppointmentCandidate
|
||||
}
|
||||
|
||||
export async function listTruckAppointmentCandidates(query: TruckAppointmentCandidateQuery = {}) {
|
||||
const response = await http.get<ApiEnvelope<CandidatePagePayload>, ApiEnvelope<CandidatePagePayload>>(`${basePath}/candidates`, {
|
||||
params: { keyword: query.keyword?.trim() || undefined, page: query.page || 1, size: query.size || 200 },
|
||||
})
|
||||
const payload = unwrapApiResponse(response)
|
||||
const page = Number(payload.page || 1)
|
||||
const size = Number(payload.size || 200)
|
||||
const total = Number(payload.total || 0)
|
||||
return {
|
||||
records: (payload.records || []).map(normalizeCandidate),
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
pages: Number(payload.pages || Math.max(Math.ceil(total / size), 1)),
|
||||
} satisfies TruckAppointmentCandidatePage
|
||||
}
|
||||
|
||||
export async function getTruckAppointment(id: string | number) {
|
||||
const response = await http.get<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}`)
|
||||
return normalizeResource(unwrapApiResponse(response))
|
||||
@@ -77,14 +114,16 @@ export async function listTruckAppointmentAttachments(id: string | number) {
|
||||
}
|
||||
|
||||
export async function referenceTruckAppointmentAttachment(id: string | number, payload: { fileId: string; attachmentType?: TruckAttachmentType | null; fileName?: string | null; fileHash?: string | null }) {
|
||||
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments`, payload)
|
||||
const { attachmentType, ...rest } = payload
|
||||
const body = { ...rest, ...(isTruckAttachmentType(attachmentType) ? { attachmentType } : {}) }
|
||||
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments`, body)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function uploadTruckAppointmentAttachment(id: string | number, file: File, attachmentType?: TruckAttachmentType | null) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
if (attachmentType) form.append('attachmentType', attachmentType)
|
||||
if (isTruckAttachmentType(attachmentType)) form.append('attachmentType', attachmentType)
|
||||
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments/upload`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -108,13 +147,16 @@ export type TruckAppointmentAction = 'SUBMIT' | 'DISPATCH' | 'PICKUP' | 'ARRIVE'
|
||||
export async function performTruckAppointmentAction(
|
||||
id: string | number,
|
||||
action: TruckAppointmentAction,
|
||||
expectedVersion: number,
|
||||
reason?: string,
|
||||
payload: TruckAppointmentActionRequest,
|
||||
idempotencyKey?: string,
|
||||
requestHash?: string,
|
||||
) {
|
||||
const operationKey = idempotencyKey || operationId(action, id)
|
||||
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, { expectedVersion, reason: reason || null }, {
|
||||
const body = {
|
||||
expectedVersion: payload.expectedVersion,
|
||||
reason: payload.reason || null,
|
||||
}
|
||||
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, body, {
|
||||
headers: { 'Idempotency-Key': operationKey, ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
|
||||
@@ -3,62 +3,61 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import TruckAppointmentForm from './TruckAppointmentForm.vue'
|
||||
import type { SaveTruckAppointmentRequest } from '../types'
|
||||
import { getOceanBooking, listOceanBookings } from '@/modules/ocean-bookings'
|
||||
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
|
||||
import { getOceanBooking } from '@/modules/ocean-bookings'
|
||||
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
|
||||
|
||||
vi.mock('@/modules/suppliers', () => ({
|
||||
listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE' }] }),
|
||||
}))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({
|
||||
listOceanBookings: vi.fn(),
|
||||
getOceanBooking: vi.fn(),
|
||||
}))
|
||||
vi.mock('../api/platformTrucking', () => ({ listTruckAppointmentCandidates: vi.fn() }))
|
||||
|
||||
const model: SaveTruckAppointmentRequest = {
|
||||
expectedVersion: 0,
|
||||
oceanBookingId: null,
|
||||
containerBookingId: null,
|
||||
loadingPlanId: null,
|
||||
carrierId: null,
|
||||
plannedLoadingAt: null,
|
||||
}
|
||||
const bookings = [
|
||||
{ id: 'booking-1', bookingNo: 'OB-1', status: 'RELEASED', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓' },
|
||||
{ id: 'booking-2', bookingNo: 'OB-2', status: 'RELEASED', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓' },
|
||||
{ id: 'booking-missing-bill', bookingNo: 'OB-3', status: 'RELEASED', billOfLadingNo: null, warehouseId: 'WH-3', warehouseName: '宁波仓' },
|
||||
const candidates = [
|
||||
{ oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true },
|
||||
{ oceanBookingId: 'booking-2', oceanBookingNo: 'OB-2', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true },
|
||||
]
|
||||
function detail(id: string) {
|
||||
const booking = bookings.find((item) => item.id === id)!
|
||||
const candidate = candidates.find((item) => item.oceanBookingId === id)!
|
||||
return {
|
||||
...booking,
|
||||
id, bookingNo: candidate.oceanBookingNo, status: 'RELEASED', billOfLadingNo: candidate.billOfLadingNo, warehouseId: candidate.warehouseId, warehouseName: candidate.warehouseName,
|
||||
documents: [{ id: `doc-${id}`, documentType: 'BILL_OF_LADING', valid: true }],
|
||||
containers: id === 'booking-1'
|
||||
? [
|
||||
{ containerBookingId: 'container-1', cabinetNo: 'CAB-1', containerNo: 'CBHU8888883' },
|
||||
{ containerBookingId: 'container-invalid', cabinetNo: 'CAB-X', containerNo: '错误柜号' },
|
||||
]
|
||||
: [{ containerBookingId: 'container-2', cabinetNo: 'CAB-2', containerNo: 'MSCU6639870' }],
|
||||
containers: [],
|
||||
}
|
||||
}
|
||||
function resource(id: string, bookingId: string): TruckAppointmentResource {
|
||||
const candidate = candidates.find((item) => item.oceanBookingId === bookingId)!
|
||||
return { id, status: 'DRAFT', oceanBookingId: bookingId, oceanBookingNo: candidate.oceanBookingNo, billOfLadingNo: candidate.billOfLadingNo, warehouseId: candidate.warehouseId, warehouseName: candidate.warehouseName }
|
||||
}
|
||||
|
||||
describe('TruckAppointmentForm booking-only behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(listOceanBookings).mockResolvedValue({ records: bookings } as never)
|
||||
vi.mocked(listTruckAppointmentCandidates).mockResolvedValue({ records: candidates, page: 1, size: 200, total: candidates.length, pages: 1 })
|
||||
vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never)
|
||||
})
|
||||
|
||||
it('selects a released booking, exposes only valid real containers, and never emits customer fields', async () => {
|
||||
it('selects only server-approved released bookings and never exposes container identity', async () => {
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
selects[0]!.vm.$emit('update:modelValue', 'booking-1')
|
||||
await flushPromises()
|
||||
|
||||
expect(getOceanBooking).toHaveBeenCalledWith('booking-1')
|
||||
expect(listTruckAppointmentCandidates).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||
expect(getOceanBooking).not.toHaveBeenCalled()
|
||||
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
|
||||
expect(labels.some((label) => label.includes('CAB-1') && label.includes('CBHU8888883'))).toBe(true)
|
||||
expect(labels.some((label) => label.includes('CAB-X'))).toBe(false)
|
||||
expect(labels.some((label) => label.includes('OB-1') && label.includes('BL-1'))).toBe(true)
|
||||
expect(labels.some((label) => label.includes('CAB-'))).toBe(false)
|
||||
expect(candidates[0]).not.toHaveProperty('containerBookingId')
|
||||
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
|
||||
expect(emitted.oceanBookingId).toBe('booking-1')
|
||||
expect(emitted.billOfLadingNo).toBe('BL-1')
|
||||
@@ -67,31 +66,78 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('clears a selected container when switching to another booking', async () => {
|
||||
it('keeps only the latest candidate response when option reloads overlap', async () => {
|
||||
let resolveSlow!: (value: Awaited<ReturnType<typeof listTruckAppointmentCandidates>>) => void
|
||||
let resolveFast!: (value: Awaited<ReturnType<typeof listTruckAppointmentCandidates>>) => void
|
||||
vi.mocked(listTruckAppointmentCandidates)
|
||||
.mockResolvedValueOnce({ records: candidates, page: 1, size: 200, total: candidates.length, pages: 1 })
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { resolveSlow = resolve }))
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { resolveFast = resolve }))
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as { loadOptions: () => Promise<void> }
|
||||
const slow = vm.loadOptions()
|
||||
const fast = vm.loadOptions()
|
||||
resolveFast({ records: [candidates[1]!], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await fast
|
||||
resolveSlow({ records: [candidates[0]!], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await slow
|
||||
await flushPromises()
|
||||
|
||||
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
|
||||
expect(labels.some((label) => label.includes('OB-2'))).toBe(true)
|
||||
expect(labels.some((label) => label.includes('OB-1'))).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('replaces frozen booking snapshots when switching the selected booking', async () => {
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
selects[0]!.vm.$emit('update:modelValue', 'booking-1')
|
||||
await flushPromises()
|
||||
selects[1]!.vm.$emit('update:modelValue', 'container-1')
|
||||
await flushPromises()
|
||||
expect((wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest).containerBookingId).toBe('container-1')
|
||||
|
||||
selects[0]!.vm.$emit('update:modelValue', 'booking-2')
|
||||
await flushPromises()
|
||||
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
|
||||
expect(emitted.oceanBookingId).toBe('booking-2')
|
||||
expect(emitted.containerBookingId).toBeNull()
|
||||
expect(emitted.billOfLadingNo).toBe('BL-2')
|
||||
expect(emitted.warehouseId).toBe('WH-2')
|
||||
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
|
||||
expect(labels.some((label) => label.includes('CAB-2'))).toBe(true)
|
||||
expect(labels.some((label) => label.includes('OB-2'))).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores stale current-booking details when a cached edit route changes', async () => {
|
||||
let resolveFirst!: (value: ReturnType<typeof detail>) => void
|
||||
let resolveSecond!: (value: ReturnType<typeof detail>) => void
|
||||
vi.mocked(getOceanBooking).mockImplementation((id) => new Promise((resolve) => {
|
||||
if (id === 'booking-1') resolveFirst = resolve as typeof resolveFirst
|
||||
else resolveSecond = resolve as typeof resolveSecond
|
||||
}) as never)
|
||||
const firstResource = resource('appointment-1', 'booking-1')
|
||||
const secondResource = resource('appointment-2', 'booking-2')
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: firstResource }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ modelValue: { ...model, oceanBookingId: 'booking-2' }, resource: secondResource })
|
||||
await flushPromises()
|
||||
|
||||
resolveSecond(detail('booking-2'))
|
||||
await flushPromises()
|
||||
resolveFirst({ ...detail('booking-1'), documents: [] })
|
||||
await flushPromises()
|
||||
|
||||
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
|
||||
expect(labels.some((label) => label.includes('OB-2') && label.includes('不可重选'))).toBe(true)
|
||||
expect(labels.some((label) => label.includes('OB-1'))).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
|
||||
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves the business code and request trace when booking readiness fails', async () => {
|
||||
vi.mocked(getOceanBooking).mockRejectedValueOnce(new ApiResponseError('订舱版本冲突', 'A0409', 409, undefined, 'req-booking', 'trace-booking'))
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-1')
|
||||
const current = resource('appointment-1', 'booking-1')
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: current }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('订舱版本冲突')
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
@@ -100,26 +146,30 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('blocks a released booking that has no bill of lading number', async () => {
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||
it('flags an edit binding whose booking has become unreleased without adding it to create candidates', async () => {
|
||||
vi.mocked(getOceanBooking).mockResolvedValueOnce({ ...detail('booking-1'), status: 'DRAFT' } as never)
|
||||
const current = resource('appointment-1', 'booking-1')
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: current }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-missing-bill')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('所选订舱缺少有效提单号')
|
||||
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
|
||||
expect(emitted.billOfLadingNo).toBeNull()
|
||||
expect(wrapper.text()).toContain('当前绑定订舱已不是放舱状态')
|
||||
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('rejects a booking that becomes unreleased before its detail is loaded', async () => {
|
||||
vi.mocked(getOceanBooking).mockResolvedValueOnce({ ...detail('booking-1'), status: 'DRAFT' } as never)
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||
it('switches the payable currency between RMB and USD and normalizes an illegal legacy value', async () => {
|
||||
const wrapper = mount(TruckAppointmentForm, {
|
||||
props: { modelValue: { ...model, payableAmount: '88.00', currency: 'EUR' as never } },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-1')
|
||||
const currencySelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择币种')!
|
||||
expect(currencySelect.props('modelValue')).toBe('RMB')
|
||||
currencySelect.vm.$emit('update:modelValue', 'USD')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('所选订舱尚未放舱')
|
||||
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
|
||||
expect(emitted.containerBookingId).toBeNull()
|
||||
expect(emitted.currency).toBe('USD')
|
||||
expect(wrapper.text()).toContain('USD')
|
||||
expect(emitted).not.toHaveProperty('receivableAmount')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,31 +3,31 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/components/TruckAppointmentForm.vue'), 'utf8')
|
||||
const typesSource = readFileSync(resolve(process.cwd(), 'src/modules/trucking/types.ts'), 'utf8')
|
||||
|
||||
describe('TruckAppointmentForm interaction contract', () => {
|
||||
it('requires a released booking before a real-numbered allocated container', () => {
|
||||
expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })")
|
||||
it('requires one server-approved released booking without exposing a container selector', () => {
|
||||
expect(source).toContain('listTruckAppointmentCandidates({ page: 1, size: 200 })')
|
||||
expect(source).toContain("oceanBookingId: [{ required: true, message: '请选择已放舱订舱'")
|
||||
expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下已录真实集装箱号的柜次'")
|
||||
expect(source.indexOf('label="订舱"')).toBeLessThan(source.indexOf('label="柜次(真实集装箱号)"'))
|
||||
expect(source).toContain(':disabled="!draft.oceanBookingId || bookingContainersLoading"')
|
||||
expect(source).not.toContain('prop="containerBookingId"')
|
||||
expect(source).not.toContain('label="柜次"')
|
||||
expect(source).not.toContain('关联订舱(可选)')
|
||||
expect(source).not.toContain('关联柜次(可选)')
|
||||
})
|
||||
|
||||
it('loads only containers allocated to the selected booking and filters missing or malformed physical numbers', () => {
|
||||
expect(source).toContain('getOceanBooking(bookingId)')
|
||||
expect(source).toContain('containers.value = booking.containers || []')
|
||||
expect(source).toContain('bookingDocuments.value = booking.documents || []')
|
||||
expect(source).toContain('isIso6346ContainerNumber(item.containerNo)')
|
||||
expect(source).toContain('该订舱暂无已录真实集装箱号的柜次')
|
||||
it('uses only server-owned booking candidates and never rebuilds availability from booking detail', () => {
|
||||
expect(source).toContain('candidates.value = results[1].value.records')
|
||||
expect(source).toContain('new Map<string')
|
||||
expect(source).not.toContain('booking.containers')
|
||||
expect(source).not.toContain('listOceanBookings')
|
||||
expect(source).not.toContain('isIso6346ContainerNumber')
|
||||
expect(source).not.toContain('listContainers')
|
||||
})
|
||||
|
||||
it('clears an incompatible container whenever the booking changes', () => {
|
||||
expect(source).toContain('draft.containerBookingId = null')
|
||||
expect(source).toContain('draft.loadingPlanId = null')
|
||||
expect(source).toContain('if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId))')
|
||||
it('does not write legacy container or loading-plan fields', () => {
|
||||
expect(source).not.toContain('draft.containerBookingId')
|
||||
expect(source).not.toContain('draft.loadingPlanId')
|
||||
expect(typesSource).not.toContain("'containerBookingId', 'loadingPlanId'")
|
||||
})
|
||||
|
||||
it('freezes warehouse and bill of lading snapshots without retaining customer data', () => {
|
||||
@@ -46,16 +46,33 @@ describe('TruckAppointmentForm interaction contract', () => {
|
||||
expect(source).toContain('所选订舱缺少有效提单号')
|
||||
expect(source).toContain("booking.status !== 'RELEASED'")
|
||||
expect(source).toContain('bookingContainersError')
|
||||
expect(source).toContain("getApiErrorDetails(cause, '该订舱柜次加载失败,请重试')")
|
||||
expect(source).toContain("getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')")
|
||||
expect(source).toContain('<ApiErrorMeta :details="bookingContainersError" />')
|
||||
expect(source).toContain('重新加载选项')
|
||||
expect(source).toContain('重新加载')
|
||||
expect(source).toContain('请选择有效的合作中拖车供应商')
|
||||
})
|
||||
|
||||
it('labels the generated cabinet reference and physical container number separately', () => {
|
||||
expect(source).toContain('柜次号:${item.cabinetNo')
|
||||
expect(source).toContain('集装箱号:${item.containerNo}')
|
||||
it('freezes the current edit booking and marks its fallback option as non-selectable', () => {
|
||||
expect(source).toContain('const relationLocked = computed(() => Boolean(props.resource))')
|
||||
expect(source).toContain('(当前绑定,不可重选)')
|
||||
expect(source).toContain(':disabled="relationLocked"')
|
||||
expect(source).not.toContain('selectedContainerFallback')
|
||||
})
|
||||
|
||||
it('guards current booking detail loads against stale route reuse responses', () => {
|
||||
expect(source).toContain('const sequence = ++bookingLoadSequence')
|
||||
expect(source).toContain("sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId")
|
||||
expect(source).toContain('const sequence = ++optionsLoadSequence')
|
||||
expect(source).toContain('if (sequence !== optionsLoadSequence) return')
|
||||
expect(source).toContain("watch(() => props.resource?.id")
|
||||
})
|
||||
|
||||
it('keeps container identity out of the booking-scoped form', () => {
|
||||
expect(source).not.toContain('柜次')
|
||||
expect(source).not.toContain('装柜计划')
|
||||
expect(source).not.toContain('集装箱号')
|
||||
expect(source).toContain('货柜资料在货柜与预装流程维护')
|
||||
})
|
||||
|
||||
it('keeps the form usable at 390px', () => {
|
||||
@@ -68,4 +85,22 @@ describe('TruckAppointmentForm interaction contract', () => {
|
||||
expect(source).toContain(':deep(.el-select__selected-item)')
|
||||
expect(source).toContain('text-overflow:ellipsis')
|
||||
})
|
||||
|
||||
it('uses payable-only fees with a fixed dynamic currency selector', () => {
|
||||
expect(typesSource).toContain("TRUCK_APPOINTMENT_CURRENCIES = ['RMB', 'USD']")
|
||||
expect(source).toContain('<template #append>{{ draft.currency }}</template>')
|
||||
expect(source).toContain('v-for="currency in TRUCK_APPOINTMENT_CURRENCIES"')
|
||||
expect(source).toContain('placeholder="请选择币种"')
|
||||
expect(source).not.toContain('receivableAmount')
|
||||
expect(source).not.toContain('应收拖车费')
|
||||
})
|
||||
|
||||
it('keeps the 1920 desktop contract compact without horizontal form overflow', () => {
|
||||
expect(source).toContain('@media (min-width: 1200px)')
|
||||
expect(source).toContain('max-width:25%; flex:0 0 25%')
|
||||
expect(source).toContain('class="relation-column"')
|
||||
expect(source).toContain('class="wide-column"')
|
||||
expect(source).toContain('max-width:50%; flex-basis:50%')
|
||||
expect(source).not.toContain('overflow-x:auto')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,22 +2,23 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Eye, Save } from '@lucide/vue'
|
||||
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
|
||||
import { TRUCK_APPOINTMENT_CURRENCIES, normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types'
|
||||
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import { getOceanBooking, listOceanBookings, type OceanBookingAllocatedContainer, type OceanBookingDocument, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||||
import { getOceanBooking, type OceanBookingDocument } from '@/modules/ocean-bookings'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { isIso6346ContainerNumber } from '../iso6346'
|
||||
|
||||
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
|
||||
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean; submitBlocked?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const relationWarning = ref('')
|
||||
const bookingContainersLoading = ref(false)
|
||||
const bookingContainersError = ref<ApiErrorDetails | null>(null)
|
||||
const bookingDocuments = ref<OceanBookingDocument[]>([])
|
||||
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', receivableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
|
||||
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: value.currency?.trim().toUpperCase() || 'RMB' } }
|
||||
const relationLocked = computed(() => Boolean(props.resource))
|
||||
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
|
||||
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } }
|
||||
const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue))
|
||||
watch(() => props.modelValue, (value) => Object.assign(draft, normalizeDraft(value)), { deep: true })
|
||||
watch(draft, (value) => emit('update:modelValue', { ...value }), { deep: true })
|
||||
@@ -32,96 +33,76 @@ const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unkno
|
||||
}, trigger: 'blur' })
|
||||
const rules: FormRules = {
|
||||
oceanBookingId: [{ required: true, message: '请选择已放舱订舱', trigger: 'change' }, { validator: (_rule, value, callback) => {
|
||||
if (!(bookings.value.some((item) => item.id === String(value) && item.status === 'RELEASED') || selectedBookingFallback.value?.id === String(value))) return callback(new Error('请选择有效的已放舱订舱'))
|
||||
if (!(bookings.value.some((item) => item.id === String(value)) || (relationLocked.value && selectedBookingFallback.value?.id === String(value)))) return callback(new Error('请选择可创建拖车单的已放舱订舱'))
|
||||
if (bookingContainersError.value) return callback(new Error('订舱资料状态加载失败,请重试'))
|
||||
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
|
||||
return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
|
||||
}, trigger: 'change' }],
|
||||
containerBookingId: [{ required: true, message: '请选择该订舱下已录真实集装箱号的柜次', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableContainers.value.some((item) => item.containerBookingId === String(value)) || (props.readonly && selectedContainerFallback.value?.id === String(value)) ? undefined : new Error('请选择该订舱下有效的真实柜号柜次')), trigger: 'change' }],
|
||||
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
|
||||
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
|
||||
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
|
||||
packageCount: [nonNegative('件数', true)], palletCount: [nonNegative('托盘数量', true)], transitMinutes: [nonNegative('在途分钟', true)],
|
||||
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], receivableAmount: [nonNegative('应收拖车费')],
|
||||
currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }],
|
||||
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')],
|
||||
}
|
||||
const carriers = ref<Supplier[]>([])
|
||||
const containers = ref<OceanBookingAllocatedContainer[]>([])
|
||||
const bookings = ref<OceanBookingResource[]>([])
|
||||
const candidates = ref<TruckAppointmentCandidate[]>([])
|
||||
const optionsLoading = ref(false)
|
||||
const optionsError = ref('')
|
||||
let bookingLoadSequence = 0
|
||||
let optionsLoadSequence = 0
|
||||
const selectableCarriers = computed(() => carriers.value.filter((item) => (
|
||||
item.status === 'ACTIVE'
|
||||
)))
|
||||
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null)
|
||||
const bookingDocumentReady = computed(() => bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType)))
|
||||
const selectableContainers = computed(() => containers.value.filter((item) => isIso6346ContainerNumber(item.containerNo)))
|
||||
const selectedContainerFallback = computed(() => {
|
||||
const id = draft.containerBookingId
|
||||
const snapshot = props.resource
|
||||
if (!id || selectableContainers.value.some((item) => String(item.containerBookingId) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null
|
||||
return {
|
||||
id: String(id),
|
||||
label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 集装箱号:${snapshot?.containerNo || '待录入'}`,
|
||||
}
|
||||
const bookings = computed(() => {
|
||||
const result = new Map<string, { id: string; bookingNo: string; billOfLadingNo: string | null; warehouseId: string; warehouseName: string | null; bookingDocumentReady: boolean }>()
|
||||
for (const item of candidates.value) if (!result.has(item.oceanBookingId)) result.set(item.oceanBookingId, {
|
||||
id: item.oceanBookingId,
|
||||
bookingNo: item.oceanBookingNo,
|
||||
billOfLadingNo: item.billOfLadingNo,
|
||||
warehouseId: item.warehouseId,
|
||||
warehouseName: item.warehouseName,
|
||||
bookingDocumentReady: item.bookingDocumentReady,
|
||||
})
|
||||
return [...result.values()]
|
||||
})
|
||||
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId) || null)
|
||||
const bookingDocumentReady = computed(() => relationLocked.value
|
||||
? bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))
|
||||
: Boolean(selectedReleasedBooking.value?.bookingDocumentReady))
|
||||
const selectedBookingFallback = computed(() => {
|
||||
const id = draft.oceanBookingId
|
||||
const snapshot = props.resource
|
||||
if (!id || bookings.value.some((item) => String(item.id) === String(id)) || String(snapshot?.oceanBookingId) !== String(id)) return null
|
||||
return { id: String(id), label: snapshot?.oceanBookingNo || '订舱号缺失' }
|
||||
if (!id || !relationLocked.value || String(snapshot?.oceanBookingId) !== String(id)) return null
|
||||
return { id: String(id), label: `${snapshot?.oceanBookingNo || '订舱号缺失'}(当前绑定,不可重选)` }
|
||||
})
|
||||
function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null }
|
||||
function selectContainer(containerId: string) {
|
||||
const item = selectableContainers.value.find((entry) => entry.containerBookingId === containerId)
|
||||
draft.containerBookingId = item?.containerBookingId || null
|
||||
relationWarning.value = ''
|
||||
if (!item) {
|
||||
draft.loadingPlanId = null
|
||||
return
|
||||
}
|
||||
draft.loadingPlanId = null
|
||||
}
|
||||
async function loadBookingContainers(bookingId: string) {
|
||||
async function loadCurrentBookingContext(bookingId: string) {
|
||||
const sequence = ++bookingLoadSequence
|
||||
bookingContainersLoading.value = true
|
||||
bookingContainersError.value = null
|
||||
try {
|
||||
const booking = await getOceanBooking(bookingId)
|
||||
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
|
||||
if (booking.status !== 'RELEASED') {
|
||||
containers.value = []
|
||||
bookingDocuments.value = []
|
||||
draft.containerBookingId = null
|
||||
draft.loadingPlanId = null
|
||||
bookingContainersError.value = { message: '所选订舱尚未放舱,不能创建拖车预约' }
|
||||
bookingContainersError.value = { message: '当前绑定订舱已不是放舱状态,请刷新后处理' }
|
||||
return
|
||||
}
|
||||
const bookingIndex = bookings.value.findIndex((item) => item.id === booking.id)
|
||||
if (bookingIndex >= 0) bookings.value[bookingIndex] = booking
|
||||
else bookings.value.push(booking)
|
||||
draft.warehouseId = booking.warehouseId
|
||||
draft.billOfLadingNo = booking.billOfLadingNo
|
||||
containers.value = booking.containers || []
|
||||
bookingDocuments.value = booking.documents || []
|
||||
if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId)) {
|
||||
draft.containerBookingId = null
|
||||
draft.loadingPlanId = null
|
||||
}
|
||||
} catch (cause) {
|
||||
containers.value = []
|
||||
if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return
|
||||
bookingDocuments.value = []
|
||||
draft.containerBookingId = null
|
||||
draft.loadingPlanId = null
|
||||
bookingContainersError.value = getApiErrorDetails(cause, '该订舱柜次加载失败,请重试')
|
||||
bookingContainersError.value = getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')
|
||||
} finally {
|
||||
bookingContainersLoading.value = false
|
||||
if (sequence === bookingLoadSequence) bookingContainersLoading.value = false
|
||||
}
|
||||
}
|
||||
async function selectBooking(bookingId: string) {
|
||||
bookingLoadSequence += 1
|
||||
const item = bookings.value.find((entry) => entry.id === bookingId)
|
||||
draft.oceanBookingId = item?.id || null
|
||||
draft.containerBookingId = null
|
||||
draft.loadingPlanId = null
|
||||
containers.value = []
|
||||
bookingDocuments.value = []
|
||||
relationWarning.value = ''
|
||||
if (!item) {
|
||||
@@ -131,37 +112,48 @@ async function selectBooking(bookingId: string) {
|
||||
}
|
||||
draft.warehouseId = item.warehouseId
|
||||
draft.billOfLadingNo = item.billOfLadingNo
|
||||
await loadBookingContainers(item.id)
|
||||
}
|
||||
async function loadOptions() {
|
||||
const sequence = ++optionsLoadSequence
|
||||
optionsLoading.value = true; optionsError.value = ''
|
||||
candidates.value = []
|
||||
const results = await Promise.allSettled([
|
||||
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
|
||||
listOceanBookings({ page: 1, size: 200, status: 'RELEASED' }),
|
||||
relationLocked.value ? Promise.resolve({ records: [] as TruckAppointmentCandidate[] }) : listTruckAppointmentCandidates({ page: 1, size: 200 }),
|
||||
])
|
||||
if (sequence !== optionsLoadSequence) return
|
||||
if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE')
|
||||
if (results[1].status === 'fulfilled') bookings.value = results[1].value.records.filter((item) => item.status === 'RELEASED')
|
||||
const failed = ['承运商', '已放舱订舱'].filter((_, index) => results[index]?.status === 'rejected')
|
||||
if (results[1].status === 'fulfilled') candidates.value = results[1].value.records
|
||||
const failed = ['承运商', '可用订舱'].filter((_, index) => results[index]?.status === 'rejected')
|
||||
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
|
||||
optionsLoading.value = false
|
||||
if (draft.oceanBookingId) await loadBookingContainers(String(draft.oceanBookingId))
|
||||
if (relationLocked.value && draft.oceanBookingId) await loadCurrentBookingContext(String(draft.oceanBookingId))
|
||||
else if (draft.oceanBookingId) {
|
||||
const candidate = candidates.value.find((item) => item.oceanBookingId === draft.oceanBookingId)
|
||||
if (candidate) {
|
||||
draft.warehouseId = candidate.warehouseId
|
||||
draft.billOfLadingNo = candidate.billOfLadingNo
|
||||
} else {
|
||||
draft.oceanBookingId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
async function validate() { return formRef.value?.validate() }
|
||||
async function submitForm() { if (props.submitting) return; if (await formRef.value?.validate()) emit('submit') }
|
||||
defineExpose({ validate })
|
||||
defineExpose({ validate, loadOptions })
|
||||
onMounted(() => { void loadOptions() })
|
||||
watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.length) void loadOptions() })
|
||||
watch(() => props.readonly, (readonly) => { if (!readonly && !carriers.value.length) void loadOptions() })
|
||||
watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) void loadOptions() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
||||
<div class="relation-note"><span>拖车预约必须先选择已放舱订舱,再选择该订舱下已录入真实 ISO 集装箱号的柜次;仓库和提单号由订舱冻结资料带入,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
|
||||
<div class="relation-note"><span>一张已放舱订舱对应一张有效拖车单;仓库和提单号由订舱冻结资料带入,货柜资料在货柜与预装流程维护,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
|
||||
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
|
||||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24"><el-divider content-position="left">订舱与柜次(必填)</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable placeholder="请先选择已放舱订舱" :loading="optionsLoading" no-data-text="暂无可用的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'}${item.billOfLadingNo ? ` / 提单号:${item.billOfLadingNo}` : ''}`" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="柜次(真实集装箱号)" prop="containerBookingId"><el-select :model-value="draft.containerBookingId" filterable :disabled="!draft.oceanBookingId || bookingContainersLoading" placeholder="请先选择订舱,再选择真实柜号柜次" :loading="bookingContainersLoading" :no-data-text="draft.oceanBookingId ? '该订舱暂无已录真实集装箱号的柜次' : '请先选择订舱'" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" :disabled="!readonly" /><el-option v-for="item in selectableContainers" :key="item.containerBookingId" :label="`柜次号:${item.cabinetNo || '待生成'} / 集装箱号:${item.containerNo}`" :value="item.containerBookingId" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadBookingContainers(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !bookingContainersLoading && !selectableContainers.length" class="field-hint">只有已分配至该订舱且已录入真实 ISO 集装箱号的柜次可以预约拖车。</div></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">订舱(必填)</el-divider></el-col>
|
||||
<el-col class="relation-column" :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable :disabled="relationLocked" placeholder="请选择可创建拖车单的已放舱订舱" :loading="optionsLoading" no-data-text="暂无可创建拖车单的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" disabled /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'} / 提单号:${item.billOfLadingNo || '缺失'}`" :value="item.id" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadCurrentBookingContext(String(draft.oceanBookingId))">重新加载</el-button></div></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></el-col>
|
||||
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !draft.billOfLadingNo?.trim()" :xs="24"><el-alert title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
|
||||
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !bookingDocumentReady" :xs="24"><el-alert title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
|
||||
@@ -184,20 +176,19 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量" prop="palletCount"><el-input v-model="draft.palletCount" inputmode="numeric" placeholder="请输入托盘数量" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="在途分钟" prop="transitMinutes"><el-input v-model="draft.transitMinutes" inputmode="numeric" placeholder="请输入预计在途分钟" /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">费用信息</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>RMB</template></el-input></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="应收拖车费" prop="receivableAmount"><el-input v-model="draft.receivableAmount" placeholder="请输入应收金额"><template #append>RMB</template></el-input></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="币种" prop="currency"><el-input v-model="draft.currency" placeholder="例如:RMB" maxlength="8" @blur="draft.currency = draft.currency?.toUpperCase()" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>{{ draft.currency }}</template></el-input></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="币种" prop="currency"><el-select v-model="draft.currency" aria-label="币种" placeholder="请选择币种" style="width:100%"><el-option v-for="currency in TRUCK_APPOINTMENT_CURRENCIES" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">门店信息与备注</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" placeholder="请输入目的门店名称" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" placeholder="请输入目的门店地址" /></el-form-item></el-col>
|
||||
<el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" placeholder="请输入目的门店地址" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="门店联系人"><el-input v-model="draft.storeContact" placeholder="请输入门店联系人" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="门店电话" prop="storePhone"><el-input v-model="draft.storePhone" placeholder="请输入门店联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" placeholder="请输入城市" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" placeholder="请输入邮政编码" /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
|
||||
<el-col class="wide-column" :xs="24" :sm="12"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" placeholder="请输入拖车预约备注(可选)" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="submitForm"><Save :size="16" />保存</el-button></div>
|
||||
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" :disabled="submitBlocked" @click="submitForm"><Save :size="16" />保存</el-button></div>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
@@ -214,6 +205,10 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len
|
||||
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
|
||||
:deep(.el-select__selected-item), :deep(.el-select-dropdown__item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;color:var(--cc-muted);font-size:12px;line-height:20px }.field-error{color:var(--el-color-danger)}
|
||||
@media (min-width: 1200px) {
|
||||
.truck-form :deep(.el-col-sm-12), .truck-form :deep(.el-col-sm-8) { max-width:25%; flex:0 0 25%; }
|
||||
.truck-form :deep(.relation-column), .truck-form :deep(.wide-column) { max-width:50%; flex-basis:50%; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.truck-form { padding-inline: 2px; }
|
||||
.relation-note { align-items:flex-start; flex-direction:column; }
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import TruckAppointmentCreateView from './TruckAppointmentCreateView.vue'
|
||||
import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking'
|
||||
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
|
||||
|
||||
const routing = vi.hoisted(() => ({ query: {} as Record<string, string>, replace: vi.fn(), push: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: routing.query }), useRouter: () => ({ replace: routing.replace, push: routing.push }) }))
|
||||
vi.mock('../api/platformTrucking', () => ({ createTruckAppointment: vi.fn(), listTruckAppointments: vi.fn() }))
|
||||
|
||||
const existing = (status: TruckAppointmentResource['status']): TruckAppointmentResource => ({
|
||||
id: 'appointment-1', appointmentNo: 'TA-260901-0003', status, oceanBookingId: 'booking-1', attachments: [],
|
||||
})
|
||||
const page = (records: TruckAppointmentResource[]) => ({ records, page: 1, size: 20, total: records.length, pages: 1 })
|
||||
const formStub = {
|
||||
name: 'TruckAppointmentForm',
|
||||
props: ['modelValue', 'submitting', 'submitBlocked'],
|
||||
emits: ['update:modelValue', 'submit', 'attachments'],
|
||||
template: '<div data-form :data-blocked="String(submitBlocked)"><button data-submit @click="$emit(\'submit\')">保存</button></div>',
|
||||
}
|
||||
type ViewModel = {
|
||||
form: SaveTruckAppointmentRequest
|
||||
submit: () => Promise<void>
|
||||
lookupExistingAppointment: (id: string, autoNavigate?: boolean) => Promise<void>
|
||||
existingAppointment: TruckAppointmentResource | null
|
||||
existingLookupError: { message: string } | null
|
||||
checkedOceanBookingId: string
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return mount(TruckAppointmentCreateView, { global: { plugins: [ElementPlus], stubs: { TruckAppointmentForm: formStub, TruckAttachmentDialog: true } } })
|
||||
}
|
||||
|
||||
describe('TruckAppointmentCreateView existing appointment reuse', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
for (const key of Object.keys(routing.query)) delete routing.query[key]
|
||||
routing.replace.mockResolvedValue(undefined)
|
||||
routing.push.mockResolvedValue(undefined)
|
||||
vi.mocked(listTruckAppointments).mockResolvedValue(page([]))
|
||||
vi.mocked(createTruckAppointment).mockResolvedValue(existing('DRAFT'))
|
||||
})
|
||||
|
||||
it('automatically reuses a DRAFT appointment for a query-prefilled booking', async () => {
|
||||
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
|
||||
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing('DRAFT')]))
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(listTruckAppointments).toHaveBeenCalledWith({ oceanBookingId: 'booking-1', page: 1, size: 20 })
|
||||
expect(routing.replace).toHaveBeenCalledWith({ name: 'trucking-detail', params: { id: 'appointment-1' }, query: { edit: '1' } })
|
||||
expect(createTruckAppointment).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['COMPLETED', 'CANCELLED', 'EXCEPTION'] as const)('allows a new appointment after terminal status %s', async (status) => {
|
||||
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
|
||||
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing(status)]))
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await (wrapper.vm as unknown as ViewModel).submit()
|
||||
expect(createTruckAppointment).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps request evidence and retry state when existing-appointment lookup fails', async () => {
|
||||
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
|
||||
vi.mocked(listTruckAppointments).mockRejectedValue(new ApiResponseError('预约查询失败', 'A0500', 500, undefined, 'req-existing', 'trace-existing'))
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('预约查询失败')
|
||||
expect(wrapper.text()).toContain('req-existing')
|
||||
expect(wrapper.text()).toContain('trace-existing')
|
||||
await (wrapper.vm as unknown as ViewModel).submit()
|
||||
expect(createTruckAppointment).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores a stale lookup response after the selected booking changes', async () => {
|
||||
let resolveFirst!: (value: ReturnType<typeof page>) => void
|
||||
let resolveSecond!: (value: ReturnType<typeof page>) => void
|
||||
vi.mocked(listTruckAppointments).mockImplementation((query = {}) => new Promise((resolve) => {
|
||||
const { oceanBookingId } = query
|
||||
if (oceanBookingId === 'booking-1') resolveFirst = resolve
|
||||
else resolveSecond = resolve
|
||||
}))
|
||||
const wrapper = mountView()
|
||||
const vm = wrapper.vm as unknown as ViewModel
|
||||
vm.form.oceanBookingId = 'booking-1'
|
||||
await wrapper.vm.$nextTick()
|
||||
vm.form.oceanBookingId = 'booking-2'
|
||||
await wrapper.vm.$nextTick()
|
||||
resolveSecond(page([]))
|
||||
await flushPromises()
|
||||
resolveFirst(page([existing('DRAFT')]))
|
||||
await flushPromises()
|
||||
expect(vm.checkedOceanBookingId).toBe('booking-2')
|
||||
expect(vm.existingAppointment).toBeNull()
|
||||
expect(routing.replace).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('suppresses duplicate create clicks while the first request is pending', async () => {
|
||||
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
|
||||
let resolveCreate!: (value: TruckAppointmentResource) => void
|
||||
vi.mocked(createTruckAppointment).mockImplementation(() => new Promise((resolve) => { resolveCreate = resolve }))
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as ViewModel
|
||||
const first = vm.submit()
|
||||
const second = vm.submit()
|
||||
expect(createTruckAppointment).toHaveBeenCalledTimes(1)
|
||||
resolveCreate(existing('DRAFT'))
|
||||
await Promise.all([first, second])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -5,22 +5,36 @@ import { resolve } from 'node:path'
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentCreateView.vue'), 'utf8')
|
||||
|
||||
describe('TruckAppointmentCreateView booking-bound creation contract', () => {
|
||||
it('starts with required booking and container choices empty until the user selects them', () => {
|
||||
it('starts with only the required booking choice empty until the user selects it', () => {
|
||||
expect(source).toContain('carrierId: null')
|
||||
expect(source).not.toContain('customerId')
|
||||
expect(source).toContain('oceanBookingId: null, containerBookingId: null, loadingPlanId: null')
|
||||
expect(source).toContain('oceanBookingId: initialOceanBookingId || null, plannedLoadingAt: null')
|
||||
expect(source).not.toContain('initialContainerBookingId')
|
||||
expect(source).toContain('plannedLoadingAt: null')
|
||||
expect(source).toContain("payableAmount: '0.00', currency: 'RMB'")
|
||||
expect(source).not.toContain('receivableAmount')
|
||||
expect(source).toContain('createTruckAppointment({ ...form })')
|
||||
})
|
||||
|
||||
it('keeps the form visible and exposes the real backend error details', () => {
|
||||
expect(source).toContain("getApiErrorDetails(err, '创建失败,请稍后重试')")
|
||||
expect(source).toContain('<ApiErrorMeta :details="error" />')
|
||||
expect(source).toContain('<TruckAppointmentForm v-model="form"')
|
||||
expect(source).toContain('<TruckAppointmentForm :model-value="form"')
|
||||
expect(source).toContain('@update:model-value="updateForm"')
|
||||
})
|
||||
|
||||
it('suppresses duplicate submits while the first create request is pending', () => {
|
||||
expect(source).toContain('if (saving.value) return')
|
||||
expect(source).toContain('if (saving.value || existingLookupLoading.value) return')
|
||||
})
|
||||
|
||||
it('reuses non-terminal appointments by booking and separates lookup failures from booking-file errors', () => {
|
||||
expect(source).toContain("listTruckAppointments({ oceanBookingId: normalizedId, page: 1, size: 20 })")
|
||||
expect(source).toContain("new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])")
|
||||
expect(source).toContain('该订舱已有拖车单')
|
||||
expect(source).toContain("getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')")
|
||||
expect(source).toContain('<ApiErrorMeta :details="existingLookupError" />')
|
||||
expect(source).toContain("router.replace({ name: 'trucking-detail'")
|
||||
expect(source).toContain('serial !== lookupSerial')
|
||||
})
|
||||
|
||||
it('places the return action beside the relation note instead of in a separate header row', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -10,17 +10,70 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||
import { createTruckAppointment } from '../api/platformTrucking'
|
||||
import type { SaveTruckAppointmentRequest } from '../types'
|
||||
const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '0.00', currency: 'RMB' })
|
||||
async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }
|
||||
import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking'
|
||||
import { truckAppointmentStatusLabel, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types'
|
||||
const route = useRoute(); const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||
const initialOceanBookingId = String(route.query.oceanBookingId || '').trim()
|
||||
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: initialOceanBookingId || null, plannedLoadingAt: null, payableAmount: '0.00', currency: 'RMB' })
|
||||
const existingAppointment = ref<TruckAppointmentResource | null>(null)
|
||||
const existingLookupLoading = ref(false)
|
||||
const existingLookupError = ref<ApiErrorDetails | null>(null)
|
||||
const checkedOceanBookingId = ref('')
|
||||
const navigatingToExisting = ref(false)
|
||||
let lookupSerial = 0
|
||||
const terminalStatuses = new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])
|
||||
const existingCanEdit = computed(() => ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(existingAppointment.value?.status || ''))
|
||||
const submitBlocked = computed(() => existingLookupLoading.value || Boolean(existingLookupError.value) || Boolean(existingAppointment.value))
|
||||
function updateForm(value: SaveTruckAppointmentRequest) { Object.assign(form, value) }
|
||||
|
||||
async function openExisting() {
|
||||
if (!existingAppointment.value || navigatingToExisting.value) return
|
||||
navigatingToExisting.value = true
|
||||
try { await router.replace({ name: 'trucking-detail', params: { id: existingAppointment.value.id }, query: existingCanEdit.value ? { edit: '1' } : undefined }) }
|
||||
finally { navigatingToExisting.value = false }
|
||||
}
|
||||
async function lookupExistingAppointment(oceanBookingId: string, autoNavigate = false) {
|
||||
const normalizedId = oceanBookingId.trim()
|
||||
const serial = ++lookupSerial
|
||||
existingAppointment.value = null
|
||||
existingLookupError.value = null
|
||||
checkedOceanBookingId.value = ''
|
||||
if (!normalizedId) { existingLookupLoading.value = false; return }
|
||||
existingLookupLoading.value = true
|
||||
try {
|
||||
const result = await listTruckAppointments({ oceanBookingId: normalizedId, page: 1, size: 20 })
|
||||
if (serial !== lookupSerial || String(form.oceanBookingId || '') !== normalizedId) return
|
||||
checkedOceanBookingId.value = normalizedId
|
||||
existingAppointment.value = result.records.find((item) => !terminalStatuses.has(item.status)) || null
|
||||
if (autoNavigate && existingAppointment.value) await openExisting()
|
||||
} catch (cause) {
|
||||
if (serial !== lookupSerial || String(form.oceanBookingId || '') !== normalizedId) return
|
||||
checkedOceanBookingId.value = normalizedId
|
||||
existingLookupError.value = getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')
|
||||
} finally {
|
||||
if (serial === lookupSerial) existingLookupLoading.value = false
|
||||
}
|
||||
}
|
||||
async function submit() {
|
||||
if (saving.value || existingLookupLoading.value) return
|
||||
const oceanBookingId = String(form.oceanBookingId || '')
|
||||
if (oceanBookingId && checkedOceanBookingId.value !== oceanBookingId) await lookupExistingAppointment(oceanBookingId)
|
||||
if (String(form.oceanBookingId || '') !== oceanBookingId || existingLookupLoading.value || existingAppointment.value || existingLookupError.value) return
|
||||
saving.value = true; error.value = null
|
||||
try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) }
|
||||
catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
watch(() => form.oceanBookingId, (value) => { void lookupExistingAppointment(String(value || ''), Boolean(initialOceanBookingId && String(value || '') === initialOceanBookingId)) }, { immediate: true })
|
||||
defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, existingLookupError, checkedOceanBookingId })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage as="div" class="truck-editor-page">
|
||||
<DetailPageLayout aria-label="新增拖车预约">
|
||||
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section>
|
||||
<el-alert v-if="existingAppointment" type="warning" :closable="false" show-icon><template #title>该订舱已有拖车单 {{ existingAppointment.appointmentNo || existingAppointment.id }}({{ truckAppointmentStatusLabel(existingAppointment.status) }})</template><el-button type="primary" link :loading="navigatingToExisting" @click="openExisting">{{ existingCanEdit ? '继续编辑' : '查看拖车单' }}</el-button></el-alert>
|
||||
<LoadErrorState v-if="existingLookupError" title="已有拖车单查询失败" :message="existingLookupError.message" compact @retry="lookupExistingAppointment(String(form.oceanBookingId || ''))"><template #details><ApiErrorMeta :details="existingLookupError" /></template></LoadErrorState>
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm :model-value="form" :submitting="saving" :submit-blocked="submitBlocked" @update:model-value="updateForm" @submit="submit" @attachments="attachmentOpen = true"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section>
|
||||
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
|
||||
@@ -32,18 +32,19 @@ describe('TruckAppointmentDetailView status contract', () => {
|
||||
expect(source).toContain("statuses.indexOf(status) === index")
|
||||
})
|
||||
|
||||
it('initializes the required booking and container associations explicitly', () => {
|
||||
expect(source).toContain("carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null")
|
||||
it('initializes the required booking association explicitly', () => {
|
||||
expect(source).toContain("carrierId: null, oceanBookingId: null, plannedLoadingAt: null")
|
||||
expect(source).not.toContain('containerBookingId')
|
||||
expect(source).not.toContain('customerId')
|
||||
expect(source).toContain('TRUCK_APPOINTMENT_SAVE_FIELDS.map')
|
||||
expect(source).toContain('normalizeTruckAppointmentCurrency(result.currency)')
|
||||
expect(source).not.toContain('Object.assign(form, { ...result')
|
||||
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
|
||||
})
|
||||
|
||||
it('blocks dispatch until the released booking, allocated real container and frozen bill number are present', () => {
|
||||
it('blocks dispatch until the released booking and frozen documents are present', () => {
|
||||
expect(source).toContain("return '确认派车前必须关联已放舱订舱'")
|
||||
expect(source).toContain("return '确认派车前必须关联该订舱下的柜次'")
|
||||
expect(source).toContain("return '确认派车前关联柜次必须录入真实 ISO 集装箱号'")
|
||||
expect(source).not.toContain('关联柜次')
|
||||
expect(source).toContain("return '确认派车前所选订舱必须填写提单号'")
|
||||
expect(source).toContain("return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'")
|
||||
expect(source).toContain('getOceanBooking(resource.value.oceanBookingId)')
|
||||
@@ -51,6 +52,13 @@ describe('TruckAppointmentDetailView status contract', () => {
|
||||
expect(source).not.toContain("item.attachmentType === 'BILL_OF_LADING'")
|
||||
})
|
||||
|
||||
it('keeps pickup booking-scoped and leaves real container maintenance to container/preload flows', () => {
|
||||
expect(source).not.toContain('ElMessageBox.prompt')
|
||||
expect(source).not.toContain('normalizeIso6346ContainerNumber')
|
||||
expect(source).toContain('performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0 })')
|
||||
expect(source).toContain('await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`')
|
||||
})
|
||||
|
||||
it('keeps the main detail available when attachments or history fail', () => {
|
||||
expect(source).toContain('Promise.allSettled([')
|
||||
expect(source).toContain("attachments.status === 'fulfilled'")
|
||||
@@ -59,6 +67,14 @@ describe('TruckAppointmentDetailView status contract', () => {
|
||||
expect(source).toContain('v-if="relatedError"')
|
||||
})
|
||||
|
||||
it('never exposes container identity or operator database ids', () => {
|
||||
expect(source).not.toContain('cabinetNo')
|
||||
expect(source).not.toContain('containerNo')
|
||||
expect(source).not.toContain('containerBookingId')
|
||||
expect(source).not.toContain('loadingPlanId')
|
||||
expect(source).not.toContain('item.operatorId')
|
||||
})
|
||||
|
||||
it('reloads a cached detail after an error without showing stale resource data', () => {
|
||||
expect(source).toContain("resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''")
|
||||
expect(source).toContain('onActivated(() => {')
|
||||
|
||||
@@ -13,15 +13,14 @@ import { getOceanBooking, type OceanBookingDetail } from '@/modules/ocean-bookin
|
||||
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
|
||||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||
import { TRUCK_APPOINTMENT_SAVE_FIELDS, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
|
||||
import { isIso6346ContainerNumber } from '../iso6346'
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null })
|
||||
import { TRUCK_APPOINTMENT_SAVE_FIELDS, normalizeTruckAppointmentCurrency, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: null, plannedLoadingAt: null })
|
||||
const history = ref<TruckAppointmentStatusHistory[]>([])
|
||||
const bookingReadiness = ref<OceanBookingDetail | null>(null)
|
||||
const relatedError = ref('')
|
||||
let activatedOnce = false
|
||||
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
|
||||
const relationSnapshot = computed(() => resource.value ? [resource.value.oceanBookingNo, resource.value.cabinetNo, resource.value.containerNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无历史关联' : '')
|
||||
const relationSnapshot = computed(() => resource.value ? [resource.value.oceanBookingNo, resource.value.billOfLadingNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无订舱关联' : '')
|
||||
async function loadRelated() {
|
||||
if (!resource.value) return
|
||||
relatedError.value = ''
|
||||
@@ -35,7 +34,7 @@ async function loadRelated() {
|
||||
if (booking.status === 'fulfilled') bookingReadiness.value = booking.value
|
||||
if (attachments.status === 'rejected' || histories.status === 'rejected' || booking.status === 'rejected') relatedError.value = '附件、状态历史或订舱资料状态暂时未能加载,主详情数据不受影响;派车时仍由服务端最终校验。'
|
||||
}
|
||||
async function load() { loading.value = true; error.value = null; resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; const draft = Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.map((key) => [key, key === 'expectedVersion' ? result.version ?? 0 : result[key as keyof TruckAppointmentResource]])); Object.assign(form, draft); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
async function load() { loading.value = true; error.value = null; resource.value = null; bookingReadiness.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; const draft = Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.map((key) => [key, key === 'expectedVersion' ? result.version ?? 0 : key === 'currency' ? normalizeTruckAppointmentCurrency(result.currency) : result[key as keyof TruckAppointmentResource]])); Object.assign(form, draft); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
async function submit() { if (saving.value) return; saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
|
||||
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
|
||||
const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppointmentStatus>> = { DRAFT: 'PENDING_DISPATCH', PENDING_DISPATCH: 'DISPATCHED', RESCHEDULED: 'DISPATCHED', DISPATCHED: 'PICKUP_IN_PROGRESS', PICKUP_IN_PROGRESS: 'ARRIVED_WAREHOUSE', ARRIVED_WAREHOUSE: 'LOADING', LOADING: 'DEPARTED', DEPARTED: 'COMPLETED' }
|
||||
@@ -50,16 +49,17 @@ const dispatchBlockReason = computed(() => {
|
||||
if (!resource.value || !['PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value.status)) return ''
|
||||
if (!form.oceanBookingId) return '确认派车前必须关联已放舱订舱'
|
||||
if (bookingReadiness.value && bookingReadiness.value.status !== 'RELEASED') return '确认派车前必须关联已放舱订舱'
|
||||
if (!form.containerBookingId) return '确认派车前必须关联该订舱下的柜次'
|
||||
if (!isIso6346ContainerNumber(resource.value.containerNo)) return '确认派车前关联柜次必须录入真实 ISO 集装箱号'
|
||||
if (!form.billOfLadingNo?.trim()) return '确认派车前所选订舱必须填写提单号'
|
||||
if (bookingReadiness.value && !bookingReadiness.value.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'
|
||||
return ''
|
||||
})
|
||||
async function confirmStatusChange(status: TruckAppointmentStatus) {
|
||||
await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
|
||||
}
|
||||
async function changeStatus(status: TruckAppointmentStatus) {
|
||||
if (!resource.value || status === resource.value.status || statusSaving.value) return
|
||||
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; return }
|
||||
try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
|
||||
try { await confirmStatusChange(status); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0 }) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
onActivated(() => {
|
||||
@@ -77,7 +77,7 @@ onActivated(() => {
|
||||
<section class="truck-overview surface-panel" aria-label="拖车预约概览">
|
||||
<div class="truck-overview__heading">
|
||||
<el-button link type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.oceanBookingNo, resource.containerNo, resource.orderNo].filter(Boolean).join(' · ') || '订舱与柜次信息缺失' : '查看拖车预约详情' }}</p></div>
|
||||
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.oceanBookingNo, resource.billOfLadingNo, resource.orderNo].filter(Boolean).join(' · ') || '订舱信息缺失' : '查看拖车预约详情' }}</p></div>
|
||||
<span v-if="resource" class="truck-overview__status">当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span>
|
||||
<div v-if="resource && canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" size="small" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div>
|
||||
</div>
|
||||
@@ -92,7 +92,7 @@ onActivated(() => {
|
||||
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else-if="resource">
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
|
||||
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
|
||||
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
|
||||
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
|
||||
@@ -2,6 +2,7 @@ import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import TruckAppointmentsView from './TruckAppointmentsView.vue'
|
||||
import { listTruckAppointments } from '../api/platformTrucking'
|
||||
import type { TruckAppointmentResource } from '../types'
|
||||
@@ -19,9 +20,9 @@ vi.mock('@/modules/iam', () => ({
|
||||
}))
|
||||
|
||||
const row: TruckAppointmentResource = {
|
||||
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', orderNo: 'ORD-1', oceanBookingNo: 'OB-1', containerBookingId: '77', cabinetNo: 'CAB-1',
|
||||
containerNo: 'CONT-1', warehouseName: '义乌仓', shippingCompanyName: '船公司A', bookingAgentName: '代理A', carrierName: '宁波运输', driverName: '王强', vehicleNo: '浙B12345',
|
||||
plannedLoadingAt: '2026-08-10T10:00:00', payableAmount: '3200.00', receivableAmount: '3800.00', attachmentCount: 2, attachments: [],
|
||||
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', orderNo: 'ORD-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1',
|
||||
warehouseName: '义乌仓', shippingCompanyName: '船公司A', bookingAgentName: '代理A', carrierName: '宁波运输', driverName: '王强', vehicleNo: '浙B12345',
|
||||
plannedLoadingAt: '2026-08-10T10:00:00', payableAmount: '3200.00', currency: 'RMB', attachmentCount: 2, attachments: [],
|
||||
}
|
||||
|
||||
describe('TruckAppointmentsView', () => {
|
||||
@@ -37,15 +38,13 @@ describe('TruckAppointmentsView', () => {
|
||||
it('loads the real list and renders relationship snapshots', async () => {
|
||||
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '', containerBookingId: '', containerType: '' })
|
||||
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '' })
|
||||
expect(wrapper.text()).toContain('TA-9001')
|
||||
expect(wrapper.text()).toContain('CONT-1')
|
||||
expect(wrapper.text()).toContain('CAB-1')
|
||||
expect(wrapper.text()).toContain('OB-1')
|
||||
expect(wrapper.text()).toContain('BL-1')
|
||||
expect(wrapper.text()).toContain('义乌仓')
|
||||
expect(wrapper.text()).toContain('船公司A')
|
||||
expect(wrapper.text()).toContain('代理A')
|
||||
expect(wrapper.text()).not.toContain('77')
|
||||
expect(wrapper.text()).toContain('宁波运输')
|
||||
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
|
||||
})
|
||||
@@ -64,10 +63,10 @@ describe('TruckAppointmentsView', () => {
|
||||
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button[aria-label="查看 9002"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[aria-label="查看拖车预约详情"]').exists()).toBe(true)
|
||||
const labels = wrapper.findAll('button[aria-label]').map((button) => button.attributes('aria-label'))
|
||||
expect(labels.every((label) => Boolean(label) && !/undefined|null/.test(label ?? ''))).toBe(true)
|
||||
expect(labels.every((label) => !label?.includes('9002'))).toBe(true)
|
||||
expect(source).toContain("appointmentActionLabel('编辑', row as TruckAppointmentResource)")
|
||||
})
|
||||
|
||||
@@ -93,6 +92,48 @@ describe('TruckAppointmentsView', () => {
|
||||
expect(source.match(/empty-text=/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders the server empty state', async () => {
|
||||
vi.mocked(listTruckAppointments).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 1 })
|
||||
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('暂无符合筛选条件的拖车预约')
|
||||
})
|
||||
|
||||
it('marks the table busy while the list request is pending', async () => {
|
||||
let resolveLoad!: (value: Awaited<ReturnType<typeof listTruckAppointments>>) => void
|
||||
vi.mocked(listTruckAppointments).mockImplementationOnce(() => new Promise((resolve) => { resolveLoad = resolve }))
|
||||
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('section[aria-busy="true"]').exists()).toBe(true)
|
||||
resolveLoad({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('section[aria-busy="false"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a recoverable load error and retries without losing the page', async () => {
|
||||
vi.mocked(listTruckAppointments).mockRejectedValueOnce(new ApiResponseError('候选服务暂不可用', 'A0503', 503, undefined, 'req-truck', 'trace-truck'))
|
||||
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('候选服务暂不可用')
|
||||
expect(wrapper.text()).toContain('req-truck')
|
||||
vi.mocked(listTruckAppointments).mockResolvedValueOnce({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(listTruckAppointments).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('TA-9001')
|
||||
})
|
||||
|
||||
it('shows create only with the independent create permission', async () => {
|
||||
let wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('新增拖车')
|
||||
wrapper.unmount()
|
||||
useAuthStore().permissions = ['platform:firstleg:truck-appointment:list', 'platform:firstleg:truck-appointment:create']
|
||||
wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('新增拖车')
|
||||
})
|
||||
|
||||
it('shows the two frequent row actions directly without a more menu', () => {
|
||||
expect(source).not.toContain('platform:firstleg:truck-appointment:view')
|
||||
expect(source).toContain("access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)")
|
||||
@@ -125,10 +166,12 @@ describe('TruckAppointmentsView', () => {
|
||||
expect(source).not.toContain("key: 'customerName'")
|
||||
expect(source).toContain("key: 'updatedAt'")
|
||||
expect(source).toContain("key: 'payableAmount', label: '应付拖车费'")
|
||||
expect(source).toContain("key: 'receivableAmount', label: '应收拖车费'")
|
||||
expect(source.indexOf("key: 'store'")).toBeLessThan(source.indexOf("key: 'payableAmount'"))
|
||||
expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'receivableAmount'"))
|
||||
expect(source.indexOf("key: 'receivableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'"))
|
||||
expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'"))
|
||||
expect(source).not.toContain('receivableAmount')
|
||||
expect(source).not.toContain('应收拖车费')
|
||||
expect(source).not.toContain("key: 'containerNo'")
|
||||
expect(source).not.toContain("key: 'cabinetNo'")
|
||||
expect(source).toContain("toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })")
|
||||
expect(source).toContain("key: 'attachmentCount'")
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabel, truckAppointme
|
||||
|
||||
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const access = useAccessPreviewStore()
|
||||
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '', containerType: '' }); const applied = ref({ ...filters.value })
|
||||
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '' }); const applied = ref({ ...filters.value })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '派车单号、车牌号、运输公司', width: 280 },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) },
|
||||
@@ -28,8 +28,7 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'appointmentNo', prop: 'appointmentNo', label: '派车单号', minWidth: 170 },
|
||||
{ key: 'oceanBookingNo', prop: 'oceanBookingNo', label: '订舱号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.oceanBookingNo || '—' },
|
||||
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.cabinetNo || '—' },
|
||||
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: TruckAppointmentResource) => row.containerNo || '—' },
|
||||
{ key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 160, formatter: (row: TruckAppointmentResource) => row.billOfLadingNo || '—' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.warehouseName || '—' },
|
||||
{ key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.shippingCompanyName || '—' },
|
||||
{ key: 'bookingAgentName', prop: 'bookingAgentName', label: '订舱代理', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.bookingAgentName || '—' },
|
||||
@@ -38,7 +37,6 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'vehicleNo', prop: 'vehicleNo', label: '车头号', minWidth: 120, formatter: (row: TruckAppointmentResource) => row.vehicleNo || '—' },
|
||||
{ key: 'store', label: '门店', minWidth: 140, slotName: 'store' },
|
||||
{ key: 'payableAmount', label: '应付拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.payableAmount, row.currency) },
|
||||
{ key: 'receivableAmount', label: '应收拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.receivableAmount, row.currency) },
|
||||
{ key: 'plannedLoadingAt', label: '预计装箱日期', minWidth: 170, formatter: (row: TruckAppointmentResource) => formatDateTime(row.plannedLoadingAt) },
|
||||
{ key: 'status', label: '状态', width: 130, slotName: 'status' },
|
||||
{ key: 'attachmentCount', label: '附件', width: 90, align: 'right', formatter: (row: TruckAppointmentResource) => String(Number(row.attachmentCount ?? 0)) },
|
||||
@@ -52,16 +50,14 @@ function formatAmount(value?: string | null, currency?: string | null) {
|
||||
return currency ? `${display} ${currency}` : display
|
||||
}
|
||||
function appointmentActionLabel(action: '查看' | '编辑', row: TruckAppointmentResource) {
|
||||
const identifier = [row.appointmentNo, row.id]
|
||||
.map((value) => String(value ?? '').trim())
|
||||
.find(Boolean)
|
||||
const identifier = String(row.appointmentNo ?? '').trim()
|
||||
return identifier ? `${action} ${identifier}` : `${action}拖车预约详情`
|
||||
}
|
||||
function statusLabel(value: unknown) {
|
||||
return truckAppointmentStatusLabel(typeof value === 'string' ? value : null)
|
||||
}
|
||||
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: '', containerType: String(model.containerType || '') }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '' }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
onMounted(() => { void load() })
|
||||
function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) }
|
||||
</script>
|
||||
|
||||
@@ -52,7 +52,17 @@ export function truckAppointmentActionLabel(action: string | null | undefined) {
|
||||
return truckAppointmentActionLabels[action] || '状态更新'
|
||||
}
|
||||
|
||||
export type TruckAttachmentType = 'DISPATCH_ORDER' | 'DRIVER_DOCUMENT' | 'VEHICLE_DOCUMENT' | 'OTHER'
|
||||
export const TRUCK_ATTACHMENT_TYPES = ['DISPATCH_ORDER', 'DRIVER_DOCUMENT', 'VEHICLE_DOCUMENT', 'OTHER'] as const
|
||||
export type TruckAttachmentType = typeof TRUCK_ATTACHMENT_TYPES[number]
|
||||
export function isTruckAttachmentType(value: unknown): value is TruckAttachmentType {
|
||||
return TRUCK_ATTACHMENT_TYPES.includes(value as TruckAttachmentType)
|
||||
}
|
||||
export const TRUCK_APPOINTMENT_CURRENCIES = ['RMB', 'USD'] as const
|
||||
export type TruckAppointmentCurrency = typeof TRUCK_APPOINTMENT_CURRENCIES[number]
|
||||
export function normalizeTruckAppointmentCurrency(value: unknown): TruckAppointmentCurrency {
|
||||
const normalized = String(value ?? '').trim().toUpperCase()
|
||||
return TRUCK_APPOINTMENT_CURRENCIES.includes(normalized as TruckAppointmentCurrency) ? normalized as TruckAppointmentCurrency : 'RMB'
|
||||
}
|
||||
|
||||
export interface TruckAttachmentReference {
|
||||
id?: string
|
||||
@@ -89,9 +99,6 @@ export interface TruckAppointmentResource {
|
||||
outboundOrderId?: string | null
|
||||
oceanBookingId?: string | null
|
||||
oceanBookingNo?: string | null
|
||||
containerBookingId?: string | null
|
||||
cabinetNo?: string | null
|
||||
loadingPlanId?: string | null
|
||||
warehouseId?: string | null
|
||||
warehouseName?: string | null
|
||||
shippingCompanyId?: string | null
|
||||
@@ -121,14 +128,11 @@ export interface TruckAppointmentResource {
|
||||
volumeCbm?: string | null
|
||||
palletCount?: number | string | null
|
||||
payableAmount?: string | null
|
||||
receivableAmount?: string | null
|
||||
currency?: string | null
|
||||
currency?: TruckAppointmentCurrency | null
|
||||
poNo?: string | null
|
||||
remark?: string | null
|
||||
orderId?: string | null
|
||||
orderNo?: string | null
|
||||
containerNo?: string | null
|
||||
relatedContainerType?: string | null
|
||||
entrustingPartyName?: string | null
|
||||
attachments?: TruckAttachmentReference[] | null
|
||||
attachmentCount?: number | string | null
|
||||
@@ -140,8 +144,6 @@ export interface TruckAppointmentResource {
|
||||
export interface SaveTruckAppointmentRequest {
|
||||
outboundOrderId?: string | null
|
||||
oceanBookingId: string | null
|
||||
containerBookingId: string | null
|
||||
loadingPlanId?: string | null
|
||||
warehouseId?: string | null
|
||||
carrierId: string | null
|
||||
carrierName?: string | null
|
||||
@@ -165,19 +167,18 @@ export interface SaveTruckAppointmentRequest {
|
||||
volumeCbm?: string | null
|
||||
palletCount?: number | string | null
|
||||
payableAmount?: string | null
|
||||
receivableAmount?: string | null
|
||||
currency?: string | null
|
||||
currency?: TruckAppointmentCurrency | null
|
||||
poNo?: string | null
|
||||
remark?: string | null
|
||||
expectedVersion: number
|
||||
}
|
||||
|
||||
export const TRUCK_APPOINTMENT_SAVE_FIELDS = [
|
||||
'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId',
|
||||
'outboundOrderId', 'oceanBookingId', 'warehouseId',
|
||||
'carrierId', 'carrierName', 'billOfLadingNo', 'fleetContact', 'fleetPhone', 'driverName',
|
||||
'driverPhone', 'driverIdCard', 'vehicleNo', 'storeName', 'storeContact', 'storePhone',
|
||||
'storeAddress', 'storeCity', 'storeZipcode', 'plannedLoadingAt', 'transitMinutes', 'packageCount',
|
||||
'grossWeightKg', 'volumeCbm', 'palletCount', 'payableAmount', 'receivableAmount', 'currency',
|
||||
'grossWeightKg', 'volumeCbm', 'palletCount', 'payableAmount', 'currency',
|
||||
'poNo', 'remark', 'expectedVersion',
|
||||
] as const satisfies readonly (keyof SaveTruckAppointmentRequest)[]
|
||||
|
||||
@@ -186,8 +187,37 @@ export interface TruckAppointmentQuery {
|
||||
size?: number
|
||||
keyword?: string
|
||||
status?: TruckAppointmentStatus | ''
|
||||
containerBookingId?: string | ''
|
||||
containerType?: string | ''
|
||||
oceanBookingId?: string | ''
|
||||
}
|
||||
|
||||
export interface TruckAppointmentCandidateQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface TruckAppointmentCandidate {
|
||||
oceanBookingId: string
|
||||
oceanBookingNo: string
|
||||
billOfLadingNo: string | null
|
||||
warehouseId: string
|
||||
warehouseName: string | null
|
||||
channelCode: string | null
|
||||
channelName: string | null
|
||||
bookingDocumentReady: boolean
|
||||
}
|
||||
|
||||
export interface TruckAppointmentCandidatePage {
|
||||
records: TruckAppointmentCandidate[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface TruckAppointmentActionRequest {
|
||||
expectedVersion: number
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export interface TruckAppointmentPage {
|
||||
|
||||
Reference in New Issue
Block a user