优化
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user