refactor(admin): scope truck orders to bookings
This commit is contained in:
@@ -8,15 +8,15 @@ describe('truck appointment API', () => {
|
|||||||
beforeEach(() => { vi.clearAllMocks() })
|
beforeEach(() => { vi.clearAllMocks() })
|
||||||
|
|
||||||
it('normalizes page values and maps filters', async () => {
|
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' } })
|
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', 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 }] })
|
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', containerBookingId: '77' } })
|
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 () => {
|
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: '海运', requirementId: 13, containerType: '40HC', requirementQuantity: '2', containerBookingId: 14, cabinetNo: 'CAB-14', containerNo: null, loadingPlanId: 15, loadingPlanNo: 'LP-15', loadingPlanStatus: 'EDITING', bookingDocumentReady: true }], page: '1', size: '200', total: '1', pages: '1' } })
|
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({
|
await expect(listTruckAppointmentCandidates({ keyword: ' OB ', page: 1, size: 200 })).resolves.toMatchObject({
|
||||||
records: [{ oceanBookingId: '11', warehouseId: '12', requirementId: '13', containerBookingId: '14', loadingPlanId: '15', requirementQuantity: 2, bookingDocumentReady: true }],
|
records: [{ oceanBookingId: '11', warehouseId: '12', bookingDocumentReady: true }],
|
||||||
total: 1,
|
total: 1,
|
||||||
})
|
})
|
||||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments/candidates', { params: { keyword: 'OB', page: 1, size: 200 } })
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments/candidates', { params: { keyword: 'OB', page: 1, size: 200 } })
|
||||||
@@ -27,31 +27,26 @@ describe('truck appointment API', () => {
|
|||||||
vi.mocked(http.post).mockResolvedValue({ data: '9001' })
|
vi.mocked(http.post).mockResolvedValue({ data: '9001' })
|
||||||
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } })
|
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } })
|
||||||
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
|
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' })
|
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', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0 })
|
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' })
|
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-') } })
|
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-dispatch-9001-') } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sends the real ISO container number only with the pickup action', async () => {
|
it('keeps pickup booking-scoped without sending container identity', async () => {
|
||||||
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'PICKUP_IN_PROGRESS', containerNo: 'MSCU6639870' } })
|
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'PICKUP_IN_PROGRESS' } })
|
||||||
await performTruckAppointmentAction('9001', 'PICKUP', { expectedVersion: 3, containerNo: 'MSCU6639870' })
|
await performTruckAppointmentAction('9001', 'PICKUP', { expectedVersion: 3 })
|
||||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/pickup', {
|
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/pickup', {
|
||||||
expectedVersion: 3, reason: null, containerNo: 'MSCU6639870',
|
expectedVersion: 3, reason: null,
|
||||||
}, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-pickup-9001-') } })
|
}, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-pickup-9001-') } })
|
||||||
|
|
||||||
await performTruckAppointmentAction('9002', 'PICKUP', { expectedVersion: 1, containerNo: null })
|
|
||||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/truck-appointments/9002/pickup', {
|
|
||||||
expectedVersion: 1, reason: null,
|
|
||||||
}, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-pickup-9002-') } })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('drops legacy customer fields instead of forwarding hidden compatibility data', async () => {
|
it('drops legacy customer fields instead of forwarding hidden compatibility data', async () => {
|
||||||
vi.mocked(http.post).mockResolvedValueOnce({ data: { id: '9001', status: 'DRAFT', attachmentCount: 0 } })
|
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, currency: 'EUR', receivableAmount: '999.00', 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)
|
await createTruckAppointment(payload as never)
|
||||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments', {
|
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments', {
|
||||||
carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0,
|
carrierId: '200', oceanBookingId: '300', plannedLoadingAt: '2026-08-10T10:00:00', currency: 'RMB', expectedVersion: 0,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function normalizePage(payload: PagePayload): TruckAppointmentPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource {
|
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>
|
const normalized = { ...resource } as Record<string, unknown>
|
||||||
for (const key of idKeys) {
|
for (const key of idKeys) {
|
||||||
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
|
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
|
||||||
@@ -56,8 +56,7 @@ export async function listTruckAppointments(query: TruckAppointmentQuery = {}) {
|
|||||||
...query,
|
...query,
|
||||||
keyword: query.keyword?.trim() || undefined,
|
keyword: query.keyword?.trim() || undefined,
|
||||||
status: query.status || undefined,
|
status: query.status || undefined,
|
||||||
containerBookingId: query.containerBookingId || undefined,
|
oceanBookingId: query.oceanBookingId || undefined,
|
||||||
containerType: query.containerType || undefined,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return normalizePage(unwrapApiResponse(response))
|
return normalizePage(unwrapApiResponse(response))
|
||||||
@@ -73,10 +72,9 @@ type CandidatePagePayload = Omit<TruckAppointmentCandidatePage, 'records' | 'pag
|
|||||||
|
|
||||||
function normalizeCandidate(candidate: TruckAppointmentCandidate & Record<string, unknown>): TruckAppointmentCandidate {
|
function normalizeCandidate(candidate: TruckAppointmentCandidate & Record<string, unknown>): TruckAppointmentCandidate {
|
||||||
const normalized = { ...candidate } as Record<string, unknown>
|
const normalized = { ...candidate } as Record<string, unknown>
|
||||||
for (const key of ['oceanBookingId', 'warehouseId', 'requirementId', 'containerBookingId', 'loadingPlanId']) {
|
for (const key of ['oceanBookingId', 'warehouseId']) {
|
||||||
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
|
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
|
||||||
}
|
}
|
||||||
normalized.requirementQuantity = Number(normalized.requirementQuantity || 0)
|
|
||||||
normalized.bookingDocumentReady = normalized.bookingDocumentReady === true
|
normalized.bookingDocumentReady = normalized.bookingDocumentReady === true
|
||||||
return normalized as unknown as TruckAppointmentCandidate
|
return normalized as unknown as TruckAppointmentCandidate
|
||||||
}
|
}
|
||||||
@@ -157,7 +155,6 @@ export async function performTruckAppointmentAction(
|
|||||||
const body = {
|
const body = {
|
||||||
expectedVersion: payload.expectedVersion,
|
expectedVersion: payload.expectedVersion,
|
||||||
reason: payload.reason || null,
|
reason: payload.reason || null,
|
||||||
...(payload.containerNo ? { containerNo: payload.containerNo } : {}),
|
|
||||||
}
|
}
|
||||||
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, body, {
|
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, body, {
|
||||||
headers: { 'Idempotency-Key': operationKey, ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) },
|
headers: { 'Idempotency-Key': operationKey, ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) },
|
||||||
|
|||||||
@@ -18,15 +18,12 @@ vi.mock('../api/platformTrucking', () => ({ listTruckAppointmentCandidates: vi.f
|
|||||||
const model: SaveTruckAppointmentRequest = {
|
const model: SaveTruckAppointmentRequest = {
|
||||||
expectedVersion: 0,
|
expectedVersion: 0,
|
||||||
oceanBookingId: null,
|
oceanBookingId: null,
|
||||||
containerBookingId: null,
|
|
||||||
loadingPlanId: null,
|
|
||||||
carrierId: null,
|
carrierId: null,
|
||||||
plannedLoadingAt: null,
|
plannedLoadingAt: null,
|
||||||
}
|
}
|
||||||
const candidates = [
|
const candidates = [
|
||||||
{ oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', requirementId: 'requirement-1', containerType: '40HC', requirementQuantity: 2, containerBookingId: 'container-1', cabinetNo: 'CAB-1', containerNo: 'CBHU8888883', loadingPlanId: 'plan-1', loadingPlanNo: 'LP-1', loadingPlanStatus: 'EDITING', bookingDocumentReady: true },
|
{ oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true },
|
||||||
{ oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', requirementId: 'requirement-1', containerType: '40HC', requirementQuantity: 2, containerBookingId: 'container-pending', cabinetNo: 'CAB-X', containerNo: null, loadingPlanId: null, loadingPlanNo: null, loadingPlanStatus: null, bookingDocumentReady: true },
|
{ oceanBookingId: 'booking-2', oceanBookingNo: 'OB-2', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓', channelCode: 'SEA', channelName: '海运', bookingDocumentReady: true },
|
||||||
{ oceanBookingId: 'booking-2', oceanBookingNo: 'OB-2', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓', channelCode: 'SEA', channelName: '海运', requirementId: 'requirement-2', containerType: '40GP', requirementQuantity: 1, containerBookingId: 'container-2', cabinetNo: 'CAB-2', containerNo: 'MSCU6639870', loadingPlanId: null, loadingPlanNo: null, loadingPlanStatus: null, bookingDocumentReady: true },
|
|
||||||
]
|
]
|
||||||
function detail(id: string) {
|
function detail(id: string) {
|
||||||
const candidate = candidates.find((item) => item.oceanBookingId === id)!
|
const candidate = candidates.find((item) => item.oceanBookingId === id)!
|
||||||
@@ -36,9 +33,9 @@ function detail(id: string) {
|
|||||||
containers: [],
|
containers: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function resource(id: string, bookingId: string, containerId: string): TruckAppointmentResource {
|
function resource(id: string, bookingId: string): TruckAppointmentResource {
|
||||||
const candidate = candidates.find((item) => item.oceanBookingId === bookingId && item.containerBookingId === containerId)!
|
const candidate = candidates.find((item) => item.oceanBookingId === bookingId)!
|
||||||
return { id, status: 'DRAFT', oceanBookingId: bookingId, oceanBookingNo: candidate.oceanBookingNo, containerBookingId: containerId, cabinetNo: candidate.cabinetNo, containerNo: candidate.containerNo, relatedContainerType: candidate.containerType }
|
return { id, status: 'DRAFT', oceanBookingId: bookingId, oceanBookingNo: candidate.oceanBookingNo, billOfLadingNo: candidate.billOfLadingNo, warehouseId: candidate.warehouseId, warehouseName: candidate.warehouseName }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('TruckAppointmentForm booking-only behavior', () => {
|
describe('TruckAppointmentForm booking-only behavior', () => {
|
||||||
@@ -48,7 +45,7 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
|||||||
vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never)
|
vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('selects a released booking, exposes pending-pickup internal 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] } })
|
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||||
@@ -58,8 +55,8 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
|||||||
expect(listTruckAppointmentCandidates).toHaveBeenCalledWith({ page: 1, size: 200 })
|
expect(listTruckAppointmentCandidates).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||||
expect(getOceanBooking).not.toHaveBeenCalled()
|
expect(getOceanBooking).not.toHaveBeenCalled()
|
||||||
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
|
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('OB-1') && label.includes('BL-1'))).toBe(true)
|
||||||
expect(labels.some((label) => label.includes('CAB-X') && label.includes('40HC') && label.includes('待补录'))).toBe(true)
|
expect(labels.some((label) => label.includes('CAB-'))).toBe(false)
|
||||||
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
|
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
|
||||||
expect(emitted.oceanBookingId).toBe('booking-1')
|
expect(emitted.oceanBookingId).toBe('booking-1')
|
||||||
expect(emitted.billOfLadingNo).toBe('BL-1')
|
expect(emitted.billOfLadingNo).toBe('BL-1')
|
||||||
@@ -68,23 +65,20 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears a selected container when switching to another booking', async () => {
|
it('replaces frozen booking snapshots when switching the selected booking', async () => {
|
||||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||||
selects[0]!.vm.$emit('update:modelValue', 'booking-1')
|
selects[0]!.vm.$emit('update:modelValue', 'booking-1')
|
||||||
await flushPromises()
|
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')
|
selects[0]!.vm.$emit('update:modelValue', 'booking-2')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
|
const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest
|
||||||
expect(emitted.oceanBookingId).toBe('booking-2')
|
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') || ''))
|
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()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -95,11 +89,11 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
|||||||
if (id === 'booking-1') resolveFirst = resolve as typeof resolveFirst
|
if (id === 'booking-1') resolveFirst = resolve as typeof resolveFirst
|
||||||
else resolveSecond = resolve as typeof resolveSecond
|
else resolveSecond = resolve as typeof resolveSecond
|
||||||
}) as never)
|
}) as never)
|
||||||
const firstResource = resource('appointment-1', 'booking-1', 'container-1')
|
const firstResource = resource('appointment-1', 'booking-1')
|
||||||
const secondResource = resource('appointment-2', 'booking-2', 'container-2')
|
const secondResource = resource('appointment-2', 'booking-2')
|
||||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', containerBookingId: 'container-1' }, resource: firstResource }, global: { plugins: [ElementPlus] } })
|
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: firstResource }, global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await wrapper.setProps({ modelValue: { ...model, oceanBookingId: 'booking-2', containerBookingId: 'container-2' }, resource: secondResource })
|
await wrapper.setProps({ modelValue: { ...model, oceanBookingId: 'booking-2' }, resource: secondResource })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
resolveSecond(detail('booking-2'))
|
resolveSecond(detail('booking-2'))
|
||||||
@@ -108,8 +102,8 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
|
const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || ''))
|
||||||
expect(labels.some((label) => label.includes('CAB-2') && label.includes('不可重选'))).toBe(true)
|
expect(labels.some((label) => label.includes('OB-2') && label.includes('不可重选'))).toBe(true)
|
||||||
expect(labels.some((label) => label.includes('CAB-1'))).toBe(false)
|
expect(labels.some((label) => label.includes('OB-1'))).toBe(false)
|
||||||
expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
|
expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件')
|
||||||
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
|
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
@@ -117,8 +111,8 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
|||||||
|
|
||||||
it('preserves the business code and request trace when booking readiness fails', async () => {
|
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'))
|
vi.mocked(getOceanBooking).mockRejectedValueOnce(new ApiResponseError('订舱版本冲突', 'A0409', 409, undefined, 'req-booking', 'trace-booking'))
|
||||||
const current = resource('appointment-1', 'booking-1', 'container-1')
|
const current = resource('appointment-1', 'booking-1')
|
||||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', containerBookingId: 'container-1' }, resource: current }, global: { plugins: [ElementPlus] } })
|
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: current }, global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain('订舱版本冲突')
|
expect(wrapper.text()).toContain('订舱版本冲突')
|
||||||
expect(wrapper.text()).toContain('A0409')
|
expect(wrapper.text()).toContain('A0409')
|
||||||
@@ -129,8 +123,8 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
|||||||
|
|
||||||
it('flags an edit binding whose booking has become unreleased without adding it to create candidates', async () => {
|
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)
|
vi.mocked(getOceanBooking).mockResolvedValueOnce({ ...detail('booking-1'), status: 'DRAFT' } as never)
|
||||||
const current = resource('appointment-1', 'booking-1', 'container-1')
|
const current = resource('appointment-1', 'booking-1')
|
||||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', containerBookingId: 'container-1' }, resource: current }, global: { plugins: [ElementPlus] } })
|
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1' }, resource: current }, global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain('当前绑定订舱已不是放舱状态')
|
expect(wrapper.text()).toContain('当前绑定订舱已不是放舱状态')
|
||||||
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
|
expect(listTruckAppointmentCandidates).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -6,31 +6,28 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/compone
|
|||||||
const typesSource = readFileSync(resolve(process.cwd(), 'src/modules/trucking/types.ts'), 'utf8')
|
const typesSource = readFileSync(resolve(process.cwd(), 'src/modules/trucking/types.ts'), 'utf8')
|
||||||
|
|
||||||
describe('TruckAppointmentForm interaction contract', () => {
|
describe('TruckAppointmentForm interaction contract', () => {
|
||||||
it('requires a released booking before an allocated internal container', () => {
|
it('requires one server-approved released booking without exposing a container selector', () => {
|
||||||
expect(source).toContain('listTruckAppointmentCandidates({ page: 1, size: 200 })')
|
expect(source).toContain('listTruckAppointmentCandidates({ page: 1, size: 200 })')
|
||||||
expect(source).toContain("oceanBookingId: [{ required: true, message: '请选择已放舱订舱'")
|
expect(source).toContain("oceanBookingId: [{ required: true, message: '请选择已放舱订舱'")
|
||||||
expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下可预约的柜次'")
|
expect(source).not.toContain('prop="containerBookingId"')
|
||||||
expect(source.indexOf('label="订舱"')).toBeLessThan(source.indexOf('label="柜次"'))
|
expect(source).not.toContain('label="柜次"')
|
||||||
expect(source).toContain(':disabled="relationLocked || !draft.oceanBookingId || optionsLoading || bookingContainersLoading"')
|
|
||||||
expect(source).not.toContain('关联订舱(可选)')
|
expect(source).not.toContain('关联订舱(可选)')
|
||||||
expect(source).not.toContain('关联柜次(可选)')
|
expect(source).not.toContain('关联柜次(可选)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses only server-owned create candidates and never rebuilds availability from booking detail', () => {
|
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('candidates.value = results[1].value.records')
|
||||||
expect(source).toContain('candidates.value.filter((item) => item.oceanBookingId === draft.oceanBookingId)')
|
expect(source).toContain('new Map<string')
|
||||||
expect(source).toContain('该订舱暂无可预约柜次')
|
|
||||||
expect(source).not.toContain('booking.containers')
|
expect(source).not.toContain('booking.containers')
|
||||||
expect(source).not.toContain('listOceanBookings')
|
expect(source).not.toContain('listOceanBookings')
|
||||||
expect(source).not.toContain('isIso6346ContainerNumber')
|
expect(source).not.toContain('isIso6346ContainerNumber')
|
||||||
expect(source).not.toContain('listContainers')
|
expect(source).not.toContain('listContainers')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears an incompatible container whenever the booking changes', () => {
|
it('does not write legacy container or loading-plan fields', () => {
|
||||||
expect(source).toContain('draft.containerBookingId = null')
|
expect(source).not.toContain('draft.containerBookingId')
|
||||||
expect(source).toContain('draft.loadingPlanId = null')
|
expect(source).not.toContain('draft.loadingPlanId')
|
||||||
expect(source).toContain('draft.containerBookingId = null')
|
expect(typesSource).not.toContain("'containerBookingId', 'loadingPlanId'")
|
||||||
expect(source).toContain('candidate.loadingPlanId')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('freezes warehouse and bill of lading snapshots without retaining customer data', () => {
|
it('freezes warehouse and bill of lading snapshots without retaining customer data', () => {
|
||||||
@@ -56,12 +53,11 @@ describe('TruckAppointmentForm interaction contract', () => {
|
|||||||
expect(source).toContain('请选择有效的合作中拖车供应商')
|
expect(source).toContain('请选择有效的合作中拖车供应商')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('freezes the current edit relation and marks its fallback options as non-selectable', () => {
|
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('const relationLocked = computed(() => Boolean(props.resource))')
|
||||||
expect(source).toContain('(当前绑定,不可重选)')
|
expect(source).toContain('(当前绑定,不可重选)')
|
||||||
expect(source).toContain(':disabled="relationLocked"')
|
expect(source).toContain(':disabled="relationLocked"')
|
||||||
expect(source).toContain('<el-option v-if="selectedContainerFallback"')
|
expect(source).not.toContain('selectedContainerFallback')
|
||||||
expect(source).toContain(':value="selectedContainerFallback.id" disabled')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('guards current booking detail loads against stale route reuse responses', () => {
|
it('guards current booking detail loads against stale route reuse responses', () => {
|
||||||
@@ -72,10 +68,10 @@ describe('TruckAppointmentForm interaction contract', () => {
|
|||||||
expect(source).toContain("watch(() => props.resource?.id")
|
expect(source).toContain("watch(() => props.resource?.id")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('labels the internal cabinet, type and pickup-time physical number separately', () => {
|
it('keeps container identity out of the booking-scoped form', () => {
|
||||||
expect(source).toContain('柜次号:${item.cabinetNo')
|
expect(source).not.toContain('柜次号:')
|
||||||
expect(source).toContain('柜型:${item.containerType')
|
expect(source).not.toContain('集装箱号:')
|
||||||
expect(source).toContain("集装箱号:${item.containerNo || '待补录'}")
|
expect(source).toContain('真实柜次及集装箱号在货柜与预装流程维护')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the form usable at 390px', () => {
|
it('keeps the form usable at 390px', () => {
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ const rules: FormRules = {
|
|||||||
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
|
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
|
||||||
return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
|
return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
|
||||||
}, trigger: 'change' }],
|
}, trigger: 'change' }],
|
||||||
containerBookingId: [{ required: true, message: '请选择该订舱下可预约的柜次', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableContainers.value.some((item) => item.containerBookingId === String(value)) || (relationLocked.value && 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' }],
|
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' }],
|
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
|
||||||
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
|
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
|
||||||
@@ -70,19 +69,6 @@ const selectedReleasedBooking = computed(() => bookings.value.find((item) => ite
|
|||||||
const bookingDocumentReady = computed(() => relationLocked.value
|
const bookingDocumentReady = computed(() => relationLocked.value
|
||||||
? bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))
|
? bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))
|
||||||
: Boolean(selectedReleasedBooking.value?.bookingDocumentReady))
|
: Boolean(selectedReleasedBooking.value?.bookingDocumentReady))
|
||||||
const selectableContainers = computed(() => candidates.value.filter((item) => item.oceanBookingId === draft.oceanBookingId))
|
|
||||||
function containerOptionLabel(item: TruckAppointmentCandidate) {
|
|
||||||
return `柜次号:${item.cabinetNo || '待生成'} / 柜型:${item.containerType || '—'} / 集装箱号:${item.containerNo || '待补录'}`
|
|
||||||
}
|
|
||||||
const selectedContainerFallback = computed(() => {
|
|
||||||
const id = draft.containerBookingId
|
|
||||||
const snapshot = props.resource
|
|
||||||
if (!id || !relationLocked.value || String(snapshot?.containerBookingId) !== String(id)) return null
|
|
||||||
return {
|
|
||||||
id: String(id),
|
|
||||||
label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 柜型:${snapshot?.relatedContainerType || '—'} / 集装箱号:${snapshot?.containerNo || '待补录'}(当前绑定,不可重选)`,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const selectedBookingFallback = computed(() => {
|
const selectedBookingFallback = computed(() => {
|
||||||
const id = draft.oceanBookingId
|
const id = draft.oceanBookingId
|
||||||
const snapshot = props.resource
|
const snapshot = props.resource
|
||||||
@@ -90,16 +76,6 @@ const selectedBookingFallback = computed(() => {
|
|||||||
return { id: String(id), label: `${snapshot?.oceanBookingNo || '订舱号缺失'}(当前绑定,不可重选)` }
|
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 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 = item.loadingPlanId
|
|
||||||
}
|
|
||||||
async function loadCurrentBookingContext(bookingId: string) {
|
async function loadCurrentBookingContext(bookingId: string) {
|
||||||
const sequence = ++bookingLoadSequence
|
const sequence = ++bookingLoadSequence
|
||||||
bookingContainersLoading.value = true
|
bookingContainersLoading.value = true
|
||||||
@@ -127,8 +103,6 @@ async function selectBooking(bookingId: string) {
|
|||||||
bookingLoadSequence += 1
|
bookingLoadSequence += 1
|
||||||
const item = bookings.value.find((entry) => entry.id === bookingId)
|
const item = bookings.value.find((entry) => entry.id === bookingId)
|
||||||
draft.oceanBookingId = item?.id || null
|
draft.oceanBookingId = item?.id || null
|
||||||
draft.containerBookingId = null
|
|
||||||
draft.loadingPlanId = null
|
|
||||||
bookingDocuments.value = []
|
bookingDocuments.value = []
|
||||||
relationWarning.value = ''
|
relationWarning.value = ''
|
||||||
if (!item) {
|
if (!item) {
|
||||||
@@ -155,15 +129,12 @@ async function loadOptions() {
|
|||||||
optionsLoading.value = false
|
optionsLoading.value = false
|
||||||
if (relationLocked.value && draft.oceanBookingId) await loadCurrentBookingContext(String(draft.oceanBookingId))
|
if (relationLocked.value && draft.oceanBookingId) await loadCurrentBookingContext(String(draft.oceanBookingId))
|
||||||
else if (draft.oceanBookingId) {
|
else if (draft.oceanBookingId) {
|
||||||
const candidate = candidates.value.find((item) => item.oceanBookingId === draft.oceanBookingId && item.containerBookingId === draft.containerBookingId)
|
const candidate = candidates.value.find((item) => item.oceanBookingId === draft.oceanBookingId)
|
||||||
if (candidate) {
|
if (candidate) {
|
||||||
draft.warehouseId = candidate.warehouseId
|
draft.warehouseId = candidate.warehouseId
|
||||||
draft.billOfLadingNo = candidate.billOfLadingNo
|
draft.billOfLadingNo = candidate.billOfLadingNo
|
||||||
draft.loadingPlanId = candidate.loadingPlanId
|
|
||||||
} else {
|
} else {
|
||||||
draft.oceanBookingId = null
|
draft.oceanBookingId = null
|
||||||
draft.containerBookingId = null
|
|
||||||
draft.loadingPlanId = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,13 +148,12 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
||||||
<div class="relation-note"><span>拖车预约必须先选择已放舱订舱,再选择该订舱下已分配的内部柜次;真实集装箱号可待司机提柜时录入,仓库和提单号由订舱冻结资料带入,最终资格仍由服务端校验。</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="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-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-row :gutter="16">
|
||||||
<el-col :xs="24"><el-divider content-position="left">订舱与柜次(必填)</el-divider></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></el-form-item></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 class="relation-column" :xs="24" :sm="12"><el-form-item label="柜次" prop="containerBookingId"><el-select :model-value="draft.containerBookingId" filterable :disabled="relationLocked || !draft.oceanBookingId || optionsLoading || bookingContainersLoading" placeholder="请先选择订舱,再选择可预约柜次" :loading="optionsLoading || 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 /><el-option v-for="item in selectableContainers" :key="item.containerBookingId" :label="containerOptionLabel(item)" :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 && loadCurrentBookingContext(String(draft.oceanBookingId))">重新加载</el-button></div><div v-else-if="draft.oceanBookingId && !optionsLoading && !bookingContainersLoading && !selectableContainers.length && !relationLocked" class="field-hint">该订舱当前没有可创建拖车预约的柜次。</div></el-form-item></el-col>
|
|
||||||
<el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></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 && !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>
|
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !bookingDocumentReady" :xs="24"><el-alert title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ vi.mock('vue-router', () => ({ useRoute: () => ({ query: routing.query }), useRo
|
|||||||
vi.mock('../api/platformTrucking', () => ({ createTruckAppointment: vi.fn(), listTruckAppointments: vi.fn() }))
|
vi.mock('../api/platformTrucking', () => ({ createTruckAppointment: vi.fn(), listTruckAppointments: vi.fn() }))
|
||||||
|
|
||||||
const existing = (status: TruckAppointmentResource['status']): TruckAppointmentResource => ({
|
const existing = (status: TruckAppointmentResource['status']): TruckAppointmentResource => ({
|
||||||
id: 'appointment-1', appointmentNo: 'TA-260901-0003', status, containerBookingId: 'container-1', attachments: [],
|
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 page = (records: TruckAppointmentResource[]) => ({ records, page: 1, size: 20, total: records.length, pages: 1 })
|
||||||
const formStub = {
|
const formStub = {
|
||||||
@@ -26,7 +26,7 @@ type ViewModel = {
|
|||||||
lookupExistingAppointment: (id: string, autoNavigate?: boolean) => Promise<void>
|
lookupExistingAppointment: (id: string, autoNavigate?: boolean) => Promise<void>
|
||||||
existingAppointment: TruckAppointmentResource | null
|
existingAppointment: TruckAppointmentResource | null
|
||||||
existingLookupError: { message: string } | null
|
existingLookupError: { message: string } | null
|
||||||
checkedContainerBookingId: string
|
checkedOceanBookingId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountView() {
|
function mountView() {
|
||||||
@@ -43,19 +43,19 @@ describe('TruckAppointmentCreateView existing appointment reuse', () => {
|
|||||||
vi.mocked(createTruckAppointment).mockResolvedValue(existing('DRAFT'))
|
vi.mocked(createTruckAppointment).mockResolvedValue(existing('DRAFT'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('automatically reuses a DRAFT appointment for a query-prefilled container', async () => {
|
it('automatically reuses a DRAFT appointment for a query-prefilled booking', async () => {
|
||||||
Object.assign(routing.query, { oceanBookingId: 'booking-1', containerBookingId: 'container-1' })
|
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
|
||||||
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing('DRAFT')]))
|
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing('DRAFT')]))
|
||||||
const wrapper = mountView()
|
const wrapper = mountView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(listTruckAppointments).toHaveBeenCalledWith({ containerBookingId: 'container-1', page: 1, size: 20 })
|
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(routing.replace).toHaveBeenCalledWith({ name: 'trucking-detail', params: { id: 'appointment-1' }, query: { edit: '1' } })
|
||||||
expect(createTruckAppointment).not.toHaveBeenCalled()
|
expect(createTruckAppointment).not.toHaveBeenCalled()
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each(['COMPLETED', 'CANCELLED', 'EXCEPTION'] as const)('allows a new appointment after terminal status %s', async (status) => {
|
it.each(['COMPLETED', 'CANCELLED', 'EXCEPTION'] as const)('allows a new appointment after terminal status %s', async (status) => {
|
||||||
Object.assign(routing.query, { containerBookingId: 'container-1' })
|
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
|
||||||
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing(status)]))
|
vi.mocked(listTruckAppointments).mockResolvedValue(page([existing(status)]))
|
||||||
const wrapper = mountView()
|
const wrapper = mountView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -65,7 +65,7 @@ describe('TruckAppointmentCreateView existing appointment reuse', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('keeps request evidence and retry state when existing-appointment lookup fails', async () => {
|
it('keeps request evidence and retry state when existing-appointment lookup fails', async () => {
|
||||||
Object.assign(routing.query, { containerBookingId: 'container-1' })
|
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
|
||||||
vi.mocked(listTruckAppointments).mockRejectedValue(new ApiResponseError('预约查询失败', 'A0500', 500, undefined, 'req-existing', 'trace-existing'))
|
vi.mocked(listTruckAppointments).mockRejectedValue(new ApiResponseError('预约查询失败', 'A0500', 500, undefined, 'req-existing', 'trace-existing'))
|
||||||
const wrapper = mountView()
|
const wrapper = mountView()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -77,32 +77,32 @@ describe('TruckAppointmentCreateView existing appointment reuse', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores a stale lookup response after the selected container changes', async () => {
|
it('ignores a stale lookup response after the selected booking changes', async () => {
|
||||||
let resolveFirst!: (value: ReturnType<typeof page>) => void
|
let resolveFirst!: (value: ReturnType<typeof page>) => void
|
||||||
let resolveSecond!: (value: ReturnType<typeof page>) => void
|
let resolveSecond!: (value: ReturnType<typeof page>) => void
|
||||||
vi.mocked(listTruckAppointments).mockImplementation((query = {}) => new Promise((resolve) => {
|
vi.mocked(listTruckAppointments).mockImplementation((query = {}) => new Promise((resolve) => {
|
||||||
const { containerBookingId } = query
|
const { oceanBookingId } = query
|
||||||
if (containerBookingId === 'container-1') resolveFirst = resolve
|
if (oceanBookingId === 'booking-1') resolveFirst = resolve
|
||||||
else resolveSecond = resolve
|
else resolveSecond = resolve
|
||||||
}))
|
}))
|
||||||
const wrapper = mountView()
|
const wrapper = mountView()
|
||||||
const vm = wrapper.vm as unknown as ViewModel
|
const vm = wrapper.vm as unknown as ViewModel
|
||||||
vm.form.containerBookingId = 'container-1'
|
vm.form.oceanBookingId = 'booking-1'
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
vm.form.containerBookingId = 'container-2'
|
vm.form.oceanBookingId = 'booking-2'
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
resolveSecond(page([]))
|
resolveSecond(page([]))
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
resolveFirst(page([existing('DRAFT')]))
|
resolveFirst(page([existing('DRAFT')]))
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(vm.checkedContainerBookingId).toBe('container-2')
|
expect(vm.checkedOceanBookingId).toBe('booking-2')
|
||||||
expect(vm.existingAppointment).toBeNull()
|
expect(vm.existingAppointment).toBeNull()
|
||||||
expect(routing.replace).not.toHaveBeenCalled()
|
expect(routing.replace).not.toHaveBeenCalled()
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('suppresses duplicate create clicks while the first request is pending', async () => {
|
it('suppresses duplicate create clicks while the first request is pending', async () => {
|
||||||
Object.assign(routing.query, { containerBookingId: 'container-1' })
|
Object.assign(routing.query, { oceanBookingId: 'booking-1' })
|
||||||
let resolveCreate!: (value: TruckAppointmentResource) => void
|
let resolveCreate!: (value: TruckAppointmentResource) => void
|
||||||
vi.mocked(createTruckAppointment).mockImplementation(() => new Promise((resolve) => { resolveCreate = resolve }))
|
vi.mocked(createTruckAppointment).mockImplementation(() => new Promise((resolve) => { resolveCreate = resolve }))
|
||||||
const wrapper = mountView()
|
const wrapper = mountView()
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { resolve } from 'node:path'
|
|||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentCreateView.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentCreateView.vue'), 'utf8')
|
||||||
|
|
||||||
describe('TruckAppointmentCreateView booking-bound creation contract', () => {
|
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).toContain('carrierId: null')
|
||||||
expect(source).not.toContain('customerId')
|
expect(source).not.toContain('customerId')
|
||||||
expect(source).toContain('oceanBookingId: initialOceanBookingId || null, containerBookingId: initialContainerBookingId || null, loadingPlanId: null')
|
expect(source).toContain('oceanBookingId: initialOceanBookingId || null, plannedLoadingAt: null')
|
||||||
|
expect(source).not.toContain('initialContainerBookingId')
|
||||||
expect(source).toContain('plannedLoadingAt: null')
|
expect(source).toContain('plannedLoadingAt: null')
|
||||||
expect(source).toContain("payableAmount: '0.00', currency: 'RMB'")
|
expect(source).toContain("payableAmount: '0.00', currency: 'RMB'")
|
||||||
expect(source).not.toContain('receivableAmount')
|
expect(source).not.toContain('receivableAmount')
|
||||||
@@ -26,10 +27,10 @@ describe('TruckAppointmentCreateView booking-bound creation contract', () => {
|
|||||||
expect(source).toContain('if (saving.value || existingLookupLoading.value) return')
|
expect(source).toContain('if (saving.value || existingLookupLoading.value) return')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reuses non-terminal appointments by container and separates lookup failures from booking-file errors', () => {
|
it('reuses non-terminal appointments by booking and separates lookup failures from booking-file errors', () => {
|
||||||
expect(source).toContain("listTruckAppointments({ containerBookingId: normalizedId, page: 1, size: 20 })")
|
expect(source).toContain("listTruckAppointments({ oceanBookingId: normalizedId, page: 1, size: 20 })")
|
||||||
expect(source).toContain("new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])")
|
expect(source).toContain("new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])")
|
||||||
expect(source).toContain('该柜已有拖车预约')
|
expect(source).toContain('该订舱已有拖车单')
|
||||||
expect(source).toContain("getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')")
|
expect(source).toContain("getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')")
|
||||||
expect(source).toContain('<ApiErrorMeta :details="existingLookupError" />')
|
expect(source).toContain('<ApiErrorMeta :details="existingLookupError" />')
|
||||||
expect(source).toContain("router.replace({ name: 'trucking-detail'")
|
expect(source).toContain("router.replace({ name: 'trucking-detail'")
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
|||||||
import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking'
|
import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking'
|
||||||
import { truckAppointmentStatusLabel, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types'
|
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 route = useRoute(); const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||||
const initialContainerBookingId = String(route.query.containerBookingId || '').trim()
|
|
||||||
const initialOceanBookingId = String(route.query.oceanBookingId || '').trim()
|
const initialOceanBookingId = String(route.query.oceanBookingId || '').trim()
|
||||||
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, carrierId: null, oceanBookingId: initialOceanBookingId || null, containerBookingId: initialContainerBookingId || null, loadingPlanId: null, plannedLoadingAt: null, payableAmount: '0.00', currency: 'RMB' })
|
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 existingAppointment = ref<TruckAppointmentResource | null>(null)
|
||||||
const existingLookupLoading = ref(false)
|
const existingLookupLoading = ref(false)
|
||||||
const existingLookupError = ref<ApiErrorDetails | null>(null)
|
const existingLookupError = ref<ApiErrorDetails | null>(null)
|
||||||
const checkedContainerBookingId = ref('')
|
const checkedOceanBookingId = ref('')
|
||||||
const navigatingToExisting = ref(false)
|
const navigatingToExisting = ref(false)
|
||||||
let lookupSerial = 0
|
let lookupSerial = 0
|
||||||
const terminalStatuses = new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])
|
const terminalStatuses = new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])
|
||||||
@@ -33,23 +32,23 @@ async function openExisting() {
|
|||||||
try { await router.replace({ name: 'trucking-detail', params: { id: existingAppointment.value.id }, query: existingCanEdit.value ? { edit: '1' } : undefined }) }
|
try { await router.replace({ name: 'trucking-detail', params: { id: existingAppointment.value.id }, query: existingCanEdit.value ? { edit: '1' } : undefined }) }
|
||||||
finally { navigatingToExisting.value = false }
|
finally { navigatingToExisting.value = false }
|
||||||
}
|
}
|
||||||
async function lookupExistingAppointment(containerBookingId: string, autoNavigate = false) {
|
async function lookupExistingAppointment(oceanBookingId: string, autoNavigate = false) {
|
||||||
const normalizedId = containerBookingId.trim()
|
const normalizedId = oceanBookingId.trim()
|
||||||
const serial = ++lookupSerial
|
const serial = ++lookupSerial
|
||||||
existingAppointment.value = null
|
existingAppointment.value = null
|
||||||
existingLookupError.value = null
|
existingLookupError.value = null
|
||||||
checkedContainerBookingId.value = ''
|
checkedOceanBookingId.value = ''
|
||||||
if (!normalizedId) { existingLookupLoading.value = false; return }
|
if (!normalizedId) { existingLookupLoading.value = false; return }
|
||||||
existingLookupLoading.value = true
|
existingLookupLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await listTruckAppointments({ containerBookingId: normalizedId, page: 1, size: 20 })
|
const result = await listTruckAppointments({ oceanBookingId: normalizedId, page: 1, size: 20 })
|
||||||
if (serial !== lookupSerial || String(form.containerBookingId || '') !== normalizedId) return
|
if (serial !== lookupSerial || String(form.oceanBookingId || '') !== normalizedId) return
|
||||||
checkedContainerBookingId.value = normalizedId
|
checkedOceanBookingId.value = normalizedId
|
||||||
existingAppointment.value = result.records.find((item) => !terminalStatuses.has(item.status)) || null
|
existingAppointment.value = result.records.find((item) => !terminalStatuses.has(item.status)) || null
|
||||||
if (autoNavigate && existingAppointment.value) await openExisting()
|
if (autoNavigate && existingAppointment.value) await openExisting()
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (serial !== lookupSerial || String(form.containerBookingId || '') !== normalizedId) return
|
if (serial !== lookupSerial || String(form.oceanBookingId || '') !== normalizedId) return
|
||||||
checkedContainerBookingId.value = normalizedId
|
checkedOceanBookingId.value = normalizedId
|
||||||
existingLookupError.value = getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')
|
existingLookupError.value = getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')
|
||||||
} finally {
|
} finally {
|
||||||
if (serial === lookupSerial) existingLookupLoading.value = false
|
if (serial === lookupSerial) existingLookupLoading.value = false
|
||||||
@@ -57,23 +56,23 @@ async function lookupExistingAppointment(containerBookingId: string, autoNavigat
|
|||||||
}
|
}
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (saving.value || existingLookupLoading.value) return
|
if (saving.value || existingLookupLoading.value) return
|
||||||
const containerBookingId = String(form.containerBookingId || '')
|
const oceanBookingId = String(form.oceanBookingId || '')
|
||||||
if (containerBookingId && checkedContainerBookingId.value !== containerBookingId) await lookupExistingAppointment(containerBookingId)
|
if (oceanBookingId && checkedOceanBookingId.value !== oceanBookingId) await lookupExistingAppointment(oceanBookingId)
|
||||||
if (String(form.containerBookingId || '') !== containerBookingId || existingLookupLoading.value || existingAppointment.value || existingLookupError.value) return
|
if (String(form.oceanBookingId || '') !== oceanBookingId || existingLookupLoading.value || existingAppointment.value || existingLookupError.value) return
|
||||||
saving.value = true; error.value = null
|
saving.value = true; error.value = null
|
||||||
try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) }
|
try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) }
|
||||||
catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') }
|
catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') }
|
||||||
finally { saving.value = false }
|
finally { saving.value = false }
|
||||||
}
|
}
|
||||||
watch(() => form.containerBookingId, (value) => { void lookupExistingAppointment(String(value || ''), Boolean(initialContainerBookingId && String(value || '') === initialContainerBookingId)) }, { immediate: true })
|
watch(() => form.oceanBookingId, (value) => { void lookupExistingAppointment(String(value || ''), Boolean(initialOceanBookingId && String(value || '') === initialOceanBookingId)) }, { immediate: true })
|
||||||
defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, existingLookupError, checkedContainerBookingId })
|
defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, existingLookupError, checkedOceanBookingId })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AdminPage as="div" class="truck-editor-page">
|
<AdminPage as="div" class="truck-editor-page">
|
||||||
<DetailPageLayout aria-label="新增拖车预约">
|
<DetailPageLayout aria-label="新增拖车预约">
|
||||||
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
<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>
|
<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.containerBookingId || ''))"><template #details><ApiErrorMeta :details="existingLookupError" /></template></LoadErrorState>
|
<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>
|
<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="[]" />
|
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
|
||||||
</DetailPageLayout>
|
</DetailPageLayout>
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ describe('TruckAppointmentDetailView status contract', () => {
|
|||||||
expect(source).toContain("statuses.indexOf(status) === index")
|
expect(source).toContain("statuses.indexOf(status) === index")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('initializes the required booking and container associations explicitly', () => {
|
it('initializes the required booking association explicitly', () => {
|
||||||
expect(source).toContain("carrierId: null, oceanBookingId: null, containerBookingId: null, plannedLoadingAt: null")
|
expect(source).toContain("carrierId: null, oceanBookingId: null, plannedLoadingAt: null")
|
||||||
|
expect(source).not.toContain('containerBookingId')
|
||||||
expect(source).not.toContain('customerId')
|
expect(source).not.toContain('customerId')
|
||||||
expect(source).toContain('TRUCK_APPOINTMENT_SAVE_FIELDS.map')
|
expect(source).toContain('TRUCK_APPOINTMENT_SAVE_FIELDS.map')
|
||||||
expect(source).toContain('normalizeTruckAppointmentCurrency(result.currency)')
|
expect(source).toContain('normalizeTruckAppointmentCurrency(result.currency)')
|
||||||
@@ -41,10 +42,9 @@ describe('TruckAppointmentDetailView status contract', () => {
|
|||||||
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
|
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('blocks dispatch until the released booking, allocated internal container and frozen documents 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 '确认派车前必须关联该订舱下的柜次'")
|
expect(source).not.toContain('关联柜次')
|
||||||
expect(source).not.toContain("return '确认派车前关联柜次必须录入真实 ISO 集装箱号'")
|
|
||||||
expect(source).toContain("return '确认派车前所选订舱必须填写提单号'")
|
expect(source).toContain("return '确认派车前所选订舱必须填写提单号'")
|
||||||
expect(source).toContain("return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'")
|
expect(source).toContain("return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'")
|
||||||
expect(source).toContain('getOceanBooking(resource.value.oceanBookingId)')
|
expect(source).toContain('getOceanBooking(resource.value.oceanBookingId)')
|
||||||
@@ -52,13 +52,10 @@ describe('TruckAppointmentDetailView status contract', () => {
|
|||||||
expect(source).not.toContain("item.attachmentType === 'BILL_OF_LADING'")
|
expect(source).not.toContain("item.attachmentType === 'BILL_OF_LADING'")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('collects a real ISO container number only when dispatched work starts pickup', () => {
|
it('keeps pickup booking-scoped and leaves real container maintenance to container/preload flows', () => {
|
||||||
expect(source).toContain("resource.value?.status === 'DISPATCHED' && status === 'PICKUP_IN_PROGRESS'")
|
expect(source).not.toContain('ElMessageBox.prompt')
|
||||||
expect(source).toContain("ElMessageBox.prompt('提柜后可填写,也可稍后在预装补录。'")
|
expect(source).not.toContain('normalizeIso6346ContainerNumber')
|
||||||
expect(source).toContain('inputValue: normalizeIso6346ContainerNumber(resource.value.containerNo)')
|
expect(source).toContain('performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0 })')
|
||||||
expect(source).toContain("inputValidator: (value) => !normalizeIso6346ContainerNumber(value) || isIso6346ContainerNumber(value) || '请输入有效的 ISO 6346 集装箱号'")
|
|
||||||
expect(source).toContain('return { containerNo: normalizeIso6346ContainerNumber(value) || null }')
|
|
||||||
expect(source).toContain('performTruckAppointmentAction(id.value, action, { expectedVersion: form.expectedVersion || 0, ...actionPayload })')
|
|
||||||
expect(source).toContain('await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`')
|
expect(source).toContain('await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,13 @@ import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointm
|
|||||||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||||||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||||
import { TRUCK_APPOINTMENT_SAVE_FIELDS, normalizeTruckAppointmentCurrency, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
|
import { TRUCK_APPOINTMENT_SAVE_FIELDS, normalizeTruckAppointmentCurrency, truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
|
||||||
import { isIso6346ContainerNumber, normalizeIso6346ContainerNumber } 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, plannedLoadingAt: null })
|
||||||
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 })
|
|
||||||
const history = ref<TruckAppointmentStatusHistory[]>([])
|
const history = ref<TruckAppointmentStatusHistory[]>([])
|
||||||
const bookingReadiness = ref<OceanBookingDetail | null>(null)
|
const bookingReadiness = ref<OceanBookingDetail | null>(null)
|
||||||
const relatedError = ref('')
|
const relatedError = ref('')
|
||||||
let activatedOnce = false
|
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 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() {
|
async function loadRelated() {
|
||||||
if (!resource.value) return
|
if (!resource.value) return
|
||||||
relatedError.value = ''
|
relatedError.value = ''
|
||||||
@@ -50,28 +49,17 @@ const dispatchBlockReason = computed(() => {
|
|||||||
if (!resource.value || !['PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value.status)) return ''
|
if (!resource.value || !['PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value.status)) return ''
|
||||||
if (!form.oceanBookingId) return '确认派车前必须关联已放舱订舱'
|
if (!form.oceanBookingId) return '确认派车前必须关联已放舱订舱'
|
||||||
if (bookingReadiness.value && bookingReadiness.value.status !== 'RELEASED') return '确认派车前必须关联已放舱订舱'
|
if (bookingReadiness.value && bookingReadiness.value.status !== 'RELEASED') return '确认派车前必须关联已放舱订舱'
|
||||||
if (!form.containerBookingId) return '确认派车前必须关联该订舱下的柜次'
|
|
||||||
if (!form.billOfLadingNo?.trim()) return '确认派车前所选订舱必须填写提单号'
|
if (!form.billOfLadingNo?.trim()) return '确认派车前所选订舱必须填写提单号'
|
||||||
if (bookingReadiness.value && !bookingReadiness.value.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'
|
if (bookingReadiness.value && !bookingReadiness.value.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '确认派车前所选订舱必须上传有效的订舱确认文件或提单文件'
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
async function confirmStatusChange(status: TruckAppointmentStatus) {
|
async function confirmStatusChange(status: TruckAppointmentStatus) {
|
||||||
if (resource.value?.status === 'DISPATCHED' && status === 'PICKUP_IN_PROGRESS') {
|
|
||||||
const { value } = await ElMessageBox.prompt('提柜后可填写,也可稍后在预装补录。', '开始提柜', {
|
|
||||||
confirmButtonText: '确认提柜', cancelButtonText: '取消', type: 'warning',
|
|
||||||
inputValue: normalizeIso6346ContainerNumber(resource.value.containerNo),
|
|
||||||
inputPlaceholder: '选填,例如 MSCU6639870',
|
|
||||||
inputValidator: (value) => !normalizeIso6346ContainerNumber(value) || isIso6346ContainerNumber(value) || '请输入有效的 ISO 6346 集装箱号',
|
|
||||||
})
|
|
||||||
return { containerNo: normalizeIso6346ContainerNumber(value) || null }
|
|
||||||
}
|
|
||||||
await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
|
await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
|
||||||
return {}
|
|
||||||
}
|
}
|
||||||
async function changeStatus(status: TruckAppointmentStatus) {
|
async function changeStatus(status: TruckAppointmentStatus) {
|
||||||
if (!resource.value || status === resource.value.status || statusSaving.value) return
|
if (!resource.value || status === resource.value.status || statusSaving.value) return
|
||||||
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; return }
|
if (status === 'DISPATCHED' && dispatchBlockReason.value) { error.value = { message: dispatchBlockReason.value }; return }
|
||||||
try { const actionPayload = 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, ...actionPayload }) } 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)
|
onMounted(load)
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
@@ -89,7 +77,7 @@ onActivated(() => {
|
|||||||
<section class="truck-overview surface-panel" aria-label="拖车预约概览">
|
<section class="truck-overview surface-panel" aria-label="拖车预约概览">
|
||||||
<div class="truck-overview__heading">
|
<div class="truck-overview__heading">
|
||||||
<el-button link type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
|
<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>
|
<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 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>
|
</div>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ vi.mock('@/modules/iam', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const row: TruckAppointmentResource = {
|
const row: TruckAppointmentResource = {
|
||||||
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', orderNo: 'ORD-1', oceanBookingNo: 'OB-1', containerBookingId: '77', cabinetNo: 'CAB-1',
|
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', orderNo: 'ORD-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1',
|
||||||
containerNo: 'CONT-1', warehouseName: '义乌仓', shippingCompanyName: '船公司A', bookingAgentName: '代理A', carrierName: '宁波运输', driverName: '王强', vehicleNo: '浙B12345',
|
warehouseName: '义乌仓', shippingCompanyName: '船公司A', bookingAgentName: '代理A', carrierName: '宁波运输', driverName: '王强', vehicleNo: '浙B12345',
|
||||||
plannedLoadingAt: '2026-08-10T10:00:00', payableAmount: '3200.00', currency: 'RMB', attachmentCount: 2, attachments: [],
|
plannedLoadingAt: '2026-08-10T10:00:00', payableAmount: '3200.00', currency: 'RMB', attachmentCount: 2, attachments: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,15 +37,13 @@ describe('TruckAppointmentsView', () => {
|
|||||||
it('loads the real list and renders relationship snapshots', async () => {
|
it('loads the real list and renders relationship snapshots', async () => {
|
||||||
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
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('TA-9001')
|
||||||
expect(wrapper.text()).toContain('CONT-1')
|
|
||||||
expect(wrapper.text()).toContain('CAB-1')
|
|
||||||
expect(wrapper.text()).toContain('OB-1')
|
expect(wrapper.text()).toContain('OB-1')
|
||||||
|
expect(wrapper.text()).toContain('BL-1')
|
||||||
expect(wrapper.text()).toContain('义乌仓')
|
expect(wrapper.text()).toContain('义乌仓')
|
||||||
expect(wrapper.text()).toContain('船公司A')
|
expect(wrapper.text()).toContain('船公司A')
|
||||||
expect(wrapper.text()).toContain('代理A')
|
expect(wrapper.text()).toContain('代理A')
|
||||||
expect(wrapper.text()).not.toContain('77')
|
|
||||||
expect(wrapper.text()).toContain('宁波运输')
|
expect(wrapper.text()).toContain('宁波运输')
|
||||||
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
|
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
@@ -129,7 +127,8 @@ describe('TruckAppointmentsView', () => {
|
|||||||
expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'"))
|
expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'"))
|
||||||
expect(source).not.toContain('receivableAmount')
|
expect(source).not.toContain('receivableAmount')
|
||||||
expect(source).not.toContain('应收拖车费')
|
expect(source).not.toContain('应收拖车费')
|
||||||
expect(source).toContain("row.containerNo || '待补录'")
|
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("toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })")
|
||||||
expect(source).toContain("key: 'attachmentCount'")
|
expect(source).toContain("key: 'attachmentCount'")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,20 +16,16 @@ 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 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 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[]>(() => [
|
const fields = computed<QueryField[]>(() => [
|
||||||
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '派车单号、车牌号、运输公司' },
|
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '派车单号、车牌号、运输公司' },
|
||||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) },
|
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) },
|
||||||
{ key: 'containerType', type: 'select', label: '柜型', placeholder: '全部柜型', options: [
|
|
||||||
{ value: '20GP', label: '20GP' }, { value: '40GP', label: '40GP' }, { value: '40HC', label: '40HC' },
|
|
||||||
] },
|
|
||||||
])
|
])
|
||||||
const columns: DataTableColumn[] = [
|
const columns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'appointmentNo', prop: 'appointmentNo', label: '派车单号', minWidth: 170 },
|
{ key: 'appointmentNo', prop: 'appointmentNo', label: '派车单号', minWidth: 170 },
|
||||||
{ key: 'oceanBookingNo', prop: 'oceanBookingNo', label: '订舱号', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.oceanBookingNo || '—' },
|
{ 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: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 160, formatter: (row: TruckAppointmentResource) => row.billOfLadingNo || '—' },
|
||||||
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: TruckAppointmentResource) => row.containerNo || '待补录' },
|
|
||||||
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.warehouseName || '—' },
|
{ 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: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.shippingCompanyName || '—' },
|
||||||
{ key: 'bookingAgentName', prop: 'bookingAgentName', label: '订舱代理', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.bookingAgentName || '—' },
|
{ key: 'bookingAgentName', prop: 'bookingAgentName', label: '订舱代理', minWidth: 150, formatter: (row: TruckAppointmentResource) => row.bookingAgentName || '—' },
|
||||||
@@ -60,7 +56,7 @@ function statusLabel(value: unknown) {
|
|||||||
return truckAppointmentStatusLabel(typeof value === 'string' ? value : null)
|
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 } }
|
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() })
|
onMounted(() => { void load() })
|
||||||
function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) }
|
function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) }
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -99,9 +99,6 @@ export interface TruckAppointmentResource {
|
|||||||
outboundOrderId?: string | null
|
outboundOrderId?: string | null
|
||||||
oceanBookingId?: string | null
|
oceanBookingId?: string | null
|
||||||
oceanBookingNo?: string | null
|
oceanBookingNo?: string | null
|
||||||
containerBookingId?: string | null
|
|
||||||
cabinetNo?: string | null
|
|
||||||
loadingPlanId?: string | null
|
|
||||||
warehouseId?: string | null
|
warehouseId?: string | null
|
||||||
warehouseName?: string | null
|
warehouseName?: string | null
|
||||||
shippingCompanyId?: string | null
|
shippingCompanyId?: string | null
|
||||||
@@ -136,8 +133,6 @@ export interface TruckAppointmentResource {
|
|||||||
remark?: string | null
|
remark?: string | null
|
||||||
orderId?: string | null
|
orderId?: string | null
|
||||||
orderNo?: string | null
|
orderNo?: string | null
|
||||||
containerNo?: string | null
|
|
||||||
relatedContainerType?: string | null
|
|
||||||
entrustingPartyName?: string | null
|
entrustingPartyName?: string | null
|
||||||
attachments?: TruckAttachmentReference[] | null
|
attachments?: TruckAttachmentReference[] | null
|
||||||
attachmentCount?: number | string | null
|
attachmentCount?: number | string | null
|
||||||
@@ -149,8 +144,6 @@ export interface TruckAppointmentResource {
|
|||||||
export interface SaveTruckAppointmentRequest {
|
export interface SaveTruckAppointmentRequest {
|
||||||
outboundOrderId?: string | null
|
outboundOrderId?: string | null
|
||||||
oceanBookingId: string | null
|
oceanBookingId: string | null
|
||||||
containerBookingId: string | null
|
|
||||||
loadingPlanId?: string | null
|
|
||||||
warehouseId?: string | null
|
warehouseId?: string | null
|
||||||
carrierId: string | null
|
carrierId: string | null
|
||||||
carrierName?: string | null
|
carrierName?: string | null
|
||||||
@@ -181,7 +174,7 @@ export interface SaveTruckAppointmentRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TRUCK_APPOINTMENT_SAVE_FIELDS = [
|
export const TRUCK_APPOINTMENT_SAVE_FIELDS = [
|
||||||
'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId',
|
'outboundOrderId', 'oceanBookingId', 'warehouseId',
|
||||||
'carrierId', 'carrierName', 'billOfLadingNo', 'fleetContact', 'fleetPhone', 'driverName',
|
'carrierId', 'carrierName', 'billOfLadingNo', 'fleetContact', 'fleetPhone', 'driverName',
|
||||||
'driverPhone', 'driverIdCard', 'vehicleNo', 'storeName', 'storeContact', 'storePhone',
|
'driverPhone', 'driverIdCard', 'vehicleNo', 'storeName', 'storeContact', 'storePhone',
|
||||||
'storeAddress', 'storeCity', 'storeZipcode', 'plannedLoadingAt', 'transitMinutes', 'packageCount',
|
'storeAddress', 'storeCity', 'storeZipcode', 'plannedLoadingAt', 'transitMinutes', 'packageCount',
|
||||||
@@ -194,8 +187,7 @@ export interface TruckAppointmentQuery {
|
|||||||
size?: number
|
size?: number
|
||||||
keyword?: string
|
keyword?: string
|
||||||
status?: TruckAppointmentStatus | ''
|
status?: TruckAppointmentStatus | ''
|
||||||
containerBookingId?: string | ''
|
oceanBookingId?: string | ''
|
||||||
containerType?: string | ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TruckAppointmentCandidateQuery {
|
export interface TruckAppointmentCandidateQuery {
|
||||||
@@ -212,15 +204,6 @@ export interface TruckAppointmentCandidate {
|
|||||||
warehouseName: string | null
|
warehouseName: string | null
|
||||||
channelCode: string | null
|
channelCode: string | null
|
||||||
channelName: string | null
|
channelName: string | null
|
||||||
requirementId: string
|
|
||||||
containerType: string
|
|
||||||
requirementQuantity: number
|
|
||||||
containerBookingId: string
|
|
||||||
cabinetNo: string
|
|
||||||
containerNo: string | null
|
|
||||||
loadingPlanId: string | null
|
|
||||||
loadingPlanNo: string | null
|
|
||||||
loadingPlanStatus: string | null
|
|
||||||
bookingDocumentReady: boolean
|
bookingDocumentReady: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +218,6 @@ export interface TruckAppointmentCandidatePage {
|
|||||||
export interface TruckAppointmentActionRequest {
|
export interface TruckAppointmentActionRequest {
|
||||||
expectedVersion: number
|
expectedVersion: number
|
||||||
reason?: string | null
|
reason?: string | null
|
||||||
containerNo?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TruckAppointmentPage {
|
export interface TruckAppointmentPage {
|
||||||
|
|||||||
Reference in New Issue
Block a user