优化
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail, listInboundFeeDetails,
|
||||
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail, type SaveInboundFeeDetailRequest,
|
||||
} from './inboundFeeDetails'
|
||||
|
||||
vi.mock('@/shared/api/http', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
|
||||
return { ...actual, http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }
|
||||
})
|
||||
|
||||
describe('inbound fee-detail API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
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: '验收' }
|
||||
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 })
|
||||
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
await listInboundFeeDetails('100', '88'); await createInboundFeeDetail('100', '88', payload)
|
||||
await updateInboundFeeDetail('100', '88', '11', payload); await reviewInboundFeeDetail('100', '88', '11', 'approve')
|
||||
await reviewInboundFeeDetail('100', '88', '11', 'reject', '金额有误'); await adjustInboundFeeDetail('100', '88', '11', 3, payload)
|
||||
await voidInboundFeeDetail('100', '88', '11', 3); await deleteInboundFeeDetail('100', '88', '11')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details', { params: { accountId: '100' } })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11/adjust', payload, { params: { accountId: '100', expectedSourceVersion: 3 } })
|
||||
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' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type InboundFeeDirection = 'RECEIVABLE' | 'PAYABLE'
|
||||
export type InboundFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
|
||||
export interface InboundFeeDetail {
|
||||
id: string | number
|
||||
inboundId: string | number
|
||||
feeItemId: string | number
|
||||
sourceType: string
|
||||
sourceFeeVersion: number
|
||||
inboundCode: string | null
|
||||
direction: InboundFeeDirection
|
||||
partnerId: string | number | null
|
||||
partnerName: string | null
|
||||
businessDate: string
|
||||
customerName: string | null
|
||||
feeTypeNameSnapshot: string
|
||||
unitPrice: number
|
||||
currency: string
|
||||
amount: number
|
||||
remark: string | null
|
||||
reviewStatus: InboundFeeReviewStatus
|
||||
reviewReason: string | null
|
||||
reviewerName: string | null
|
||||
reviewedAt: string | null
|
||||
publicationStatus: string
|
||||
settlementIncluded: boolean
|
||||
}
|
||||
|
||||
export interface SaveInboundFeeDetailRequest {
|
||||
feeItemId: string | number
|
||||
direction: InboundFeeDirection
|
||||
businessDate: string
|
||||
unitPrice: number
|
||||
currency: string
|
||||
amount: number
|
||||
settlementIncluded: boolean
|
||||
partnerId: string | number | null
|
||||
partnerName: string | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface InboundFeeItemOption {
|
||||
id: string | number
|
||||
feeTypeName: string
|
||||
warehousePayable: boolean
|
||||
warehouseReceivable: boolean
|
||||
}
|
||||
|
||||
export interface InboundFeePartnerOption { id: string | number; partnerName: string }
|
||||
|
||||
function basePath(inboundId: string) { return `/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details` }
|
||||
const config = (accountId: string) => ({ params: { accountId } })
|
||||
|
||||
export async function listInboundFeeDetails(accountId: string, inboundId: string) {
|
||||
const response = await http.get<unknown, ApiEnvelope<InboundFeeDetail[]>>(basePath(inboundId), config(accountId))
|
||||
return unwrapApiResponse(response) || []
|
||||
}
|
||||
|
||||
export async function createInboundFeeDetail(accountId: string, inboundId: string, payload: SaveInboundFeeDetailRequest) {
|
||||
return unwrapApiResponse(await http.post<unknown, ApiEnvelope<InboundFeeDetail>>(basePath(inboundId), payload, config(accountId)))
|
||||
}
|
||||
|
||||
export async function updateInboundFeeDetail(accountId: string, inboundId: string, detailId: string, payload: SaveInboundFeeDetailRequest) {
|
||||
return unwrapApiResponse(await http.put<unknown, ApiEnvelope<InboundFeeDetail>>(`${basePath(inboundId)}/${encodeURIComponent(detailId)}`, payload, config(accountId)))
|
||||
}
|
||||
|
||||
export async function deleteInboundFeeDetail(accountId: string, inboundId: string, detailId: string) {
|
||||
return unwrapApiResponse(await http.delete<unknown, ApiEnvelope<null>>(`${basePath(inboundId)}/${encodeURIComponent(detailId)}`, config(accountId)))
|
||||
}
|
||||
|
||||
export async function reviewInboundFeeDetail(accountId: string, inboundId: string, detailId: string, action: 'approve' | 'reject', reason?: string) {
|
||||
return unwrapApiResponse(await http.post<unknown, ApiEnvelope<null>>(
|
||||
`${basePath(inboundId)}/${encodeURIComponent(detailId)}/${action}`,
|
||||
action === 'reject' ? { reason } : undefined, config(accountId),
|
||||
))
|
||||
}
|
||||
|
||||
export async function adjustInboundFeeDetail(accountId: string, inboundId: string, detailId: string, expectedSourceVersion: number, payload: SaveInboundFeeDetailRequest) {
|
||||
return unwrapApiResponse(await http.post<unknown, ApiEnvelope<InboundFeeDetail>>(
|
||||
`${basePath(inboundId)}/${encodeURIComponent(detailId)}/adjust`, payload,
|
||||
{ params: { accountId, expectedSourceVersion } },
|
||||
))
|
||||
}
|
||||
|
||||
export async function voidInboundFeeDetail(accountId: string, inboundId: string, detailId: string, expectedSourceVersion: number) {
|
||||
return unwrapApiResponse(await http.post<unknown, ApiEnvelope<null>>(
|
||||
`${basePath(inboundId)}/${encodeURIComponent(detailId)}/void`, { expectedSourceVersion }, config(accountId),
|
||||
))
|
||||
}
|
||||
|
||||
export async function listInboundFeeItemOptions() {
|
||||
const response = await http.get<unknown, ApiEnvelope<{ records: InboundFeeItemOption[] }>>(
|
||||
'/platform/v1/fee-items', { params: { status: 'ACTIVE', page: 1, size: 200 } },
|
||||
)
|
||||
return unwrapApiResponse(response).records || []
|
||||
}
|
||||
|
||||
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 || []
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import {
|
||||
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
|
||||
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeePartnerOptions,
|
||||
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(),
|
||||
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,
|
||||
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,
|
||||
}
|
||||
const approved: InboundFeeDetail = { ...pending, id: '12', sourceFeeVersion: 3, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED' }
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function mountManager(permissions: string[]) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(InboundFeeDetailManager, { attachTo: document.body, props: { accountId: '100', inboundId: '88' }, global: { plugins: [pinia, ElementPlus] } })
|
||||
}
|
||||
|
||||
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(createInboundFeeDetail).mockResolvedValue(pending)
|
||||
vi.mocked(updateInboundFeeDetail).mockResolvedValue(pending)
|
||||
vi.mocked(adjustInboundFeeDetail).mockResolvedValue({ ...approved, reviewStatus: 'PENDING' })
|
||||
vi.mocked(reviewInboundFeeDetail).mockResolvedValue(null)
|
||||
vi.mocked(deleteInboundFeeDetail).mockResolvedValue(null)
|
||||
vi.mocked(voidInboundFeeDetail).mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('renders actions from backend status and exact permissions', async () => {
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:delete', 'firstleg:inbound:fee-detail:review'])
|
||||
await flushPromises()
|
||||
expect(listInboundFeeDetails).toHaveBeenCalledWith('100', '88')
|
||||
const first = wrapper.findAll('.el-table__row')[0]!.text()
|
||||
const second = wrapper.findAll('.el-table__row')[1]!.text()
|
||||
const secondButtons = wrapper.findAll('.el-table__row')[1]!.findAll('button').map((button) => button.text())
|
||||
expect(first).toContain('编辑'); expect(first).toContain('审核'); expect(first).toContain('驳回')
|
||||
expect(second).toContain('调整'); expect(second).toContain('作废'); expect(secondButtons).not.toContain('审核')
|
||||
expect(wrapper.find('th').text()).toContain('操作')
|
||||
})
|
||||
|
||||
it('does not request or expose fee rows without view permission', async () => {
|
||||
mountManager([]); await flushPromises()
|
||||
expect(listInboundFeeDetails).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('没有查看费用明细的权限')
|
||||
})
|
||||
|
||||
it('approves a pending fee then refreshes rows and emits changed', async () => {
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:review'])
|
||||
await flushPromises()
|
||||
wrapper.findAll('button').find((button) => button.text().includes('审核'))?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(reviewInboundFeeDetail).toHaveBeenCalledWith('100', '88', '11', 'approve')
|
||||
expect(listInboundFeeDetails).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves request and trace IDs when a write fails', async () => {
|
||||
vi.mocked(reviewInboundFeeDetail).mockRejectedValue(new ApiResponseError('状态冲突', 'A0409', 409, null, 'request-fee', 'trace-fee'))
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:review'])
|
||||
await flushPromises()
|
||||
wrapper.findAll('button').find((button) => button.text().includes('审核'))?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('request-fee')
|
||||
expect(document.body.textContent).toContain('trace-fee')
|
||||
})
|
||||
|
||||
it('keeps currency fixed to CNY in the form and write payload', async () => {
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit'])
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
|
||||
await flushPromises()
|
||||
const currency = document.querySelector<HTMLInputElement>('input[aria-label="货币 CNY"]')
|
||||
expect(currency?.readOnly).toBe(true)
|
||||
expect(currency?.value).toBe('CNY')
|
||||
})
|
||||
|
||||
it('uses only the Element Plus table scroller without an outer horizontal scrollbar', async () => {
|
||||
const wrapper = mountManager(['firstleg:inbound:fee-detail:view'])
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.fee-table-shell').exists()).toBe(true)
|
||||
expect(wrapper.find('.detail-table-wrap').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Check, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2, Undo2, X } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import {
|
||||
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
|
||||
listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeePartnerOptions,
|
||||
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail,
|
||||
} from '@/modules/unified-orders/api/inboundFeeDetails'
|
||||
import type {
|
||||
InboundFeeDetail, InboundFeeItemOption,
|
||||
InboundFeePartnerOption, SaveInboundFeeDetailRequest,
|
||||
} from '@/modules/unified-orders/api/inboundFeeDetails'
|
||||
|
||||
const props = defineProps<{ accountId: string; inboundId: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<InboundFeeDetail[]>([])
|
||||
const feeItems = ref<InboundFeeItemOption[]>([])
|
||||
const partners = ref<InboundFeePartnerOption[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const editorOpen = ref(false)
|
||||
const editorMode = ref<'create' | 'edit' | 'adjust'>('create')
|
||||
const target = ref<InboundFeeDetail | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const rejectOpen = ref(false)
|
||||
const rejectReason = ref('')
|
||||
|
||||
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,
|
||||
})
|
||||
const form = reactive<SaveInboundFeeDetailRequest>(emptyForm())
|
||||
const rules: FormRules = {
|
||||
feeItemId: [{ required: true, message: '请选择费用项目', trigger: 'change' }],
|
||||
businessDate: [{ required: true, message: '请选择业务发生日', trigger: 'change' }],
|
||||
unitPrice: [{ required: true, type: 'number', min: 0, message: '单价不能小于 0', trigger: 'change' }],
|
||||
amount: [{ required: true, type: 'number', min: 0, message: '金额不能小于 0', trigger: 'change' }],
|
||||
currency: [{ required: true, pattern: /^[A-Z]{3}$/, message: '货币须为三个大写字母', trigger: 'blur' }],
|
||||
}
|
||||
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||||
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable))
|
||||
|
||||
function canEdit(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus !== 'APPROVED' }
|
||||
function canDelete(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:delete') && row.reviewStatus !== 'APPROVED' }
|
||||
function canReview(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'PENDING' }
|
||||
function canAdjust(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
|
||||
function canVoid(row: InboundFeeDetail) { return access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
|
||||
function reviewLabel(value: string) { return ({ PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
|
||||
function directionLabel(value: string) { return value === 'RECEIVABLE' ? '应收' : '应付' }
|
||||
|
||||
async function load() {
|
||||
if (!canView.value) return
|
||||
loading.value = true; loadError.value = null
|
||||
try { rows.value = await listInboundFeeDetails(props.accountId, props.inboundId) }
|
||||
catch (error) { loadError.value = getApiErrorDetails(error, '费用明细加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
try { [feeItems.value, partners.value] = await Promise.all([listInboundFeeItemOptions(), listInboundFeePartnerOptions()]) }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '费用维护选项加载失败') }
|
||||
}
|
||||
|
||||
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,
|
||||
} : emptyForm())
|
||||
}
|
||||
|
||||
async function openEditor(mode: 'create' | 'edit' | 'adjust', row?: InboundFeeDetail) {
|
||||
editorMode.value = mode; target.value = row || null; assignForm(row); actionError.value = null
|
||||
editorOpen.value = true
|
||||
if (!feeItems.value.length) await loadOptions()
|
||||
}
|
||||
|
||||
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,
|
||||
remark: form.remark?.trim() || null }
|
||||
}
|
||||
|
||||
async function finishAction(message: string) {
|
||||
await load(); emit('changed'); ElMessage.success(message)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!await formRef.value?.validate()) return
|
||||
if (form.direction === 'PAYABLE' && !form.partnerId) { actionError.value = { message: '应付费用必须选择往来单位' }; return }
|
||||
saving.value = true; actionError.value = null
|
||||
try {
|
||||
const payload = normalizePayload()
|
||||
if (editorMode.value === 'create') await createInboundFeeDetail(props.accountId, props.inboundId, payload)
|
||||
else if (editorMode.value === 'edit' && target.value) await updateInboundFeeDetail(props.accountId, props.inboundId, String(target.value.id), payload)
|
||||
else if (target.value) await adjustInboundFeeDetail(props.accountId, props.inboundId, String(target.value.id), target.value.sourceFeeVersion, payload)
|
||||
editorOpen.value = false
|
||||
await finishAction(editorMode.value === 'adjust' ? '费用调整已提交审核' : '费用明细已保存')
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '费用明细保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function remove(row: InboundFeeDetail) {
|
||||
try {
|
||||
await ElMessageBox.confirm('删除后无法恢复,是否继续?', '删除费用明细', { type: 'warning', confirmButtonText: '删除' })
|
||||
await deleteInboundFeeDetail(props.accountId, props.inboundId, String(row.id)); await finishAction('费用明细已删除')
|
||||
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '费用明细删除失败') }
|
||||
}
|
||||
|
||||
async function approve(row: InboundFeeDetail) {
|
||||
try { await reviewInboundFeeDetail(props.accountId, props.inboundId, String(row.id), 'approve'); await finishAction('费用审核通过') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '费用审核失败') }
|
||||
}
|
||||
|
||||
function openReject(row: InboundFeeDetail) { target.value = row; rejectReason.value = ''; actionError.value = null; rejectOpen.value = true }
|
||||
async function reject() {
|
||||
if (!target.value || !rejectReason.value.trim()) { actionError.value = { message: '请填写驳回原因' }; return }
|
||||
saving.value = true
|
||||
try { await reviewInboundFeeDetail(props.accountId, props.inboundId, String(target.value.id), 'reject', rejectReason.value.trim()); rejectOpen.value = false; await finishAction('费用已驳回') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '费用驳回失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function voidRow(row: InboundFeeDetail) {
|
||||
try {
|
||||
await ElMessageBox.confirm('作废会同步撤销尚未结算的财务费用来源,是否继续?', '作废费用明细', { type: 'warning', confirmButtonText: '作废' })
|
||||
await voidInboundFeeDetail(props.accountId, props.inboundId, String(row.id), row.sourceFeeVersion); await finishAction('费用明细已作废')
|
||||
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '费用作废失败') }
|
||||
}
|
||||
|
||||
watch(() => [props.accountId, props.inboundId], () => void load())
|
||||
watch(() => form.direction, () => { if (form.direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null } })
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="fee-detail-manager" aria-label="运单费用维护">
|
||||
<div class="fee-toolbar">
|
||||
<span>共 {{ rows.length }} 条应收应付费用明细</span>
|
||||
<div>
|
||||
<el-tooltip content="刷新费用明细" placement="top"><el-button circle aria-label="刷新费用明细" :loading="loading" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||
<el-button v-if="access.can('firstleg:inbound:fee-detail:edit')" type="primary" @click="openEditor('create')"><Plus :size="16" />新增费用</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="!canView" title="当前账号没有查看费用明细的权限" type="warning" :closable="false" show-icon />
|
||||
<LoadErrorState v-else-if="loadError" title="费用明细加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else>
|
||||
<el-alert v-if="actionError && !editorOpen && !rejectOpen" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<div v-loading="loading" class="fee-table-shell">
|
||||
<el-table :data="rows" empty-text="暂无费用数据" row-key="id">
|
||||
<el-table-column label="操作" fixed="left" width="250">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canEdit(row as InboundFeeDetail)" link type="primary" @click="openEditor('edit', row as InboundFeeDetail)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="canReview(row as InboundFeeDetail)" link type="success" @click="approve(row as InboundFeeDetail)"><Check :size="15" />审核</el-button>
|
||||
<el-button v-if="canReview(row as InboundFeeDetail)" link type="warning" @click="openReject(row as InboundFeeDetail)"><Undo2 :size="15" />驳回</el-button>
|
||||
<el-button v-if="canAdjust(row as InboundFeeDetail)" link type="primary" @click="openEditor('adjust', row as InboundFeeDetail)"><Pencil :size="15" />调整</el-button>
|
||||
<el-button v-if="canVoid(row as InboundFeeDetail)" link type="danger" @click="voidRow(row as InboundFeeDetail)"><X :size="15" />作废</el-button>
|
||||
<el-dropdown v-if="canDelete(row as InboundFeeDetail)">
|
||||
<el-button link aria-label="更多费用操作"><MoreHorizontal :size="17" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item @click="remove(row as InboundFeeDetail)"><Trash2 :size="15" />删除</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核状态" width="120"><template #default="{ row }"><StatusBadge :status="reviewLabel(row.reviewStatus)" /></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 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 />
|
||||
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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="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="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }]" /></el-form-item>
|
||||
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable><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><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.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>
|
||||
<AppDialog v-model="rejectOpen" title="驳回费用" :confirm-loading="saving" confirm-text="确认驳回" confirm-type="warning" @confirm="reject">
|
||||
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<el-form label-position="top"><el-form-item label="驳回原因" required><el-input v-model="rejectReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fee-detail-manager { display: grid; min-width: 0; max-width: 100%; gap: 12px; }
|
||||
.fee-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.fee-toolbar > div { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.fee-toolbar :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }
|
||||
.fee-form :deep(.el-select), .fee-form :deep(.el-date-editor), .fee-form :deep(.el-input-number) { width: 100%; }
|
||||
.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.fee-table-shell { min-width: 0; max-width: 100%; overflow: hidden; }
|
||||
.fee-table-shell :deep(.el-table) { width: 100%; max-width: 100%; }
|
||||
@media (max-width: 720px) { .fee-toolbar { align-items: flex-start; flex-direction: column; } .fee-form { grid-template-columns: 1fr; } .fee-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
||||
</style>
|
||||
@@ -242,7 +242,8 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain("partyValue(detail.order.parties, 'RECEIVER', 'phone')")
|
||||
expect(source).not.toContain("partyValue(detail.order.parties, 'CONSIGNEE'")
|
||||
expect(source).toContain("cargoTotal(detail.order.cargoes, 'netWeightKg')")
|
||||
expect(source).toContain('暂无费用数据')
|
||||
expect(source).toContain('InboundFeeDetailManager')
|
||||
expect(source).toContain('@changed="loadDetail(true)"')
|
||||
expect(source).toContain('firstLegTransportModeLabel(detail.order.order?.transportMode)')
|
||||
expect(source).toContain('firstLegPickupMethodLabel(detail.order.order?.pickupMethod)')
|
||||
expect(source).toContain('firstLegDestinationTypeLabel(detail.order.order?.destinationType)')
|
||||
|
||||
@@ -7,15 +7,14 @@ import {
|
||||
getPlatformUnifiedOrder,
|
||||
approvePlatformUnifiedOrder,
|
||||
addPlatformUnifiedOrderNote,
|
||||
listPlatformUnifiedOrderFeeDetails,
|
||||
listPlatformUnifiedOrders,
|
||||
unifiedOrderStatusOptions,
|
||||
type UnifiedOrderDetailResource,
|
||||
type UnifiedOrderMainStatus,
|
||||
type UnifiedOrderResource,
|
||||
type UnifiedOrderFeeDetailResource,
|
||||
} from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue'
|
||||
import InboundFeeDetailManager from '@/modules/unified-orders/components/InboundFeeDetailManager.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
@@ -100,9 +99,6 @@ const noteContent = ref('')
|
||||
const noteOrder = ref<UnifiedOrderResource | null>(null)
|
||||
const relationOpen = ref(false)
|
||||
const relationOrder = ref<UnifiedOrderResource | null>(null)
|
||||
const feeDetails = ref<UnifiedOrderFeeDetailResource[]>([])
|
||||
const feeLoading = ref(false)
|
||||
const feeLoadedKey = ref('')
|
||||
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
|
||||
const measurementLoading = ref(false)
|
||||
const measurementLoadedKey = ref('')
|
||||
@@ -384,43 +380,10 @@ function cargoImage(cargo: Record<string, unknown>) {
|
||||
return typeof value === 'string' && value ? value : ''
|
||||
}
|
||||
|
||||
function feeDate(value: string | null | undefined) {
|
||||
return value ? formatDate(value) : '—'
|
||||
}
|
||||
|
||||
function feeText(value: string | null | undefined) {
|
||||
return value || '—'
|
||||
}
|
||||
|
||||
function feeNumber(value: number | null | undefined) {
|
||||
return value === null || value === undefined ? '—' : formatNumber(value, 4)
|
||||
}
|
||||
|
||||
function feeReviewLabel(value: string | null | undefined) {
|
||||
return unifiedOrderReviewStatusLabel(value)
|
||||
}
|
||||
|
||||
function feeDirectionLabel(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
return ({ RECEIVABLE: '应收', PAYABLE: '应付' } as Record<string, string>)[value] || '未知方向'
|
||||
}
|
||||
|
||||
async function loadFeeDetails() {
|
||||
const summary = detail.value?.summary
|
||||
if (!summary?.inboundId || !can('firstleg:inbound:fee-detail:view')) return
|
||||
const key = `${summary.tenantId}:${summary.inboundId}`
|
||||
if (feeLoadedKey.value === key || feeLoading.value) return
|
||||
feeLoading.value = true
|
||||
try {
|
||||
feeDetails.value = await listPlatformUnifiedOrderFeeDetails(String(summary.tenantId), String(summary.inboundId))
|
||||
feeLoadedKey.value = key
|
||||
} catch {
|
||||
feeDetails.value = []
|
||||
} finally {
|
||||
feeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function measurementValue(row: Record<string, unknown>, key: string, customerSource = false) {
|
||||
if (customerSource && key === 'unitWeightKg') {
|
||||
const total = Number(row.grossWeightKg)
|
||||
@@ -492,7 +455,6 @@ async function loadMeasurementDetails() {
|
||||
|
||||
function selectDetailTab(key: string) {
|
||||
activeDetailTab.value = key
|
||||
if (key === 'fees') void loadFeeDetails()
|
||||
if (key === 'measurement') void loadMeasurementDetails()
|
||||
}
|
||||
|
||||
@@ -740,8 +702,6 @@ async function loadDetail(force = false) {
|
||||
if (!force && (detailLoadingKey === requestKey || detail.value?.summary.orderId === orderId.value && String(detail.value.summary.tenantId) === tenantId)) return
|
||||
const serial = ++detailRequestSerial
|
||||
detailLoadingKey = requestKey
|
||||
feeDetails.value = []
|
||||
feeLoadedKey.value = ''
|
||||
measurementDetail.value = null
|
||||
measurementLoadedKey.value = ''
|
||||
revokeMeasurementImages()
|
||||
@@ -914,36 +874,12 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeDetailTab === 'fees'">
|
||||
<div class="panel-title"><div><h2>费用信息</h2><p>共 {{ feeDetails.length }} 条应收应付费用明细</p></div><WalletCards :size="19" /></div>
|
||||
<el-alert v-if="!can('firstleg:inbound:fee-detail:view')" title="当前账号没有查看费用明细的权限" type="warning" :closable="false" show-icon />
|
||||
<div v-else v-loading="feeLoading" class="detail-table-wrap">
|
||||
<el-table :data="feeDetails" empty-text="暂无费用数据" row-key="id">
|
||||
<el-table-column label="审核状态" min-width="120"><template #default="{ row }"><StatusBadge :status="feeReviewLabel((row as UnifiedOrderFeeDetailResource).reviewStatus)" /></template></el-table-column>
|
||||
<el-table-column label="应收/应付" min-width="110"><template #default="{ row }">{{ feeDirectionLabel((row as UnifiedOrderFeeDetailResource).direction) }}</template></el-table-column>
|
||||
<el-table-column label="客户公司名" min-width="170"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).customerName) }}</template></el-table-column>
|
||||
<el-table-column label="裹箱头" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).invoiceTitle) }}</template></el-table-column>
|
||||
<el-table-column label="入仓码" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).inboundCode) }}</template></el-table-column>
|
||||
<el-table-column label="业务发生日" min-width="130"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).businessDate) }}</template></el-table-column>
|
||||
<el-table-column label="渠道" min-width="130"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).channelName) }}</template></el-table-column>
|
||||
<el-table-column label="目的仓" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).destinationName) }}</template></el-table-column>
|
||||
<el-table-column label="FBA编号" min-width="130"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).fbaNo) }}</template></el-table-column>
|
||||
<el-table-column label="箱数" width="90"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).packageCount) }}</template></el-table-column>
|
||||
<el-table-column label="毛重" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).actualGrossWeightKg) }}</template></el-table-column>
|
||||
<el-table-column label="体积" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).actualVolumeCbm) }}</template></el-table-column>
|
||||
<el-table-column label="费用类型" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).feeTypeNameSnapshot) }}</template></el-table-column>
|
||||
<el-table-column label="货币" width="90"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).currency) }}</template></el-table-column>
|
||||
<el-table-column label="应收金额" min-width="120"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).amount) }}</template></el-table-column>
|
||||
<el-table-column label="计费体称/重量" min-width="150"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).chargeableQuantity) }} {{ (row as UnifiedOrderFeeDetailResource).chargeUnit || '' }}</template></el-table-column>
|
||||
<el-table-column label="单价" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).unitPrice) }}</template></el-table-column>
|
||||
<el-table-column label="入仓时间" min-width="150"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).inboundedAt) }}</template></el-table-column>
|
||||
<el-table-column label="出仓时间" min-width="150"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).outboundAt) }}</template></el-table-column>
|
||||
<el-table-column label="仓储天数" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).storageDays) }}</template></el-table-column>
|
||||
<el-table-column label="参与结算" min-width="110"><template #default="{ row }">{{ (row as UnifiedOrderFeeDetailResource).settlementIncluded == null ? '—' : (row as UnifiedOrderFeeDetailResource).settlementIncluded ? '是' : '否' }}</template></el-table-column>
|
||||
<el-table-column label="账单编号" min-width="140"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).billNo) }}</template></el-table-column>
|
||||
<el-table-column label="结算起始日" min-width="130"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).settlementStartDate) }}</template></el-table-column>
|
||||
<el-table-column label="结算结束日" min-width="130"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).settlementEndDate) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="panel-title"><div><h2>费用信息</h2><p>维护运单与入库单应收应付费用</p></div><WalletCards :size="19" /></div>
|
||||
<InboundFeeDetailManager
|
||||
:account-id="String(detail.summary.tenantId)"
|
||||
:inbound-id="String(detail.summary.inboundId)"
|
||||
@changed="loadDetail(true)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeDetailTab === 'history'">
|
||||
|
||||
Reference in New Issue
Block a user