From 78a0d7916c55a37eb749a74a1ca4d3874f2f3474 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 16 Sep 2026 11:06:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- .../api/platformContainerLoading.ts | 8 +++++ .../SmartLoadingCandidateInbounds.test.ts | 14 +++++++++ .../SmartLoadingCandidateInbounds.vue | 23 +++++++++++---- .../pages/ContainersView.test.ts | 20 ++++++------- .../pages/ContainersView.vue | 10 +++---- .../pages/GlobalSmartLoadingView.test.ts | 26 +++++++++++++++++ .../pages/GlobalSmartLoadingView.vue | 12 ++++++-- .../pages/SmartLoadingWorkbenchView.test.ts | 29 ++++++++++++++++++- .../pages/SmartLoadingWorkbenchView.vue | 12 +++++--- .../smartLoadingPlanGroups.test.ts | 1 + src/modules/container-loading/types.ts | 4 +++ .../finance/pages/FinanceG3G4Views.test.ts | 2 +- .../suppliers/api/platformSuppliers.test.ts | 9 +++++- .../suppliers/api/platformSuppliers.ts | 2 +- .../suppliers/pages/SupplierDetailView.vue | 3 +- .../suppliers/pages/SupplierFormView.vue | 18 ++++++++---- .../pages/SupplierRouteReuse.behavior.test.ts | 22 +++++++++++++- src/modules/suppliers/pages/SuppliersView.vue | 13 +++++---- src/modules/suppliers/types.ts | 7 +++++ .../TruckAppointmentForm.behavior.test.ts | 5 ++-- .../components/TruckAppointmentForm.vue | 6 ++-- 22 files changed, 197 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6168659e..33e9ad0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 | | 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | -| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` | +| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` | | 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 | | 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` | diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index 0cde8afb..1f8bef5d 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -159,6 +159,14 @@ function plan(raw: SmartLoadingPlan): SmartLoadingPlan { fullPackageCount: numberValue(group.fullPackageCount), memberInboundIds: (group.memberInboundIds || []).map(id), })), + palletMembers: (raw.palletMembers || []).map((pallet) => ({ + palletId: id(pallet.palletId), + members: (pallet.members || []).map((member) => ({ + ...member, + accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId), + availablePackageCount: numberValue(member.availablePackageCount), + })), + })), items: (raw.items || []).map(item), } } diff --git a/src/modules/container-loading/components/SmartLoadingCandidateInbounds.test.ts b/src/modules/container-loading/components/SmartLoadingCandidateInbounds.test.ts index 3bc4aff2..b47c956c 100644 --- a/src/modules/container-loading/components/SmartLoadingCandidateInbounds.test.ts +++ b/src/modules/container-loading/components/SmartLoadingCandidateInbounds.test.ts @@ -15,4 +15,18 @@ describe('sealed pallet composition', () => { expect(wrapper.text()).not.toContain('13 箱') expect(wrapper.findAll('button')).toHaveLength(0) }) + + it('renders the same composition when it comes from an assigned plan pallet', () => { + const wrapper = mount(SmartLoadingCandidateInbounds, { props: { + sealedPallet: true, + fallbackInboundCode: 'I180', + members: [ + { accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null }, + { accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null }, + ], + } }) + expect(wrapper.get('summary').text()).toBe('2 个入仓码 · 展开') + expect(wrapper.text()).toContain('I180:6 箱') + expect(wrapper.text()).toContain('I181:5 箱') + }) }) diff --git a/src/modules/container-loading/components/SmartLoadingCandidateInbounds.vue b/src/modules/container-loading/components/SmartLoadingCandidateInbounds.vue index 5bd4645d..9c624d88 100644 --- a/src/modules/container-loading/components/SmartLoadingCandidateInbounds.vue +++ b/src/modules/container-loading/components/SmartLoadingCandidateInbounds.vue @@ -1,15 +1,26 @@ diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 5df9d95f..490ac617 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -467,7 +467,7 @@ describe('ContainersView', () => { }) it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => { - vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 }) const router = createTestRouter() await router.push('/'); await router.isReady() const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } }) @@ -514,11 +514,11 @@ describe('ContainersView', () => { const oldSearch = vm.searchTerminalFleets('旧关键词') const latestSearch = vm.searchTerminalFleets('新车队') - expect(listSuppliers).toHaveBeenNthCalledWith(1, { page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: '旧关键词' }) - expect(listSuppliers).toHaveBeenNthCalledWith(2, { page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: '新车队' }) - resolveLatest({ records: [{ id: '503', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 }) + expect(listSuppliers).toHaveBeenNthCalledWith(1, { page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '旧关键词' }) + expect(listSuppliers).toHaveBeenNthCalledWith(2, { page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '新车队' }) + resolveLatest({ records: [{ id: '503', name: '新车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never, { id: '504', name: '国内车队', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' } as never], page: 1, size: 20, total: 2, pages: 1 }) await latestSearch - resolveOld({ records: [{ id: '502', name: '旧车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 }) + resolveOld({ records: [{ id: '502', name: '旧车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 }) await oldSearch expect(vm.terminalFleetOptions).toEqual([expect.objectContaining({ id: '503', name: '新车队' })]) wrapper.unmount() @@ -742,7 +742,7 @@ describe('ContainersView', () => { }) it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => { - vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 }) vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never) vi.mocked(updateContainer).mockResolvedValue({} as never) const router = createTestRouter() @@ -762,7 +762,7 @@ describe('ContainersView', () => { }) it('clears the trucking supplier id and name together', async () => { - vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 }) vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never) vi.mocked(updateContainer).mockResolvedValue({} as never) const router = createTestRouter() @@ -822,9 +822,9 @@ describe('ContainersView trucking supplier contract', () => { it('uses active TRUCKING suppliers without allowing free-text carrier names', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') - expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })") - expect(source).toContain("page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: keyword.trim() || undefined") - expect(source).toContain("supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE'") + expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' })") + expect(source).toContain("page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: keyword.trim() || undefined") + expect(source).toContain("supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE'") expect(source).toContain('filterable remote reserve-keyword remote-show-suffix') expect(source).toContain(':remote-method="searchTerminalFleets"') expect(source).toContain('selectTruckingSupplier') diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index ce8b21a8..d9c9283d 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -197,7 +197,7 @@ const canViewPreload = computed(() => access.can('platform:firstleg:preload:list const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list')) const editorTitle = computed(() => form.id ? '编辑' : '新增') const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER'))) -const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING')) +const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS')) interface PortOption { value: string; code: string | null; name: string | null } function portValue(code: string | null | undefined, name: string | null | undefined) { return code || name ? JSON.stringify([code || '', name || '']) : '' } function collectPorts(direction: 'departure' | 'destination'): PortOption[] { @@ -465,8 +465,8 @@ async function loadTruckingSuppliers(): Promise { if (truckingSuppliersLoaded) return truckingSuppliers.value if (truckingSuppliersPromise) return truckingSuppliersPromise truckingSuppliersPromise = (async () => { - const result = await listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }) - const records = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE') + const result = await listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }) + const records = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE') truckingSuppliers.value = records truckingSuppliersLoaded = true return records @@ -479,10 +479,10 @@ async function searchTerminalFleets(keyword = '') { terminalFleetLoading.value = true try { const result = await listSuppliers({ - page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: keyword.trim() || undefined, + page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: keyword.trim() || undefined, }) if (sequence !== terminalFleetSearchSequence) return - terminalFleetOptions.value = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE') + terminalFleetOptions.value = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE') } catch (error) { if (sequence !== terminalFleetSearchSequence) return terminalFleetOptions.value = [] diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts index 474f2906..6985904f 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts @@ -77,6 +77,7 @@ const plan = { canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const, pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], + palletMembers: [], items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }], } const candidate = { @@ -953,6 +954,31 @@ describe('GlobalSmartLoadingView', () => { expect(removeSmartLoadingItem).not.toHaveBeenCalled() }) + it('shows every inbound code and its box count for an assigned sealed pallet', async () => { + const palletItem = { + ...plan.items[0], inboundId: '180', inboundCode: 'I180', unitType: 'SEALED_PALLET' as const, + palletId: '801', palletNo: 'DP-260914-0025', indivisible: true, packageCount: 11, + pickedPackageCount: 0, minPackageCount: 11, canAdjust: false, canRemove: true, + } + vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ + ...plan, + items: [palletItem], + palletMembers: [{ palletId: '801', members: [ + { accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null }, + { accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null }, + ] }], + }) + + const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } }) + await flushPromises() + + const row = wrapper.findAll('.plan-pane .el-table__body-wrapper tr').find((candidateRow) => candidateRow.text().includes('DP-260914-0025')) + expect(row).toBeDefined() + expect(row!.text()).toContain('2 个入仓码 · 展开') + expect(row!.text()).toContain('I180:6 箱') + expect(row!.text()).toContain('I181:5 箱') + }) + it('renders final completion capability as read-only with the server reason', async () => { vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue index 2ff69267..97e7f145 100644 --- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue +++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue @@ -31,6 +31,7 @@ import { } from '../api/platformContainerLoading' import type { SmartLoadingCandidate, + SmartLoadingCandidateGroupMember, SmartLoadingPlan, SmartLoadingWorkbenchContainer, } from '../types' @@ -146,7 +147,10 @@ const displayedCandidates = computed(() => { } }) }) -type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] } +const planPalletMembersByPalletId = computed(() => new Map( + (plan.value?.palletMembers || []).map((entry) => [entry.palletId, entry.members]), +)) +type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[]; memberOrderSummaries: string[] } const displayedPlanItems = computed(() => { const inboundRows = groupSmartLoadingItems(plan.value?.items || []) const result: SmartLoadingPlanRow[] = [] @@ -154,7 +158,7 @@ const displayedPlanItems = computed(() => { for (const row of inboundRows) { const group = planGroupsByInboundId.value.get(row.inboundId) if (!group) { - result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) }) + result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) }) continue } grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row]) @@ -178,6 +182,7 @@ const displayedPlanItems = computed(() => { sourceItems: rows.flatMap((row) => row.sourceItems), detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0), coLoadGroup: group, + palletMembers: [], memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'), memberOrderSummaries: memberOrderSummaries(rows), }) @@ -243,7 +248,7 @@ const candidateColumns: DataTableColumn[] = [ ] const itemColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, - { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true }, + { key: 'inboundCode', label: '入仓码', minWidth: 220, fixed: 'left', slotName: 'planInbounds' }, { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true }, { key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' }, @@ -821,6 +826,7 @@ onMounted(() => { void load(); void loadCustomers() }) + diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts index 837afef6..0f9d9e48 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts @@ -29,7 +29,7 @@ const container = { const planResponse = { id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2, canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const, - pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], items: [], + pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], palletMembers: [], items: [], } const candidate = { accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null } function candidatePage(records: unknown[]) { @@ -78,6 +78,33 @@ describe('SmartLoadingWorkbenchView', () => { expect(wrapper.text()).toContain('150% 容量上限') }) + it('shows the full inbound composition for an assigned sealed pallet', async () => { + vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ + ...planResponse, + items: [{ + id: '101', loadingPlanId: '99', accountId: '1', inboundId: '180', inboundDetailId: '201', orderId: '280', + orderNo: 'O180', inboundCode: 'I180', customerName: '客户', destinationWarehouseId: '9', + destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '货物', + unitType: 'SEALED_PALLET', palletId: '801', palletNo: 'DP-260914-0025', indivisible: true, + packageCount: 11, productQuantity: 110, grossWeightKg: 109.2, volumeCbm: 1.87, loadSequence: 1, + version: 1, pickedPackageCount: 0, minPackageCount: 11, canAdjust: false, canRemove: true, + }], + palletMembers: [{ palletId: '801', members: [ + { accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null }, + { accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null }, + ] }], + }) + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] }) + await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady() + const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + + const row = wrapper.findAll('.el-table__body-wrapper tr').find((candidateRow) => candidateRow.text().includes('DP-260914-0025')) + expect(row).toBeDefined() + expect(row!.text()).toContain('I180:6 箱') + expect(row!.text()).toContain('I181:5 箱') + }) + it('uses loading plan capabilities to keep PDA increments editable', async () => { vi.mocked(getContainer).mockResolvedValue({ ...container, planStatus: 'LOADING' }) vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...planResponse, status: 'LOADING', pdaTaskId: '700', pdaTaskStatus: 'LOADING', pdaTaskVersion: 4 }) diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue index 31ab8689..65bbfad8 100644 --- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue +++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue @@ -18,7 +18,7 @@ import { getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem, } from '../api/platformContainerLoading' -import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types' +import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingCandidateGroupMember, SmartLoadingPlan } from '../types' import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems' import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups' import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups' @@ -80,7 +80,10 @@ const displayedCandidates = computed(() => { } }) }) -type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] } +const planPalletMembersByPalletId = computed(() => new Map( + (plan.value?.palletMembers || []).map((entry) => [entry.palletId, entry.members]), +)) +type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[]; memberOrderSummaries: string[] } const displayedPlanItems = computed(() => { const inboundRows = groupSmartLoadingItems(plan.value?.items || []) const result: SmartLoadingPlanRow[] = [] @@ -88,7 +91,7 @@ const displayedPlanItems = computed(() => { for (const row of inboundRows) { const group = planGroupsByInboundId.value.get(row.inboundId) if (!group) { - result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) }) + result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) }) continue } grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row]) @@ -114,6 +117,7 @@ const displayedPlanItems = computed(() => { sourceItems: rows.flatMap((row) => row.sourceItems), detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0), coLoadGroup: group, + palletMembers: [], memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'), memberOrderSummaries: memberOrderSummaries(rows), }) @@ -384,7 +388,7 @@ onMounted(load) - + diff --git a/src/modules/container-loading/smartLoadingPlanGroups.test.ts b/src/modules/container-loading/smartLoadingPlanGroups.test.ts index 3a87e604..8d37b6a7 100644 --- a/src/modules/container-loading/smartLoadingPlanGroups.test.ts +++ b/src/modules/container-loading/smartLoadingPlanGroups.test.ts @@ -15,6 +15,7 @@ const plan = { pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [{ groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2, inboundFragmentCount: 2, fullPackageCount: 12, memberInboundIds: ['201', '202'] }], + palletMembers: [], items: [item, { ...item, id: '702', inboundId: '202', inboundDetailId: '802', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', packageCount: 7 }], } satisfies SmartLoadingPlan diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts index 0b0ed760..be31b11c 100644 --- a/src/modules/container-loading/types.ts +++ b/src/modules/container-loading/types.ts @@ -454,5 +454,9 @@ export interface SmartLoadingPlan { fullPackageCount: number memberInboundIds: string[] }> + palletMembers: Array<{ + palletId: string + members: SmartLoadingCandidateGroupMember[] + }> items: SmartLoadingItem[] } diff --git a/src/modules/finance/pages/FinanceG3G4Views.test.ts b/src/modules/finance/pages/FinanceG3G4Views.test.ts index a0fb8923..cd403677 100644 --- a/src/modules/finance/pages/FinanceG3G4Views.test.ts +++ b/src/modules/finance/pages/FinanceG3G4Views.test.ts @@ -79,7 +79,7 @@ describe('Finance G3/G4 views', () => { document.body.innerHTML = '' const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 }) - vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', name: '拖车供应商A', category: 'TRUCKING', carrierCodes: [], scope: 'DOMESTIC', region: '宁波', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-01', updatedBy: null, updatedAt: '2026-08-01', deleted: false, version: 0 } as Supplier], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', name: '拖车供应商A', category: 'TRUCKING', truckingScope: 'DOMESTIC', carrierCodes: [], scope: 'DOMESTIC', region: '宁波', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-01', updatedBy: null, updatedAt: '2026-08-01', deleted: false, version: 0 } as Supplier], page: 1, size: 200, total: 1, pages: 1 }) vi.mocked(listFinanceStatements).mockReset().mockResolvedValue([statement]) vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(statementDetail) vi.mocked(actionReconciliationStatement).mockReset().mockResolvedValue(statementDetail) diff --git a/src/modules/suppliers/api/platformSuppliers.test.ts b/src/modules/suppliers/api/platformSuppliers.test.ts index eed51af9..592dee42 100644 --- a/src/modules/suppliers/api/platformSuppliers.test.ts +++ b/src/modules/suppliers/api/platformSuppliers.test.ts @@ -8,7 +8,7 @@ vi.mock('@/shared/api/http', async (original) => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() }, })) -const payload: SaveSupplierRequest = { name: '测试供应商', category: 'BOOKING', carrierCodes: ['MATSON', 'SML'], region: '宁波', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: '9007199254740995', status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null } +const payload: SaveSupplierRequest = { name: '测试供应商', category: 'BOOKING', truckingScope: null, carrierCodes: ['MATSON', 'SML'], region: '宁波', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: '9007199254740995', status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null } const resource = { id: 9007199254740993, tenantId: 0, ...payload, createdBy: 9007199254740994, createdAt: '2026-08-13T10:00:00', updatedBy: null, updatedAt: '2026-08-13T10:00:00', deleted: false, version: 1 } describe('platform supplier API', () => { @@ -37,4 +37,11 @@ describe('platform supplier API', () => { expect(http.put).toHaveBeenCalledWith('/platform/v1/suppliers/77', payload, { params: { tenantId: 0 } }) expect(http.patch).toHaveBeenCalledWith('/platform/v1/suppliers/77/status', { status: 'INACTIVE' }, { params: { tenantId: 0 } }) }) + it('passes the trucking scope filter and keeps the authoritative type in resources', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ ...resource, category: 'TRUCKING', carrierCodes: [], truckingScope: 'OVERSEAS' }], page: 1, size: 20, total: 1, pages: 1 } }) + const page = await listSuppliers({ category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/suppliers', { params: expect.objectContaining({ category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }) }) + expect(page.records[0]?.truckingScope).toBe('OVERSEAS') + }) + }) diff --git a/src/modules/suppliers/api/platformSuppliers.ts b/src/modules/suppliers/api/platformSuppliers.ts index dedb058d..0b19f7bf 100644 --- a/src/modules/suppliers/api/platformSuppliers.ts +++ b/src/modules/suppliers/api/platformSuppliers.ts @@ -21,7 +21,7 @@ export async function listSuppliers(query: SupplierQuery): Promise page: query.page ?? 1, size: query.size ?? 20, category: query.category, region: query.region?.trim() || undefined, status: query.status || undefined, keyword: query.keyword?.trim() || undefined, - carrierCode: query.carrierCode || undefined, + carrierCode: query.carrierCode || undefined, truckingScope: query.truckingScope, }, }) const data = unwrapApiResponse(response) diff --git a/src/modules/suppliers/pages/SupplierDetailView.vue b/src/modules/suppliers/pages/SupplierDetailView.vue index 87e1823f..3429080b 100644 --- a/src/modules/suppliers/pages/SupplierDetailView.vue +++ b/src/modules/suppliers/pages/SupplierDetailView.vue @@ -12,7 +12,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { useAccessPreviewStore } from '@/core/access/accessPreview' import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue' import { getSupplier } from '@/modules/suppliers/api/platformSuppliers' -import { categoryLabels, formatCarrierCodes, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types' +import { formatTruckingScope, categoryLabels, formatCarrierCodes, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types' const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore() const canView = computed(() => access.can('platform:supplier:list')) @@ -21,6 +21,7 @@ const supplier = ref(null); const loading = ref(false); const e let loadSequence = 0 const items = computed(() => supplier.value ? [ ['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['可代理船公司', supplier.value.category === 'BOOKING' ? formatCarrierCodes(supplier.value.carrierCodes) : '—'], ['地区', supplier.value.region], + ...(supplier.value.category === 'TRUCKING' ? [['拖车类型', formatTruckingScope(supplier.value.truckingScope)]] : []), ['法人代表', supplier.value.legalRepresentative], ['联系人', supplier.value.contactName], ['联系电话', supplier.value.phone], ['电子邮箱', supplier.value.email || '—'], ['营业执照号', supplier.value.businessLicenseNo || '—'], ['结算周期', settlementLabels[supplier.value.settlementPeriod]], ['公司地址', supplier.value.address || '—'], ['备注', supplier.value.remark || '—'], ] : []) diff --git a/src/modules/suppliers/pages/SupplierFormView.vue b/src/modules/suppliers/pages/SupplierFormView.vue index 3156a752..121c45de 100644 --- a/src/modules/suppliers/pages/SupplierFormView.vue +++ b/src/modules/suppliers/pages/SupplierFormView.vue @@ -14,7 +14,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' import { createSupplier, getSupplier, updateSupplier } from '@/modules/suppliers/api/platformSuppliers' -import { carrierCodeOptions, categoryLabels, settlementLabels, statusLabels, type SaveSupplierRequest } from '@/modules/suppliers/types' +import { truckingScopeLabels, carrierCodeOptions, categoryLabels, settlementLabels, statusLabels, type TruckingScope, type SaveSupplierRequest } from '@/modules/suppliers/types' const route = useRoute() const router = useRouter() @@ -27,10 +27,13 @@ const loading = ref(false) const saving = ref(false) const loadError = ref(null) const submitError = ref(null) -const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', carrierCodes: [], region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null }) +const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', truckingScope: null, carrierCodes: [], region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null }) const form = reactive(initial()) +const saveAttempted = ref(false) +const truckingScopeError = computed(() => saveAttempted.value && form.category === 'TRUCKING' && !form.truckingScope ? '请选择国内拖车或海外车队' : '') const rules: FormRules = { name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }], category: [{ required: true, message: '请选择供应商类别', trigger: 'change' }], + truckingScope: [{ required: true, type: 'enum', enum: ['DOMESTIC', 'OVERSEAS'], message: '请选择国内拖车或海外车队', trigger: 'change' }], carrierCodes: [{ validator: (_rule, value, callback) => form.category === 'BOOKING' && (!Array.isArray(value) || value.length === 0) ? callback(new Error('请至少选择一家可代理船公司')) : callback(), trigger: 'change' }], region: [{ required: true, message: '请输入地区', trigger: 'blur' }], legalRepresentative: [{ required: true, message: '请输入法人代表', trigger: 'blur' }], contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }], phone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }], @@ -42,11 +45,11 @@ const { captureBaseline } = useUnsavedWorkRegistration({ key: 'supplier-editor', function optional(value: string | null) { return value?.trim() || null } function payload(): SaveSupplierRequest { const { carrierCodes, ...fields } = form - const normalized = { ...fields, name: form.name.trim(), region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) } + const normalized = { ...fields, truckingScope: form.category === 'TRUCKING' ? form.truckingScope : null, name: form.name.trim(), region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) } return form.category === 'BOOKING' ? { ...normalized, carrierCodes: [...(carrierCodes || [])] } : normalized } let loadSequence = 0 -function resetForm() { Object.assign(form, initial()) } +function resetForm() { Object.assign(form, initial()); saveAttempted.value = false } async function load() { const requestedId = supplierId.value const sequence = ++loadSequence @@ -59,7 +62,7 @@ async function load() { try { const data = await getSupplier(requestedId) if (sequence !== loadSequence || requestedId !== supplierId.value) return - Object.assign(form, { name: data.name, category: data.category, carrierCodes: data.category === 'BOOKING' ? [...data.carrierCodes] : [], region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark }) + Object.assign(form, { name: data.name, category: data.category, truckingScope: data.truckingScope, carrierCodes: data.category === 'BOOKING' ? [...data.carrierCodes] : [], region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark }) captureBaseline() } catch (error) { if (sequence === loadSequence && requestedId === supplierId.value) loadError.value = getApiErrorDetails(error, '供应商详情加载失败') @@ -67,6 +70,8 @@ async function load() { } async function save() { if (!access.can(requiredPermission.value)) return + saveAttempted.value = true + if (truckingScopeError.value) return if (!await formRef.value?.validate().catch(() => false)) return saving.value = true; submitError.value = null try { @@ -86,7 +91,7 @@ onActivated(() => { else activatedOnce = true }) onDeactivated(() => { active = false }) -watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carrierCodes = [] }) +watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carrierCodes = []; if (category !== 'TRUCKING') form.truckingScope = null }) + diff --git a/src/modules/suppliers/types.ts b/src/modules/suppliers/types.ts index 35ca52ec..85529c0b 100644 --- a/src/modules/suppliers/types.ts +++ b/src/modules/suppliers/types.ts @@ -1,4 +1,8 @@ export type SupplierCategory = 'BOOKING' | 'CUSTOMS' | 'TRUCKING' | 'CERTIFICATE' +export type TruckingScope = 'DOMESTIC' | 'OVERSEAS' +export const truckingScopeLabels: Record = { DOMESTIC: '国内拖车', OVERSEAS: '海外车队' } +export const truckingScopeOptions = Object.entries(truckingScopeLabels).map(([value, label]) => ({ value, label })) +export function formatTruckingScope(scope: TruckingScope | null) { return scope ? truckingScopeLabels[scope] : '—' } export type SupplierStatus = 'ACTIVE' | 'INACTIVE' export type SettlementPeriod = 'MONTHLY_30' | 'MONTHLY_45' | 'MONTHLY_60' | 'MONTHLY_90' | 'PER_TRANSACTION' | 'IMMEDIATE' export type CarrierCode = 'MATSON' | 'SML' | 'ZIM' | 'COSCO' | 'EMC' | 'HEDE' | 'OTHER' @@ -8,6 +12,7 @@ export interface Supplier { tenantId: string name: string category: SupplierCategory + truckingScope: TruckingScope | null carrierCodes: CarrierCode[] region: string legalRepresentative: string @@ -42,6 +47,7 @@ export interface SupplierQuery { page?: number size?: number category?: SupplierCategory + truckingScope?: TruckingScope region?: string status?: SupplierStatus | '' keyword?: string @@ -51,6 +57,7 @@ export interface SupplierQuery { export interface SaveSupplierRequest { name: string category: SupplierCategory + truckingScope: TruckingScope | null carrierCodes?: CarrierCode[] region: string legalRepresentative: string diff --git a/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts b/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts index ea25044f..275bb8f0 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts +++ b/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts @@ -9,7 +9,7 @@ import { getOceanBooking } from '@/modules/ocean-bookings' import { listTruckAppointmentCandidates } from '../api/platformTrucking' vi.mock('@/modules/suppliers', () => ({ - listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE', contactName: '张调度', phone: '13800000001' }, { id: 'carrier-2', name: '第二车队', category: 'TRUCKING', status: 'ACTIVE', contactName: '李调度', phone: '13800000002' }] }), + listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE', contactName: '张调度', phone: '13800000001' }, { id: 'carrier-2', name: '第二车队', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE', contactName: '李调度', phone: '13800000002' }, { id: 'overseas-1', name: '海外车队候选', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }] }), })) vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), @@ -172,8 +172,9 @@ describe('TruckAppointmentForm booking-only behavior', () => { it('copies supplier contacts and replaces or clears them when switching suppliers', async () => { const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) await flushPromises() - expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }) + expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' }) const supplier = wrapper.findAllComponents({ name: 'ElSelect' })[1]! + expect(supplier.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).not.toContain('overseas-1') supplier.vm.$emit('update:modelValue', 'carrier-1') await flushPromises() expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: 'carrier-1', fleetContact: '张调度', fleetPhone: '13800000001', currency: 'RMB' }) diff --git a/src/modules/trucking/components/TruckAppointmentForm.vue b/src/modules/trucking/components/TruckAppointmentForm.vue index da18140b..a2e49740 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.vue +++ b/src/modules/trucking/components/TruckAppointmentForm.vue @@ -53,7 +53,7 @@ const optionsError = ref('') let bookingLoadSequence = 0 let optionsLoadSequence = 0 const selectableCarriers = computed(() => carriers.value.filter((item) => ( - item.status === 'ACTIVE' + item.status === 'ACTIVE' && item.truckingScope === 'DOMESTIC' ))) const bookings = computed(() => { const result = new Map() @@ -130,11 +130,11 @@ async function loadOptions() { optionsLoading.value = true; optionsError.value = '' candidates.value = [] const results = await Promise.allSettled([ - listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }), + listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' }), relationLocked.value ? Promise.resolve({ records: [] as TruckAppointmentCandidate[] }) : listTruckAppointmentCandidates({ page: 1, size: 200 }), ]) if (sequence !== optionsLoadSequence) return - if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE') + if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE' && item.truckingScope === 'DOMESTIC') if (results[1].status === 'fulfilled') candidates.value = results[1].value.records const failed = ['拖车供应商', '可用订舱'].filter((_, index) => results[index]?.status === 'rejected') if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`