This commit is contained in:
2026-08-19 09:14:35 +08:00
parent c188482c24
commit 80cfca5056
36 changed files with 854 additions and 1044 deletions
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail, listInboundFeeDetails,
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail, type SaveInboundFeeDetailRequest,
listInboundFeeItemOptions, listInboundFeeSupplierOptions, reviewInboundFeeDetail,
updateInboundFeeDetail, voidInboundFeeDetail, type SaveInboundFeeDetailRequest,
} from './inboundFeeDetails'
vi.mock('@/shared/api/http', async () => {
@@ -18,7 +19,7 @@ describe('inbound fee-detail API', () => {
})
it('maps the complete write contract and optimistic version', async () => {
const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, settlementIncluded: true, partnerId: '41', partnerName: '测试供应商', remark: '验收' }
const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, supplierId: '41', remark: '验收' }
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: payload })
@@ -32,4 +33,31 @@ describe('inbound fee-detail API', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11/void', { expectedSourceVersion: 3 }, { params: { accountId: '100' } })
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 () => {
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 },
])
})
it('requests and retains only ACTIVE suppliers in the required category', 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('TRUCKING')).resolves.toEqual([
{ id: '41', supplierName: '承运商A', category: 'TRUCKING' },
])
expect(http.get).toHaveBeenCalledWith('/platform/v1/suppliers', { params: {
page: 1, size: 200, category: 'TRUCKING', region: undefined, status: 'ACTIVE', keyword: undefined,
} })
})
})
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { listSuppliers, type SupplierCategory } from '@/modules/suppliers'
export type InboundFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export type InboundFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
@@ -11,8 +12,9 @@ export interface InboundFeeDetail {
sourceFeeVersion: number
inboundCode: string | null
direction: InboundFeeDirection
partnerId: string | number | null
partnerName: string | null
supplierId: string | number | null
supplierName: string | null
supplierCategory: SupplierCategory | null
businessDate: string
customerName: string | null
feeTypeNameSnapshot: string
@@ -25,7 +27,6 @@ export interface InboundFeeDetail {
reviewerName: string | null
reviewedAt: string | null
publicationStatus: string
settlementIncluded: boolean
}
export interface SaveInboundFeeDetailRequest {
@@ -35,20 +36,26 @@ export interface SaveInboundFeeDetailRequest {
unitPrice: number
currency: string
amount: number
settlementIncluded: boolean
partnerId: string | number | null
partnerName: string | null
supplierId: string | number | null
remark: string | null
}
export interface InboundFeeItemOption {
id: string | number
feeTypeName: string
feeCode: string
supplierCategory: SupplierCategory | null
warehousePayable: boolean
warehouseReceivable: boolean
}
export interface InboundFeePartnerOption { id: string | number; partnerName: string }
export interface InboundFeeSupplierOption { id: string; supplierName: string; category: SupplierCategory }
const supplierCategories: SupplierCategory[] = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']
function supplierCategory(value: unknown): SupplierCategory | null {
return supplierCategories.includes(value as SupplierCategory) ? value as SupplierCategory : null
}
function basePath(inboundId: string) {
const identifier = String(inboundId ?? '').trim()
@@ -95,15 +102,22 @@ export async function voidInboundFeeDetail(accountId: string, inboundId: string,
}
export async function listInboundFeeItemOptions() {
const response = await http.get<unknown, ApiEnvelope<{ records: InboundFeeItemOption[] }>>(
const response = await http.get<unknown, ApiEnvelope<{ records: Record<string, unknown>[] }>>(
'/platform/v1/fee-items', { params: { status: 'ACTIVE', page: 1, size: 200 } },
)
return unwrapApiResponse(response).records || []
return (unwrapApiResponse(response).records || []).map((raw): InboundFeeItemOption => ({
id: raw.id as string | number,
feeTypeName: String(raw.feeTypeName || ''),
feeCode: String(raw.feeCode || ''),
supplierCategory: supplierCategory(raw.supplierCategory),
warehousePayable: Boolean(raw.warehousePayable),
warehouseReceivable: Boolean(raw.warehouseReceivable),
}))
}
export async function listInboundFeePartnerOptions() {
const response = await http.get<unknown, ApiEnvelope<{ records: InboundFeePartnerOption[] }>>(
'/platform/v1/partners', { params: { status: 'ACTIVE', page: 1, size: 200 } },
)
return unwrapApiResponse(response).records || []
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 => ({
id: supplier.id, supplierName: supplier.name, category: supplier.category,
}))
}
@@ -255,7 +255,6 @@ export interface UnifiedOrderFeeDetailResource {
inboundedAt: string | null
outboundAt?: string | null
storageDays?: number | null
settlementIncluded: boolean | null
billNo?: string | null
settlementStartDate?: string | null
settlementEndDate?: string | null
@@ -9,24 +9,24 @@ import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeePartnerOptions,
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions,
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail,
} from '@/modules/unified-orders/api/inboundFeeDetails'
import InboundFeeDetailManager from './InboundFeeDetailManager.vue'
import type { InboundFeeDetail } from '@/modules/unified-orders/api/inboundFeeDetails'
vi.mock('@/modules/unified-orders/api/inboundFeeDetails', () => ({
listInboundFeeDetails: vi.fn(), listInboundFeeItemOptions: vi.fn(), listInboundFeePartnerOptions: vi.fn(),
listInboundFeeDetails: vi.fn(), listInboundFeeItemOptions: vi.fn(), listInboundFeeSupplierOptions: vi.fn(),
createInboundFeeDetail: vi.fn(), updateInboundFeeDetail: vi.fn(), deleteInboundFeeDetail: vi.fn(),
reviewInboundFeeDetail: vi.fn(), adjustInboundFeeDetail: vi.fn(), voidInboundFeeDetail: vi.fn(),
}))
const pending: InboundFeeDetail = {
id: '11', inboundId: '88', feeItemId: '31', sourceType: 'MANUAL', sourceFeeVersion: 1,
inboundCode: 'RK-88', direction: 'RECEIVABLE', partnerId: null, partnerName: null,
inboundCode: 'RK-88', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null,
businessDate: '2026-08-12', customerName: '测试客户', feeTypeNameSnapshot: '操作费',
unitPrice: 10, currency: 'CNY', amount: 100, remark: 'marker', reviewStatus: 'PENDING',
reviewReason: null, reviewerName: null, reviewedAt: null, publicationStatus: 'DRAFT', settlementIncluded: true,
reviewReason: null, reviewerName: null, reviewedAt: null, publicationStatus: 'DRAFT',
}
const approved: InboundFeeDetail = { ...pending, id: '12', sourceFeeVersion: 3, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED' }
const source = readFileSync(join(process.cwd(), 'src/modules/unified-orders/components/InboundFeeDetailManager.vue'), 'utf8')
@@ -44,8 +44,8 @@ describe('InboundFeeDetailManager', () => {
beforeEach(() => {
vi.clearAllMocks(); document.body.innerHTML = ''
vi.mocked(listInboundFeeDetails).mockResolvedValue([pending, approved])
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '操作费', warehouseReceivable: true, warehousePayable: true }])
vi.mocked(listInboundFeePartnerOptions).mockResolvedValue([{ id: '41', partnerName: '测试往来单位' }])
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true }])
vi.mocked(listInboundFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '测试供应商', category: 'TRUCKING' }])
vi.mocked(createInboundFeeDetail).mockResolvedValue(pending)
vi.mocked(updateInboundFeeDetail).mockResolvedValue(pending)
vi.mocked(adjustInboundFeeDetail).mockResolvedValue({ ...approved, reviewStatus: 'PENDING' })
@@ -144,14 +144,14 @@ describe('InboundFeeDetailManager', () => {
expect(source).toContain('@keyframes fee-refresh-spin')
})
it('loads RECEIVABLE fee items without requesting counterparties', async () => {
it('loads RECEIVABLE fee items without requesting suppliers', async () => {
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
await flushPromises()
expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1)
expect(listInboundFeePartnerOptions).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('缺少往来单位查看权限')
expect(listInboundFeeSupplierOptions).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('缺少供应商查看权限')
expect(document.body.textContent).toContain('应收费用不受影响')
})
@@ -162,25 +162,41 @@ describe('InboundFeeDetailManager', () => {
expect(listInboundFeeItemOptions).not.toHaveBeenCalled()
})
it('keeps fee items usable when the independent counterparty request fails', async () => {
vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', partnerId: '41', partnerName: '测试往来单位' }])
vi.mocked(listInboundFeePartnerOptions).mockRejectedValue(new ApiResponseError('往来单位不可用', 'A0403', 403, null, 'request-partner', 'trace-partner'))
it('keeps fee items usable but blocks payable save when the supplier request fails', async () => {
vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', supplierId: '41', supplierName: '测试供应商', supplierCategory: 'TRUCKING' }])
vi.mocked(listInboundFeeSupplierOptions).mockRejectedValue(new ApiResponseError('供应商不可用', 'A0403', 403, null, 'request-supplier', 'trace-supplier'))
const wrapper = mountManager([
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
'firstleg:fee-item:view', 'platform:counterparty:list',
'firstleg:fee-item:view', 'platform:supplier:list',
])
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('编辑'))?.trigger('click')
await flushPromises()
expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1)
expect(listInboundFeePartnerOptions).toHaveBeenCalledTimes(1)
expect(listInboundFeeSupplierOptions).toHaveBeenCalledWith('TRUCKING')
expect(document.body.textContent).toContain('当前账号无权访问该数据')
expect(document.body.textContent).toContain('A0403')
expect(document.body.textContent).toContain('request-partner')
expect(document.body.textContent).toContain('trace-partner')
expect(document.body.textContent).toContain('request-supplier')
expect(document.body.textContent).toContain('trace-supplier')
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 () => {
vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '未映射费用', feeCode: 'UNMAPPED', supplierCategory: null, warehouseReceivable: false, warehousePayable: true }])
const wrapper = mountManager([
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
'firstleg:fee-item:view', 'platform:supplier:list',
])
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
await flushPromises()
const state = (wrapper.vm.$ as unknown as { setupState: { save(): Promise<void>; 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()
await state.save(); await flushPromises()
expect(createInboundFeeDetail).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('该应付费用项目未配置供应商类别,已阻断保存')
})
it('refreshes fee rows and the order detail after every successful write', () => {
expect(source).toContain("async function finishAction(message: string) {\n await load(); emit('changed')")
expect(source).toMatch(/createInboundFeeDetail[\s\S]*updateInboundFeeDetail[\s\S]*adjustInboundFeeDetail[\s\S]*await finishAction\(/)
@@ -10,12 +10,12 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeePartnerOptions,
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions,
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail,
} from '@/modules/unified-orders/api/inboundFeeDetails'
import type {
InboundFeeDetail, InboundFeeItemOption,
InboundFeePartnerOption, SaveInboundFeeDetailRequest,
InboundFeeSupplierOption, SaveInboundFeeDetailRequest,
} from '@/modules/unified-orders/api/inboundFeeDetails'
const props = defineProps<{ accountId: string; inboundId: string }>()
@@ -24,12 +24,12 @@ const emit = defineEmits<{ changed: []; summary: [value: OtherFeeSummary] }>()
const access = useAccessPreviewStore()
const rows = ref<InboundFeeDetail[]>([])
const feeItems = ref<InboundFeeItemOption[]>([])
const partners = ref<InboundFeePartnerOption[]>([])
const suppliers = ref<InboundFeeSupplierOption[]>([])
const feeItemsLoading = ref(false)
const partnersLoading = ref(false)
const suppliersLoading = ref(false)
const loadedSupplierCategory = ref<string | null>(null)
const feeItemsError = ref<ApiErrorDetails | null>(null)
const partnersError = ref<ApiErrorDetails | null>(null)
const partnersLoadAttempted = ref(false)
const suppliersError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -40,11 +40,12 @@ const target = ref<InboundFeeDetail | null>(null)
const formRef = ref<FormInstance>()
const rejectOpen = ref(false)
const rejectReason = ref('')
let supplierLoadSequence = 0
const emptyForm = (): SaveInboundFeeDetailRequest => ({
feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10),
unitPrice: 0, currency: 'CNY', amount: 0, settlementIncluded: true,
partnerId: null, partnerName: null, remark: null,
unitPrice: 0, currency: 'CNY', amount: 0,
supplierId: null, remark: null,
})
const form = reactive<SaveInboundFeeDetailRequest>(emptyForm())
const rules: FormRules = {
@@ -60,12 +61,13 @@ const normalizedInboundId = computed(() => {
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
})
const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view'))
const canViewPartners = computed(() => access.can('platform:counterparty:list'))
const canViewSuppliers = computed(() => access.can('platform:supplier:list'))
const directionOptions = computed(() => [
{ label: '应收', value: 'RECEIVABLE' },
{ label: '应付', value: 'PAYABLE', disabled: !canViewPartners.value },
{ label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers.value },
])
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable))
const selectedFeeItem = computed(() => feeItems.value.find((item) => String(item.id) === String(form.feeItemId)))
function isManual(row: InboundFeeDetail) { return row.sourceType === 'MANUAL' }
function canEdit(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus === 'PENDING' }
@@ -84,7 +86,7 @@ async function load() {
loading.value = true; loadError.value = null
try {
rows.value = await listInboundFeeDetails(props.accountId, normalizedInboundId.value)
const approved = rows.value.filter((row) => isManual(row) && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' && row.settlementIncluded)
const approved = rows.value.filter((row) => isManual(row) && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED')
emit('summary', {
currency: approved.find((row) => row.currency)?.currency || 'CNY',
approvedReceivable: approved.filter((row) => row.direction === 'RECEIVABLE').reduce((sum, row) => sum + Number(row.amount || 0), 0),
@@ -103,37 +105,42 @@ async function loadFeeItems() {
finally { feeItemsLoading.value = false }
}
async function loadPartners() {
if (!canViewPartners.value || partnersLoading.value || partners.value.length || partnersLoadAttempted.value) return
partnersLoadAttempted.value = true
partnersLoading.value = true; partnersError.value = null
try { partners.value = await listInboundFeePartnerOptions() }
catch (error) { partnersError.value = getApiErrorDetails(error, '往来单位加载失败') }
finally { partnersLoading.value = false }
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
const requestedCategory = item.supplierCategory
suppliersLoading.value = true
try {
const result = await listInboundFeeSupplierOptions(requestedCategory)
if (sequence === supplierLoadSequence && form.direction === 'PAYABLE' && selectedFeeItem.value?.supplierCategory === requestedCategory) {
suppliers.value = result
loadedSupplierCategory.value = requestedCategory
}
}
catch (error) { if (sequence === supplierLoadSequence) suppliersError.value = getApiErrorDetails(error, '供应商加载失败') }
finally { if (sequence === supplierLoadSequence) suppliersLoading.value = false }
}
function assignForm(row?: InboundFeeDetail) {
Object.assign(form, row ? {
feeItemId: row.feeItemId, direction: row.direction, businessDate: row.businessDate,
unitPrice: Number(row.unitPrice), currency: row.currency, amount: Number(row.amount),
settlementIncluded: Boolean(row.settlementIncluded), partnerId: row.partnerId,
partnerName: row.partnerName, remark: row.remark,
supplierId: row.supplierId, remark: row.remark,
} : emptyForm())
}
async function openEditor(mode: 'create' | 'edit' | 'adjust', row?: InboundFeeDetail) {
partnersLoadAttempted.value = false
editorMode.value = mode; target.value = row || null; assignForm(row); actionError.value = null
feeItemsError.value = null; partnersError.value = null
feeItemsError.value = null; suppliersError.value = null
editorOpen.value = true
if (!feeItems.value.length) await loadFeeItems()
if (form.direction === 'PAYABLE') await loadPartners()
if (form.direction === 'PAYABLE') await loadSuppliers()
}
function normalizePayload(): SaveInboundFeeDetailRequest {
const partner = partners.value.find((item) => String(item.id) === String(form.partnerId))
return { ...form, currency: 'CNY', partnerId: form.direction === 'PAYABLE' ? form.partnerId : null,
partnerName: form.direction === 'PAYABLE' ? partner?.partnerName || form.partnerName : null,
return { ...form, currency: 'CNY', supplierId: form.direction === 'PAYABLE' ? form.supplierId : null,
remark: form.remark?.trim() || null }
}
@@ -144,8 +151,14 @@ async function finishAction(message: string) {
async function save() {
if (!await formRef.value?.validate()) return
if (!canViewFeeItems.value) { actionError.value = { message: '当前账号缺少费用项目查看权限,无法保存费用' }; return }
if (form.direction === 'PAYABLE' && !canViewPartners.value) { actionError.value = { message: '当前账号缺少往来单位查看权限,无法维护应付费用' }; return }
if (form.direction === 'PAYABLE' && !form.partnerId) { 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 }
saving.value = true; actionError.value = null
try {
const payload = normalizePayload()
@@ -188,9 +201,10 @@ async function voidRow(row: InboundFeeDetail) {
watch(() => [props.accountId, normalizedInboundId.value], () => void load())
watch(() => form.direction, (direction) => {
if (direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null; partnersError.value = null }
else void loadPartners()
if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = null; 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() })
onMounted(() => void load())
</script>
@@ -228,12 +242,11 @@ onMounted(() => void load())
<el-table-column label="来源" width="120"><template #default="{ row }">{{ row.sourceType === 'FBA_PRICING' ? '头程派生' : '人工录入' }}</template></el-table-column>
<el-table-column label="应收/应付" width="110"><template #default="{ row }">{{ directionLabel(row.direction) }}</template></el-table-column>
<el-table-column prop="feeTypeNameSnapshot" label="费用类型" min-width="150" />
<el-table-column label="往来单位/客户" min-width="170"><template #default="{ row }">{{ row.direction === 'PAYABLE' ? row.partnerName : row.customerName || '—' }}</template></el-table-column>
<el-table-column label="供应商/客户" min-width="170"><template #default="{ row }">{{ row.direction === 'PAYABLE' ? row.supplierName : row.customerName || '—' }}</template></el-table-column>
<el-table-column prop="businessDate" label="业务发生日" width="130" />
<el-table-column prop="currency" label="货币" width="90" />
<el-table-column label="金额" min-width="120"><template #default="{ row }">{{ Number(row.amount).toFixed(2) }}</template></el-table-column>
<el-table-column label="单价" min-width="110"><template #default="{ row }">{{ Number(row.unitPrice).toFixed(4) }}</template></el-table-column>
<el-table-column label="参与结算" width="110"><template #default="{ row }">{{ row.settlementIncluded ? '是' : '否' }}</template></el-table-column>
<el-table-column prop="sourceFeeVersion" label="版本" width="85" />
<el-table-column prop="publicationStatus" label="发布状态" min-width="110" />
<el-table-column prop="reviewReason" label="驳回原因" min-width="180" show-overflow-tooltip />
@@ -245,19 +258,18 @@ onMounted(() => void load())
<AppDialog v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(680px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
<el-alert v-if="editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show-icon />
<el-alert v-if="!canViewFeeItems" title="当前账号缺少费用项目查看权限,无法新增或修改费用" type="warning" :closable="false" show-icon />
<el-alert v-if="!canViewPartners" title="当前账号缺少往来单位查看权限,应付费用不可维护;应收费用不受影响" type="warning" :closable="false" show-icon />
<el-alert v-if="!canViewSuppliers" title="当前账号缺少供应商查看权限,应付费用不可维护;应收费用不受影响" type="warning" :closable="false" show-icon />
<el-alert v-if="feeItemsError" type="error" :closable="true" @close="feeItemsError = null"><template #title>{{ feeItemsError.message }}</template><ApiErrorMeta :details="feeItemsError" /></el-alert>
<el-alert v-if="partnersError" type="error" :closable="true" @close="partnersError = null"><template #title>{{ partnersError.message }}</template><ApiErrorMeta :details="partnersError" /></el-alert>
<el-alert v-if="suppliersError" type="error" :closable="true" @close="suppliersError = null"><template #title>{{ suppliersError.message }}</template><ApiErrorMeta :details="suppliersError" /></el-alert>
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form">
<el-form-item label="应收/应付" prop="direction"><el-segmented v-model="form.direction" :options="directionOptions" /></el-form-item>
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems"><el-option v-for="item in applicableItems" :key="item.id" :label="item.feeTypeName" :value="item.id" /></el-select></el-form-item>
<el-form-item v-if="form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable :loading="partnersLoading" :disabled="!canViewPartners"><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></el-form-item>
<el-form-item v-if="form.direction === 'PAYABLE'" label="供应商" required><el-select v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem?.supplierCategory || !!suppliersError" placeholder="请先选择已配置供应商类别的费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="业务发生日" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="货币" prop="currency"><el-input model-value="CNY" readonly aria-label="货币 CNY" /></el-form-item>
<el-form-item label="单价" prop="unitPrice"><el-input-number v-model="form.unitPrice" :min="0" :precision="4" /></el-form-item>
<el-form-item label="金额" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" /></el-form-item>
<el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</el-form>
</AppDialog>