From f7afcb6cdc01aa4e5cc5aa0a7749c9a1f382bbfa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 10:33:43 +0800 Subject: [PATCH] feat(admin): filter booking agents by carrier --- .../api/platformOceanBookings.test.ts | 13 +++- .../api/platformOceanBookings.ts | 7 +- .../ocean-bookings/bookingAgents.test.ts | 19 ++--- src/modules/ocean-bookings/bookingAgents.ts | 10 +-- .../pages/OceanBookingDetailView.test.ts | 23 ++++++- .../pages/OceanBookingDetailView.vue | 69 +++++++++++++++---- src/modules/ocean-bookings/types.ts | 11 ++- 7 files changed, 118 insertions(+), 34 deletions(-) diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts index 05c697de..d5900515 100644 --- a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts +++ b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { createOceanBooking, deleteOceanBookingDraft, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings' +import { createOceanBooking, deleteOceanBookingDraft, listOceanBookingAgents, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings' import { http } from '@/shared/api/http' const detailResponse = (version = 0, status = 'DRAFT') => ({ @@ -68,4 +68,15 @@ describe('ocean bookings api', () => { await expect(listWarehouseLogisticsChannels('8')).resolves.toEqual([{ channelCode: 'SEA_FCL', channelName: '海运整箱' }]) expect(http.get).toHaveBeenCalledWith('/platform/v1/ocean-bookings/channels', { params: { originWarehouseId: '8' } }) }) + + it('loads only server-filtered booking agents for the selected carrier and preserves long ids', async () => { + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ supplierId: 9007199254740993n, name: '代理A', carrierCodes: ['MATSON', 'COSCO'] }] }) + await expect(listOceanBookingAgents('MATSON')).resolves.toEqual([{ supplierId: '9007199254740993', name: '代理A', carrierCodes: ['MATSON', 'COSCO'] }]) + expect(http.get).toHaveBeenCalledWith('/platform/v1/ocean-bookings/booking-agents', { params: { carrierCode: 'MATSON' } }) + }) + + it('treats an empty server candidate response as a successful empty list', async () => { + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] }) + await expect(listOceanBookingAgents('HEDE')).resolves.toEqual([]) + }) }) diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.ts b/src/modules/ocean-bookings/api/platformOceanBookings.ts index 374ae1ef..d9ee1ab2 100644 --- a/src/modules/ocean-bookings/api/platformOceanBookings.ts +++ b/src/modules/ocean-bookings/api/platformOceanBookings.ts @@ -1,5 +1,5 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { OceanBookingDetail, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types' +import type { OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types' const basePath = '/platform/v1/ocean-bookings' @@ -16,6 +16,7 @@ function booking(raw: OceanBookingResource): OceanBookingResource { version: Number(item.version ?? 0), customerId: raw.customerId == null ? null : String(raw.customerId), shippingCompanyId: raw.shippingCompanyId == null ? null : String(raw.shippingCompanyId), + carrierCode: raw.carrierCode ?? null, bookingAgentId: raw.bookingAgentId == null ? null : String(raw.bookingAgentId), containerCount: Number(raw.containerCount ?? 0), pol: raw.pol ?? raw.departurePortName ?? null, @@ -91,4 +92,8 @@ export async function listWarehouseLogisticsChannels(warehouseId: string): Promi const response = await http.get, ApiEnvelope>(`${basePath}/channels`, { params: { originWarehouseId: warehouseId } }) return (unwrapApiResponse(response) || []).map((item) => ({ channelCode: String(item.channelCode), channelName: String(item.channelName) })) } +export async function listOceanBookingAgents(carrierCode: OceanBookingCarrierCode): Promise { + const response = await http.get, ApiEnvelope>(`${basePath}/booking-agents`, { params: { carrierCode } }) + return (unwrapApiResponse(response) || []).map((item) => ({ ...item, supplierId: String(item.supplierId), name: String(item.name), carrierCodes: [...(item.carrierCodes || [])] })) +} export type { OceanBookingResource, OceanBookingStatus } diff --git a/src/modules/ocean-bookings/bookingAgents.test.ts b/src/modules/ocean-bookings/bookingAgents.test.ts index de91639c..c70c2171 100644 --- a/src/modules/ocean-bookings/bookingAgents.test.ts +++ b/src/modules/ocean-bookings/bookingAgents.test.ts @@ -1,15 +1,18 @@ import { describe, expect, it } from 'vitest' -import { isActiveBookingAgent } from './bookingAgents' +import { shouldClearBookingAgent } from './bookingAgents' describe('ocean booking agent candidates', () => { - it('accepts an active booking supplier', () => { - expect(isActiveBookingAgent({ category: 'BOOKING', status: 'ACTIVE' })).toBe(true) + const options = [{ supplierId: '11', name: '代理A', carrierCodes: ['MATSON', 'COSCO'] as const }] + + it('keeps an agent returned by the server-filtered carrier candidates', () => { + expect(shouldClearBookingAgent('11', options.map((item) => ({ ...item, carrierCodes: [...item.carrierCodes] })))).toBe(false) }) - it.each([ - { category: 'BOOKING', status: 'INACTIVE' }, - { category: 'TRUCKING', status: 'ACTIVE' }, - ])('rejects a non-eligible supplier: %o', (supplier) => { - expect(isActiveBookingAgent(supplier)).toBe(false) + it('clears an agent missing from the newly selected carrier candidates', () => { + expect(shouldClearBookingAgent('12', options.map((item) => ({ ...item, carrierCodes: [...item.carrierCodes] })))).toBe(true) + }) + + it('does not clear an empty selection', () => { + expect(shouldClearBookingAgent(null, [])).toBe(false) }) }) diff --git a/src/modules/ocean-bookings/bookingAgents.ts b/src/modules/ocean-bookings/bookingAgents.ts index 3fcdab2d..982ad619 100644 --- a/src/modules/ocean-bookings/bookingAgents.ts +++ b/src/modules/ocean-bookings/bookingAgents.ts @@ -1,9 +1,5 @@ -export interface BookingAgentCandidate { - category: string - status: string -} +import type { OceanBookingAgentOption } from './types' -export function isActiveBookingAgent(candidate: BookingAgentCandidate) { - return candidate.category === 'BOOKING' - && candidate.status === 'ACTIVE' +export function shouldClearBookingAgent(currentSupplierId: string | null | undefined, options: OceanBookingAgentOption[]) { + return Boolean(currentSupplierId) && !options.some((option) => option.supplierId === String(currentSupplierId)) } diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts index 6521e0f2..ea5f1b8a 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts @@ -49,21 +49,40 @@ describe('OceanBookingDetailView draft and release contract', () => { expect(source).toContain('function releaseValidationError()') expect(source).toContain('预计到港时间不能早于预计开船时间') expect(source).toContain("[form.data.customerId, '客户']") + expect(source).toContain("if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'") + expect(source).toContain("return '船公司标准编码尚未保存,请先保存草稿后再放舱'") expect(source).toContain('放舱前请填写${missing[1]}') expect(source).toContain('放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。') expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version })') expect(source).not.toContain('releaseReason') }) - it('keeps warehouse-scoped channel and customer/supplier selectors accessible', () => { + it('freezes the selected carrier standard code into the booking draft', () => { + expect(source).toContain('form.data.carrierCode = partner?.carrierCode ?? null') + expect(source).toContain('carrierCode: value.carrierCode ?? selectedCarrier?.carrierCode ?? null') + expect(source).toContain('form.data.shippingCompanyId && !form.data.carrierCode') + expect(source).toContain('form.data.carrierCode = selected?.carrierCode ?? null') + }) + + it('keeps warehouse-scoped channel and server-filtered booking-agent selectors accessible', () => { expect(source).toContain("warehouseType: 'DOMESTIC', status: 'ACTIVE'") expect(source).toContain('listWarehouseLogisticsChannels') expect(source).toContain('placeholder="草稿可暂不选择"') - expect(source).toContain('暂无启用的订舱代理') + expect(source).toContain('当前船公司暂无可用订舱代理') expect(source).toContain(':label="supplier.name"') expect(source).toContain('filterable clearable') }) + it('loads booking agents from the carrier-filtered backend contract and clears an invalid selection', () => { + expect(source).toContain('listOceanBookingAgents(carrierCode)') + expect(source).toContain('shouldClearBookingAgent(form.data.bookingAgentId, options)') + expect(source).toContain('clearBookingAgent()') + expect(source).toContain("form.data.carrierCode ? '请选择订舱代理' : '请先选择船公司'") + expect(source).toContain('') + expect(source).not.toContain('listSuppliers(') + expect(source).not.toContain('isActiveBookingAgent') + }) + it('keeps dense layout, operation feedback and narrow-screen behavior', () => { expect(source).toContain('') expect(source).toContain('LoadErrorState') diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index f9f586b5..9732cbc2 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -5,7 +5,7 @@ import { useRoute, useRouter } from 'vue-router' import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs' -import { getApiErrorDetails } from '@/shared/api/http' +import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' @@ -17,13 +17,13 @@ import PageToolbar from '@/shared/components/PageToolbar.vue' import { CONTAINER_SPECIFICATIONS } from '@/shared/domain/containerSpecifications' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data' -import { listSuppliers, type Supplier } from '@/modules/suppliers' import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses' import { CarrierScheduleQueryPanel } from '@/modules/port-schedules' import { createOceanBooking, deleteOceanBookingDraft, getOceanBooking, + listOceanBookingAgents, releaseOceanBooking, listWarehouseLogisticsChannels, updateOceanBookingDraft, @@ -31,6 +31,8 @@ import { } from '../api/platformOceanBookings' import type { OceanBookingDetail, + OceanBookingAgentOption, + OceanBookingCarrierCode, OceanBookingDraftData, OceanBookingParty, OceanBookingSaveRequest, @@ -38,7 +40,7 @@ import type { } from '../types' import { oceanBookingStatusLabel } from '../status' import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements' -import { isActiveBookingAgent } from '../bookingAgents' +import { shouldClearBookingAgent } from '../bookingAgents' const partyTypes = [ { value: 'SHIPPER', label: '发货人' }, @@ -65,7 +67,9 @@ const actionError = ref | null>(null) const formRef = ref() const customers = ref([]) const partners = ref([]) -const bookingSuppliers = ref([]) +const bookingAgents = ref([]) +const bookingAgentsLoading = ref(false) +const bookingAgentsError = ref(null) const warehouses = ref([]) const optionsLoading = ref(false) const optionsError = ref('') @@ -74,11 +78,11 @@ const channelsLoading = ref(false) const channelsError = ref('') const selectedChannelName = ref('') let channelRequestSerial = 0 +let bookingAgentRequestSerial = 0 const selectableCustomers = computed(() => customers.value.filter((customer) => ( customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId) ))) const shippingCompanies = computed(() => partners.value.filter((partner) => partner.types.includes('CARRIER'))) -const selectableBookingAgents = computed(() => bookingSuppliers.value.filter(isActiveBookingAgent)) const channelUnavailable = computed(() => Boolean(form.data.warehouseId) && !channelsLoading.value && !channelsError.value && channels.value.length === 0) function blankParties(): OceanBookingParty[] { @@ -88,6 +92,7 @@ function blankData(): OceanBookingDraftData { return { customerId: null, customerName: null, channelCode: '', warehouseId: '', serviceTerm: null, freightTerm: null, parties: blankParties(), billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, + shippingCompanyId: null, shippingCompanyName: null, carrierCode: null, containerRequirements: [createBlankContainerRequirement()], } } @@ -106,18 +111,21 @@ async function loadOptions() { optionsLoading.value = true optionsError.value = '' try { - const [customerResult, partnerResult, supplierResult, warehouseResult] = await Promise.all([ + const [customerResult, partnerResult, warehouseResult] = await Promise.all([ listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }), listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }), - listSuppliers({ page: 1, size: 200, category: 'BOOKING', status: 'ACTIVE' }), listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' }), ]) customers.value = customerResult.records.filter((customer) => customer.status === 'ACTIVE') partners.value = partnerResult.records - bookingSuppliers.value = supplierResult.records.filter(isActiveBookingAgent) + if (form.data.shippingCompanyId && !form.data.carrierCode) { + const selected = partners.value.find((partner) => String(partner.id) === String(form.data.shippingCompanyId)) + form.data.carrierCode = selected?.carrierCode ?? null + } warehouses.value = warehouseResult.records.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC') + if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode) } catch (cause) { - optionsError.value = getApiErrorDetails(cause, '客户、船公司或订舱代理选项加载失败').message + optionsError.value = getApiErrorDetails(cause, '客户、船公司或仓库选项加载失败').message } finally { optionsLoading.value = false } @@ -165,24 +173,53 @@ function selectChannel(channelCode: string) { form.data.channelCode = channelCode selectedChannelName.value = channel?.channelName || '' } +function clearBookingAgent() { + form.data.bookingAgentId = null + form.data.bookingAgentName = null +} +async function loadBookingAgents(carrierCode: OceanBookingCarrierCode) { + const serial = ++bookingAgentRequestSerial + bookingAgentsLoading.value = true + bookingAgentsError.value = null + try { + const options = await listOceanBookingAgents(carrierCode) + if (serial !== bookingAgentRequestSerial || carrierCode !== form.data.carrierCode) return + bookingAgents.value = options + if (editable.value && shouldClearBookingAgent(form.data.bookingAgentId, options)) clearBookingAgent() + } catch (cause) { + if (serial !== bookingAgentRequestSerial) return + bookingAgents.value = [] + bookingAgentsError.value = getApiErrorDetails(cause, '订舱代理候选加载失败') + } finally { + if (serial === bookingAgentRequestSerial) bookingAgentsLoading.value = false + } +} function selectShippingCompany(partnerId: string) { const partner = partners.value.find((item) => String(item.id) === partnerId) form.data.shippingCompanyId = partnerId || null form.data.shippingCompanyName = partner?.partnerName || null + form.data.carrierCode = partner?.carrierCode ?? null + bookingAgents.value = [] + bookingAgentsError.value = null + bookingAgentRequestSerial += 1 + if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode) + else clearBookingAgent() } function selectBookingAgent(supplierId: string) { - const supplier = selectableBookingAgents.value.find((item) => String(item.id) === supplierId) - form.data.bookingAgentId = supplier ? String(supplier.id) : null - form.data.bookingAgentName = supplier?.name || null + const supplier = bookingAgents.value.find((item) => item.supplierId === supplierId) + form.data.bookingAgentId = supplier?.supplierId ?? null + form.data.bookingAgentName = supplier?.name ?? null } function applyDetail(value: OceanBookingDetail) { detail.value = value form.expectedVersion = value.version + const selectedCarrier = partners.value.find((partner) => String(partner.id) === String(value.shippingCompanyId || '')) form.data = { customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '', billOfLadingNo: value.billOfLadingNo || '', vesselName: value.vesselName || '', voyageNo: value.voyageNo || '', estimatedSailingAt: value.estimatedSailingAt || null, estimatedArrivalAt: value.estimatedArrivalAt || null, shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName, + carrierCode: value.carrierCode ?? selectedCarrier?.carrierCode ?? null, bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName, departurePortCode: value.departurePortCode, departurePortName: value.departurePortName, destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName, @@ -194,6 +231,7 @@ function applyDetail(value: OceanBookingDetail) { selectedChannelName.value = value.channelName || '' channels.value = [] if (form.data.warehouseId) void loadChannels(form.data.warehouseId) + if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode) } async function load() { if (!id.value || isNew.value) return @@ -239,6 +277,11 @@ function openReleaseDialog() { releaseDialogOpen.value = true } function releaseValidationError() { + if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码' + if (detail.value && (detail.value.carrierCode !== form.data.carrierCode + || String(detail.value.shippingCompanyId || '') !== String(form.data.shippingCompanyId || ''))) { + return '船公司标准编码尚未保存,请先保存草稿后再放舱' + } const requiredFields: Array<[unknown, string]> = [ [form.data.customerId, '客户'], [form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'], [form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'], @@ -321,7 +364,7 @@ onMounted(() => { void load(); void loadOptions() }) {{ channel.channelName }}{{ channel.channelCode }}
{{ channelsError }}重新加载
该仓库暂无有效物流渠道,请更换仓库或联系管理员。
- +
重新加载
diff --git a/src/modules/ocean-bookings/types.ts b/src/modules/ocean-bookings/types.ts index add33cb4..215cf1d2 100644 --- a/src/modules/ocean-bookings/types.ts +++ b/src/modules/ocean-bookings/types.ts @@ -1,5 +1,12 @@ export type OceanBookingStatus = 'DRAFT' | 'RELEASED' export type OceanBookingContainerType = '20GP' | '40GP' | '40HC' | '40NR' | '45HC' +export type OceanBookingCarrierCode = 'MATSON' | 'SML' | 'ZIM' | 'COSCO' | 'EMC' | 'HEDE' | 'OTHER' + +export interface OceanBookingAgentOption { + supplierId: string + name: string + carrierCodes: OceanBookingCarrierCode[] +} export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null } export interface OceanBookingContainerRequirement { @@ -27,7 +34,7 @@ export interface OceanBookingDraftData { channelCode: string | null; warehouseId: string | null billOfLadingNo: string; vesselName: string; voyageNo: string estimatedSailingAt: string | null; estimatedArrivalAt: string | null - shippingCompanyId?: string | null; shippingCompanyName?: string | null; bookingAgentId?: string | null; bookingAgentName?: string | null + shippingCompanyId?: string | null; shippingCompanyName?: string | null; carrierCode?: OceanBookingCarrierCode | null; bookingAgentId?: string | null; bookingAgentName?: string | null departurePortCode?: string | null; departurePortName?: string | null; destinationPortCode?: string | null; destinationPortName?: string | null placeOfDelivery?: string | null; serviceTerm: string | null; freightTerm: string | null remark?: string | null @@ -38,7 +45,7 @@ export interface OceanBookingResource { /** 订舱柜型需求 quantity 的总和,不是已关联真实柜数。 */ shipperName: string | null; consigneeName: string | null; containerCount: number channelCode: string | null; channelName: string | null; warehouseId: string | null; warehouseName: string | null - shippingCompanyId: string | null; shippingCompanyName: string | null; bookingAgentId: string | null; bookingAgentName: string | null + shippingCompanyId: string | null; shippingCompanyName: string | null; carrierCode: OceanBookingCarrierCode | null; bookingAgentId: string | null; bookingAgentName: string | null vesselName: string | null; voyageNo: string | null; departurePortCode: string | null; departurePortName: string | null destinationPortCode: string | null; destinationPortName: string | null; placeOfDelivery: string | null serviceTerm: string | null; freightTerm: string | null; estimatedSailingAt: string | null; estimatedArrivalAt: string | null