修复财务页面竞态并整理采购与运输展示

This commit is contained in:
2026-10-30 18:50:53 +08:00
parent 19a618c9d2
commit 7704532e8f
29 changed files with 591 additions and 217 deletions
@@ -60,7 +60,7 @@ function setFee(overrides: Partial<FinanceFeeSourceDetail> = {}) {
vi.mocked(api.listFinanceFeeSources).mockResolvedValue({ records: [current], total: 1, pages: 1 })
vi.mocked(api.getFinanceFeeSource).mockResolvedValue(current)
}
async function mountPage(kind: 'arap' | 'statements') {
async function mountPage(kind: 'arap' | 'statements', cachedWorkspace = false) {
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 },
@@ -69,7 +69,10 @@ async function mountPage(kind: 'arap' | 'statements') {
] })
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] } })
const component = cachedWorkspace
? { template: '<router-view v-slot="{ Component, route }"><keep-alive><component :is="Component" :key="route.name" /></keep-alive></router-view>' }
: kind === 'arap' ? FinanceCustomerArapView : FinanceCustomerStatementsView
const wrapper = mount(component, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
return { wrapper, router }
}
@@ -99,17 +102,6 @@ async function fillCreation(dialog: VueWrapper, selected = false) {
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()
@@ -233,6 +225,30 @@ describe('fee source and statement native dialogs', () => {
await click(dialog, '确定')
expect(api.createReconciliationStatement).toHaveBeenCalledWith(expect.objectContaining({ generationMode: 'SELECTED', feeSourceIds: [fee.feeSourceId] }))
})
it.each([false, true])('opens selected statement generation directly from arap with a cached statement page=%s', async cached => {
setFee({ reviewStatus: 'APPROVED' })
const { wrapper, router } = await mountPage(cached ? 'statements' : 'arap', true)
const cachedPage = cached ? wrapper.getComponent(FinanceCustomerStatementsView).element : null
if (cached) {
await click(wrapper, '生成对账单')
await click(activeDialog(wrapper), '取消')
await router.push({ name: 'finance-customer-arap' })
await flushPromises()
}
await wrapper.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
await click(wrapper, '生成所选对账单')
const page = wrapper.getComponent(FinanceCustomerStatementsView)
if (cachedPage) expect(page.element).toBe(cachedPage)
const dialog = activeDialog(wrapper)
expect(dialog.props('title')).toBe('生成客户对账单')
expect(dialog.getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('SELECTED')
expect(dialog.findAllComponents({ name: 'ElSelect' })[0]!.props('modelValue')).toBe(String(customer.id))
expect(dialog.findAllComponents({ name: 'ElDatePicker' }).map(item => item.props('modelValue'))).toEqual(['2026-10-01', '2026-10-01'])
expect(dialog.get('.selected-source-summary').text()).toContain('已选 1 笔,合计 100.00 CNY')
expect(router.currentRoute.value.query).toEqual({})
expect(api.createReconciliationStatement).not.toHaveBeenCalled()
})
it('preserves customer and period validation before creating', async () => {
const { dialog } = await openCreate()
await click(dialog, '确定')
@@ -271,48 +287,14 @@ describe('fee source and statement native dialogs', () => {
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, '确定')
it.each(['APPROVED', 'ISSUED', 'PAID'])('does not offer adjustment or void after statement approval: %s', async status => {
vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, status }])
const { wrapper } = await mountPage('statements')
const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(labels).toContain('详情')
expect(labels).not.toContain('调整')
expect(labels).not.toContain('作废')
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'],
@@ -576,7 +576,7 @@ onBeforeUnmount(clearVoucherPreview)
<tr><th scope="row">原消费金额</th><td>{{ money(refundSource?.entry.amount) }}</td></tr>
</tbody></table></section>
<el-alert title="退款会生成独立流水并关联原消费;累计可退金额以服务端校验为准。" type="info" :closable="false" />
<div v-if="refundError"><el-alert :title="refundError.message" type="error" :closable="false" /><ApiErrorMeta :details="refundError" /></div>
<el-alert v-if="refundError" :title="refundError.message" type="error" :closable="false" />
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="退款登记"><el-form class="density-form" label-position="left" label-width="100px" :disabled="refundSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="退款金额" required><el-input v-model="refundForm.amount" inputmode="decimal" placeholder="请输入退款金额" /></el-form-item></td></tr>
<tr><td><el-form-item label="退款说明" required><el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写退款说明" /></el-form-item></td></tr>
@@ -1,8 +1,9 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { defineComponent, h, KeepAlive, type VNode } from 'vue'
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -53,7 +54,7 @@ const eligibleFeeSource: FinanceFeeSourceSummary = {
sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15',
}
async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') {
async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements', cached = false) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
@@ -63,7 +64,10 @@ async function mountPage(permissions = ['platform:finance:customer-statement:lis
{ path: '/downloads', name: 'download-center', component: { template: '<div>下载中心</div>' } },
] })
await router.push(initialPath); await router.isReady()
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
const cachedPage = defineComponent({ setup: () => () => h(RouterView, {}, {
default: ({ Component }: { Component: VNode }) => h(KeepAlive, {}, { default: () => Component }),
}) })
const wrapper = mount(cached ? cachedPage : FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises()
return { wrapper, router }
}
@@ -121,6 +125,25 @@ describe('FinanceCustomerStatementsView', () => {
expect(wrapper.find('[aria-label="支付历史"]').isVisible()).toBe(true)
})
it('shows overseas procurement as the source type in statement detail fee lines', async () => {
vi.mocked(getReconciliationStatement).mockResolvedValue({
...detail,
lines: ['开箱费', '提柜-额外费用'].map((feeItemName, index) => ({
...detail.lines[0]!, statementLineId: `procurement-line-${index}`, sourceType: 'OVERSEAS_PROCUREMENT_FEE',
sourceNo: 'CG-TEST-001', feeItemName,
})),
})
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'], '/finance/customer-statements/501')
const table = wrapper.get('[aria-label="客户对账单账单明细"]')
const rows = table.findAll('.el-table__body tbody tr')
expect(rows).toHaveLength(2)
expect(rows.map(row => row.findAll('td')[1]!.text())).toEqual(['海外采购费用', '海外采购费用'])
expect(table.text()).toContain('CG-TEST-001')
expect(table.text()).toContain('开箱费')
expect(table.text()).toContain('提柜-额外费用')
expect(table.text()).not.toMatch(/未知来源类型|OVERSEAS_PROCUREMENT_FEE/)
})
it('collapses advanced criteria without losing values and resets hidden filters', async () => {
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; appliedFilters: Record<string, unknown> }
@@ -160,31 +183,33 @@ describe('FinanceCustomerStatementsView', () => {
expect(source).toContain('<RowActions text-actions :actions="statementActions(row as FinanceStatementReadModel)"')
})
it('keeps three available row actions visible with primary and danger semantics', async () => {
it.each(['APPROVED', 'ISSUED', 'PAID'])('hides adjustment and void for reviewed statement %s even with write permissions', async status => {
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status, customerConfirmationStatus: 'PENDING_CONFIRM' }])
const { wrapper } = await mountPage([
'platform:finance:customer-statement:list',
'platform:finance:reconciliation:adjust',
'platform:finance:reconciliation:void',
'platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void',
])
const actionButtons = [...document.querySelectorAll('button')].filter((button) => ['详情', '调整', '作废'].includes(button.textContent?.trim() || ''))
const detailButton = actionButtons.find((button) => button.textContent?.trim() === '详情')
const adjustButton = actionButtons.find((button) => button.textContent?.trim() === '调整')
const voidButton = actionButtons.find((button) => button.textContent?.trim() === '作废')
expect(new Set(actionButtons.map((button) => button.textContent?.trim()))).toEqual(new Set(['详情', '调整', '作废']))
expect(detailButton?.classList).toContain('el-button--small')
expect(detailButton?.classList).toContain('density-action')
expect(detailButton?.getAttribute('data-tone')).toBe('neutral')
expect(adjustButton?.classList).toContain('density-action')
expect(voidButton?.getAttribute('data-tone')).toBe('danger')
const vm = wrapper.vm as unknown as { actionBusy: boolean; adjustBusy: boolean }
vm.actionBusy = true
vm.adjustBusy = true
await flushPromises()
expect(adjustButton?.hasAttribute('disabled')).toBe(true)
expect(voidButton?.hasAttribute('disabled')).toBe(true)
const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(labels).toContain('详情')
expect(labels).not.toContain('调整')
expect(labels).not.toContain('作废')
})
it.each(['DRAFT', 'PENDING_REVIEW'])('keeps void before approval with the independent permission: %s', async status => {
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status }])
const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:void'])
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text().trim() === '作废')!
expect(button.attributes('data-tone')).toBe('danger')
const vm = wrapper.vm as unknown as { actionBusy: boolean }
vm.actionBusy = true
await flushPromises()
expect(button.attributes('disabled')).toBeDefined()
})
it('does not offer void without permission even before approval', async () => {
vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status: 'DRAFT' }])
const { wrapper } = await mountPage()
expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())).not.toContain('作废')
})
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
@@ -224,10 +249,6 @@ describe('FinanceCustomerStatementsView', () => {
expect(text).toContain('2026-08-14 03:27')
expect(text).not.toContain('2026-08-14T03:27:34Z')
await (wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise<void> }).openAdjustment(internalStatement)
await flushPromises()
expect(document.body.textContent).toContain('调整客户对账单')
expect(document.body.textContent).toContain('运费')
})
it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => {
@@ -373,6 +394,67 @@ describe('FinanceCustomerStatementsView', () => {
expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } })
})
const selectedCreatePermissions = ['platform:finance:customer-statement:list', 'platform:finance:customer-arap:list', 'platform:finance:reconciliation:create', 'platform:customer:list']
const selectedCreatePath = '/finance/customer-statements?createMode=SELECTED&customerId=10001&feeSourceIds=42&periodStart=2026-08-15&periodEnd=2026-08-15'
type SourcePage = Awaited<ReturnType<typeof listFinanceFeeSources>>
it.each(['success', 'failure'] as const)('ignores a late %s after leaving a reactivated statement page', async (outcome) => {
const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true)
const pageVm = wrapper.getComponent(FinanceCustomerStatementsView).vm as unknown as {
createOpen: boolean; createSourcesError: unknown; createSourcesLoading: boolean
}
await router.push('/downloads'); await flushPromises()
let resolve!: (value: SourcePage) => void, reject!: (reason: Error) => void
vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise((yes, no) => { resolve = yes; reject = no }))
await router.push(selectedCreatePath); await flushPromises()
expect(listFinanceFeeSources).toHaveBeenCalledTimes(1)
const replace = vi.spyOn(router, 'replace')
await router.push('/downloads'); await flushPromises()
if (outcome === 'success') resolve({ records: [eligibleFeeSource], total: 2, pages: 2 })
else reject(new Error('旧候选查询失败'))
await flushPromises()
expect(router.currentRoute.value.name).toBe('download-center')
expect(replace).not.toHaveBeenCalled()
expect(pageVm.createOpen).toBe(false)
expect(pageVm.createSourcesError).toBeNull()
expect(pageVm.createSourcesLoading).toBe(false)
expect(listFinanceFeeSources).toHaveBeenCalledTimes(1)
})
it('opens the current selection after reactivation without letting an older response reopen it', async () => {
const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true)
const pageVm = wrapper.getComponent(FinanceCustomerStatementsView).vm as unknown as {
createOpen: boolean; createForm: { feeSourceIds: string[] }; createSourceOptions: FinanceFeeSourceSummary[]
}
await router.push('/downloads'); await flushPromises()
let resolve!: (value: SourcePage) => void
vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise(yes => { resolve = yes }))
await router.push(selectedCreatePath); await flushPromises()
await router.push('/downloads'); await flushPromises()
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [{ ...eligibleFeeSource, feeSourceId: '43' }], total: 1, pages: 1 })
await router.push(selectedCreatePath.replace('feeSourceIds=42', 'feeSourceIds=43')); await flushPromises()
expect(pageVm.createOpen).toBe(true)
expect(pageVm.createForm.feeSourceIds).toEqual(['43'])
const replace = vi.spyOn(router, 'replace')
resolve({ records: [eligibleFeeSource], total: 1, pages: 1 }); await flushPromises()
expect(replace).not.toHaveBeenCalled()
expect(pageVm.createForm.feeSourceIds).toEqual(['43'])
expect(pageVm.createSourceOptions.map(item => item.feeSourceId)).toEqual(['43'])
})
it('does not open or navigate after the statement page is unmounted during candidate loading', async () => {
const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true)
await router.push('/downloads'); await flushPromises()
let resolve!: (value: SourcePage) => void
vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise(yes => { resolve = yes }))
await router.push(selectedCreatePath); await flushPromises()
const replace = vi.spyOn(router, 'replace')
wrapper.unmount()
resolve({ records: [eligibleFeeSource], total: 1, pages: 1 }); await flushPromises()
expect(replace).not.toHaveBeenCalled()
expect(document.body.textContent).not.toContain('对账单生成设置')
})
it('keeps the generation dialog open with a customer-friendly message when no billable fees exist', async () => {
const permissions = ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create', 'platform:customer:list']
const { wrapper } = await mountPage(permissions)
@@ -2,7 +2,7 @@
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue'
@@ -29,15 +29,6 @@ import { actionReconciliationStatement, createReconciliationStatement, getReconc
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
selected: boolean
statementLineId: string
feeItemName: string
originalAmount: number
adjustedAmount: number
reason: string
}
type StatementRow = Omit<FinanceStatementReadModel, 'creditDays' | 'settled'> & {
periodStart: string
periodEnd: string
@@ -85,12 +76,9 @@ const createForm = ref({
remark: '',
})
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[]>([])
let selectedCreateGeneration = 0
let pageActive = true
let initialLoadCompleted = false
const canView = computed(() => access.can('platform:finance:customer-statement:list'))
const canListCustomers = computed(() => access.can('platform:customer:list'))
@@ -100,7 +88,6 @@ const canCreate = computed(() => access.can('platform:finance:reconciliation:cre
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust'))
const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
const canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list'))
const canGenerate = computed(() => canCreate.value && canListCustomers.value)
@@ -407,6 +394,7 @@ function defaultCreateForm() {
function openCreate() {
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
invalidateCreateLoads()
createForm.value = defaultCreateForm()
createSourceOptions.value = []
createSourcesError.value = null
@@ -440,6 +428,7 @@ async function loadEligibleCreateSources(preferredIds: string[] = createForm.val
businessDateFrom: createForm.value.periodStart,
businessDateTo: createForm.value.periodEnd,
})
if (sequence !== createSourceLoadSequence) return
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
@@ -467,7 +456,9 @@ function queryText(value: unknown) {
}
async function openSelectedCreateFromRoute() {
if (route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
if (!pageActive || route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
const generation = ++selectedCreateGeneration
const sourcePath = route.fullPath
if (!canGenerate.value || !canSelectSources.value) {
ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单')
return
@@ -484,8 +475,12 @@ async function openSelectedCreateFromRoute() {
ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用')
return
}
createError.value = null
createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' }
await loadEligibleCreateSources(feeSourceIds)
// 带入候选只属于当前激活页面及本次路由,旧响应不能重新打开弹框或覆盖用户导航。
if (!pageActive || generation !== selectedCreateGeneration || route.fullPath !== sourcePath
|| !canGenerate.value || !canSelectSources.value) return
createOpen.value = true
await router.replace({ name: 'finance-customer-statements' })
}
@@ -535,18 +530,6 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
finally { actionBusy.value = false }
}
async function openAdjustment(row: FinanceStatementReadModel) {
adjustError.value = null
adjustBusy.value = true
try {
const current = await fetchDetail(row.id, true)
adjustStatement.value = { ...row, version: current.version }
adjustReason.value = ''
adjustLines.value = current.lines.flatMap((line) => line.statementLineId ? [{ selected: false, statementLineId: line.statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : [])
adjustOpen.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
finally { adjustBusy.value = false }
}
function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 300, disabled: loading.value },
@@ -554,14 +537,13 @@ function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
{ key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value },
{ key: 'adjust', label: '调整', priority: 200, visible: row.status === 'ISSUED' && canAdjust.value, loading: adjustBusy.value },
{ key: 'reopen', label: '重开', priority: 150, visible: ['REJECTED', 'VOIDED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW', 'APPROVED', 'ISSUED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
]
}
function handleStatementAction(command: string, row: FinanceStatementReadModel) {
if (!statementActions(row).some(item => item.key === command && item.visible !== false)) return
if (command === 'detail') void openDetail(row)
else if (command === 'adjust') void openAdjustment(row)
else {
const actions: Record<string, ReconciliationActionPayload['action']> = {
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID',
@@ -571,35 +553,39 @@ 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,
adjustmentLines: selected.map((line) => ({ statementLineId: line.statementLineId, adjustedAmount: line.adjustedAmount, reason: line.reason.trim() })),
})
adjustOpen.value = false
ElMessage.success('对账单调整已保存')
await load(appliedFilters.value)
await openDetail(statement)
} catch (cause) { adjustError.value = getApiErrorDetails(cause, '对账单调整失败'); error.value = adjustError.value }
finally { adjustBusy.value = false }
function invalidateCreateLoads() {
selectedCreateGeneration += 1
createSourceLoadSequence += 1
createSourcesLoading.value = false
}
function deactivateCreateLoads() {
pageActive = false
invalidateCreateLoads()
}
onMounted(async () => {
if (canListCustomers.value) await loadCustomers()
await (canListCustomers.value ? loadCustomers() : undefined)
initialLoadCompleted = true
await openSelectedCreateFromRoute()
})
onActivated(() => {
pageActive = true
if (initialLoadCompleted) void openSelectedCreateFromRoute()
})
onDeactivated(deactivateCreateLoads)
onBeforeUnmount(deactivateCreateLoads)
watch(() => route.name, (name) => {
if (name !== 'finance-customer-statements') invalidateCreateLoads()
}, { flush: 'sync' })
watch(
() => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd],
([open, mode]) => {
if (!open || mode !== 'SELECTED') return
([open, mode], [wasOpen]) => {
if (!open) {
if (wasOpen === true) invalidateCreateLoads()
return
}
if (mode !== 'SELECTED') return
void loadEligibleCreateSources()
},
)
@@ -744,16 +730,7 @@ watch(detailRouteId, (statementId) => {
</section>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-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 workbench-evidence" 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">
<div class="workbench-evidence"><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></div>
<div class="workbench-table"><el-table :data="adjustLines" size="small" border stripe 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>
</div></section>
</el-form>
</AppDialog>
</AdminPage>
</template>
@@ -761,10 +738,8 @@ watch(detailRouteId, (statementId) => {
.finance-statements-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.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 { 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: 100%; margin: 0 auto; overflow: hidden; }
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statement-outstanding { color: var(--el-color-warning-dark-2); }
@@ -90,6 +90,7 @@ const receiptValidationError = ref<string | null>(null)
const receiptError = ref<ApiErrorDetails | null>(null)
const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' })
const actionBusy = ref(false)
const actionError = ref<ApiErrorDetails | null>(null)
const exportSubmitting = ref(false)
const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create')
@@ -459,12 +460,19 @@ async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
}).then((result) => result.value.trim()).catch(() => null)
if (reason === null) return
actionBusy.value = true
actionError.value = null
try {
await actionFinanceSettlement(row.id, { action, reason, expectedVersion: row.version })
ElMessage.success(action === 'VOID' ? '结算单已作废' : '结算单已重开')
if (detailMode.value) await loadDetail(row.id)
else await load()
} catch (cause) { error.value = getApiErrorDetails(cause, `${action === 'VOID' ? '作废' : '重开'}结算单失败`) }
} catch (cause) {
const details = getApiErrorDetails(cause, `${action === 'VOID' ? '作废' : '重开'}结算单失败`)
if (props.kind === 'receivable' && action === 'REOPEN' && details.message === '原对账单已生成其他有效应收结算单') {
details.message = '已有新的应收单,无法重开'
}
actionError.value = details
}
finally { actionBusy.value = false }
}
@@ -524,6 +532,7 @@ onMounted(() => {
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
<OperationErrorToast :message="receiptValidationError" />
<OperationErrorToast :details="receiptError" />
<OperationErrorToast :details="actionError" />
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface" :aria-label="`${title}详情`">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" aria-label="返回列表" @click="backToList"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.settlementNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div><div class="finance-detail-actions">
@@ -674,7 +683,7 @@ onMounted(() => {
</template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
<section class="density-dialog-section workbench-evidence" 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>
@@ -95,7 +95,7 @@ describe('finance G3/G4 page operations', () => {
vi.mocked(createFinanceReceipt).mockReset().mockResolvedValue({ id: '801', customerId: '10001', customerName: '客户A', receivedDate: '2026-08-11', amount: 300, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: ['9001'], allocationCount: 1 })
})
it('loads frozen G3 detail and submits multiple selected adjustment lines', async () => {
it('keeps issued statement history readable and ignores stale adjustment or void commands', async () => {
const pinia = piniaWithPermissions(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust'])
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
@@ -105,26 +105,17 @@ describe('finance G3/G4 page operations', () => {
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
openAdjustment(row: FinanceStatementReadModel): Promise<void>
submitAdjustment(): Promise<void>
adjustReason: string
adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }>
openDetail(row: FinanceStatementReadModel): Promise<void>
handleStatementAction(command: string, row: FinanceStatementReadModel): void
}
await vm.openAdjustment(statement)
vm.adjustReason = '账期复核调整'
vm.adjustLines[0]!.selected = true; vm.adjustLines[0]!.adjustedAmount = 90; vm.adjustLines[0]!.reason = '运费更正'
vm.adjustLines[1]!.selected = true; vm.adjustLines[1]!.adjustedAmount = 180; vm.adjustLines[1]!.reason = '操作费更正'
await vm.submitAdjustment()
vm.handleStatementAction('adjust', statement)
vm.handleStatementAction('void', statement)
await flushPromises()
expect(actionReconciliationStatement).not.toHaveBeenCalled()
await vm.openDetail(statement)
await flushPromises()
expect(getReconciliationStatement).toHaveBeenCalledWith('501')
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', {
action: 'ADJUST', reason: '账期复核调整', expectedVersion: 3,
adjustmentLines: [
{ statementLineId: '601', adjustedAmount: 90, reason: '运费更正' },
{ statementLineId: '602', adjustedAmount: 180, reason: '操作费更正' },
],
})
expect(wrapper.text()).toContain(statement.billNo)
}, 15_000)
it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => {
@@ -101,16 +101,14 @@ describe('Finance G3/G4 views', () => {
expect(actionReconciliationStatement).not.toHaveBeenCalled()
})
it('submits every selected adjustment line with the detail version', async () => {
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust'])
const vm = wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise<void>; submitAdjustment(): Promise<void>; adjustReason: string; adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }> }
await vm.openAdjustment(statement)
vm.adjustReason = '月末核对更正'
vm.adjustLines.forEach((line, index) => { line.selected = true; line.adjustedAmount = index === 0 ? 90 : 180; line.reason = `明细${index + 1}更正` })
await vm.submitAdjustment()
expect(actionReconciliationStatement).toHaveBeenCalledWith('501', { action: 'ADJUST', reason: '月末核对更正', expectedVersion: 3, adjustmentLines: [{ statementLineId: '601', adjustedAmount: 90, reason: '明细1更正' }, { statementLineId: '602', adjustedAmount: 180, reason: '明细2更正' }] })
it('keeps an issued statement read-only with legacy adjustment permission', async () => {
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void'])
const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(labels).toContain('详情')
expect(labels).not.toContain('调整')
expect(labels).not.toContain('作废')
expect(actionReconciliationStatement).not.toHaveBeenCalled()
})
it('creates a payable settlement and registers a voucher-backed payment', async () => {
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:create', 'platform:finance:payment:create'], { kind: 'payable' })
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void>; openCreate(): Promise<void>; submitCreate(): Promise<void>; openPayment(row: FinancePayableSettlement): void; submitPayment(): Promise<void>; createForm: { customerId: string; supplierId: string; dueDate: string; feeSourceIds: string[] }; paymentFile: File | null; paymentForm: { amount: number; transactionDate: string; method: 'BANK_TRANSFER'; referenceNo: string; remark: string } }
@@ -1,8 +1,51 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformTenants } from '@/modules/iam'
import { getReceivableStatistics } from '../api/finance'
import type { ReceivableStatisticsResult } from '../types'
import FinanceReceivableStatisticsView from './FinanceReceivableStatisticsView.vue'
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', () => ({ getReceivableStatistics: vi.fn() }))
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceReceivableStatisticsView.vue'), 'utf8')
const statistics: ReceivableStatisticsResult = {
statisticsMonth: '2026-10', currency: 'CNY', totalReceivable: '2600', totalReceived: '2550', totalOutstanding: '50',
totalCustomers: 2, page: 1, pageSize: 20,
records: [
{ customerId: '10001', customerName: '客户A', currency: 'CNY', totalReceivable: '2400', totalReceived: '2400', totalOutstanding: '0' },
{ customerId: '10002', customerName: '客户 10002', currency: 'CNY', totalReceivable: '200', totalReceived: '150', totalOutstanding: '50' },
],
}
async function mountPage() {
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/receivable-statistics', name: 'finance-receivable-statistics', component: FinanceReceivableStatisticsView },
{ path: '/other', name: 'other-page', component: { template: '<div>其他页面</div>' } },
] })
await router.push('/finance/receivable-statistics')
await router.isReady()
const wrapper = mount(FinanceReceivableStatisticsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
return { wrapper, router }
}
enableAutoUnmount(afterEach)
beforeEach(() => {
can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10002', tenantName: '客户B', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getReceivableStatistics).mockResolvedValue(statistics)
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('receivable statistics page', () => {
it('renders only the approved CNY derived metrics and current-filter actions', () => {
@@ -25,4 +68,47 @@ describe('receivable statistics page', () => {
expect(page).toContain('当前月份暂无应收数据')
expect(page).toContain(':loading="loading"')
})
it('prints an isolated report with all five columns, the loaded month and server totals', async () => {
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
const { wrapper } = await mountPage()
const report = document.body.querySelector(':scope > .receivable-statistics-print')!
expect(report).not.toBeNull()
expect(report.querySelector('h1')?.textContent).toBe('应收款统计表')
expect(report.textContent).toContain('统计月份:2026-10')
expect([...report.querySelectorAll('thead th')].map(cell => cell.textContent)).toEqual(['应收单位', '币种', '应收总额(CNY)', '已收合计(CNY)', '余额未收(CNY)'])
expect([...report.querySelectorAll('tbody tr')].map(row => [...row.querySelectorAll('td')].map(cell => cell.textContent))).toEqual([
['客户A', 'CNY', '2,400.00', '2,400.00', '0.00'],
['客户B', 'CNY', '200.00', '150.00', '50.00'],
])
expect([...report.querySelectorAll('tfoot td')].map(cell => cell.textContent)).toEqual(['2,600.00', '2,550.00', '50.00'])
expect(report.querySelector('button, input, .finance-metric-grid, .data-table__pagination')).toBeNull()
expect(page).toContain(':global(body:has(> .receivable-statistics-print) > :not(.receivable-statistics-print)) { display: none !important; }')
expect(page).toContain('width: 100%; border-collapse: collapse; table-layout: fixed;')
await wrapper.findAll('button').find(button => button.text() === '打印')!.trigger('click')
expect(print).toHaveBeenCalledOnce()
})
it('removes the print report when a cached statistics page is no longer the active route', async () => {
const { router } = await mountPage()
expect(document.querySelector('.receivable-statistics-print')).not.toBeNull()
await router.push('/other')
await flushPromises()
expect(document.querySelector('.receivable-statistics-print')).toBeNull()
await router.push('/finance/receivable-statistics')
await flushPromises()
expect(document.querySelector('.receivable-statistics-print')).not.toBeNull()
})
it.each(['loading', 'error', 'no-permission'])('does not print unavailable statistics: %s', async state => {
if (state === 'loading') vi.mocked(getReceivableStatistics).mockReturnValueOnce(new Promise(() => {}))
if (state === 'error') vi.mocked(getReceivableStatistics).mockRejectedValueOnce(new Error('不可用'))
if (state === 'no-permission') can.mockReturnValue(false)
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { printCurrent(): void }
expect(document.querySelector('.receivable-statistics-print')).toBeNull()
vm.printCurrent()
expect(print).not.toHaveBeenCalled()
})
})
@@ -5,7 +5,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref } from 'vue'
import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -21,6 +21,7 @@ import type { ReceivableStatisticsResult, ReceivableStatisticsRow } from '../typ
const now = new Date()
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
const month = ref(defaultMonth)
const customerId = ref('')
@@ -33,6 +34,7 @@ const error = ref<ApiErrorDetails | null>(null)
const result = ref<ReceivableStatisticsResult | null>(null)
const canView = computed(() => access.can('platform:finance:receivable-statistics:list'))
const canPrint = computed(() => route.name === 'finance-receivable-statistics' && canView.value && !loading.value && !error.value && Boolean(result.value))
const canExport = computed(() => access.can('platform:finance:receivable-statistics:export') && access.can('platform:download:list'))
const rows = computed<ReceivableStatisticsRow[]>(() => result.value?.records ?? [])
const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName])))
@@ -65,7 +67,7 @@ async function loadCustomers() {
function search() { page.value = 1; void load() }
function reset() { month.value = defaultMonth; customerId.value = ''; page.value = 1; void load() }
function printCurrent() { window.print() }
function printCurrent() { if (canPrint.value) window.print() }
function statisticsRowKey(row: ReceivableStatisticsRow) { return `${row.customerId}:${row.customerName}` }
function displayCustomerName(value: unknown) {
const row = value as ReceivableStatisticsRow
@@ -116,12 +118,30 @@ onMounted(() => { void loadCustomers(); void load() })
</section></template>
<template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #toolbar-actions><div class="report-actions"><el-button :disabled="!canPrint" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
<template #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template>
</DataTable></div>
</template>
</AdminPage>
<Teleport to="body">
<section v-if="canPrint && result" class="receivable-statistics-print" aria-label="应收款统计打印报表">
<header>
<h1>应收款统计表</h1>
<p>统计月份:{{ result.statisticsMonth }} · 币种:CNY</p>
</header>
<table>
<colgroup><col class="print-customer-column" /><col class="print-currency-column" /><col /><col /><col /></colgroup>
<thead><tr><th scope="col">应收单位</th><th scope="col">币种</th><th scope="col">应收总额(CNY)</th><th scope="col">已收合计(CNY)</th><th scope="col">余额未收(CNY)</th></tr></thead>
<tbody>
<tr v-for="row in rows" :key="statisticsRowKey(row)"><td>{{ displayCustomerName(row) }}</td><td>{{ row.currency }}</td><td>{{ money(row.totalReceivable) }}</td><td>{{ money(row.totalReceived) }}</td><td>{{ money(row.totalOutstanding) }}</td></tr>
<tr v-if="!rows.length"><td colspan="5">当前月份暂无应收数据</td></tr>
</tbody>
<tfoot><tr><th scope="row" colspan="2">统计汇总</th><td>{{ money(result.totalReceivable) }}</td><td>{{ money(result.totalReceived) }}</td><td>{{ money(result.totalOutstanding) }}</td></tr></tfoot>
</table>
<p>数据第 {{ result.page }} 页 · 共 {{ result.totalCustomers }} 家应收单位</p>
</section>
</Teleport>
</template>
<style scoped>
@@ -133,10 +153,21 @@ onMounted(() => { void loadCustomers(); void load() })
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.statistics-table { overflow: hidden; }
.receivable-statistics-print { display: none; }
@media print {
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; }
.receivable-statistics-page { gap: 10px; padding: 0; color: #000; }
.workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; }
.finance-metric-grid > article, .statistics-table { border-color: #999; box-shadow: none; break-inside: avoid; }
:global(body:has(> .receivable-statistics-print) > :not(.receivable-statistics-print)) { display: none !important; }
:global(body:has(> .receivable-statistics-print)) { min-width: 0; height: auto; margin: 0; overflow: visible; background: #fff; }
.receivable-statistics-print { display: block; width: 100%; color: #000; font: 10pt/1.5 Arial, 'Microsoft YaHei', sans-serif; }
.receivable-statistics-print h1 { margin: 0 0 8pt; text-align: center; font-size: 18pt; }
.receivable-statistics-print p { margin: 8pt 0; }
.receivable-statistics-print table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.receivable-statistics-print col { width: 18%; }
.receivable-statistics-print .print-customer-column { width: 38%; }
.receivable-statistics-print .print-currency-column { width: 8%; }
.receivable-statistics-print th, .receivable-statistics-print td { padding: 5pt; border: 1px solid #999; text-align: left; overflow-wrap: anywhere; }
.receivable-statistics-print tbody td:nth-child(n+3), .receivable-statistics-print thead th:nth-child(n+3), .receivable-statistics-print tfoot td { text-align: right; font-variant-numeric: tabular-nums; }
.receivable-statistics-print thead { display: table-header-group; }
.receivable-statistics-print tfoot { display: table-row-group; font-weight: 700; }
.receivable-statistics-print tr { break-inside: avoid; }
}
</style>
@@ -2,7 +2,7 @@ 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 { ElMessage, ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
@@ -295,4 +295,46 @@ describe('formal settlement native dialogs', () => {
expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(current.settlementNo), expect.objectContaining({ customClass: 'admin-density-confirm admin-workbench', inputPattern: /\S+/ }))
expect(api.actionFinanceSettlement).toHaveBeenCalledWith(current.id, { action, reason: '测试原因', expectedVersion: current.version })
})
it('shows a concise reopen conflict message and retains both the voided and replacement receivables', async () => {
const voided = { ...ar, status: 'VOIDED' }
const replacement = { ...ar, id: '9223372036854775012', settlementNo: 'AR202610200002' }
vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([voided, replacement])
vi.mocked(api.actionFinanceSettlement).mockRejectedValue(new ApiResponseError(
'原对账单已生成其他有效应收结算单', 'FIN_409', 409, null, 'reopen-test-request', 'reopen-test-trace',
))
vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: '测试重开', action: 'confirm' as const }))
const message = vi.spyOn(ElMessage, 'error')
const wrapper = await mountPage('receivable')
for (let attempt = 1; attempt <= 2; attempt += 1) {
await click(wrapper, '重开', '.el-table__body-wrapper button')
expect(message).toHaveBeenCalledTimes(attempt)
expect(message).toHaveBeenLastCalledWith({ message: '已有新的应收单,无法重开', showClose: true, duration: 4500 })
}
expect(api.actionFinanceSettlement).toHaveBeenLastCalledWith(ar.id, { action: 'REOPEN', reason: '测试重开', expectedVersion: ar.version })
expect(errorDetails(wrapper)).toMatchObject({ message: '已有新的应收单,无法重开', code: 'FIN_409', requestId: 'reopen-test-request', traceId: 'reopen-test-trace' })
const list = wrapper.get('.el-table__body-wrapper')
expect(list.text()).toContain(voided.settlementNo)
expect(list.text()).toContain(replacement.settlementNo)
expect(wrapper.text()).not.toContain('加载失败')
expect(wrapper.text()).not.toContain('reopen-test-request')
expect(wrapper.text()).not.toContain('reopen-test-trace')
})
it.each([
['receivable', 'VOID', 'ISSUED'], ['receivable', 'REOPEN', 'VOIDED'],
['payable', 'VOID', 'ISSUED'], ['payable', 'REOPEN', 'VOIDED'],
] as const)('retains the %s list and original diagnostics when %s fails', async (kind, action, status) => {
const current = kind === 'receivable' ? ar : ap
if (kind === 'receivable') vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([{ ...ar, status }])
else vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([{ ...ap, status }])
vi.mocked(api.actionFinanceSettlement).mockRejectedValue(failure())
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')
expect(errorDetails(wrapper)).toMatchObject({ message: '测试操作失败', code: 'TEST_REJECTED', requestId: 'settlement-test-request' })
expect(wrapper.get('.el-table__body-wrapper').text()).toContain(current.settlementNo)
expect(wrapper.text()).not.toContain('加载失败')
})
})
+15
View File
@@ -52,6 +52,21 @@ describe('finance status presentation', () => {
expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作')
})
it.each([
['OVERSEAS_INBOUND_FEE', '海外入仓费用'],
['OVERSEAS_PROCUREMENT_FEE', '海外采购费用'],
['OVERSEAS_TRUCK_FEE', '本土卡车费用'],
['OVERSEAS_LOOSEBOARD_FEE', '本土散板费用'],
['OVERSEAS_WAREHOUSE_FEE', '海外仓服务费'],
['OVERSEAS_CONTAINER_FEE', '海外柜子费用'],
['OVERSEAS_ORDER_FEE', '尾程订单费用'],
['OVERSEAS_DELIVERY_FEE', '尾程派送费用'],
['OVERSEAS_RELABEL_FEE', '换标费用'],
])('labels the current overseas fee source %s consistently', (code, label) => {
expect(financeSourceTypeLabel(code)).toBe(label)
expect(financeSourceTypeLabel(` ${code.toLowerCase()} `)).toBe(label)
})
it('uses review-status values for the customer AR/AP review filter', () => {
expect(financeArapStatusOptions).toEqual([
{ label: '草稿', value: 'DRAFT' },
+5
View File
@@ -60,6 +60,11 @@ export function financeStatusTone(status: unknown): FinanceStatusTone {
const financeSourceTypeLabels: Record<string, string> = {
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_FEE: '装柜费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
OVERSEAS_INBOUND_FEE: '海外入仓费用', OVERSEAS_PROCUREMENT_FEE: '海外采购费用',
OVERSEAS_TRUCK_FEE: '本土卡车费用', OVERSEAS_LOOSEBOARD_FEE: '本土散板费用',
OVERSEAS_WAREHOUSE_FEE: '海外仓服务费', OVERSEAS_CONTAINER_FEE: '海外柜子费用',
OVERSEAS_ORDER_FEE: '尾程订单费用', OVERSEAS_DELIVERY_FEE: '尾程派送费用',
OVERSEAS_RELABEL_FEE: '换标费用',
}
const financeBusinessTypeLabels: Record<string, string> = {
RECONCILIATION_CREATED: '生成对账单',
@@ -270,6 +270,20 @@ describe('单据费用操作', () => {
expect(dialog.text()).toContain('审核状态')
expect(dialog.findAll('button').some(button => button.text().trim() === '审核')).toBe(false)
})
it.each([['ADJUST', '调整'], ['VOID', '作废']] as const)('does not execute %s after the fee was approved elsewhere', async (action, label) => {
const pending = { ...fee, allowedActions: [action] }
const approved = { ...fee, reviewStatus: 'APPROVED', allowedActions: ['ADJUST', 'VOID'] }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValueOnce({ records: [pending], total: 1, page: 1, size: 20, pages: 1 })
.mockResolvedValue({ records: [approved], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(approved)
const wrapper = await mountPage()
await click(wrapper, label, '.el-table__body-wrapper button')
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
expect(wrapper.findAllComponents(AppDialog).some(component => component.props('modelValue'))).toBe(false)
const buttons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(buttons).not.toContain('调整')
expect(buttons).not.toContain('作废')
})
it('voids with the freshly loaded version and a required reason', async () => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['VOID'] }], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, sourceVersion: 5, allowedActions: ['VOID'] })
@@ -24,7 +24,7 @@ function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-c
}
beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(null); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()) })
describe('simplified procurement operations', () => {
it('allows a rejected purchase to be resubmitted without uploading photos and clears the old reason after success', async () => {
it('allows a rejected purchase to be resubmitted without uploading photos and hides the old reason after success', async () => {
const wrapper = render()
const rejected = { ...workflow(), rejectionReason: '请核对', photosNeedUpdate: true, receipts: [
{ id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' },
@@ -39,8 +39,32 @@ describe('simplified procurement operations', () => {
expect(uploadProcurementFile).not.toHaveBeenCalled()
expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'request-confirmation', { version: 4, idempotencyKey: expect.any(String) })
expect(wrapper.emitted('changed')).toHaveLength(1)
await wrapper.setProps({ workflow: { ...rejected, status: 'WAIT_CUSTOMER_CONFIRM', version: 5, rejectionReason: null, photosNeedUpdate: false } })
await wrapper.setProps({ workflow: { ...rejected, status: 'WAIT_CUSTOMER_CONFIRM', version: 5 } })
expect(wrapper.text()).not.toContain('客户驳回原因')
expect(wrapper.text()).not.toContain('请核对')
await wrapper.setProps({ workflow: { ...rejected, status: 'CONFIRMED', version: 6 } })
expect(wrapper.text()).not.toContain('客户驳回原因')
expect(wrapper.text()).not.toContain('请核对')
})
it('hides the previous rejection after receipt photos are updated and shows a new rejection again', async () => {
const wrapper = render()
const rejected = { ...workflow(), rejectionReason: '照片不清晰', photosNeedUpdate: true, receipts: [
{ id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' },
{ id: 'r-2', quantity: 3, fileIds: ['old-2'], receivedAt: '' },
] }
await wrapper.setProps({ workflow: rejected })
expect(wrapper.text()).toContain('照片不清晰')
await wrapper.setProps({ workflow: { ...rejected, version: 5, rejectionReason: null, photosNeedUpdate: true } })
expect(wrapper.text()).not.toContain('客户驳回原因')
expect(wrapper.text()).not.toContain('照片不清晰')
expect(wrapper.findAll('button').some(button => button.text() === '重新上传照片')).toBe(true)
await wrapper.setProps({ workflow: { ...rejected, version: 6, rejectionReason: null, photosNeedUpdate: false } })
expect(wrapper.findAll('button').some(button => button.text() === '重新上传照片')).toBe(false)
await wrapper.setProps({ workflow: { ...rejected, version: 7, rejectionReason: '请补拍完整商品' } })
expect(wrapper.text()).toContain('请补拍完整商品')
})
it('accepts the first and subsequent receipt batches using the remaining purchase quantity', async () => {
const wrapper = render()
@@ -101,14 +101,14 @@ const fileName = (id: string) => props.detail.files.find(file => file.fileId ===
</script>
<template>
<section class="procurement-workflow">
<el-alert v-if="workflow.rejectionReason" title="客户驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" show-icon />
<el-alert v-if="workflow.status === 'RECEIVING' && workflow.rejectionReason" title="客户驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" show-icon />
<div class="density-actions">
<template v-if="workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity" @click="begin('request-confirmation')">采购完成</el-button></template>
<el-button v-if="['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(workflow.status)" type="primary" :loading="checkingFees" :disabled="!can('ship-customer') || workflow.availableQuantity < 1" @click="begin('outbounds')">创建出库单</el-button>
</div>
<el-text v-if="permissionHint" type="warning" role="status">{{ permissionHint }}</el-text>
<dl class="density-fields inventory"><div><dt>累计收货</dt><dd>{{ workflow.receivedQuantity }}</dd></div><div><dt>采购库存</dt><dd>{{ workflow.stockQuantity }}</dd></div><div><dt>出库处理中</dt><dd>{{ workflow.reservedQuantity }}</dd></div><div><dt>可出库</dt><dd>{{ workflow.availableQuantity }}</dd></div></dl>
<h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-if="row.id && workflow.status === 'RECEIVING' && workflow.rejectionReason && can('receive-overseas')" link type="primary" @click="begin('receipt-photos', row as ProcurementWorkflowReceipt)">重新上传照片</el-button><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-if="row.id && workflow.status === 'RECEIVING' && (workflow.rejectionReason || workflow.photosNeedUpdate) && can('receive-overseas')" link type="primary" @click="begin('receipt-photos', row as ProcurementWorkflowReceipt)">重新上传照片</el-button><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<h3>出库单</h3><DataTable :data="workflow.outbounds" :columns="outboundColumns" row-key="id" empty-text="暂无出库单"><template #actions="{ row }"><template v-if="row.status === 'IN_TRANSIT'"><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('edit-outbound', row as ProcurementOutbound)">编辑</el-button><el-button :disabled="!can('ship-customer')" link type="danger" @click="begin('cancel', row as ProcurementOutbound)">取消</el-button><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('dispatch', row as ProcurementOutbound)">开始派送</el-button></template><el-button v-if="row.status === 'DELIVERING'" :disabled="!can('ship-customer')" link type="primary" @click="begin('complete', row as ProcurementOutbound)">上传签收凭证</el-button></template><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="title" width="580px" confirm-text="确认" :confirm-loading="saving" :confirm-disabled="uploading" @confirm="save">
<el-form :model="form" class="density-form density-form--outlined" label-position="top" :disabled="saving">
@@ -105,12 +105,12 @@ async function submit() {
</div>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="'确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
<template v-if="active">
<p>{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p>
<p v-if="active !== 'review' && active !== 'arrange'">{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p>
<el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" />
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="active === 'arrange' || (active === 'review' && form.decision === 'APPROVE')" label="调度仓库" required><el-select v-model="form.warehouseId" placeholder="选择 A 海外仓" filterable><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item v-if="active === 'arrange' || (active === 'review' && form.decision === 'APPROVE')" label="调度仓库" required><el-select v-model="form.warehouseId" placeholder="" filterable><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<template v-if="active === 'receive-pallets'">
<el-form-item label="累计实收托数" required><el-input-number v-model="form.actualPalletCount" :min="Math.max(1, detail.receivedPalletCount)" :precision="0" /></el-form-item>
<el-form-item label="实收件数"><el-input-number v-model="form.packageCount" :min="0" :precision="0" /></el-form-item>
@@ -13,6 +13,17 @@ const fee = (overrides: Partial<OverseasFee> = {}): OverseasFee => ({
})
describe('海外费用动作资格', () => {
it.each(['DRAFT', 'PENDING'])('审核前按服务端资格和独立权限展示调整、作废:%s', reviewStatus => {
const pending = fee({ reviewStatus, allowedActions: ['ADJUST', 'VOID', 'APPROVE', 'REJECT'] })
expect(feeActions(pending, () => true).map(row => row.key)).toEqual(['DETAIL', 'ADJUST', 'VOID', 'REVIEW'])
expect(feeActions(pending, permission => permission === feePermissions.edit).map(row => row.key)).toEqual(['DETAIL', 'ADJUST'])
expect(feeActions(pending, permission => permission === feePermissions.review).map(row => row.key)).toEqual(['DETAIL', 'VOID', 'REVIEW'])
expect(feeActions(fee({ reviewStatus }), () => true).map(row => row.key)).toEqual(['DETAIL'])
})
it.each(['BILL', 'WALLET'])('审核通过后即使旧白名单仍包含调整、作废也不展示:%s', collectionMode => {
const approved = fee({ reviewStatus: 'APPROVED', collectionMode, walletStatus: collectionMode === 'WALLET' ? 'SETTLED' : 'NOT_REQUIRED', allowedActions: ['ADJUST', 'VOID'] })
expect(feeActions(approved, () => true).map(row => row.key)).toEqual(['DETAIL'])
})
it('明确时区的同一时刻显示一致,旧无时区时间不猜测转换', () => {
expect(feeTime('2026-10-23T10:43:25Z')).toBe(feeTime('2026-10-23T18:43:25+08:00'))
const date = new Date('2026-10-23T10:43:25Z')
@@ -79,6 +79,7 @@ export function feeActions(fee: OverseasFee, can: (permission: string) => boolea
const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus)
const canReview = can(feePermissions.review)
const visibleActions = fee.allowedActions.filter(action => action in actionLabels && !['APPROVE', 'REJECT', 'CANCEL'].includes(action))
.filter(action => !['ADJUST', 'VOID'].includes(action) || ['DRAFT', 'PENDING'].includes(fee.reviewStatus))
.filter(action => !closed || ['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action))
.filter(action => can(['VOID'].includes(action) ? feePermissions.review : feePermissions.edit))
.map(action => ({ key: action, label: actionLabels[action]!, priority: 20,
@@ -238,6 +238,27 @@ describe('overseas business mounted pages', () => {
})
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('收货中'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it.each([
{ status: 'REJECTED' as const, label: '已拒绝' },
{ status: 'CANCELLED' as const, label: '已取消' },
])('filters procurement orders by $status and resets to all statuses', async ({ status, label }) => {
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [{ ...purchase, status }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage(PurchaseOrdersView)
await flushPromises()
const statusSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部状态')!
expect(statusSelect.findAllComponents({ name: 'ElOption' }).map(option => ({ value: option.props('value'), label: option.props('label') }))).toEqual(expect.arrayContaining([
{ value: 'REJECTED', label: '已拒绝' }, { value: 'CANCELLED', label: '已取消' },
]))
statusSelect.vm.$emit('update:modelValue', status)
await wrapper.vm.$nextTick()
await wrapper.findAll('button').find(button => button.text() === '查询')!.trigger('click')
await flushPromises()
expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ status }))
expect(wrapper.find('.el-table__body .el-tag').text()).toBe(label)
await wrapper.findAll('button').find(button => button.text() === '重置')!.trigger('click')
await flushPromises()
expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ status: '' }))
})
it('reviews pending procurement from the list, submits rejection as remark, and omits edit', async () => {
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [{ ...purchase, status: 'PENDING_REVIEW' }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage(PurchaseOrdersView); await flushPromises()
@@ -402,12 +423,15 @@ describe('overseas business mounted pages', () => {
})
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('15拖'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
it('opens the requested truck review from the list using its customer scope', async () => {
it('shows a truck detail entry instead of review and edit and keeps its customer scope', async () => {
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
const auth = useAuthStore(); auth.permissions.push('platform:overseas-business:truck:status')
await wrapper.vm.$nextTick()
const review = wrapper.findAll('button').find(button => button.text() === '审核')
expect(review).toBeDefined(); await review!.trigger('click'); await flushPromises()
const actions = wrapper.findAll('.el-table__body-wrapper button')
expect(actions.map(button => button.text())).toContain('详情')
expect(actions.map(button => button.text())).not.toContain('审核')
expect(actions.map(button => button.text())).not.toContain('编辑')
await actions.find(button => button.text() === '详情')!.trigger('click'); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-truck-detail', params: { orderId: 'TK-200' }, query: { customerId: '10002' } })
})
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
@@ -5,9 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import RowActions from '@/shared/components/RowActions.vue'
import type { DataTableColumn } from '@/shared/components/DataTable.vue'
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions as permissions } from '../permissions'
import type { LocalLooseboardOrder } from '../types'
import type { LocalLooseboardOrder, LocalTransportOrder } from '../types'
import TransportOrdersView from './TransportOrdersView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { query: {} } }))
@@ -30,7 +31,7 @@ function mountList(domain: 'truck' | 'looseboard', edit = false) {
auth.permissions = [permissions.truckList, permissions.looseboardList, ...(edit ? [permissions.looseboardEdit] : [])]
useAccessPreviewStore().initializeAfterLogin()
return mount(TransportOrdersView, { props: { domain }, global: { plugins: [pinia, ElementPlus], stubs: {
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.id"><slot name="actions" :row="row" /></div></div>' },
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.id"><span v-for="column in columns" :key="column.key" :data-column="column.key">{{ column.prop ? row[column.prop] : \'\' }}</span><slot name="actions" :row="row" /></div></div>' },
AppDialog: true,
} } })
}
@@ -63,4 +64,34 @@ describe('transport document entry', () => {
expect(listUsTruckOrders).toHaveBeenCalledOnce()
expect(listLocalLooseboardOrders).not.toHaveBeenCalled()
})
it('displays truck pallet counts instead of the unused package count', async () => {
const truck: LocalTransportOrder = {
id: '9007199254741001', customerId: '9007199254741999', orderNo: 'TR-TEST', fleetArrangement: null,
vehicleType: 'PALLET_15', origin: 'LA', destination: 'ONT', palletCount: 3, packageCount: 0,
receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0,
totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD',
originContactName: null, originContactPhone: null, destinationContactName: null, destinationContactPhone: null,
pickupAt: null, deliveryAt: null, status: 'PENDING_REVIEW', version: 1, createdAt: '', updatedAt: '',
}
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [truck], total: 1, page: 1, size: 20, pages: 1 })
const wrapper = mountList('truck'); await flushPromises()
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as DataTableColumn[]
expect(columns.find(column => column.key === 'palletCount')).toMatchObject({ label: '托数', prop: 'palletCount' })
expect(columns.some(column => column.key === 'packageCount')).toBe(false)
expect(wrapper.get('[data-column="palletCount"]').text()).toBe('3')
const actions = wrapper.findComponent(RowActions)
expect(actions.text()).toContain('详情')
expect(actions.text()).not.toContain('审核')
expect(actions.text()).not.toContain('编辑')
})
it('preserves looseboard package counts and forecast pallet counts', async () => {
const wrapper = mountList('looseboard'); await flushPromises()
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as DataTableColumn[]
expect(columns.find(column => column.key === 'packageCount')).toMatchObject({ label: '件数', prop: 'packageCount' })
expect(columns.find(column => column.key === 'estimatedPalletCount')).toMatchObject({ label: '预计托数', prop: 'estimatedPalletCount' })
expect(wrapper.get('[data-column="packageCount"]').text()).toBe('20')
expect(wrapper.get('[data-column="estimatedPalletCount"]').text()).toBe('2')
})
})
@@ -60,7 +60,7 @@ const truckColumns: DataTableColumn[] = [
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
{ key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
{ key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 },
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
{ key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
{ key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.pickupAt) },
{ key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.deliveryAt) },
@@ -119,8 +119,7 @@ function looseboardActions(row: LocalLooseboardOrder): RowActionItem[] {
}
function truckActions(row: LocalTransportOrder): RowActionItem[] {
return [
{ key: 'detail', label: row.status === 'PENDING_REVIEW' && !row.fleetArrangement && access.can(overseasBusinessPermissions.truckStatus) ? '审核' : '详情', priority: 10 },
{ key: 'edit', label: '编辑', visible: !row.fleetArrangement && access.can(editPermission.value), disabled: !canEdit(row) },
{ key: 'detail', label: '详情', priority: 10 },
{ key: 'arrangement', label: '承运安排', visible: !!row.fleetArrangement },
]
}
@@ -68,6 +68,14 @@ describe('truck review and fulfillment', () => {
})
})
it('displays saved pallet counts instead of the unused package count', async () => {
vi.mocked(getUsTruckOrder).mockResolvedValue(order({ palletCount: 3, packageCount: 0 }))
const wrapper = mountDetail(); await flushPromises()
const facts = new Map(wrapper.findAll('.truck-detail-fact').map(fact => [fact.get('dt').text(), fact.get('dd').text()]))
expect(facts.get('托数')).toBe('3')
expect(facts.has('件数')).toBe(false)
})
it('groups the business number, single status and actions in the shared detail header', async () => {
const wrapper = mountDetail(); await flushPromises()
const header = wrapper.find('.detail-page-layout__header')
@@ -42,7 +42,7 @@ const fields = computed(() => detail.value ? [
['取货联系人', detail.value.originContactName], ['取货联系电话', detail.value.originContactPhone],
['送达地', detail.value.destination], ['送达地址', detail.value.destinationAddress],
['送达联系人', detail.value.destinationContactName], ['送达联系电话', detail.value.destinationContactPhone],
['件数', detail.value.packageCount], ['重量(kg)', detail.value.totalWeightKg], ['体积(m³)', detail.value.totalVolumeCbm],
['托数', detail.value.palletCount], ['重量(kg)', detail.value.totalWeightKg], ['体积(m³)', detail.value.totalVolumeCbm],
['预算总价', detail.value.budgetAmount == null ? '—' : `${detail.value.budgetAmount} ${detail.value.currency}`],
['取货时间', dateTime(detail.value.pickupAt)], ['送货时间', dateTime(detail.value.deliveryAt)],
['创建时间', dateTime(detail.value.createdAt)], ['备注', detail.value.remark],
@@ -97,7 +97,7 @@ watch([orderNo, customerId], load, { immediate: true })
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /><p class="truck-detail-note">取货和送达照片请在排车任务中上传。</p></section>
<section class="truck-detail-section truck-flow-section" aria-label="运输流程">
<div class="truck-section-heading"><h2>运输流程</h2><span class="truck-flow-caption">审核通过后由排车单推进取货、派送和送达</span></div>
<div class="truck-section-heading"><h2>运输流程</h2></div>
<ol class="truck-flow">
<li v-for="(step, index) in flowSteps" :key="step" :class="{ active: index === flowIndex, done: index < flowIndex || detail.status === 'COMPLETED' }">
<span class="truck-flow-marker"><Check v-if="index < flowIndex || detail.status === 'COMPLETED'" :size="14" /><MapPin v-else-if="index === 2 || index === 4" :size="14" /><span v-else>{{ index + 1 }}</span></span>
@@ -106,9 +106,9 @@ watch([orderNo, customerId], load, { immediate: true })
</ol>
</section>
<section class="truck-detail-section"><div class="truck-section-heading"><h2>订单资料</h2><span class="truck-flow-caption">基础信息与货量</span></div><dl class="truck-detail-grid"><div v-for="[label, value] in fields" :key="String(label)" class="truck-detail-fact"><dt>{{ label }}</dt><dd>{{ value ?? '—' }}</dd></div></dl></section>
<section class="truck-detail-section"><div class="truck-section-heading"><h2>订单资料</h2></div><dl class="truck-detail-grid"><div v-for="[label, value] in fields" :key="String(label)" class="truck-detail-fact"><dt>{{ label }}</dt><dd>{{ value ?? '—' }}</dd></div></dl></section>
<section class="truck-detail-section"><BusinessDocumentFees business-type="US_TRUCK" :business-no="detail.orderNo" :customer-id="detail.customerId" :business-id="detail.id" /></section>
<section class="truck-detail-section"><div class="truck-section-heading"><h2>审核与操作记录</h2><span class="truck-flow-caption">保留关键节点</span></div><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
<section class="truck-detail-section"><div class="truck-section-heading"><h2>审核与操作记录</h2></div><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template>
</DetailPageLayout></AdminPage>
</template>
@@ -121,7 +121,7 @@ watch([orderNo, customerId], load, { immediate: true })
.truck-detail-section { margin-bottom: var(--admin-space-6); }
.truck-section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.truck-section-heading h2 { margin: 0; font-size: var(--admin-font-section); color: var(--cc-text); }
.truck-flow-caption, .truck-detail-note { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.truck-detail-note { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.truck-detail-note { margin: 10px 0 0; }
.truck-flow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; margin: 0; padding: 0; list-style: none; }
.truck-flow li { position: relative; display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 0; color: var(--cc-muted); text-align: center; }
@@ -4,12 +4,13 @@ import type { Amount, ProcurementFee } from './procurementTypes'
export const procurementStatuses: Record<string, string> = {
FUNDING_PENDING: '订单受理中', FUNDING_FAILED: '订单处理失败', PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认',
IN_TRANSIT: '运输中', DELIVERING: '派送中', COMPLETED: '签收完成',
REJECTED: '已拒绝', CANCELLED: '已取消',
}
export const feePurposes: Record<string, string> = {
SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费',
DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款',
}
export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || ({ REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭' } as Record<string, string>)[status] || '待核对进度' : '—', direction) }
export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || ({ EXCEPTION_CLOSED: '异常关闭' } as Record<string, string>)[status] || '待核对进度' : '—', direction) }
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
if (status === 'COMPLETED') return 'success'
if ((status === 'REJECTED' || status === 'FUNDING_FAILED') || status === 'EXCEPTION_CLOSED') return 'danger'
+1 -1
View File
@@ -5,7 +5,7 @@ export interface OverseasBusinessHistory {
operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string
}
export type OverseasPurchaseStatus = 'FUNDING_PENDING' | 'FUNDING_FAILED' | 'PENDING_REVIEW' | 'RECEIVING' | 'WAIT_CUSTOMER_CONFIRM' | 'CONFIRMED' | 'IN_TRANSIT' | 'DELIVERING' | 'COMPLETED'
export type OverseasPurchaseStatus = 'FUNDING_PENDING' | 'FUNDING_FAILED' | 'PENDING_REVIEW' | 'RECEIVING' | 'WAIT_CUSTOMER_CONFIRM' | 'CONFIRMED' | 'IN_TRANSIT' | 'DELIVERING' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
export interface OverseasPurchaseQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | ''
@@ -310,6 +310,16 @@ describe('dispatch workflow native layout', () => {
})
it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => {
const { dialog } = await render(action)
if (action === 'CANCEL') {
expect(dialog.props('confirmText')).toBe('确认取消排车')
expect(dialog.props('confirmType')).toBe('danger')
expect(dialog.props('cancelText')).toBe('暂不取消')
expect(dialog.props('width')).toBe('560px')
expect(field(dialog, '取消原因').props()).toMatchObject({ labelPosition: 'top', required: true })
expect(dialog.get('textarea').attributes('placeholder')).toBe('请填写取消原因')
expect(dialog.find('[aria-label="操作原因与意见"]').exists()).toBe(false)
expect(dialog.text()).toContain('取消本次排车,不会自动回库。')
}
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview
if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() }
@@ -318,6 +328,20 @@ describe('dispatch workflow native layout', () => {
dialog.vm.$emit('confirm'); await flushPromises()
expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: null }) })
})
it('preserves the cancellation reason after failure and refreshing current facts', async () => {
const { dialog } = await render('CANCEL')
await dialog.get('textarea').setValue(' 客户调整送货安排 ')
vi.mocked(api.cancelDispatch).mockRejectedValueOnce(new ApiResponseError('排车资料已更新,请核对后重试', 'A0409', 409))
dialog.vm.$emit('confirm'); await flushPromises()
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户调整送货安排 ')
expect(dialog.get('textarea').attributes('disabled')).toBeUndefined()
expect(dialog.props('confirmLoading')).toBe(false)
dialog.getComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe(' 客户调整送货安排 ')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.cancelDispatch).toHaveBeenCalledTimes(2)
expect(vi.mocked(api.cancelDispatch).mock.calls[1]![1]).toMatchObject({ recordVersion: 1, reason: '客户调整送货安排' })
})
it('reorders only real unload stops and preserves the external stop and zero route version', async () => {
const { dialog } = await render('EDIT', 'ROUTE')
const rows = dialog.get('[aria-label="本车卸货顺序"]').findAll('tbody tr').filter(row => row.findAll('button').length)
@@ -150,7 +150,7 @@ function payload(): object {
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
case 'CANCEL': case 'RETURN_TO_REVIEW':
if (!form.reason.trim()) throw new DispatchInputError('请填写操作原因')
if (!form.reason.trim()) throw new DispatchInputError(currentAction.value === 'CANCEL' ? '请填写取消原因' : '请填写操作原因')
return { ...common, reason: form.reason.trim() } satisfies ReasonCommand
case 'LOADING_COMPLETE': {
if (!preview.value || preview.value.pickingReconciled !== true || preview.value.loadingComplete.allowed !== true) throw new DispatchInputError(preview.value?.loadingComplete.blockReason || 'PDA真实拣货尚未核平')
@@ -250,7 +250,7 @@ function moveStop(index: number, offset: number) { const target = index + offset
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '当前不可操作' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()">
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '当前不可操作' : currentAction === 'CANCEL' ? '确认取消排车' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" :confirm-type="currentAction === 'CANCEL' ? 'danger' : 'primary'" :cancel-text="currentAction === 'CANCEL' ? '暂不取消' : '取消'" @confirm="submit()">
<div class="density-dialog-sections">
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
@@ -313,14 +313,17 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<section class="density-dialog-section" data-tone="work" aria-label="确认装车完成"><h3 class="density-dialog-section__title">确认装车完成</h3><p>已完成 {{ preview.pallets.length }} 托拣货,共 {{ preview.pallets.reduce((sum, row) => sum + Number(row.pickedPackages), 0) }} 箱。请确认本车货物已全部装车。</p></section>
</template>
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
<el-alert v-else-if="currentAction === 'CANCEL'" title="取消本次排车,不会自动回库。" type="warning" :closable="false" show-icon />
<section v-if="isReview || currentAction === 'SUBMIT_REVIEW' && !vehicleEditor" class="density-dialog-section" data-tone="source" aria-label="审核照片">
<h3 class="density-dialog-section__title">车辆司机照片</h3>
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
</div>
</section>
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="currentAction === 'DISPATCH_REVIEW' ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
<el-form-item v-if="currentAction === 'CANCEL' && currentOperation !== 'ROUTE'" label="取消原因" label-position="top" required>
<el-input v-model="form.reason" aria-label="取消原因" type="textarea" :rows="4" placeholder="请填写取消原因" />
</el-form-item>
<section v-else-if="currentOperation === 'ROUTE' || ['RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="currentAction === 'DISPATCH_REVIEW' ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || currentAction === 'RETURN_TO_REVIEW' ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
</el-form>
</div>
@@ -81,6 +81,23 @@ describe('OutboundDetailView', () => {
emptyWrapper.unmount()
})
it.each([
['WAIT_PICK', '待拣货'], ['WAIT_OUTBOUND', '待出库'], ['PICKING', '拣货中'],
['PARTIAL_PICKED', '部分已拣货'], ['PICKED', '已拣货'], ['OUTBOUNDED', '已出库'], ['CANCELLED', '已取消'],
[null, '—'], ['FUTURE_STATUS', '未知状态'],
])('renders outbound pallet status %s in Chinese', async (status, label) => {
vi.mocked(getOutbound).mockResolvedValueOnce({
outbound: { id: '1', outboundNo: 'OUT/1', status: 'OUTBOUNDED' },
pallets: [{ palletNo: 'DP-1', status }], deliveries: [],
})
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const table = wrapper.get('.outbound-pallet-table')
expect(table.text()).toContain(label)
if (status) expect(table.text()).not.toContain(status)
wrapper.unmount()
})
it('shows cancellation only before picking with the independent permission', async () => {
canPermission.mockImplementation((code: string) => code === 'platform:overseas-warehouse:outbound:cancel')
vi.mocked(getOutbound).mockResolvedValueOnce({ outbound: { id: '1', outboundNo: 'OUT/1', status: 'WAIT_PICK' }, pallets: [], deliveries: [] })
@@ -24,10 +24,10 @@ const outboundNo = computed(() => String(route.params.outboundNo || '').trim())
const detail = ref<OutboundDetail | null>(null); const loading = ref(false); const cancelling = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
const warehouseName = ref('')
const statusLabels: Record<string, string> = { WAIT_PICK: '待拣货', WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' }
const statusLabels: Record<string, string> = { WAIT_PICK: '待拣货', WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PARTIAL_PICKED: '部分已拣货', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' }
const palletColumns: DataTableColumn[] = [
{ key: 'palletNo', label: '托盘码', width: 220 },
{ key: 'status', label: '状态', width: 110, afterColumn: 'palletNo' },
{ key: 'status', label: '状态', width: 110, afterColumn: 'palletNo', formatter: (_row: unknown, _column: unknown, value: unknown) => palletStatusLabel(value) },
{ key: 'inboundCode', label: '入仓码', width: 190 },
{ key: 'destinationWarehouseCode', label: '目的仓编码', width: 150 },
{ key: 'destinationWarehouseName', label: '目的仓名称', minWidth: 180 },
@@ -62,6 +62,7 @@ watch(() => detail.value, async (value, _previous, onCleanup) => {
}
})
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
function palletStatusLabel(value: unknown) { const code = String(value ?? '').trim(); return code ? statusLabels[code] || '未知状态' : '—' }
function outboundTypeLabel(value?: string | null) { return value === 'RELABEL_OUTBOUND' ? '贴标出库' : value === 'DISCARD_OUTBOUND' ? '废弃出库' : value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.outboundType !== 'DISCARD_OUTBOUND' && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(detail.value?.outbound.status || '')))
async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } }