style(admin): unify dialog layouts and dense readonly presentation
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import * as api from '../api/finance'
|
||||
import type { FinanceAccountSummary, FinanceRechargeRequest } from '../types'
|
||||
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
|
||||
|
||||
const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
listFinanceAccounts: vi.fn(), listFinanceAccountLedger: vi.fn(), listFinanceRechargeRequests: vi.fn(),
|
||||
reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), updateFinanceCreditLimit: vi.fn(),
|
||||
downloadFinanceRechargeVoucher: vi.fn(), getFinanceCustomerTerms: vi.fn(), updateFinanceCustomerTerms: vi.fn(),
|
||||
}))
|
||||
const account: FinanceAccountSummary = {
|
||||
customerId: '9223372036854775001', customerCode: 'C-TEST', customerName: '测试客户', contactName: '', contactPhone: '',
|
||||
outstandingReceivable: 0, pendingBilling: 0, receivedInPeriod: 0, totalCost: 0, paidCost: 0, outstandingPayable: 0,
|
||||
actualBalance: '-20.00', frozenBalance: '0.00', availableBalance: '80.00', creditLimit: '100.00', creditUsed: '0.00', availableCredit: '100.00',
|
||||
creditExpiresOn: '2099-12-31', creditActive: true, accountVersion: 7, pendingRechargeCount: 1, status: 'ACTIVE', lastChangedAt: '',
|
||||
}
|
||||
const recharge: FinanceRechargeRequest = {
|
||||
requestId: '9223372036854775002', requestNo: 'RC202610200001', amount: '500.00', voucherFileId: '9223372036854775003', voucherFileName: '付款凭证.png',
|
||||
status: 'PENDING', version: 8, submittedByName: '测试客户', reviewedByName: '', reviewRemark: '', createdAt: '2026-10-20T01:00:00Z', reviewedAt: '',
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const failure = () => new ApiResponseError('测试操作未成功', 'TEST_REJECTED', 409, null, 'finance-test-request')
|
||||
async function mountPage(detail = true) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-accounts', name: 'finance-customer-accounts', component: FinanceCustomerAccountsView },
|
||||
{ path: '/finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: FinanceCustomerAccountsView },
|
||||
] })
|
||||
await router.push(detail ? '/finance/customer-accounts/' + account.customerId : '/finance/customer-accounts')
|
||||
await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerAccountsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
function activeDialog(wrapper: VueWrapper) {
|
||||
const dialog = wrapper.findAllComponents(ElDialog).find(item => item.props('modelValue'))
|
||||
expect(dialog).toBeDefined()
|
||||
return dialog!
|
||||
}
|
||||
async function click(wrapper: VueWrapper, text: string, selector = 'button') {
|
||||
const button = wrapper.findAll(selector).find(item => item.text().trim() === text)
|
||||
expect(button, text).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
async function openAdjustment(type: 'RECHARGE' | 'DEDUCTION', detail = true) {
|
||||
const wrapper = await mountPage(detail)
|
||||
await click(wrapper, detail ? (type === 'RECHARGE' ? '手工充值' : '手工扣款') : (type === 'RECHARGE' ? '充值' : '扣款'), detail ? 'button' : '.el-table__body-wrapper button')
|
||||
return wrapper
|
||||
}
|
||||
async function openCredit() {
|
||||
const wrapper = await mountPage()
|
||||
await click(wrapper, '设置授信')
|
||||
return wrapper
|
||||
}
|
||||
async function openReview() {
|
||||
const wrapper = await mountPage()
|
||||
await click(wrapper, '审核', '.recharge-table button')
|
||||
return wrapper
|
||||
}
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
can.mockReturnValue(true)
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.stubGlobal('URL', class extends URL {
|
||||
static createObjectURL = vi.fn(() => 'blob:finance-current')
|
||||
static revokeObjectURL = vi.fn()
|
||||
})
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
||||
vi.mocked(api.listFinanceAccounts).mockResolvedValue({ records: [account], total: 1, pages: 1 })
|
||||
vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [], total: 0, pages: 1 })
|
||||
vi.mocked(api.listFinanceRechargeRequests).mockResolvedValue({ records: [recharge], total: 1, pages: 1 })
|
||||
vi.mocked(api.adjustFinanceAccount).mockResolvedValue({})
|
||||
vi.mocked(api.updateFinanceCreditLimit).mockResolvedValue({})
|
||||
vi.mocked(api.reviewFinanceRechargeRequest).mockResolvedValue({ ...recharge, status: 'APPROVED', version: 9 })
|
||||
vi.mocked(api.downloadFinanceRechargeVoucher).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
describe('customer wallet native dialogs', () => {
|
||||
it.each([true, false])('opens the same recharge dialog from detail=%s with plain authoritative balances', async detail => {
|
||||
const wrapper = await openAdjustment('RECHARGE', detail)
|
||||
const dialog = activeDialog(wrapper)
|
||||
expect((wrapper.vm as unknown as { adjustmentAccount: FinanceAccountSummary }).adjustmentAccount).toMatchObject(account)
|
||||
expect(dialog.props('width')).toContain('720px')
|
||||
expect(dialog.props('title')).toBe('手工充值 · 测试客户')
|
||||
expect(dialog.get('[data-tone="summary"]').text()).toContain('¥-20.00')
|
||||
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
||||
expect(dialog.find('[data-tone="work"] .density-table--editable').exists()).toBe(true)
|
||||
})
|
||||
it.each(['RECHARGE', 'DEDUCTION'] as const)('preserves %s fields, account version and refresh after fullscreen editing', async type => {
|
||||
const wrapper = await openAdjustment(type)
|
||||
const dialog = activeDialog(wrapper)
|
||||
if (type === 'DEDUCTION') {
|
||||
expect(dialog.get('[data-tone="summary"]').text()).toContain('¥80.00')
|
||||
expect(dialog.get('.el-alert').text()).toContain('不足部分再使用可用授信')
|
||||
}
|
||||
await dialog.get('input[placeholder="请输入大于 0 的金额"]').setValue('12.3')
|
||||
await dialog.get('input[placeholder="请输入关联业务单号(选填)"]').setValue(' RC-TEST ')
|
||||
await dialog.get('textarea').setValue(' 测试备注 ')
|
||||
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-20')
|
||||
dialog.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CASH')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.props('fullscreen')).toBe(true)
|
||||
expect((dialog.get('input[placeholder="请输入大于 0 的金额"]').element as HTMLInputElement).value).toBe('12.3')
|
||||
await click(dialog, type === 'RECHARGE' ? '确认充值' : '确认扣款')
|
||||
expect(api.adjustFinanceAccount).toHaveBeenCalledWith(account.customerId, { type, amount: '12.30', occurredDate: '2026-10-20', paymentMethod: 'CASH', referenceNo: 'RC-TEST', remark: '测试备注', expectedAccountVersion: 7 })
|
||||
expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(2)
|
||||
expect(api.listFinanceRechargeRequests).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it.each(['', '0', '-1', 'invalid'])('keeps invalid adjustment %s from reaching the API', async amount => {
|
||||
const wrapper = await openAdjustment('RECHARGE')
|
||||
const dialog = activeDialog(wrapper)
|
||||
await dialog.get('input[placeholder="请输入大于 0 的金额"]').setValue(amount)
|
||||
await click(dialog, '确认充值')
|
||||
expect(api.adjustFinanceAccount).not.toHaveBeenCalled()
|
||||
})
|
||||
it('locks adjustment controls and close paths, then keeps the rejected draft', async () => {
|
||||
const pending = deferred<Awaited<ReturnType<typeof api.adjustFinanceAccount>>>()
|
||||
vi.mocked(api.adjustFinanceAccount).mockReturnValue(pending.promise)
|
||||
const error = vi.spyOn(ElMessage, 'error')
|
||||
const wrapper = await openAdjustment('DEDUCTION')
|
||||
const dialog = activeDialog(wrapper)
|
||||
await dialog.get('input[placeholder="请输入大于 0 的金额"]').setValue('10')
|
||||
await dialog.get('textarea').setValue('保留备注')
|
||||
await click(dialog, '确认扣款')
|
||||
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
||||
await click(dialog, '取消')
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(error).toHaveBeenCalledWith('测试操作未成功')
|
||||
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留备注')
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeUndefined()
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('submits zero credit with a far-future expiry and the original account version', async () => {
|
||||
const wrapper = await openCredit()
|
||||
const dialog = activeDialog(wrapper)
|
||||
expect(dialog.props('width')).toContain('560px')
|
||||
expect(dialog.get('[data-tone="summary"]').text()).toContain('¥0.00')
|
||||
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
||||
expect(dialog.get('.el-alert').text()).toContain('次日授信失效')
|
||||
const date = dialog.getComponent({ name: 'ElDatePicker' })
|
||||
const disabledDate = date.props('disabledDate') as (value: Date) => boolean
|
||||
expect(disabledDate(new Date('2099-12-31T00:00:00'))).toBe(false)
|
||||
await dialog.get('input[placeholder="请输入大于或等于 0 的金额"]').setValue('0')
|
||||
await dialog.get('textarea').setValue(' 清零额度 ')
|
||||
date.vm.$emit('update:modelValue', '2099-12-31')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, '保存授信额度')
|
||||
expect(api.updateFinanceCreditLimit).toHaveBeenCalledWith(account.customerId, { creditLimit: '0.00', creditExpiresOn: '2099-12-31', remark: '清零额度', expectedAccountVersion: 7 })
|
||||
})
|
||||
it.each(['-1', '1.234', ''])('preserves credit amount validation for %s', async amount => {
|
||||
const wrapper = await openCredit()
|
||||
const dialog = activeDialog(wrapper)
|
||||
await dialog.get('input[placeholder="请输入大于或等于 0 的金额"]').setValue(amount)
|
||||
await click(dialog, '保存授信额度')
|
||||
expect(api.updateFinanceCreditLimit).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rejects a past expiry without imposing a maximum future date', async () => {
|
||||
const wrapper = await openCredit()
|
||||
const dialog = activeDialog(wrapper)
|
||||
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2000-01-01')
|
||||
await flushPromises()
|
||||
await click(dialog, '保存授信额度')
|
||||
expect(api.updateFinanceCreditLimit).not.toHaveBeenCalled()
|
||||
})
|
||||
it('retains credit input and restores close controls after a rejected request', async () => {
|
||||
const pending = deferred<Awaited<ReturnType<typeof api.updateFinanceCreditLimit>>>()
|
||||
vi.mocked(api.updateFinanceCreditLimit).mockReturnValue(pending.promise)
|
||||
const wrapper = await openCredit()
|
||||
const dialog = activeDialog(wrapper)
|
||||
await dialog.get('input[placeholder="请输入大于或等于 0 的金额"]').setValue('321.09')
|
||||
await click(dialog, '保存授信额度')
|
||||
expect(dialog.props('showClose')).toBe(false)
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(dialog.props('showClose')).toBe(true)
|
||||
expect((dialog.get('input[placeholder="请输入大于或等于 0 的金额"]').element as HTMLInputElement).value).toBe('321.09')
|
||||
})
|
||||
it.each(['APPROVE', 'REJECT'] as const)('reviews %s with plain request details and the original request version', async decision => {
|
||||
const wrapper = await openReview()
|
||||
const dialog = activeDialog(wrapper)
|
||||
expect(dialog.props('title')).toBe('审核充值申请 · ' + recharge.requestNo)
|
||||
expect(dialog.props('width')).toContain('620px')
|
||||
expect(dialog.get('[data-tone="source"]').text()).toContain('¥500.00')
|
||||
expect(dialog.find('[data-tone="source"] input').exists()).toBe(false)
|
||||
expect(dialog.text()).not.toContain(recharge.requestId)
|
||||
dialog.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', decision)
|
||||
await dialog.get('textarea').setValue(' 测试审核 ')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, '确认审核')
|
||||
expect(api.reviewFinanceRechargeRequest).toHaveBeenCalledWith(recharge.requestId, { decision, remark: '测试审核', expectedVersion: 8 })
|
||||
})
|
||||
it('locks review controls during submission and keeps the failed decision and remark', async () => {
|
||||
const pending = deferred<FinanceRechargeRequest>()
|
||||
vi.mocked(api.reviewFinanceRechargeRequest).mockReturnValue(pending.promise)
|
||||
const wrapper = await openReview()
|
||||
const dialog = activeDialog(wrapper)
|
||||
dialog.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECT')
|
||||
await dialog.get('textarea').setValue('保留审核意见')
|
||||
await click(dialog, '确认审核')
|
||||
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(dialog.getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('REJECT')
|
||||
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留审核意见')
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('keeps independent adjustment and review permissions on the account detail', async () => {
|
||||
can.mockImplementation(permission => permission === 'platform:finance:customer-account:list')
|
||||
const wrapper = await mountPage()
|
||||
const labels = wrapper.findAll('button').map(button => button.text().trim())
|
||||
for (const label of ['手工充值', '手工扣款', '设置授信', '审核']) expect(labels).not.toContain(label)
|
||||
expect(labels).toContain(recharge.voucherFileName)
|
||||
})
|
||||
it('previews the authenticated voucher, preserves its URL through fullscreen and revokes on close', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await click(wrapper, recharge.voucherFileName, '.recharge-table button')
|
||||
const dialog = activeDialog(wrapper)
|
||||
expect(api.downloadFinanceRechargeVoucher).toHaveBeenCalledWith(recharge.requestId)
|
||||
expect(dialog.props('width')).toContain('920px')
|
||||
expect(dialog.get('.voucher-preview__image').attributes('src')).toBe('blob:finance-current')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.props('fullscreen')).toBe(true)
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
dialog.vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:finance-current')
|
||||
})
|
||||
it('does not create an image URL for a voucher response arriving after close', async () => {
|
||||
const pending = deferred<Blob>()
|
||||
vi.mocked(api.downloadFinanceRechargeVoucher).mockReturnValue(pending.promise)
|
||||
const wrapper = await mountPage()
|
||||
await click(wrapper, recharge.voucherFileName, '.recharge-table button')
|
||||
activeDialog(wrapper).vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
pending.resolve(new Blob(['late'], { type: 'image/png' }))
|
||||
await flushPromises()
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps the current long-named voucher when an older request finishes after reopen', async () => {
|
||||
const pending = deferred<Blob>()
|
||||
const filename = '第二份很长的付款凭证名称'.repeat(20) + '.png'
|
||||
vi.mocked(api.listFinanceRechargeRequests).mockResolvedValue({ records: [recharge, { ...recharge, requestId: '9223372036854775004', requestNo: 'RC202610200002', voucherFileName: filename }], total: 2, pages: 1 })
|
||||
vi.mocked(api.downloadFinanceRechargeVoucher).mockReturnValueOnce(pending.promise)
|
||||
const wrapper = await mountPage()
|
||||
await click(wrapper, recharge.voucherFileName, '.recharge-table button')
|
||||
activeDialog(wrapper).vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
await click(wrapper, filename, '.recharge-table button')
|
||||
expect(activeDialog(wrapper).props('title')).toBe(filename)
|
||||
pending.resolve(new Blob(['old'], { type: 'image/png' }))
|
||||
await flushPromises()
|
||||
expect(activeDialog(wrapper).get('.voucher-preview__image').attributes('src')).toBe('blob:finance-current')
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('reports voucher download failure and allows reopening the same request', async () => {
|
||||
vi.mocked(api.downloadFinanceRechargeVoucher).mockRejectedValueOnce(failure())
|
||||
const error = vi.spyOn(ElMessage, 'error')
|
||||
const wrapper = await mountPage()
|
||||
await click(wrapper, recharge.voucherFileName, '.recharge-table button')
|
||||
expect(error).toHaveBeenCalledWith('测试操作未成功')
|
||||
expect(wrapper.findAllComponents(ElDialog).some(item => item.props('modelValue'))).toBe(false)
|
||||
await click(wrapper, recharge.voucherFileName, '.recharge-table button')
|
||||
expect(activeDialog(wrapper).find('.voucher-preview__image').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,334 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import * as api from '../api/finance'
|
||||
import type { FinanceFeeSourceDetail, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
|
||||
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
|
||||
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
|
||||
|
||||
const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
listFinanceFeeSources: vi.fn(), getFinanceFeeSource: vi.fn(), reviewFinanceFeeSource: vi.fn(),
|
||||
editFinanceFeeSource: vi.fn(), adjustFinanceFeeSource: vi.fn(), submitFinanceFeeSource: vi.fn(), voidFinanceFeeSource: vi.fn(),
|
||||
listFinanceStatements: vi.fn(), getReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(), actionReconciliationStatement: vi.fn(),
|
||||
}))
|
||||
const customer: PlatformTenantResource = {
|
||||
id: '9223372036854775001', tenantCode: 'C-TEST', tenantName: '测试客户', status: 'ACTIVE', legalName: null, legalRepresentative: null,
|
||||
unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null,
|
||||
paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null,
|
||||
internalRemark: null, createdAt: null, approvedAt: null,
|
||||
}
|
||||
const fee: FinanceFeeSourceDetail = {
|
||||
poolItemId: '9223372036854775002', feeSourceId: '9223372036854775003', direction: 'RECEIVABLE', sourceType: 'MANUAL', sourceId: '9223372036854775004',
|
||||
sourceFeeId: '9223372036854775004', sourceVersion: 3, sourceSystem: 'FINANCE', sourceBusinessId: 'IN-TEST-1',
|
||||
customerId: String(customer.id), customerName: customer.tenantName, supplierId: null, supplierName: '', orderId: null,
|
||||
inboundId: '9223372036854775005', inboundCode: 'IN-TEST-1', containerId: null, containerNo: '', businessDate: '2026-10-01', feeItemName: '运费',
|
||||
quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'DRAFT', sourceStatus: 'AVAILABLE', status: 'AVAILABLE',
|
||||
statementGenerated: false, operationVersion: 9, createdAt: '2026-10-01', operationLogs: [],
|
||||
}
|
||||
const statement: FinanceStatementReadModel = {
|
||||
id: '9223372036854775010', customerId: String(customer.id), billNo: 'REC202610200001', customerName: customer.tenantName,
|
||||
billDate: '2026-10-20', dueDate: '', periodStart: '2026-10-01', periodEnd: '2026-10-20', creditDays: 30, lineCount: 1, sourceCount: 1,
|
||||
totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, currency: 'CNY', status: 'ISSUED', version: 3,
|
||||
}
|
||||
const detail: FinanceReconciliationDetail = {
|
||||
...statement, paidAmount: 0, periodStart: '2026-10-01', periodEnd: '2026-10-20', creditDays: 30, settled: false, remark: '', version: 8,
|
||||
lines: [{ statementLineId: '9223372036854775011', sourceFeeId: fee.sourceFeeId, sourceFeeVersion: 3, sourceHash: 'test-hash', direction: 'RECEIVABLE',
|
||||
sourceType: 'MANUAL', sourceNo: 'IN-TEST-1', orderId: null, inboundId: fee.inboundId, inboundCode: 'IN-TEST-1', containerId: null, containerNo: '',
|
||||
businessDate: '2026-10-01', quantity: 2, unitPrice: 50, feeItemName: '运费', sourceAmount: 100, amount: 100 }],
|
||||
paymentHistory: [], revisions: [],
|
||||
}
|
||||
const failure = (message = '测试拒绝操作', code = 'TEST_REJECTED') => new ApiResponseError(message, code, 409, null, 'finance-dialog-test')
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
function setFee(overrides: Partial<FinanceFeeSourceDetail> = {}) {
|
||||
const current = { ...fee, ...overrides }
|
||||
vi.mocked(api.listFinanceFeeSources).mockResolvedValue({ records: [current], total: 1, pages: 1 })
|
||||
vi.mocked(api.getFinanceFeeSource).mockResolvedValue(current)
|
||||
}
|
||||
async function mountPage(kind: 'arap' | 'statements') {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView },
|
||||
{ path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView },
|
||||
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
|
||||
{ path: '/finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: FinanceCustomerStatementsView },
|
||||
] })
|
||||
await router.push(kind === 'arap' ? '/finance/customer-arap' : '/finance/customer-statements')
|
||||
await router.isReady()
|
||||
const wrapper = mount(kind === 'arap' ? FinanceCustomerArapView : FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
function activeDialog(wrapper: VueWrapper) {
|
||||
const dialog = wrapper.findAllComponents(ElDialog).find(item => item.props('modelValue'))
|
||||
expect(dialog).toBeDefined()
|
||||
return dialog!
|
||||
}
|
||||
async function click(wrapper: VueWrapper, text: string, selector = 'button') {
|
||||
const button = wrapper.findAll(selector).find(item => item.text().trim() === text)
|
||||
expect(button, text).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
async function rowClick(wrapper: VueWrapper, label: string) { await click(wrapper, label, '.el-table__body-wrapper button') }
|
||||
async function openCreate() {
|
||||
const { wrapper, router } = await mountPage('statements')
|
||||
await click(wrapper, '生成对账单')
|
||||
return { wrapper, router, dialog: activeDialog(wrapper) }
|
||||
}
|
||||
async function fillCreation(dialog: VueWrapper, selected = false) {
|
||||
dialog.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', selected ? 'SELECTED' : 'BY_CUTOFF')
|
||||
dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', String(customer.id))
|
||||
const dates = dialog.findAllComponents({ name: 'ElDatePicker' })
|
||||
dates[0]!.vm.$emit('update:modelValue', '2026-10-01')
|
||||
dates[1]!.vm.$emit('update:modelValue', '2026-10-20')
|
||||
await flushPromises()
|
||||
await dialog.get('textarea').setValue('测试备注')
|
||||
}
|
||||
async function openAdjustment() {
|
||||
const { wrapper, router } = await mountPage('statements')
|
||||
await rowClick(wrapper, '调整')
|
||||
return { wrapper, router, dialog: activeDialog(wrapper) }
|
||||
}
|
||||
async function fillAdjustment(dialog: VueWrapper) {
|
||||
await dialog.get('textarea[placeholder="请输入整体调整原因"]').setValue(' 整体调整 ')
|
||||
await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
|
||||
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 88.12)
|
||||
await dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').setValue(' 行说明 ')
|
||||
}
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
can.mockReturnValue(true)
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
||||
setFee()
|
||||
for (const method of [api.reviewFinanceFeeSource, api.editFinanceFeeSource, api.adjustFinanceFeeSource, api.submitFinanceFeeSource, api.voidFinanceFeeSource]) vi.mocked(method).mockResolvedValue(fee)
|
||||
vi.mocked(api.listFinanceStatements).mockResolvedValue([statement])
|
||||
vi.mocked(api.getReconciliationStatement).mockResolvedValue(detail)
|
||||
vi.mocked(api.createReconciliationStatement).mockResolvedValue({ ...detail, id: '9223372036854775012', billNo: 'REC202610200002', status: 'DRAFT' })
|
||||
vi.mocked(api.actionReconciliationStatement).mockResolvedValue(detail)
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
describe('fee source and statement native dialogs', () => {
|
||||
it.each(['APPROVE', 'REJECT'] as const)('reviews %s with plain fee facts and the loaded operation version', async action => {
|
||||
setFee({ reviewStatus: 'PENDING' })
|
||||
const { wrapper } = await mountPage('arap')
|
||||
await rowClick(wrapper, '审核')
|
||||
const dialog = activeDialog(wrapper)
|
||||
expect(dialog.props('title')).toContain('应收费用 · 运费')
|
||||
expect(dialog.get('[data-tone="source"]').text()).toContain('IN-TEST-1')
|
||||
expect(dialog.find('[data-tone="source"] input').exists()).toBe(false)
|
||||
expect(dialog.text()).not.toContain(fee.feeSourceId)
|
||||
dialog.getComponent({ name: 'ElSegmented' }).vm.$emit('update:modelValue', action)
|
||||
await dialog.get('textarea').setValue(' 审核说明 ')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.props('fullscreen')).toBe(true)
|
||||
await click(dialog, '提交审核')
|
||||
expect(api.reviewFinanceFeeSource).toHaveBeenCalledWith(fee.feeSourceId, { action, reason: '审核说明', expectedVersion: 9 })
|
||||
})
|
||||
it('retains required rejection reasons', async () => {
|
||||
setFee({ reviewStatus: 'PENDING' })
|
||||
const { wrapper } = await mountPage('arap')
|
||||
await rowClick(wrapper, '审核')
|
||||
const dialog = activeDialog(wrapper)
|
||||
dialog.getComponent({ name: 'ElSegmented' }).vm.$emit('update:modelValue', 'REJECT')
|
||||
await flushPromises()
|
||||
await click(dialog, '提交审核')
|
||||
expect(api.reviewFinanceFeeSource).not.toHaveBeenCalled()
|
||||
expect(dialog.get('textarea').attributes('placeholder')).toBe('请填写驳回原因')
|
||||
})
|
||||
it.each([
|
||||
['编辑', 'DRAFT', 'editFinanceFeeSource'], ['调整', 'APPROVED', 'adjustFinanceFeeSource'],
|
||||
['提交审核', 'DRAFT', 'submitFinanceFeeSource'], ['作废', 'APPROVED', 'voidFinanceFeeSource'],
|
||||
] as const)('keeps %s command, reason, amount applicability and version', async (label, status, method) => {
|
||||
setFee({ reviewStatus: status })
|
||||
const { wrapper } = await mountPage('arap')
|
||||
await rowClick(wrapper, label)
|
||||
const dialog = activeDialog(wrapper)
|
||||
const hasAmount = label === '编辑' || label === '调整'
|
||||
expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(hasAmount)
|
||||
await dialog.get('textarea').setValue(' 操作说明 ')
|
||||
if (hasAmount) dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 123.45)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, label === '作废' ? '确认作废' : '确认')
|
||||
expect(api[method]).toHaveBeenCalledWith(fee.feeSourceId, { reason: '操作说明', expectedVersion: 9, ...(hasAmount ? { amount: 123.45 } : {}) })
|
||||
})
|
||||
it('keeps operation reasons mandatory before editing', async () => {
|
||||
const { wrapper } = await mountPage('arap')
|
||||
await rowClick(wrapper, '编辑')
|
||||
await click(activeDialog(wrapper), '确认')
|
||||
expect(api.editFinanceFeeSource).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['review', 'edit'] as const)('locks %s forms and keeps failed drafts and diagnostics', async mode => {
|
||||
const pending = deferred<FinanceFeeSourceDetail>()
|
||||
if (mode === 'review') { setFee({ reviewStatus: 'PENDING' }); vi.mocked(api.reviewFinanceFeeSource).mockReturnValue(pending.promise) }
|
||||
else vi.mocked(api.editFinanceFeeSource).mockReturnValue(pending.promise)
|
||||
const { wrapper } = await mountPage('arap')
|
||||
await rowClick(wrapper, mode === 'review' ? '审核' : '编辑')
|
||||
const dialog = activeDialog(wrapper)
|
||||
await dialog.get('textarea').setValue('保留操作说明')
|
||||
await click(dialog, mode === 'review' ? '提交审核' : '确认')
|
||||
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(OperationErrorToast).props('details')).toMatchObject({ requestId: 'finance-dialog-test' })
|
||||
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留操作说明')
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('shows the current mutation failure after a previous review failed', async () => {
|
||||
setFee({ reviewStatus: 'PENDING' })
|
||||
vi.mocked(api.reviewFinanceFeeSource).mockRejectedValueOnce(failure('审核失败'))
|
||||
vi.mocked(api.voidFinanceFeeSource).mockRejectedValueOnce(failure('作废失败'))
|
||||
const { wrapper } = await mountPage('arap')
|
||||
await rowClick(wrapper, '审核')
|
||||
await click(activeDialog(wrapper), '提交审核')
|
||||
await click(activeDialog(wrapper), '取消')
|
||||
await rowClick(wrapper, '作废')
|
||||
await activeDialog(wrapper).get('textarea').setValue('测试作废')
|
||||
await click(activeDialog(wrapper), '确认作废')
|
||||
expect(wrapper.getComponent(OperationErrorToast).props('details')).toMatchObject({ message: '作废失败' })
|
||||
})
|
||||
it.each(['FIRSTLEG', 'LASTLEG'])('keeps %s upstream adjustment and void actions unavailable', async system => {
|
||||
setFee({ sourceSystem: system, reviewStatus: 'APPROVED' })
|
||||
const { wrapper } = await mountPage('arap')
|
||||
expect(wrapper.get('.el-table__body-wrapper .density-actions').findAll('button').map(button => button.text())).toEqual(['详情'])
|
||||
})
|
||||
|
||||
it('creates a cutoff statement with the original customer, CNY and period payload', async () => {
|
||||
const { dialog, router } = await openCreate()
|
||||
expect(dialog.props('width')).toContain('680px')
|
||||
await fillCreation(dialog)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, '确定')
|
||||
expect(api.createReconciliationStatement).toHaveBeenCalledWith({ customerId: String(customer.id), currency: 'CNY', generationMode: 'BY_CUTOFF', feeSourceIds: undefined, periodStart: '2026-10-01', periodEnd: '2026-10-20', remark: '测试备注' })
|
||||
expect(router.currentRoute.value.name).toBe('finance-customer-statement-detail')
|
||||
})
|
||||
it('keeps selected-source filtering, summary and complete long IDs through fullscreen', async () => {
|
||||
setFee({ reviewStatus: 'APPROVED' })
|
||||
const { dialog } = await openCreate()
|
||||
await fillCreation(dialog, true)
|
||||
expect(api.listFinanceFeeSources).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: String(customer.id), direction: 'RECEIVABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', statementGenerated: false }))
|
||||
const select = dialog.findAllComponents({ name: 'ElSelect' }).find(item => item.props('multiple'))!
|
||||
select.vm.$emit('update:modelValue', [fee.feeSourceId])
|
||||
await flushPromises()
|
||||
expect(dialog.get('.selected-source-summary').text()).toContain('已选 1 笔,合计 100.00 CNY')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, '确定')
|
||||
expect(api.createReconciliationStatement).toHaveBeenCalledWith(expect.objectContaining({ generationMode: 'SELECTED', feeSourceIds: [fee.feeSourceId] }))
|
||||
})
|
||||
it('preserves customer and period validation before creating', async () => {
|
||||
const { dialog } = await openCreate()
|
||||
await click(dialog, '确定')
|
||||
expect(api.createReconciliationStatement).not.toHaveBeenCalled()
|
||||
await fillCreation(dialog)
|
||||
dialog.findAllComponents({ name: 'ElDatePicker' })[0]!.vm.$emit('update:modelValue', '2026-11-01')
|
||||
await flushPromises()
|
||||
await click(dialog, '确定')
|
||||
expect(api.createReconciliationStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps selected-source mode disabled without its independent read permission', async () => {
|
||||
can.mockImplementation(permission => permission !== 'platform:finance:customer-arap:list')
|
||||
const { dialog } = await openCreate()
|
||||
expect(dialog.findAllComponents({ name: 'ElRadioButton' })[1]!.props('disabled')).toBe(true)
|
||||
})
|
||||
it('keeps eligible-source loading errors visible and retries the same selection context', async () => {
|
||||
vi.mocked(api.listFinanceFeeSources).mockRejectedValueOnce(failure('费用加载失败'))
|
||||
const { dialog } = await openCreate()
|
||||
await fillCreation(dialog, true)
|
||||
expect(dialog.get('.el-alert').text()).toContain('可生成费用加载失败')
|
||||
await click(dialog, '重试')
|
||||
expect(dialog.find('.el-alert').exists()).toBe(false)
|
||||
expect(api.listFinanceFeeSources).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: String(customer.id), businessDateFrom: '2026-10-01', businessDateTo: '2026-10-20' }))
|
||||
})
|
||||
it('locks creation then retains the draft and no-eligible-source feedback after failure', async () => {
|
||||
const pending = deferred<FinanceReconciliationDetail>()
|
||||
vi.mocked(api.createReconciliationStatement).mockReturnValue(pending.promise)
|
||||
const { dialog } = await openCreate()
|
||||
await fillCreation(dialog)
|
||||
await click(dialog, '确定')
|
||||
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure('没有可生成的费用', 'FIN_409'))
|
||||
await flushPromises()
|
||||
expect(dialog.get('.el-alert').text()).toContain('所选账期内没有可生成的应收费用')
|
||||
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('测试备注')
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('keeps all adjustment rows in the main dialog flow with plain statement facts', async () => {
|
||||
vi.mocked(api.getReconciliationStatement).mockResolvedValue({ ...detail, lines: Array.from({ length: 13 }, (_, index) => ({ ...detail.lines[0]!, statementLineId: 'line-' + index, feeItemName: '费用' + (index + 1) })) })
|
||||
const { dialog } = await openAdjustment()
|
||||
expect(dialog.props('width')).toContain('900px')
|
||||
expect(dialog.props('title')).toContain(statement.billNo)
|
||||
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
||||
expect(dialog.getComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
|
||||
expect(dialog.findAll('.el-table__body-wrapper tr')).toHaveLength(13)
|
||||
expect(dialog.get('.el-table__body-wrapper').text()).toContain('费用13')
|
||||
})
|
||||
it('saves only checked lines with full IDs and the freshly loaded version', async () => {
|
||||
const { dialog } = await openAdjustment()
|
||||
await fillAdjustment(dialog)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, '确定')
|
||||
expect(api.actionReconciliationStatement).toHaveBeenCalledWith(statement.id, { action: 'ADJUST', reason: '整体调整', expectedVersion: 8, adjustmentLines: [{ statementLineId: '9223372036854775011', adjustedAmount: 88.12, reason: '行说明' }] })
|
||||
})
|
||||
it('requires an overall reason, selection and each selected line reason', async () => {
|
||||
const { dialog } = await openAdjustment()
|
||||
await click(dialog, '确定')
|
||||
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
|
||||
await dialog.get('textarea').setValue('整体调整')
|
||||
await click(dialog, '确定')
|
||||
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
|
||||
await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
|
||||
await click(dialog, '确定')
|
||||
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
it('locks adjustment rows and retains their state with visible failure diagnostics', async () => {
|
||||
const pending = deferred<FinanceReconciliationDetail>()
|
||||
vi.mocked(api.actionReconciliationStatement).mockReturnValue(pending.promise)
|
||||
const { dialog } = await openAdjustment()
|
||||
await fillAdjustment(dialog)
|
||||
await click(dialog, '确定')
|
||||
expect(dialog.props('showClose')).toBe(false)
|
||||
expect(dialog.get('.el-table__body-wrapper input[type="checkbox"]').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(dialog.getComponent(OperationErrorToast).props('details')).toMatchObject({ message: '测试拒绝操作', requestId: 'finance-dialog-test' })
|
||||
expect((dialog.get('.el-table__body-wrapper input[type="checkbox"]').element as HTMLInputElement).checked).toBe(true)
|
||||
expect((dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').element as HTMLInputElement).value).toBe(' 行说明 ')
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it.each([
|
||||
['驳回', 'PENDING_REVIEW', 'REJECT'], ['作废', 'DRAFT', 'VOID'], ['重开', 'REJECTED', 'REOPEN'],
|
||||
] as const)('identifies %s reason prompts without changing the state command', async (label, status, action) => {
|
||||
vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, status }])
|
||||
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: '测试原因', action: 'confirm' as const }))
|
||||
const { wrapper } = await mountPage('statements')
|
||||
await rowClick(wrapper, label)
|
||||
expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(statement.billNo), expect.objectContaining({ customClass: 'admin-density-confirm', inputPattern: /\S+/ }))
|
||||
expect(api.actionReconciliationStatement).toHaveBeenCalledWith(statement.id, { action, reason: '测试原因', expectedVersion: 3 })
|
||||
})
|
||||
it('does not change statement state when its reason prompt is cancelled', async () => {
|
||||
vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, status: 'REJECTED' }])
|
||||
vi.spyOn(ElMessageBox, 'prompt').mockRejectedValue('cancel')
|
||||
const { wrapper } = await mountPage('statements')
|
||||
await rowClick(wrapper, '重开')
|
||||
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '../financeDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -11,6 +11,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -70,6 +71,8 @@ const voucherPreviewOpen = ref(false)
|
||||
const voucherPreviewLoading = ref(false)
|
||||
const voucherPreviewUrl = ref('')
|
||||
const voucherPreviewName = ref('付款凭证')
|
||||
let voucherPreviewRequest = 0
|
||||
watch(voucherPreviewOpen, value => { if (!value) clearVoucherPreview() })
|
||||
|
||||
const canView = computed(() => access.can('platform:finance:customer-account:list'))
|
||||
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
|
||||
@@ -241,7 +244,7 @@ function openAdjustment(account: FinanceAccountSummary, type: 'RECHARGE' | 'DEDU
|
||||
adjustmentOpen.value = true
|
||||
}
|
||||
async function submitAdjustment() {
|
||||
if (!adjustmentAccount.value) return
|
||||
if (adjustmentSaving.value || !adjustmentAccount.value) return
|
||||
const amount = amountInput(adjustmentForm.amount)
|
||||
if (!amount || !adjustmentForm.occurredDate || !adjustmentForm.paymentMethod) { ElMessage.warning('请填写有效金额、发生日期和付款方式'); return }
|
||||
adjustmentSaving.value = true
|
||||
@@ -287,7 +290,7 @@ function handleAccountAction(command: string, account: FinanceAccountSummary) {
|
||||
else handleAccountMore(command, account)
|
||||
}
|
||||
async function submitCreditLimit() {
|
||||
if (!adjustmentAccount.value) return
|
||||
if (creditLimitSaving.value || !adjustmentAccount.value) return
|
||||
const raw = creditLimitForm.amount.trim()
|
||||
const amount = Number(raw)
|
||||
if (!Number.isFinite(amount) || amount < 0 || !/^\d+(?:\.\d{1,2})?$/.test(raw)) {
|
||||
@@ -318,7 +321,7 @@ function openReview(request: FinanceRechargeRequest) {
|
||||
reviewOpen.value = true
|
||||
}
|
||||
async function submitReview() {
|
||||
if (!reviewRequest.value) return
|
||||
if (reviewSaving.value || !reviewRequest.value) return
|
||||
reviewSaving.value = true
|
||||
try {
|
||||
await reviewFinanceRechargeRequest(reviewRequest.value.requestId, {
|
||||
@@ -331,21 +334,25 @@ async function submitReview() {
|
||||
finally { reviewSaving.value = false }
|
||||
}
|
||||
function clearVoucherPreview() {
|
||||
voucherPreviewRequest++
|
||||
if (voucherPreviewUrl.value) URL.revokeObjectURL(voucherPreviewUrl.value)
|
||||
voucherPreviewUrl.value = ''
|
||||
}
|
||||
async function previewVoucher(request: FinanceRechargeRequest) {
|
||||
clearVoucherPreview()
|
||||
const requestSequence = ++voucherPreviewRequest
|
||||
voucherPreviewName.value = request.voucherFileName || '付款凭证'
|
||||
voucherPreviewOpen.value = true
|
||||
voucherPreviewLoading.value = true
|
||||
try {
|
||||
const blob = await downloadFinanceRechargeVoucher(request.requestId)
|
||||
if (requestSequence !== voucherPreviewRequest || !voucherPreviewOpen.value) return
|
||||
voucherPreviewUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) {
|
||||
if (requestSequence !== voucherPreviewRequest || !voucherPreviewOpen.value) return
|
||||
voucherPreviewOpen.value = false
|
||||
ElMessage.error(getApiErrorMessage(error, '付款凭证加载失败'))
|
||||
} finally { voucherPreviewLoading.value = false }
|
||||
} finally { if (requestSequence === voucherPreviewRequest) voucherPreviewLoading.value = false }
|
||||
}
|
||||
function customerName(customerId: string, snapshot?: string) {
|
||||
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
|
||||
@@ -483,20 +490,54 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
</DetailPageLayout>
|
||||
</section>
|
||||
</AdminPage>
|
||||
<AppDialog class="admin-density-dialog" v-model="adjustmentOpen" :title="adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'" width="min(720px, calc(100vw - 28px))" :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
|
||||
<el-alert :title="adjustmentForm.type === 'RECHARGE' ? '充值会先归还已用授信,剩余金额计入实际余额。' : '扣款先使用未冻结的实际余额,不足部分再使用可用授信。'" :type="adjustmentForm.type === 'RECHARGE' ? 'info' : 'warning'" :closable="false" />
|
||||
<el-form label-position="top" class="wallet-form density-form"><div class="wallet-form__grid"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前实际余额"><el-input :model-value="money(adjustmentAccount?.actualBalance)" readonly /></el-form-item><el-form-item v-if="adjustmentForm.type === 'DEDUCTION'" label="当前可用余额"><el-input :model-value="money(adjustmentAccount?.availableBalance)" readonly /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值金额' : '扣款金额'" required><el-input v-model="adjustmentForm.amount" inputmode="decimal" placeholder="请输入大于 0 的金额" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '到账日期' : '扣款日期'" required><el-date-picker v-model="adjustmentForm.occurredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值方式' : '扣款方式'" required><el-select v-model="adjustmentForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="关联单据"><el-input v-model="adjustmentForm.referenceNo" maxlength="128" placeholder="请输入关联业务单号(选填)" /></el-form-item></div><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值备注' : '扣款备注'"><el-input v-model="adjustmentForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="adjustmentOpen" :title="`${adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'} · ${adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
|
||||
<div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="当前账户资料">
|
||||
<h3 class="density-dialog-section__title">当前账户(CNY) <InlineHelp v-if="adjustmentForm.type === 'RECHARGE'" title="充值说明">充值会先归还已用授信,剩余金额计入实际余额。</InlineHelp></h3>
|
||||
<table class="density-table wallet-summary"><tbody>
|
||||
<tr><th scope="row">客户名称</th><td colspan="3">{{ adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—' }}</td></tr>
|
||||
<tr><th scope="row">当前实际余额</th><td :colspan="adjustmentForm.type === 'DEDUCTION' ? 1 : 3">{{ money(adjustmentAccount?.actualBalance) }}</td><template v-if="adjustmentForm.type === 'DEDUCTION'"><th scope="row">当前可用余额</th><td>{{ money(adjustmentAccount?.availableBalance) }}</td></template></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<el-alert v-if="adjustmentForm.type === 'DEDUCTION'" title="扣款先使用未冻结的实际余额,不足部分再使用可用授信。" type="warning" :closable="false" />
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="账户变动登记">
|
||||
<h3 class="density-dialog-section__title">{{ adjustmentForm.type === 'RECHARGE' ? '充值登记' : '扣款登记' }}</h3>
|
||||
<el-form label-position="left" label-width="100px" class="density-form" :disabled="adjustmentSaving">
|
||||
<table class="density-table density-table--editable wallet-adjustment-table"><tbody>
|
||||
<tr><td><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值金额' : '扣款金额'" required><el-input v-model="adjustmentForm.amount" inputmode="decimal" placeholder="请输入大于 0 的金额" /></el-form-item></td><td><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '到账日期' : '扣款日期'" required><el-date-picker v-model="adjustmentForm.occurredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值方式' : '扣款方式'" required><el-select v-model="adjustmentForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td><td><el-form-item label="关联单据"><el-input v-model="adjustmentForm.referenceNo" maxlength="128" placeholder="请输入关联业务单号(选填)" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值备注' : '扣款备注'"><el-input v-model="adjustmentForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="creditLimitOpen" title="设置授信额度" width="min(560px, calc(100vw - 28px))" confirm-text="保存授信额度" :confirm-loading="creditLimitSaving" @confirm="submitCreditLimit">
|
||||
<el-alert title="授信到期日当天仍有效,次日授信失效;届时负数实际余额将阻止客户提交新订单。" type="warning" :closable="false" />
|
||||
<el-form label-position="top" class="wallet-form density-form"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前已用授信"><el-input :model-value="money(adjustmentAccount?.creditUsed)" readonly /></el-form-item><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item><el-form-item label="授信到期日" required><el-date-picker v-model="creditLimitForm.expiresOn" type="date" value-format="YYYY-MM-DD" :disabled-date="disableCreditExpiry" placeholder="请选择今天以后的日期" /></el-form-item><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="creditLimitOpen" :title="`设置授信额度 · ${adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="保存授信额度" :confirm-loading="creditLimitSaving" @confirm="submitCreditLimit">
|
||||
<div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="当前授信资料"><h3 class="density-dialog-section__title">当前授信(CNY)</h3><table class="density-table wallet-summary"><tbody><tr><th scope="row">客户名称</th><td>{{ adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—' }}</td></tr><tr><th scope="row">当前已用授信</th><td>{{ money(adjustmentAccount?.creditUsed) }}</td></tr></tbody></table></section>
|
||||
<el-alert title="授信到期日当天仍有效,次日授信失效;届时负数实际余额将阻止客户提交新订单。" type="warning" :closable="false" />
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="授信额度设置"><h3 class="density-dialog-section__title">授信设置</h3>
|
||||
<el-form label-position="left" label-width="100px" class="density-form" :disabled="creditLimitSaving"><table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="授信到期日" required><el-date-picker v-model="creditLimitForm.expiresOn" type="date" value-format="YYYY-MM-DD" :disabled-date="disableCreditExpiry" placeholder="请选择今天以后的日期" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="reviewOpen" title="审核充值申请" width="min(620px, calc(100vw - 28px))" confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<el-descriptions class="density-descriptions" v-if="reviewRequest" :column="2" border><el-descriptions-item label="申请单号">{{ reviewRequest.requestNo }}</el-descriptions-item><el-descriptions-item label="充值金额">{{ money(reviewRequest.amount) }}</el-descriptions-item><el-descriptions-item label="付款凭证">{{ reviewRequest.voucherFileName || '—' }}</el-descriptions-item></el-descriptions>
|
||||
<el-form label-position="top" class="review-form density-form"><el-form-item label="审核结果" required><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item><el-form-item label="审核备注"><el-input v-model="reviewForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="reviewOpen" :title="`审核充值申请 · ${reviewRequest?.requestNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<div class="density-dialog-sections">
|
||||
<section v-if="reviewRequest" class="density-dialog-section" data-tone="source" aria-label="充值申请资料"><h3 class="density-dialog-section__title">充值申请</h3><table class="density-table wallet-summary"><tbody><tr><th scope="row">申请单号</th><td>{{ reviewRequest.requestNo || '—' }}</td></tr><tr><th scope="row">充值金额(CNY)</th><td>{{ money(reviewRequest.amount) }}</td></tr><tr><th scope="row">付款凭证</th><td>{{ reviewRequest.voucherFileName || '—' }}</td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="充值审核登记"><h3 class="density-dialog-section__title">审核登记</h3>
|
||||
<el-form label-position="left" label-width="100px" class="density-form" :disabled="reviewSaving"><table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="审核结果" required><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="审核备注"><el-input v-model="reviewForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="voucherPreviewOpen" :title="voucherPreviewName" width="min(920px, calc(100vw - 28px))" :show-footer="false" @closed="clearVoucherPreview">
|
||||
<div v-loading="voucherPreviewLoading" class="voucher-preview"><img v-if="voucherPreviewUrl" class="voucher-preview__image" :src="voucherPreviewUrl" :alt="voucherPreviewName" /></div>
|
||||
<AppDialog class="admin-density-dialog" v-model="voucherPreviewOpen" :title="voucherPreviewName" width="min(920px, calc(100vw - 28px))" fullscreenable :loading="voucherPreviewLoading" :show-footer="false">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="付款凭证"><div class="voucher-preview"><img v-if="voucherPreviewUrl" class="voucher-preview__image" :src="voucherPreviewUrl" :alt="voucherPreviewName" /></div></section>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -628,21 +669,10 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
color: var(--cc-muted);
|
||||
}
|
||||
|
||||
.wallet-form,
|
||||
.review-form {
|
||||
margin-top: var(--admin-space-5);
|
||||
}
|
||||
|
||||
.wallet-form__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 var(--admin-space-5);
|
||||
}
|
||||
|
||||
.wallet-form :deep(.el-date-editor),
|
||||
.wallet-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
.wallet-summary { table-layout: fixed; }
|
||||
.wallet-summary th { width: var(--admin-label-width); }
|
||||
.wallet-adjustment-table { table-layout: fixed; }
|
||||
.wallet-adjustment-table td:not([colspan]) { width: 50%; }
|
||||
|
||||
.detail-footer {
|
||||
width: 100%;
|
||||
@@ -675,12 +705,6 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.wallet-form__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-heading {
|
||||
align-items: stretch;
|
||||
|
||||
@@ -182,7 +182,7 @@ function sourceSummary(sourceType: unknown, orderId: unknown, inboundId: unknown
|
||||
const type = sourceTypeDisplay(sourceType)
|
||||
return relations.length ? `${type} · ${relations.join('、')}` : type
|
||||
}
|
||||
function businessReference(row: ArapRow) {
|
||||
function businessReference(row: Pick<ArapRow, 'inboundCode' | 'containerNo' | 'sourceBusinessId'>) {
|
||||
const references = [row.inboundCode ? `入仓码 ${row.inboundCode}` : '', row.containerNo ? `柜次 ${row.containerNo}` : '']
|
||||
const businessId = row.sourceBusinessId.trim()
|
||||
if (businessId && ![row.inboundCode, row.containerNo].includes(businessId)) references.push(`业务单号 ${businessId}`)
|
||||
@@ -255,10 +255,10 @@ async function openReview(row: ArapRow) {
|
||||
selected.value = row
|
||||
const loaded = await loadDetail(row)
|
||||
if (!loaded) { detailOpen.value = true; return }
|
||||
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; reviewOpen.value = true
|
||||
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; mutationError.value = null; reviewOpen.value = true
|
||||
}
|
||||
async function submitReview() {
|
||||
if (!detail.value) return
|
||||
if (reviewSaving.value || !detail.value) return
|
||||
if (reviewForm.action === 'REJECT' && !reviewForm.reason.trim()) { ElMessage.warning('请填写驳回原因'); return }
|
||||
reviewSaving.value = true; reviewError.value = null
|
||||
try {
|
||||
@@ -277,7 +277,7 @@ async function openMutation(row: ArapRow, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | '
|
||||
}
|
||||
const loaded = await loadDetail(row)
|
||||
if (!loaded) { detailOpen.value = true; return }
|
||||
mutationMode.value = mode; Object.assign(mutationForm, { amount: loaded.amount, reason: '' }); mutationError.value = null; mutationOpen.value = true
|
||||
mutationMode.value = mode; Object.assign(mutationForm, { amount: loaded.amount, reason: '' }); mutationError.value = null; reviewError.value = null; mutationOpen.value = true
|
||||
}
|
||||
function sourceActions(row: ArapRow): RowActionItem[] {
|
||||
const available = row.status === 'AVAILABLE'
|
||||
@@ -301,7 +301,7 @@ function handleSourceAction(command: string, row: ArapRow) {
|
||||
else if (command === 'void') void openMutation(row, 'VOID')
|
||||
}
|
||||
async function submitMutation() {
|
||||
if (!detail.value) return
|
||||
if (mutationSaving.value || !detail.value) return
|
||||
if (!mutationForm.reason.trim()) { ElMessage.warning('请填写操作原因'); return }
|
||||
if ((mutationMode.value === 'EDIT' || mutationMode.value === 'ADJUST') && mutationForm.amount <= 0) { ElMessage.warning('金额必须大于 0'); return }
|
||||
mutationSaving.value = true; mutationError.value = null
|
||||
@@ -423,20 +423,54 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
|
||||
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template>
|
||||
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></FormSection></template>
|
||||
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></FormSection></template>
|
||||
</DetailPageLayout>
|
||||
</section>
|
||||
</AdminPage>
|
||||
<AppDialog class="admin-density-dialog" v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<el-form class="density-form" label-position="top"><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="reviewOpen" :title="`费用来源审核 · ${selected?.documentNo || '费用来源'}`" fullscreenable confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<div class="density-dialog-sections">
|
||||
<section v-if="detail" class="density-dialog-section" data-tone="source" aria-label="费用来源资料">
|
||||
<h3 class="density-dialog-section__title">费用来源</h3>
|
||||
<table class="density-table fee-dialog-facts"><tbody>
|
||||
<tr><th scope="row">客户</th><td colspan="3">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</td></tr>
|
||||
<tr><th scope="row">费用项目</th><td>{{ displayFeeName(detail.feeItemName) || '—' }}</td><th scope="row">金额(CNY)</th><td>{{ money(detail.amount) }}</td></tr>
|
||||
<tr><th scope="row">关联业务</th><td colspan="3">{{ businessReference(detail) }}</td></tr>
|
||||
<tr><th scope="row">收付方向</th><td>{{ financeDirectionLabel(detail.direction) }}</td><th scope="row">审核状态</th><td>{{ statusLabel(detail.reviewStatus) }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="费用审核登记"><h3 class="density-dialog-section__title">审核登记</h3>
|
||||
<el-form class="density-form" label-position="left" label-width="110px" :disabled="reviewSaving"><table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'" :required="reviewForm.action === 'REJECT'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="mutationOpen" :title="mutationTitle" :confirm-text="mutationMode === 'VOID' ? '确认作废' : '确认'" :confirm-loading="mutationSaving" @confirm="submitMutation">
|
||||
<el-form class="density-form" label-position="top"><el-form-item v-if="mutationMode === 'EDIT' || mutationMode === 'ADJUST'" label="金额(CNY)"><el-input-number v-model="mutationForm.amount" :min="0.01" :precision="2" :controls="false" style="width:100%" placeholder="请输入调整金额" /></el-form-item><el-form-item label="操作原因"><el-input v-model="mutationForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请填写操作原因" show-word-limit /></el-form-item></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="mutationOpen" :title="`${mutationTitle} · ${selected?.documentNo || '费用来源'}`" fullscreenable :confirm-type="mutationMode === 'VOID' ? 'danger' : 'primary'" :confirm-text="mutationMode === 'VOID' ? '确认作废' : '确认'" :confirm-loading="mutationSaving" @confirm="submitMutation">
|
||||
<div class="density-dialog-sections">
|
||||
<section v-if="detail" class="density-dialog-section" data-tone="source" aria-label="费用来源资料">
|
||||
<h3 class="density-dialog-section__title">费用来源</h3>
|
||||
<table class="density-table fee-dialog-facts"><tbody>
|
||||
<tr><th scope="row">客户</th><td colspan="3">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</td></tr>
|
||||
<tr><th scope="row">费用项目</th><td>{{ displayFeeName(detail.feeItemName) || '—' }}</td><th scope="row">金额(CNY)</th><td>{{ money(detail.amount) }}</td></tr>
|
||||
<tr><th scope="row">关联业务</th><td colspan="3">{{ businessReference(detail) }}</td></tr>
|
||||
<tr><th scope="row">收付方向</th><td>{{ financeDirectionLabel(detail.direction) }}</td><th scope="row">审核状态</th><td>{{ statusLabel(detail.reviewStatus) }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="费用操作登记"><h3 class="density-dialog-section__title">{{ mutationTitle }}</h3>
|
||||
<el-form class="density-form" label-position="left" label-width="110px" :disabled="mutationSaving"><table class="density-table density-table--editable"><tbody>
|
||||
<tr v-if="mutationMode === 'EDIT' || mutationMode === 'ADJUST'"><td><el-form-item label="金额(CNY)" required><el-input-number v-model="mutationForm.amount" :min="0.01" :precision="2" :controls="false" placeholder="请输入调整金额" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="操作原因" required><el-input v-model="mutationForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请填写操作原因" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fee-dialog-facts { table-layout: fixed; }
|
||||
.fee-dialog-facts th { width: var(--admin-label-width); }
|
||||
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
|
||||
.finance-arap-page {
|
||||
min-width: 0;
|
||||
|
||||
@@ -13,6 +13,8 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
@@ -83,6 +85,7 @@ const createForm = ref({
|
||||
let createSourceLoadSequence = 0
|
||||
const adjustOpen = ref(false)
|
||||
const adjustBusy = ref(false)
|
||||
const adjustError = ref<ApiErrorDetails | null>(null)
|
||||
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
|
||||
const adjustReason = ref('')
|
||||
const adjustLines = ref<AdjustmentRow[]>([])
|
||||
@@ -479,6 +482,7 @@ async function openSelectedCreateFromRoute() {
|
||||
}
|
||||
|
||||
async function createStatement() {
|
||||
if (createBusy.value) return
|
||||
if (!createForm.value.customerId) return ElMessage.warning('请先选择客户')
|
||||
if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期')
|
||||
if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期')
|
||||
@@ -508,8 +512,9 @@ async function createStatement() {
|
||||
}
|
||||
|
||||
async function performAction(row: FinanceStatementReadModel, actionName: ReconciliationActionPayload['action']) {
|
||||
const title = ({ REJECT: '驳回对账单', VOID: '作废对账单', REOPEN: '重开对账单' } as Partial<Record<ReconciliationActionPayload['action'], string>>)[actionName] || '对账单操作'
|
||||
const reason = ['REJECT', 'VOID', 'REOPEN'].includes(actionName)
|
||||
? await ElMessageBox.prompt('请输入操作原因', '对账单操作', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
|
||||
? await ElMessageBox.prompt('请输入操作原因', `${title} · ${row.billNo || '—'}`, { customClass: 'admin-density-confirm', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
|
||||
: undefined
|
||||
if (reason === null) return
|
||||
actionBusy.value = true
|
||||
@@ -522,6 +527,7 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
|
||||
}
|
||||
|
||||
async function openAdjustment(row: FinanceStatementReadModel) {
|
||||
adjustError.value = null
|
||||
adjustBusy.value = true
|
||||
try {
|
||||
const current = await fetchDetail(row.id, true)
|
||||
@@ -557,11 +563,13 @@ function handleStatementAction(command: string, row: FinanceStatementReadModel)
|
||||
}
|
||||
|
||||
async function submitAdjustment() {
|
||||
if (adjustBusy.value) return
|
||||
const statement = adjustStatement.value
|
||||
const selected = adjustLines.value.filter((line) => line.selected)
|
||||
if (!statement || !adjustReason.value.trim() || !selected.length) return ElMessage.warning('请填写调整原因并至少选择一条明细')
|
||||
if (selected.some((line) => line.adjustedAmount <= 0 || !line.reason.trim())) return ElMessage.warning('请填写所选明细的新金额和调整说明')
|
||||
adjustBusy.value = true
|
||||
adjustError.value = null
|
||||
try {
|
||||
await actionReconciliationStatement(statement.id, {
|
||||
action: 'ADJUST', reason: adjustReason.value.trim(), expectedVersion: statement.version,
|
||||
@@ -571,7 +579,7 @@ async function submitAdjustment() {
|
||||
ElMessage.success('对账单调整已保存')
|
||||
await load(appliedFilters.value)
|
||||
await openDetail(statement)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单调整失败') }
|
||||
} catch (cause) { adjustError.value = getApiErrorDetails(cause, '对账单调整失败'); error.value = adjustError.value }
|
||||
finally { adjustBusy.value = false }
|
||||
}
|
||||
|
||||
@@ -700,34 +708,33 @@ watch(detailRouteId, (statementId) => {
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement">
|
||||
<AppDialog class="admin-density-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="createStatement">
|
||||
<el-alert v-if="createError" type="warning" :closable="true" show-icon title="暂时无法生成对账单" @close="createError = null"><p>{{ createError.message }}</p></el-alert>
|
||||
<el-form class="density-form" label-position="top">
|
||||
<el-form-item label="生成方式">
|
||||
<el-radio-group v-model="createForm.generationMode">
|
||||
<el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button>
|
||||
<el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item>
|
||||
<div class="create-period-row"><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item></div>
|
||||
<template v-if="createForm.generationMode === 'SELECTED'">
|
||||
<el-alert v-if="!canSelectSources" type="warning" :closable="false" title="当前账号没有查看费用来源的权限,不能指定费用生成。" />
|
||||
<el-alert v-else-if="createSourcesError" type="error" :closable="false"><template #title>可生成费用加载失败</template><p>{{ createSourcesError.message }}</p><ApiErrorMeta :details="createSourcesError" /><el-button size="small" @click="loadEligibleCreateSources()">重试</el-button></el-alert>
|
||||
<el-form-item label="应收费用">
|
||||
<el-select v-model="createForm.feeSourceIds" multiple filterable collapse-tags :max-collapse-tags="3" :loading="createSourcesLoading" placeholder="请选择审核通过、已发布且未出账的费用">
|
||||
<el-option v-for="item in createSourceOptions" :key="item.feeSourceId" :label="`${item.feeItemName} · ${item.businessDate} · ${Number(item.amount).toFixed(2)} CNY`" :value="item.feeSourceId" />
|
||||
</el-select>
|
||||
<div class="selected-source-summary">已选 {{ createForm.feeSourceIds.length }} 笔,合计 {{ selectedCreateAmount.toFixed(2) }} CNY</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="对账单生成设置">
|
||||
<h3 class="density-dialog-section__title">生成设置 <InlineHelp title="对账单生成说明">按账期自动汇总,或指定审核通过、已发布且未出账的应收费用;生成币种为CNY。</InlineHelp></h3>
|
||||
<el-form class="density-form" label-position="left" label-width="90px" :disabled="createBusy"><table class="density-table density-table--editable statement-create-table"><tbody>
|
||||
<tr><td colspan="2"><el-form-item label="生成方式"><el-radio-group v-model="createForm.generationMode"><el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button><el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button></el-radio-group></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td><td><el-form-item label="币种"><span>CNY</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
|
||||
<tr v-if="createForm.generationMode === 'SELECTED'"><td colspan="2">
|
||||
<el-alert v-if="!canSelectSources" type="warning" :closable="false" title="当前账号没有查看费用来源的权限,不能指定费用生成。" />
|
||||
<el-alert v-else-if="createSourcesError" type="error" :closable="false"><template #title>可生成费用加载失败</template><p>{{ createSourcesError.message }}</p><ApiErrorMeta :details="createSourcesError" /><el-button size="small" @click="loadEligibleCreateSources()">重试</el-button></el-alert>
|
||||
<el-form-item label="应收费用"><el-select v-model="createForm.feeSourceIds" multiple filterable collapse-tags :max-collapse-tags="3" :loading="createSourcesLoading" placeholder="请选择审核通过、已发布且未出账的费用"><el-option v-for="item in createSourceOptions" :key="item.feeSourceId" :label="`${item.feeItemName} · ${item.businessDate} · ${Number(item.amount).toFixed(2)} CNY`" :value="item.feeSourceId" /></el-select><div class="density-dialog-note selected-source-summary">已选 {{ createForm.feeSourceIds.length }} 笔,合计 {{ selectedCreateAmount.toFixed(2) }} CNY</div></el-form-item>
|
||||
</td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="adjustOpen" title="调整客户对账单" width="min(900px, 96vw)" :confirm-loading="adjustBusy" @confirm="submitAdjustment">
|
||||
<el-form class="density-form" label-position="top"><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></el-form>
|
||||
<el-table :data="adjustLines" size="small" max-height="420" border stripe><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table>
|
||||
<AppDialog class="admin-density-dialog" v-model="adjustOpen" :title="`调整客户对账单 · ${adjustStatement?.billNo || '—'}`" width="min(900px, 96vw)" fullscreenable :confirm-loading="adjustBusy" @confirm="submitAdjustment">
|
||||
<OperationErrorToast :details="adjustError" />
|
||||
<el-form class="density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="adjustBusy">
|
||||
<section v-if="adjustStatement" class="density-dialog-section" data-tone="summary" aria-label="当前对账单"><h3 class="density-dialog-section__title">当前对账单</h3><table class="density-table statement-dialog-facts"><tbody><tr><th scope="row">对账单号</th><td>{{ adjustStatement.billNo || '—' }}</td><th scope="row">客户</th><td>{{ customerDisplayName(adjustStatement.customerId, adjustStatement.customerName) }}</td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="对账单调整登记"><h3 class="density-dialog-section__title">调整登记</h3><div class="statement-adjust-editor">
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></td></tr></tbody></table>
|
||||
<el-table :data="adjustLines" size="small" border :stripe="false" row-key="statementLineId"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" :aria-label="`选择${row.feeItemName || '费用明细'}`" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" show-overflow-tooltip /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table>
|
||||
</div></section>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -735,8 +742,11 @@ watch(detailRouteId, (statementId) => {
|
||||
<style scoped>
|
||||
.finance-statements-page { min-width: 0; }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
.create-period-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-6); }
|
||||
.selected-source-summary { width: 100%; margin-top: var(--admin-space-2); color: var(--cc-text-secondary); font-size: var(--admin-font-caption); text-align: right; }
|
||||
.selected-source-summary { width: 100%; margin-top: var(--admin-space-2); text-align: right; }
|
||||
.statement-create-table, .statement-dialog-facts { table-layout: fixed; }
|
||||
.statement-create-table td:not([colspan]) { width: 50%; }
|
||||
.statement-dialog-facts th { width: var(--admin-label-width); }
|
||||
.statement-adjust-editor { display: grid; gap: var(--admin-space-5); }
|
||||
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
|
||||
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-outstanding { color: var(--el-color-warning-dark-2); }
|
||||
|
||||
@@ -64,8 +64,9 @@ describe('Finance formal settlement desktop presentation', () => {
|
||||
|
||||
it('keeps receipt and payment dialogs readable at desktop width', () => {
|
||||
expect(source.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
|
||||
expect(source.match(/class="density-detail-overview partial-summary"/g)).toHaveLength(2)
|
||||
expect(source).toContain('.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
|
||||
expect(source.match(/class="density-dialog-section partial-summary"/g)).toHaveLength(2)
|
||||
expect(source).toContain('density-table density-table--editable settlement-operation-table')
|
||||
expect(source).toContain('.settlement-operation-table td:not([colspan]) { width: 50%; }')
|
||||
expect(source).toContain('<OperationErrorToast :message="receiptValidationError" />')
|
||||
expect(source).toContain('<OperationErrorToast :details="receiptError" />')
|
||||
expect(source).not.toContain('<el-alert v-if="receiptValidationError"')
|
||||
|
||||
@@ -79,6 +79,7 @@ const createForm = ref({
|
||||
const paymentOpen = ref(false)
|
||||
const paymentBusy = ref(false)
|
||||
const paymentFile = ref<File | null>(null)
|
||||
const paymentUploadKey = ref(0)
|
||||
const paymentForm = ref({ transactionDate: new Date().toISOString().slice(0, 10), amount: 0, method: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', referenceNo: '', remark: '' })
|
||||
const receiptOpen = ref(false)
|
||||
const receiptBusy = ref(false)
|
||||
@@ -288,6 +289,10 @@ async function openCreate() {
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '结算来源加载失败') }
|
||||
}
|
||||
|
||||
function reportCreateSourceError(reason: unknown) {
|
||||
if (createOpen.value) error.value = getApiErrorDetails(reason, '结算来源加载失败')
|
||||
}
|
||||
|
||||
function resetCreateDependentFields() {
|
||||
createForm.value.reconciliationStatementId = ''
|
||||
createForm.value.supplierId = ''
|
||||
@@ -329,21 +334,23 @@ async function refreshCreateDependencies(customerId?: string) {
|
||||
}
|
||||
|
||||
watch(() => createForm.value.customerId, (customerId) => {
|
||||
if (createOpen.value) void refreshCreateDependencies(customerId)
|
||||
if (createOpen.value) void refreshCreateDependencies(customerId).catch(reportCreateSourceError)
|
||||
})
|
||||
watch(() => createForm.value.supplierId, () => {
|
||||
if (!createOpen.value) return
|
||||
createForm.value.feeSourceIds = []
|
||||
feeOptions.value = []
|
||||
if (createForm.value.customerId) void refreshCreateDependencies(createForm.value.customerId)
|
||||
if (createForm.value.customerId) void refreshCreateDependencies(createForm.value.customerId).catch(reportCreateSourceError)
|
||||
})
|
||||
|
||||
async function submitCreate() {
|
||||
if (createBusy.value) return
|
||||
const customerId = String(createForm.value.customerId || '')
|
||||
if (!customerId) return ElMessage.warning('请先选择客户')
|
||||
if (props.kind === 'receivable' && !createForm.value.reconciliationStatementId) return ElMessage.warning('请选择已出账对账单')
|
||||
if (props.kind === 'payable' && (!createForm.value.supplierId || !createForm.value.dueDate || !createForm.value.feeSourceIds.length)) return ElMessage.warning('请完整填写供应商、到期日和费用来源')
|
||||
createBusy.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (props.kind === 'receivable') await createReceivableSettlement({ reconciliationStatementId: createForm.value.reconciliationStatementId, currency: 'CNY' })
|
||||
else await createPayableSettlement({ customerId, supplierId: createForm.value.supplierId, feeSourceIds: createForm.value.feeSourceIds, dueDate: createForm.value.dueDate, currency: 'CNY' })
|
||||
@@ -359,6 +366,8 @@ function openPayment(row: unknown) {
|
||||
selectedRow.value = settlement
|
||||
detail.value = { id: settlement.id, settlementNo: settlement.settlementNo, customerId: settlement.customerId, direction: 'PAYABLE', amount: settlement.payableAmount, paidAmount: settlement.paidAmount, outstandingAmount: settlement.outstandingAmount, currency: 'CNY', status: settlement.status, version: settlement.version, lines: [], transactions: [], revisions: [] }
|
||||
paymentFile.value = null
|
||||
paymentUploadKey.value += 1
|
||||
error.value = null
|
||||
paymentForm.value = { transactionDate: new Date().toISOString().slice(0, 10), amount: settlement.outstandingAmount, method: 'BANK_TRANSFER', referenceNo: '', remark: '' }
|
||||
paymentOpen.value = true
|
||||
}
|
||||
@@ -381,6 +390,7 @@ async function openReceipt(row: unknown) {
|
||||
if (!current.reconciliationStatementId) throw new Error('应收结算单缺少对账单关联')
|
||||
detail.value = current
|
||||
receiptFile.value = null
|
||||
receiptUploadKey.value += 1
|
||||
receiptForm.value = { receivedDate: new Date().toISOString().slice(0, 10), amount: current.outstandingAmount, paymentMethod: 'BANK_TRANSFER', remark: '' }
|
||||
receiptOpen.value = true
|
||||
} catch (cause) { receiptError.value = getApiErrorDetails(cause, '应收结算单加载失败,无法登记收款') }
|
||||
@@ -406,6 +416,7 @@ function selectReceiptFile(file: UploadFile) {
|
||||
}
|
||||
|
||||
async function submitReceipt() {
|
||||
if (receiptBusy.value) return
|
||||
const current = detail.value
|
||||
if (!current?.reconciliationStatementId || !receiptFile.value || receiptForm.value.amount <= 0) {
|
||||
receiptValidationError.value = '请填写收款金额并选择收款凭证'
|
||||
@@ -425,8 +436,10 @@ async function submitReceipt() {
|
||||
}
|
||||
|
||||
async function submitPayment() {
|
||||
if (paymentBusy.value) return
|
||||
if (!detail.value || !paymentFile.value || paymentForm.value.amount <= 0) return ElMessage.warning('请填写付款金额并选择付款凭证')
|
||||
paymentBusy.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const voucher = await uploadFinanceVoucher(detail.value.customerId, paymentFile.value)
|
||||
await createFinanceSettlementPayment(detail.value.id, { ...paymentForm.value, currency: 'CNY', voucherFileIds: [voucher.fileId], expectedVersion: detail.value.version })
|
||||
@@ -439,7 +452,7 @@ async function submitPayment() {
|
||||
}
|
||||
|
||||
async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
|
||||
const reason = await ElMessageBox.prompt('请输入操作原因', action === 'VOID' ? `作废${title.value}` : `重开${title.value}`, {
|
||||
const reason = await ElMessageBox.prompt('请输入操作原因', `${action === 'VOID' ? '作废' : '重开'}${title.value} · ${row.settlementNo || '—'}`, {
|
||||
customClass: 'admin-density-confirm',
|
||||
inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消',
|
||||
}).then((result) => result.value.trim()).catch(() => null)
|
||||
@@ -507,6 +520,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-settlement-page admin-density" spacing="normal">
|
||||
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
|
||||
<OperationErrorToast :message="receiptValidationError" />
|
||||
<OperationErrorToast :details="receiptError" />
|
||||
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
|
||||
@@ -640,25 +654,63 @@ onMounted(() => {
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
|
||||
<el-form class="density-form" label-position="top">
|
||||
<el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item>
|
||||
<el-form-item v-if="kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
|
||||
<template v-else><el-form-item label="供应商"><el-select v-model="createForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="`${item.name} · ${categoryLabels[item.category]}`" :value="String(item.id)" /></el-select></el-form-item><el-form-item label="到期日"><el-date-picker v-model="createForm.dueDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="应付费用来源"><el-select v-model="createForm.feeSourceIds" multiple filterable><el-option v-for="item in feeOptions" :key="item.feeSourceId!" :label="`${item.feeItemName} · ${item.amount} CNY`" :value="item.feeSourceId!" /></el-select></el-form-item></template>
|
||||
</el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
|
||||
<el-form class="density-form" label-position="left" label-width="120px" :disabled="createBusy"><table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="币种"><span>CNY</span></el-form-item></td></tr>
|
||||
<tr v-if="kind === 'receivable'"><td><el-form-item label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item></td></tr>
|
||||
<template v-else>
|
||||
<tr><td><el-form-item label="供应商"><el-select v-model="createForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="`${item.name} · ${categoryLabels[item.category]}`" :value="String(item.id)" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="到期日"><el-date-picker v-model="createForm.dueDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="应付费用来源"><el-select v-model="createForm.feeSourceIds" multiple filterable><el-option v-for="item in feeOptions" :key="item.feeSourceId!" :label="`${item.feeItemName} · ${item.amount} CNY`" :value="item.feeSourceId!" /></el-select></el-form-item></td></tr>
|
||||
</template>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="paymentOpen" title="登记付款" width="min(760px, 94vw)" :confirm-loading="paymentBusy" @confirm="submitPayment">
|
||||
<dl v-if="detail" class="density-detail-overview partial-summary"><div><dt>结算单号</dt><dd>{{ detail.settlementNo }}</dd></div><div><dt>供应商</dt><dd>{{ detailPartyName() }}</dd></div><div><dt>应付金额</dt><dd>{{ money(detail.amount) }}</dd></div><div><dt>已付金额</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div><div><dt>当前余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div></dl>
|
||||
<el-form class="settlement-operation-form density-form" label-position="top"><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入付款金额" /></el-form-item><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="银行流水号/凭证号"><el-input v-model="paymentForm.referenceNo" placeholder="请输入付款流水号" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" placeholder="请输入付款备注(选填)" /></el-form-item></el-form>
|
||||
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(paymentForm.amount) }}</strong></div>
|
||||
<AppDialog class="admin-density-dialog" v-model="paymentOpen" :title="`登记付款 · ${detail?.settlementNo || '—'}`" width="min(760px, 94vw)" fullscreenable :confirm-loading="paymentBusy" @confirm="submitPayment">
|
||||
<div class="density-dialog-sections">
|
||||
<section v-if="detail" class="density-dialog-section partial-summary" data-tone="summary" aria-label="当前结算资料"><h3 class="density-dialog-section__title">当前结算(CNY)</h3>
|
||||
<table class="density-table settlement-dialog-facts"><tbody>
|
||||
<tr><th scope="row">结算单号</th><td>{{ detail.settlementNo || '—' }}</td><th scope="row">供应商</th><td>{{ detailPartyName() }}</td></tr>
|
||||
<tr><th scope="row">应付金额</th><td>{{ money(detail.amount) }}</td><th scope="row">已付金额</th><td class="is-success">{{ money(detail.paidAmount) }}</td></tr>
|
||||
<tr><th scope="row">当前余额</th><td colspan="3" class="is-danger">{{ money(detail.outstandingAmount) }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="付款登记"><h3 class="density-dialog-section__title">付款登记</h3>
|
||||
<el-form class="settlement-operation-form density-form" label-position="left" label-width="140px" :disabled="paymentBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody>
|
||||
<tr><td><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="本次付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入付款金额" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td><td><el-form-item label="银行流水号/凭证号"><el-input v-model="paymentForm.referenceNo" placeholder="请输入付款流水号" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="付款凭证"><el-upload :key="paymentUploadKey" :disabled="paymentBusy" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" placeholder="请输入付款备注(选填)" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="登记后剩余余额"><strong>{{ remainingAfter(paymentForm.amount) }}</strong></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="receiptOpen" title="登记收款" width="min(760px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
|
||||
<dl v-if="detail" class="density-detail-overview partial-summary"><div><dt>结算单号</dt><dd>{{ detail.settlementNo }}</dd></div><div><dt>客户公司</dt><dd>{{ detailPartyName() }}</dd></div><div><dt>应收金额</dt><dd>{{ money(detail.amount) }}</dd></div><div><dt>已收金额</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div><div><dt>当前余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div></dl>
|
||||
<el-form class="settlement-operation-form density-form" label-position="top"><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" placeholder="请输入收款备注(选填)" /></el-form-item></el-form>
|
||||
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(receiptForm.amount) }}</strong></div>
|
||||
<AppDialog class="admin-density-dialog" v-model="receiptOpen" :title="`登记收款 · ${detail?.settlementNo || '—'}`" width="min(760px, 94vw)" fullscreenable :confirm-loading="receiptBusy" @confirm="submitReceipt">
|
||||
<div class="density-dialog-sections">
|
||||
<section v-if="detail" class="density-dialog-section partial-summary" data-tone="summary" aria-label="当前结算资料"><h3 class="density-dialog-section__title">当前结算(CNY)</h3>
|
||||
<table class="density-table settlement-dialog-facts"><tbody>
|
||||
<tr><th scope="row">结算单号</th><td>{{ detail.settlementNo || '—' }}</td><th scope="row">客户公司</th><td>{{ detailPartyName() }}</td></tr>
|
||||
<tr><th scope="row">应收金额</th><td>{{ money(detail.amount) }}</td><th scope="row">已收金额</th><td class="is-success">{{ money(detail.paidAmount) }}</td></tr>
|
||||
<tr><th scope="row">当前余额</th><td colspan="3" class="is-danger">{{ money(detail.outstandingAmount) }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3>
|
||||
<el-form class="settlement-operation-form density-form" label-position="left" label-width="140px" :disabled="receiptBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody>
|
||||
<tr><td><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :disabled="receiptBusy" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" placeholder="请输入收款备注(选填)" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="登记后剩余余额"><strong>{{ remainingAfter(receiptForm.amount) }}</strong></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -677,11 +729,9 @@ onMounted(() => {
|
||||
:deep(.settlement-detail-info .el-descriptions__table) { table-layout: fixed; }
|
||||
:deep(.settlement-detail-info .el-descriptions__content) { min-width: 0; overflow-wrap: anywhere; }
|
||||
:deep(.detail-table-scroll .el-table) { min-width: 920px; }
|
||||
.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--admin-space-5); row-gap: 0; }
|
||||
.settlement-operation-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.settlement-operation-form :deep(.el-input-number) { width: 100%; }
|
||||
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); padding: var(--admin-space-3) var(--admin-space-5); border-radius: var(--admin-radius); color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
|
||||
.remaining-preview strong { color: #b45309; font-size: var(--admin-font-section); }
|
||||
.settlement-dialog-facts, .settlement-operation-table { table-layout: fixed; }
|
||||
.settlement-dialog-facts th { width: var(--admin-label-width); }
|
||||
.settlement-operation-table td:not([colspan]) { width: 50%; }
|
||||
.operation-error { margin-bottom: var(--admin-space-5); }
|
||||
.operation-error-meta { margin: -var(--admin-space-2) 0 var(--admin-space-5); }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
@@ -707,18 +757,13 @@ onMounted(() => {
|
||||
margin-bottom: var(--admin-space-3);
|
||||
}
|
||||
|
||||
.settlement-operation-form {
|
||||
column-gap: var(--admin-space-5);
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.settlement-detail-summary, .partial-summary, .settlement-operation-form { grid-template-columns: 1fr; }
|
||||
.settlement-detail-summary { grid-template-columns: 1fr; }
|
||||
.settlement-detail-summary { gap: 10px; padding: 12px 16px; }
|
||||
.settlement-detail-summary .summary-item { padding: 0; border-left: 0; }
|
||||
.settlement-detail-content { padding: 0 16px 14px; }
|
||||
.settlement-detail-state { margin: 14px; }
|
||||
.partial-summary { gap: 10px; padding: 12px 10px; }
|
||||
.partial-summary div { padding: 0; border-left: 0; }
|
||||
.settlement-summary { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
.settlement-summary .summary-item + .summary-item { margin-left: 0; padding-left: 0; border-left: 0; }
|
||||
.settlement-list-actions { width: 100%; justify-content: flex-start; }
|
||||
|
||||
@@ -43,8 +43,8 @@ describe('finance presentation governance', () => {
|
||||
expect(settlement).not.toContain('class="detail-section surface-panel"')
|
||||
expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
|
||||
expect(settlement.match(/class="settlement-operation-form density-form"/g)).toHaveLength(2)
|
||||
expect(settlement).toContain('grid-template-columns: repeat(2, minmax(0, 1fr));')
|
||||
expect(settlement).toContain('column-gap: var(--admin-space-5);')
|
||||
expect(settlement).toContain('density-table density-table--editable settlement-operation-table')
|
||||
expect(settlement).toContain('.settlement-operation-table td:not([colspan]) { width: 50%; }')
|
||||
})
|
||||
|
||||
it('keeps settlement totals and list actions in the table toolbar', () => {
|
||||
@@ -128,10 +128,11 @@ describe('finance presentation governance', () => {
|
||||
expect(statements).not.toContain('disabled title="接口尚未提供')
|
||||
})
|
||||
|
||||
it('uses bordered striped tables for statement business details', () => {
|
||||
it('keeps bordered statement details and lets the adjustment editor scroll with its dialog', () => {
|
||||
expect(statements).toContain(':data="detail.lines" size="small" border stripe')
|
||||
expect(statements).toContain(':data="detail.revisions" size="small" border stripe')
|
||||
expect(statements).toContain(':data="adjustLines" size="small" max-height="420" border stripe')
|
||||
expect(statements).toContain(':data="adjustLines" size="small" border :stripe="false" row-key="statementLineId"')
|
||||
expect(statements).not.toContain('max-height="420"')
|
||||
})
|
||||
|
||||
it('opens statement details on a compact dedicated route instead of below the list', () => {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import * as api from '../api/finance'
|
||||
import type { FinanceFeePoolItem, FinancePayableSettlement, FinanceReceivableSettlement, FinanceSettlementDetail, FinanceStatementReadModel } from '../types'
|
||||
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
|
||||
|
||||
const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn(), categoryLabels: { CERTIFICATE: '证书服务' } }))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
actionFinanceSettlement: vi.fn(), createFinanceReceipt: vi.fn(), createFinanceSettlementPayment: vi.fn(),
|
||||
createPayableSettlement: vi.fn(), createReceivableSettlement: vi.fn(), getFinanceSettlement: vi.fn(),
|
||||
listFinanceFeePool: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(),
|
||||
listFinanceStatements: vi.fn(), uploadFinanceVoucher: vi.fn(),
|
||||
}))
|
||||
const customer: PlatformTenantResource = {
|
||||
id: '9223372036854775001', tenantCode: 'C-TEST', tenantName: '测试客户', status: 'ACTIVE', legalName: null, legalRepresentative: null,
|
||||
unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null,
|
||||
paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null,
|
||||
internalRemark: null, createdAt: null, approvedAt: null,
|
||||
}
|
||||
const supplier: Supplier = {
|
||||
id: '9223372036854775002', tenantId: '0', name: '测试供应商', category: 'CERTIFICATE', truckingScope: null, carrierCodes: [], region: '',
|
||||
legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE',
|
||||
settlementPeriod: 'PER_TRANSACTION', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1,
|
||||
}
|
||||
const ar: FinanceReceivableSettlement = {
|
||||
id: '9223372036854775010', customerId: String(customer.id), customerName: customer.tenantName, settlementNo: 'AR202610200001',
|
||||
reconciliationStatementId: '9223372036854775020', reconciliationStatementNo: 'REC202610200001', billDate: '2026-10-20', dueDate: '2026-11-20',
|
||||
receivableAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3,
|
||||
}
|
||||
const ap: FinancePayableSettlement = {
|
||||
id: '9223372036854775011', customerId: String(customer.id), customerName: customer.tenantName, settlementNo: 'AP202610200001',
|
||||
supplierId: supplier.id, supplierName: supplier.name, costType: 'SINGLE_ORDER', orderId: null, inboundId: null, containerId: null, feeItemName: '操作费',
|
||||
payableAmount: 300, paidAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', createdAt: '', version: 4,
|
||||
}
|
||||
const statement: FinanceStatementReadModel = {
|
||||
id: ar.reconciliationStatementId!, customerId: String(customer.id), customerName: customer.tenantName, billNo: 'REC202610200001',
|
||||
billDate: '2026-10-20', dueDate: '', totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 6,
|
||||
}
|
||||
const fee: FinanceFeePoolItem = {
|
||||
poolItemId: '9223372036854775030', feeSourceId: '9223372036854775031', customerId: String(customer.id), customerName: customer.tenantName,
|
||||
direction: 'PAYABLE', sourceType: 'MANUAL', sourceId: '9223372036854775032', sourceVersion: 1, orderId: null, inboundId: null,
|
||||
containerId: null, feeItemName: '操作费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '',
|
||||
}
|
||||
function settlement(kind: 'receivable' | 'payable'): FinanceSettlementDetail {
|
||||
return { id: kind === 'receivable' ? ar.id : ap.id, settlementNo: kind === 'receivable' ? ar.settlementNo : ap.settlementNo,
|
||||
customerId: String(customer.id), customerName: customer.tenantName, supplierId: supplier.id, supplierName: supplier.name,
|
||||
reconciliationStatementId: statement.id, reconciliationStatementNo: statement.billNo, direction: kind === 'receivable' ? 'RECEIVABLE' : 'PAYABLE',
|
||||
amount: 300, paidAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 8, lines: [], transactions: [], revisions: [] }
|
||||
}
|
||||
const failure = () => new ApiResponseError('测试操作失败', 'TEST_REJECTED', 409, null, 'settlement-test-request')
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
async function mountPage(kind: 'receivable' | 'payable', detail = false) {
|
||||
vi.mocked(api.getFinanceSettlement).mockResolvedValue(settlement(kind))
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] })
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind, ...(detail ? { settlementId: kind === 'receivable' ? ar.id : ap.id } : {}) }, attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
function activeDialog(wrapper: VueWrapper) {
|
||||
const dialog = wrapper.findAllComponents(ElDialog).find(item => item.props('modelValue'))
|
||||
expect(dialog).toBeDefined()
|
||||
return dialog!
|
||||
}
|
||||
async function click(wrapper: VueWrapper, text: string, selector = 'button') {
|
||||
const button = wrapper.findAll(selector).find(item => item.text().trim() === text)
|
||||
expect(button, text).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
async function openCreate(kind: 'receivable' | 'payable') {
|
||||
const wrapper = await mountPage(kind)
|
||||
await click(wrapper, kind === 'receivable' ? '生成应收结算单' : '生成应付结算单')
|
||||
return { wrapper, dialog: activeDialog(wrapper) }
|
||||
}
|
||||
async function openCashier(kind: 'receivable' | 'payable', detail = false) {
|
||||
const wrapper = await mountPage(kind, detail)
|
||||
const label = kind === 'receivable' ? '收款' : '付款'
|
||||
await click(wrapper, detail ? '部分' + label : label, detail ? 'button' : '.el-table__body-wrapper button')
|
||||
return { wrapper, dialog: activeDialog(wrapper) }
|
||||
}
|
||||
async function chooseFile(dialog: VueWrapper, name = '测试凭证.png', type = 'image/png') {
|
||||
const file = Object.assign(new File(['test image'], name, { type }), { uid: 100 })
|
||||
const input = dialog.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
|
||||
await input.trigger('change')
|
||||
await flushPromises()
|
||||
return file
|
||||
}
|
||||
function errorDetails(wrapper: VueWrapper) {
|
||||
return wrapper.findAllComponents(OperationErrorToast).find(toast => toast.props('details'))?.props('details')
|
||||
}
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
can.mockReturnValue(true)
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [supplier], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([ar])
|
||||
vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([ap])
|
||||
vi.mocked(api.listFinanceStatements).mockResolvedValue([statement, { ...statement, id: 'draft-statement', status: 'DRAFT' }])
|
||||
vi.mocked(api.listFinanceFeePool).mockResolvedValue([fee, { ...fee, feeSourceId: 'direct-paid', settlementMode: 'DIRECT_PAID' }, { ...fee, feeSourceId: 'locked-fee', status: 'LOCKED' }])
|
||||
vi.mocked(api.createReceivableSettlement).mockResolvedValue(settlement('receivable'))
|
||||
vi.mocked(api.createPayableSettlement).mockResolvedValue(settlement('payable'))
|
||||
vi.mocked(api.createFinanceSettlementPayment).mockResolvedValue(settlement('payable'))
|
||||
vi.mocked(api.actionFinanceSettlement).mockResolvedValue(settlement('receivable'))
|
||||
vi.mocked(api.uploadFinanceVoucher).mockResolvedValue({ fileId: '9223372036854775040', customerId: String(customer.id), fileName: '测试凭证.png', contentType: 'image/png', sizeBytes: 10 })
|
||||
vi.mocked(api.createFinanceReceipt).mockResolvedValue({ id: '9223372036854775041', customerId: String(customer.id), customerName: customer.tenantName, receivedDate: '2026-10-20', amount: 50, currency: 'CNY', paymentMethod: 'CASH', voucherFileIds: ['9223372036854775040'], allocationCount: 1 })
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
describe('formal settlement native dialogs', () => {
|
||||
it('creates receivables from issued statements with original long IDs and CNY', async () => {
|
||||
const { dialog } = await openCreate('receivable')
|
||||
expect(dialog.props('width')).toContain('600px')
|
||||
const selects = dialog.findAllComponents({ name: 'ElSelect' })
|
||||
selects[0]!.vm.$emit('update:modelValue', String(customer.id))
|
||||
await flushPromises()
|
||||
expect(api.listFinanceStatements).toHaveBeenCalledWith(String(customer.id))
|
||||
expect(selects[1]!.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual([statement.id])
|
||||
selects[1]!.vm.$emit('update:modelValue', statement.id)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, '确定')
|
||||
expect(api.createReceivableSettlement).toHaveBeenCalledWith({ reconciliationStatementId: statement.id, currency: 'CNY' })
|
||||
})
|
||||
it('keeps active suppliers and excludes directly paid or locked payable sources', async () => {
|
||||
const { dialog } = await openCreate('payable')
|
||||
const selects = dialog.findAllComponents({ name: 'ElSelect' })
|
||||
selects[0]!.vm.$emit('update:modelValue', String(customer.id))
|
||||
selects[1]!.vm.$emit('update:modelValue', supplier.id)
|
||||
await flushPromises()
|
||||
expect(api.listFinanceFeePool).toHaveBeenLastCalledWith(String(customer.id), 'PAYABLE', supplier.id)
|
||||
expect(selects[2]!.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual([fee.feeSourceId])
|
||||
selects[2]!.vm.$emit('update:modelValue', [fee.feeSourceId])
|
||||
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2099-12-31')
|
||||
await click(dialog, '确定')
|
||||
expect(api.createPayableSettlement).toHaveBeenCalledWith({ customerId: String(customer.id), supplierId: supplier.id, feeSourceIds: [fee.feeSourceId], dueDate: '2099-12-31', currency: 'CNY' })
|
||||
})
|
||||
it.each(['receivable', 'payable'] as const)('keeps %s creation validation before mutation', async kind => {
|
||||
const { dialog } = await openCreate(kind)
|
||||
await click(dialog, '确定')
|
||||
expect(api.createReceivableSettlement).not.toHaveBeenCalled()
|
||||
expect(api.createPayableSettlement).not.toHaveBeenCalled()
|
||||
})
|
||||
it('shows dependency load errors without an unhandled customer-selection request', async () => {
|
||||
vi.mocked(api.listFinanceStatements).mockRejectedValueOnce(failure())
|
||||
const { wrapper, dialog } = await openCreate('receivable')
|
||||
dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', String(customer.id))
|
||||
await flushPromises()
|
||||
expect(errorDetails(wrapper)).toMatchObject({ message: '测试操作失败', requestId: 'settlement-test-request' })
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('locks creation controls and retains a rejected selection', async () => {
|
||||
const pending = deferred<FinanceSettlementDetail>()
|
||||
vi.mocked(api.createReceivableSettlement).mockReturnValue(pending.promise)
|
||||
const { wrapper, dialog } = await openCreate('receivable')
|
||||
const selects = dialog.findAllComponents({ name: 'ElSelect' })
|
||||
selects[0]!.vm.$emit('update:modelValue', String(customer.id))
|
||||
await flushPromises()
|
||||
selects[1]!.vm.$emit('update:modelValue', statement.id)
|
||||
await click(dialog, '确定')
|
||||
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(dialog.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true)
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(errorDetails(wrapper)).toMatchObject({ requestId: 'settlement-test-request' })
|
||||
expect(selects[1]!.props('modelValue')).toBe(statement.id)
|
||||
})
|
||||
it.each([false, true])('keeps payable balances plain and posts the expected version from detail=%s', async detail => {
|
||||
const { dialog } = await openCashier('payable', detail)
|
||||
expect(dialog.props('title')).toBe('登记付款 · ' + ap.settlementNo)
|
||||
expect(dialog.props('width')).toContain('760px')
|
||||
expect(dialog.get('[data-tone="summary"]').text()).toContain('¥100.00')
|
||||
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
||||
const amount = dialog.getComponent({ name: 'ElInputNumber' })
|
||||
expect(amount.props('max')).toBe(200)
|
||||
amount.vm.$emit('update:modelValue', 50.25)
|
||||
dialog.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CASH')
|
||||
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-20')
|
||||
await dialog.get('input[placeholder="请输入付款流水号"]').setValue('BANK-TEST')
|
||||
await dialog.get('textarea').setValue('付款备注')
|
||||
const file = await chooseFile(dialog)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.text()).toContain('¥149.75')
|
||||
await click(dialog, '确定')
|
||||
expect(api.uploadFinanceVoucher).toHaveBeenCalledWith(String(customer.id), file)
|
||||
expect(api.createFinanceSettlementPayment).toHaveBeenCalledWith(ap.id, { transactionDate: '2026-10-20', amount: 50.25, method: 'CASH', referenceNo: 'BANK-TEST', remark: '付款备注', currency: 'CNY', voucherFileIds: ['9223372036854775040'], expectedVersion: detail ? 8 : 4 })
|
||||
})
|
||||
it.each([false, true])('allocates a real receipt to its loaded statement from detail=%s', async detail => {
|
||||
const { dialog } = await openCashier('receivable', detail)
|
||||
expect(dialog.props('title')).toBe('登记收款 · ' + ar.settlementNo)
|
||||
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
||||
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 50)
|
||||
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-20')
|
||||
dialog.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CASH')
|
||||
await dialog.get('textarea').setValue('收款备注')
|
||||
const file = await chooseFile(dialog)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.text()).toContain('¥150.00')
|
||||
await click(dialog, '确定')
|
||||
expect(api.uploadFinanceVoucher).toHaveBeenCalledWith(String(customer.id), file)
|
||||
expect(api.createFinanceReceipt).toHaveBeenCalledWith({ customerId: String(customer.id), receivedDate: '2026-10-20', amount: 50, paymentMethod: 'CASH', remark: '收款备注', allocations: [{ billId: statement.id, amount: 50 }] }, ['9223372036854775040'])
|
||||
})
|
||||
it.each(['receivable', 'payable'] as const)('requires a selected voucher for %s before uploading', async kind => {
|
||||
const { dialog } = await openCashier(kind)
|
||||
await click(dialog, '确定')
|
||||
expect(api.uploadFinanceVoucher).not.toHaveBeenCalled()
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('retains non-image receipt rejection and clears the invalid file view', async () => {
|
||||
const { dialog } = await openCashier('receivable')
|
||||
await chooseFile(dialog, 'invalid.pdf', 'application/pdf')
|
||||
await click(dialog, '确定')
|
||||
expect(api.uploadFinanceVoucher).not.toHaveBeenCalled()
|
||||
expect(dialog.text()).not.toContain('invalid.pdf')
|
||||
})
|
||||
it.each(['receivable', 'payable'] as const)('locks %s upload and form controls then retains rejected input', async kind => {
|
||||
const pending = deferred<Awaited<ReturnType<typeof api.uploadFinanceVoucher>>>()
|
||||
vi.mocked(api.uploadFinanceVoucher).mockReturnValue(pending.promise)
|
||||
const { wrapper, dialog } = await openCashier(kind)
|
||||
const filename = '很长的凭证名称'.repeat(12) + '.png'
|
||||
await chooseFile(dialog, filename)
|
||||
await dialog.get('textarea').setValue('保留备注')
|
||||
await click(dialog, '确定')
|
||||
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(dialog.getComponent({ name: 'ElUpload' }).props('disabled')).toBe(true)
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(errorDetails(wrapper)).toMatchObject({ requestId: 'settlement-test-request' })
|
||||
expect(dialog.text()).toContain(filename)
|
||||
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留备注')
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it.each(['receivable', 'payable'] as const)('clears the previous %s voucher filename when reopened', async kind => {
|
||||
const { wrapper, dialog } = await openCashier(kind)
|
||||
await chooseFile(dialog, 'old-voucher.png')
|
||||
expect(dialog.text()).toContain('old-voucher.png')
|
||||
await click(dialog, '取消')
|
||||
await click(wrapper, kind === 'receivable' ? '收款' : '付款', '.el-table__body-wrapper button')
|
||||
expect(activeDialog(wrapper).text()).not.toContain('old-voucher.png')
|
||||
await click(activeDialog(wrapper), '确定')
|
||||
expect(api.uploadFinanceVoucher).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['receivable', 'payable'] as const)('keeps %s cashier actions behind their independent permission', async kind => {
|
||||
can.mockImplementation(permission => permission.endsWith(':list'))
|
||||
const wrapper = await mountPage(kind)
|
||||
const labels = wrapper.get('.el-table__body-wrapper').findAll('button').map(button => button.text().trim())
|
||||
expect(labels).not.toContain(kind === 'receivable' ? '收款' : '付款')
|
||||
})
|
||||
it.each([
|
||||
['receivable', 'VOID', 'ISSUED'], ['receivable', 'REOPEN', 'VOIDED'],
|
||||
['payable', 'VOID', 'ISSUED'], ['payable', 'REOPEN', 'VOIDED'],
|
||||
] as const)('identifies %s %s prompts with its settlement number and original version', async (kind, action, status) => {
|
||||
if (kind === 'receivable') vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([{ ...ar, status }])
|
||||
else vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([{ ...ap, status }])
|
||||
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: ' 测试原因 ', action: 'confirm' as const }))
|
||||
const wrapper = await mountPage(kind)
|
||||
await click(wrapper, action === 'VOID' ? '作废' : '重开', '.el-table__body-wrapper button')
|
||||
const current = kind === 'receivable' ? ar : ap
|
||||
expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(current.settlementNo), expect.objectContaining({ customClass: 'admin-density-confirm', inputPattern: /\S+/ }))
|
||||
expect(api.actionFinanceSettlement).toHaveBeenCalledWith(current.id, { action, reason: '测试原因', expectedVersion: current.version })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user