diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index a25c227d..47f6c61b 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -140,6 +140,7 @@ export const adminNavigation: AdminNavigationNode[] = [ children: [ { key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' }, { key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' }, + { key: 'finance-receivable-statistics', title: '应收款统计表', icon: CircleDollarSign, path: '/finance/receivable-statistics', permission: 'platform:finance:receivable-statistics:list' }, { key: 'finance-customer-statements', title: '客户对账单', icon: FileCheck2, path: '/finance/customer-statements', permission: 'platform:finance:customer-statement:list' }, { key: 'finance-settlement-receivables', title: '结算单应收', icon: CircleDollarSign, path: '/finance/settlement-receivables', permission: 'platform:finance:settlement-ar:list' }, { key: 'finance-settlement-payables', title: '结算单应付', icon: Database, path: '/finance/settlement-payables', permission: 'platform:finance:settlement-ap:list' }, diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index 7062a573..64597b7c 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -45,7 +45,7 @@ describe('platform container loading API', () => { it('uses full-edit and transport whitelist request shapes', async () => { vi.mocked(http.put).mockResolvedValue({ code: '200', data: container }) vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container }) - const data = { cabinetNo: 'CB-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null } + const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null } await updateContainer('7', 2, data) await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' }) expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data }) diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 9a59c66a..4f41fc2f 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -255,6 +255,9 @@ describe('ContainersView', () => { expect(source).toContain('CONTAINER_SPECIFICATIONS') expect(source).toContain('selectSimulationContainerType') expect(source).toContain('服务端会生成稳定的柜次号') + expect(source).toContain(':model-value="form.cabinetNo" disabled placeholder="系统生成"') + expect(source).not.toContain('v-model="form.cabinetNo"') + expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()') expect(source).not.toContain('openCreate') expect(source).not.toContain("['20GP','40GP','40HQ','45HQ']") expect(source).not.toContain('v-model="form.capacityCbm"') diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index d33f3c48..f0165d05 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -24,7 +24,7 @@ import { cancelContainerException, completeContainerClearance, createContainerSi import type { ContainerBookingResource, ContainerDocumentStatus, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types' type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] } -type ContainerForm = ContainerSaveRequest & { id: string; version: number } +type ContainerForm = ContainerSaveRequest & Pick & { id: string; version: number } const router = useRouter() const access = useAccessPreviewStore() @@ -108,7 +108,6 @@ function emptyForm(): ContainerForm { } const form = reactive(emptyForm()) const rules: FormRules = { - cabinetNo: [{ required: true, message: '请输入柜次号', trigger: 'blur' }], warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }], containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }], terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(!value || selectedTruckingSupplierId.value ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }], @@ -211,7 +210,7 @@ function replaceForm(source: ContainerBookingResource | null) { function payload(): ContainerSaveRequest { const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId) return { - containerNo: optional(form.containerNo), cabinetNo: form.cabinetNo.trim(), + containerNo: optional(form.containerNo), warehouseId: form.warehouseId, warehouseName: selectedWarehouse?.warehouseName || optional(form.warehouseName), billOfLadingNo: optional(form.billOfLadingNo), vesselName: optional(form.vesselName), voyageNo: optional(form.voyageNo), @@ -629,7 +628,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin 重新加载业务选项 - + diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts index e743f290..505cb9cb 100644 --- a/src/modules/container-loading/types.ts +++ b/src/modules/container-loading/types.ts @@ -184,7 +184,6 @@ export interface ContainerQuery { export interface ContainerSaveRequest { containerNo: string | null - cabinetNo: string warehouseId: string warehouseName: string | null billOfLadingNo: string | null diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts index c575fcc2..88e25760 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts @@ -63,7 +63,8 @@ describe('FinanceCustomerAccountsView delivery', () => { expect(page).not.toContain('class="detail-toolbar"') expect(page).not.toContain(' import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户详情', description: '查看客户派生财务汇总与账户流水', permission: 'platform:finance:customer-account:list', showPageHeading: false } }, { path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:customer-arap:list' } }, { path: 'finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '费用来源详情', description: '查看费用来源、审核进度、关联业务与操作记录', permission: 'platform:finance:customer-arap:list', showPageHeading: false } }, + { path: 'finance/receivable-statistics', name: 'finance-receivable-statistics', component: () => import('./pages/FinanceReceivableStatisticsView.vue'), meta: { title: '应收款统计表', description: '按月份和客户统计 CNY 应收、已收与未收余额', permission: 'platform:finance:receivable-statistics:list' } }, { path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:customer-statement:list' } }, { path: 'finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单详情', description: '查看对账单概要、费用明细、支付历史与调整记录', permission: 'platform:finance:customer-statement:list', showPageHeading: false } }, { path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:list' } }, diff --git a/src/modules/finance/status.test.ts b/src/modules/finance/status.test.ts index ddfa83c8..4bfe5a51 100644 --- a/src/modules/finance/status.test.ts +++ b/src/modules/finance/status.test.ts @@ -29,8 +29,6 @@ describe('finance status presentation', () => { it('translates source and ledger codes without exposing unknown values', () => { expect(financeSourceTypeLabel('CONTAINER_COST')).toBe('整柜成本') expect(financeBusinessTypeLabel('RECEIPT')).toBe('收款') - expect(financeBusinessTypeLabel('FEE_INCLUDED')).toBe('纳入结算') - expect(financeBusinessTypeLabel('FEE_EXCLUDED')).toBe('排除结算') expect(financeBusinessTypeLabel('RECONCILIATION_CREATED')).toBe('生成对账单') expect(financeBusinessTypeLabel('SETTLEMENT_ISSUED')).toBe('结算单生效') expect(financeBusinessTypeLabel('PAYMENT')).toBe('付款') diff --git a/src/modules/finance/status.ts b/src/modules/finance/status.ts index 0fa80ba4..517a9623 100644 --- a/src/modules/finance/status.ts +++ b/src/modules/finance/status.ts @@ -63,15 +63,13 @@ const financeSourceTypeLabels: Record = { INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_FEE: '装柜费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用', } const financeBusinessTypeLabels: Record = { - FEE_INCLUDED: '纳入结算', - FEE_EXCLUDED: '排除结算', RECONCILIATION_CREATED: '生成对账单', SETTLEMENT_ISSUED: '结算单生效', RECEIPT: '收款', PAYMENT: '付款', ADJUSTMENT: '账务调整', VOID: '作废', - // The following values are still returned by older Finance read models. + // The following values are returned by current Finance read models. BILL: '对账单', COST: '成本', SETTLEMENT: '结算', diff --git a/src/modules/loading-lists/api/containerFees.test.ts b/src/modules/loading-lists/api/containerFees.test.ts index ad807b65..6e23331d 100644 --- a/src/modules/loading-lists/api/containerFees.test.ts +++ b/src/modules/loading-lists/api/containerFees.test.ts @@ -31,6 +31,19 @@ describe('container fee API', () => { expect(listSuppliers).toHaveBeenCalledWith({ status: 'ACTIVE', category: 'TRUCKING', page: 1, size: 200 }) }) + it('loads every ACTIVE supplier without a category filter when the fee item category is empty', async () => { + vi.mocked(listSuppliers).mockResolvedValue({ records: [ + { id: '41', name: '承运商A', status: 'ACTIVE', category: 'TRUCKING' }, + { id: '42', name: '停用承运商', status: 'INACTIVE', category: 'TRUCKING' }, + { id: '43', name: '报关行', status: 'ACTIVE', category: 'CUSTOMS' }, + ], page: 1, size: 200, total: 3, pages: 1 } as never) + await expect(listContainerFeeSupplierOptions(null)).resolves.toEqual([ + { id: '41', supplierName: '承运商A', category: 'TRUCKING' }, + { id: '43', supplierName: '报关行', category: 'CUSTOMS' }, + ]) + expect(listSuppliers).toHaveBeenCalledWith({ status: 'ACTIVE', category: undefined, page: 1, size: 200 }) + }) + it('reads allocation rows from Finance and never calculates them', async () => { const get = vi.spyOn(http, 'get') .mockResolvedValueOnce({ code: '200', data: [{ id: 81, customerId: 11, containerId: 99 }, { id: 82, customerId: 11, containerId: 100 }] }) diff --git a/src/modules/loading-lists/api/containerFees.ts b/src/modules/loading-lists/api/containerFees.ts index e4573229..2c867ba9 100644 --- a/src/modules/loading-lists/api/containerFees.ts +++ b/src/modules/loading-lists/api/containerFees.ts @@ -83,7 +83,11 @@ export interface ContainerCostDetail { const id = (value: unknown) => value == null ? '' : String(value) const numberValue = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : 0 const supplierCategories: SupplierCategory[] = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE'] -const supplierCategory = (value: unknown): SupplierCategory | null => supplierCategories.includes(value as SupplierCategory) ? value as SupplierCategory : null +function supplierCategory(value: unknown): SupplierCategory | null { + if (value == null || value === '') return null + if (supplierCategories.includes(value as SupplierCategory)) return value as SupplierCategory + throw new TypeError(`Unsupported supplierCategory: ${String(value)}`) +} const base = (planId: string) => `/platform/v1/loading-plans/${encodeURIComponent(planId)}/fee-details` const params = (accountId: string) => ({ accountId }) @@ -134,9 +138,9 @@ export async function listApplicableFeeItems() { return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), feeCode: String(raw.feeCode || ''), supplierCategory: supplierCategory(raw.supplierCategory), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) })) } -export async function listContainerFeeSupplierOptions(category: SupplierCategory): Promise { - const page = await listSuppliers({ status: 'ACTIVE', category, page: 1, size: 200 }) - return page.records.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === category).map((supplier) => ({ id: supplier.id, supplierName: supplier.name, category: supplier.category })) +export async function listContainerFeeSupplierOptions(category: SupplierCategory | null): Promise { + const page = await listSuppliers({ status: 'ACTIVE', category: category ?? undefined, page: 1, size: 200 }) + return page.records.filter((supplier) => supplier.status === 'ACTIVE' && (!category || supplier.category === category)).map((supplier) => ({ id: supplier.id, supplierName: supplier.name, category: supplier.category })) } export async function listContainerCostDetails(customerId: string, containerId: string): Promise { diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts index fcfcdf5d..7b6ac834 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts +++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts @@ -175,15 +175,16 @@ describe('ContainerFeesPanel contract', () => { expect(createContainerFee).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ partnerId: expect.anything() })) }) - it('fails closed when the payable fee item has no supplier category', async () => { + it('allows any loaded ACTIVE supplier when the payable fee item has no supplier category', async () => { vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '未映射费用', feeCode: 'UNMAPPED', supplierCategory: null, warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }]) + vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '报关行', category: 'CUSTOMS' }]) const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list']) const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState state.openCreate(); await flushPromises() - state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; state.form.supplierId = '41'; state.form.amount = 100; await flushPromises() + state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; await flushPromises(); state.form.supplierId = '41'; state.form.amount = 100 await state.save(); await flushPromises() - expect(createContainerFee).not.toHaveBeenCalled() - expect(document.body.textContent).toContain('该应付费用项目未配置供应商类别,已阻断保存') + expect(listContainerFeeSupplierOptions).toHaveBeenCalledWith(null) + expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41' })) }) it('fails closed when a supplier candidate does not match the fee-item category', async () => { diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue index 566060dc..e2e4b0e3 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.vue +++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue @@ -24,7 +24,7 @@ const feeItemsError = ref(null) const suppliersError = ref(null) const feeItemsLoading = ref(false) const suppliersLoading = ref(false) -const loadedSupplierCategory = ref(null) +const loadedSupplierCategory = ref(undefined) const editorOpen = ref(false) const editorError = ref(null) const rejectOpen = ref(false) @@ -87,9 +87,10 @@ async function loadFeeItems() { async function loadSuppliers() { const sequence = ++supplierLoadSequence - suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null - const category = selectedFeeItem.value?.supplierCategory - if (!canViewSuppliers.value || form.direction !== 'PAYABLE' || !category) return + suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null + const item = selectedFeeItem.value + if (!canViewSuppliers.value || form.direction !== 'PAYABLE' || !item) return + const category = item.supplierCategory suppliersLoading.value = true try { const result = await listContainerFeeSupplierOptions(category) @@ -148,12 +149,11 @@ async function save() { } finally { saving.value = false } return } - if (form.direction === 'PAYABLE' && !selectedFeeItem.value?.supplierCategory) { ElMessage.warning('该应付费用项目未配置供应商类别,已阻断保存'); return } if (form.direction === 'PAYABLE' && suppliersError.value) { ElMessage.warning('供应商列表加载失败,无法校验应付费用供应商'); return } if (form.direction === 'PAYABLE' && loadedSupplierCategory.value !== selectedFeeItem.value?.supplierCategory) { ElMessage.warning('供应商候选尚未按费用项目类别加载完成,无法保存'); return } if (form.direction === 'PAYABLE' && !form.supplierId) { ElMessage.warning('应付费用必须选择供应商'); return } const supplier = suppliers.value.find((item) => item.id === String(form.supplierId)) - if (form.direction === 'PAYABLE' && (!supplier || supplier.category !== selectedFeeItem.value?.supplierCategory)) { ElMessage.warning('所选供应商与费用项目要求的供应商类别不匹配'); return } + if (form.direction === 'PAYABLE' && (!supplier || (selectedFeeItem.value?.supplierCategory && supplier.category !== selectedFeeItem.value.supplierCategory))) { ElMessage.warning('所选供应商与费用项目要求的供应商类别不匹配'); return } const payload: ContainerFeeSaveRequest = { ...form, supplierId: form.direction === 'PAYABLE' ? form.supplierId : null } saving.value = true try { @@ -181,7 +181,7 @@ function initializeCustomer() { accountId.value = customerOptions.value.length = function clearCustomerContext() { loadSequence += 1 supplierLoadSequence += 1 - fees.value = []; costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = null + fees.value = []; costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = '' resetForm() } @@ -189,10 +189,10 @@ watch(customerOptions, initializeCustomer, { immediate: true }) watch(accountId, () => { clearCustomerContext(); void load() }) watch(() => form.direction, (direction) => { if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = '' - if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null; suppliersLoading.value = false } + if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null; suppliersLoading.value = false } else void loadSuppliers() }) -watch(() => form.feeItemId, () => { form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null; if (form.direction === 'PAYABLE') void loadSuppliers() }) +watch(() => form.feeItemId, () => { form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null; if (form.direction === 'PAYABLE') void loadSuppliers() }) onMounted(() => void load()) @@ -242,7 +242,7 @@ onMounted(() => void load()) diff --git a/src/modules/unified-orders/api/inboundFeeDetails.test.ts b/src/modules/unified-orders/api/inboundFeeDetails.test.ts index 8c29634f..5ae03bd6 100644 --- a/src/modules/unified-orders/api/inboundFeeDetails.test.ts +++ b/src/modules/unified-orders/api/inboundFeeDetails.test.ts @@ -34,17 +34,19 @@ describe('inbound fee-detail API', () => { expect(http.delete).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11', { params: { accountId: '100' } }) }) - it('fails closed when a fee item has no recognized supplier category', async () => { + it('keeps an empty supplier category as null and rejects an unknown non-empty category', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [ { id: 31, feeTypeName: '拖车费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehousePayable: true }, - { id: 32, feeTypeName: '未知费用', feeCode: 'UNMAPPED', supplierCategory: 'UNKNOWN', warehousePayable: true }, { id: 33, feeTypeName: '缺少类别', feeCode: 'MISSING', warehousePayable: true }, ] } }) await expect(listInboundFeeItemOptions()).resolves.toMatchObject([ { id: 31, feeCode: 'TRUCKING', supplierCategory: 'TRUCKING' }, - { id: 32, feeCode: 'UNMAPPED', supplierCategory: null }, { id: 33, feeCode: 'MISSING', supplierCategory: null }, ]) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [ + { id: 32, feeTypeName: '未知费用', feeCode: 'UNMAPPED', supplierCategory: 'UNKNOWN', warehousePayable: true }, + ] } }) + await expect(listInboundFeeItemOptions()).rejects.toThrow('Unsupported supplierCategory: UNKNOWN') }) it('requests and retains only ACTIVE suppliers in the required category', async () => { @@ -60,4 +62,19 @@ describe('inbound fee-detail API', () => { page: 1, size: 200, category: 'TRUCKING', region: undefined, status: 'ACTIVE', keyword: undefined, } }) }) + + it('requests all categories and retains every ACTIVE supplier when the category is empty', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [ + { id: 41, tenantId: 0, name: '承运商A', category: 'TRUCKING', status: 'ACTIVE', version: 1, deleted: false }, + { id: 42, tenantId: 0, name: '停用承运商', category: 'TRUCKING', status: 'INACTIVE', version: 1, deleted: false }, + { id: 43, tenantId: 0, name: '报关行', category: 'CUSTOMS', status: 'ACTIVE', version: 1, deleted: false }, + ], page: 1, size: 200, total: 3, pages: 1 } }) + await expect(listInboundFeeSupplierOptions(null)).resolves.toEqual([ + { id: '41', supplierName: '承运商A', category: 'TRUCKING' }, + { id: '43', supplierName: '报关行', category: 'CUSTOMS' }, + ]) + expect(http.get).toHaveBeenCalledWith('/platform/v1/suppliers', { params: { + page: 1, size: 200, category: undefined, region: undefined, status: 'ACTIVE', keyword: undefined, + } }) + }) }) diff --git a/src/modules/unified-orders/api/inboundFeeDetails.ts b/src/modules/unified-orders/api/inboundFeeDetails.ts index 9d65f0d2..1a7a4793 100644 --- a/src/modules/unified-orders/api/inboundFeeDetails.ts +++ b/src/modules/unified-orders/api/inboundFeeDetails.ts @@ -54,7 +54,9 @@ export interface InboundFeeSupplierOption { id: string; supplierName: string; ca const supplierCategories: SupplierCategory[] = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE'] function supplierCategory(value: unknown): SupplierCategory | null { - return supplierCategories.includes(value as SupplierCategory) ? value as SupplierCategory : null + if (value == null || value === '') return null + if (supplierCategories.includes(value as SupplierCategory)) return value as SupplierCategory + throw new TypeError(`Unsupported supplierCategory: ${String(value)}`) } function basePath(inboundId: string) { @@ -115,9 +117,9 @@ export async function listInboundFeeItemOptions() { })) } -export async function listInboundFeeSupplierOptions(category: SupplierCategory) { - const page = await listSuppliers({ status: 'ACTIVE', category, page: 1, size: 200 }) - return page.records.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === category).map((supplier): InboundFeeSupplierOption => ({ +export async function listInboundFeeSupplierOptions(category: SupplierCategory | null) { + const page = await listSuppliers({ status: 'ACTIVE', category: category ?? undefined, page: 1, size: 200 }) + return page.records.filter((supplier) => supplier.status === 'ACTIVE' && (!category || supplier.category === category)).map((supplier): InboundFeeSupplierOption => ({ id: supplier.id, supplierName: supplier.name, category: supplier.category, })) } diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts index d0b1b2d8..7066a41a 100644 --- a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts +++ b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts @@ -181,8 +181,9 @@ describe('InboundFeeDetailManager', () => { expect(document.querySelector('.fee-form .el-select')?.classList.contains('is-disabled')).toBe(false) }) - it('fails closed when a payable fee item has no supplier category', async () => { + it('allows any loaded ACTIVE supplier when a payable fee item has no supplier category', async () => { vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '未映射费用', feeCode: 'UNMAPPED', supplierCategory: null, warehouseReceivable: false, warehousePayable: true }]) + vi.mocked(listInboundFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '测试供应商', category: 'CUSTOMS' }]) const wrapper = mountManager([ 'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list', @@ -191,10 +192,10 @@ describe('InboundFeeDetailManager', () => { await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click') await flushPromises() const state = (wrapper.vm.$ as unknown as { setupState: { save(): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState - state.form.direction = 'PAYABLE'; state.form.feeItemId = '31'; state.form.supplierId = '41'; state.form.amount = 100; await flushPromises() + state.form.direction = 'PAYABLE'; state.form.feeItemId = '31'; await flushPromises(); state.form.supplierId = '41'; state.form.amount = 100 await state.save(); await flushPromises() - expect(createInboundFeeDetail).not.toHaveBeenCalled() - expect(document.body.textContent).toContain('该应付费用项目未配置供应商类别,已阻断保存') + expect(listInboundFeeSupplierOptions).toHaveBeenCalledWith(null) + expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41' })) }) it('refreshes fee rows and the order detail after every successful write', () => { diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.vue b/src/modules/unified-orders/components/InboundFeeDetailManager.vue index 3409b3ce..a1f64154 100644 --- a/src/modules/unified-orders/components/InboundFeeDetailManager.vue +++ b/src/modules/unified-orders/components/InboundFeeDetailManager.vue @@ -27,7 +27,7 @@ const feeItems = ref([]) const suppliers = ref([]) const feeItemsLoading = ref(false) const suppliersLoading = ref(false) -const loadedSupplierCategory = ref(null) +const loadedSupplierCategory = ref(undefined) const feeItemsError = ref(null) const suppliersError = ref(null) const loading = ref(false) @@ -108,8 +108,8 @@ async function loadFeeItems() { async function loadSuppliers() { const sequence = ++supplierLoadSequence const item = selectedFeeItem.value - suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null - if (!canViewSuppliers.value || form.direction !== 'PAYABLE' || !item?.supplierCategory) return + suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null + if (!canViewSuppliers.value || form.direction !== 'PAYABLE' || !item) return const requestedCategory = item.supplierCategory suppliersLoading.value = true try { @@ -153,12 +153,11 @@ async function save() { if (!canViewFeeItems.value) { actionError.value = { message: '当前账号缺少费用项目查看权限,无法保存费用' }; return } const selectedItem = feeItems.value.find((item) => String(item.id) === String(form.feeItemId)) if (form.direction === 'PAYABLE' && !canViewSuppliers.value) { actionError.value = { message: '当前账号缺少供应商查看权限,无法维护应付费用' }; return } - if (form.direction === 'PAYABLE' && !selectedItem?.supplierCategory) { actionError.value = { message: '该应付费用项目未配置供应商类别,已阻断保存' }; return } if (form.direction === 'PAYABLE' && suppliersError.value) { actionError.value = { message: '供应商列表加载失败,无法校验应付费用供应商' }; return } if (form.direction === 'PAYABLE' && loadedSupplierCategory.value !== selectedItem?.supplierCategory) { actionError.value = { message: '供应商候选尚未按费用项目类别加载完成,无法保存' }; return } if (form.direction === 'PAYABLE' && !form.supplierId) { actionError.value = { message: '应付费用必须选择供应商' }; return } const selectedSupplier = suppliers.value.find((supplier) => supplier.id === String(form.supplierId)) - if (form.direction === 'PAYABLE' && (!selectedSupplier || selectedSupplier.category !== selectedItem?.supplierCategory)) { actionError.value = { message: '所选供应商与费用项目要求的供应商类别不匹配' }; return } + if (form.direction === 'PAYABLE' && (!selectedSupplier || (selectedItem?.supplierCategory && selectedSupplier.category !== selectedItem.supplierCategory))) { actionError.value = { message: '所选供应商与费用项目要求的供应商类别不匹配' }; return } saving.value = true; actionError.value = null try { const payload = normalizePayload() @@ -201,10 +200,10 @@ async function voidRow(row: InboundFeeDetail) { watch(() => [props.accountId, normalizedInboundId.value], () => void load()) watch(() => form.direction, (direction) => { - if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null; suppliersLoading.value = false } + if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null; suppliersLoading.value = false } else void loadSuppliers() }) -watch(() => form.feeItemId, () => { form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null; if (form.direction === 'PAYABLE') void loadSuppliers() }) +watch(() => form.feeItemId, () => { form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null; if (form.direction === 'PAYABLE') void loadSuppliers() }) onMounted(() => void load()) @@ -265,7 +264,7 @@ onMounted(() => void load()) - +