This commit is contained in:
2026-08-14 14:27:03 +08:00
parent 724848feb6
commit a81af4a664
64 changed files with 961 additions and 176 deletions
+47 -2
View File
@@ -2,7 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, ref } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import AppLayout from './AppLayout.vue'
@@ -44,8 +44,53 @@ async function mountLayout(path = '/') {
return { wrapper, router }
}
function mockViewport(isMobile: boolean) {
const mediaQuery = {
matches: isMobile,
media: '(max-width: 720px)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
} as unknown as MediaQueryList
vi.stubGlobal('matchMedia', vi.fn(() => mediaQuery))
}
describe('AppLayout', () => {
beforeEach(() => { vi.mocked(ElMessageBox.confirm).mockReset() })
beforeEach(() => {
vi.mocked(ElMessageBox.confirm).mockReset()
mockViewport(false)
})
afterEach(() => vi.unstubAllGlobals())
it('reports the desktop navigation expanded and collapsed states', async () => {
const { wrapper } = await mountLayout()
const menuButton = wrapper.get('.shell-menu-button')
expect(menuButton.attributes('aria-label')).toBe('收起导航')
expect(menuButton.attributes('aria-expanded')).toBe('true')
await menuButton.trigger('click')
expect(menuButton.attributes('aria-label')).toBe('展开导航')
expect(menuButton.attributes('aria-expanded')).toBe('false')
})
it('reports the mobile menu closed and open states', async () => {
mockViewport(true)
const { wrapper } = await mountLayout()
const menuButton = wrapper.get('.shell-menu-button')
expect(menuButton.attributes('aria-label')).toBe('打开菜单')
expect(menuButton.attributes('aria-expanded')).toBe('false')
await menuButton.trigger('click')
expect(menuButton.attributes('aria-label')).toBe('关闭菜单')
expect(menuButton.attributes('aria-expanded')).toBe('true')
})
it('shows the authenticated identity without a role preview switcher', async () => {
const { wrapper } = await mountLayout()
+26 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Bell, FileText, LogOut, Mail, Menu } from '@lucide/vue'
@@ -20,12 +20,22 @@ const auth = useAuthStore()
const access = useAccessPreviewStore()
const unsaved = useUnsavedWorkStore()
const workspace = useWorkspaceTabsStore()
const mobileViewportQuery = '(max-width: 720px)'
const collapsed = ref(false)
const mobileMenuOpen = ref(false)
const isMobileViewport = ref(false)
let mobileViewportMedia: MediaQueryList | undefined
const pageTitle = computed(() => route.meta.title || '管理后台')
const pageDescription = computed(() => route.meta.description || '')
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
const menuButtonLabel = computed(() => {
if (isMobileViewport.value) return mobileMenuOpen.value ? '关闭菜单' : '打开菜单'
return collapsed.value ? '展开导航' : '收起导航'
})
const menuExpanded = computed(() => (
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
))
const canOpenNotificationManagement = computed(() => (
access.can('platform:notification:task:list')
|| access.can('platform:notification:template:list')
@@ -89,15 +99,27 @@ async function handleLogout() {
}
function handleMenuToggle() {
if (window.matchMedia('(max-width: 720px)').matches) {
if (isMobileViewport.value) {
mobileMenuOpen.value = !mobileMenuOpen.value
return
}
collapsed.value = !collapsed.value
}
function syncMobileViewport(event: MediaQueryList | MediaQueryListEvent) {
isMobileViewport.value = event.matches
if (!event.matches) mobileMenuOpen.value = false
}
function openNotificationPage(path: string) { void router.push(path) }
onMounted(() => {
mobileViewportMedia = window.matchMedia(mobileViewportQuery)
syncMobileViewport(mobileViewportMedia)
mobileViewportMedia.addEventListener('change', syncMobileViewport)
})
onBeforeUnmount(() => mobileViewportMedia?.removeEventListener('change', syncMobileViewport))
watch(() => route.fullPath, () => {
mobileMenuOpen.value = false
syncCurrentRoute()
@@ -129,8 +151,8 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<button
class="shell-menu-button"
type="button"
:aria-label="mobileMenuOpen ? '关闭菜单' : collapsed ? '展开导航' : '收起导航'"
:aria-expanded="mobileMenuOpen || !collapsed"
:aria-label="menuButtonLabel"
:aria-expanded="menuExpanded"
@click="handleMenuToggle"
><Menu :size="22" /></button>
<div v-if="showPageHeading" class="page-heading"><h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p></div>
@@ -17,6 +17,21 @@ describe('platform audit api', () => {
expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs', { params: { accountId: '8', page: 1, size: 20 } })
expect(result.records[0].id).toBe('2082813911786024961')
expect(result.total).toBe(1)
expect(result.pages).toBe(1)
})
it('preserves the server total when the current page has fewer records', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
records: [{ id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }],
page: 1, size: 20, total: 13, pages: 1,
} })
const result = await listPlatformAuditLogs({ accountId: '8', page: 1, size: 20 })
expect(result.records).toHaveLength(1)
expect(result.total).toBe(13)
expect(result.pages).toBe(1)
})
it('maps audit filters and omits empty query values', async () => {
@@ -28,6 +28,13 @@ describe('CustomerAuditLogsPanel', () => {
expect(wrapper.text()).toContain('编辑客户')
})
it('shows the server total instead of the current page row count', async () => {
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 13, pages: 1 })
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
expect(wrapper.text()).toContain('共 13 条客户操作记录')
})
it('applies keyword, result and local date range only after search', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
vi.mocked(listPlatformAuditLogs).mockClear()
@@ -7,7 +7,7 @@ import type { ContainerOverview } from '../types'
const overview: ContainerOverview = {
container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', version: 2 },
loadingPlanId: '9',
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', taskVersion: 2, itemCount: 4, completedItemCount: 3, unresolvedItemCount: 1, photoCounts: [{ evidenceType: 'EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/files/loading-evidence/21/22/23', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }],
exception: { active: true, reason: '码头查验', markedBy: '13', markedByName: '操作员', markedAt: '2026-08-13T11:00:00' },
exceptionHistory: [{ id: '14', action: 'MARK', reason: '码头查验', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T11:00:00' }],
@@ -20,6 +20,9 @@ describe('ContainerOverviewPanel', () => {
expect(wrapper.text()).toContain('DO 资料')
expect(wrapper.text()).toContain('do.pdf')
expect(wrapper.text()).toContain('PDA 作业进度')
expect(wrapper.text()).toContain('待人工复核')
expect(wrapper.text()).toContain('空柜照片')
expect(wrapper.text()).not.toContain('PHOTO_STEP_EMPTY_CONTAINER')
expect(wrapper.text()).toContain('一项待复核')
expect(wrapper.text()).toContain('码头查验')
expect(wrapper.text()).toContain('更新预计到港时间')
@@ -36,7 +39,7 @@ describe('ContainerOverviewPanel', () => {
it('emits the backend-provided photo object from an accessible view control', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await wrapper.find('button[aria-label="查看装柜照片 empty.jpg"]').trigger('click')
expect(wrapper.emitted('previewPhoto')?.[0]?.[0]).toMatchObject({ evidenceId: '20', downloadPath: '/api/platform/v1/files/loading-evidence/21/22/23' })
expect(wrapper.emitted('previewPhoto')?.[0]?.[0]).toMatchObject({ evidenceId: '20', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download' })
})
it('renders empty and forbidden/load failure states without exposing credentials', () => {
@@ -38,6 +38,15 @@ const pdaProgress = computed(() => {
return Math.min(100, Math.round((pda.completedItemCount / pda.itemCount) * 100))
})
const activePhotos = computed(() => (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath))
const workflowStatusLabels: Record<string, string> = {
LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成',
}
const evidenceTypeLabels: Record<string, string> = {
PHOTO_STEP_EMPTY_CONTAINER: '空柜照片', PHOTO_STEP_HALF_LOADED: '半柜照片', PHOTO_STEP_FULL_CONTAINER: '满柜照片',
EMPTY_CONTAINER: '空柜照片', HALF_LOADED: '半柜照片', FULL_CONTAINER: '满柜照片', SEAL: '封条照片',
}
function workflowStatusLabel(workflowStatus: string | null | undefined, taskStatus: string) { return workflowStatusLabels[workflowStatus || taskStatus] || '作业状态待同步' }
function evidenceTypeLabel(value: string) { return evidenceTypeLabels[value] || '装柜照片' }
function formatTime(value: string | null | undefined) {
return value?.replace('T', ' ').slice(0, 16) || '—'
@@ -69,9 +78,9 @@ function eventLabel(value: string) {
</section>
<section class="overview-section" aria-label="PDA作业摘要">
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="overview.pda.taskStatus" /></header>
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
<template v-else><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div><div v-if="overview.pda.photoCounts.length" class="photo-summary"><span v-for="item in overview.pda.photoCounts" :key="item.evidenceType">{{ item.evidenceType }}:{{ item.count }}</span></div><div v-if="activePhotos.length" class="photo-list"><article v-for="photo in activePhotos" :key="photo.evidenceId"><div><strong>{{ photo.evidenceType }}</strong><span>{{ photo.fileName }}</span><small>{{ formatTime(photo.uploadedAt) }}</small></div><el-button size="small" :loading="photoBusyId === photo.evidenceId" :aria-label="`查看装柜照片 ${photo.fileName}`" @click="emit('previewPhoto', photo)"><Eye :size="15" />查看</el-button></article></div><el-empty v-else description="暂无可查看的有效照片" :image-size="48" /><LoadErrorState v-if="photoError" compact title="照片加载失败" :message="photoError.message" :show-retry="false"><template #details><ApiErrorMeta :details="photoError" /></template></LoadErrorState><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
<template v-else><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div><div v-if="overview.pda.photoCounts.length" class="photo-summary"><span v-for="item in overview.pda.photoCounts" :key="item.evidenceType">{{ evidenceTypeLabel(item.evidenceType) }}:{{ item.count }}</span></div><div v-if="activePhotos.length" class="photo-list"><article v-for="photo in activePhotos" :key="photo.evidenceId"><div><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><span>{{ photo.fileName }}</span><small>{{ formatTime(photo.uploadedAt) }}</small></div><el-button size="small" :loading="photoBusyId === photo.evidenceId" :aria-label="`查看装柜照片 ${photo.fileName}`" @click="emit('previewPhoto', photo)"><Eye :size="15" />查看</el-button></article></div><el-empty v-else description="暂无可查看的有效照片" :image-size="48" /><LoadErrorState v-if="photoError" compact title="照片加载失败" :message="photoError.message" :show-retry="false"><template #details><ApiErrorMeta :details="photoError" /></template></LoadErrorState><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
</section>
<section class="overview-section" aria-label="运输轨迹和操作日志">
@@ -132,7 +132,7 @@ describe('ContainersView', () => {
it('uses active customer names and sends the stable customerId filter', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("customer.legalName?.trim() || customer.tenantName")
expect(source).toContain("label: customer.tenantName")
expect(source).toContain('customerId: applied.value.customerId || undefined')
expect(source).not.toContain('customer.tenantCode')
})
@@ -208,7 +208,7 @@ describe('ContainersView', () => {
it('renders container business actions as buttons without changing the transport gate', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel')
expect(source).toContain("row.planStatus === 'LOADING' ? '录入集装箱号' : '编辑'")
expect(source).toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
expect(source).toContain("row.planStatus === 'EDITING' && canSmartLoading")
expect(source).not.toContain('canSmartLoading && row.containerNo')
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
@@ -135,7 +135,7 @@ const shippingCompanyValue = computed(() => form.shippingCompanyId || (form.ship
const destinationWarehouseValue = computed(() => form.destinationWarehouseCode || (form.destinationWarehouseName ? `name:${form.destinationWarehouseName}` : ''))
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 220, placeholder: '全部客户', options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.legalName?.trim() || customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'customerId', type: 'select', label: '客户', width: 220, placeholder: '全部客户', options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、柜号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
{ key: 'containerType', type: 'select', label: '柜型', width: 140, options: ['20GP', '40GP', '40HQ', '45HQ'].map((value) => ({ label: value, value })), props: { 'aria-label': '柜型' } },
{ key: 'status', type: 'select', label: '排柜状态', width: 150, options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
@@ -458,7 +458,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
</template>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? '录入集装箱号' : '编辑' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>{{ row.bookingNo }}</small></template>
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
@@ -79,7 +79,7 @@ describe('GlobalSmartLoadingView', () => {
it('filters candidates by an active name-only customer selector and accountId', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("customer.legalName?.trim() || customer.tenantName")
expect(source).toContain("label: customer.tenantName")
expect(source).toContain('item.accountId !== customerId')
expect(source).not.toContain('customer.tenantCode')
})
@@ -73,7 +73,7 @@ const displayedVolumePercent = computed(() => percentage(volumePercent.value))
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.legalName?.trim() || customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'orderKeyword', type: 'input', label: '订单或货物', placeholder: '订单号、入库单、货物', width: 250 },
{ key: 'containerKeyword', type: 'input', label: '柜次', placeholder: '柜次号、集装箱号、目的仓', width: 230 },
{ key: 'status', type: 'select', label: '柜次状态', width: 160, options: [
+1
View File
@@ -72,6 +72,7 @@ export interface ContainerPdaSummary {
loadingPlanId: string
containerBookingId: string
taskStatus: string
workflowStatus?: string | null
taskVersion: number
itemCount: number
completedItemCount: number
@@ -41,7 +41,7 @@ describe('CustomsCasesView', () => {
it('uses an active name-only customer selector backed by customerId', () => {
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("customer.status === 'ACTIVE'")
expect(source).toContain("customer.legalName?.trim() || customer.tenantName")
expect(source).toContain("label: customer.tenantName")
expect(source).toContain("customerId: String(filters.value.customerId || '') || undefined")
expect(source).not.toContain('customer.tenantCode')
})
@@ -20,7 +20,7 @@ import type { CustomsCaseDetail, CustomsCaseResource } from '../types'
const access = useAccessPreviewStore(); const rows = ref<CustomsCaseResource[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false)
const filters = ref<QueryModel>({ customerId: '', status: '' }); const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.legalName?.trim() || customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'status', type: 'select', label: '状态', options: customsCaseStatusOptions.map((option) => ({ ...option })) },
])
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { key: 'orderLink', label: '订单关联', minWidth: 120, formatter: (row: CustomsCaseResource) => associationLabel(row.orderId) }, { key: 'inboundLink', label: '入库单关联', minWidth: 120, formatter: (row: CustomsCaseResource) => associationLabel(row.inboundId) }, { prop: 'checklistCode', label: '资料清单', minWidth: 125 }, { prop: 'checklistVersionNo', label: '版本', width: 90 }, { prop: 'status', label: '状态', width: 110, slotName: 'status' }, { prop: 'updatedAt', label: '更新时间', minWidth: 160 }]
@@ -332,6 +332,7 @@ onMounted(() => {
class="surface-panel"
ariaLabel="费用项目筛选"
:fields="queryFields"
:loading="loading"
preference-key="fee-items-query"
@search="applyFilters"
/>
@@ -7,6 +7,7 @@ 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 { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listFinanceAccountLedger, listFinanceAccounts } from '../api/finance'
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
@@ -16,6 +17,7 @@ vi.mock('../api/finance', () => ({
getFinanceCustomerTerms: vi.fn(),
updateFinanceCustomerTerms: vi.fn(),
}))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
@@ -62,7 +64,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
vi.stubGlobal('localStorage', memoryStorage())
vi.stubGlobal('sessionStorage', memoryStorage())
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: [], total: 0, 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 })
})
it('restores account summary and ledger after opening the detail URL directly', async () => {
@@ -82,6 +85,25 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
wrapper.unmount()
})
it('formats known ledger codes, operator ids and timestamps for display', async () => {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['platform:finance:customer-account:list']
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 },
] })
await router.push('/finance/customer-accounts/10001'); await router.isReady()
const wrapper = mount(FinanceCustomerAccountsView, { global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('收款')
expect(wrapper.text()).toContain('操作人信息缺失')
expect(wrapper.text()).not.toContain('operator-1000001')
expect(wrapper.text()).not.toContain('1000001')
expect(wrapper.text()).not.toContain('2026-08-13T10:00:00')
wrapper.unmount()
})
})
afterAll(() => vi.unstubAllGlobals())
@@ -55,7 +55,7 @@ const queryFields = computed<QueryField[]>(() => [
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'customerName', label: '客户', minWidth: 180 },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'customerCode', label: '客户编号', width: 150 },
{ key: 'contactName', label: '联系人', width: 120 },
{ key: 'contactPhone', label: '联系电话', width: 140 },
@@ -73,9 +73,9 @@ const ledgerQueryFields: QueryField[] = [
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
]
const ledgerColumns: DataTableColumn[] = [
{ key: 'ledgerId', label: '流水号', minWidth: 160 }, { key: 'occurredAt', label: '发生时间', width: 170 }, { key: 'businessType', label: '业务类型', width: 130, slotName: 'businessType' },
{ key: 'ledgerId', label: '流水号', minWidth: 160 }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 130, slotName: 'businessType' },
{ key: 'documentNo', label: '关联单据', minWidth: 170 }, { key: 'receivableChange', label: '应收变动(CNY)', width: 145 }, { key: 'payableChange', label: '应付变动(CNY)', width: 145 },
{ key: 'outstandingReceivableAfter', label: '变动后未收(CNY)', width: 160 }, { key: 'operatorName', label: '操作人', width: 120 }, { key: 'remark', label: '备注', minWidth: 180 },
{ key: 'outstandingReceivableAfter', label: '变动后未收(CNY)', width: 160 }, { key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
]
const summary = computed(() => rows.value.reduce((result, row) => ({
receivable: result.receivable + row.outstandingReceivable,
@@ -147,14 +147,36 @@ async function saveTerms() {
finally { saving.value = false }
}
function money(value: unknown) { return Number(value || 0).toFixed(2) }
function customerName(customerId: string, snapshot?: string) {
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
const candidate = String(snapshot || '').trim()
const placeholder = [String(customerId), `客户 ${customerId}`, `客户${customerId}`].includes(candidate)
return masterName || (candidate && !placeholder ? candidate : '客户名称暂不可用')
}
function ledgerBusinessType(value: unknown) {
const code = String(value || '').trim().toUpperCase()
const known: Record<string, string> = { REC: '收款', RECEIPT: '收款', PAYMENT: '付款', COST: '成本', BILL: '对账单', RECONCILIATION: '对账单', SETTLEMENT: '结算', FEE_SOURCE: '费用来源', ADJUSTMENT: '账务调整' }
return known[code] || financeBusinessTypeLabel(code)
}
function displayOperator(value: unknown) {
const operator = String(value || '').trim()
if (!operator) return '系统'
return /^(?:operator-)?\d+$/i.test(operator) ? '操作人信息缺失' : operator
}
function formatDateTime(value: unknown) {
const raw = String(value || '').trim()
if (!raw) return '时间暂不可用'
const date = new Date(raw)
return Number.isNaN(date.getTime()) ? '时间暂不可用' : date.toLocaleString('zh-CN', { hour12: false })
}
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)
}
function exportAccounts() { downloadCsv('客户账户.csv', columns.slice(1).map((item) => String(item.label)), rows.value.map((row) => [row.customerName, row.customerCode, row.contactName, row.contactPhone, row.outstandingReceivable, row.pendingBilling, row.receivedInPeriod, row.outstandingPayable, row.status, row.lastChangedAt])) }
function exportLedger() { downloadCsv(`${selected.value?.customerName || '客户'}-账户流水.csv`, ledgerColumns.map((item) => String(item.label)), ledger.value.map((row) => [row.ledgerId, row.occurredAt, financeBusinessTypeLabel(row.businessType), row.documentNo, row.receivableChange, row.payableChange, row.outstandingReceivableAfter, row.operatorName, row.remark])) }
function exportAccounts() { downloadCsv('客户账户.csv', columns.slice(1).map((item) => String(item.label)), rows.value.map((row) => [customerName(row.customerId, row.customerName), row.customerCode, row.contactName, row.contactPhone, 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' })
@@ -174,7 +196,7 @@ async function restoreAccountDetail(customerId: string) {
}
onMounted(() => {
const customerId = String(route.params.customerId || '')
if (route.name === 'finance-customer-account-detail' && customerId) void restoreAccountDetail(customerId)
if (route.name === 'finance-customer-account-detail' && customerId) { if (canListCustomers.value) void loadCustomers(); void restoreAccountDetail(customerId) }
else { if (canListCustomers.value) void loadCustomers(); void load() }
})
watch(() => [route.name, String(route.params.customerId || '')], ([name, customerId], previous) => {
@@ -194,17 +216,18 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
<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 #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 #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
<template #pagination-left><span>汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 应付 {{ money(summary.payable) }} CNY</span></template>
</DataTable>
</section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-account-detail" spacing="normal">
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><div><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ selected.customerName }} · {{ selected.customerCode }}</p></div></div>
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><div><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · {{ selected.customerCode }}</p></div></div>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
<FormSection v-if="!editing" title="账户流水" description="展示应收、应付及每次变动后的未收金额。"><QueryToolbar v-model="ledgerFilters" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" /><div class="detail-toolbar"><span>共 {{ ledgerTotal }} 条</span><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></div><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #businessType="{ row }">{{ financeBusinessTypeLabel(row.businessType) }}</template></DataTable></FormSection>
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ customerName(selected.customerId, selected.customerName) }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
<FormSection v-if="!editing" title="账户流水" description="展示应收、应付及每次变动后的未收金额。"><QueryToolbar v-model="ledgerFilters" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" /><div class="detail-toolbar"><span>共 {{ ledgerTotal }} 条</span><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></div><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></FormSection>
<FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></FormSection>
</template>
<div v-if="editing" class="detail-footer"><el-button @click="closeDetail">关闭</el-button><el-button v-if="canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div>
@@ -50,7 +50,10 @@ async function mountView(permissions: string[], initialPath = '/finance/customer
{ path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView },
] })
await router.push(initialPath); await router.isReady()
const wrapper = mount(FinanceCustomerArapView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
const wrapper = mount(FinanceCustomerArapView, {
attachTo: document.body,
global: { plugins: [pinia, ElementPlus, router], stubs: { LayoutPreferenceList: true } },
})
await flushPromises()
return Object.assign(wrapper, { router })
}
@@ -164,6 +167,23 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(wrapper.router.currentRoute.value.name).toBe('finance-customer-arap-detail')
})
it('resolves blank detail customer names from master data and preserves unknown source codes', async () => {
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, customerName: '', sourceType: 'NEW_SOURCE' })
const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42')
expect(wrapper.text()).toContain('运费 · 客户A')
expect(wrapper.text()).toContain('未识别来源(NEW_SOURCE)')
expect(wrapper.text()).not.toContain('其他来源')
expect(wrapper.text()).not.toContain('42 ·')
})
it.each(['10001', '客户 10001', '客户10001'])('never displays customer id placeholder %s as a customer name', async (customerName) => {
vi.mocked(listPlatformTenants).mockResolvedValueOnce({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, customerName })
const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42')
expect(wrapper.text()).toContain('客户名称暂不可用')
expect(wrapper.text()).not.toContain(customerName)
})
it('creates a reconciliation draft from selected eligible receivables', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:reconciliation:create'])
await loadRows(wrapper)
@@ -20,6 +20,7 @@ import type { FinanceFeeSourceDetail } from '../types'
interface ArapRow {
id: string
feeSourceId: string | null
customerId: string
customerName: string
direction: 'RECEIVABLE' | 'PAYABLE'
documentNo: string
@@ -88,10 +89,32 @@ const queryFields = computed<QueryField[]>(() => [
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: (row: ArapRow) => row.direction === 'RECEIVABLE' && Boolean(row.feeSourceId) }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180 }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120 },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120 },
{ key: 'sourceId', label: '来源追溯', minWidth: 200 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 },
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'settlementIncluded', label: '参与结算', width: 110, slotName: 'included' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 130 },
]
function customerName(customerId: string, snapshot?: string) {
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
const candidate = String(snapshot || '').trim()
const placeholder = [String(customerId), `客户 ${customerId}`, `客户${customerId}`].includes(candidate)
return masterName || (candidate && !placeholder ? candidate : '客户名称暂不可用')
}
function sourceTypeDisplay(value: unknown) {
const code = String(value || '').trim().toUpperCase()
const label = financeSourceTypeLabel(code)
return label === '其他来源' ? (code ? `未识别来源(${code})` : '来源类型暂不可用') : label
}
function formatDateTime(value: unknown) {
const raw = String(value || '').trim()
if (!raw) return '时间暂不可用'
const date = new Date(raw)
return Number.isNaN(date.getTime()) ? '时间暂不可用' : date.toLocaleString('zh-CN', { hour12: false })
}
function displayOperator(value: unknown) {
const operator = String(value || '').trim()
if (!operator) return '系统'
return /^(?:operator-)?\d+$/i.test(operator) ? '操作人信息缺失' : operator
}
const filteredRows = computed(() => {
const keyword = String(filters.value.keyword || '').trim().toLowerCase(); const direction = String(filters.value.direction || ''); const reviewStatus = String(filters.value.reviewStatus || '')
const included = String(filters.value.settlementIncluded || ''); const generated = String(filters.value.statementGenerated || ''); const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
@@ -114,7 +137,7 @@ async function load(model: QueryModel = filters.value) {
try {
const direction = String(filters.value.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
const items = await listFinanceFeePool(customerId || undefined, direction || undefined)
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerName: item.customerName || customers.value.find((customer) => String(customer.id) === item.customerId)?.tenantName || `客户 ${item.customerId}`, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt, businessDate: item.createdAt, reviewStatus: item.status, settlementIncluded: null, statementGenerated: null }))
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt, businessDate: item.createdAt, reviewStatus: item.status, settlementIncluded: null, statementGenerated: null }))
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
finally { loading.value = false }
}
@@ -181,14 +204,14 @@ async function restoreFeeSourceDetail(feeSourceId: string) {
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
try {
detail.value = await getFinanceFeeSource(feeSourceId)
selected.value = { id: detail.value.poolItemId, feeSourceId, customerName: detail.value.customerName, direction: detail.value.direction, documentNo: detail.value.poolItemId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', feeName: detail.value.feeItemName, amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, settlementIncluded: detail.value.settlementIncluded, statementGenerated: detail.value.statementGenerated }
selected.value = { id: detail.value.poolItemId, feeSourceId, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: detail.value.feeItemName || detail.value.sourceId || detail.value.poolItemId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', feeName: detail.value.feeItemName, amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, settlementIncluded: detail.value.settlementIncluded, statementGenerated: detail.value.statementGenerated }
} catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试') }
finally { detailLoading.value = false }
}
onMounted(async () => {
const feeSourceId = String(route.params.feeSourceId || '')
if (route.name === 'finance-customer-arap-detail' && feeSourceId) { await restoreFeeSourceDetail(feeSourceId); return }
if (route.name === 'finance-customer-arap-detail' && feeSourceId) { if (canListCustomers.value) await loadCustomers(); await restoreFeeSourceDetail(feeSourceId); return }
if (canListCustomers.value) await loadCustomers()
await load()
})
@@ -209,7 +232,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<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 :disabled="!filters.customerId" @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 #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 #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></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 #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
<template #included="{ row }">{{ row.settlementIncluded == null ? '待加载' : row.settlementIncluded ? '参与' : '不参与' }}</template>
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
@@ -217,11 +240,11 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
</DataTable></section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-arap-detail" spacing="normal">
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div><h2>费用来源详情</h2><p>{{ selected?.documentNo }} · {{ selected?.customerName }}</p></div><div class="detail-actions"><el-button v-if="canReviewSource && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button><el-button v-if="canSettleSource && selected" @click="openInclusion(selected as unknown as Record<string, unknown>)"><Link2 :size="16" />参与结算</el-button></div></div>
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canReviewSource && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button><el-button v-if="canSettleSource && selected" @click="openInclusion(selected as unknown as Record<string, unknown>)"><Link2 :size="16" />参与结算</el-button></div></div>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail">
<el-descriptions :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName || selected?.customerName }}</el-descriptions-item><el-descriptions-item label="来源状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ financeSourceTypeLabel(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="来源版本">v{{ detail.sourceVersion }}</el-descriptions-item><el-descriptions-item label="入库单">{{ detail.inboundCode || detail.inboundId || '-' }}</el-descriptions-item><el-descriptions-item label="柜次">{{ detail.containerNo || detail.containerId || '-' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="log.occurredAt">{{ financeOperationActionLabel(log.action) }} · {{ log.operatorName }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
<el-descriptions :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="来源状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="来源版本">v{{ detail.sourceVersion }}</el-descriptions-item><el-descriptions-item label="入库单">{{ detail.inboundCode || detail.inboundId || '-' }}</el-descriptions-item><el-descriptions-item label="柜次">{{ detail.containerNo || detail.containerId || '-' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
</template>
</AdminPage>
<AppDialog v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
@@ -78,10 +78,45 @@ describe('FinanceCustomerStatementsView', () => {
expect(document.body.textContent).toContain('hash-81')
expect(document.body.textContent).toContain('优惠调整')
expect(document.body.textContent).toContain('创建人A')
expect(document.body.textContent).toContain('2026-08-20 11:00:00')
expect(document.body.textContent).toContain('2026-08-20 11:00')
expect(document.querySelector('.el-drawer')).toBeNull()
})
it('resolves customer names from the active customer list and never exposes internal customer or operator ids', async () => {
const internalCustomerId = '2088123456789'
const internalOperatorId = '1000001'
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: internalCustomerId, tenantName: '虫虫跨境科技', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
const internalStatement = {
...statement, customerId: internalCustomerId, customerName: `客户${internalCustomerId}`,
createdBy: internalOperatorId, createdAt: '2026-08-14T03:27:34Z', updatedBy: internalOperatorId, updatedAt: '2026-08-14T04:28:35Z',
}
const internalDetail = {
...detail, ...internalStatement,
paymentHistory: [{ ...detail.paymentHistory[0]!, createdBy: internalOperatorId, occurredAt: '2026-08-14T04:28:35Z' }],
}
vi.mocked(listFinanceStatements).mockResolvedValue([internalStatement])
vi.mocked(getReconciliationStatement).mockResolvedValue(internalDetail)
const wrapper = await mountPage(['platform:finance:customer-statement:list', 'platform:customer:list', 'platform:finance:reconciliation:adjust'])
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(internalStatement)
await flushPromises()
const text = document.body.textContent || ''
expect(text).toContain('虫虫跨境科技')
expect(document.querySelector('[aria-label="客户对账单列表"]')?.textContent).toContain('虫虫跨境科技')
expect(document.querySelector('[aria-label="所选对账单联动明细"]')?.textContent).toContain('虫虫跨境科技')
expect(text).not.toContain(`客户${internalCustomerId}`)
expect(text).not.toContain(internalOperatorId)
expect(text).toContain('操作人信息缺失')
expect(text).toContain('2026-08-14 03:27')
expect(text).not.toContain('2026-08-14T03:27:34Z')
await (wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise<void> }).openAdjustment(internalStatement)
await flushPromises()
expect(document.body.textContent).toContain('调整客户对账单')
expect(document.body.textContent).toContain('运费')
})
it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => {
const wrapper = await mountPage()
const vm = wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void>; filters: Record<string, unknown>; filteredRows: FinanceStatementReadModel[] }
@@ -98,7 +98,7 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '审核状态', width: 120, slotName: 'status' },
{ key: 'billNo', label: '对账单号', minWidth: 180 },
{ key: 'customerName', label: '客户', minWidth: 180 },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'periodStart', label: '结算起始日', width: 130 },
{ key: 'periodEnd', label: '结算结束日', width: 130 },
{ key: 'creditDays', label: '账期(天)', width: 100 },
@@ -106,15 +106,39 @@ const columns: DataTableColumn[] = [
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 },
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 },
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 },
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170 },
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' },
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
{ key: 'remark', label: '备注', minWidth: 180 },
{ key: 'createdBy', label: '创建人', width: 110 },
{ key: 'createdAt', label: '创建时间', minWidth: 170 },
{ key: 'updatedBy', label: '更新人', width: 110 },
{ key: 'updatedAt', label: '更新时间', minWidth: 170 },
{ key: 'createdBy', label: '创建人', width: 130, slotName: 'createdBy' },
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
{ key: 'updatedBy', label: '更新人', width: 130, slotName: 'updatedBy' },
{ key: 'updatedAt', label: '更新时间', minWidth: 170, slotName: 'updatedAt' },
]
const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName.trim()])))
function isInternalCustomerLabel(value: string, customerId: string) {
const normalized = value.replaceAll(' ', '')
return normalized === customerId || /^客户(?:ID)?\d+$/.test(normalized)
}
function customerDisplayName(customerId: string, value?: string) {
const resolved = customerNameById.value.get(String(customerId))
if (resolved) return resolved
const candidate = String(value || '').trim()
return candidate && !isInternalCustomerLabel(candidate, String(customerId)) ? candidate : '客户名称未提供'
}
function operatorDisplayName(value?: string) {
const candidate = String(value || '').trim()
if (!candidate) return '系统'
return /^\d+$/.test(candidate) ? '操作人信息缺失' : candidate
}
function formatDateTime(value?: string) {
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—'
}
function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconciliationDetail): StatementRow {
const latestPayment = current?.paymentHistory.map((item) => item.occurredAt || item.transactionDate).filter(Boolean).sort().at(-1) || ''
return {
@@ -277,7 +301,7 @@ async function exportStatements() {
return
}
const header = ['审核状态', '对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额', '已收金额', '未收金额', '最后收款时间', '是否结清', '备注', '创建人', '创建时间', '更新人', '更新时间']
const body = filteredRows.value.map((row) => [financeStatusLabel(row.status), row.billNo, row.customerName, row.periodStart, row.periodEnd, row.creditDays ?? '', row.currency, row.totalAmount, row.receivedAmount, row.outstandingAmount, row.lastPaymentAt, row.settled === null ? '' : (row.settled ? '是' : '否'), row.remark, row.createdBy || '', row.createdAt || '', row.updatedBy || '', row.updatedAt || ''])
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.createdBy), formatDateTime(row.createdAt), operatorDisplayName(row.updatedBy), 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')
@@ -383,14 +407,20 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
<el-button v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" link type="danger" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
</template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
<template #lastPaymentAt="{ row }">{{ formatDateTime(row.lastPaymentAt) }}</template>
<template #settled="{ row }"><el-tag v-if="row.settled !== null" :type="row.settled ? 'success' : 'warning'">{{ row.settled ? '是' : '否' }}</el-tag><span v-else>—</span></template>
<template #createdBy="{ row }">{{ operatorDisplayName(row.createdBy) }}</template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
<template #updatedBy="{ row }">{{ operatorDisplayName(row.updatedBy) }}</template>
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
</DataTable>
</section>
<section class="surface-panel statement-detail-panel" aria-label="所选对账单联动明细">
<header class="statement-detail-header">
<div><strong>已选择 {{ selectedId ? 1 : 0 }} 项</strong><span v-if="detail">{{ detail.billNo }} · {{ detail.customerName }}</span></div>
<div><strong>已选择 {{ selectedId ? 1 : 0 }} 项</strong><span v-if="detail">{{ detail.billNo }} · {{ customerDisplayName(detail.customerId, detail.customerName) }}</span></div>
<el-button v-if="selectedId" link @click="clearSelection">清空</el-button>
</header>
<el-tabs v-model="activeDetailTab">
@@ -400,7 +430,7 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
<template v-else-if="detail">
<el-table :data="detail.lines" size="small" border stripe empty-text="该对账单暂无账单明细">
<el-table-column label="应收/应付" width="110"><template #default>应收</template></el-table-column>
<el-table-column label="客户" min-width="170"><template #default>{{ detail.customerName }}</template></el-table-column>
<el-table-column label="客户" min-width="170"><template #default>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template></el-table-column>
<el-table-column label="入仓号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template></el-table-column>
<el-table-column label="柜次号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template></el-table-column>
<el-table-column label="业务发生日" width="130"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template></el-table-column>
@@ -418,18 +448,19 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
<el-table-column label="结算起始日" width="130"><template #default>{{ detail.periodStart || '—' }}</template></el-table-column>
<el-table-column label="结算结束日" width="130"><template #default>{{ detail.periodEnd || '—' }}</template></el-table-column>
</el-table>
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column prop="occurredAt" label="发生时间" min-width="170" /></el-table></el-collapse-item></el-collapse>
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></el-collapse-item></el-collapse>
</template>
<el-empty v-else description="请在主表中选择一张对账单" />
</el-tab-pane>
<el-tab-pane label="账单支付历史表" name="payments">
<el-table v-if="detail" :data="detail.paymentHistory" size="small" border stripe empty-text="该对账单暂无有效收款记录">
<el-table-column prop="transactionDate" label="支付日期" min-width="130" />
<el-table-column label="客户" min-width="170"><template #default>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template></el-table-column>
<el-table-column label="支付日期" min-width="130"><template #default="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template></el-table-column>
<el-table-column prop="amount" label="支付金额(CNY)" min-width="150" />
<el-table-column label="支付方式" min-width="130"><template #default="{ row }">{{ paymentMethodLabel(row.method) }}</template></el-table-column>
<el-table-column prop="referenceNo" label="支付流水号" min-width="170" />
<el-table-column prop="createdBy" label="登记人" min-width="130" />
<el-table-column prop="occurredAt" label="登记时间" min-width="170" />
<el-table-column label="登记人" min-width="130"><template #default="{ row }">{{ operatorDisplayName(row.createdBy) }}</template></el-table-column>
<el-table-column label="登记时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" />
</el-table>
<el-empty v-else description="请在主表中选择一张对账单" />
@@ -63,6 +63,9 @@ const paymentForm = ref({ transactionDate: new Date().toISOString().slice(0, 10)
const receiptOpen = ref(false)
const receiptBusy = ref(false)
const receiptFile = ref<File | null>(null)
const receiptUploadKey = ref(0)
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)
@@ -134,7 +137,7 @@ function displayDate(value?: string) { return value || '—' }
function rowTotal(value: unknown) { return totalAmount(value as SettlementRow) }
function rowPaid(value: unknown) { return paidAmount(value as SettlementRow) }
function rowOutstanding(value: unknown) { return Number((value as SettlementRow).outstandingAmount || 0) }
function customerName(customerId: string, snapshot?: string) { return snapshot || customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || `客户 ${customerId}` }
function customerName(customerId: string, snapshot?: string) { return snapshot || customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '—' }
function totalAmount(row: SettlementRow) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
function paidAmount(row: SettlementRow) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivedAmount : (row as FinancePayableSettlement).paidAmount }
function selectedSupplierName() { return selectedRow.value && 'supplierName' in selectedRow.value ? selectedRow.value.supplierName || '—' : '—' }
@@ -172,10 +175,9 @@ async function load(model: QueryModel = filters.value) {
finally { loading.value = false }
}
async function openDetail(row: unknown) {
function openDetail(row: unknown) {
selectedRow.value = row as SettlementRow
emit('openDetail', selectedRow.value.id)
await loadDetail(selectedRow.value.id)
}
async function loadDetail(settlementId: string) {
@@ -243,6 +245,8 @@ function selectPaymentFile(file: UploadFile) { paymentFile.value = file.raw ?? n
async function openReceipt(row: unknown) {
const settlement = row as FinanceReceivableSettlement
selectedRow.value = settlement
receiptError.value = null
receiptValidationError.value = null
receiptBusy.value = true
try {
const current = await getFinanceSettlement(settlement.id)
@@ -251,7 +255,7 @@ async function openReceipt(row: unknown) {
receiptFile.value = null
receiptForm.value = { receivedDate: new Date().toISOString().slice(0, 10), amount: current.outstandingAmount, paymentMethod: 'BANK_TRANSFER', remark: '' }
receiptOpen.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '应收结算单加载失败,无法登记收款') }
} catch (cause) { receiptError.value = getApiErrorDetails(cause, '应收结算单加载失败,无法登记收款') }
finally { receiptBusy.value = false }
}
@@ -260,11 +264,26 @@ async function openReceiptFromDetail() {
await openReceipt({ id: detail.value.id, customerId: detail.value.customerId, settlementNo: detail.value.settlementNo, receivableAmount: detail.value.amount, receivedAmount: detail.value.paidAmount, outstandingAmount: detail.value.outstandingAmount, currency: 'CNY', status: detail.value.status, version: detail.value.version })
}
function selectReceiptFile(file: UploadFile) { receiptFile.value = file.raw ?? null }
function selectReceiptFile(file: UploadFile) {
const raw = file.raw
if (!raw || !raw.type.startsWith('image/')) {
receiptFile.value = null
receiptValidationError.value = '收款凭证仅支持图片文件'
receiptUploadKey.value += 1
return
}
receiptFile.value = raw
receiptValidationError.value = null
receiptError.value = null
}
async function submitReceipt() {
const current = detail.value
if (!current?.reconciliationStatementId || !receiptFile.value || receiptForm.value.amount <= 0) return ElMessage.warning('请填写收款金额并选择收款凭证')
if (!current?.reconciliationStatementId || !receiptFile.value || receiptForm.value.amount <= 0) {
receiptValidationError.value = '请填写收款金额并选择收款凭证'
return
}
receiptError.value = null
receiptBusy.value = true
try {
const voucher = await uploadFinanceVoucher(current.customerId, receiptFile.value)
@@ -273,7 +292,7 @@ async function submitReceipt() {
ElMessage.success('收款已登记')
if (detailMode.value) await loadDetail(current.id)
else await load()
} catch (cause) { error.value = getApiErrorDetails(cause, '收款登记失败') }
} catch (cause) { receiptError.value = getApiErrorDetails(cause, '收款登记失败') }
finally { receiptBusy.value = false }
}
@@ -478,7 +497,10 @@ onMounted(() => {
<AppDialog v-model="receiptOpen" title="登记收款" width="min(560px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
<div v-if="detail" class="partial-summary"><div><span>结算单号</span><strong>{{ detail.settlementNo }}</strong></div><div><span>客户公司</span><strong>{{ detailPartyName() }}</strong></div><div><span>应收金额</span><strong>{{ money(detail.amount) }}</strong></div><div><span>已收金额</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div><div><span>当前余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div></div>
<el-form label-position="top"><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="收款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" /></el-form-item></el-form>
<el-alert v-if="receiptValidationError" type="error" :title="receiptValidationError" :closable="false" show-icon class="operation-error" />
<el-alert v-if="receiptError" type="error" :title="receiptError.message" :closable="false" show-icon class="operation-error" />
<ApiErrorMeta v-if="receiptError" :details="receiptError" class="operation-error-meta" />
<el-form label-position="top"><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" /></el-form-item></el-form>
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(receiptForm.amount) }}</strong></div>
</AppDialog>
</AdminPage>
@@ -510,6 +532,8 @@ onMounted(() => {
.partial-summary strong.is-danger { color: #c2413a; }
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 8px; color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
.remaining-preview strong { color: #b45309; font-size: 16px; }
.operation-error { margin-bottom: 12px; }
.operation-error-meta { margin: -6px 0 12px; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
@media (max-width: 720px) {
.settlement-summary, .settlement-detail-summary, .partial-summary { grid-template-columns: 1fr; }
@@ -5,6 +5,7 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { ApiResponseError } from '@/shared/api/http'
import {
actionFinanceSettlement,
actionReconciliationStatement,
@@ -143,6 +144,60 @@ describe('finance G3/G4 page operations', () => {
}, ['9001'])
})
it('keeps receipt errors in the modal and rejects non-image vouchers before upload', async () => {
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:receipt:create'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
openReceipt(row: Record<string, unknown>): Promise<void>
selectReceiptFile(file: { raw: File }): void
submitReceipt(): Promise<void>
receiptFile: File | null
receiptOpen: boolean
receiptValidationError: string | null
receiptError: { message: string } | null
}
vm.selectReceiptFile({ raw: new File(['not an image'], 'receipt.pdf', { type: 'application/pdf' }) })
expect(vm.receiptFile).toBeNull()
expect(vm.receiptValidationError).toBe('收款凭证仅支持图片文件')
await vm.openReceipt({ id: '701' })
vm.selectReceiptFile({ raw: new File(['receipt'], 'receipt.png', { type: 'image/png' }) })
vi.mocked(uploadFinanceVoucher).mockRejectedValueOnce(new ApiResponseError('FIN_400: 凭证校验失败', 'FIN_400', 400, undefined, 'request-1', 'trace-1'))
await vm.submitReceipt()
expect(vm.receiptOpen).toBe(true)
expect(vm.receiptError?.message).toContain('FIN_400')
expect(wrapper.text()).toContain('FIN_400')
expect(wrapper.find('[aria-label="结算单应收列表"]').exists()).toBe(true)
})
it('emits payable detail navigation and displays customer names without internal IDs', async () => {
const pinia = piniaWithPermissions(['platform:finance:settlement-ap:list'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'payable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as { openDetail(row: Record<string, unknown>): void; customerName(customerId: string, snapshot?: string): string }
vi.mocked(getFinanceSettlement).mockClear()
vm.openDetail({ id: '801' })
expect(wrapper.emitted('openDetail')).toEqual([['801']])
expect(getFinanceSettlement).not.toHaveBeenCalled()
expect(vm.customerName('2088', '上海客户有限公司')).toBe('上海客户有限公司')
expect(vm.customerName('2088')).toBe('—')
})
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 wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable', settlementId: '701' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('导出')
expect(wrapper.text()).not.toContain('部分收款')
})
it('submits settlement void and reopen actions with the current version and reason', async () => {
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: '账务冲销' } as never)
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:settlement:void'])
@@ -12,6 +12,15 @@ describe('PlatformFirstLegOrdersPanel review queue', () => {
expect(source).toContain("key: 'tenantId'")
expect(source).toContain('customerDisplay(row as PlatformFirstLegOrderResource).name')
expect(source).toContain('result.pages')
const queryFieldsSource = source.slice(source.indexOf('const queryFields'), source.indexOf('const orderColumns'))
expect(source).toContain("listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })")
expect(queryFieldsSource).toContain("key: 'tenantId'")
expect(queryFieldsSource).toContain("type: 'select'")
expect(queryFieldsSource).toContain('filterable: true')
expect(queryFieldsSource).toContain('tenant.legalName?.trim() || tenant.tenantName')
expect(queryFieldsSource).not.toContain('tenantCode')
expect(queryFieldsSource).not.toContain('客户编码')
})
it('does not retain the historical fixed tenant', () => {
@@ -1,6 +1,19 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { PlatformTenantResource } from '@/modules/iam'
import { loadAllActiveCustomers } from '../task-list'
function activeCustomer(id: string, tenantName: string, legalName: string | null, tenantCode: string): PlatformTenantResource {
return {
id, tenantName, legalName, tenantCode, status: 'ACTIVE', legalRepresentative: null,
unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null,
servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null,
enterpriseType: null, establishedDate: null, registeredCapital: null,
registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null,
createdAt: null, approvedAt: null,
}
}
describe('InboundTaskList measurement scope', () => {
it('uses the inbound tenant isolation key for measurement and review requests', () => {
@@ -10,6 +23,42 @@ describe('InboundTaskList measurement scope', () => {
expect(source).not.toContain('selectedInbound.customerId || selectedInbound.tenantId')
})
it('loads only active customers and exposes a searchable name-only select', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
const queryFieldsSource = source.slice(source.indexOf('const queryFields'), source.indexOf('const columns'))
expect(source).toContain('loadAllActiveCustomers')
expect(queryFieldsSource).toContain("type: 'select'")
expect(queryFieldsSource).toContain('filterable: true')
expect(queryFieldsSource).toContain('label: customer.legalName?.trim() || customer.tenantName')
expect(queryFieldsSource).not.toContain('tenantCode')
expect(queryFieldsSource).not.toContain('客户编码')
})
it('requests every active-customer page and merges name-only options', async () => {
const calls: Array<{ page: number; size: number; status: 'ACTIVE' }> = []
const records = await loadAllActiveCustomers(async (query) => {
calls.push(query)
if (query.page === 1) {
return {
pages: 2,
records: [activeCustomer('10001', '客户一', '客户一有限公司', 'CUS-001')],
}
}
return {
pages: 2,
records: [activeCustomer('10002', '客户二', null, 'CUS-002')],
}
})
expect(calls).toEqual([
{ page: 1, size: 200, status: 'ACTIVE' },
{ page: 2, size: 200, status: 'ACTIVE' },
])
expect(records.map((customer) => customer.legalName?.trim() || customer.tenantName)).toEqual(['客户一有限公司', '客户二'])
expect(records.map((customer) => customer.legalName?.trim() || customer.tenantName).join(' ')).not.toContain('CUS-')
})
it('opens the shared unified order detail from the view action', () => {
const source = readFileSync(resolve('src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
@@ -17,7 +17,7 @@ import type { InboundOrderSummary } from '../core/types'
import InboundMeasurementPanel from '../measurement/InboundMeasurementPanel.vue'
import MeasurementReviewPanel from '../review/MeasurementReviewPanel.vue'
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
import { buildInboundTaskQuery, type InboundTaskMode } from '../task-list'
import { buildInboundTaskQuery, loadAllActiveCustomers, type InboundTaskMode } from '../task-list'
type FilterModel = QueryModel & {
customerId: string
@@ -43,6 +43,7 @@ const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
const selectedInbound = ref<InboundOrderSummary | null>(null)
const taskDialogOpen = ref(false)
let requestSerial = 0
let optionsRequestSerial = 0
let disposed = false
let activatedOnce = false
@@ -109,14 +110,15 @@ function dateParam(value: string | undefined, end = false) {
}
async function loadOptions() {
const serial = ++optionsRequestSerial
customerLoadError.value = null
try {
const [customerPage, warehousePage] = await Promise.all([
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
const [customerRecords, warehousePage] = await Promise.all([
loadAllActiveCustomers((query) => listPlatformTenants(query)),
listPlatformWarehouses(1, 200),
])
if (disposed) return
customers.value = customerPage.records
if (disposed || serial !== optionsRequestSerial) return
customers.value = customerRecords
warehouseOptions.value = warehousePage.records.map((warehouse) => ({
label: `${warehouse.warehouseName} · ${warehouse.warehouseCode}`,
value: String(warehouse.id),
@@ -190,7 +192,7 @@ onActivated(() => {
}
void loadTasks()
})
onBeforeUnmount(() => { disposed = true; requestSerial += 1 })
onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSerial += 1 })
</script>
<template>
@@ -241,7 +241,7 @@ onBeforeUnmount(() => {
<div class="inbound-operation-dialog__footer">
<el-button :disabled="Boolean(reviewPanelRef?.actionLoading)" @click="reviewDialogOpen = false">取消</el-button>
<el-button v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview" type="danger" plain :loading="reviewPanelRef?.actionLoading === 'reject'" @click="reviewPanelRef?.openReasonDialog('reject')">驳回</el-button>
<el-button v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview" type="primary" :loading="reviewPanelRef?.actionLoading === 'approve'" @click="reviewPanelRef?.confirmApprove()">通过并完成入库</el-button>
<el-button v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview" type="primary" :loading="reviewPanelRef?.actionLoading === 'approve'" @click="reviewPanelRef?.confirmApprove()">通过并进入待分柜</el-button>
<el-button v-if="reviewPanelRef?.isRejected && reviewPanelRef?.canRelease" type="warning" plain :loading="reviewPanelRef?.actionLoading === 'release'" @click="reviewPanelRef?.openReasonDialog('release')">异常放行</el-button>
</div>
</template>
@@ -177,6 +177,14 @@ describe('InboundFeePanel', () => {
expect(document.querySelector('[data-testid="adjust-fee-button"]')).not.toBeNull()
})
it('allows adjustment for a calculated fee waiting for split', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(true)
})
it('submits a reasoned adjustment and emits the refreshed snapshot', async () => {
const wrapper = mountPanel()
await flushPromises()
@@ -184,12 +192,15 @@ describe('InboundFeePanel', () => {
await flushPromises()
const numbers = [...document.querySelectorAll<HTMLInputElement>('.adjustment-form input')]
numbers[0]!.value = '1200'
numbers[0]!.value = '140'
numbers[0]!.dispatchEvent(new Event('input', { bubbles: true }))
numbers[0]!.dispatchEvent(new Event('change', { bubbles: true }))
numbers[1]!.value = '80'
numbers[1]!.value = '8.2'
numbers[1]!.dispatchEvent(new Event('input', { bubbles: true }))
numbers[1]!.dispatchEvent(new Event('change', { bubbles: true }))
numbers[2]!.value = '80'
numbers[2]!.dispatchEvent(new Event('input', { bubbles: true }))
numbers[2]!.dispatchEvent(new Event('change', { bubbles: true }))
const reason = document.querySelector<HTMLTextAreaElement>('.adjustment-form textarea')!
reason.value = '补充已确认的附加费用'
reason.dispatchEvent(new Event('input', { bubbles: true }))
@@ -203,7 +214,7 @@ describe('InboundFeePanel', () => {
expect(adjustPublishedInboundFee).toHaveBeenCalledWith(
'10001',
'88',
{ subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费用' },
{ expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费用' },
expect.stringMatching(/^fee-adjust-/),
)
expect(wrapper.emitted('changed')?.[0]?.[0]).toMatchObject({ feeVersion: 3 })
@@ -219,10 +230,12 @@ describe('InboundFeePanel', () => {
await flushPromises()
const numbers = [...document.querySelectorAll<HTMLInputElement>('.adjustment-form input')]
numbers[0]!.value = '1200'
numbers[0]!.value = '140'
numbers[0]!.dispatchEvent(new Event('input', { bubbles: true }))
numbers[1]!.value = '80'
numbers[1]!.value = '8.2'
numbers[1]!.dispatchEvent(new Event('input', { bubbles: true }))
numbers[2]!.value = '80'
numbers[2]!.dispatchEvent(new Event('input', { bubbles: true }))
const reason = document.querySelector<HTMLTextAreaElement>('.adjustment-form textarea')!
reason.value = '并发场景价格修正'
reason.dispatchEvent(new Event('input', { bubbles: true }))
+24 -13
View File
@@ -43,13 +43,16 @@ let adjustmentFingerprint = ''
let adjustmentIdempotencyKey = ''
const adjustmentForm = reactive({
subtotalAmount: 0,
expectedFeeVersion: 0,
chargeableQuantity: 0,
unitPrice: 0,
additionalAmount: 0,
reason: '',
})
const adjustmentRules: FormRules = {
subtotalAmount: [{ required: true, message: '请输入调整后的基础运费', trigger: 'change' }],
chargeableQuantity: [{ required: true, message: '请输入调整后的计费量', trigger: 'change' }],
unitPrice: [{ required: true, message: '请输入调整后的单价', trigger: 'change' }],
additionalAmount: [{ required: true, message: '请输入调整后的附加费', trigger: 'change' }],
reason: [
{ required: true, whitespace: true, message: '请填写费用调整原因', trigger: 'blur' },
@@ -61,8 +64,8 @@ const canView = computed(() => access.can('firstleg:fba-pricing:view'))
const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust'))
const customerContextId = computed(() => props.customerId || props.tenantId || '')
const adjustmentEligible = computed(() => (
snapshot.value?.feeStatus === 'SENT'
&& snapshot.value.nextProcessStatus === 'WAIT_SPLIT'
['CALCULATED', 'CONFIRMED', 'SENT'].includes(snapshot.value?.feeStatus || '')
&& snapshot.value?.nextProcessStatus === 'WAIT_SPLIT'
))
const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费')
const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM')
@@ -142,7 +145,9 @@ async function loadFee(options: { preserveAdjustment?: boolean } = {}) {
}
function fillAdjustmentForm(value: InboundFeeSnapshot) {
adjustmentForm.subtotalAmount = Number(value.subtotalAmount)
adjustmentForm.expectedFeeVersion = Number(value.feeVersion)
adjustmentForm.chargeableQuantity = Number(value.chargeableQuantity)
adjustmentForm.unitPrice = Number(value.unitPrice)
adjustmentForm.additionalAmount = Number(value.additionalAmount)
adjustmentForm.reason = ''
adjustmentError.value = null
@@ -159,7 +164,9 @@ function openAdjustment() {
function adjustmentPayload(): AdjustPublishedInboundFeeRequest {
return {
subtotalAmount: Number(adjustmentForm.subtotalAmount),
expectedFeeVersion: adjustmentForm.expectedFeeVersion,
chargeableQuantity: Number(adjustmentForm.chargeableQuantity),
unitPrice: Number(adjustmentForm.unitPrice),
additionalAmount: Number(adjustmentForm.additionalAmount),
reason: adjustmentForm.reason.trim(),
}
@@ -244,7 +251,7 @@ defineExpose({ reload: loadFee })
data-testid="adjust-fee-button"
type="primary"
@click="openAdjustment"
><PencilLine :size="16" />调整已发布费用</el-button>
><PencilLine :size="16" />调整初算费用</el-button>
</div>
</header>
@@ -343,14 +350,14 @@ defineExpose({ reload: loadFee })
<AppDialog
v-model="adjustOpen"
title="调整已发布费用"
title="调整入库费用"
width="min(560px, calc(100vw - 28px))"
confirm-text="确认调整并生成新版本"
confirm-type="warning"
:confirm-loading="adjusting"
@confirm="submitAdjustment"
>
<el-alert type="warning" :closable="false" show-icon title="调整会生成新的已发布版本,不会回退实物入库状态。" />
<el-alert type="warning" :closable="false" show-icon title="调整会生成新的费用版本;小计和总额由服务端按计费量、单价和附加费重算。" />
<el-alert v-if="conflictRefreshed" class="adjustment-alert" type="warning" :closable="false" show-icon>
<template #title>费用已被其他操作员更新,系统已刷新最新快照</template>
<p>当前输入仍保留,请先核对最新版本后再提交。</p>
@@ -362,16 +369,20 @@ defineExpose({ reload: loadFee })
</el-alert>
<el-form ref="adjustFormRef" :model="adjustmentForm" :rules="adjustmentRules" label-position="top" class="adjustment-form" :disabled="adjusting">
<div class="adjustment-amounts">
<el-form-item label="调整后基础运费" prop="subtotalAmount" required>
<el-input-number v-model="adjustmentForm.subtotalAmount" aria-label="调整后基础运费" :min="0" :max="999999999999.99" :precision="2" :step="100" controls-position="right" />
<span>{{ snapshot?.currency }}</span>
<el-form-item label="调整后计费量" prop="chargeableQuantity" required>
<el-input-number v-model="adjustmentForm.chargeableQuantity" aria-label="调整后计费量" :min="0.0001" :max="999999999999.9999" :precision="4" :step="1" controls-position="right" />
<span>{{ chargeUnitLabel }}</span>
</el-form-item>
<el-form-item label="调整后单价" prop="unitPrice" required>
<el-input-number v-model="adjustmentForm.unitPrice" aria-label="调整后单价" :min="0" :max="999999999999.9999" :precision="4" :step="0.1" controls-position="right" />
<span>{{ snapshot?.currency }} / {{ chargeUnitLabel }}</span>
</el-form-item>
<el-form-item label="调整后附加费" prop="additionalAmount" required>
<el-input-number v-model="adjustmentForm.additionalAmount" aria-label="调整后附加费" :min="0" :max="999999999999.99" :precision="2" :step="10" controls-position="right" />
<span>{{ snapshot?.currency }}</span>
</el-form-item>
</div>
<div class="adjustment-total"><span>调整后总额</span><strong>{{ formatMoney(adjustmentForm.subtotalAmount + adjustmentForm.additionalAmount, snapshot?.currency) }}</strong></div>
<div class="adjustment-total"><span>当前服务端计算金额</span><strong>{{ formatMoney(snapshot?.totalAmount, snapshot?.currency) }}</strong><small>提交后以服务端返回的新版本金额为准</small></div>
<el-form-item label="调整原因" prop="reason" required>
<el-input v-model="adjustmentForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="说明价格变更、补充费用或修正原因,记录将进入审计历史" />
</el-form-item>
+2 -2
View File
@@ -34,7 +34,7 @@ describe('inbound fee API', () => {
it('adjusts a published snapshot with the required idempotency key', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, feeVersion: 3 } })
const payload = { subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费' }
const payload = { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费' }
await expect(adjustPublishedInboundFee('10001', '88', payload, 'fee-adjust-1'))
.resolves.toMatchObject({ feeVersion: 3 })
@@ -54,7 +54,7 @@ describe('inbound fee API', () => {
const error = await adjustPublishedInboundFee(
'10001',
'88',
{ subtotalAmount: 1200, additionalAmount: 80, reason: '价格修正' },
{ expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '价格修正' },
'fee-adjust-2',
).catch((reason: unknown) => reason)
+3 -1
View File
@@ -47,7 +47,9 @@ export interface InboundFeeSnapshot {
}
export interface AdjustPublishedInboundFeeRequest {
subtotalAmount: number
expectedFeeVersion: number
chargeableQuantity: number
unitPrice: number
additionalAmount: number
reason: string
}
@@ -110,6 +110,9 @@ describe('InboundOrdersView', () => {
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCustomerChange + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
const customerQueryField = (toolbar.props('fields') as Array<{ key: string; type?: string; props?: Record<string, unknown> }>).find((field) => field.key === 'customerId')
expect(customerQueryField?.type).toBe('select')
expect(customerQueryField?.props?.filterable).toBe(true)
expect(document.body.textContent).toContain('RK-001')
expect(document.body.textContent).toContain('查看')
expect(document.body.textContent).toContain('示例企业有限公司')
@@ -117,6 +120,18 @@ describe('InboundOrdersView', () => {
expect(wrapper.find('.inbound-tabs .is-active').text()).toContain('全部')
})
it('keeps the customer filter name-only and does not add a customer-code input', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrdersView.vue'), 'utf8')
const queryFieldsSource = source.slice(source.indexOf('const allQueryFields'), source.indexOf('const columns'))
expect(queryFieldsSource).toContain("key: 'customerId'")
expect(queryFieldsSource).toContain("type: 'select'")
expect(queryFieldsSource).toContain('filterable: true')
expect(queryFieldsSource).toContain('legalName?.trim() || customer.tenantName')
expect(queryFieldsSource).not.toContain('tenantCode')
expect(queryFieldsSource).not.toContain('客户编码')
})
it('uses backend-supported warehouse IDs from tenant-scoped options without coercing Long IDs', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
@@ -65,7 +65,7 @@ const tabs: Array<{ value: InboundListTab | ''; label: string; description: stri
{ value: 'WAIT_MEASURE', label: '待测量', description: '等待测量或补正' },
{ value: 'WAIT_REVIEW', label: '待审核', description: '等待测量审核' },
{ value: 'WAIT_FEE_SEND', label: '待费用/入库', description: '等待费用编排完成' },
{ value: 'WAIT_SPLIT', label: '待分柜', description: '费用已发布并完成入库,等待分柜' },
{ value: 'WAIT_SPLIT', label: '待分柜', description: '测量审核完成,等待分柜;费用可继续维护' },
]
const advancedFilterKeys = new Set(['orderStatus', 'feeReviewStatus', 'customerName', 'destinationCode', 'holdFlag'])
@@ -55,7 +55,7 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.text()).toContain('调整原因:去除托盘重量后采用净值')
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
expect(wrapper.text()).not.toContain('租户')
expect(wrapper.text()).toContain('通过并完成入库')
expect(wrapper.text()).toContain('通过并进入待分柜')
expect(wrapper.text()).not.toContain('确认费用')
expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001')
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
@@ -77,25 +77,37 @@ describe('MeasurementReviewPanel', () => {
})
it('emits changed after a successful review write', async () => {
vi.mocked(approveMeasurement).mockResolvedValue({ feeId: '1', feeNo: 'FEE-1', feeVersion: 1, feeStatus: 'SENT', inboundId: '90001', totalAmount: 100, currency: 'CNY', nextProcessStatus: 'WAIT_SPLIT' })
vi.mocked(approveMeasurement).mockResolvedValue({ inboundId: '90001', measurementId: '70001', processStatus: 'WAIT_SPLIT', feeStatus: 'CALCULATED', initialPricingSucceeded: true, initialPricingMessage: null, initialFee: null })
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const buttons = wrapper.findAll('button')
const approve = buttons.find((button) => button.text().includes('通过并完成入库'))
const approve = buttons.find((button) => button.text().includes('通过并进入待分柜'))
expect(approve).toBeDefined()
await approve?.trigger('click')
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
expect.stringContaining('自动试算并发布费用'),
expect.stringContaining('初算失败不影响审核通过'),
'确认测量审核通过',
expect.objectContaining({ confirmButtonText: '通过并完成入库' }),
expect.objectContaining({ confirmButtonText: '通过并进入待分柜' }),
)
expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 7 })
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('keeps approval successful when initial pricing fails', async () => {
vi.mocked(approveMeasurement).mockResolvedValue({ inboundId: '90001', measurementId: '70001', processStatus: 'WAIT_SPLIT', feeStatus: 'UNPRICED', initialPricingSucceeded: false, initialPricingMessage: '缺少有效价格', initialFee: null })
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click')
await flushPromises()
expect(wrapper.emitted('changed')).toHaveLength(1)
expect(wrapper.text()).toContain('初算失败不阻断审核')
})
it('rejects with the inbound optimistic-lock version instead of the measurement version', async () => {
vi.mocked(rejectMeasurement).mockResolvedValue(undefined)
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
@@ -145,20 +145,24 @@ async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value) return
try {
await ElMessageBox.confirm(
'审核通过后,系统会自动试算并发布费用,同时完成托盘库位占用和入库。客户无需再次确认费用。是否继续?',
'审核通过后,入库单将进入待分柜。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?',
'确认测量审核通过',
{ confirmButtonText: '通过并完成入库', cancelButtonText: '返回', type: 'warning' },
{ confirmButtonText: '通过并进入待分柜', cancelButtonText: '返回', type: 'warning' },
)
} catch {
return
}
actionLoading.value = 'approve'
try {
await approveMeasurement(customerId.value, props.inboundId, {
const result = await approveMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.inboundVersion,
})
ElMessage.success('审核已通过,费用发布和入库正在完成')
if (result.initialPricingSucceeded) {
ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算')
} else {
ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
}
await reload()
emit('changed')
} catch (error) {
@@ -295,10 +299,10 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
</div>
<div class="review-actions" :class="{ 'review-actions--disabled': !isPending && !isRejected }">
<div><strong>审核结论</strong><span v-if="isPending">通过后将自动完成计费、费用发布和入库。</span><span v-else-if="isRejected">当前测量已驳回,可修正后重新提交,或按权限申请异常放行。</span><span v-else>当前记录不在可操作的测量审核状态。</span></div>
<div><strong>审核结论</strong><span v-if="isPending">通过后进入待分柜;费用仅做初算,初算失败不阻断审核。</span><span v-else-if="isRejected">当前测量已驳回,可修正后重新提交,或按权限申请异常放行。</span><span v-else>当前记录不在可操作的测量审核状态。</span></div>
<div class="action-buttons">
<el-button v-if="isPending && canReview" type="danger" plain :loading="actionLoading === 'reject'" @click="openReasonDialog('reject')"><X :size="16" />驳回</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并完成入库</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
<el-button v-if="isRejected && canRelease" type="warning" plain :loading="actionLoading === 'release'" @click="openReasonDialog('release')"><ShieldAlert :size="16" />异常放行</el-button>
</div>
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
ExceptionReleaseRequest,
FeeSnapshotAfterApprove,
MeasurementApprovalResponse,
MeasurementReviewDetail,
MeasurementReviewHistoryPage,
MeasurementReviewHistoryPayload,
@@ -63,7 +63,7 @@ export function approveMeasurement(
request: MeasurementReviewRequest,
key?: string,
) {
return postReview<FeeSnapshotAfterApprove>(
return postReview<MeasurementApprovalResponse>(
`/platform/v1/inbounds/${inboundId}/measurements/approve`,
tenantId,
request,
+10
View File
@@ -116,6 +116,16 @@ export interface ExceptionReleaseRequest {
reason: string
}
export interface MeasurementApprovalResponse {
inboundId: string | null
measurementId: string | null
processStatus: 'WAIT_SPLIT' | string
feeStatus: 'CALCULATED' | 'UNPRICED' | string
initialPricingSucceeded: boolean
initialPricingMessage: string | null
initialFee: FeeSnapshotAfterApprove | null
}
export interface FeeSnapshotAfterApprove {
feeId: string | null
feeNo: string | null
+27
View File
@@ -1,7 +1,34 @@
import type { PlatformTenantResource } from '@/modules/iam'
import type { InboundListQuery } from './core/types'
export type InboundTaskMode = 'measurement' | 'review'
export interface ActiveCustomerPage {
records: PlatformTenantResource[]
pages: number
}
export interface ActiveCustomerQuery {
page: number
size: number
status: 'ACTIVE'
}
export async function loadAllActiveCustomers(
fetchPage: (query: ActiveCustomerQuery) => Promise<ActiveCustomerPage>,
) {
const records: PlatformTenantResource[] = []
let page = 1
let pages = 1
do {
const response = await fetchPage({ page, size: 200, status: 'ACTIVE' })
records.push(...response.records)
pages = Math.max(1, response.pages)
page += 1
} while (page <= pages)
return records
}
export function buildInboundTaskQuery(
mode: InboundTaskMode,
query: Omit<InboundListQuery, 'tab' | 'processStatus' | 'measureReviewStatus'>,
@@ -9,7 +9,7 @@ const preloadDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/loa
describe('LoadingListsView', () => {
it('filters both list modes by an active name-only customer selector', () => {
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("customer.legalName?.trim() || customer.tenantName")
expect(source).toContain("label: customer.tenantName")
expect(source).toContain("customerId: String(query.customerId || '').trim() || undefined")
expect(source).not.toContain('customer.tenantCode')
})
@@ -69,7 +69,7 @@ const canView = computed(() => canList.value)
const canEdit = computed(() => isPreload.value && access.can('platform:firstleg:preload:edit'))
const canManageCustoms = computed(() => !isPreload.value && access.can('platform:firstleg:customs:manage'))
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.legalName?.trim() || customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/集装箱号/柜号' : '装柜清单号/集装箱号/柜号', width: 250 },
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 260 },
])
@@ -18,7 +18,7 @@ describe('OceanBookingDetailView interaction contract', () => {
it('hides unavailable customers instead of filling the selector with disabled options', () => {
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain('customer.legalName?.trim() || customer.tenantName')
expect(source).toContain(':label="customer.tenantName"')
expect(source).not.toContain('customer.tenantCode')
expect(source).toContain("customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)")
expect(source).toContain('v-for="customer in selectableCustomers"')
@@ -26,8 +26,8 @@ describe('OceanBookingDetailView interaction contract', () => {
})
it('keeps historical partner names visible when an existing partner is no longer selectable', () => {
expect(source).toContain("form.data.shippingCompanyName || '当前船公司'")
expect(source).toContain("form.data.bookingAgentName || '当前订舱代理'")
expect(source).toContain("form.data.shippingCompanyName || '船公司名称缺失'")
expect(source).toContain("form.data.bookingAgentName || '订舱代理名称缺失'")
expect(source).toContain("from '@/modules/master-data'")
})
@@ -53,7 +53,8 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).not.toContain('@update:model-value="(value) =>')
expect(source).toContain('v-model="container.capacityCbm"')
expect(source).toContain('v-model="container.maxWeightKg"')
expect(source).toContain('releaseOceanBooking(id.value, releaseRequest)')
expect(source).toContain('releaseOceanBooking(id.value, { expectedVersion: detail.value.version')
expect(source).toContain('title="确认放舱"')
expect(source).toContain('createBlankContainerRequirement()')
expect(source).toContain('containerRequirementError(form.data.containerRequirements)')
expect(source).toContain('function validateContainerRequirements()')
@@ -102,6 +102,8 @@ const availableActions = computed(() => (actionsByStatus[detail.value?.status ||
const changeDialogOpen = ref(false)
const changeForm = reactive({ vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' })
const releaseDialogOpen = ref(false)
const releaseForm = reactive({ billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' })
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
return oceanBookingStatusLabel(status)
@@ -127,7 +129,7 @@ async function loadOptions() {
function selectCustomer(customerId: string) {
form.data.customerId = customerId
const customer = customers.value.find((item) => String(item.id) === customerId)
form.data.customerName = customer ? customer.legalName?.trim() || customer.tenantName : ''
form.data.customerName = customer?.tenantName || ''
}
function selectWarehouse(value: unknown) {
const warehouseId = String(value || '')
@@ -208,6 +210,37 @@ function openChangeDialog() {
changeForm.reason = ''
changeDialogOpen.value = true
}
function openReleaseDialog() {
releaseForm.billOfLadingNo = detail.value?.billOfLadingNo || ''
releaseForm.vesselName = detail.value?.vesselName || ''
releaseForm.voyageNo = detail.value?.voyageNo || ''
releaseForm.estimatedSailingAt = detail.value?.estimatedSailingAt || null
releaseForm.estimatedArrivalAt = detail.value?.estimatedArrivalAt || null
releaseForm.reason = ''
releaseDialogOpen.value = true
}
async function submitRelease() {
if (!detail.value) return
const billOfLadingNo = releaseForm.billOfLadingNo.trim()
const vesselName = releaseForm.vesselName.trim()
const voyageNo = releaseForm.voyageNo.trim()
if (!billOfLadingNo || !vesselName || !voyageNo || !releaseForm.estimatedSailingAt || !releaseForm.estimatedArrivalAt) {
actionError.value = getApiErrorDetails(new Error('请完整填写提单号、船名、航次、预计开船和预计到港时间'))
return
}
if (releaseForm.estimatedArrivalAt < releaseForm.estimatedSailingAt) {
actionError.value = getApiErrorDetails(new Error('预计到港时间不能早于预计开船时间'))
return
}
actionSaving.value = true
actionError.value = null
try {
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: detail.value.version, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt: releaseForm.estimatedSailingAt, estimatedArrivalAt: releaseForm.estimatedArrivalAt, reason: releaseForm.reason.trim() || null }))
releaseDialogOpen.value = false
ElMessage.success('确认放舱成功')
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { actionSaving.value = false }
}
async function submitChange() {
if (!detail.value || !changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()) return
actionSaving.value = true
@@ -221,16 +254,14 @@ async function submitChange() {
}
async function runAction(action: OceanBookingAction) {
if (!detail.value || action === 'change-request') { if (action === 'change-request') openChangeDialog(); return }
if (action === 'release') { openReleaseDialog(); return }
actionError.value = null
if (!validateContainerRequirements()) return
try {
const reason = await requiredReason(action)
await ElMessageBox.confirm(`确认执行“${actionLabels[action]}”吗?`, actionLabels[action], { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
actionSaving.value = true
if (action === 'release') {
const releaseRequest = { expectedVersion: detail.value.version, billOfLadingNo: form.data.billOfLadingNo || '', vesselName: form.data.vesselName || '', voyageNo: form.data.voyageNo || '', estimatedSailingAt: form.data.estimatedSailingAt || '', estimatedArrivalAt: form.data.estimatedArrivalAt || '', reason }
applyDetail(await releaseOceanBooking(id.value, releaseRequest))
} else applyDetail(await performOceanBookingAction(id.value, action, { expectedVersion: detail.value.version, reason }))
applyDetail(await performOceanBookingAction(id.value, action, { expectedVersion: detail.value.version, reason }))
ElMessage.success(`${actionLabels[action]}成功`)
} catch (cause) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
@@ -278,14 +309,14 @@ onMounted(() => { void load(); void loadOptions() })
<section class="booking-group" aria-label="基本信息">
<h3>基本信息</h3><div class="booking-group__body">
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
<el-form-item label="客户" prop="customerId"><el-select :model-value="form.data.customerId" filterable placeholder="请选择客户" :loading="optionsLoading" :disabled="!isNew" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '当前客户'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.legalName?.trim() || customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="客户" prop="customerId"><el-select :model-value="form.data.customerId" filterable placeholder="请选择客户" :loading="optionsLoading" :disabled="!isNew" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '待创建'" disabled /></el-form-item>
<el-form-item label="渠道编码" prop="channelCode"><el-input v-model="form.data.channelCode" placeholder="请输入渠道编码" /></el-form-item>
<el-form-item label="渠道名称" prop="channelName"><el-input v-model="form.data.channelName" placeholder="请输入渠道名称" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.data.billOfLadingNo" placeholder="请输入提单号(可选)" /></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '当前船公司'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-if="form.data.bookingAgentId && !bookingAgents.some((partner) => String(partner.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '当前订舱代理'" :value="String(form.data.bookingAgentId)" /><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-if="form.data.bookingAgentId && !bookingAgents.some((partner) => String(partner.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
</div>
</section>
<section class="booking-group" aria-label="航线船期">
@@ -348,6 +379,18 @@ onMounted(() => { void load(); void loadOptions() })
<el-form-item label="预计到港"><el-date-picker v-model="changeForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item label="变更原因" required><el-input v-model="changeForm.reason" type="textarea" placeholder="请说明改船原因" /></el-form-item>
</el-form>
<LoadErrorState v-if="actionError" compact title="提交失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="!releaseForm.billOfLadingNo.trim() || !releaseForm.vesselName.trim() || !releaseForm.voyageNo.trim() || !releaseForm.estimatedSailingAt || !releaseForm.estimatedArrivalAt" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<el-form label-position="top">
<el-form-item label="提单号" required><el-input v-model="releaseForm.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
<el-form-item label="船名" required><el-input v-model="releaseForm.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次" required><el-input v-model="releaseForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="预计开船" required><el-date-picker v-model="releaseForm.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<el-form-item label="预计到港" required><el-date-picker v-model="releaseForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item label="备注"><el-input v-model="releaseForm.reason" type="textarea" maxlength="1000" placeholder="请输入放舱备注(可选)" /></el-form-item>
</el-form>
<LoadErrorState v-if="actionError" compact title="放舱失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
</AppDialog>
</AdminPage>
</template>
@@ -41,7 +41,7 @@ describe('OceanBookingsView customer filter contract', () => {
it('loads active customers, displays names only and submits customerId', () => {
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("customer.status === 'ACTIVE'")
expect(source).toContain("customer.legalName?.trim() || customer.tenantName")
expect(source).toContain("label: customer.tenantName")
expect(source).toContain("customerId: String(query.customerId || '') || undefined")
expect(source).not.toContain('customer.tenantCode')
})
@@ -29,7 +29,7 @@ const customersLoading = ref(false)
const query = reactive<QueryModel>({ customerId: '', keyword: '', status: '' })
const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE'))
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: activeCustomers.value.map((customer) => ({ label: customer.legalName?.trim() || customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/订单号', width: 220 },
{ key: 'status', type: 'select', label: '状态', options: oceanBookingStatusOptions },
])
@@ -74,9 +74,10 @@ describe('OutboundDispatchesView', () => {
it('loads only active customer names for the filter', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("records.filter((customer) => customer.status === 'ACTIVE')")
expect(source).toContain("item.legalName?.trim() || item.tenantName")
expect(source).toContain("listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })")
expect(source).toContain("result.records.filter((customer) => customer.status === 'ACTIVE')")
expect(source).toContain('while (currentPage <= pages)')
expect(source).toContain("item.tenantName")
expect(source).not.toContain('item.tenantCode')
})
@@ -39,7 +39,7 @@ let detailRequestId = 0
let disposed = false
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.legalName?.trim() || item.tenantName, value: String(item.id) })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'orderId', type: 'select', label: '订单 / 入库单', width: 280, placeholder: filters.value.customerId ? '请选择该客户的订单' : '请选择订单或入库单', options: orders.value.map((item) => ({ label: `${item.orderNo || item.orderId} / ${item.inboundCode || item.inboundId || '未入库'}`, value: String(item.orderId), disabled: item.inboundId == null })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'containerBookingId', type: 'select', label: '柜次', width: 240, placeholder: '请选择柜号或集装箱号', options: containers.value.map((item) => ({ label: `${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } },
])
@@ -115,12 +115,12 @@ async function loadOptions(customerId = filters.value.customerId) {
optionsLoading.value = true
optionsError.value = ''
const results = await Promise.allSettled([
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
loadActiveCustomers(),
listPlatformUnifiedOrders({ customerId: customerId || undefined, page: 1, size: 200 }),
listContainers({ page: 1, size: 200 }),
])
if (disposed || requestId !== optionRequestId) return
if (results[0].status === 'fulfilled') customers.value = results[0].value.records.filter((customer) => customer.status === 'ACTIVE')
if (results[0].status === 'fulfilled') customers.value = results[0].value
if (results[1].status === 'fulfilled') orders.value = results[1].value.records
if (results[2].status === 'fulfilled') containers.value = results[2].value.records
const failed = ['客户', '订单', '柜次'].filter((_, index) => results[index]?.status === 'rejected')
@@ -128,6 +128,19 @@ async function loadOptions(customerId = filters.value.customerId) {
optionsLoading.value = false
}
async function loadActiveCustomers() {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
records.push(...result.records.filter((customer) => customer.status === 'ACTIVE'))
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
return records
}
watch(() => filters.value.customerId, (customerId, previous) => {
if (customerId === previous) return
filters.value = { ...filters.value, orderId: '' }
@@ -26,10 +26,11 @@ describe('PortSchedulesView', () => {
it('selects real customers and customer-scoped ocean bookings', () => {
expect(source).toContain("from '@/modules/iam'")
expect(source).toContain("from '@/modules/ocean-bookings'")
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })")
expect(source).toContain('while (currentPage <= pages)')
expect(source).toContain('listOceanBookings({ page: 1, size: 200, customerId })')
expect(source).toContain("customerId: String(query.customerId || '') || undefined")
expect(source).toContain('customer.legalName?.trim() || customer.tenantName')
expect(source).toContain('function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }')
expect(source).not.toContain('customer.tenantCode')
expect(source).toContain("form.oceanBookingId = ''")
expect(source).not.toContain('label="客户 ID"')
@@ -41,9 +41,9 @@ const selectableCustomers = computed(() => customers.value.filter((customer) =>
const canCreate = computed(() => access.can('platform:firstleg:port-schedule:create'))
const dialogTitle = computed(() => dialogMode.value === 'create' ? '新增船期' : dialogMode.value === 'edit' ? '编辑船期' : '查看船期')
const dialogReadonly = computed(() => dialogMode.value === 'view')
function customerLabel(customer: PlatformTenantResource) { return customer.legalName?.trim() || customer.tenantName }
function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }
function bookingLabel(booking: OceanBookingResource) { return [booking.bookingNo || '待生成订舱号', [booking.vesselName, booking.voyageNo].filter(Boolean).join(' / '), [booking.departurePortCode, booking.destinationPortCode].filter(Boolean).join(' → ')].filter(Boolean).join(' · ') }
async function loadCustomers() { const serial = ++customerRequestSerial; customersLoading.value = true; customersError.value = null; try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); if (serial === customerRequestSerial) customers.value = result.records } catch (cause) { if (serial === customerRequestSerial) customersError.value = getApiErrorDetails(cause, '客户候选加载失败') } finally { if (serial === customerRequestSerial) customersLoading.value = false } }
async function loadCustomers() { const serial = ++customerRequestSerial; customersLoading.value = true; customersError.value = null; try { const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1; do { const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages); if (serial === customerRequestSerial) customers.value = records } catch (cause) { if (serial === customerRequestSerial) customersError.value = getApiErrorDetails(cause, '客户候选加载失败') } finally { if (serial === customerRequestSerial) customersLoading.value = false } }
async function loadBookings(customerId = String(form.customerId || '')) { const serial = ++bookingRequestSerial; bookings.value = []; bookingsError.value = null; if (!customerId) { bookingsLoading.value = false; return } bookingsLoading.value = true; try { const result = await listOceanBookings({ page: 1, size: 200, customerId }); if (serial === bookingRequestSerial && customerId === String(form.customerId || '')) bookings.value = result.records } catch (cause) { if (serial === bookingRequestSerial && customerId === String(form.customerId || '')) bookingsError.value = getApiErrorDetails(cause, '订舱候选加载失败') } finally { if (serial === bookingRequestSerial) bookingsLoading.value = false } }
function handleCustomerChange(value: string) { form.customerId = String(value || ''); form.oceanBookingId = ''; void formRef.value?.clearValidate('oceanBookingId'); void loadBookings(form.customerId) }
async function load() { loading.value = true; error.value = null; try { const result = await listPortSchedules({ page: page.value, size: pageSize.value, customerId: String(query.customerId || '') || undefined, keyword: String(query.keyword || '') || undefined, expired: query.expired === '' ? undefined : query.expired === 'true' }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, FileText, Pencil } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -17,13 +17,31 @@ const route = useRoute(); const router = useRouter(); const access = useAccessPr
const canView = computed(() => access.can('platform:supplier:list'))
const supplierId = computed(() => typeof route.params.id === 'string' ? route.params.id : '')
const supplier = ref<Supplier | null>(null); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const licenseOpen = ref(false)
let loadSequence = 0
const items = computed(() => supplier.value ? [
['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['国内/国外', supplier.value.scope ? scopeLabels[supplier.value.scope] : '—'], ['地区', supplier.value.region],
['法人代表', supplier.value.legalRepresentative], ['联系人', supplier.value.contactName], ['联系电话', supplier.value.phone], ['电子邮箱', supplier.value.email || '—'],
['营业执照号', supplier.value.businessLicenseNo || '—'], ['结算周期', settlementLabels[supplier.value.settlementPeriod]], ['公司地址', supplier.value.address || '—'], ['备注', supplier.value.remark || '—'],
] : [])
async function load() { if (!canView.value) return; loading.value = true; error.value = null; try { supplier.value = await getSupplier(supplierId.value) } catch (cause) { error.value = getApiErrorDetails(cause, '供应商详情加载失败') } finally { loading.value = false } }
onMounted(load)
async function load() {
const requestedId = supplierId.value
const sequence = ++loadSequence
if (!canView.value || !requestedId) { supplier.value = null; loading.value = false; error.value = null; return }
supplier.value = null
loading.value = true
error.value = null
try {
const result = await getSupplier(requestedId)
if (sequence !== loadSequence || requestedId !== supplierId.value) return
supplier.value = result
} catch (cause) {
if (sequence !== loadSequence || requestedId !== supplierId.value) return
error.value = getApiErrorDetails(cause, '供应商详情加载失败')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
watch(supplierId, () => { void load() }, { immediate: true })
</script>
<template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowLeft, FileText, Save } from '@lucide/vue'
@@ -40,14 +40,25 @@ function optional(value: string | null) { return value?.trim() || null }
function payload(): SaveSupplierRequest {
return { ...form, name: form.name.trim(), scope: form.category === 'BOOKING' ? form.scope : null, region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) }
}
let loadSequence = 0
function resetForm() { Object.assign(form, initial()) }
async function load() {
if (!editing.value || !access.can(requiredPermission.value)) { captureBaseline(); return }
loading.value = true; loadError.value = null
const requestedId = supplierId.value
const sequence = ++loadSequence
resetForm()
loadError.value = null
submitError.value = null
saving.value = false
if (!requestedId || !access.can(requiredPermission.value)) { loading.value = false; captureBaseline(); return }
loading.value = true
try {
const data = await getSupplier(supplierId.value)
const data = await getSupplier(requestedId)
if (sequence !== loadSequence || requestedId !== supplierId.value) return
Object.assign(form, { name: data.name, category: data.category, scope: data.scope, region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark })
captureBaseline()
} catch (error) { loadError.value = getApiErrorDetails(error, '供应商详情加载失败') } finally { loading.value = false }
} catch (error) {
if (sequence === loadSequence && requestedId === supplierId.value) loadError.value = getApiErrorDetails(error, '供应商详情加载失败')
} finally { if (sequence === loadSequence) loading.value = false }
}
async function save() {
if (!access.can(requiredPermission.value)) return
@@ -63,7 +74,7 @@ async function save() {
}
watch(() => form.category, (category) => { if (category === 'BOOKING' && !form.scope) form.scope = 'DOMESTIC'; if (category !== 'BOOKING') form.scope = null })
onMounted(load)
watch(supplierId, () => { void load() }, { immediate: true })
</script>
<template>
@@ -109,5 +120,7 @@ onMounted(load)
.supplier-form-panel { padding: 22px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }.span-2 { grid-column: 1 / -1; }
.form-grid :deep(.el-select) { width: 100%; }.file-placeholder { display: flex; width: 100%; min-height: 96px; align-items: center; justify-content: center; flex-direction: column; gap: 6px; padding: 16px; border: 1px dashed #d9d9d9; border-radius: 6px; background: #fafafa; color: var(--cc-muted); text-align: center; }.file-placeholder small { max-width: 420px; line-height: 1.5; }
.form-actions { display: flex; justify-content: center; gap: 14px; padding: 18px 0 0; border-top: 1px solid var(--cc-border); }.form-actions :deep(.el-button) { min-width: 112px; margin-left: 0; }
@media (min-width: 1600px) { .form-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (min-width: 1280px) and (max-width: 1599px) { .form-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 720px) { .supplier-form-panel { padding: 16px; } .form-grid { grid-template-columns: 1fr; }.span-2 { grid-column: auto; }.form-page-header { align-items: stretch; flex-direction: column; }.form-actions { flex-direction: column; }.form-actions :deep(.el-button) { width: 100%; min-height: 44px; } }
</style>
@@ -0,0 +1,87 @@
import { flushPromises, mount } from '@vue/test-utils'
import { reactive, nextTick } from 'vue'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import type { Component } from 'vue'
import type { Supplier } from '@/modules/suppliers/types'
const mocks = vi.hoisted(() => ({
route: null as null | { params: { id: string } },
push: vi.fn(), getSupplier: vi.fn(), updateSupplier: vi.fn(),
can: vi.fn((permission: string) => permission === 'platform:supplier:list' || permission === 'platform:supplier:edit' || permission === 'platform:supplier:create'),
}))
const routeState = reactive({ params: { id: 'A' } })
mocks.route = routeState
let SupplierFormView: Component
let SupplierDetailView: Component
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: mocks.can }) }))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
vi.mock('@/modules/suppliers/api/platformSuppliers', () => ({ getSupplier: mocks.getSupplier, updateSupplier: mocks.updateSupplier, createSupplier: vi.fn() }))
function supplier(id: string, name: string): Supplier {
return { id, tenantId: '0', name, category: 'BOOKING', scope: 'DOMESTIC', region: '宁波', legalRepresentative: '法人', contactName: '联系人', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((next) => { resolve = next })
return { promise, resolve }
}
const stubs = {
AdminPage: { template: '<main><slot /></main>' },
DetailPageLayout: { template: '<article><slot name="header"/><slot /></article>' },
FormSection: { template: '<section><slot /></section>' },
ApiErrorMeta: { template: '<span />' },
LoadErrorState: { template: '<div class="load-error"><slot name="details" /></div>' },
SupplierLicenseDialog: { template: '<div />' },
}
describe('supplier route reuse', () => {
beforeAll(async () => {
SupplierFormView = (await import('@/modules/suppliers/pages/SupplierFormView.vue')).default
SupplierDetailView = (await import('@/modules/suppliers/pages/SupplierDetailView.vue')).default
})
beforeEach(() => {
routeState.params.id = 'A'
mocks.push.mockReset(); mocks.getSupplier.mockReset(); mocks.updateSupplier.mockReset().mockResolvedValue(null)
mocks.can.mockClear()
})
it('reloads form A to B, ignores late A, and submits B', async () => {
const requestA = deferred<Supplier>(); const requestB = deferred<Supplier>()
mocks.getSupplier.mockImplementation((id: string) => id === 'A' ? requestA.promise : requestB.promise)
const wrapper = mount(SupplierFormView, { global: { plugins: [ElementPlus], stubs } })
expect(mocks.getSupplier).toHaveBeenCalledWith('A')
routeState.params.id = 'B'
await nextTick()
expect(mocks.getSupplier).toHaveBeenLastCalledWith('B')
requestB.resolve(supplier('B', '供应商B')); await flushPromises()
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('供应商B')
requestA.resolve(supplier('A', '供应商A')); await flushPromises()
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('供应商B')
expect((wrapper.find('input').element as HTMLInputElement).value).not.toBe('供应商A')
const saveButton = wrapper.findAll('button').find((button) => button.text().includes('保存'))
expect(saveButton).toBeDefined()
await saveButton!.trigger('click')
await flushPromises()
expect(mocks.updateSupplier).toHaveBeenCalledWith('B', expect.objectContaining({ name: '供应商B' }))
expect(mocks.push).toHaveBeenCalledWith({ name: 'supplier-list' })
})
it('reloads detail A to B when the route is reused', async () => {
const requestA = deferred<Supplier>(); const requestB = deferred<Supplier>()
mocks.getSupplier.mockImplementation((id: string) => id === 'A' ? requestA.promise : requestB.promise)
const wrapper = mount(SupplierDetailView, { global: { plugins: [ElementPlus], stubs } })
routeState.params.id = 'B'; await nextTick()
requestB.resolve(supplier('B', '供应商B')); await flushPromises()
expect(wrapper.text()).toContain('供应商B')
requestA.resolve(supplier('A', '供应商A')); await flushPromises()
expect(wrapper.text()).toContain('供应商B')
expect(wrapper.text()).not.toContain('供应商A')
})
})
@@ -14,6 +14,14 @@ describe('supplier formal pages', () => {
expect(source).toContain("access.can('platform:supplier:edit')")
expect(source).toContain("access.can('platform:supplier:status')")
expect(source).toContain("access.can('platform:supplier:list')")
expect(source).toContain('<el-button size="small" plain type="primary" @click="router.push')
})
it('matches the prototype statistics, category tabs and search sequence', () => {
expect(source).toContain('class="supplier-list-top"')
expect(source).toContain('class="supplier-query-panel"')
expect(source).toContain('class="supplier-query-toolbar"')
expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px')
})
it('uses independent routes for list, detail, create and edit', () => {
@@ -34,4 +42,11 @@ describe('supplier formal pages', () => {
expect(formSource).toContain('<ApiErrorMeta')
expect(detailSource).toContain('<ApiErrorMeta')
})
it('reloads detail data when the reused route id changes and protects the newer request', () => {
expect(detailSource).toContain("watch(supplierId, () => { void load() }, { immediate: true })")
expect(detailSource).toContain('const requestedId = supplierId.value')
expect(detailSource).toContain('if (sequence !== loadSequence || requestedId !== supplierId.value) return')
expect(detailSource).toContain("params: { id: supplier.id }")
})
})
+20 -23
View File
@@ -115,15 +115,20 @@ onMounted(load)
<template>
<AdminPage>
<ListPageLayout aria-label="供应商列表">
<template #metrics>
<div class="supplier-metrics" aria-label="供应商分类统计">
<button v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)">
<span>{{ item.label }}</span><strong>{{ item.value }}<small>家</small></strong>
</button>
</div>
</template>
<template #toolbar>
<QueryToolbar v-model="filters" ariaLabel="供应商筛选" :fields="queryFields" @search="search" />
<div class="supplier-list-top">
<div class="supplier-metrics" aria-label="供应商分类统计">
<button v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)">
<span>{{ item.label }}</span><strong>{{ item.value }}<small>家</small></strong>
</button>
</div>
<div class="supplier-query-panel">
<nav class="supplier-tabs" aria-label="供应商分类">
<button v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)">{{ categoryLabels[item.category] }}</button>
</nav>
<QueryToolbar v-model="filters" class="supplier-query-toolbar" ariaLabel="供应商筛选" :fields="queryFields" @search="search" />
</div>
</div>
</template>
<template #state>
<LoadErrorState v-if="!canView" title="暂无供应商查看权限" message="请联系管理员开通供应商查看权限。" :show-retry="false" />
@@ -132,24 +137,21 @@ onMounted(load)
</LoadErrorState>
</template>
<section v-if="canView" class="surface-panel table-panel">
<nav class="supplier-tabs" aria-label="供应商分类">
<button v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)">{{ categoryLabels[item.category] }}</button>
</nav>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" empty-text="暂无供应商数据" preference-key="supplier-list">
<template #toolbar-actions>
<el-button v-if="access.can('platform:supplier:create')" type="primary" @click="router.push({ name: 'supplier-create' })"><Plus :size="16" />新增供应商</el-button>
</template>
<template #actions="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="router.push({ name: 'supplier-detail', params: { id: row.id } })"><Eye :size="15" />详情</el-button>
<el-button v-if="access.can('platform:supplier:edit')" link type="primary" @click="router.push({ name: 'supplier-edit', params: { id: row.id } })"><Pencil :size="15" />编辑</el-button>
<el-button v-if="access.can('platform:supplier:status')" link :type="row.status === 'ACTIVE' ? 'danger' : 'success'" :loading="actionId === row.id" @click="toggleStatus(supplierRow(row))"><Power :size="15" />{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
<el-button size="small" plain type="primary" @click="router.push({ name: 'supplier-detail', params: { id: row.id } })"><Eye :size="15" />详情</el-button>
<el-button v-if="access.can('platform:supplier:edit')" size="small" plain type="primary" @click="router.push({ name: 'supplier-edit', params: { id: row.id } })"><Pencil :size="15" />编辑</el-button>
<el-button v-if="access.can('platform:supplier:status')" size="small" plain :type="row.status === 'ACTIVE' ? 'danger' : 'success'" :loading="actionId === row.id" @click="toggleStatus(supplierRow(row))"><Power :size="15" />{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
</template>
<template #name="{ row }"><strong class="supplier-name">{{ row.name }}</strong></template>
<template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template>
<template #scope="{ row }">{{ row.scope ? scopeLabels[row.scope as SupplierScope] : '—' }}</template>
<template #license="{ row }"><el-button link type="primary" @click="showLicense(supplierRow(row))"><FileText :size="15" />查看</el-button></template>
<template #license="{ row }"><el-button size="small" plain type="primary" @click="showLicense(supplierRow(row))"><FileText :size="15" />查看</el-button></template>
<template #status="{ row }"><StatusBadge :status="statusLabels[row.status as SupplierStatus]" /></template>
<template #settlement="{ row }">{{ settlementLabels[supplierRow(row).settlementPeriod] }}</template>
</DataTable>
@@ -160,13 +162,8 @@ onMounted(load)
</template>
<style scoped>
.supplier-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.supplier-metrics button { min-width: 0; padding: 18px 20px; border: 0; border-right: 1px solid var(--cc-border); background: #fff; color: var(--cc-text); text-align: left; cursor: pointer; }
.supplier-metrics button:last-child { border-right: 0; }.supplier-metrics button.active { box-shadow: inset 0 3px 0 var(--el-color-primary); background: #f8fbff; }
.supplier-metrics span { display: block; margin-bottom: 7px; color: var(--cc-muted); font-size: 12px; }.supplier-metrics strong { font-size: 25px; }.supplier-metrics small { margin-left: 3px; color: var(--cc-muted); font-size: 12px; }
.supplier-tabs { display: flex; gap: 6px; padding: 14px 14px 0; overflow-x: auto; border-bottom: 1px solid var(--cc-border); }
.supplier-tabs button { min-width: 76px; padding: 7px 20px; border: 0; border-radius: 6px 6px 0 0; background: #f5f5f5; color: #555; cursor: pointer; }.supplier-tabs button.active { background: var(--el-color-primary); color: #fff; font-weight: 650; }
.supplier-list-top { display: grid; gap: 16px; }.supplier-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }.supplier-metrics button { min-width: 0; padding: 12px 14px; border: 1px solid #e7edf5; border-radius: 8px; background: #f8fafc; color: var(--cc-text); text-align: left; cursor: pointer; }.supplier-metrics button.active { border-color: var(--cc-primary); background: #f7f8ff; box-shadow: inset 0 3px 0 var(--cc-primary); }.supplier-metrics span { display: block; margin-bottom: 6px; color: #64748b; font-size: 12px; }.supplier-metrics strong { color: #1e293b; font-size: 20px; }.supplier-metrics small { margin-left: 3px; color: #64748b; font-size: 12px; }.supplier-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.supplier-query-toolbar { border: 0; }.supplier-tabs { display: flex; gap: 6px; padding: 10px 14px; overflow-x: auto; border-bottom: 1px solid var(--cc-border); background: #fafbfc; }.supplier-tabs button { min-width: 76px; padding: 7px 20px; border: 0; border-radius: 6px; background: #f1f3f5; color: #555; cursor: pointer; }.supplier-tabs button.active { background: var(--cc-primary); color: #fff; font-weight: 650; }
.row-actions { display: flex; align-items: center; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 8px; }.supplier-name { overflow-wrap: anywhere; }
@media (max-width: 720px) { .supplier-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .supplier-metrics button:nth-child(2) { border-right: 0; } .supplier-metrics button:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); } }
@media (max-width: 380px) { .supplier-metrics { grid-template-columns: 1fr; } .supplier-metrics button { border-right: 0; border-bottom: 1px solid var(--cc-border); } .supplier-metrics button:last-child { border-bottom: 0; } }
@media (max-width: 720px) { .supplier-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 380px) { .supplier-metrics { grid-template-columns: 1fr; } }
</style>
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment } from './platformTrucking'
import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, uploadTruckAppointmentAttachment } from './platformTrucking'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data }))
@@ -40,4 +40,14 @@ describe('truck appointment API', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/77/attachments', { fileId: '9007199254740993', fileName: '派车单.pdf' })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/truck-appointments/77/attachments/1')
})
it('uploads an attachment through the frozen multipart endpoint', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { id: '1', fileId: '2', fileName: '派车单.pdf' } })
const file = new File(['dispatch'], '派车单.pdf', { type: 'application/pdf' })
await uploadTruckAppointmentAttachment('77', file, 'DISPATCH_ORDER')
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/77/attachments/upload', expect.any(FormData), { headers: { 'Content-Type': 'multipart/form-data' } })
const form = vi.mocked(http.post).mock.calls[0]?.[1] as FormData
expect(form.get('file')).toBe(file)
expect(form.get('attachmentType')).toBe('DISPATCH_ORDER')
})
})
@@ -71,6 +71,14 @@ export async function referenceTruckAppointmentAttachment(id: string | number, p
return unwrapApiResponse(response)
}
export async function uploadTruckAppointmentAttachment(id: string | number, file: File, attachmentType?: string | null) {
const form = new FormData()
form.append('file', file)
if (attachmentType) form.append('attachmentType', attachmentType)
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments/upload`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
return unwrapApiResponse(response)
}
export async function deleteTruckAppointmentAttachment(id: string | number, attachmentId: string | number) {
await http.delete<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`${basePath}/${id}/attachments/${attachmentId}`)
}
@@ -14,7 +14,7 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).not.toContain('label="承运商 ID"')
expect(source).not.toContain('label="客户关联 ID"')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain('customer.legalName?.trim() || customer.tenantName')
expect(source).toContain('function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }')
expect(source).not.toContain('customer.tenantCode')
})
@@ -51,7 +51,7 @@ const selectableWarehouses = computed(() => warehouses.value.filter((item) => (
const selectableCarriers = computed(() => carriers.value.filter((item) => (
item.status === 'ACTIVE' || String(item.id) === String(draft.carrierId)
)))
function customerLabel(customer: PlatformTenantResource) { return customer.legalName?.trim() || customer.tenantName }
function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }
function selectCustomer(customerId: string) { const customer = customers.value.find((item) => String(item.id) === customerId); draft.customerId = customerId || null; draft.customerName = customer ? customerLabel(customer) : null }
function selectCarrier(carrierId: string) { draft.carrierId = carrierId || null; draft.carrierName = carriers.value.find((item) => String(item.id) === carrierId)?.partnerName || null }
function selectContainer(containerId: string) { const item = containers.value.find((entry) => entry.id === containerId); draft.containerBookingId = containerId || null; if (item) { draft.loadingPlanId = item.planId; draft.warehouseId = item.warehouseId } }
@@ -12,10 +12,13 @@ describe('TruckAttachmentDialog interaction governance', () => {
expect(source).not.toContain('item.objectKey')
})
it('hides the unavailable manual reference flow but keeps deletion', () => {
it('provides scoped upload and deletion without exposing manual file ids', () => {
expect(source).not.toContain('referenceTruckAppointmentAttachment')
expect(source).not.toContain('输入文件服务 ID')
expect(source).toContain('uploadTruckAppointmentAttachment')
expect(source).toContain('上传并关联')
expect(source).toContain('deleteTruckAppointmentAttachment')
expect(source).toContain("confirmButtonText: '确认', cancelButtonText: '取消'")
expect(source).toContain('<Trash2 :size="15" />删除')
expect(source).toContain('size="small" type="danger" plain')
expect(source).not.toContain(' text type="danger"')
@@ -1,18 +1,23 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Trash2 } from '@lucide/vue'
import { deleteTruckAppointmentAttachment } from '../api/platformTrucking'
import { FileUp, Trash2 } from '@lucide/vue'
import { deleteTruckAppointmentAttachment, uploadTruckAppointmentAttachment } from '../api/platformTrucking'
import type { TruckAttachmentReference } from '../types'
const props = defineProps<{ modelValue: boolean; appointmentId?: string; attachments: TruckAttachmentReference[]; canManage?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [boolean]; changed: [] }>()
const busy = ref(false)
const selectedFile = ref<File | null>(null)
const selectedType = ref('OTHER')
const attachmentTypeLabels: Record<string, string> = { DISPATCH_ORDER: '派车单', DRIVER_DOCUMENT: '司机证件', VEHICLE_DOCUMENT: '车辆证件', OTHER: '其他附件' }
function attachmentTypeLabel(value: string | null | undefined) { return value ? attachmentTypeLabels[value] || '业务附件' : '业务附件' }
async function remove(item: TruckAttachmentReference) { if (!props.appointmentId || !item.id) return; await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { type: 'warning' }); busy.value = true; try { await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch { ElMessage.error('删除失败') } finally { busy.value = false } }
function selectFile(upload: { raw?: File }) { selectedFile.value = upload.raw || null }
async function upload() { if (!props.appointmentId || !selectedFile.value) return; busy.value = true; try { await uploadTruckAppointmentAttachment(props.appointmentId, selectedFile.value, selectedType.value); selectedFile.value = null; emit('changed'); ElMessage.success('附件上传成功') } catch { ElMessage.error('上传失败') } finally { busy.value = false } }
async function remove(item: TruckAttachmentReference) { if (!props.appointmentId || !item.id) return; await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); busy.value = true; try { await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch { ElMessage.error('删除失败') } finally { busy.value = false } }
</script>
<template>
<el-dialog :model-value="props.modelValue" title="预约附件" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
<div v-if="props.canManage" class="upload-panel"><el-select v-model="selectedType" aria-label="附件类型" style="width:160px"><el-option v-for="(label, type) in attachmentTypeLabels" :key="type" :label="label" :value="type" /></el-select><el-upload :auto-upload="false" :limit="1" :show-file-list="true" :on-change="selectFile" :on-remove="() => { selectedFile = null }"><el-button><FileUp :size="15" />选择附件</el-button></el-upload><el-button type="primary" :loading="busy" :disabled="!selectedFile" @click="upload">上传并关联</el-button></div>
<el-empty v-if="attachments.length === 0" description="暂无预约附件" :image-size="72" />
<div v-for="item in attachments" :key="item.fileId" class="attachment-row">
<div><strong>{{ item.fileName || '未命名文件' }}</strong><small>{{ attachmentTypeLabel(item.attachmentType) }}{{ item.uploadedAt ? ` · ${item.uploadedAt}` : '' }}</small></div>
@@ -22,6 +27,7 @@ async function remove(item: TruckAttachmentReference) { if (!props.appointmentId
</el-dialog>
</template>
<style scoped>
.upload-panel { display:flex; align-items:center; flex-wrap:wrap; gap:8px; margin-bottom:16px; }
.attachment-row { display:flex; justify-content:space-between; gap:12px; margin:14px 0; padding:12px; border:1px solid var(--el-border-color-lighter); border-radius:6px; overflow-wrap:anywhere; }.attachment-row small { display:block; margin-top:4px; color:var(--el-text-color-secondary); }
@media (max-width: 560px) { .attachment-row { flex-direction:column; } }
</style>
@@ -125,7 +125,7 @@ describe('TruckAppointmentsView', () => {
it('filters with an active name-only customer selector and stable customerId', () => {
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("customer.status === 'ACTIVE'")
expect(source).toContain("customer.legalName?.trim() || customer.tenantName")
expect(source).toContain("label: customer.tenantName")
expect(source).toContain("customerId: String(model.customerId || '')")
expect(source).not.toContain('customer.tenantCode')
})
@@ -20,7 +20,7 @@ const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false)
const filters = ref({ customerId: '', keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '', containerType: '' }); const applied = ref({ ...filters.value })
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.legalName?.trim() || customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '派车单号、车牌号、运输公司' },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) },
{ key: 'containerType', type: 'select', label: '柜型', placeholder: '全部柜型', options: [
@@ -257,6 +257,25 @@ describe('WarehousesView', () => {
})
})
it('submits the current keyword once when Enter is pressed in the text filter', async () => {
const wrapper = await mountView()
const keywordInput = wrapper.find('input[placeholder="仓库名称、编码、城市或地址"]')
await keywordInput.setValue('AKR1')
await keywordInput.trigger('keyup', { key: 'Enter' })
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenCalledTimes(2)
expect(listPlatformWarehouses).toHaveBeenLastCalledWith({
page: 1,
size: 20,
keyword: 'AKR1',
warehouseType: undefined,
status: undefined,
})
expect(wrapper.findComponent(QueryToolbar).props('loading')).toBe(false)
})
it('shows a recoverable API error without falling back to demo warehouses', async () => {
vi.mocked(listPlatformWarehouses).mockRejectedValueOnce(new Error('Gateway unavailable'))
const wrapper = await mountView()
@@ -672,7 +672,7 @@ onMounted(() => void loadWarehouses())
<AdminPage as="div">
<ListPageLayout aria-label="仓库列表页面">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" preference-key="warehouses-query" @search="queryWarehouses" />
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" :loading="loading" preference-key="warehouses-query" @search="queryWarehouses" />
</template>
<template v-if="loadError" #state>
<LoadErrorState
+53 -3
View File
@@ -10,9 +10,13 @@ import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayo
const FieldStub = defineComponent({
inheritAttrs: false,
props: { placeholder: String },
template: '<input :data-placeholder="placeholder" />',
emits: ['keyup'],
template: '<input :data-placeholder="placeholder" @keyup="$emit(\'keyup\', $event)" />',
})
const ButtonStub = defineComponent({
props: { disabled: Boolean, loading: Boolean },
template: '<button type="button" :disabled="disabled" :data-loading="String(loading)"><slot /></button>',
})
const ButtonStub = defineComponent({ template: '<button type="button"><slot /></button>' })
const PopoverStub = defineComponent({ template: '<div><slot name="reference" /><slot /></div>' })
const fields: QueryField[] = [
@@ -21,7 +25,7 @@ const fields: QueryField[] = [
{ key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '创建日期' },
]
function mountToolbar() {
function mountToolbar(props: Record<string, unknown> = {}) {
return shallowMount(QueryToolbar, {
props: {
modelValue: { keyword: '', status: '', createdRange: [] },
@@ -29,6 +33,7 @@ function mountToolbar() {
ariaLabel: '订单筛选',
fields,
preferenceKey: 'orders-query',
...props,
},
global: {
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
@@ -70,4 +75,49 @@ describe('QueryToolbar layout preferences', () => {
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
expect(source).toContain('.query-toolbar__tail { display: flex; align-items: flex-end; width: auto; flex: 0 0 auto; gap: 10px; }')
})
it('submits the current filters once when Enter is released in a text input', async () => {
const wrapper = mountToolbar({ modelValue: { keyword: 'AKR1', status: 'ACTIVE', createdRange: [] } })
const textInput = wrapper.findAll('input')[0]
await textInput.trigger('keyup', { key: 'Enter' })
expect(wrapper.emitted('search')).toEqual([[
{ keyword: 'AKR1', status: 'ACTIVE', createdRange: [] },
]])
})
it('does not submit Enter from select or date controls, composition, or repeated key events', async () => {
const wrapper = mountToolbar()
const controls = wrapper.findAll('input')
await controls[1].trigger('keyup', { key: 'Enter' })
await controls[2].trigger('keyup', { key: 'Enter' })
await controls[0].trigger('keyup', { key: 'Enter', isComposing: true })
await controls[0].trigger('keyup', { key: 'Enter', repeat: true })
expect(wrapper.emitted('search')).toBeUndefined()
})
it('resets all filters once and blocks search and reset while loading', async () => {
const wrapper = mountToolbar({
modelValue: { keyword: 'AKR1', status: 'ACTIVE', createdRange: ['2026-08-01', '2026-08-14'] },
})
;(wrapper.vm as unknown as { reset: () => void }).reset()
expect(wrapper.emitted('reset')).toEqual([[
{ keyword: '', status: '', createdRange: [] },
]])
expect(wrapper.emitted('search')).toEqual([[
{ keyword: '', status: '', createdRange: [] },
]])
await wrapper.setProps({ loading: true })
;(wrapper.vm as unknown as { search: () => void; reset: () => void }).search()
;(wrapper.vm as unknown as { search: () => void; reset: () => void }).reset()
expect(wrapper.emitted('search')).toHaveLength(1)
expect(wrapper.emitted('reset')).toHaveLength(1)
expect(wrapper.findAllComponents(ButtonStub)[0].props('loading')).toBe(true)
expect(wrapper.findAllComponents(ButtonStub)[1].props('disabled')).toBe(true)
})
})
+14 -4
View File
@@ -40,10 +40,12 @@ const props = withDefaults(defineProps<{
searchText?: string
resetText?: string
searchOnReset?: boolean
loading?: boolean
}>(), {
searchText: '查询',
resetText: '重置',
searchOnReset: true,
loading: false,
})
const model = defineModel<QueryModel>({ required: true })
@@ -140,10 +142,12 @@ function resetValue(field: QueryField): QueryValue {
}
function search() {
if (props.loading) return
emit('search', model.value)
}
function reset() {
if (props.loading) return
const next = { ...model.value }
for (const field of props.fields) next[field.key] = resetValue(field)
model.value = next
@@ -151,6 +155,11 @@ function reset() {
if (props.searchOnReset) emit('search', next)
}
function searchFromTextInput(event: KeyboardEvent) {
if (event.isComposing || event.repeat) return
search()
}
defineExpose({ search, reset })
</script>
@@ -175,6 +184,7 @@ defineExpose({ search, reset })
clearable
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
@keyup.enter.exact="searchFromTextInput"
>
<template #prefix><Search :size="16" /></template>
</el-input>
@@ -203,8 +213,8 @@ defineExpose({ search, reset })
/>
</div>
<div v-if="index === orderedFields.length - 1" class="query-toolbar__actions">
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<el-button type="primary" :loading="loading" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button :disabled="loading" @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<slot name="actions" />
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
<template #reference>
@@ -216,8 +226,8 @@ defineExpose({ search, reset })
</div>
</div>
<div v-if="orderedFields.length === 0" class="query-toolbar__actions">
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<el-button type="primary" :loading="loading" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button :disabled="loading" @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<slot name="actions" />
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
<template #reference>