Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
30 changed files with 1043 additions and 249 deletions
Showing only changes of commit c7334b2cf8 - Show all commits
@@ -2,7 +2,17 @@ import axios from 'axios'
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type ExportTaskStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'
export type ExportTaskType = 'CUSTOMER_LIST' | string
export type KnownExportTaskType =
| 'CUSTOMER_LIST'
| 'FINANCE_CUSTOMER_ACCOUNT_LIST'
| 'FINANCE_CUSTOMER_ACCOUNT_LEDGER'
| 'FINANCE_FEE_SOURCE_LIST'
| 'FINANCE_RECONCILIATION_STATEMENT_LIST'
| 'FINANCE_RECEIVABLE_SETTLEMENT_LIST'
| 'FINANCE_RECEIVABLE_SETTLEMENT_DETAIL'
| 'FINANCE_PAYABLE_SETTLEMENT_LIST'
| 'FINANCE_PAYABLE_SETTLEMENT_DETAIL'
export type ExportTaskType = KnownExportTaskType | (string & {})
export interface PlatformExportTask {
id: string
@@ -101,6 +101,30 @@ describe('DownloadCenterView', () => {
expect(retryPlatformExportTask).toHaveBeenCalledWith('9')
})
it('labels and retries Finance tasks with their exact source export permission', async () => {
const financeTask = {
...task,
exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST',
taskName: '客户对账单',
status: 'FAILED' as const,
}
vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [financeTask], page: 1, size: 20, total: 1, pages: 1 })
const pinia = setActivePinia(createPinia())
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['platform:download:list', 'platform:finance:customer-statement:export']
useAccessPreviewStore().initializeAfterLogin()
mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(document.body.textContent).toContain('客户对账单')
const retry = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((item) => item.textContent?.trim() === '重试')
retry?.click()
await flushPromises()
expect(retryPlatformExportTask).toHaveBeenCalledWith('9')
})
it('loads a fresh task detail from the backend', async () => {
const wrapper = mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
@@ -25,6 +25,18 @@ type DownloadFilters = QueryModel & {
requestedRange: string[]
}
const exportTypeMeta: Record<string, { label: string; retryPermission: string }> = {
CUSTOMER_LIST: { label: '客户列表', retryPermission: 'platform:customer:export' },
FINANCE_CUSTOMER_ACCOUNT_LIST: { label: '客户账户', retryPermission: 'platform:finance:customer-account:export' },
FINANCE_CUSTOMER_ACCOUNT_LEDGER: { label: '客户账户流水', retryPermission: 'platform:finance:customer-account:export' },
FINANCE_FEE_SOURCE_LIST: { label: '客户应收应付费用来源', retryPermission: 'platform:finance:customer-arap:export' },
FINANCE_RECONCILIATION_STATEMENT_LIST: { label: '客户对账单', retryPermission: 'platform:finance:customer-statement:export' },
FINANCE_RECEIVABLE_SETTLEMENT_LIST: { label: '应收结算单列表', retryPermission: 'platform:finance:settlement-ar:export' },
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: { label: '应收结算单详情', retryPermission: 'platform:finance:settlement-ar:export' },
FINANCE_PAYABLE_SETTLEMENT_LIST: { label: '应付结算单列表', retryPermission: 'platform:finance:settlement-ap:export' },
FINANCE_PAYABLE_SETTLEMENT_DETAIL: { label: '应付结算单详情', retryPermission: 'platform:finance:settlement-ap:export' },
}
const access = useAccessPreviewStore()
const page = ref(1)
const pageSize = ref(20)
@@ -46,7 +58,7 @@ let detailRequestId = 0
const queryFields: QueryField[] = [
{ key: 'exportType', type: 'select', label: '任务类型', placeholder: '全部任务类型', options: [
{ label: '客户列表', value: 'CUSTOMER_LIST' },
...Object.entries(exportTypeMeta).map(([value, meta]) => ({ label: meta.label, value })),
] },
{ key: 'status', type: 'select', label: '任务状态', placeholder: '全部任务状态', options: [
{ label: '等待处理', value: 'PENDING' }, { label: '处理中', value: 'RUNNING' },
@@ -196,7 +208,7 @@ function safeFileName(value: string) {
return safe || '导出文件.xlsx'
}
function typeLabel(type: string) { return type === 'CUSTOMER_LIST' ? '客户列表' : type }
function typeLabel(type: string) { return exportTypeMeta[type]?.label || type }
function statusMeta(status: ExportTaskStatus) {
const values = {
@@ -223,7 +235,8 @@ function formatSize(bytes: number | null) {
}
function canRetry(task: PlatformExportTask) {
return task.status === 'FAILED' && task.exportType === 'CUSTOMER_LIST' && access.can('platform:customer:export')
const permission = exportTypeMeta[task.exportType]?.retryPermission
return task.status === 'FAILED' && permission !== undefined && access.can(permission)
}
watch([page, pageSize], () => { void loadTasks() })
@@ -1,13 +1,15 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessage } from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { listFinanceAccountLedger, listFinanceAccounts } from '../api/finance'
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
@@ -18,6 +20,8 @@ vi.mock('../api/finance', () => ({
updateFinanceCustomerTerms: vi.fn(),
}))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
@@ -26,6 +30,21 @@ function memoryStorage(): Storage {
return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) }
}
async function mountAccountsView(permissions: string[], initialPath = '/finance/customer-accounts') {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/customer-accounts', name: 'finance-customer-accounts', component: FinanceCustomerAccountsView },
{ path: '/finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: FinanceCustomerAccountsView },
{ path: '/downloads', name: 'download-center', component: { template: '<div>下载中心</div>' } },
] })
await router.push(initialPath); await router.isReady()
const wrapper = mount(FinanceCustomerAccountsView, { global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises()
return Object.assign(wrapper, { router })
}
describe('FinanceCustomerAccountsView delivery', () => {
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerAccountsView.vue'), 'utf8')
const routes = readFileSync(resolve(process.cwd(), 'src/modules/finance/routes.ts'), 'utf8')
@@ -92,6 +111,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
vi.mocked(listFinanceAccounts).mockReset().mockResolvedValue({ records: [{ customerId: '10001', customerCode: 'C-001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 100, pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE', lastChangedAt: '2026-08-13 10:00:00' }], total: 1, pages: 1 })
vi.mocked(listFinanceAccountLedger).mockReset().mockResolvedValue({ records: [{ ledgerId: '1', occurredAt: '2026-08-13T10:00:00', businessType: 'REC', documentType: 'RECEIPT', documentId: '2', documentNo: 'REC-2', receivableChange: -10, payableChange: 0, outstandingReceivableAfter: 90, operatorName: 'operator-1000001', remark: '收款' }], total: 1, pages: 1 })
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ id: 'exp-1', taskNo: 'EXP-1', taskName: '客户账户', status: 'PENDING' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
})
it('restores account summary and ledger after opening the detail URL directly', async () => {
@@ -133,6 +154,57 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(wrapper.text()).not.toContain('2026-08-13T10:00:00')
wrapper.unmount()
})
it('creates account and ledger export tasks from the complete active filters without page parameters', async () => {
const permissions = ['platform:finance:customer-account:list', 'platform:finance:customer-account:export', 'platform:download:list']
const listWrapper = await mountAccountsView(permissions)
const listVm = listWrapper.vm as unknown as { filters: Record<string, unknown>; exportAccounts(): Promise<void> }
Object.assign(listVm.filters, { customerId: '10001', status: 'ACTIVE' })
await listVm.exportAccounts()
expect(createPlatformExportTask).toHaveBeenLastCalledWith({
exportType: 'FINANCE_CUSTOMER_ACCOUNT_LIST', format: 'XLSX', taskName: '客户账户',
parameters: { customerId: '10001', status: 'ACTIVE' },
})
expect(createPlatformExportTask).not.toHaveBeenCalledWith(expect.objectContaining({ parameters: expect.objectContaining({ page: expect.anything() }) }))
listWrapper.unmount()
vi.mocked(createPlatformExportTask).mockResolvedValueOnce({ id: 'exp-2', taskNo: 'EXP-2', taskName: '客户账户流水', status: 'PENDING' } as never)
const detailWrapper = await mountAccountsView(permissions, '/finance/customer-accounts/10001')
const detailVm = detailWrapper.vm as unknown as { ledgerFilters: Record<string, unknown>; exportLedger(): Promise<void> }
Object.assign(detailVm.ledgerFilters, { businessType: 'RECEIPT', occurredRange: ['2026-08-01', '2026-08-31'] })
await detailVm.exportLedger()
expect(createPlatformExportTask).toHaveBeenLastCalledWith({
exportType: 'FINANCE_CUSTOMER_ACCOUNT_LEDGER', format: 'XLSX', taskName: '客户账户流水',
parameters: { customerId: '10001', businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' },
})
expect(showExportTaskCreatedDialog).toHaveBeenLastCalledWith({ taskName: '客户账户流水', taskNo: 'EXP-2' })
detailWrapper.unmount()
})
it('requires both the finance export permission and download-center access', async () => {
const wrapper = await mountAccountsView(['platform:finance:customer-account:list', 'platform:finance:customer-account:export'])
expect(wrapper.findAll('button').map((button) => button.text())).not.toContain('导出Excel')
expect(createPlatformExportTask).not.toHaveBeenCalled()
wrapper.unmount()
})
it('opens the download center after confirmation and reports task creation failures', async () => {
const permissions = ['platform:finance:customer-account:list', 'platform:finance:customer-account:export', 'platform:download:list']
vi.mocked(showExportTaskCreatedDialog).mockResolvedValueOnce(true)
const wrapper = await mountAccountsView(permissions)
await (wrapper.vm as unknown as { exportAccounts(): Promise<void> }).exportAccounts()
expect(wrapper.router.currentRoute.value.name).toBe('download-center')
wrapper.unmount()
const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => undefined as never)
vi.mocked(createPlatformExportTask).mockRejectedValueOnce(new Error('导出服务不可用'))
const failedWrapper = await mountAccountsView(permissions)
await (failedWrapper.vm as unknown as { exportAccounts(): Promise<void> }).exportAccounts()
expect(errorSpy).toHaveBeenCalledWith('客户账户导出任务创建失败')
expect(showExportTaskCreatedDialog).toHaveBeenCalledTimes(1)
failedWrapper.unmount()
errorSpy.mockRestore()
})
})
afterAll(() => vi.unstubAllGlobals())
@@ -4,8 +4,9 @@ import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Download, Eye, Pencil, Save } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -14,6 +15,7 @@ import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
import { financeBusinessTypeLabel } from '../status'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
@@ -44,11 +46,14 @@ const ledgerFilters = ref<QueryModel>({ businessType: '', occurredRange: [] })
const ledgerPage = ref(1)
const ledgerPageSize = ref(20)
const ledgerTotal = ref(0)
const accountExportBusy = ref(false)
const ledgerExportBusy = ref(false)
const canView = computed(() => access.can('platform:finance:customer-account:list'))
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view'))
const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update'))
const canExport = computed(() => access.can('platform:finance:customer-account:export') && access.can('platform:download:list'))
const stateError = computed(() => customersError.value || loadError.value)
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '全部客户', props: { 'aria-label': '客户名称', filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
@@ -173,14 +178,45 @@ function formatDateTime(value: unknown) {
function formatLastChangedAt(value: unknown) { return String(value || '').trim() ? formatDateTime(value) : '暂无变动' }
function displayDocumentNo(value: unknown) { return String(value || '').trim() || '关联单据缺失' }
function displayLedgerId(value: unknown) { return String(value || '').trim() || '流水号缺失' }
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
function downloadCsv(fileName: string, headers: string[], data: unknown[][]) {
const content = `\uFEFF${[headers, ...data].map((line) => line.map(csvCell).join(',')).join('\r\n')}`
const url = URL.createObjectURL(new Blob([content], { type: 'text/csv;charset=utf-8' }))
const link = document.createElement('a'); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url)
async function finishExport(task: Awaited<ReturnType<typeof createPlatformExportTask>>) {
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
if (openDownloadCenter) await router.push({ name: 'download-center' })
}
async function exportAccounts() {
const customerId = String(filters.value.customerId || '').trim()
const status = String(filters.value.status || '').trim()
accountExportBusy.value = true
try {
const task = await createPlatformExportTask({
exportType: 'FINANCE_CUSTOMER_ACCOUNT_LIST', format: 'XLSX', taskName: '客户账户',
parameters: {
...(customerId ? { customerId } : {}),
...(status ? { status } : {}),
},
})
await finishExport(task)
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户账户导出任务创建失败')) }
finally { accountExportBusy.value = false }
}
async function exportLedger() {
if (!selected.value) return
const range = Array.isArray(ledgerFilters.value.occurredRange) ? ledgerFilters.value.occurredRange.map(String) : []
const businessType = String(ledgerFilters.value.businessType || '').trim()
ledgerExportBusy.value = true
try {
const task = await createPlatformExportTask({
exportType: 'FINANCE_CUSTOMER_ACCOUNT_LEDGER', format: 'XLSX', taskName: '客户账户流水',
parameters: {
customerId: selected.value.customerId,
...(businessType ? { businessType } : {}),
...(range[0] ? { startDate: range[0] } : {}),
...(range[1] ? { endDate: range[1] } : {}),
},
})
await finishExport(task)
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户账户流水导出任务创建失败')) }
finally { ledgerExportBusy.value = false }
}
function exportAccounts() { downloadCsv('客户账户.csv', columns.slice(1).map((item) => String(item.label)), rows.value.map((row) => [customerName(row.customerId, row.customerName), row.outstandingReceivable, row.pendingBilling, row.receivedInPeriod, row.outstandingPayable, row.status, row.lastChangedAt])) }
function exportLedger() { downloadCsv(`${selected.value ? customerName(selected.value.customerId, selected.value.customerName) : '客户'}-账户流水.csv`, ledgerColumns.map((item) => String(item.label)), ledger.value.map((row) => [row.ledgerId, formatDateTime(row.occurredAt), ledgerBusinessType(row.businessType), row.documentNo, row.receivableChange, row.payableChange, row.outstandingReceivableAfter, displayOperator(row.operatorName), row.remark])) }
async function closeDetail() {
detailOpen.value = false; selected.value = null; editing.value = false; ledger.value = []
if (route.name === 'finance-customer-account-detail') await router.push({ name: 'finance-customer-accounts' })
@@ -218,7 +254,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
<template v-if="stateError" #state><LoadErrorState title="客户账户加载失败" :message="stateError.message" :loading="loading || customersLoading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!stateError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button :disabled="loading || !rows.length" @click="exportAccounts"><Download :size="16" />导出</el-button></template>
<template #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdateTerms" link type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
@@ -236,7 +272,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
<template v-else-if="selected">
<FormSection v-if="!editing" class="ledger-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" />
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.ledgerId)">{{ displayLedgerId(row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.documentNo)">{{ displayDocumentNo(row.documentNo) }}</span></template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.ledgerId)">{{ displayLedgerId(row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.documentNo)">{{ displayDocumentNo(row.documentNo) }}</span></template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection>
<FormSection v-else class="term-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></div></div></FormSection>
</template>
@@ -5,13 +5,17 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { createPlatformExportTask } from '@/modules/downloads'
import { ApiResponseError } from '@/shared/api/http'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import type { FinanceFeeSourceDetail } from '../types'
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
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', () => ({
listFinanceFeePool: vi.fn(),
getFinanceFeeSource: vi.fn(),
@@ -80,6 +84,8 @@ describe('FinanceCustomerArapView G2 operations', () => {
vi.mocked(reviewFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', operationVersion: 4 })
vi.mocked(updateFinanceFeeSourceSettlementInclusion).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4 })
vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({} as never)
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ id: 'exp-3', taskNo: 'EXP-3', taskName: '客户应收应付费用来源', status: 'PENDING' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
})
it('shows detail from the list permission but keeps G2 operations independent', async () => {
@@ -231,4 +237,36 @@ describe('FinanceCustomerArapView G2 operations', () => {
await vm.submitGeneration()
expect(createReconciliationStatement).toHaveBeenCalledWith({ customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'], periodStart: '2026-08-01', periodEnd: '2026-08-31', remark: '八月费用' })
})
it('creates a fee-source export task with the complete active filters and typed booleans', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:customer-arap:export', 'platform:download:list'])
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportFeeSources(): Promise<void> }
Object.assign(vm.filters, {
customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: 'true',
statementGenerated: 'false', businessRange: ['2026-08-01', '2026-08-31'], keyword: ' 运费 ',
})
await vm.exportFeeSources()
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'FINANCE_FEE_SOURCE_LIST', format: 'XLSX', taskName: '客户应收应付费用来源',
parameters: {
customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true,
statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: '运费',
},
})
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户应收应付费用来源', taskNo: 'EXP-3' })
})
it('does not expose fee-source export without download-center access', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:customer-arap:export'])
expect(wrapper.findAll('button').map((button) => button.text())).not.toContain('导出Excel')
expect(createPlatformExportTask).not.toHaveBeenCalled()
})
it('omits every empty fee-source filter instead of exporting the current table page', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:customer-arap:export', 'platform:download:list'])
await (wrapper.vm as unknown as { exportFeeSources(): Promise<void> }).exportFeeSources()
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'FINANCE_FEE_SOURCE_LIST', format: 'XLSX', taskName: '客户应收应付费用来源', parameters: {},
})
})
})
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Eye, FileCheck2, FilePlus2, Link2 } from '@lucide/vue'
import { ArrowLeft, Download, Eye, FileCheck2, FilePlus2, Link2 } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -15,6 +16,7 @@ import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
import type { FinanceFeeSourceDetail } from '../types'
@@ -71,6 +73,7 @@ const generationForm = reactive<{ mode: 'BY_CUTOFF' | 'SELECTED'; customerId: st
const selectedRows = ref<ArapRow[]>([])
const page = ref(1)
const pageSize = ref(20)
const exportBusy = ref(false)
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
@@ -78,6 +81,7 @@ const canViewSource = computed(() => access.can('platform:finance:customer-arap:
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
const canSettleSource = computed(() => access.can('platform:finance:fee-source:settlement'))
const canCreateStatement = computed(() => access.can('platform:finance:reconciliation:create'))
const canExport = computed(() => access.can('platform:finance:customer-arap:export') && access.can('platform:download:list'))
const stateError = computed(() => customersError.value || loadError.value)
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const queryFields = computed<QueryField[]>(() => [
@@ -226,6 +230,34 @@ async function submitGeneration() {
generationSaving.value = true; generationError.value = null
try { await createReconciliationStatement({ customerId: generationForm.customerId, currency: 'CNY', generationMode: generationForm.mode, feeSourceIds: generationForm.mode === 'SELECTED' ? eligible.map((row) => row.feeSourceId as string) : undefined, periodStart: period[0], periodEnd: period[1], remark: generationForm.remark.trim() }); generationOpen.value = false; selectedRows.value = []; ElMessage.success('对账单草稿已生成'); await load() } catch (error) { generationError.value = getApiErrorDetails(error, '对账单生成失败,请重试') } finally { generationSaving.value = false }
}
async function exportFeeSources() {
const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
const customerId = String(filters.value.customerId || '').trim()
const direction = String(filters.value.direction || '').trim()
const reviewStatus = String(filters.value.reviewStatus || '').trim()
const included = String(filters.value.settlementIncluded || '')
const generated = String(filters.value.statementGenerated || '')
const keyword = String(filters.value.keyword || '').trim()
exportBusy.value = true
try {
const task = await createPlatformExportTask({
exportType: 'FINANCE_FEE_SOURCE_LIST', format: 'XLSX', taskName: '客户应收应付费用来源',
parameters: {
...(customerId ? { customerId } : {}),
...(direction ? { direction } : {}),
...(reviewStatus ? { reviewStatus } : {}),
...(included ? { settlementIncluded: included === 'true' } : {}),
...(generated ? { statementGenerated: generated === 'true' } : {}),
...(range[0] ? { businessDateFrom: range[0] } : {}),
...(range[1] ? { businessDateTo: range[1] } : {}),
...(keyword ? { keyword } : {}),
},
})
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
if (openDownloadCenter) await router.push({ name: 'download-center' })
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户应收应付导出任务创建失败')) }
finally { exportBusy.value = false }
}
async function closeDetail() { detailOpen.value = false; selected.value = null; detail.value = null; await router.push({ name: 'finance-customer-arap' }) }
async function restoreFeeSourceDetail(feeSourceId: string) {
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
@@ -257,7 +289,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange">
<template #toolbar-actions><div v-if="canCreateStatement" class="generation-actions"><el-button @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></div></template>
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><template v-if="canCreateStatement"><el-button @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></template></div></template>
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" link type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" @click="openInclusion(row)"><Link2 :size="15" />结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #documentNo="{ row }">{{ feeDocumentLabel(row.direction, row.feeName) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #sourceId="{ row }">{{ row.sourceId }}</template>
@@ -5,12 +5,16 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants } from '@/modules/iam'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
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', () => ({
actionReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(),
getFinanceFeeSource: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceStatements: vi.fn(),
@@ -42,6 +46,7 @@ async function mountPage(permissions = ['platform:finance:customer-statement:lis
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
{ path: '/finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: FinanceCustomerStatementsView },
{ 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] } })
@@ -62,6 +67,8 @@ describe('FinanceCustomerStatementsView', () => {
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listFinanceStatements).mockResolvedValue([statement])
vi.mocked(getReconciliationStatement).mockResolvedValue(detail)
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
vi.mocked(getFinanceFeeSource).mockResolvedValue({
poolItemId: '81', feeSourceId: '81', customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '',
direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceId: 'FEE-81', sourceFeeId: 'FEE-81', sourceVersion: 2,
@@ -159,13 +166,35 @@ describe('FinanceCustomerStatementsView', () => {
expect(vm.filteredRows).toHaveLength(0)
})
it('hydrates and exports the currently filtered real statement rows as CSV', async () => {
const clicked = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
it('creates a formal statement export task with only non-empty filters', async () => {
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportStatements(): Promise<void> }
vm.filters = {
status: 'ISSUED', billNo: ' REC-501 ', customerId: '10001', containerNo: ' CONT-31 ',
periodStartRange: ['2026-08-01', '2026-08-05'], periodEndRange: [], settled: 'false',
lastPaymentRange: ['2026-08-20', '2026-08-21'], createdRange: [],
}
await (wrapper.vm as unknown as { exportStatements(): Promise<void> }).exportStatements()
expect(getReconciliationStatement).toHaveBeenCalledWith('501')
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
expect(clicked).toHaveBeenCalledOnce()
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST', format: 'XLSX', taskName: '客户对账单',
parameters: {
customerId: '10001', status: 'ISSUED', billNo: 'REC-501', containerNo: 'CONT-31',
periodStartFrom: '2026-08-01', periodStartTo: '2026-08-05', settled: false,
lastPaymentFrom: '2026-08-20', lastPaymentTo: '2026-08-21',
},
})
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' })
expect(getReconciliationStatement).not.toHaveBeenCalledWith('501')
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('requires both statement export and download-center list permissions for the export action', async () => {
await mountPage(['platform:finance:customer-statement:list', 'platform:finance:customer-statement:export'])
expect([...document.querySelectorAll('button')].some((item) => item.textContent?.includes('导出'))).toBe(false)
document.body.innerHTML = ''
await mountPage(['platform:finance:customer-statement:list', 'platform:finance:customer-statement:export', 'platform:download:list'])
expect([...document.querySelectorAll('button')].some((item) => item.textContent?.includes('导出'))).toBe(true)
})
it('disables statement generation when customer selection permission is missing', async () => {
@@ -4,8 +4,9 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -15,6 +16,7 @@ import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { actionReconciliationStatement, createReconciliationStatement, getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
@@ -90,6 +92,7 @@ const canReview = computed(() => access.can('platform:finance:reconciliation:rev
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)
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
@@ -318,31 +321,46 @@ function revisionActionLabel(action: string) {
return ({ SUBMIT: '提交审核', APPROVE: '审核通过', REJECT: '审核驳回', ISSUE: '出账', ADJUST: '调整', VOID: '作废', REOPEN: '重开' } as Record<string, string>)[action] || action || '—'
}
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
async function exportStatements() {
const customerId = String(filters.value.customerId || '').trim()
if (customerId && !/^[1-9]\d*$/.test(customerId)) return ElMessage.error('客户业务标识无效,无法创建导出任务')
const parameters: Record<string, string | boolean> = {}
const putText = (key: string) => {
const value = String(filters.value[key] || '').trim()
if (value) parameters[key] = value
}
const putRange = (sourceKey: string, fromKey: string, toKey: string) => {
const value = filters.value[sourceKey]
if (!Array.isArray(value)) return
if (value[0]) parameters[fromKey] = String(value[0])
if (value[1]) parameters[toKey] = String(value[1])
}
if (customerId) parameters.customerId = customerId
putText('status')
putText('billNo')
putText('containerNo')
putRange('periodStartRange', 'periodStartFrom', 'periodStartTo')
putRange('periodEndRange', 'periodEndFrom', 'periodEndTo')
putRange('lastPaymentRange', 'lastPaymentFrom', 'lastPaymentTo')
putRange('createdRange', 'createdFrom', 'createdTo')
const settled = String(filters.value.settled || '')
if (settled) parameters.settled = settled === 'true'
exportBusy.value = true
try {
const hydrated = await Promise.all(rows.value.map(async (row) => {
const current = await fetchDetail(row.id)
return detailToRow(row, current)
}))
rows.value = hydrated
const task = await createPlatformExportTask({
exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST',
format: 'XLSX',
taskName: '客户对账单',
parameters,
})
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
if (openDownloadCenter) await router.push({ name: 'download-center' })
} catch (cause) {
error.value = getApiErrorDetails(cause, '导出前加载完整对账单字段失败')
ElMessage.error(getApiErrorMessage(cause, '客户对账单导出任务创建失败'))
} finally {
exportBusy.value = false
return
}
const header = ['审核状态', '对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额', '已收金额', '未收金额', '最后收款时间', '是否结清', '备注', '创建人', '创建时间', '更新人', '更新时间']
const body = filteredRows.value.map((row) => [financeStatusLabel(row.status), row.billNo, customerDisplayName(row.customerId, row.customerName), row.periodStart, row.periodEnd, row.creditDays ?? '', row.currency, row.totalAmount, row.receivedAmount, row.outstandingAmount, formatDateTime(row.lastPaymentAt), row.settled === null ? '' : (row.settled ? '是' : '否'), row.remark, operatorDisplayName(row.createdByName), formatDateTime(row.createdAt), operatorDisplayName(row.updatedByName), formatDateTime(row.updatedAt)])
const blob = new Blob([`\uFEFF${[header, ...body].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `客户对账单-${new Date().toISOString().slice(0, 10)}.csv`
anchor.click()
URL.revokeObjectURL(url)
exportBusy.value = false
ElMessage.success(`已导出 ${body.length} 条客户对账单`)
}
function openCreate() {
@@ -495,7 +513,7 @@ watch(detailRouteId, (statementId) => {
<section class="surface-panel table-panel">
<DataTable :data="filteredRows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
<template #toolbar-actions>
<el-button :loading="exportBusy" :disabled="loading || exportBusy || !filteredRows.length" @click="exportStatements"><Download :size="15" />导出</el-button>
<el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportStatements"><Download :size="15" />导出</el-button>
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
</template>
<template #actions="{ row }">
@@ -0,0 +1,160 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants } from '@/modules/iam'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance'
import type { FinanceSettlementDetail } from '../types'
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('@/modules/suppliers', () => ({ categoryLabels: {}, listSuppliers: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('../api/finance', async (importOriginal) => ({
...await importOriginal<typeof import('../api/finance')>(),
getFinanceSettlement: vi.fn(),
listFinancePayableSettlements: vi.fn(),
listFinanceReceivableSettlements: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value),
}
}
function piniaWithPermissions(permissions: string[]) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
return pinia
}
function testRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'settlements', component: { template: '<div />' } },
{ path: '/downloads', name: 'download-center', component: { template: '<div />' } },
],
})
}
const detail: FinanceSettlementDetail = {
id: '701', settlementNo: 'AR-701', customerId: '10001', reconciliationStatementId: '501', direction: 'RECEIVABLE',
amount: 300, paidAmount: 0, outstandingAmount: 300, currency: 'CNY', status: 'ISSUED', version: 0,
lines: [], transactions: [], revisions: [],
}
enableAutoUnmount(afterEach)
describe('formal settlement download-center exports', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', createMemoryStorage())
vi.stubGlobal('sessionStorage', createMemoryStorage())
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([])
vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue(detail)
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '应收结算单列表', taskNo: 'EXP-001' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
})
it('creates an AR list task with current filters and no paging or direction parameters', async () => {
const router = testRouter()
await router.push('/'); await router.isReady()
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:settlement-ar:export', 'platform:download:list'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportRows(): Promise<void> }
vm.filters = { customerId: '10001', status: 'ISSUED', settlementNo: ' AR-001 ', supplierName: '' }
await vm.exportRows()
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'FINANCE_RECEIVABLE_SETTLEMENT_LIST', format: 'XLSX', taskName: '应收结算单列表',
parameters: { customerId: '10001', status: 'ISSUED', settlementNo: 'AR-001' },
})
expect(createPlatformExportTask).not.toHaveBeenCalledWith(expect.objectContaining({ parameters: expect.objectContaining({ direction: expect.anything(), page: expect.anything(), pageSize: expect.anything() }) }))
})
it('creates an AP list task with the supplier filter', async () => {
const router = testRouter()
await router.push('/'); await router.isReady()
const pinia = piniaWithPermissions(['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:export', 'platform:download:list'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'payable' }, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportRows(): Promise<void> }
vm.filters = { customerId: '10002', status: 'PARTIALLY_PAID', settlementNo: 'AP-002', supplierName: '承运供应商' }
await vm.exportRows()
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'FINANCE_PAYABLE_SETTLEMENT_LIST', format: 'XLSX', taskName: '应付结算单列表',
parameters: { customerId: '10002', status: 'PARTIALLY_PAID', settlementNo: 'AP-002', supplierName: '承运供应商' },
})
})
it('exports a detail by settlement business key and opens the download center on confirmation', async () => {
vi.mocked(createPlatformExportTask).mockResolvedValueOnce({ taskName: '应收结算单详情', taskNo: 'EXP-DETAIL-1' } as never)
vi.mocked(showExportTaskCreatedDialog).mockResolvedValueOnce(true)
const router = testRouter()
await router.push('/'); await router.isReady()
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:settlement-ar:export', 'platform:download:list'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable', settlementId: '701' }, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as { exportDetail(): Promise<void> }
await vm.exportDetail()
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'FINANCE_RECEIVABLE_SETTLEMENT_DETAIL', format: 'XLSX', taskName: '应收结算单详情', parameters: { settlementId: '701' },
})
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '应收结算单详情', taskNo: 'EXP-DETAIL-1' })
expect(router.currentRoute.value.name).toBe('download-center')
})
it('uses the payable detail type without a direction parameter', async () => {
vi.mocked(getFinanceSettlement).mockResolvedValueOnce({ ...detail, id: '801', settlementNo: 'AP-801', direction: 'PAYABLE' })
vi.mocked(createPlatformExportTask).mockResolvedValueOnce({ taskName: '应付结算单详情', taskNo: 'EXP-DETAIL-2' } as never)
const router = testRouter()
await router.push('/'); await router.isReady()
const pinia = piniaWithPermissions(['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:export', 'platform:download:list'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'payable', settlementId: '801' }, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises()
await (wrapper.vm as unknown as { exportDetail(): Promise<void> }).exportDetail()
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'FINANCE_PAYABLE_SETTLEMENT_DETAIL', format: 'XLSX', taskName: '应付结算单详情', parameters: { settlementId: '801' },
})
})
it('hides export actions unless both export and download-list permissions are present', async () => {
const router = testRouter()
await router.push('/'); await router.isReady()
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:download:list'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises()
expect(wrapper.find('.settlement-list-actions').text()).not.toContain('导出')
})
})
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, CreditCard, Download, Eye, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { categoryLabels, listSuppliers, type Supplier } from '@/modules/suppliers'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -15,6 +17,7 @@ import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import {
actionFinanceSettlement,
createFinanceReceipt,
@@ -41,6 +44,7 @@ type SettlementRow = FinanceReceivableSettlement | FinancePayableSettlement
const props = defineProps<{ kind: 'receivable' | 'payable'; settlementId?: string }>()
const emit = defineEmits<{ openDetail: [settlementId: string]; back: [] }>()
const router = useRouter()
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const filters = ref<QueryModel>({ customerId: '', supplierName: '', settlementNo: '', status: '' })
@@ -77,15 +81,18 @@ 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 exportSubmitting = ref(false)
const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create')
const listPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:list' : 'platform:finance:settlement-ap:list')
const exportPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:export' : 'platform:finance:settlement-ap:export')
const canView = computed(() => access.can(listPermission.value))
const canListCustomers = computed(() => access.can('platform:customer:list'))
const canCreate = computed(() => access.can(createPermission.value))
const canPay = computed(() => props.kind === 'payable' && access.can('platform:finance:payment:create'))
const canReceive = computed(() => props.kind === 'receivable' && access.can('platform:finance:receipt:create'))
const canVoid = computed(() => access.can('platform:finance:settlement:void'))
const canExport = computed(() => access.can(exportPermission.value) && access.can('platform:download:list'))
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
const detailMode = computed(() => Boolean(props.settlementId))
const createLabel = computed(() => props.kind === 'receivable' ? '生成应收结算单' : '生成应付结算单')
@@ -397,37 +404,48 @@ async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
finally { actionBusy.value = false }
}
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
function exportRows() {
if (!filteredRows.value.length) return ElMessage.warning('当前没有可导出的结算单')
const header = props.kind === 'receivable'
? ['结算单号', '客户', '币种', '应收金额', '已收', '余额', '生成日期', '到期日', '收款状态']
: ['结算单号', '供应商', '费用类型', '币种', '应付金额', '已付', '余额', '生成日期', '付款状态']
const records = filteredRows.value.map((row) => props.kind === 'receivable'
? [row.settlementNo, customerName(row.customerId, (row as FinanceReceivableSettlement).customerName), row.currency, totalAmount(row), paidAmount(row), row.outstandingAmount, (row as FinanceReceivableSettlement).billDate, (row as FinanceReceivableSettlement).dueDate, financeStatusLabel(row.status)]
: [row.settlementNo, (row as FinancePayableSettlement).supplierName, (row as FinancePayableSettlement).feeItemName, row.currency, totalAmount(row), paidAmount(row), row.outstandingAmount, (row as FinancePayableSettlement).createdAt, financeStatusLabel(row.status)])
const blob = new Blob([`\ufeff${[header, ...records].map((record) => record.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${title.value}-${new Date().toISOString().slice(0, 10)}.csv`
link.click()
URL.revokeObjectURL(url)
async function createSettlementExport(detailId?: string) {
const isDetail = Boolean(detailId)
const taskName = props.kind === 'receivable'
? (isDetail ? '应收结算单详情' : '应收结算单列表')
: (isDetail ? '应付结算单详情' : '应付结算单列表')
const exportType = props.kind === 'receivable'
? (isDetail ? 'FINANCE_RECEIVABLE_SETTLEMENT_DETAIL' : 'FINANCE_RECEIVABLE_SETTLEMENT_LIST')
: (isDetail ? 'FINANCE_PAYABLE_SETTLEMENT_DETAIL' : 'FINANCE_PAYABLE_SETTLEMENT_LIST')
const settlementId = String(detailId || '')
if (isDetail && !/^[1-9]\d*$/.test(settlementId)) return ElMessage.error('结算单业务标识无效,无法创建导出任务')
const parameters: Record<string, string> = isDetail ? { settlementId } : {}
if (!isDetail) {
const customerId = String(filters.value.customerId || '').trim()
if (customerId && !/^[1-9]\d*$/.test(customerId)) return ElMessage.error('客户业务标识无效,无法创建导出任务')
const status = String(filters.value.status || '').trim()
const settlementNo = String(filters.value.settlementNo || '').trim()
const supplierName = String(filters.value.supplierName || '').trim()
if (customerId) parameters.customerId = customerId
if (status) parameters.status = status
if (settlementNo) parameters.settlementNo = settlementNo
if (props.kind === 'payable' && supplierName) parameters.supplierName = supplierName
}
exportSubmitting.value = true
try {
const task = await createPlatformExportTask({
exportType,
format: 'XLSX',
taskName,
parameters,
})
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
if (openDownloadCenter) await router.push({ name: 'download-center' })
} catch (cause) {
ElMessage.error(getApiErrorMessage(cause, `${taskName}导出任务创建失败`))
} finally {
exportSubmitting.value = false
}
}
function exportDetail() {
if (!detail.value) return
const current = detail.value
const header = ['结算单号', props.kind === 'receivable' ? '客户' : '供应商', '币种', props.kind === 'receivable' ? '应收金额' : '应付金额', props.kind === 'receivable' ? '已收' : '已付', '余额', '业务期间', '生成日期', '到期日', '状态']
const record = [current.settlementNo, props.kind === 'receivable' ? customerName(current.customerId, current.customerName) : current.supplierName, current.currency, current.amount, current.paidAmount, current.outstandingAmount, current.businessPeriod, current.generatedDate, current.dueDate, financeStatusLabel(current.status)]
const blob = new Blob([`\ufeff${[header, record].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${current.settlementNo}.csv`
link.click()
URL.revokeObjectURL(url)
}
function exportRows() { return createSettlementExport() }
function exportDetail() { return detail.value ? createSettlementExport(detail.value.id) : undefined }
onMounted(() => {
if (canListCustomers.value) void loadCustomers()
@@ -448,7 +466,7 @@ onMounted(() => {
<el-tag v-if="detail" :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
</div>
<div class="settlement-detail-actions">
<el-button v-if="detail" @click="exportDetail"><Download :size="15" />导出</el-button>
<el-button v-if="detail && canExport" :loading="exportSubmitting" :disabled="exportSubmitting" @click="exportDetail"><Download :size="15" />导出</el-button>
<el-button v-if="detail && canReceive && ['ISSUED','PARTIALLY_PAID'].includes(detail.status)" type="primary" :loading="receiptBusy" @click="openReceiptFromDetail"><HandCoins :size="15" />部分收款</el-button>
<el-button v-if="detail && canPay && ['ISSUED','PARTIALLY_PAID'].includes(detail.status)" type="primary" @click="openPaymentFromDetail"><CreditCard :size="15" />部分付款</el-button>
<el-button v-if="detail && canVoid && detail.status === 'ISSUED'" type="danger" :loading="actionBusy" @click="performAction(detailActionRow(), 'VOID')"><XCircle :size="15" />作废</el-button>
@@ -534,7 +552,7 @@ onMounted(() => {
</div>
<div class="settlement-list-actions">
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button>
<el-button @click="exportRows"><Download :size="15" />导出</el-button>
<el-button v-if="canExport" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportRows"><Download :size="15" />导出</el-button>
</div>
</section>
<section class="surface-panel table-panel">
@@ -197,7 +197,7 @@ describe('finance G3/G4 page operations', () => {
it('keeps settled settlements readable and exportable while hiding receipt actions', async () => {
vi.mocked(getFinanceSettlement).mockResolvedValue({ ...receivable, status: 'PAID', paidAmount: 300, outstandingAmount: 0 })
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:receipt:create'])
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:settlement-ar:export', 'platform:finance:receipt:create', 'platform:download:list'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable', settlementId: '701' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
@@ -113,11 +113,11 @@ describe('Finance G3/G4 views', () => {
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: { supplierId: string; dueDate: string; feeSourceIds: string[] }; paymentFile: File | null; paymentForm: { amount: number; transactionDate: string; method: 'BANK_TRANSFER'; referenceNo: string; remark: string } }
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 } }
await vm.load({ customerId: '10001' }); await vm.openCreate()
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
expect(document.body.textContent).toContain('拖车供应商A · 拖车')
Object.assign(vm.createForm, { supplierId: '301', dueDate: '2026-09-30', feeSourceIds: ['81'] })
Object.assign(vm.createForm, { customerId: '10001', supplierId: '301', dueDate: '2026-09-30', feeSourceIds: ['81'] })
await vm.submitCreate()
expect(createPayableSettlement).toHaveBeenCalledWith({ customerId: '10001', supplierId: '301', feeSourceIds: ['81'], dueDate: '2026-09-30', currency: 'CNY' })
vm.openPayment(payable); vm.paymentFile = new File(['x'], 'voucher.png', { type: 'image/png' }); vm.paymentForm.amount = 100
@@ -5,6 +5,7 @@ import {
acceptPlatformFirstLegOrder,
addPlatformFirstLegOrderNote,
approvePlatformFirstLegOrder,
batchApprovePlatformFirstLegOrders,
cancelPlatformFirstLegOrder,
completePlatformFirstLegOrder,
getPlatformFirstLegOrder,
@@ -96,6 +97,22 @@ describe('platform first-leg order API', () => {
expect(http.post).toHaveBeenNthCalledWith(6, '/platform/v1/orders/first-leg/90001/internal-notes', { content: '已联系客户确认' }, { params: { tenantId: '10001' } })
})
it('uses the formal tenant-scoped batch approval contract and preserves long order ids', async () => {
vi.mocked(http.post).mockResolvedValue({
code: '200', message: 'success',
data: { total: 2, successCount: 1, failureCount: 1, successOrderIds: ['2089223263733518337'], failedOrders: [{ orderId: '2089223263733518338', code: 'A0409', message: '订单状态已变化' }] },
})
await expect(batchApprovePlatformFirstLegOrders('2089176869176389634', [
'2089223263733518337', '2089223263733518338',
])).resolves.toMatchObject({ total: 2, successCount: 1, failureCount: 1 })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/orders/first-leg/batch-approve',
{ orderIds: ['2089223263733518337', '2089223263733518338'] },
{ params: { tenantId: '2089176869176389634' } },
)
})
it('writes the backend express contract as names, not a guessed master-data ID', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
@@ -115,6 +115,20 @@ export interface PlatformTenantOrderStatistics {
asOf: string
}
export interface PlatformOrderBatchReviewFailure {
orderId: number | string
code: string
message: string
}
export interface PlatformOrderBatchReviewResult {
total: number
successCount: number
failureCount: number
successOrderIds: Array<number | string>
failedOrders: PlatformOrderBatchReviewFailure[]
}
type PlatformFirstLegOrderPagePayload = Omit<PlatformFirstLegOrderPageResource, 'page' | 'size' | 'total' | 'pages'> & {
page: number | string
size: number | string
@@ -176,6 +190,14 @@ export function approvePlatformFirstLegOrder(tenantId: string, orderId: string)
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/approve`, undefined, { tenantId })
}
export function batchApprovePlatformFirstLegOrders(tenantId: string, orderIds: string[]) {
return postData<PlatformOrderBatchReviewResult>(
'/platform/v1/orders/first-leg/batch-approve',
{ orderIds },
{ tenantId },
)
}
export function acceptPlatformFirstLegOrder(tenantId: string, orderId: string) {
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/accept`, undefined, { tenantId })
}
@@ -34,6 +34,27 @@ describe('PlatformFirstLegOrdersPanel review queue', () => {
expect(source).toContain('await nextTick()')
})
it('supports tenant-aware batch approval with partial failure feedback', () => {
expect(source).toContain("key: 'selection', type: 'selection'")
expect(source).toContain("row.status === 'SUBMITTED' && !batchReviewLoading.value")
expect(source).toContain('@selection-change="handleReviewSelectionChange"')
expect(source).toContain('<template #toolbar-actions>')
expect(source).toContain('批量审核通过')
expect(source).toContain('groupOrdersByTenant(selected)')
expect(source.indexOf('batchReviewLoading.value = true')).toBeLessThan(source.indexOf('await ElMessageBox.confirm('))
expect(source).toContain('batchApprovePlatformFirstLegOrders(')
expect(source).toContain('successCount += result.successCount')
expect(source).toContain('failureCount += result.failureCount')
expect(source).toContain('成功 ${successCount} 个,失败 ${failureCount} 个')
})
it('keeps the operation column first before the batch selection column', () => {
const actionColumn = source.indexOf("{ key: 'actions', label: '操作'")
const selectionColumn = source.indexOf("key: 'selection', type: 'selection'")
expect(actionColumn).toBeGreaterThan(0)
expect(selectionColumn).toBeGreaterThan(actionColumn)
})
it('shows up to three row actions directly and overflows only the fourth action onward', () => {
expect(source).toContain('class="table-actions order-row-actions"')
expect(source).toContain('directSecondaryActionCount')
@@ -8,6 +8,7 @@ import {
acceptPlatformFirstLegOrder,
addPlatformFirstLegOrderNote,
approvePlatformFirstLegOrder,
batchApprovePlatformFirstLegOrders,
cancelPlatformFirstLegOrder,
completePlatformFirstLegOrder,
getPlatformFirstLegOrder,
@@ -40,6 +41,8 @@ const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const actionLoadingId = ref('')
const batchReviewLoading = ref(false)
const selectedReviewOrders = ref<PlatformFirstLegOrderResource[]>([])
const loadError = ref<ApiErrorDetails | null>(null)
const detailLoading = ref(false)
const detail = ref<PlatformFirstLegOrderDetailResource | null>(null)
@@ -96,8 +99,12 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'dateRange', type: 'daterange', label: '订单创建日期', props: { 'aria-label': '订单创建日期' } },
])
const orderColumns: DataTableColumn[] = [
const orderColumns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', minWidth: 288, fixed: 'left', slotName: 'actions' },
...(can('platform:order:approve') ? [{
key: 'selection', type: 'selection', width: 48, fixed: 'left',
selectable: (row: PlatformFirstLegOrderResource) => row.status === 'SUBMITTED' && !batchReviewLoading.value,
} satisfies DataTableColumn] : []),
{ key: 'status', label: '状态', width: 108, slotName: 'status' },
{ key: 'orderInfo', label: '订单信息', minWidth: 210, slotName: 'orderInfo' },
{ key: 'customer', label: '客户', minWidth: 190, showOverflowTooltip: true, slotName: 'customer' },
@@ -105,7 +112,7 @@ const orderColumns: DataTableColumn[] = [
{ key: 'destination', label: '目的地', minWidth: 150, slotName: 'destination' },
{ key: 'receivingWarehouse', label: '平台收货仓', minWidth: 180, slotName: 'receivingWarehouse' },
{ key: 'express', label: '履约快递', minWidth: 160, slotName: 'express' },
]
])
const expressRules: FormRules = {
expressCompany: [{ required: true, message: '请填写快递公司', trigger: 'blur' }],
@@ -270,6 +277,7 @@ function actionLabel(order: PlatformFirstLegOrderResource) {
async function loadOrders() {
loadError.value = null
selectedReviewOrders.value = []
loading.value = true
try {
const result = await listPlatformFirstLegOrders({
@@ -292,6 +300,64 @@ async function loadOrders() {
}
}
function handleReviewSelectionChange(rows: PlatformFirstLegOrderResource[]) {
selectedReviewOrders.value = rows.filter((row) => row.status === 'SUBMITTED')
}
function groupOrdersByTenant(selected: PlatformFirstLegOrderResource[]) {
const groups = new Map<string, PlatformFirstLegOrderResource[]>()
for (const order of selected) {
const tenantId = String(order.tenantId)
groups.set(tenantId, [...(groups.get(tenantId) || []), order])
}
return groups
}
async function batchApproveSelectedOrders() {
const selected = [...selectedReviewOrders.value]
if (!selected.length || batchReviewLoading.value) return
const customerCount = new Set(selected.map((order) => String(order.tenantId))).size
batchReviewLoading.value = true
try {
await ElMessageBox.confirm(
`确认批量审核通过已勾选的 ${selected.length} 个订单吗?涉及 ${customerCount} 个客户,审核成功后订单将进入待测量。`,
'批量审核通过',
{ confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' },
)
let successCount = 0
let failureCount = 0
const failureMessages: string[] = []
for (const [tenantId, tenantOrders] of groupOrdersByTenant(selected)) {
try {
const result = await batchApprovePlatformFirstLegOrders(
tenantId,
tenantOrders.map((order) => String(order.id)),
)
successCount += result.successCount
failureCount += result.failureCount
failureMessages.push(...result.failedOrders.map((failure) => failure.message))
} catch (error) {
failureCount += tenantOrders.length
failureMessages.push(getApiErrorDetails(error, '该客户订单批量审核失败').message)
}
}
await loadOrders()
const reasons = [...new Set(failureMessages.filter(Boolean))].slice(0, 3).join(';')
if (failureCount === 0) {
ElMessage.success(`已批量审核通过 ${successCount} 个订单`)
} else if (successCount > 0) {
ElMessage.warning(`批量审核完成:成功 ${successCount} 个,失败 ${failureCount} 个${reasons ? `。${reasons}` : ''}`)
} else {
ElMessage.error(`批量审核失败:${failureCount} 个订单未通过${reasons ? `。${reasons}` : ''}`)
}
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorDetails(error, '批量审核失败,请稍后重试').message)
} finally {
batchReviewLoading.value = false
}
}
function searchOrders() {
if (page.value !== 1) {
page.value = 1
@@ -597,7 +663,17 @@ onMounted(() => void loadTenants())
:page-sizes="[20, 50, 100]"
empty-text="当前没有符合条件的头程订单"
row-key="id"
@selection-change="handleReviewSelectionChange"
>
<template #toolbar-actions>
<el-button
v-if="can('platform:order:approve')"
type="success"
:loading="batchReviewLoading"
:disabled="loading || batchReviewLoading || selectedReviewOrders.length === 0"
@click="batchApproveSelectedOrders"
><CircleCheckBig :size="16" />批量审核通过<span v-if="selectedReviewOrders.length">({{ selectedReviewOrders.length }})</span></el-button>
</template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条订单</span></template>
<template #actions="{ row }">
<div class="table-actions order-row-actions">
@@ -19,20 +19,22 @@ describe('InboundTaskList measurement scope', () => {
it('uses the inbound tenant isolation key for measurement and review requests', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
expect(source.match(/:tenant-id="selectedInbound\.tenantId"/g)).toHaveLength(2)
expect(source).toContain(':tenant-id="selectedInbound.tenantId"')
expect(source).toContain(':customer-id="selectedInbound.tenantId"')
expect(source).not.toContain('selectedInbound.customerId || selectedInbound.tenantId')
})
it('embeds the weight OCR workflow in the actual measurement task without closing on weight changes', () => {
it('opens the unique shared measurement dialog and refreshes without duplicating its workflow', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
expect(source).toContain("import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'")
expect(source).toContain('<WeightOcrPanel')
expect(source).toContain("import InboundMeasurementDialog from '../measurement/InboundMeasurementDialog.vue'")
expect(source).toContain('<InboundMeasurementDialog')
expect(source).toContain('v-model="measurementDialogOpen"')
expect(source).toContain(':customer-id="selectedInbound.tenantId"')
expect(source).toContain('@changed="handleWeightChanged"')
expect(source).toContain('@summary-changed="handleWeightSummaryChanged"')
expect(source).toContain('measurementPanelRef.value?.updateActualGrossWeight(actualGrossWeightKg)')
expect(source).not.toContain('<WeightOcrPanel\n :customer-id="selectedInbound.tenantId"\n :inbound-id="selectedInbound.id"\n @changed="handleTaskChanged"')
expect(source).toContain('@updated="handleMeasurementUpdated"')
expect(source).toContain('@submitted="handleMeasurementSubmitted"')
expect(source).not.toContain('<WeightOcrPanel')
expect(source).not.toContain('<InboundMeasurementPanel')
})
it('loads only active customers and exposes a searchable name-only select', () => {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ClipboardCheck, Eye, Ruler, SearchCheck } from '@lucide/vue'
import { Eye, Ruler, SearchCheck } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses } from '@/modules/warehouses'
@@ -14,11 +14,10 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listPlatformInbounds } from '../core/api'
import type { InboundOrderSummary } from '../core/types'
import InboundMeasurementPanel from '../measurement/InboundMeasurementPanel.vue'
import InboundMeasurementDialog from '../measurement/InboundMeasurementDialog.vue'
import MeasurementReviewPanel from '../review/MeasurementReviewPanel.vue'
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
import { buildInboundTaskQuery, loadAllActiveCustomers, type InboundTaskMode } from '../task-list'
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
type FilterModel = QueryModel & {
customerId: string
@@ -41,8 +40,8 @@ const customers = ref<PlatformTenantResource[]>([])
const customerLoadError = ref<ApiErrorDetails | null>(null)
const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
const selectedInbound = ref<InboundOrderSummary | null>(null)
const taskDialogOpen = ref(false)
const measurementPanelRef = ref<InstanceType<typeof InboundMeasurementPanel> | null>(null)
const measurementDialogOpen = ref(false)
const reviewDialogOpen = ref(false)
let requestSerial = 0
let optionsRequestSerial = 0
let disposed = false
@@ -168,7 +167,8 @@ function resetFilters() {
function openTask(row: InboundOrderSummary) {
selectedInbound.value = row
taskDialogOpen.value = true
if (isReview.value) reviewDialogOpen.value = true
else measurementDialogOpen.value = true
}
function openDetail(row: InboundOrderSummary) {
@@ -177,21 +177,21 @@ function openDetail(row: InboundOrderSummary) {
}
function handleTaskChanged() {
taskDialogOpen.value = false
reviewDialogOpen.value = false
selectedInbound.value = null
void loadTasks()
}
function handleWeightChanged() {
void loadTasks()
}
function handleMeasurementUpdated() {
void loadTasks()
}
function handleWeightSummaryChanged(actualGrossWeightKg: number | null) {
measurementPanelRef.value?.updateActualGrossWeight(actualGrossWeightKg)
function handleMeasurementSubmitted() {
void loadTasks()
}
function clearSelectedInbound() {
selectedInbound.value = null
}
watch([page, pageSize], () => { void loadTasks() })
@@ -280,38 +280,34 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
</ListPageLayout>
<el-dialog
v-model="taskDialogOpen"
v-if="isReview"
v-model="reviewDialogOpen"
:title="selectedInbound ? `${taskLabel} · ${selectedInbound.inboundCode || selectedInbound.id}` : taskLabel"
width="min(1180px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
append-to-body
class="inbound-task-dialog"
@closed="clearSelectedInbound"
>
<MeasurementReviewPanel
v-if="isReview && selectedInbound"
v-if="selectedInbound"
:tenant-id="selectedInbound.tenantId"
:inbound-id="selectedInbound.id"
@changed="handleTaskChanged"
/>
<div v-else-if="selectedInbound" class="measurement-task-workspace">
<section class="surface-panel inbound-task-weight" aria-label="称重图片与 OCR 核对">
<WeightOcrPanel
:customer-id="selectedInbound.tenantId"
:inbound-id="selectedInbound.id"
@changed="handleWeightChanged"
@summary-changed="handleWeightSummaryChanged"
/>
</section>
<InboundMeasurementPanel
ref="measurementPanelRef"
:tenant-id="selectedInbound.tenantId"
:inbound-id="selectedInbound.id"
@changed="handleTaskChanged"
@updated="handleMeasurementUpdated"
/>
</div>
</el-dialog>
<InboundMeasurementDialog
v-if="!isReview && selectedInbound"
v-model="measurementDialogOpen"
:customer-id="selectedInbound.tenantId"
:inbound-id="selectedInbound.id"
:inbound-code="selectedInbound.inboundCode"
@updated="handleMeasurementUpdated"
@submitted="handleMeasurementSubmitted"
@closed="clearSelectedInbound"
/>
</AdminPage>
</template>
@@ -323,12 +319,9 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
.primary-cell { display: grid; gap: 3px; min-width: 0; }
.primary-cell strong, .primary-cell span { overflow-wrap: anywhere; }
.primary-cell span, .muted-action { color: var(--cc-muted); font-size: 12px; }
.measurement-task-workspace { display: grid; min-width: 0; gap: 20px; }
.inbound-task-weight { min-width: 0; padding: 20px; }
@media (max-width: 520px) {
.row-actions { align-items: stretch; flex-direction: column; }
.row-actions :deep(.el-button) { width: 100%; }
.inbound-task-weight { padding: 14px; }
}
</style>
@@ -8,8 +8,7 @@ describe('InboundWorkflowWorkspace', () => {
it('assembles every phase-two inbound domain behind one server-backed workspace', () => {
expect(source).toContain('InboundSummaryPanel')
expect(source).toContain('InboundHistoryPanel')
expect(source).toContain('InboundMeasurementPanel')
expect(source).toContain('WeightOcrPanel')
expect(source).toContain('InboundMeasurementDialog')
expect(source).toContain('MeasurementReviewPanel')
expect(source).toContain('InboundFeePanel')
expect(source).toContain('getPlatformInbound')
@@ -23,7 +22,7 @@ describe('InboundWorkflowWorkspace', () => {
})
it('opens measurement editing and review in prototype-sized centered dialogs', () => {
expect(source).toContain(':title="`测量 · ${title}`"')
expect(source).toContain('<InboundMeasurementDialog')
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
expect(source).toContain(':title="`测量审核 · ${title}`"')
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
@@ -37,12 +36,11 @@ describe('InboundWorkflowWorkspace', () => {
it('keeps domain writes inside their existing panels and mounts them only in dialog bodies', () => {
expect(source).toContain('@click="measurementDialogOpen = true"')
expect(source).toContain('@click="reviewDialogOpen = true"')
expect(source).toContain(':key="`measurement-dialog-${revision}`"')
expect(source).toContain(':key="`review-dialog-${revision}`"')
expect(source).toContain('@changed="handleReviewChanged"')
expect(source).toContain('<template #footer>')
expect(source).toContain('measurementPanelRef?.saveDraft()')
expect(source).toContain('measurementPanelRef?.submitForReview()')
expect(source).toContain('@updated="handleDomainChanged"')
expect(source).toContain('@submitted="handleDomainChanged"')
expect(source).toContain("reviewPanelRef?.openReasonDialog('reject')")
expect(source).toContain('reviewPanelRef?.confirmApprove()')
expect(source).toContain('inbound-operation-dialog__footer')
@@ -69,7 +67,7 @@ describe('InboundWorkflowWorkspace', () => {
it('uses customer context in the page layer and maps it to compatible domain APIs', () => {
expect(source).toContain('customerId: string')
expect(source).toContain('getPlatformInbound(props.customerId, props.inboundId)')
expect(source).toContain(':tenant-id="customerId"')
expect(source).toContain(':customer-id="customerId"')
expect(source).toContain('缺少所属企业信息')
expect(source).not.toContain('请选择企业')
expect(source).not.toContain('租户')
@@ -10,8 +10,7 @@ import { getPlatformInbound } from '../core/api'
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
import type { InboundOrderDetail } from '../core/types'
import { InboundMeasurementPanel } from '../measurement'
import { WeightOcrPanel } from '../weight'
import { InboundMeasurementDialog } from '../measurement'
import { MeasurementReviewPanel } from '../review'
import { InboundFeePanel } from '../fee'
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
@@ -29,7 +28,6 @@ const access = useAccessPreviewStore()
const activeTab = ref('overview')
const measurementDialogOpen = ref(false)
const reviewDialogOpen = ref(false)
const measurementPanelRef = ref<InstanceType<typeof InboundMeasurementPanel> | null>(null)
const reviewPanelRef = ref<InstanceType<typeof MeasurementReviewPanel> | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -136,16 +134,14 @@ onBeforeUnmount(() => {
<el-tab-pane label="货品与测量" name="measurement" lazy>
<div class="workflow-action-row">
<span>核对原单数据、确认扫描箱数,并查看服务端计算的重量和体积结果。</span>
<el-button type="primary" @click="measurementDialogOpen = true"><Pencil :size="16" />编辑测量数据</el-button>
</div>
<div class="measurement-workflow">
<WeightOcrPanel
:key="`weight-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleDomainChanged"
/>
<el-button v-if="canViewMeasurement" type="primary" @click="measurementDialogOpen = true"><Pencil :size="16" />编辑测量数据</el-button>
</div>
<el-alert
:title="canViewMeasurement ? '称重图片、OCR、托盘与箱规统一在测量弹框中维护。' : '当前账号缺少测量查看权限。'"
:type="canViewMeasurement ? 'info' : 'warning'"
:closable="false"
show-icon
/>
</el-tab-pane>
<el-tab-pane label="测量审核" name="review" lazy>
<div v-if="canViewMeasurement" class="workflow-action-row">
@@ -191,32 +187,14 @@ onBeforeUnmount(() => {
</el-tab-pane>
</el-tabs>
<el-dialog
<InboundMeasurementDialog
v-model="measurementDialogOpen"
class="inbound-operation-dialog inbound-measurement-dialog"
:title="`测量 · ${title}`"
width="min(1240px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
:close-on-click-modal="false"
>
<div class="inbound-operation-dialog__body">
<InboundMeasurementPanel
ref="measurementPanelRef"
:key="`measurement-dialog-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleDomainChanged"
/>
</div>
<template #footer>
<div class="inbound-operation-dialog__footer">
<el-button @click="measurementDialogOpen = false">取消</el-button>
<el-button v-if="measurementPanelRef?.canEdit" type="primary" plain :loading="measurementPanelRef?.saving" :disabled="!measurementPanelRef?.mayMaintain" @click="measurementPanelRef?.saveDraft()">保存测量数据</el-button>
<el-button v-if="measurementPanelRef?.canSubmit" type="primary" :loading="measurementPanelRef?.submitting" :disabled="!measurementPanelRef?.maySubmit" @click="measurementPanelRef?.submitForReview()">提交审核</el-button>
</div>
</template>
</el-dialog>
:customer-id="customerId"
:inbound-id="inboundId"
:inbound-code="title"
@updated="handleDomainChanged"
@submitted="handleDomainChanged"
/>
<el-dialog
v-model="reviewDialogOpen"
@@ -263,7 +241,6 @@ onBeforeUnmount(() => {
.workflow-tabs :deep(.el-tabs__active-bar) { height: 2px; background: var(--cc-primary); }
.workflow-tabs :deep(.el-tabs__item) { min-height: 44px; padding: 0 18px; color: #475467; font-size: 13px; font-weight: 500; }
.workflow-tabs :deep(.el-tabs__item.is-active) { color: var(--cc-primary); font-weight: 650; }
.measurement-workflow { display: grid; min-width: 0; gap: 28px; }
.workflow-action-row { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 0 0 14px; border-bottom: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.inbound-operation-dialog__body { min-width: 0; }
.inbound-operation-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
@@ -272,8 +249,6 @@ onBeforeUnmount(() => {
:global(.inbound-operation-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 18px 24px; border-bottom: 1px solid var(--cc-border); }
:global(.inbound-operation-dialog .el-dialog__body) { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 20px 24px; }
:global(.inbound-operation-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 14px 24px; border-top: 1px solid var(--cc-border); }
:global(.inbound-measurement-dialog .measurement-panel) { border: 0; border-radius: 0; box-shadow: none; }
:global(.inbound-measurement-dialog .measurement-panel__heading .measurement-panel__actions), :global(.inbound-measurement-dialog .measurement-panel__footer) { display: none; }
:global(.inbound-review-dialog .review-actions .action-buttons) { display: none; }
@media (max-width: 720px) {
.workspace-header { align-items: stretch; flex-direction: column; padding: 16px; }
+2
View File
@@ -1,4 +1,6 @@
export { getInboundMeasurement } from './measurement/api'
export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
export { downloadWeightImage } from './weight/api/weightOcr'
export type { InboundMeasurementDetail } from './measurement/types'
@@ -0,0 +1,48 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/measurement/InboundMeasurementDialog.vue'), 'utf8')
const panelSource = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/measurement/InboundMeasurementPanel.vue'), 'utf8')
const taskListSource = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
const orderListSource = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'), 'utf8')
describe('InboundMeasurementDialog', () => {
it('owns the complete measurement workspace behind one centered dialog', () => {
expect(source).toContain('class="inbound-measurement-workspace-dialog"')
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
expect(source).toContain('height: 92vh')
expect(source).toContain('<WeightOcrPanel')
expect(source).toContain('<InboundMeasurementPanel')
expect(source).toContain('@summary-changed="handleWeightSummaryChanged"')
expect(source).toContain('measurementPanelRef.value?.updateActualGrossWeight(actualGrossWeightKg)')
})
it('is the only measurement dialog opened by both formal list entries', () => {
for (const entrySource of [taskListSource, orderListSource]) {
expect(entrySource).toContain('<InboundMeasurementDialog')
expect(entrySource).not.toContain('<InboundMeasurementPanel')
expect(entrySource).not.toContain('<WeightOcrPanel')
}
})
it('keeps draft saves open, closes only after submit, and refreshes parent entries consistently', () => {
expect(source).toContain('@saved="handleUpdated"')
expect(source).toContain('@updated="handleUpdated"')
expect(source).toContain('@submitted="handleSubmitted"')
expect(source).toContain("emit('updated')")
expect(source).toContain("emit('update:modelValue', false)")
expect(source).toContain("emit('submitted')")
expect(panelSource).toContain("emit('saved')")
expect(panelSource).toContain("emit('submitted')")
})
it('uses the existing permission-aware panels and protects unsaved work on close', () => {
expect(source).toContain('measurementPanelRef.value?.isDirty')
expect(source).toContain('当前测量数据尚未保存')
expect(source).toContain(':close-on-click-modal="false"')
expect(source).toContain(':before-close="beforeClose"')
expect(source).toContain('measurementPanelRef?.canEdit')
expect(source).toContain('measurementPanelRef?.canSubmit')
})
})
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessageBox } from 'element-plus'
import InboundMeasurementPanel from './InboundMeasurementPanel.vue'
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
const props = defineProps<{
modelValue: boolean
customerId: string
inboundId: string
inboundCode?: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: []
submitted: []
closed: []
}>()
const measurementPanelRef = ref<InstanceType<typeof InboundMeasurementPanel> | null>(null)
const title = computed(() => `测量 · ${props.inboundCode?.trim() || props.inboundId}`)
async function mayClose() {
if (!measurementPanelRef.value?.isDirty) return true
try {
await ElMessageBox.confirm('当前测量数据尚未保存,关闭后将丢失修改。确定关闭吗?', '关闭测量', {
type: 'warning',
confirmButtonText: '放弃修改并关闭',
cancelButtonText: '继续编辑',
})
return true
} catch {
return false
}
}
async function requestClose() {
if (await mayClose()) emit('update:modelValue', false)
}
async function beforeClose(done: () => void) {
if (await mayClose()) done()
}
function handleWeightSummaryChanged(actualGrossWeightKg: number | null) {
measurementPanelRef.value?.updateActualGrossWeight(actualGrossWeightKg)
}
function handleUpdated() {
emit('updated')
}
function handleSubmitted() {
emit('update:modelValue', false)
emit('submitted')
}
</script>
<template>
<el-dialog
:model-value="modelValue"
class="inbound-measurement-workspace-dialog"
:title="title"
width="min(1240px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
append-to-body
:close-on-click-modal="false"
:before-close="beforeClose"
@update:model-value="emit('update:modelValue', $event)"
@closed="emit('closed')"
>
<div class="measurement-dialog-workspace">
<section class="surface-panel measurement-dialog-weight" aria-label="称重图片、OCR 与托盘核对">
<WeightOcrPanel
:customer-id="customerId"
:inbound-id="inboundId"
@changed="handleUpdated"
@summary-changed="handleWeightSummaryChanged"
/>
</section>
<InboundMeasurementPanel
ref="measurementPanelRef"
:customer-id="customerId"
:inbound-id="inboundId"
@updated="handleUpdated"
@saved="handleUpdated"
@submitted="handleSubmitted"
/>
</div>
<template #footer>
<div class="measurement-dialog-footer">
<el-button :disabled="Boolean(measurementPanelRef?.saving || measurementPanelRef?.submitting)" @click="requestClose">取消</el-button>
<el-button
v-if="measurementPanelRef?.canEdit"
type="primary"
plain
:loading="measurementPanelRef?.saving"
:disabled="!measurementPanelRef?.mayMaintain"
@click="measurementPanelRef?.saveDraft()"
>保存测量数据</el-button>
<el-button
v-if="measurementPanelRef?.canSubmit"
type="primary"
:loading="measurementPanelRef?.submitting"
:disabled="!measurementPanelRef?.maySubmit"
@click="measurementPanelRef?.submitForReview()"
>提交审核</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.measurement-dialog-workspace { display: grid; min-width: 0; gap: 20px; }
.measurement-dialog-weight { min-width: 0; padding: 20px; }
.measurement-dialog-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.measurement-dialog-footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) {
.measurement-dialog-weight { padding: 14px; }
.measurement-dialog-footer { display: grid; grid-template-columns: 1fr; }
.measurement-dialog-footer :deep(.el-button) { width: 100%; min-height: 44px; }
}
</style>
<style>
.inbound-measurement-workspace-dialog.el-dialog { display: flex; max-width: calc(100vw - 32px); height: 92vh; max-height: 92vh; margin-bottom: 0; flex-direction: column; overflow: hidden; }
.inbound-measurement-workspace-dialog .el-dialog__header { flex: 0 0 auto; margin: 0; padding: 18px 24px; border-bottom: 1px solid var(--cc-border); }
.inbound-measurement-workspace-dialog .el-dialog__body { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 20px 24px; }
.inbound-measurement-workspace-dialog .el-dialog__footer { flex: 0 0 auto; padding: 14px 24px; border-top: 1px solid var(--cc-border); }
.inbound-measurement-workspace-dialog .measurement-panel { border: 0; border-radius: 0; box-shadow: none; }
.inbound-measurement-workspace-dialog .measurement-panel__heading .measurement-panel__actions,
.inbound-measurement-workspace-dialog .measurement-panel__footer { display: none; }
@media (max-width: 720px) {
.inbound-measurement-workspace-dialog.el-dialog { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); }
.inbound-measurement-workspace-dialog .el-dialog__header { padding: 16px; }
.inbound-measurement-workspace-dialog .el-dialog__body { padding: 14px; }
}
@media (max-width: 360px) {
.inbound-measurement-workspace-dialog.el-dialog { width: calc(100vw - 12px) !important; max-width: calc(100vw - 12px); }
.inbound-measurement-workspace-dialog .el-dialog__header { padding: 14px 12px; }
.inbound-measurement-workspace-dialog .el-dialog__body { padding: 10px; }
}
</style>
@@ -24,6 +24,8 @@ const props = defineProps<{
const emit = defineEmits<{
changed: []
updated: []
saved: []
submitted: []
}>()
interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest {
@@ -360,6 +362,7 @@ async function saveDraft() {
form.version = version
ElMessage.success('确认箱数已保存,计算结果已由服务端更新')
await loadMeasurement()
emit('saved')
emit('changed')
} catch (error) {
const details = setOperationError(error, '测量草稿保存失败,请检查后重试')
@@ -394,6 +397,7 @@ async function submitForReview() {
})
ElMessage.success('测量数据已提交审核')
await loadMeasurement()
emit('submitted')
emit('changed')
} catch (error) {
const details = setOperationError(error, '测量数据提交失败,请检查图片和称重结果后重试')
@@ -459,7 +463,7 @@ watch(
onBeforeUnmount(releaseImagePreview)
defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit })
defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })
</script>
<template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { ImageOff } from '@lucide/vue'
import { downloadWeightThumbnail } from '../../weight/api/weightOcr'
const props = withDefaults(defineProps<{
customerId: string
inboundId: string
fileId?: string | null
alt: string
emptyText?: string
}>(), {
fileId: null,
emptyText: '未绑定测量图片',
})
defineEmits<{ open: [] }>()
const imageUrl = ref('')
const loading = ref(false)
const failed = ref(false)
let requestId = 0
function releaseImage() {
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
imageUrl.value = ''
}
async function load() {
const currentRequest = ++requestId
releaseImage()
failed.value = false
if (!props.fileId) {
loading.value = false
return
}
loading.value = true
try {
const blob = await downloadWeightThumbnail(props.customerId, props.inboundId, props.fileId)
if (currentRequest !== requestId) return
imageUrl.value = URL.createObjectURL(blob)
} catch {
if (currentRequest === requestId) failed.value = true
} finally {
if (currentRequest === requestId) loading.value = false
}
}
watch(() => [props.customerId, props.inboundId, props.fileId], load, { immediate: true })
onBeforeUnmount(() => {
requestId += 1
releaseImage()
})
</script>
<template>
<button
class="measurement-evidence-thumb"
type="button"
:disabled="!fileId"
:aria-label="fileId ? `查看${alt}` : emptyText"
@click="$emit('open')"
>
<img v-if="imageUrl" :src="imageUrl" :alt="alt" loading="lazy" />
<span v-else class="measurement-evidence-thumb__state" :class="{ 'is-loading': loading }">
<ImageOff v-if="failed || !fileId" :size="22" aria-hidden="true" />
<span>{{ !fileId ? emptyText : failed ? '图片加载失败,点击重试' : '图片加载中' }}</span>
</span>
</button>
</template>
<style scoped>
.measurement-evidence-thumb { display: block; width: 100%; min-width: 96px; aspect-ratio: 4 / 3; overflow: hidden; padding: 0; border: 1px solid var(--cc-border); border-radius: 6px; background: #f2f4f7; cursor: zoom-in; }
.measurement-evidence-thumb:disabled { cursor: default; }
.measurement-evidence-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
.measurement-evidence-thumb__state { display: grid; width: 100%; height: 100%; place-items: center; align-content: center; gap: 7px; padding: 8px; color: #667085; font-size: 12px; }
.measurement-evidence-thumb__state.is-loading { color: #98a2b3; }
.measurement-evidence-thumb:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
</style>
+2 -1
View File
@@ -1,4 +1,5 @@
export { default as InboundMeasurementPanel } from './InboundMeasurementPanel.vue'
export { default as InboundMeasurementDialog } from './InboundMeasurementDialog.vue'
export { default as MeasurementEvidenceThumbnail } from './components/MeasurementEvidenceThumbnail.vue'
export * from './api'
export type * from './types'
@@ -1,64 +1,23 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { ImageOff } from '@lucide/vue'
import { downloadWeightThumbnail } from '../api/weightOcr'
import MeasurementEvidenceThumbnail from '../../measurement/components/MeasurementEvidenceThumbnail.vue'
const props = defineProps<{
customerId: string
inboundId: string
fileId: string
fileId: string | null
alt: string
}>()
defineEmits<{ open: [] }>()
const imageUrl = ref('')
const loading = ref(false)
const failed = ref(false)
let requestId = 0
function releaseImage() {
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
imageUrl.value = ''
}
async function load() {
const currentRequest = ++requestId
releaseImage()
failed.value = false
loading.value = true
try {
const blob = await downloadWeightThumbnail(props.customerId, props.inboundId, props.fileId)
if (currentRequest !== requestId) return
imageUrl.value = URL.createObjectURL(blob)
} catch {
if (currentRequest === requestId) failed.value = true
} finally {
if (currentRequest === requestId) loading.value = false
}
}
watch(() => [props.customerId, props.inboundId, props.fileId], load, { immediate: true })
onBeforeUnmount(() => {
requestId += 1
releaseImage()
})
</script>
<template>
<button class="weight-thumb" type="button" :aria-label="`查看${alt}`" @click="$emit('open')">
<img v-if="imageUrl" :src="imageUrl" :alt="alt" loading="lazy" />
<span v-else class="weight-thumb__state" :class="{ 'is-loading': loading }">
<ImageOff v-if="failed" :size="22" aria-hidden="true" />
<span>{{ failed ? '图片加载失败' : '图片加载中' }}</span>
</span>
</button>
<MeasurementEvidenceThumbnail
:customer-id="props.customerId"
:inbound-id="props.inboundId"
:file-id="props.fileId"
:alt="props.alt"
empty-text="未绑定称重图片"
@open="$emit('open')"
/>
</template>
<style scoped>
.weight-thumb { display: block; width: 100%; aspect-ratio: 4 / 3; overflow: hidden; padding: 0; border: 0; border-radius: 6px 6px 0 0; background: #f2f4f7; cursor: zoom-in; }
.weight-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
.weight-thumb__state { display: grid; width: 100%; height: 100%; place-items: center; align-content: center; gap: 7px; color: #667085; font-size: 12px; }
.weight-thumb__state.is-loading { color: #98a2b3; }
.weight-thumb:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
</style>
@@ -151,11 +151,14 @@ describe('UnifiedOrdersView contract', () => {
it('keeps only confirmed business actions as explicit boundaries', () => {
expect(source).toContain('nextActionBoundary')
expect(source).not.toContain('class="action-boundary"')
expect(source).toContain("can('platform:firstleg:inbound:view')")
expect(source).toContain("can('platform:firstleg:inbound:measure:view')")
expect(source).toContain('openMeasurement')
expect(source).toContain('<InboundMeasurementPanel')
expect(source).toContain('width="min(1180px, calc(100vw - 32px))"')
expect(source).toContain('<InboundMeasurementDialog')
expect(source).toContain('measurementDialogOpen.value = true')
expect(source).toContain('@updated="handleMeasurementUpdated"')
expect(source).toContain('@submitted="handleMeasurementSubmitted"')
expect(source).not.toContain('<InboundMeasurementPanel')
expect(source).not.toContain('class="inbound-task-dialog"')
expect(source).not.toContain("name: 'inbound-order-detail'")
expect(source).not.toContain('openInboundFromDetail')
expect(source).not.toContain('inbound-code-link')
@@ -50,7 +50,7 @@ import {
import {
downloadWeightImage,
getInboundMeasurement,
InboundMeasurementPanel,
InboundMeasurementDialog,
type InboundMeasurementDetail,
} from '@/modules/inbounds'
import {
@@ -644,17 +644,23 @@ async function openDetail(row: UnifiedOrderResource) {
}
function openMeasurement(row: UnifiedOrderResource) {
if (!row.inboundId || !can('platform:firstleg:inbound:view')) return
if (!row.inboundId || !can('platform:firstleg:inbound:measure:view')) return
measurementOrder.value = row
measurementDialogOpen.value = true
}
function handleMeasurementChanged() {
measurementDialogOpen.value = false
measurementOrder.value = null
function handleMeasurementUpdated() {
void loadOrders()
}
function handleMeasurementSubmitted() {
void loadOrders()
}
function clearMeasurementOrder() {
measurementOrder.value = null
}
type UnifiedOrderRowAction = 'note' | 'approve' | 'measurement'
const unifiedOrderRowActionIcons: Record<UnifiedOrderRowAction, Component> = {
note: StickyNote,
@@ -665,7 +671,7 @@ function unifiedOrderSecondaryActions(row: UnifiedOrderResource): UnifiedOrderRo
return [
...(can('platform:order:note') ? ['note' as const] : []),
...(canApprove(row) ? ['approve' as const] : []),
...(row.mainStatus === 'WAIT_MEASURE' && row.inboundId && can('platform:firstleg:inbound:view') ? ['measurement' as const] : []),
...(row.mainStatus === 'WAIT_MEASURE' && row.inboundId && can('platform:firstleg:inbound:measure:view') ? ['measurement' as const] : []),
]
}
function directUnifiedOrderActions(row: UnifiedOrderResource) {
@@ -1048,23 +1054,16 @@ onMounted(() => {
<el-alert title="确认订单资料无误后审核通过,订单将进入待测量阶段。" type="info" :closable="false" show-icon />
</AppDialog>
<el-dialog
<InboundMeasurementDialog
v-if="measurementOrder?.inboundId"
v-model="measurementDialogOpen"
:title="measurementOrder?.inboundCode ? `测量 · ${measurementOrder.inboundCode}` : '测量'"
width="min(1180px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
append-to-body
class="inbound-task-dialog"
@closed="measurementOrder = null"
>
<InboundMeasurementPanel
v-if="measurementOrder?.inboundId"
:tenant-id="String(measurementOrder.tenantId)"
:inbound-id="String(measurementOrder.inboundId)"
@changed="handleMeasurementChanged"
/>
</el-dialog>
:customer-id="String(measurementOrder.tenantId)"
:inbound-id="String(measurementOrder.inboundId)"
:inbound-code="measurementOrder.inboundCode"
@updated="handleMeasurementUpdated"
@submitted="handleMeasurementSubmitted"
@closed="clearMeasurementOrder"
/>
</section>
</AdminPage>