优化
This commit is contained in:
@@ -79,6 +79,9 @@
|
||||
- 后端兼容字段 `tenantId` 仅留在 API 适配层和内部类型中,不在页面文案或交互中暴露;需要客户范围的写操作仍显式选择客户,长整型客户 ID 按字符串透传。
|
||||
|
||||
- 菜单隐藏和前端路由守卫只改善体验,不能替代服务端鉴权。新增操作必须使用稳定的权限编码,并与 API 权限保持一致。
|
||||
- 列表权限包含列表、详情和只读辅助数据访问;有正式列表页时,导航、列表路由和详情路由统一只使用 `*:list`。当前未上线,不兼容旧独立 `*:view`,不得通过 `permissionAnyOf` 或页面等价判断继续接受它;新增、编辑、删除、启停、审核、导出、上传、完成等操作仍分别校验独立权限。
|
||||
- 供应商菜单、列表和详情固定只使用 `platform:supplier:list`,`platform:supplier:view` 必须停用/移除。修改 `modules/suppliers/`、`core/navigation/adminNavigation.ts` 或根路由时,不得恢复或兼容 `platform:supplier:view`。
|
||||
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 属于共享装配文件;并行业务任务不得用局部模块结论覆盖菜单与权限总控已经确认的权限语义,发现重叠改动时保留并回传总控处理。
|
||||
- 认证统一经过 `src/core/auth/auth.ts` 和 `src/shared/api/http.ts`;不要在页面中复制令牌读写或手工拼装 `Authorization`。
|
||||
- 当前登录和部分数据仍可能是模拟实现。接入 `baiyue-firstleg` Gateway 真实接口时删除对应模拟路径,不允许让生产逻辑静默回退到假数据。
|
||||
- 删除、停用、改权限等高风险操作必须有明确确认、处理中、成功和失败反馈;失败时保留用户上下文,避免误以为已生效。
|
||||
|
||||
@@ -3,10 +3,10 @@ import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const pages = [
|
||||
['src/modules/container-loading/pages/ContainersView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无货柜"', 'statusText(row.planStatus)', 'platform:firstleg:container:view', '@retry="loadRows"'],
|
||||
['src/modules/container-loading/pages/ContainersView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无货柜"', 'statusText(row.planStatus)', 'platform:firstleg:container:list', '@retry="loadRows"'],
|
||||
['src/modules/loading-lists/components/LoadingListsView.vue', ':loading="loading"', 'LoadErrorState', ':empty-text="`暂无符合筛选条件的${title}`"', 'loadingListStatusLabel(row.status)', 'canView', '@retry="load"'],
|
||||
['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', 'platform:firstleg:ocean-booking:view', '@retry="load"'],
|
||||
['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', 'platform:firstleg:truck-appointment:view', '@retry="load"'],
|
||||
['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', '<Eye :size="15" />查看', '@retry="load"'],
|
||||
['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', '<Eye :size="15" />详情', '@retry="load"'],
|
||||
] as const
|
||||
|
||||
function source(path: string) {
|
||||
@@ -14,13 +14,13 @@ function source(path: string) {
|
||||
}
|
||||
|
||||
describe('domestic outbound list state contracts', () => {
|
||||
it.each(pages)('%s exposes loading, error, empty, translated status, view permission and retry states', (path, loading, error, empty, status, viewPermission, retry) => {
|
||||
it.each(pages)('%s exposes loading, error, empty, translated status, detail action and retry states', (path, loading, error, empty, status, detailAction, retry) => {
|
||||
const content = source(path)
|
||||
expect(content).toContain(loading)
|
||||
expect(content).toContain(error)
|
||||
expect(content).toContain(empty)
|
||||
expect(content).toContain(status)
|
||||
expect(content).toContain(viewPermission)
|
||||
expect(content).toContain(detailAction)
|
||||
expect(content).toContain(retry)
|
||||
})
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import { containerLoadingRoutes } from '@/modules/container-loading/routes'
|
||||
import { loadingListRoutes } from '@/modules/loading-lists/routes'
|
||||
import { financeRoutes } from '@/modules/finance/routes'
|
||||
import { notificationRoutes } from '@/modules/notifications/routes'
|
||||
import { domesticContainerRoutes } from '@/modules/domestic-containers/routes'
|
||||
import { supplierRoutes } from '@/modules/suppliers/routes'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -55,6 +57,8 @@ const router = createRouter({
|
||||
...loadingListRoutes,
|
||||
...financeRoutes,
|
||||
...notificationRoutes,
|
||||
...domesticContainerRoutes,
|
||||
...supplierRoutes,
|
||||
...customerRoutes,
|
||||
...warehouseRoutes,
|
||||
...unifiedOrderRoutes,
|
||||
|
||||
@@ -34,6 +34,7 @@ describe('adminNavigation', () => {
|
||||
'测量数据审核',
|
||||
'库位管理',
|
||||
'托盘管理',
|
||||
'整柜业务',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'service-center')).toBeUndefined()
|
||||
expect(adminNavigation.find((item) => item.key === 'supplier-center')?.children?.map((item) => item.title)).toEqual([
|
||||
@@ -117,16 +118,17 @@ describe('adminNavigation', () => {
|
||||
expect(paths).toContain('/system/permissions')
|
||||
expect(new Set(paths).size).toBe(paths.length)
|
||||
const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index)
|
||||
expect(new Set(duplicatedPermissions)).toEqual(new Set(['platform:finance:bill:view']))
|
||||
expect(new Set(duplicatedPermissions)).toEqual(new Set())
|
||||
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the five formal finance pages with their real backend permissions', () => {
|
||||
const filtered = filterAdminNavigation(adminNavigation, new Set([
|
||||
'platform:finance:account:view',
|
||||
'platform:finance:bill:view',
|
||||
'platform:finance:settlement-ar:view',
|
||||
'platform:finance:settlement-ap:view',
|
||||
'platform:finance:customer-account:list',
|
||||
'platform:finance:customer-arap:list',
|
||||
'platform:finance:customer-statement:list',
|
||||
'platform:finance:settlement-ar:list',
|
||||
'platform:finance:settlement-ap:list',
|
||||
]))
|
||||
|
||||
expect(getNavigationLeaves(filtered).map((item) => item.path).filter((path) => path.startsWith('/finance/'))).toEqual([
|
||||
@@ -187,6 +189,14 @@ describe('adminNavigation', () => {
|
||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
|
||||
})
|
||||
|
||||
it('shows the public supplier list only with the list permission', () => {
|
||||
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
|
||||
const missing = filterAdminNavigation(adminNavigation, new Set())
|
||||
|
||||
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
|
||||
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
|
||||
})
|
||||
|
||||
it('shows each formal container workflow entry only with its matching permission', () => {
|
||||
const filtered = filterAdminNavigation(adminNavigation, new Set([
|
||||
'platform:firstleg:container:list',
|
||||
|
||||
@@ -66,10 +66,11 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
icon: PrototypeDomesticWarehouseIcon,
|
||||
children: [
|
||||
{ key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] },
|
||||
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-dedup', hidden: true },
|
||||
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/prototype/domestic-warehouse/inbound-measure', hidden: true },
|
||||
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' },
|
||||
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' },
|
||||
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
|
||||
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
|
||||
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -89,7 +90,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
title: '供应商中心',
|
||||
icon: PrototypeSupplierIcon,
|
||||
children: [
|
||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/prototype/supplier-center/supplier-list', hidden: true },
|
||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
|
||||
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' },
|
||||
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' },
|
||||
],
|
||||
@@ -139,11 +140,11 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
title: '财务管理',
|
||||
icon: PrototypeFinanceIcon,
|
||||
children: [
|
||||
{ key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:account:view' },
|
||||
{ key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:bill:view' },
|
||||
{ key: 'finance-customer-statements', title: '客户对账单', icon: FileCheck2, path: '/finance/customer-statements', permission: 'platform:finance:bill:view' },
|
||||
{ key: 'finance-settlement-receivables', title: '结算单应收', icon: CircleDollarSign, path: '/finance/settlement-receivables', permission: 'platform:finance:settlement-ar:view' },
|
||||
{ key: 'finance-settlement-payables', title: '结算单应付', icon: Database, path: '/finance/settlement-payables', permission: 'platform:finance:settlement-ap:view' },
|
||||
{ key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list', permissionAnyOf: ['platform:finance:account:view'] },
|
||||
{ key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list', permissionAnyOf: ['platform:finance:bill:view'] },
|
||||
{ key: 'finance-customer-statements', title: '客户对账单', icon: FileCheck2, path: '/finance/customer-statements', permission: 'platform:finance:customer-statement:list', permissionAnyOf: ['platform:finance:bill:view'] },
|
||||
{ key: 'finance-settlement-receivables', title: '结算单应收', icon: CircleDollarSign, path: '/finance/settlement-receivables', permission: 'platform:finance:settlement-ar:list', permissionAnyOf: ['platform:finance:settlement-ar:view'] },
|
||||
{ key: 'finance-settlement-payables', title: '结算单应付', icon: Database, path: '/finance/settlement-payables', permission: 'platform:finance:settlement-ap:list', permissionAnyOf: ['platform:finance:settlement-ap:view'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getPlatformAuditLog, listPlatformAuditLogs } from './platformAudit'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('platform audit api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('queries the selected customer and preserves long ids as strings', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: '2082813911786024961', 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: 1, pages: 1 } })
|
||||
|
||||
const result = await listPlatformAuditLogs({ accountId: '8', page: 1, size: 20 })
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs', { params: { accountId: '8', page: 1, size: 20 } })
|
||||
expect(result.records[0].id).toBe('2082813911786024961')
|
||||
})
|
||||
|
||||
it('maps audit filters and omits empty query values', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
|
||||
|
||||
await listPlatformAuditLogs({
|
||||
accountId: '8', page: 1, size: 20, keyword: 'admin', success: false,
|
||||
occurredFrom: '2026-08-01T00:00:00.000Z', occurredTo: '2026-08-13T23:59:59.999Z',
|
||||
eventType: '', resourceType: undefined,
|
||||
})
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs', { params: {
|
||||
accountId: '8', page: 1, size: 20, keyword: 'admin', success: false,
|
||||
occurredFrom: '2026-08-01T00:00:00.000Z', occurredTo: '2026-08-13T23:59:59.999Z',
|
||||
} })
|
||||
})
|
||||
|
||||
it('loads one audit detail', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { 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', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{}', afterData: '{"name":"new"}' } })
|
||||
|
||||
const result = await getPlatformAuditLog('1')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs/1')
|
||||
expect(result.afterData).toBe('{"name":"new"}')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface PlatformAuditLogListItem {
|
||||
id: string
|
||||
eventId: string
|
||||
eventType: string
|
||||
resourceType: string
|
||||
resourceId: string | null
|
||||
description: string | null
|
||||
accountId: string
|
||||
operatorTenantId: string
|
||||
userId: string
|
||||
username: string | null
|
||||
appCode: string | null
|
||||
clientType: string | null
|
||||
requestId: string | null
|
||||
traceId: string | null
|
||||
serviceName: string | null
|
||||
success: boolean
|
||||
errorCode: string | null
|
||||
errorMessage: string | null
|
||||
occurredAt: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PlatformAuditLogDetail extends PlatformAuditLogListItem {
|
||||
httpMethod: string | null
|
||||
requestUri: string | null
|
||||
clientIp: string | null
|
||||
durationMs: number | null
|
||||
beforeData: string | null
|
||||
afterData: string | null
|
||||
}
|
||||
|
||||
export interface PlatformAuditLogPage {
|
||||
records: PlatformAuditLogListItem[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface PlatformAuditLogQuery {
|
||||
accountId: string
|
||||
page?: number
|
||||
size?: number
|
||||
operatorUserId?: string
|
||||
eventType?: string
|
||||
resourceType?: string
|
||||
resourceId?: string
|
||||
success?: boolean
|
||||
occurredFrom?: string
|
||||
occurredTo?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
type RawId = string | number | null
|
||||
type RawListItem = Omit<PlatformAuditLogListItem, 'id' | 'accountId' | 'operatorTenantId' | 'userId' | 'resourceId'> & {
|
||||
id: RawId
|
||||
accountId: RawId
|
||||
operatorTenantId: RawId
|
||||
userId: RawId
|
||||
resourceId: RawId
|
||||
}
|
||||
type RawDetail = Omit<PlatformAuditLogDetail, keyof PlatformAuditLogListItem> & RawListItem
|
||||
type RawPage = Omit<PlatformAuditLogPage, 'records' | 'page' | 'size' | 'total' | 'pages'> & {
|
||||
records?: RawListItem[]
|
||||
page: number | string
|
||||
size: number | string
|
||||
total: number | string
|
||||
pages?: number | string
|
||||
}
|
||||
|
||||
const basePath = '/platform/v1/audit/logs'
|
||||
const id = (value: RawId) => value == null ? '' : String(value)
|
||||
|
||||
function normalizeListItem(item: RawListItem): PlatformAuditLogListItem {
|
||||
return {
|
||||
...item,
|
||||
id: id(item.id),
|
||||
accountId: id(item.accountId),
|
||||
operatorTenantId: id(item.operatorTenantId),
|
||||
userId: id(item.userId),
|
||||
resourceId: item.resourceId == null ? null : String(item.resourceId),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPlatformAuditLogs(query: PlatformAuditLogQuery) {
|
||||
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== null && value !== undefined))
|
||||
const response = await http.get<ApiEnvelope<RawPage>, ApiEnvelope<RawPage>>(basePath, { params })
|
||||
const data = unwrapApiResponse(response)
|
||||
const size = Number(data.size || query.size || 20)
|
||||
const total = Number(data.total || 0)
|
||||
return {
|
||||
records: (data.records || []).map(normalizeListItem),
|
||||
page: Number(data.page || query.page || 1),
|
||||
size,
|
||||
total,
|
||||
pages: Number(data.pages ?? (total > 0 ? Math.ceil(total / size) : 0)),
|
||||
} satisfies PlatformAuditLogPage
|
||||
}
|
||||
|
||||
export async function getPlatformAuditLog(idValue: string) {
|
||||
const response = await http.get<ApiEnvelope<RawDetail>, ApiEnvelope<RawDetail>>(`${basePath}/${encodeURIComponent(idValue)}`)
|
||||
return normalizeListItem(unwrapApiResponse(response)) as PlatformAuditLogDetail
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { getPlatformAuditLog, listPlatformAuditLogs } from '../api/platformAudit'
|
||||
import CustomerAuditLogsPanel from './CustomerAuditLogsPanel.vue'
|
||||
|
||||
vi.mock('../api/platformAudit', () => ({ getPlatformAuditLog: vi.fn(), listPlatformAuditLogs: vi.fn() }))
|
||||
|
||||
const row = { id: '1', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', resourceId: '8', description: '编辑客户', accountId: '8', operatorTenantId: '0', userId: '9', username: 'platform_admin', appCode: 'ADMIN', clientType: 'WEB', requestId: 'req', traceId: 'trace', serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }
|
||||
|
||||
function mountPanel(permissions: string[]) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(CustomerAuditLogsPanel, { props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [pinia, ElementPlus] } })
|
||||
}
|
||||
|
||||
describe('CustomerAuditLogsPanel', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
|
||||
|
||||
it('queries logs with the current customer id', async () => {
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '8', page: 1, size: 20 })
|
||||
expect(wrapper.text()).toContain('编辑客户')
|
||||
})
|
||||
|
||||
it('applies keyword, result and local date range only after search', async () => {
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
vi.mocked(listPlatformAuditLogs).mockClear()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { keyword: ' admin ', success: false, occurredRange: ['2026-08-01', '2026-08-13'] })
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(listPlatformAuditLogs).not.toHaveBeenCalled()
|
||||
|
||||
toolbar.vm.$emit('search', { keyword: ' admin ', success: false, occurredRange: ['2026-08-01', '2026-08-13'] })
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledWith({
|
||||
accountId: '8', page: 1, size: 20, keyword: 'admin', success: false,
|
||||
occurredFrom: new Date(2026, 7, 1, 0, 0, 0, 0).toISOString(),
|
||||
occurredTo: new Date(2026, 7, 13, 23, 59, 59, 999).toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
it('resets to all logs and requests once on the first page', async () => {
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { keyword: 'admin', success: true, occurredRange: ['2026-08-01', '2026-08-13'] })
|
||||
toolbar.vm.$emit('search', { keyword: 'admin', success: true, occurredRange: ['2026-08-01', '2026-08-13'] })
|
||||
await flushPromises()
|
||||
vi.mocked(listPlatformAuditLogs).mockClear()
|
||||
|
||||
;(toolbar.vm as unknown as { reset: () => void }).reset()
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent(QueryToolbar).props('modelValue')).toEqual({ keyword: '', success: '', occurredRange: [] })
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '8', page: 1, size: 20, keyword: undefined, success: undefined, occurredFrom: undefined, occurredTo: undefined })
|
||||
})
|
||||
|
||||
it('does not query without audit permission', async () => {
|
||||
const wrapper = mountPanel([]); await flushPromises()
|
||||
expect(listPlatformAuditLogs).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('暂无操作日志查看权限')
|
||||
})
|
||||
|
||||
it('loads detail and formats json snapshots', async () => {
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...row, httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('button[aria-label="查看操作日志1详情"]').trigger('click'); await flushPromises()
|
||||
expect(getPlatformAuditLog).toHaveBeenCalledWith('1')
|
||||
expect(wrapper.text()).toContain('旧名称')
|
||||
expect(wrapper.text()).toContain('新名称')
|
||||
})
|
||||
|
||||
it('loads the selected audit page', async () => {
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('search', { keyword: 'tenant', success: true, occurredRange: [] })
|
||||
await flushPromises()
|
||||
vi.mocked(listPlatformAuditLogs).mockClear()
|
||||
|
||||
;(wrapper.vm as unknown as { page: number }).page = 2
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '8', page: 2, size: 20, keyword: 'tenant', success: true, occurredFrom: undefined, occurredTo: undefined })
|
||||
})
|
||||
|
||||
it('clears draft and applied filters when switching customers', async () => {
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { keyword: 'draft', success: false, occurredRange: [] })
|
||||
toolbar.vm.$emit('search', { keyword: 'applied', success: true, occurredRange: [] })
|
||||
await flushPromises()
|
||||
vi.mocked(listPlatformAuditLogs).mockClear()
|
||||
|
||||
await wrapper.setProps({ customerId: '9' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(QueryToolbar).props('modelValue')).toEqual({ keyword: '', success: '', occurredRange: [] })
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '9', page: 1, size: 20, keyword: undefined, success: undefined, occurredFrom: undefined, occurredTo: undefined })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Eye, ScrollText } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getPlatformAuditLog, listPlatformAuditLogs, type PlatformAuditLogDetail, type PlatformAuditLogListItem, type PlatformAuditLogQuery } from '../api/platformAudit'
|
||||
|
||||
const props = defineProps<{ customerId: string; customerName: string }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<PlatformAuditLogListItem[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<PlatformAuditLogDetail | null>(null)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const emptyFilters = (): QueryModel => ({ keyword: '', success: '', occurredRange: [] })
|
||||
const filters = ref<QueryModel>(emptyFilters())
|
||||
const appliedFilters = ref<QueryModel>(emptyFilters())
|
||||
let requestSerial = 0
|
||||
|
||||
const queryFields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 260, placeholder: '操作内容/操作人/资源或请求标识' },
|
||||
{ key: 'success', type: 'select', label: '结果', width: 150, options: [{ label: '成功', value: true }, { label: '失败', value: false }] },
|
||||
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
|
||||
]
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'description', label: '操作内容', minWidth: 190, slotName: 'description' },
|
||||
{ key: 'eventType', prop: 'eventType', label: '事件类型', minWidth: 220 },
|
||||
{ key: 'username', label: '操作人', width: 140, slotName: 'username' },
|
||||
{ key: 'serviceName', label: '来源服务', width: 150, slotName: 'serviceName' },
|
||||
{ key: 'success', label: '结果', width: 90, slotName: 'success' },
|
||||
{ key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' },
|
||||
]
|
||||
|
||||
const display = (value?: string | null) => value?.trim() || '—'
|
||||
const formatDate = (value?: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
function formatSnapshot(value?: string | null) {
|
||||
if (!value?.trim()) return '无'
|
||||
try { return JSON.stringify(JSON.parse(value), null, 2) }
|
||||
catch { return value }
|
||||
}
|
||||
|
||||
function localDayIso(value: string, end: boolean) {
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
if (!year || !month || !day) return undefined
|
||||
return new Date(year, month - 1, day, end ? 23 : 0, end ? 59 : 0, end ? 59 : 0, end ? 999 : 0).toISOString()
|
||||
}
|
||||
|
||||
function toQuery(customerId: string): PlatformAuditLogQuery {
|
||||
const range = Array.isArray(appliedFilters.value.occurredRange) ? appliedFilters.value.occurredRange.map(String) : []
|
||||
const success = appliedFilters.value.success
|
||||
return {
|
||||
accountId: customerId,
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
keyword: String(appliedFilters.value.keyword || '').trim() || undefined,
|
||||
success: typeof success === 'boolean' ? success : undefined,
|
||||
occurredFrom: range[0] ? localDayIso(range[0], false) : undefined,
|
||||
occurredTo: range[1] ? localDayIso(range[1], true) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const customerId = props.customerId.trim()
|
||||
const serial = ++requestSerial
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = null
|
||||
if (!customerId || !access.can('platform:audit:list')) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listPlatformAuditLogs(toQuery(customerId))
|
||||
if (serial !== requestSerial || customerId !== props.customerId.trim()) return
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
if (serial === requestSerial) loadError.value = getApiErrorDetails(error, '客户操作日志加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (serial === requestSerial) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(model: QueryModel) {
|
||||
appliedFilters.value = {
|
||||
keyword: String(model.keyword || '').trim(),
|
||||
success: typeof model.success === 'boolean' ? model.success : '',
|
||||
occurredRange: Array.isArray(model.occurredRange) ? model.occurredRange.map(String) : [],
|
||||
}
|
||||
if (page.value === 1) void load()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformAuditLogListItem) {
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
try { detail.value = await getPlatformAuditLog(row.id) }
|
||||
catch (error) { detailError.value = getApiErrorDetails(error, '操作日志详情加载失败,请稍后重试') }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
watch(() => props.customerId, () => {
|
||||
filters.value = emptyFilters()
|
||||
appliedFilters.value = emptyFilters()
|
||||
if (page.value === 1) void load()
|
||||
else page.value = 1
|
||||
}, { immediate: true })
|
||||
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
|
||||
if (nextPage !== previousPage || nextSize !== previousSize) void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="customer-audit-panel">
|
||||
<div class="panel-title"><div><h2>操作日志</h2><p>{{ customerName }}已归属当前客户的档案与业务操作记录</p></div><ScrollText :size="19" /></div>
|
||||
<div v-if="!access.can('platform:audit:list')" class="module-empty"><ScrollText :size="30" /><strong>暂无操作日志查看权限</strong><p>请联系管理员开通平台操作日志查看权限。</p></div>
|
||||
<template v-else>
|
||||
<QueryToolbar v-model="filters" class="audit-query" ariaLabel="客户操作日志筛选" :fields="queryFields" preference-key="customer-audit-logs-query" @search="applyFilters" />
|
||||
<LoadErrorState v-if="loadError" title="客户操作日志加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<DataTable v-else v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" aria-label="客户操作日志列表" empty-text="当前客户暂无操作日志" preference-key="customer-audit-logs" border stripe>
|
||||
<template #actions="{ row }"><el-button link type="primary" :aria-label="`查看操作日志${(row as PlatformAuditLogListItem).id}详情`" @click="openDetail(row as PlatformAuditLogListItem)"><Eye :size="15" />详情</el-button></template>
|
||||
<template #description="{ row }">{{ display((row as PlatformAuditLogListItem).description) }}</template>
|
||||
<template #username="{ row }">{{ display((row as PlatformAuditLogListItem).username) }}</template>
|
||||
<template #serviceName="{ row }">{{ display((row as PlatformAuditLogListItem).serviceName) }}</template>
|
||||
<template #success="{ row }"><el-tag :type="(row as PlatformAuditLogListItem).success ? 'success' : 'danger'">{{ (row as PlatformAuditLogListItem).success ? '成功' : '失败' }}</el-tag></template>
|
||||
<template #occurredAt="{ row }">{{ formatDate((row as PlatformAuditLogListItem).occurredAt) }}</template>
|
||||
<template #pagination-left>共 {{ total }} 条客户操作记录</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<AppDrawer v-model="detailOpen" title="操作日志详情" size="min(860px, 94vw)" :loading="detailLoading" :show-footer="false">
|
||||
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="操作内容">{{ display(detail.description) }}</el-descriptions-item><el-descriptions-item label="结果">{{ detail.success ? '成功' : '失败' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作人">{{ display(detail.username) }}</el-descriptions-item><el-descriptions-item label="发生时间">{{ formatDate(detail.occurredAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="事件类型">{{ detail.eventType }}</el-descriptions-item><el-descriptions-item label="资源">{{ detail.resourceType }} / {{ display(detail.resourceId) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="请求">{{ display(detail.httpMethod) }} {{ display(detail.requestUri) }}</el-descriptions-item><el-descriptions-item label="耗时">{{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }}</el-descriptions-item>
|
||||
<el-descriptions-item label="来源服务">{{ display(detail.serviceName) }}</el-descriptions-item><el-descriptions-item label="请求标识">{{ display(detail.requestId) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="!detail.success" label="失败原因" :span="2">{{ display(detail.errorMessage || detail.errorCode) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="snapshot-grid"><section><h3>操作前快照</h3><pre>{{ formatSnapshot(detail.beforeData) }}</pre></section><section><h3>操作结果快照</h3><pre>{{ formatSnapshot(detail.afterData) }}</pre></section></div>
|
||||
</template>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customer-audit-panel { min-width:0; }.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid var(--cc-border); }.panel-title h2 { margin:0; color:#344054; font-size:16px; }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; }.panel-title > svg { color:var(--cc-primary); }.audit-query { margin-bottom:16px; }.module-empty { display:flex; min-height:260px; align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }.module-empty strong { color:#475467; font-size:13px; }.module-empty p { margin:0; color:var(--cc-muted); font-size:12px; }.snapshot-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; margin-top:18px; }.snapshot-grid section { min-width:0; }.snapshot-grid h3 { margin:0 0 8px; color:#475467; font-size:14px; }.snapshot-grid pre { min-height:160px; margin:0; padding:14px; overflow:auto; border:1px solid var(--cc-border); border-radius:6px; background:#f8fafc; color:#344054; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }@media(max-width:720px){.snapshot-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CustomerAuditLogsPanel } from './components/CustomerAuditLogsPanel.vue'
|
||||
@@ -3,6 +3,8 @@ import { http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
|
||||
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem,
|
||||
getContainerClearance, uploadContainerClearance, completeContainerClearance,
|
||||
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
|
||||
updateContainer, updateContainerTransport,
|
||||
} from './platformContainerLoading'
|
||||
|
||||
@@ -58,4 +60,57 @@ describe('platform container loading API', () => {
|
||||
expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } })
|
||||
expect(vi.mocked(http.post).mock.calls[2]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.stringContaining('push-preload-') } })
|
||||
})
|
||||
|
||||
it('uses stable container id and versioned clearance document actions', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 2 } })
|
||||
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 3 } })
|
||||
expect((await getContainerClearance('7'))?.fileId).toBe('8')
|
||||
const file = new File(['clearance'], 'clearance.pdf', { type: 'application/pdf' })
|
||||
await uploadContainerClearance('7', file, 2, '上传资料')
|
||||
await completeContainerClearance('7', 3, '完成清关')
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/containers/7/clearance')
|
||||
expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe('/platform/v1/containers/7/clearance/document/upload')
|
||||
const body = vi.mocked(http.post).mock.calls[0]?.[1] as FormData
|
||||
expect(body.get('file')).toBe(file)
|
||||
expect(body.get('expectedVersion')).toBe('2')
|
||||
expect(body.get('operationReason')).toBe('上传资料')
|
||||
expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
|
||||
expect(vi.mocked(http.post).mock.calls[1]?.[0]).toBe('/platform/v1/containers/7/clearance/complete')
|
||||
})
|
||||
|
||||
it('normalizes aggregate ids and uses the frozen overview and exception contracts', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: {
|
||||
container: { id: 9007199254740993, warehouseId: 9007199254740994, version: '4' }, loadingPlanId: 9007199254740995,
|
||||
pda: { taskId: 9007199254740996, loadingPlanId: 9007199254740995, containerBookingId: 9007199254740993, taskVersion: '2', itemCount: '4', completedItemCount: '3', unresolvedItemCount: '1', photoCounts: [{ evidenceType: 'EMPTY_CONTAINER', count: '2' }], photos: [{ evidenceId: 20, tenantId: 21, inboundId: 22, fileId: 23, downloadPath: '/api/platform/v1/files/loading-evidence/21/22/23' }], exceptionReasons: [] },
|
||||
documents: [{ id: 9007199254740997, documentType: 'DELIVERY_ORDER', fileId: 9007199254740998, uploadedBy: 9, version: '2' }],
|
||||
exception: { active: false, markedBy: null }, exceptionHistory: [{ id: 10, operatorId: 11 }], timeline: [{ operatorId: 12 }],
|
||||
} })
|
||||
const result = await getContainerOverview('7')
|
||||
expect(result).toMatchObject({ container: { id: expect.any(String), warehouseId: expect.any(String), version: 4 }, loadingPlanId: expect.any(String), pda: { taskId: expect.any(String), itemCount: 4, photoCounts: [{ count: 2 }] }, documents: [{ fileId: expect.any(String), version: 2 }] })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/overview')
|
||||
|
||||
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: null })
|
||||
vi.mocked(http.delete).mockResolvedValueOnce({ code: '200', data: null })
|
||||
await markContainerException('7', 2, '查验')
|
||||
await cancelContainerException('7', 3, '已解除')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/containers/7/exception', { expectedVersion: 2, reason: '查验' }, { headers: { 'Idempotency-Key': expect.stringContaining('container-exception-mark-') } })
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/exception', { data: { expectedVersion: 3, reason: '已解除' }, headers: { 'Idempotency-Key': expect.stringContaining('container-exception-cancel-') } })
|
||||
})
|
||||
|
||||
it('uploads only DO or PU through the logistics endpoint and downloads through the protected proxy', async () => {
|
||||
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: { id: 1, documentType: 'DELIVERY_ORDER', fileId: 2, fileName: 'do.pdf', version: 1 } })
|
||||
vi.mocked(http.get).mockResolvedValueOnce(new Blob(['do']))
|
||||
const file = new File(['do'], 'do.pdf')
|
||||
await uploadContainerDocument('7', 'DELIVERY_ORDER', file, 0, '上传DO')
|
||||
await downloadContainerDocument('7', 'DELIVERY_ORDER', '2')
|
||||
expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe('/platform/v1/containers/7/documents/DELIVERY_ORDER/upload')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/documents/DELIVERY_ORDER/2/download', { responseType: 'blob' })
|
||||
})
|
||||
|
||||
it('downloads PDA photos only from the backend-provided protected evidence path', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(new Blob(['photo']))
|
||||
await downloadContainerPdaPhoto('/api/platform/v1/files/loading-evidence/21/22/23')
|
||||
expect(http.get).toHaveBeenCalledWith('/api/platform/v1/files/loading-evidence/21/22/23', { responseType: 'blob' })
|
||||
expect(() => downloadContainerPdaPhoto('https://example.com/photo.jpg')).toThrow('invalid container photo path')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
SmartLoadingCandidate,
|
||||
SmartLoadingItem,
|
||||
SmartLoadingPlan,
|
||||
BusinessDocument,
|
||||
ContainerDocumentType,
|
||||
ContainerOverview,
|
||||
ContainerOverviewDocument,
|
||||
} from '../types'
|
||||
|
||||
const containerPath = '/platform/v1/containers'
|
||||
@@ -19,6 +23,35 @@ function numberValue(value: unknown, fallback = 0): number {
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
function document(raw: BusinessDocument | null | undefined): BusinessDocument | null {
|
||||
if (!raw) return null
|
||||
return { ...raw, id: raw.id == null ? null : id(raw.id), fileId: id(raw.fileId), uploadedBy: raw.uploadedBy == null ? null : id(raw.uploadedBy), completedBy: raw.completedBy == null ? null : id(raw.completedBy), version: numberValue(raw.version) }
|
||||
}
|
||||
|
||||
function overviewDocument(raw: ContainerOverviewDocument): ContainerOverviewDocument {
|
||||
return { ...raw, id: id(raw.id), fileId: id(raw.fileId), uploadedBy: optionalId(raw.uploadedBy), version: numberValue(raw.version) }
|
||||
}
|
||||
|
||||
function overview(raw: ContainerOverview): ContainerOverview {
|
||||
return {
|
||||
...raw,
|
||||
container: { ...raw.container, id: id(raw.container.id), warehouseId: id(raw.container.warehouseId), version: numberValue(raw.container.version) },
|
||||
loadingPlanId: optionalId(raw.loadingPlanId),
|
||||
pda: raw.pda ? {
|
||||
...raw.pda, taskId: id(raw.pda.taskId), loadingPlanId: id(raw.pda.loadingPlanId),
|
||||
containerBookingId: id(raw.pda.containerBookingId), taskVersion: numberValue(raw.pda.taskVersion),
|
||||
itemCount: numberValue(raw.pda.itemCount), completedItemCount: numberValue(raw.pda.completedItemCount),
|
||||
unresolvedItemCount: numberValue(raw.pda.unresolvedItemCount),
|
||||
photoCounts: (raw.pda.photoCounts || []).map((item) => ({ ...item, count: numberValue(item.count) })),
|
||||
photos: (raw.pda.photos || []).map((item) => ({ ...item, evidenceId: id(item.evidenceId), tenantId: id(item.tenantId), inboundId: id(item.inboundId), fileId: id(item.fileId) })),
|
||||
exceptionReasons: raw.pda.exceptionReasons || [],
|
||||
} : null,
|
||||
documents: (raw.documents || []).map(overviewDocument),
|
||||
exception: { ...raw.exception, markedBy: optionalId(raw.exception?.markedBy) },
|
||||
exceptionHistory: (raw.exceptionHistory || []).map((entry) => ({ ...entry, id: id(entry.id), operatorId: optionalId(entry.operatorId) })),
|
||||
timeline: (raw.timeline || []).map((entry) => ({ ...entry, operatorId: optionalId(entry.operatorId) })),
|
||||
}
|
||||
}
|
||||
|
||||
function container(raw: ContainerBookingResource): ContainerBookingResource {
|
||||
return {
|
||||
@@ -30,6 +63,7 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
|
||||
version: numberValue(raw.version),
|
||||
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
|
||||
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
|
||||
clearanceDocument: document(raw.clearanceDocument),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +95,39 @@ export async function getContainer(containerId: string) {
|
||||
return container(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getContainerOverview(containerId: string) {
|
||||
const response = await http.get<ApiEnvelope<ContainerOverview>, ApiEnvelope<ContainerOverview>>(`${containerPath}/${encodeURIComponent(containerId)}/overview`)
|
||||
return overview(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function markContainerException(containerId: string, expectedVersion: number, reason: string) {
|
||||
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(`${containerPath}/${encodeURIComponent(containerId)}/exception`, { expectedVersion, reason }, { headers: { 'Idempotency-Key': operationId('container-exception-mark') } })
|
||||
unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function cancelContainerException(containerId: string, expectedVersion: number, reason: string) {
|
||||
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${containerPath}/${encodeURIComponent(containerId)}/exception`, { data: { expectedVersion, reason }, headers: { 'Idempotency-Key': operationId('container-exception-cancel') } })
|
||||
unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function uploadContainerDocument(containerId: string, documentType: Extract<ContainerDocumentType, 'DELIVERY_ORDER' | 'PICKUP_ORDER'>, file: File, expectedVersion: number, operationReason?: string | null) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
body.append('expectedVersion', String(expectedVersion))
|
||||
if (operationReason) body.append('operationReason', operationReason)
|
||||
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/upload`, body, { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId(`container-${documentType.toLowerCase()}-upload`) } })
|
||||
return document(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export function downloadContainerDocument(containerId: string, documentType: Extract<ContainerDocumentType, 'DELIVERY_ORDER' | 'PICKUP_ORDER'>, fileId: string) {
|
||||
return http.get<Blob, Blob>(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export function downloadContainerPdaPhoto(downloadPath: string) {
|
||||
if (!downloadPath.startsWith('/api/platform/v1/files/loading-evidence/')) throw new Error('invalid container photo path')
|
||||
return http.get<Blob, Blob>(downloadPath, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export async function createContainer(payload: ContainerSaveRequest) {
|
||||
const response = await http.post<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(containerPath, payload)
|
||||
return container(unwrapApiResponse(response))
|
||||
@@ -76,6 +143,25 @@ export async function updateContainerTransport(containerId: string, version: num
|
||||
return container(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getContainerClearance(containerId: string) {
|
||||
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance`)
|
||||
return document(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function uploadContainerClearance(containerId: string, file: File, expectedVersion: number, operationReason?: string | null) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
body.append('expectedVersion', String(expectedVersion))
|
||||
if (operationReason) body.append('operationReason', operationReason)
|
||||
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance/document/upload`, body, { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-clearance-upload') } })
|
||||
return document(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function completeContainerClearance(containerId: string, expectedVersion: number, operationReason?: string | null) {
|
||||
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance/complete`, { expectedVersion, operationReason }, { headers: { 'Idempotency-Key': operationId('container-clearance-complete') } })
|
||||
return document(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function listSmartLoadingCandidates(bookingId: string, warehouseId: string) {
|
||||
const response = await http.get<ApiEnvelope<SmartLoadingCandidate[]>, ApiEnvelope<SmartLoadingCandidate[]>>(`${smartLoadingPath}/candidates`, { params: { bookingId, warehouseId } })
|
||||
return unwrapApiResponse(response).map((raw) => ({
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ContainerOverviewPanel from './ContainerOverviewPanel.vue'
|
||||
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: ['一项待复核'] },
|
||||
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' }],
|
||||
timeline: [{ eventType: 'TRANSPORT_UPDATE', detail: '更新预计到港时间', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T12:00:00' }],
|
||||
}
|
||||
|
||||
describe('ContainerOverviewPanel', () => {
|
||||
it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', () => {
|
||||
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
||||
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()).toContain('更新预计到港时间')
|
||||
})
|
||||
|
||||
it('emits stable document types and does not expose file ids in visible text', async () => {
|
||||
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
|
||||
const updateButton = wrapper.findAll('button').find((button) => button.text().includes('更新'))!
|
||||
await updateButton.trigger('click')
|
||||
expect(wrapper.emitted('uploadDocument')?.[0]).toEqual(['DELIVERY_ORDER'])
|
||||
expect(wrapper.text()).not.toContain('9007199254740999')
|
||||
})
|
||||
|
||||
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' })
|
||||
})
|
||||
|
||||
it('renders empty and forbidden/load failure states without exposing credentials', () => {
|
||||
const withoutPhotos = { ...overview, pda: overview.pda ? { ...overview.pda, photos: [] } : null }
|
||||
const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageException: false, photoError: { message: '当前账号无权访问该数据,请联系管理员确认权限', code: 'HTTP_403', requestId: 'req-photo' } }, global: { plugins: [ElementPlus] } })
|
||||
expect(wrapper.text()).toContain('暂无可查看的有效照片')
|
||||
expect(wrapper.text()).toContain('当前账号无权访问该数据')
|
||||
expect(wrapper.text()).toContain('req-photo')
|
||||
expect(wrapper.text()).not.toContain('Authorization')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Download, Eye, FileUp, TriangleAlert } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import type { ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
overview: ContainerOverview
|
||||
canManageDocuments: boolean
|
||||
canManageException: boolean
|
||||
documentBusy?: boolean
|
||||
photoBusyId?: string | null
|
||||
photoError?: ApiErrorDetails | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploadDocument: [documentType: string]
|
||||
downloadDocument: [document: ContainerOverviewDocument]
|
||||
manageException: []
|
||||
previewPhoto: [photo: ContainerPdaPhoto]
|
||||
}>()
|
||||
|
||||
const documentTypes = [
|
||||
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
|
||||
{ type: 'CUSTOMS_DECLARATION', label: '报关资料' },
|
||||
{ type: 'DELIVERY_ORDER', label: 'DO 资料' },
|
||||
{ type: 'PICKUP_ORDER', label: 'PU 资料' },
|
||||
] as const
|
||||
|
||||
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
|
||||
const downloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
|
||||
const pdaProgress = computed(() => {
|
||||
const pda = props.overview.pda
|
||||
if (!pda || pda.itemCount <= 0) return 0
|
||||
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))
|
||||
|
||||
function formatTime(value: string | null | undefined) {
|
||||
return value?.replace('T', ' ').slice(0, 16) || '—'
|
||||
}
|
||||
|
||||
function eventLabel(value: string) {
|
||||
return ({
|
||||
MARK: '标记异常', CANCEL: '取消异常', TRANSPORT_UPDATE: '更新运输信息',
|
||||
} as Record<string, string>)[value] || value || '状态更新'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overview-panel">
|
||||
<section class="overview-section" aria-label="货柜异常状态">
|
||||
<header><h3>异常状态</h3><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview.exception.active ? '取消异常' : '标记异常' }}</el-button></header>
|
||||
<el-alert v-if="overview.exception.active" type="error" :closable="false" show-icon><template #title>货柜已标记异常</template><p>{{ overview.exception.reason || '未填写异常原因' }}</p><small>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</small></el-alert>
|
||||
<el-empty v-else description="当前无货柜异常" :image-size="64" />
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="货柜业务资料">
|
||||
<header><h3>业务资料</h3><span>{{ overview.documents.length }} 份</span></header>
|
||||
<div class="document-grid">
|
||||
<article v-for="item in documentTypes" :key="item.type" class="document-item">
|
||||
<div><strong>{{ item.label }}</strong><span>{{ documentsByType.get(item.type)?.fileName || '未上传' }}</span><small v-if="documentsByType.get(item.type)">{{ documentsByType.get(item.type)?.uploadedByName || '上传人信息缺失' }} · {{ formatTime(documentsByType.get(item.type)?.uploadedAt) }}</small></div>
|
||||
<div class="document-actions"><el-button v-if="documentsByType.get(item.type) && downloadableTypes.has(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canManageDocuments && downloadableTypes.has(item.type)" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新' : '上传' }}</el-button></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="PDA作业摘要">
|
||||
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="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>
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="运输轨迹和操作日志">
|
||||
<header><h3>运输轨迹与操作日志</h3><span>{{ overview.timeline.length }} 条</span></header>
|
||||
<el-empty v-if="overview.timeline.length === 0" description="暂无轨迹或操作记录" :image-size="64" />
|
||||
<el-timeline v-else><el-timeline-item v-for="(entry, index) in overview.timeline" :key="`${entry.eventType}-${entry.occurredAt}-${index}`" :timestamp="formatTime(entry.occurredAt)" placement="top"><strong>{{ eventLabel(entry.eventType) }}</strong><p>{{ entry.detail || '无补充说明' }}</p><small>{{ entry.operatorName || '系统' }}</small></el-timeline-item></el-timeline>
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="异常处理历史">
|
||||
<header><h3>异常处理历史</h3><span>{{ overview.exceptionHistory.length }} 条</span></header>
|
||||
<el-empty v-if="overview.exceptionHistory.length === 0" description="暂无异常处理历史" :image-size="64" />
|
||||
<el-timeline v-else><el-timeline-item v-for="history in overview.exceptionHistory" :key="history.id" :timestamp="formatTime(history.occurredAt)" placement="top"><strong>{{ eventLabel(history.action) }}</strong><p>{{ history.reason }}</p><small>{{ history.operatorName || '操作人信息缺失' }}</small></el-timeline-item></el-timeline>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overview-panel { display: grid; gap: 16px; margin-top: 16px; }
|
||||
.overview-section { min-width: 0; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.overview-section h3 { margin: 0; font-size: 15px; }
|
||||
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; }
|
||||
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: 13px; }
|
||||
.document-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.document-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--cc-border); }
|
||||
.document-item > div:first-child { display: grid; min-width: 0; gap: 4px; }
|
||||
.document-item span { overflow: hidden; color: var(--cc-muted); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.document-actions { display: flex; flex-shrink: 0; gap: 6px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 12px 0; }
|
||||
.metric-grid > div { display: grid; gap: 4px; padding: 10px; background: var(--cc-bg-subtle, #f7f8fa); }
|
||||
.metric-grid span { color: var(--cc-muted); font-size: 12px; }
|
||||
.metric-grid strong { font-size: 18px; }
|
||||
.photo-summary { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }.photo-summary span { padding: 4px 8px; border: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
|
||||
.photo-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin-bottom: 12px; }.photo-list article { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:8px; padding:10px; border:1px solid var(--cc-border); }.photo-list article > div { display:grid; min-width:0; gap:3px; }.photo-list span { overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
@media (max-width: 720px) { .document-grid,.photo-list { grid-template-columns: 1fr; } .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 420px) { .document-item { align-items: stretch; flex-direction: column; } .document-actions :deep(.el-button) { flex: 1; } }
|
||||
</style>
|
||||
@@ -9,14 +9,14 @@ import ContainersView from './ContainersView.vue'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { listPlatformPartners } from '@/modules/master-data'
|
||||
import { listOceanBookings } from '@/modules/ocean-bookings'
|
||||
import { listContainers } from '../api/platformContainerLoading'
|
||||
import { getContainer, getContainerOverview, listContainers } from '../api/platformContainerLoading'
|
||||
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookings: vi.fn() }))
|
||||
const accessCan = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({ history: createMemoryHistory(), routes: [
|
||||
@@ -35,6 +35,8 @@ describe('ContainersView', () => {
|
||||
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
||||
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
|
||||
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
|
||||
})
|
||||
|
||||
it('loads the real paged contract and renders core container fields', async () => {
|
||||
@@ -115,25 +117,38 @@ describe('ContainersView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('排柜')
|
||||
expect(wrapper.text()).toContain('查看预装清单')
|
||||
expect(wrapper.text()).toContain('查看装柜清单')
|
||||
expect(wrapper.text()).toContain('预装清单')
|
||||
expect(wrapper.text()).toContain('装柜清单')
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().trim() === '排柜')!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('查看预装清单'))!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('查看装柜清单'))!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('预装清单'))!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('装柜清单'))!.trigger('click')
|
||||
expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } })
|
||||
expect(push).toHaveBeenNthCalledWith(2, { name: 'loading-plans', query: { planId: '9102', warehouseId: '8' } })
|
||||
expect(push).toHaveBeenNthCalledWith(3, { name: 'loading-lists', query: { planId: '9103', warehouseId: '8' } })
|
||||
})
|
||||
|
||||
it('keeps view and edit operations behind their own backend permissions', async () => {
|
||||
accessCan.mockImplementation((permission: string) => permission !== 'platform:firstleg:container:view')
|
||||
it('uses the list permission for detail while keeping edit independent', async () => {
|
||||
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:list')
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[aria-label="更多货柜操作"]').exists()).toBe(true)
|
||||
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not accept the removed standalone view permission', async () => {
|
||||
accessCan.mockReturnValue(false)
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(false)
|
||||
expect(wrapper.find('button[aria-label="更多货柜操作"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -190,4 +205,22 @@ describe('ContainersView', () => {
|
||||
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
|
||||
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
|
||||
})
|
||||
|
||||
it('uploads clearance documents from a file picker without exposing file ids', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain('<el-upload :auto-upload="false" :limit="1"')
|
||||
expect(source).toContain('@confirm="uploadClearance"')
|
||||
expect(source).toContain('uploadContainerClearance(detailTarget.value.id, clearanceFile.value')
|
||||
expect(source).toContain(':show-confirm="!clearanceDocument?.completedAt"')
|
||||
expect(source).toContain('<el-form v-else label-position="top"')
|
||||
expect(source).not.toContain('文件 ID')
|
||||
expect(source).not.toContain('上传入口待')
|
||||
})
|
||||
|
||||
it('does not expose manual container creation without an ocean-booking channel snapshot', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).not.toContain('新增货柜')
|
||||
expect(source).not.toContain('createContainer(')
|
||||
expect(source).not.toContain('openCreate')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Boxes, Eye, ListChecks, Pencil, Plus, Sparkles } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Boxes, Check, Eye, FileText, FileUp, ListChecks, MoreHorizontal, Pencil, Sparkles, TriangleAlert } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||||
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -16,8 +17,9 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createContainer, getContainer, listContainers, updateContainer, updateContainerTransport } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource, ContainerPlanStatus, ContainerSaveRequest } from '../types'
|
||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||
import { cancelContainerException, completeContainerClearance, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, listContainers, markContainerException, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest } from '../types'
|
||||
|
||||
type Filters = QueryModel & { keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
||||
type ContainerForm = ContainerSaveRequest & { id: string; version: number }
|
||||
@@ -45,6 +47,26 @@ const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<ContainerBookingResource | null>(null)
|
||||
const detailTarget = ref<ContainerBookingResource | null>(null)
|
||||
const clearanceDocument = ref<import('../types').BusinessDocument | null>(null)
|
||||
const clearanceLoading = ref(false)
|
||||
const clearanceActionLoading = ref(false)
|
||||
const clearanceError = ref<ApiErrorDetails | null>(null)
|
||||
const clearanceFile = ref<File | null>(null)
|
||||
const clearanceDialogOpen = ref(false)
|
||||
const overview = ref<ContainerOverview | null>(null)
|
||||
const overviewError = ref<ApiErrorDetails | null>(null)
|
||||
const documentDialogOpen = ref(false)
|
||||
const documentType = ref<ContainerDocumentType>('DELIVERY_ORDER')
|
||||
const documentFile = ref<File | null>(null)
|
||||
const documentSaving = ref(false)
|
||||
const documentError = ref<ApiErrorDetails | null>(null)
|
||||
const documentDownloading = ref(false)
|
||||
const photoBusyId = ref<string | null>(null)
|
||||
const photoError = ref<ApiErrorDetails | null>(null)
|
||||
const exceptionDialogOpen = ref(false)
|
||||
const exceptionReason = ref('')
|
||||
const exceptionSaving = ref(false)
|
||||
const exceptionError = ref<ApiErrorDetails | null>(null)
|
||||
const editorOpen = ref(false)
|
||||
const editorError = ref<ApiErrorDetails | null>(null)
|
||||
const saving = ref(false)
|
||||
@@ -70,13 +92,16 @@ const rules: FormRules<ContainerForm> = {
|
||||
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const canCreate = computed(() => access.can('platform:firstleg:container:create'))
|
||||
const canView = computed(() => access.can('platform:firstleg:container:view'))
|
||||
const canView = computed(() => access.can('platform:firstleg:container:list'))
|
||||
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
|
||||
const canCustomsManage = computed(() => access.can('platform:firstleg:customs:manage'))
|
||||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const editorTitle = computed(() => form.id ? '编辑' : '新增')
|
||||
const selectedOverviewDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
||||
const documentDialogTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', DESTINATION_CLEARANCE: '目的港清关资料', CUSTOMS_DECLARATION: '报关资料' })[documentType.value])
|
||||
const exceptionDialogTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
|
||||
const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING')
|
||||
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
|
||||
const truckingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.some((type) => ['CARRIER', 'SUPPLIER'].includes(type))))
|
||||
@@ -118,11 +143,18 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
|
||||
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
|
||||
{ key: 'terminalTruckingCompany', prop: 'terminalTruckingCompany', label: '码头车队', minWidth: 160 },
|
||||
{ key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 150 },
|
||||
{ key: 'route', label: '航线', minWidth: 220, slotName: 'route' },
|
||||
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 100 },
|
||||
{ key: 'capacity', label: '容量上限', minWidth: 150, slotName: 'capacity' },
|
||||
{ key: 'vessel', label: '船名/航次', minWidth: 180, slotName: 'vessel' },
|
||||
{ key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150 },
|
||||
{ key: 'loadingAt', label: '装柜时间', minWidth: 160, slotName: 'loadingAt' },
|
||||
{ key: 'estimatedSailingAt', label: '预计开船', minWidth: 160, slotName: 'estimatedSailingAt' },
|
||||
{ key: 'actualSailingAt', label: '实际开船', minWidth: 160, slotName: 'actualSailingAt' },
|
||||
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
|
||||
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' },
|
||||
]
|
||||
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
@@ -251,13 +283,101 @@ function search() { applied.value = { ...filters.value, sailingRange: [...filter
|
||||
async function openDetail(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
detailTarget.value = row
|
||||
detailOpen.value = true; detail.value = null; detailError.value = null; detailLoading.value = true
|
||||
detailOpen.value = true; detail.value = null; overview.value = null; detailError.value = null; overviewError.value = null; detailLoading.value = true
|
||||
const sequence = ++detailSequence
|
||||
try { const result = await getContainer(row.id); if (sequence === detailSequence) detail.value = result }
|
||||
try { const [result, aggregate] = await Promise.all([getContainer(row.id), getContainerOverview(row.id)]); if (sequence === detailSequence) { detail.value = result; overview.value = aggregate; clearanceDocument.value = null } }
|
||||
catch (error) { if (sequence === detailSequence) detailError.value = getApiErrorDetails(error, '货柜详情加载失败') }
|
||||
finally { if (sequence === detailSequence) detailLoading.value = false }
|
||||
}
|
||||
function openCreate() { detail.value = null; editorError.value = null; replaceForm(null); editorOpen.value = true }
|
||||
function handleMoreAction(command: string, source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
if (command === 'overview') { void openDetail(row); return }
|
||||
detailTarget.value = row
|
||||
void openDetail(row).then(() => {
|
||||
if (command === 'exception') openExceptionDialog()
|
||||
if (command === 'documents') openDocumentDialog('DELIVERY_ORDER')
|
||||
})
|
||||
}
|
||||
function openDocumentDialog(type: string) {
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type)) {
|
||||
if (type === 'DESTINATION_CLEARANCE' && detailTarget.value) void openClearance(detailTarget.value)
|
||||
return
|
||||
}
|
||||
documentType.value = type as ContainerDocumentType
|
||||
documentFile.value = null
|
||||
documentError.value = null
|
||||
documentDialogOpen.value = true
|
||||
}
|
||||
function selectDocumentFile(uploadFile: UploadFile) { documentFile.value = uploadFile.raw ?? null; documentError.value = null }
|
||||
function removeDocumentFile() { documentFile.value = null }
|
||||
async function saveDocument() {
|
||||
if (!detailTarget.value || !overview.value || !documentFile.value) { documentError.value = { message: '请选择需要上传的资料文件', code: 'FILE_REQUIRED' }; return }
|
||||
documentSaving.value = true; documentError.value = null
|
||||
try {
|
||||
const version = selectedOverviewDocument.value?.version || 0
|
||||
if (documentType.value === 'DESTINATION_CLEARANCE') await uploadContainerClearance(detailTarget.value.id, documentFile.value, version, '上传目的港清关资料')
|
||||
else if (documentType.value === 'CUSTOMS_DECLARATION') {
|
||||
if (!overview.value.loadingPlanId) throw new Error('missing loading plan')
|
||||
await uploadLoadingDeclaration(overview.value.loadingPlanId, detailTarget.value.warehouseId, documentFile.value, version, '上传装柜清单报关资料')
|
||||
} else await uploadContainerDocument(detailTarget.value.id, documentType.value, documentFile.value, version, `上传${documentDialogTitle.value}`)
|
||||
overview.value = await getContainerOverview(detailTarget.value.id)
|
||||
documentDialogOpen.value = false; documentFile.value = null; ElMessage.success(`${documentDialogTitle.value}已上传`)
|
||||
} catch (error) { documentError.value = getApiErrorDetails(error, `${documentDialogTitle.value}上传失败`) }
|
||||
finally { documentSaving.value = false }
|
||||
}
|
||||
async function saveBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
async function downloadDocument(item: ContainerOverviewDocument) {
|
||||
if (!detailTarget.value) return
|
||||
documentDownloading.value = true
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(item.documentType)) return
|
||||
try { await saveBlob(await downloadContainerDocument(detailTarget.value.id, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId), item.fileName); ElMessage.success('资料下载已开始') }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '资料下载失败').message) }
|
||||
finally { documentDownloading.value = false }
|
||||
}
|
||||
async function previewPhoto(photo: ContainerPdaPhoto) {
|
||||
photoBusyId.value = photo.evidenceId; photoError.value = null
|
||||
try {
|
||||
const url = URL.createObjectURL(await downloadContainerPdaPhoto(photo.downloadPath))
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
} catch (error) { photoError.value = getApiErrorDetails(error, '装柜照片加载失败') }
|
||||
finally { photoBusyId.value = null }
|
||||
}
|
||||
function openExceptionDialog() { exceptionReason.value = ''; exceptionError.value = null; exceptionDialogOpen.value = true }
|
||||
async function saveException() {
|
||||
if (!detailTarget.value || !detail.value || !overview.value || !exceptionReason.value.trim()) { exceptionError.value = { message: '请输入异常原因或取消备注', code: 'REASON_REQUIRED' }; return }
|
||||
exceptionSaving.value = true; exceptionError.value = null
|
||||
try {
|
||||
const action = overview.value.exception.active ? cancelContainerException : markContainerException
|
||||
await action(detailTarget.value.id, overview.value.container.version, exceptionReason.value.trim())
|
||||
overview.value = await getContainerOverview(detailTarget.value.id)
|
||||
exceptionDialogOpen.value = false; ElMessage.success(overview.value.exception.active ? '货柜已标记异常' : '货柜异常已取消')
|
||||
} catch (error) { exceptionError.value = getApiErrorDetails(error, '货柜异常处理失败') }
|
||||
finally { exceptionSaving.value = false }
|
||||
}
|
||||
async function openClearance(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
detailTarget.value = row
|
||||
clearanceDialogOpen.value = true; clearanceError.value = null; clearanceFile.value = null; clearanceLoading.value = true
|
||||
try { clearanceDocument.value = await getContainerClearance(row.id) } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关资料加载失败') } finally { clearanceLoading.value = false }
|
||||
}
|
||||
function selectClearanceFile(uploadFile: UploadFile) { clearanceFile.value = uploadFile.raw ?? null; clearanceError.value = null }
|
||||
function removeClearanceFile() { clearanceFile.value = null }
|
||||
async function uploadClearance() {
|
||||
if (!detailTarget.value || !clearanceFile.value) { clearanceError.value = { message: '请选择清关资料文件', code: 'FILE_REQUIRED' }; return }
|
||||
clearanceActionLoading.value = true; clearanceError.value = null
|
||||
try { clearanceDocument.value = await uploadContainerClearance(detailTarget.value.id, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); clearanceFile.value = null; clearanceDialogOpen.value = false; ElMessage.success('清关资料已上传') } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关资料上传失败') } finally { clearanceActionLoading.value = false }
|
||||
}
|
||||
async function completeClearance() {
|
||||
if (!detailTarget.value || !clearanceDocument.value) return
|
||||
clearanceActionLoading.value = true; clearanceError.value = null
|
||||
try { clearanceDocument.value = await completeContainerClearance(detailTarget.value.id, clearanceDocument.value.version, '确认目的港清关完成'); ElMessage.success('已完成清关') } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关完成失败') } finally { clearanceActionLoading.value = false }
|
||||
}
|
||||
async function openEdit(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
editorError.value = null
|
||||
@@ -269,10 +389,9 @@ async function save() {
|
||||
saving.value = true
|
||||
editorError.value = null
|
||||
try {
|
||||
if (!form.id) await createContainer(payload())
|
||||
else if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) })
|
||||
if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) })
|
||||
else await updateContainer(form.id, form.version, payload())
|
||||
ElMessage.success(form.id ? '货柜已更新' : '货柜已新增'); editorOpen.value = false; await loadRows()
|
||||
ElMessage.success('货柜已更新'); editorOpen.value = false; await loadRows()
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '保存失败')
|
||||
if (details.code === 'A0409' && form.id) {
|
||||
@@ -324,24 +443,33 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadBusinessOptions(), loa
|
||||
</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 #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增货柜</el-button></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' ? '录入集装箱号' : '编辑' }}</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></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' ? '录入集装箱号' : '编辑' }}</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>
|
||||
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
|
||||
<template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template>
|
||||
<template #vessel="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') || '—' }}</template>
|
||||
<template #loadingAt="{ row }">{{ formatTime(row.loadingAt) }}</template>
|
||||
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
|
||||
<template #actualSailingAt="{ row }">{{ formatTime(row.actualSailingAt) }}</template>
|
||||
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
|
||||
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualPortArrivalAt) }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer v-model="detailOpen" title="货柜详情" size="min(760px, 94vw)" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
|
||||
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<el-descriptions v-else-if="detail" :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions>
|
||||
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDialog v-model="clearanceDialogOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :confirm-loading="clearanceActionLoading" :confirm-disabled="!clearanceFile" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><el-form v-else label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="clearanceError" compact title="操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
||||
|
||||
<AppDialog v-model="documentDialogOpen" :title="documentDialogTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentDialogTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedOverviewDocument ? '上传新文件将替换当前资料并保留操作历史。' : `请选择需要提交的${documentDialogTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
|
||||
<AppDialog v-model="exceptionDialogOpen" :title="exceptionDialogTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="exceptionError" /></template></LoadErrorState></AppDialog>
|
||||
|
||||
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="确定" cancel-text="关闭" @confirm="save">
|
||||
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
||||
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
|
||||
@@ -373,6 +501,7 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadBusinessOptions(), loa
|
||||
|
||||
<style scoped>
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }
|
||||
.document-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 16px; padding: 14px 16px; border: 1px solid var(--cc-border); background: #fff; }.document-panel > div { display: grid; gap: 4px; min-width: 0; }.document-panel span { overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; }
|
||||
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
|
||||
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
|
||||
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
|
||||
|
||||
@@ -34,6 +34,98 @@ export interface ContainerBookingResource {
|
||||
version: number
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
clearanceDocument?: BusinessDocument | null
|
||||
}
|
||||
|
||||
export interface BusinessDocument {
|
||||
id: string | null
|
||||
documentType: string
|
||||
fileId: string
|
||||
fileName: string
|
||||
fileHash: string | null
|
||||
uploadedBy: string | null
|
||||
uploadedByName: string | null
|
||||
uploadedAt: string | null
|
||||
completedBy: string | null
|
||||
completedByName: string | null
|
||||
completedAt: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'CUSTOMS_DECLARATION' | 'DELIVERY_ORDER' | 'PICKUP_ORDER'
|
||||
|
||||
export interface ContainerOverviewDocument {
|
||||
id: string
|
||||
documentType: ContainerDocumentType | string
|
||||
fileId: string
|
||||
fileName: string
|
||||
fileHash: string | null
|
||||
uploadedBy: string | null
|
||||
uploadedByName: string | null
|
||||
uploadedAt: string | null
|
||||
version: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export interface ContainerPdaSummary {
|
||||
taskId: string
|
||||
loadingPlanId: string
|
||||
containerBookingId: string
|
||||
taskStatus: string
|
||||
taskVersion: number
|
||||
itemCount: number
|
||||
completedItemCount: number
|
||||
unresolvedItemCount: number
|
||||
photoCounts: Array<{ evidenceType: string; count: number }>
|
||||
photos: ContainerPdaPhoto[]
|
||||
exceptionReasons: string[]
|
||||
}
|
||||
|
||||
export interface ContainerPdaPhoto {
|
||||
evidenceId: string
|
||||
tenantId: string
|
||||
inboundId: string
|
||||
evidenceType: string
|
||||
fileId: string
|
||||
fileName: string
|
||||
status: string
|
||||
downloadPath: string
|
||||
uploadedAt: string | null
|
||||
}
|
||||
|
||||
export interface ContainerExceptionState {
|
||||
active: boolean
|
||||
reason: string | null
|
||||
markedBy: string | null
|
||||
markedByName: string | null
|
||||
markedAt: string | null
|
||||
}
|
||||
|
||||
export interface ContainerExceptionHistory {
|
||||
id: string
|
||||
action: string
|
||||
reason: string
|
||||
operatorId: string | null
|
||||
operatorName: string | null
|
||||
occurredAt: string | null
|
||||
}
|
||||
|
||||
export interface ContainerTimelineEntry {
|
||||
eventType: string
|
||||
detail: string | null
|
||||
operatorId: string | null
|
||||
operatorName: string | null
|
||||
occurredAt: string | null
|
||||
}
|
||||
|
||||
export interface ContainerOverview {
|
||||
container: Pick<ContainerBookingResource, 'id' | 'bookingNo' | 'cabinetNo' | 'containerNo' | 'warehouseId' | 'warehouseName' | 'voyageNo' | 'vesselName' | 'version'> & { status: string }
|
||||
loadingPlanId: string | null
|
||||
pda: ContainerPdaSummary | null
|
||||
documents: ContainerOverviewDocument[]
|
||||
exception: ContainerExceptionState
|
||||
exceptionHistory: ContainerExceptionHistory[]
|
||||
timeline: ContainerTimelineEntry[]
|
||||
}
|
||||
|
||||
export interface ContainerPage {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { addFeeTier, removeFeeTier, validateFeeTiers } from './overseasStorageFe
|
||||
|
||||
const props = defineProps<{ customerId: string; customerName: string }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const canView = computed(() => access.can('platform:customer:view'))
|
||||
const canView = computed(() => access.can('platform:customer:list'))
|
||||
const canEdit = computed(() => access.can('platform:customer:edit'))
|
||||
const config = ref<OverseasStorageFeeConfig | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('CustomerDetailView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.getAttribute('aria-label'))).toEqual([
|
||||
'基本信息', '业务概况', '运单记录', '计费规则',
|
||||
'基本信息', '业务概况', '运单记录', '财务账单', '计费规则', '操作日志',
|
||||
])
|
||||
expect(document.body.textContent).toContain('企业用户1人')
|
||||
expect(document.body.textContent).toContain('1 人正常使用')
|
||||
|
||||
@@ -37,8 +37,10 @@ import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
|
||||
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
|
||||
import { CustomerStatementsPanel } from '@/modules/finance'
|
||||
import { CustomerAuditLogsPanel } from '@/modules/audit'
|
||||
|
||||
type DetailTab = 'basic' | 'business' | 'orders' | 'billing'
|
||||
type DetailTab = 'basic' | 'business' | 'orders' | 'statements' | 'billing' | 'logs'
|
||||
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
|
||||
|
||||
const route = useRoute()
|
||||
@@ -74,7 +76,9 @@ const tabs: Array<{ key: DetailTab; label: string; icon: string }> = [
|
||||
{ key: 'basic', label: '基本信息', icon: '📋' },
|
||||
{ key: 'business', label: '业务概况', icon: '📊' },
|
||||
{ key: 'orders', label: '运单记录', icon: '📦' },
|
||||
{ key: 'statements', label: '财务账单', icon: '🧾' },
|
||||
{ key: 'billing', label: '计费规则', icon: '⚖️' },
|
||||
{ key: 'logs', label: '操作日志', icon: '📝' },
|
||||
]
|
||||
|
||||
const approvedQualifications = computed(() => qualifications.value.filter((item) => item.status === 'APPROVED').length)
|
||||
@@ -425,6 +429,8 @@ watch(orderPage, (page, previousPage) => {
|
||||
<div v-else class="module-empty"><Ship :size="30" /><strong>暂无运单查看权限</strong><p>请联系管理员申请头程订单查看权限。</p></div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'statements'" class="detail-panel module-panel"><CustomerStatementsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><CustomerAuditLogsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
|
||||
@@ -391,6 +391,37 @@ describe('CustomersView', () => {
|
||||
expect(document.body.textContent).toContain('Temporary#2026')
|
||||
})
|
||||
|
||||
it('loads and previews authenticated qualification files in the review dialog', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = [...auth.permissions, 'platform:customer:review', 'platform:customer:approve']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({
|
||||
records: [{ ...fullTenant, status: 'PENDING' }], page: 1, size: 10, total: 1, pages: 1,
|
||||
})
|
||||
vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview])
|
||||
vi.mocked(listTenantQualifications).mockResolvedValue([
|
||||
{ id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'PENDING', reviewComment: null, validTo: null },
|
||||
{ id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID', certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null },
|
||||
])
|
||||
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
tableButtonByText('审核')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(listTenantQualifications).toHaveBeenCalledWith(fullTenant.id)
|
||||
expect(document.body.textContent).toContain('营业执照')
|
||||
expect(document.body.textContent).toContain('法人身份证')
|
||||
const previewButton = [...document.querySelectorAll<HTMLButtonElement>('.review-document-actions button')]
|
||||
.find((button) => button.textContent?.trim() === '预览')
|
||||
previewButton?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(downloadTenantQualification).toHaveBeenCalledWith(fullTenant.id, '/files/license.png')
|
||||
expect(document.querySelector<HTMLImageElement>('img[alt="license.png预览"]')?.src).toBe('blob:qualification-preview')
|
||||
})
|
||||
|
||||
it('keeps the client registration account as administrator after approval', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = [...auth.permissions, 'platform:customer:review', 'platform:customer:approve']
|
||||
|
||||
@@ -80,6 +80,14 @@ const reviewLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedReview = ref<EnterpriseReview | null>(null)
|
||||
const decisionOpen = ref(false)
|
||||
const reviewSubmitting = ref(false)
|
||||
const reviewQualifications = ref<PlatformTenantQualification[]>([])
|
||||
const reviewQualificationsLoading = ref(false)
|
||||
const reviewQualificationsError = ref<ApiErrorDetails | null>(null)
|
||||
const reviewPreviewOpen = ref(false)
|
||||
const reviewPreviewName = ref('')
|
||||
const reviewPreviewUrl = ref('')
|
||||
const reviewPreviewKind = ref<'image' | 'pdf'>('image')
|
||||
const reviewDocumentLoading = ref('')
|
||||
const approvalResultOpen = ref(false)
|
||||
const approvalResult = ref<EnterpriseApprovalResponse | null>(null)
|
||||
const credentialSource = ref<'creation' | 'approval'>('approval')
|
||||
@@ -252,6 +260,12 @@ const impersonationRules: FormRules = {
|
||||
}
|
||||
function displayText(value?: string | null) { return value?.trim() || '—' }
|
||||
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function qualificationLabel(type?: string | null) {
|
||||
return ({ BUSINESS_LICENSE: '营业执照', LEGAL_PERSON_ID: '法人身份证', FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书', BANK_ACCOUNT_LICENSE: '银行开户许可证' } as Record<string, string>)[type || ''] || type || '企业资质'
|
||||
}
|
||||
function qualificationName(item: PlatformTenantQualification) {
|
||||
try { return qualificationFileName(item.documentUrl) } catch { return qualificationLabel(item.qualificationType) }
|
||||
}
|
||||
function statusMeta(status: PlatformTenantStatus) {
|
||||
if (status === 'ACTIVE') return { label: '启用', type: 'success' as const }
|
||||
if (status === 'PROFILE_REQUIRED') return { label: '待完善', type: 'info' as const }
|
||||
@@ -819,7 +833,7 @@ async function submitArchive() {
|
||||
}
|
||||
}
|
||||
|
||||
function openDecision(row: PlatformTenantResource, type: 'approve' | 'reject') {
|
||||
async function openDecision(row: PlatformTenantResource, type: 'approve' | 'reject') {
|
||||
if (reviewSubmitting.value) return
|
||||
const review = pendingReviewByTenantId.value.get(String(row.id))
|
||||
if (!review) {
|
||||
@@ -830,9 +844,60 @@ function openDecision(row: PlatformTenantResource, type: 'approve' | 'reject') {
|
||||
decision.type = type
|
||||
decision.comment = type === 'approve' ? '企业资料与必填资质完整、清晰,审核通过' : ''
|
||||
decisionOpen.value = true
|
||||
await loadReviewQualifications()
|
||||
void nextTick(() => decisionFormRef.value?.clearValidate('comment'))
|
||||
}
|
||||
|
||||
async function loadReviewQualifications() {
|
||||
const review = selectedReview.value
|
||||
if (!review) return
|
||||
reviewQualifications.value = []
|
||||
reviewQualificationsError.value = null
|
||||
reviewQualificationsLoading.value = true
|
||||
try {
|
||||
reviewQualifications.value = await listTenantQualifications(review.tenantId)
|
||||
} catch (error) {
|
||||
reviewQualificationsError.value = getApiErrorDetails(error, '客户资质加载失败,请重试后再审核')
|
||||
} finally {
|
||||
reviewQualificationsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearReviewPreview() {
|
||||
if (reviewPreviewUrl.value) URL.revokeObjectURL(reviewPreviewUrl.value)
|
||||
reviewPreviewOpen.value = false
|
||||
reviewPreviewUrl.value = ''
|
||||
reviewPreviewName.value = ''
|
||||
}
|
||||
|
||||
async function loadReviewDocument(item: PlatformTenantQualification, download: boolean) {
|
||||
const review = selectedReview.value
|
||||
if (!review || reviewDocumentLoading.value) return
|
||||
const fileName = qualificationName(item)
|
||||
reviewDocumentLoading.value = String(item.id)
|
||||
try {
|
||||
const blob = await downloadTenantQualification(review.tenantId, item.documentUrl)
|
||||
const url = URL.createObjectURL(blob)
|
||||
if (download) {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
clearReviewPreview()
|
||||
reviewPreviewName.value = fileName
|
||||
reviewPreviewKind.value = blob.type === 'application/pdf' || fileName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
|
||||
reviewPreviewUrl.value = url
|
||||
reviewPreviewOpen.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, '资质文件读取失败,请稍后重试'))
|
||||
} finally {
|
||||
reviewDocumentLoading.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDecision() {
|
||||
const review = selectedReview.value
|
||||
if (!review || !decisionFormRef.value || reviewSubmitting.value) return
|
||||
@@ -931,6 +996,7 @@ watch(() => decision.type, (type) => {
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
clearQualificationPreviews()
|
||||
clearReviewPreview()
|
||||
impersonationCleanup?.()
|
||||
})
|
||||
onMounted(() => { void loadCustomers() })
|
||||
@@ -951,8 +1017,8 @@ onMounted(() => { void loadCustomers() })
|
||||
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers"><Download :size="16" />导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增客户</el-button></div></template>
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button v-if="access.can('platform:customer:view')" class="customer-action-button customer-action-detail" size="small" @click="showDetail(row as PlatformTenantResource)"><Eye :size="14" />详情</el-button>
|
||||
<el-button v-if="access.can('platform:customer:view') && access.can('platform:customer:edit')" class="customer-action-button customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="14" />编辑</el-button>
|
||||
<el-button v-if="access.can('platform:customer:list')" class="customer-action-button customer-action-detail" size="small" @click="showDetail(row as PlatformTenantResource)"><Eye :size="14" />详情</el-button>
|
||||
<el-button v-if="access.can('platform:customer:edit')" class="customer-action-button customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="14" />编辑</el-button>
|
||||
<el-button v-if="hasReviewAction(row as PlatformTenantResource)" class="customer-action-button customer-action-review" size="small" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')"><ShieldCheck :size="14" />审核</el-button>
|
||||
<el-button v-for="action in customerSecondaryActions(row as PlatformTenantResource)" :key="action" :class="[`customer-action-button`, `customer-action-${action}`, { 'is-enable': action === 'status' && (row as PlatformTenantResource).status !== 'ACTIVE', 'is-disable': action === 'status' && (row as PlatformTenantResource).status === 'ACTIVE' }]" size="small" :disabled="customerActionDisabled(action)" @click="handleMoreAction(row as PlatformTenantResource, action)"><component :is="customerRowActionIcons[action]" :size="14" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-button>
|
||||
</div>
|
||||
@@ -1043,9 +1109,12 @@ onMounted(() => { void loadCustomers() })
|
||||
</section>
|
||||
<section class="review-dialog-section">
|
||||
<h3>资质文件</h3>
|
||||
<div v-if="reviewQualificationsLoading" class="review-document-state">正在加载资质文件...</div>
|
||||
<div v-else-if="reviewQualificationsError" class="review-document-error" role="alert"><span>{{ reviewQualificationsError.message }}</span><el-button link type="primary" @click="loadReviewQualifications">重试</el-button></div>
|
||||
<div class="review-document-table" role="table" aria-label="待审核客户资质">
|
||||
<div class="review-document-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">证书编号</span><span role="columnheader">状态</span></div>
|
||||
<div class="review-document-row" role="row"><span role="cell">{{ selectedReview.qualificationType || '企业资质' }}</span><span role="cell">{{ selectedReview.certificateNo || '—' }}</span><span role="cell">待审核</span></div>
|
||||
<div class="review-document-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">文件名称</span><span role="columnheader">操作</span></div>
|
||||
<div v-for="item in reviewQualifications" :key="String(item.id)" class="review-document-row" role="row"><span role="cell">{{ qualificationLabel(item.qualificationType) }}</span><span role="cell">{{ qualificationName(item) }}</span><span role="cell" class="review-document-actions"><el-button link type="primary" :loading="reviewDocumentLoading === String(item.id)" @click="loadReviewDocument(item, false)"><Eye :size="14" />预览</el-button><el-button link :disabled="Boolean(reviewDocumentLoading)" @click="loadReviewDocument(item, true)"><Download :size="14" />下载</el-button></span></div>
|
||||
<div v-if="!reviewQualificationsLoading && !reviewQualificationsError && reviewQualifications.length === 0" class="review-document-empty">暂无资质文件</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1056,6 +1125,10 @@ onMounted(() => { void loadCustomers() })
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="reviewSubmitting" @click="decisionOpen = false">返回</el-button><el-button v-if="access.can('platform:customer:reject') && decision.type === 'reject'" type="danger" :loading="reviewSubmitting" @click="submitDecision">确认驳回</el-button><el-button v-if="access.can('platform:customer:approve') && decision.type === 'approve'" type="primary" :loading="reviewSubmitting" @click="submitDecision">审核通过</el-button></div></template>
|
||||
</el-dialog>
|
||||
|
||||
<AppDialog v-model="reviewPreviewOpen" :title="reviewPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" destroy-on-close :show-confirm="false" cancel-text="关闭预览" @closed="clearReviewPreview">
|
||||
<div class="qualification-preview"><img v-if="reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></div>
|
||||
</AppDialog>
|
||||
|
||||
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(520px, calc(100vw - 28px))" class="customer-password-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
||||
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
|
||||
<el-alert v-if="passwordError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ passwordError.message }}</span><ApiErrorMeta :details="passwordError" /></div></template></el-alert>
|
||||
@@ -1152,11 +1225,16 @@ onMounted(() => { void loadCustomers() })
|
||||
.review-info-grid dt { color:#64748b; font-size:12px; }
|
||||
.review-info-grid dd { min-width:0; margin:0; overflow-wrap:anywhere; color:#1e293b; font-size:13px; }
|
||||
.review-document-table { overflow:hidden; border:1px solid #e4e4e7; border-radius:6px; }
|
||||
.review-document-header,.review-document-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) 76px; }
|
||||
.review-document-header,.review-document-row { display:grid; grid-template-columns:minmax(120px,.8fr) minmax(140px,1fr) 150px; }
|
||||
.review-document-header { background:rgba(102,126,234,.08); color:#52525b; font-size:12px; font-weight:600; }
|
||||
.review-document-header > span,.review-document-row > span { min-width:0; padding:9px 10px; overflow-wrap:anywhere; border-right:1px solid #e4e4e7; }
|
||||
.review-document-header > span:last-child,.review-document-row > span:last-child { border-right:0; }
|
||||
.review-document-row { border-top:1px solid #e4e4e7; color:#334155; font-size:13px; }
|
||||
.review-document-actions { display:flex; align-items:center; gap:8px; }
|
||||
.review-document-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.review-document-state,.review-document-error,.review-document-empty { padding:14px; color:var(--cc-muted); font-size:12px; }
|
||||
.review-document-error { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px; border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; }
|
||||
.review-document-empty { text-align:center; }
|
||||
.decision-summary > div > span { display:block; margin-top:4px; }.decision-type { margin:0 0 16px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:8px; }.dialog-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; }.archive-confirm { display:grid; gap:8px; margin:16px 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:12px; }
|
||||
.approval-result-warning { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong,.approval-result-warning span { display:block; } .approval-result-warning strong { font-size:14px; } .approval-result-warning span { margin-top:4px; font-size:12px; line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); border-radius:8px; } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:12px; padding:12px 14px; } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:12px; } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:10px; margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:14px; font-weight:700; }
|
||||
@media (max-width:720px) { .customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.review-dialog-grid { grid-template-columns:1fr; gap:18px; }.review-info-grid > div { grid-template-columns:92px minmax(0,1fr); }.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
|
||||
|
||||
@@ -71,7 +71,7 @@ onMounted(() => { void load() })
|
||||
<template v-if="error" #state><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template>
|
||||
<section v-if="!error" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border stripe empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
|
||||
<template #actions="{ row }"><el-button v-if="access.can('platform:customer:edit')" size="small" plain type="primary" @click="openEdit(row as OverseasStorageFeeConfig)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #actions="{ row }"><el-button v-if="access.can('platform:overseas-storage-fee:edit')" size="small" plain type="primary" @click="openEdit(row as OverseasStorageFeeConfig)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #storage="{ row }"><div class="tier-list"><div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'STORAGE')" :key="tier.id" class="tier-item"><span>{{ label(tier) }}</span><strong>{{ tier.unitPrice }} {{ (row as OverseasStorageFeeConfig).currency }}</strong></div></div></template>
|
||||
<template #returnStorage="{ row }"><div class="tier-list"><div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'RETURN_STORAGE')" :key="tier.id" class="tier-item"><span>{{ label(tier) }}</span><strong>{{ tier.unitPrice }} {{ (row as OverseasStorageFeeConfig).currency }}</strong></div></div></template>
|
||||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
|
||||
|
||||
@@ -17,7 +17,7 @@ export const customerRoutes = [
|
||||
path: 'customers/:tenantId',
|
||||
name: 'customer-detail',
|
||||
component: () => import('@/modules/customers/pages/CustomerDetailView.vue'),
|
||||
meta: { title: '客户详情', description: '查看企业资料、关联用户和业务记录', permission: 'platform:customer:view', showPageHeading: false },
|
||||
meta: { title: '客户详情', description: '查看企业资料、关联用户和业务记录', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:view'], showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'customers/reviews',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { createDomesticContainerOutbound, listDomesticContainerInventories } from './domesticContainers'
|
||||
vi.mock('@/shared/api/http', async (original) => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||
const envelope = <T>(data: T) => ({ code: '200', message: 'ok', data })
|
||||
describe('domestic container api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('normalizes inventory pagination and summary from the real endpoint', async () => { vi.mocked(http.get).mockResolvedValue(envelope({ records: [], page: '1', size: '20', total: '0', pages: '0', summary: { inboundBoxes: 2, outboundBoxes: 1, availableBoxes: 1, inboundVolumeCbm: 3.25 } })); const result = await listDomesticContainerInventories({ page: 1, size: 20 }); expect(http.get).toHaveBeenCalledWith('/platform/v1/domestic-containers/inventories', { params: { page: 1, size: 20 } }); expect(result).toMatchObject({ page: 1, total: 0, summary: { availableBoxes: 1 } }) })
|
||||
it('passes idempotency key and inventory version without local deduction', async () => { vi.mocked(http.post).mockResolvedValue(envelope({ id: 1, orderNo: 'ZGCK1', replayed: false })); await createDomesticContainerOutbound({ warehouseId: '2', containerNo: 'MSCU1', boxCount: 3, outboundTime: '2026-08-13T10:00:00', status: 'COMPLETED', idempotencyKey: 'stable-key', inventoryVersion: 7 }); expect(http.post).toHaveBeenCalledWith('/platform/v1/domestic-containers/outbounds', expect.objectContaining({ idempotencyKey: 'stable-key', inventoryVersion: 7 })) })
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { ContainerOrder, CreateInboundRequest, CreateOutboundRequest, CreateResponse, DomesticContainerFilters, InventoryDetail, InventoryPage, PageResult } from '../types'
|
||||
|
||||
const basePath = '/platform/v1/domestic-containers'
|
||||
type PagePayload<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: number | string; size: number | string; total: number | string; pages: number | string }
|
||||
type InventoryPayload = Omit<InventoryPage, 'page' | 'size' | 'total' | 'pages'> & { page: number | string; size: number | string; total: number | string; pages: number | string; summary?: Partial<InventoryPage['summary']> }
|
||||
|
||||
function pageResult<T>(value: PagePayload<T>): PageResult<T> { return { ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) } }
|
||||
|
||||
export async function listDomesticContainerInbounds(query: DomesticContainerFilters) {
|
||||
const response = await http.get<ApiEnvelope<PagePayload<ContainerOrder>>, ApiEnvelope<PagePayload<ContainerOrder>>>(`${basePath}/inbounds`, { params: query })
|
||||
return pageResult(unwrapApiResponse(response))
|
||||
}
|
||||
export async function listDomesticContainerOutbounds(query: DomesticContainerFilters) {
|
||||
const response = await http.get<ApiEnvelope<PagePayload<ContainerOrder>>, ApiEnvelope<PagePayload<ContainerOrder>>>(`${basePath}/outbounds`, { params: query })
|
||||
return pageResult(unwrapApiResponse(response))
|
||||
}
|
||||
export async function listDomesticContainerInventories(query: DomesticContainerFilters) {
|
||||
const response = await http.get<ApiEnvelope<InventoryPayload>, ApiEnvelope<InventoryPayload>>(`${basePath}/inventories`, { params: query })
|
||||
const result = unwrapApiResponse(response)
|
||||
return { ...pageResult(result), summary: { inboundBoxes: Number(result.summary?.inboundBoxes ?? 0), outboundBoxes: Number(result.summary?.outboundBoxes ?? 0), availableBoxes: Number(result.summary?.availableBoxes ?? 0), inboundVolumeCbm: Number(result.summary?.inboundVolumeCbm ?? 0) } }
|
||||
}
|
||||
export async function getDomesticContainerInventory(inventoryId: number | string) {
|
||||
const response = await http.get<ApiEnvelope<InventoryDetail>, ApiEnvelope<InventoryDetail>>(`${basePath}/inventories/${inventoryId}`)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function createDomesticContainerInbound(payload: CreateInboundRequest) {
|
||||
const response = await http.post<ApiEnvelope<CreateResponse>, ApiEnvelope<CreateResponse>>(`${basePath}/inbounds`, payload)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function createDomesticContainerOutbound(payload: CreateOutboundRequest) {
|
||||
const response = await http.post<ApiEnvelope<CreateResponse>, ApiEnvelope<CreateResponse>>(`${basePath}/outbounds`, payload)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './api/domesticContainers'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getDomesticContainerInventory } from '../api/domesticContainers'
|
||||
import type { InventoryDetail, TransactionRecord } from '../types'
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const detail = ref<InventoryDetail | null>(null)
|
||||
const transactionColumns: DataTableColumn[] = [{ prop: 'businessType', label: '类型', width: 100, slotName: 'businessType' }, { prop: 'orderNo', label: '单号', minWidth: 160 }, { prop: 'quantity', label: '数量', width: 100, align: 'right', formatter: (row: TransactionRecord) => `${row.quantity} 箱` }, { prop: 'operationTime', label: '操作时间', minWidth: 170, formatter: (row: TransactionRecord) => row.operationTime.replace('T', ' ').slice(0, 16) }, { prop: 'remark', label: '备注', minWidth: 180, formatter: (row: TransactionRecord) => row.remark || '—' }]
|
||||
function inventoryStatus(status: string) { return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as Record<string, string>)[status] || status }
|
||||
async function load() { loading.value = true; error.value = null; try { detail.value = await getDomesticContainerInventory(String(route.params.inventoryId)) } catch (cause) { error.value = getApiErrorDetails(cause, '整柜库存详情加载失败') } finally { loading.value = false } }
|
||||
function back() { void router.push({ name: 'domestic-containers' }) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template><AdminPage as="div" class="inventory-detail-page"><header class="detail-header"><div><el-button aria-label="返回整柜业务" @click="back"><ArrowLeft :size="16" />返回</el-button><h1>{{ detail?.inventory.containerNo || '整柜库存详情' }}</h1><StatusBadge v-if="detail" :status="inventoryStatus(detail.inventory.inventoryStatus)" /></div><p>查看整柜库存快照与不可变交易记录</p></header><LoadErrorState v-if="error" title="整柜库存详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><template v-else-if="detail"><section class="detail-grid"><div class="surface-panel detail-section"><h2>货物信息</h2><dl><div><dt>货物名称</dt><dd>{{ detail.inventory.cargoName }}</dd></div><div><dt>集装箱号</dt><dd>{{ detail.inventory.containerNo }}</dd></div><div><dt>国内仓</dt><dd>{{ detail.inventory.warehouseName }}</dd></div><div><dt>入库单数量</dt><dd>{{ detail.inboundOrderCount }}</dd></div><div><dt>出库单数量</dt><dd>{{ detail.outboundOrderCount }}</dd></div><div><dt>累计入库体积</dt><dd>{{ detail.inventory.inboundVolumeCbm.toFixed(2) }} m³</dd></div></dl></div><div class="surface-panel detail-section"><h2>库存信息</h2><dl><div><dt>入库箱数</dt><dd>{{ detail.inventory.inboundBoxes }} 箱</dd></div><div><dt>已出库箱数</dt><dd>{{ detail.inventory.outboundBoxes }} 箱</dd></div><div><dt>当前库存</dt><dd>{{ detail.inventory.availableBoxes }} 箱</dd></div><div><dt>库存状态</dt><dd>{{ inventoryStatus(detail.inventory.inventoryStatus) }}</dd></div><div><dt>最后更新时间</dt><dd>{{ detail.inventory.updatedAt.replace('T', ' ').slice(0, 16) }}</dd></div><div><dt>库存版本</dt><dd>{{ detail.inventory.version }}</dd></div></dl></div></section><section class="surface-panel table-panel transactions"><h2>进销存交易记录</h2><DataTable :data="detail.transactions" :columns="transactionColumns" row-key="id" stripe column-settings-mode="hide" empty-text="暂无进销存流水"><template #businessType="{ row }"><StatusBadge :status="row.businessType === 'INBOUND' ? '入库' : '出库'" /></template></DataTable></section></template><div v-else class="surface-panel detail-loading" v-loading="loading">正在加载整柜库存详情</div></AdminPage></template>
|
||||
<style scoped>.inventory-detail-page{display:grid;min-width:0;gap:16px}.detail-header{padding:18px 20px;background:#fff;border:1px solid var(--cc-border)}.detail-header>div{display:flex;align-items:center;flex-wrap:wrap;gap:12px}.detail-header h1{margin:0;font-size:20px;letter-spacing:0}.detail-header p{margin:10px 0 0;color:var(--cc-muted)}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.detail-section{padding:18px}.detail-section h2,.transactions h2{margin:0 0 16px;font-size:16px;letter-spacing:0}.detail-section dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--cc-border);border-left:1px solid var(--cc-border)}.detail-section dl>div{min-width:0;padding:12px;border-right:1px solid var(--cc-border);border-bottom:1px solid var(--cc-border)}dt{font-size:12px;color:var(--cc-muted)}dd{margin:5px 0 0;overflow-wrap:anywhere}.transactions{padding:18px}.detail-loading{min-height:180px;padding:24px;color:var(--cc-muted)}@media(max-width:720px){.detail-grid{grid-template-columns:1fr}}@media(max-width:360px){.detail-header,.detail-section,.transactions{padding:12px}.detail-section dl{grid-template-columns:1fr}.detail-header h1{font-size:18px;width:100%}}</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/domestic-containers/pages/DomesticContainersView.vue'), 'utf8')
|
||||
describe('DomesticContainersView', () => {
|
||||
it('uses formal shared list and centered dialog components', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain('AppDialog'); expect(source).toContain("fixed: 'left'"); expect(source).toContain('width="min(720px, calc(100vw - 28px))"') })
|
||||
it('gates create actions with backend permissions', () => { expect(source).toContain("access.can('platform:domestic-container:inbound:create')"); expect(source).toContain("access.can('platform:domestic-container:outbound:create')") })
|
||||
it('loads only active domestic warehouse candidates and surfaces errors', () => { expect(source).toContain("listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })"); expect(source).toContain('国内仓候选加载失败') })
|
||||
it('keeps idempotency key stable through retry and handles conflicts by refreshing inventory', () => { expect(source).toContain('idempotencyKey.value'); expect(source).toContain("submitError.value.code === 'A0409'"); expect(source).toContain('await loadCandidates()'); expect(source).not.toContain('localStorage') })
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, Plus } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { createDomesticContainerInbound, createDomesticContainerOutbound, getDomesticContainerInventory, listDomesticContainerInbounds, listDomesticContainerInventories, listDomesticContainerOutbounds } from '../api/domesticContainers'
|
||||
import type { ContainerOrder, ContainerOrderStatus, InventoryRecord, InventorySummary } from '../types'
|
||||
|
||||
type TabName = 'inbound' | 'outbound' | 'inventory'
|
||||
const router = useRouter(); const access = useAccessPreviewStore(); const activeTab = ref<TabName>('inbound')
|
||||
const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const rows = ref<(ContainerOrder | InventoryRecord)[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const summary = ref<InventorySummary>({ inboundBoxes: 0, outboundBoxes: 0, availableBoxes: 0, inboundVolumeCbm: 0 })
|
||||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([]); const warehouseLoading = ref(false); const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ warehouseId: '', orderNo: '', containerNo: '', cargoName: '', status: '' })
|
||||
const appliedFilters = ref<QueryModel>({ ...filters.value })
|
||||
const warehouseOptions = computed(() => warehouses.value.map((item) => ({ label: item.warehouseName, value: String(item.id) })))
|
||||
const queryFields = computed<QueryField[]>(() => {
|
||||
const base: QueryField[] = [{ key: 'warehouseId', type: 'select', label: '国内仓', placeholder: warehouseError.value ? '国内仓加载失败' : '全部国内仓', options: warehouseOptions.value, props: { loading: warehouseLoading.value, disabled: Boolean(warehouseError.value) } }]
|
||||
if (activeTab.value !== 'inventory') base.push({ key: 'orderNo', type: 'input', label: '单号', placeholder: activeTab.value === 'inbound' ? '入库单号' : '出库单号' })
|
||||
base.push({ key: 'containerNo', type: 'input', label: '集装箱号', placeholder: '集装箱号' }, { key: 'cargoName', type: 'input', label: '货物名称', placeholder: '货物名称' })
|
||||
base.push({ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: activeTab.value === 'inventory' ? [{ label: '在库', value: 'IN_STOCK' }, { label: '部分出库', value: 'PARTIALLY_OUTBOUND' }, { label: '已出库', value: 'OUTBOUND' }] : [{ label: activeTab.value === 'inbound' ? '待入库' : '待出库', value: 'PENDING' }, { label: activeTab.value === 'inbound' ? '已入库' : '已出库', value: 'COMPLETED' }] })
|
||||
return base
|
||||
})
|
||||
const columns = computed<DataTableColumn[]>(() => activeTab.value === 'inventory' ? [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 112, slotName: 'actions', hideable: false, movable: false }, { prop: 'containerNo', label: '集装箱号', minWidth: 150 }, { prop: 'warehouseName', label: '国内仓', minWidth: 150 }, { prop: 'cargoName', label: '货物名称', minWidth: 150 }, { prop: 'inboundBoxes', label: '入库箱数', width: 105, align: 'right' }, { prop: 'outboundBoxes', label: '已出库箱数', width: 115, align: 'right' }, { prop: 'availableBoxes', label: '当前库存', width: 105, align: 'right' }, { prop: 'inboundVolumeCbm', label: '累计入库体积(m³)', width: 155, align: 'right' }, { prop: 'inventoryStatus', label: '状态', width: 110, slotName: 'status' },
|
||||
] : [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 112, slotName: 'actions', hideable: false, movable: false }, { prop: 'orderNo', label: activeTab.value === 'inbound' ? '入库单号' : '出库单号', minWidth: 155 }, { prop: 'warehouseName', label: '国内仓', minWidth: 150 }, { prop: 'containerNo', label: '集装箱号', minWidth: 150 }, { prop: 'cargoName', label: '货物名称', minWidth: 150 }, { prop: 'boxCount', label: activeTab.value === 'inbound' ? '箱子数量' : '出库箱数', width: 105, align: 'right' }, ...(activeTab.value === 'inbound' ? [{ prop: 'volumeCbm', label: '体积(m³)', width: 105, align: 'right' }] : []), { prop: 'operationTime', label: activeTab.value === 'inbound' ? '入库时间' : '出库时间', minWidth: 165, formatter: (row: ContainerOrder) => displayTime(row.operationTime) }, { prop: 'status', label: '状态', width: 105, slotName: 'status' },
|
||||
])
|
||||
function displayTime(value: string) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function statusLabel(row: ContainerOrder | InventoryRecord) { if ('inventoryStatus' in row) return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as const)[row.inventoryStatus]; return row.status === 'PENDING' ? (activeTab.value === 'inbound' ? '待入库' : '待出库') : (activeTab.value === 'inbound' ? '已入库' : '已出库') }
|
||||
function queryParams() { const model = appliedFilters.value; return { page: page.value, size: pageSize.value, warehouseId: String(model.warehouseId || '') || undefined, orderNo: activeTab.value === 'inventory' ? undefined : String(model.orderNo || '') || undefined, containerNo: String(model.containerNo || '') || undefined, cargoName: String(model.cargoName || '') || undefined, status: String(model.status || '') || undefined } }
|
||||
async function load() { loading.value = true; loadError.value = null; try { const result = activeTab.value === 'inbound' ? await listDomesticContainerInbounds(queryParams()) : activeTab.value === 'outbound' ? await listDomesticContainerOutbounds(queryParams()) : await listDomesticContainerInventories(queryParams()); rows.value = result.records; total.value = result.total; if ('summary' in result) summary.value = result.summary } catch (cause) { loadError.value = getApiErrorDetails(cause, '整柜业务加载失败') } finally { loading.value = false } }
|
||||
async function loadWarehouses() { warehouseLoading.value = true; warehouseError.value = null; try { warehouses.value = (await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })).filter((item) => item.warehouseType === 'DOMESTIC' && item.status === 'ACTIVE') } catch (cause) { warehouseError.value = getApiErrorDetails(cause, '国内仓候选加载失败') } finally { warehouseLoading.value = false } }
|
||||
function search(model = filters.value) { appliedFilters.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function switchTab(name: string | number) { activeTab.value = name as TabName; filters.value = { warehouseId: filters.value.warehouseId || '', orderNo: '', containerNo: '', cargoName: '', status: '' }; appliedFilters.value = { ...filters.value }; page.value = 1; void load() }
|
||||
function viewInventory(row: ContainerOrder | InventoryRecord) { if ('inventoryStatus' in row) void router.push({ name: 'domestic-container-inventory-detail', params: { inventoryId: String(row.id) } }); else void findAndOpenInventory(row) }
|
||||
async function findAndOpenInventory(order: ContainerOrder) { try { const result = await listDomesticContainerInventories({ page: 1, size: 20, warehouseId: String(order.warehouseId), containerNo: order.containerNo }); const inventory = result.records.find((item) => String(item.warehouseId) === String(order.warehouseId) && item.containerNo === order.containerNo); if (!inventory) return ElMessage.warning('该单据尚未形成整柜库存'); await router.push({ name: 'domestic-container-inventory-detail', params: { inventoryId: String(inventory.id) } }) } catch (cause) { loadError.value = getApiErrorDetails(cause, '整柜库存定位失败') } }
|
||||
|
||||
const dialogType = ref<'inbound' | 'outbound'>('inbound'); const dialogOpen = ref(false); const saving = ref(false); const submitError = ref<ApiErrorDetails | null>(null); const formRef = ref<FormInstance>(); const idempotencyKey = ref('')
|
||||
const inboundForm = reactive({ warehouseId: '', cargoName: '', containerNo: '', boxCount: 1, volumeCbm: 0.01, operationTime: '', status: 'COMPLETED' as ContainerOrderStatus, remark: '' })
|
||||
const outboundForm = reactive({ warehouseId: '', inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined as number | undefined, boxCount: 1, operationTime: '', status: 'COMPLETED' as ContainerOrderStatus, remark: '' })
|
||||
const inventoryCandidates = ref<InventoryRecord[]>([]); const candidateLoading = ref(false); const candidateError = ref<ApiErrorDetails | null>(null)
|
||||
const currentForm = computed(() => dialogType.value === 'inbound' ? inboundForm : outboundForm)
|
||||
const rules: FormRules = { warehouseId: [{ required: true, message: '请选择国内仓', trigger: 'change' }], cargoName: [{ required: true, message: '请输入货物名称', trigger: 'blur' }], containerNo: [{ required: true, message: '请选择或输入集装箱号', trigger: 'change' }], boxCount: [{ required: true, type: 'integer', min: 1, message: '箱数必须为正整数', trigger: 'change' }], volumeCbm: [{ required: true, type: 'number', min: 0.0001, message: '体积必须大于等于 0.0001', trigger: 'change' }], operationTime: [{ required: true, message: '请选择操作时间', trigger: 'change' }], status: [{ required: true, message: '请选择状态', trigger: 'change' }] }
|
||||
function localDateTime() { const offset = new Date().getTimezoneOffset() * 60_000; return `${new Date(Date.now() - offset).toISOString().slice(0, 16)}:00` }
|
||||
function newIdempotencyKey(type: string) { return `${type}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`}` }
|
||||
function openInbound() { dialogType.value = 'inbound'; Object.assign(inboundForm, { warehouseId: '', cargoName: '', containerNo: '', boxCount: 1, volumeCbm: 0.01, operationTime: localDateTime(), status: 'COMPLETED', remark: '' }); idempotencyKey.value = newIdempotencyKey('inbound'); submitError.value = null; dialogOpen.value = true }
|
||||
function openOutbound() { dialogType.value = 'outbound'; Object.assign(outboundForm, { warehouseId: '', inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined, boxCount: 1, operationTime: localDateTime(), status: 'COMPLETED', remark: '' }); inventoryCandidates.value = []; candidateError.value = null; idempotencyKey.value = newIdempotencyKey('outbound'); submitError.value = null; dialogOpen.value = true }
|
||||
async function loadCandidates() { inventoryCandidates.value = []; Object.assign(outboundForm, { inventoryId: '', containerNo: '', cargoName: '', availableBoxes: 0, inventoryVersion: undefined }); if (!outboundForm.warehouseId) return; candidateLoading.value = true; candidateError.value = null; try { const result = await listDomesticContainerInventories({ page: 1, size: 200, warehouseId: outboundForm.warehouseId }); inventoryCandidates.value = result.records.filter((item) => item.availableBoxes > 0) } catch (cause) { candidateError.value = getApiErrorDetails(cause, '可出库整柜库存加载失败') } finally { candidateLoading.value = false } }
|
||||
async function selectInventory(id: string) { const selected = inventoryCandidates.value.find((item) => String(item.id) === id); if (!selected) return; Object.assign(outboundForm, { inventoryId: id, containerNo: selected.containerNo, cargoName: selected.cargoName, availableBoxes: selected.availableBoxes, inventoryVersion: selected.version }); try { const detail = await getDomesticContainerInventory(id); Object.assign(outboundForm, { availableBoxes: detail.inventory.availableBoxes, inventoryVersion: detail.inventory.version }) } catch (cause) { candidateError.value = getApiErrorDetails(cause, '库存最新版本加载失败') } }
|
||||
async function submit() { if (!await formRef.value?.validate().catch(() => false)) return; if (dialogType.value === 'outbound' && outboundForm.status === 'COMPLETED' && outboundForm.boxCount > outboundForm.availableBoxes) { submitError.value = { message: `库存不足,当前可用 ${outboundForm.availableBoxes} 箱`, code: 'A0409' }; return } saving.value = true; submitError.value = null; try { const result = dialogType.value === 'inbound' ? await createDomesticContainerInbound({ warehouseId: inboundForm.warehouseId, cargoName: inboundForm.cargoName.trim(), containerNo: inboundForm.containerNo.trim().toUpperCase(), boxCount: inboundForm.boxCount, volumeCbm: inboundForm.volumeCbm, inboundTime: inboundForm.operationTime, status: inboundForm.status, remark: inboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value }) : await createDomesticContainerOutbound({ warehouseId: outboundForm.warehouseId, containerNo: outboundForm.containerNo, boxCount: outboundForm.boxCount, outboundTime: outboundForm.operationTime, status: outboundForm.status, remark: outboundForm.remark.trim() || undefined, idempotencyKey: idempotencyKey.value, inventoryVersion: outboundForm.status === 'COMPLETED' ? outboundForm.inventoryVersion : undefined }); ElMessage.success(result.replayed ? `请求已处理,单号 ${result.orderNo}` : `创建成功,单号 ${result.orderNo}`); dialogOpen.value = false; await load() } catch (cause) { submitError.value = getApiErrorDetails(cause, '整柜单据创建失败'); if (dialogType.value === 'outbound' && submitError.value.code === 'A0409') await loadCandidates() } finally { saving.value = false } }
|
||||
watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) })
|
||||
</script>
|
||||
|
||||
<template><AdminPage as="div" class="domestic-containers-page"><el-tabs :model-value="activeTab" class="business-tabs" @tab-change="switchTab"><el-tab-pane label="整柜入库单" name="inbound" /><el-tab-pane label="整柜出库单" name="outbound" /><el-tab-pane label="整柜库存" name="inventory" /></el-tabs>
|
||||
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></template><template v-if="loadError" #state><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><section v-if="!loadError" class="surface-panel table-panel"><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'"><template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><el-button size="small" type="primary" plain @click="viewInventory(row as ContainerOrder | InventoryRecord)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
|
||||
|
||||
<style scoped>.domestic-containers-page{min-width:0}.business-tabs{margin-bottom:16px;background:#fff;border:1px solid var(--cc-border);padding:0 16px}.inventory-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;margin:0 0 12px;background:var(--cc-border);border:1px solid var(--cc-border)}.inventory-summary>div{display:grid;gap:6px;padding:14px 16px;background:#fff}.inventory-summary span{font-size:12px;color:var(--cc-muted)}.inventory-summary strong{font-size:20px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:360px){.business-tabs{padding:0 8px}.business-tabs :deep(.el-tabs__item){padding:0 8px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary strong{font-size:18px}}</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const domesticContainerRoutes = [
|
||||
{ path: 'domestic-containers', name: 'domestic-containers', component: () => import('./pages/DomesticContainersView.vue'), meta: { title: '整柜业务', description: '管理国内仓整柜入库、出库与库存', permission: 'platform:domestic-container:list' } },
|
||||
{ path: 'domestic-containers/inventories/:inventoryId', name: 'domestic-container-inventory-detail', component: () => import('./pages/DomesticContainerInventoryDetailView.vue'), meta: { title: '整柜库存详情', description: '查看整柜库存快照与不可变交易记录', permission: 'platform:domestic-container:list', showPageHeading: false } },
|
||||
] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,63 @@
|
||||
export type ContainerOrderStatus = 'PENDING' | 'COMPLETED'
|
||||
export type InventoryStatus = 'IN_STOCK' | 'PARTIALLY_OUTBOUND' | 'OUTBOUND'
|
||||
export type BusinessType = 'INBOUND' | 'OUTBOUND'
|
||||
|
||||
export interface DomesticContainerFilters {
|
||||
page: number
|
||||
size: number
|
||||
warehouseId?: string
|
||||
orderNo?: string
|
||||
containerNo?: string
|
||||
cargoName?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface ContainerOrder {
|
||||
id: number | string
|
||||
businessType: BusinessType
|
||||
orderNo: string
|
||||
warehouseId: number | string
|
||||
warehouseName: string
|
||||
containerNo: string
|
||||
cargoName: string
|
||||
boxCount: number
|
||||
volumeCbm: number
|
||||
operationTime: string
|
||||
status: ContainerOrderStatus
|
||||
remark?: string | null
|
||||
version: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface InventoryRecord {
|
||||
id: number | string
|
||||
warehouseId: number | string
|
||||
warehouseName: string
|
||||
containerNo: string
|
||||
cargoName: string
|
||||
inboundBoxes: number
|
||||
outboundBoxes: number
|
||||
availableBoxes: number
|
||||
inboundVolumeCbm: number
|
||||
inventoryStatus: InventoryStatus
|
||||
version: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface TransactionRecord {
|
||||
id: number | string
|
||||
businessType: BusinessType
|
||||
orderNo: string
|
||||
quantity: number
|
||||
operationTime: string
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
export interface InventorySummary { inboundBoxes: number; outboundBoxes: number; availableBoxes: number; inboundVolumeCbm: number }
|
||||
export interface InventoryPage extends PageResult<InventoryRecord> { summary: InventorySummary }
|
||||
export interface InventoryDetail { inventory: InventoryRecord; inboundOrderCount: number; outboundOrderCount: number; transactions: TransactionRecord[] }
|
||||
|
||||
export interface CreateResponse { id: number | string; orderNo: string; replayed: boolean }
|
||||
export interface CreateInboundRequest { warehouseId: number | string; cargoName: string; containerNo: string; boxCount: number; volumeCbm: number; inboundTime: string; status: ContainerOrderStatus; remark?: string; idempotencyKey: string }
|
||||
export interface CreateOutboundRequest { warehouseId: number | string; containerNo: string; boxCount: number; outboundTime: string; status: ContainerOrderStatus; remark?: string; idempotencyKey: string; inventoryVersion?: number }
|
||||
@@ -113,6 +113,13 @@ describe('DownloadCenterView', () => {
|
||||
expect(getPlatformExportTask).toHaveBeenCalledWith('9')
|
||||
expect(document.body.textContent).toContain('任务详情 · EXP9')
|
||||
expect(document.body.textContent).toContain('客户列表.xlsx')
|
||||
expect(document.querySelector('.el-drawer')).toBeNull()
|
||||
expect(document.querySelector('.el-dialog')).not.toBeNull()
|
||||
const detailDownload = [...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((item) => item.textContent?.trim() === '下载文件')
|
||||
detailDownload?.click()
|
||||
await flushPromises()
|
||||
expect(downloadPlatformExportTask).toHaveBeenCalledWith('9')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/modules/downloads/api/platformExportTasks'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
@@ -148,6 +148,10 @@ function closeDetail() {
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
||||
function downloadDetailTask() {
|
||||
if (detailTask.value?.status === 'SUCCEEDED') void downloadTask(detailTask.value)
|
||||
}
|
||||
|
||||
async function downloadTask(task: PlatformExportTask) {
|
||||
downloadingId.value = task.id
|
||||
try {
|
||||
@@ -270,12 +274,12 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer
|
||||
<AppDialog
|
||||
v-model="detailOpen"
|
||||
:title="detailTask ? `任务详情 · ${detailTask.taskNo}` : '任务详情'"
|
||||
size="min(620px, 94vw)"
|
||||
width="min(720px, calc(100vw - 28px))"
|
||||
:loading="detailLoading"
|
||||
:show-footer="false"
|
||||
:show-confirm="false"
|
||||
destroy-on-close
|
||||
@closed="closeDetail"
|
||||
>
|
||||
@@ -309,7 +313,18 @@ onBeforeUnmount(() => {
|
||||
<p>{{ detailTask.failureMessage }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
<template #footer="{ close }">
|
||||
<div class="app-dialog__footer">
|
||||
<el-button @click="close">关闭</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="downloadingId === detailTask?.id"
|
||||
:disabled="detailTask?.status !== 'SUCCEEDED'"
|
||||
@click="downloadDetailTask"
|
||||
>下载文件</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -57,8 +57,8 @@ describe('finance api', () => {
|
||||
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { customerId: 10001, totalReceivable: 300, totalReceived: 100, outstandingAmount: 200, entries: [] } })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ poolItemId: 9, customerId: 10001, direction: 'PAYABLE', sourceType: 'CONTAINER_COST', sourceId: 81, sourceVersion: 2, containerId: 81, feeItemName: '拖车费', amount: 680.5, status: 'OPEN' }] })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [] })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ id: 81, customerId: 10001, settlementNo: 'AR-81', receivableAmount: 300, receivedAmount: 100, outstandingAmount: 200, status: 'PARTIALLY_PAID', version: 2 }] })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ id: 71, customerId: 10001, settlementNo: 'AP-71', costType: 'CONTAINER_SHARED', containerId: 81, feeItemName: '拖车费', payableAmount: 680.5, paidAmount: 0, outstandingAmount: 680.5, status: 'UNPAID', version: 1 }] })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ settlementId: 81, customerId: 10001, settlementNo: 'AR-81', direction: 'RECEIVABLE', totalAmount: 300, paidAmount: 100, outstandingAmount: 200, generatedDate: '2026-08-11', status: 'PARTIALLY_PAID', version: 2 }] })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ settlementId: 71, customerId: 10001, settlementNo: 'AP-71', direction: 'PAYABLE', supplierName: '供应商A', totalAmount: 680.5, paidAmount: 0, outstandingAmount: 680.5, generatedDate: '2026-08-11', status: 'ISSUED', version: 1 }] })
|
||||
|
||||
const account = await getFinanceCustomerAccount('10001')
|
||||
const pool = await listFinanceFeePool('10001', 'PAYABLE')
|
||||
@@ -76,7 +76,7 @@ describe('finance api', () => {
|
||||
expect(account).toMatchObject({ customerId: '10001', currency: 'CNY', accountMode: 'MANUAL_RECEIVABLE_LEDGER', outstandingAmount: 200 })
|
||||
expect(pool[0]).toMatchObject({ poolItemId: '9', customerId: '10001', sourceId: '81', sourceVersion: 2, containerId: '81', feeItemName: '拖车费', amount: 680.5 })
|
||||
expect(receivables[0]).toMatchObject({ id: '81', settlementNo: 'AR-81', currency: 'CNY' })
|
||||
expect(payables[0]).toMatchObject({ id: '71', settlementNo: 'AP-71', feeItemName: '拖车费', payableAmount: 680.5, currency: 'CNY' })
|
||||
expect(payables[0]).toMatchObject({ id: '71', settlementNo: 'AP-71', supplierName: '供应商A', payableAmount: 680.5, currency: 'CNY' })
|
||||
})
|
||||
|
||||
it('derives the numeric fee source id without treating manual costs as reviewable sources', async () => {
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function listFinanceFeePool(customerId?: string, direction?: 'RECEI
|
||||
return unwrapApiResponse(response).map((item) => {
|
||||
const poolItemId = stringValue(item.poolItemId)
|
||||
const feeSourceId = stringValue(item.sourceType) === 'MANUAL_COST' ? null : (poolItemId.match(/^[A-Z_]+:(\d+)$/)?.[1] ?? null)
|
||||
return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
|
||||
return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ function normalizeReconciliationDetail(item: RawResource): FinanceReconciliation
|
||||
return {
|
||||
id: stringValue(item.statementId), billNo: stringValue(item.statementNo), customerId: stringValue(item.customerId), customerName: stringValue(item.customerName),
|
||||
billDate: stringValue(item.periodEnd), dueDate: '', periodStart: stringValue(item.periodStart), periodEnd: stringValue(item.periodEnd), creditDays: numberValue(item.creditDays), currency: 'CNY',
|
||||
totalAmount: numberValue(item.totalReceivable), paidAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), settled: Boolean(item.settled), status: stringValue(item.status) as FinanceBillResource['status'], version: numberValue(item.version), remark: stringValue(item.remark), lines,
|
||||
totalAmount: numberValue(item.totalReceivable), paidAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), settled: Boolean(item.settled), status: stringValue(item.status) as FinanceBillResource['status'], version: numberValue(item.version), remark: stringValue(item.remark), createdBy: stringValue(item.createdBy), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedAt: stringValue(item.updatedAt), lines,
|
||||
paymentHistory: (Array.isArray(item.paymentHistory) ? item.paymentHistory : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), createdBy: stringValue(row.createdBy), occurredAt: stringValue(row.occurredAt ?? row.createdAt), remark: stringValue(row.remark) || undefined } }),
|
||||
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, operatorId: stringValue(row.operatorId), occurredAt: stringValue(row.occurredAt) } }),
|
||||
}
|
||||
@@ -142,7 +142,7 @@ function normalizeReconciliationDetail(item: RawResource): FinanceReconciliation
|
||||
function normalizeSettlementDetail(item: RawResource): FinanceSettlementDetail {
|
||||
const summary = item.summary && typeof item.summary === 'object' ? item.summary as RawResource : item
|
||||
return {
|
||||
id: stringValue(summary.settlementId ?? summary.id), settlementNo: stringValue(summary.settlementNo), customerId: stringValue(summary.customerId), supplierId: summary.supplierId == null ? undefined : stringValue(summary.supplierId), reconciliationStatementId: summary.reconciliationStatementId == null ? undefined : stringValue(summary.reconciliationStatementId), direction: stringValue(summary.direction) as FinanceSettlementDetail['direction'],
|
||||
id: stringValue(summary.settlementId ?? summary.id), settlementNo: stringValue(summary.settlementNo), customerId: stringValue(summary.customerId), customerName: stringValue(summary.customerName), supplierId: summary.supplierId == null ? undefined : stringValue(summary.supplierId), supplierName: stringValue(summary.supplierName), reconciliationStatementId: summary.reconciliationStatementId == null ? undefined : stringValue(summary.reconciliationStatementId), businessPeriod: stringValue(summary.businessPeriod), generatedDate: stringValue(summary.generatedDate), dueDate: stringValue(summary.dueDate), paymentStatus: stringValue(summary.paymentStatus) as FinanceSettlementDetail['paymentStatus'], direction: stringValue(summary.direction) as FinanceSettlementDetail['direction'],
|
||||
amount: numberValue(summary.totalAmount ?? summary.amount), paidAmount: numberValue(summary.paidAmount), outstandingAmount: numberValue(summary.outstandingAmount), currency: 'CNY', status: stringValue(summary.status), version: numberValue(summary.version),
|
||||
lines: (Array.isArray(item.lines) ? item.lines : []).map((line) => normalizeFeeSourceDetail(line as RawResource)),
|
||||
transactions: (Array.isArray(item.transactions) ? item.transactions : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], remark: stringValue(row.remark) || undefined } }),
|
||||
@@ -178,20 +178,36 @@ export async function createFinanceSettlementPayment(settlementId: string, paylo
|
||||
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/settlements/${settlementId}/transactions`, payload, mutationHeaders('settlement-payment'))
|
||||
return normalizeSettlementDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function actionFinanceSettlement(settlementId: string, payload: import('../types').FinanceSettlementActionPayload) {
|
||||
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/settlements/${settlementId}/actions`, payload, mutationHeaders('settlement-action'))
|
||||
return normalizeSettlementDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function listFinanceStatements(customerId?: string): Promise<FinanceStatementReadModel[]> {
|
||||
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/statements`, { params: { customerId: customerId?.trim() || undefined } })
|
||||
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), totalAmount: numberValue(item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceStatementReadModel['status'], version: numberValue(item.version) }))
|
||||
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), totalAmount: numberValue(item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceStatementReadModel['status'], version: numberValue(item.version), createdBy: stringValue(item.createdBy), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedAt: stringValue(item.updatedAt) }))
|
||||
}
|
||||
|
||||
export async function listFinanceReceivableSettlements(customerId?: string): Promise<FinanceReceivableSettlement[]> {
|
||||
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/receivable-settlements`, { params: { customerId: customerId?.trim() || undefined } })
|
||||
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), settlementNo: stringValue(item.settlementNo), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), receivableAmount: numberValue(item.receivableAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceReceivableSettlement['status'], version: numberValue(item.version) }))
|
||||
return unwrapApiResponse(response).map((item) => ({
|
||||
id: stringValue(item.settlementId ?? item.id), customerId: stringValue(item.customerId),
|
||||
customerName: stringValue(item.customerName), settlementNo: stringValue(item.settlementNo), reconciliationStatementId: item.reconciliationStatementId == null ? undefined : stringValue(item.reconciliationStatementId), businessPeriod: stringValue(item.businessPeriod), generatedDate: stringValue(item.generatedDate), billDate: stringValue(item.generatedDate ?? item.billDate),
|
||||
dueDate: stringValue(item.dueDate), receivableAmount: numberValue(item.totalAmount ?? item.receivableAmount),
|
||||
receivedAmount: numberValue(item.paidAmount ?? item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount),
|
||||
paymentStatus: stringValue(item.paymentStatus) as FinanceReceivableSettlement['paymentStatus'], currency: 'CNY', status: stringValue(item.status) as FinanceReceivableSettlement['status'], version: numberValue(item.version),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function listFinancePayableSettlements(customerId?: string): Promise<FinancePayableSettlement[]> {
|
||||
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/payable-settlements`, { params: { customerId: customerId?.trim() || undefined } })
|
||||
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), settlementNo: stringValue(item.settlementNo), costType: stringValue(item.costType) as FinancePayableSettlement['costType'], orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), supplierName: stringValue(item.supplierName), feeItemName: stringValue(item.feeItemName), payableAmount: numberValue(item.payableAmount), paidAmount: numberValue(item.paidAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt), version: numberValue(item.version) }))
|
||||
return unwrapApiResponse(response).map((item) => ({
|
||||
id: stringValue(item.settlementId ?? item.id), customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), settlementNo: stringValue(item.settlementNo), supplierId: item.supplierId == null ? undefined : stringValue(item.supplierId),
|
||||
costType: 'SINGLE_ORDER', orderId: null, inboundId: null, containerId: null,
|
||||
supplierName: stringValue(item.supplierName), feeItemName: '', businessPeriod: stringValue(item.businessPeriod), generatedDate: stringValue(item.generatedDate), dueDate: stringValue(item.dueDate), payableAmount: numberValue(item.totalAmount ?? item.payableAmount),
|
||||
paidAmount: numberValue(item.paidAmount), outstandingAmount: numberValue(item.outstandingAmount), paymentStatus: stringValue(item.paymentStatus) as FinancePayableSettlement['paymentStatus'], currency: 'CNY',
|
||||
status: stringValue(item.status), createdAt: stringValue(item.generatedDate ?? item.createdAt), version: numberValue(item.version),
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeCustomerTerms(item: RawResource, customerId: string): FinanceCustomerTerms {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import CustomerStatementsPanel from './CustomerStatementsPanel.vue'
|
||||
|
||||
vi.mock('../api/finance', () => ({
|
||||
getReconciliationStatement: vi.fn(),
|
||||
listFinanceStatements: vi.fn(),
|
||||
}))
|
||||
|
||||
const statement = {
|
||||
id: 'statement-1', customerId: '8', billNo: 'ST202608001', customerName: '柏悦物流',
|
||||
billDate: '2026-08-01', dueDate: '2026-08-31', totalAmount: 1200, receivedAmount: 200,
|
||||
outstandingAmount: 1000, currency: 'CNY' as const, status: 'ISSUED' as const, version: 1,
|
||||
}
|
||||
|
||||
function mountPanel(permissions: string[]) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(CustomerStatementsPanel, {
|
||||
props: { customerId: '8', customerName: '柏悦物流' },
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
}
|
||||
|
||||
describe('CustomerStatementsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(listFinanceStatements).mockResolvedValue([statement])
|
||||
})
|
||||
|
||||
it('loads only the selected customer statements', async () => {
|
||||
const wrapper = mountPanel(['platform:finance:customer-statement:list'])
|
||||
await flushPromises()
|
||||
|
||||
expect(listFinanceStatements).toHaveBeenCalledWith('8')
|
||||
expect(wrapper.text()).toContain('ST202608001')
|
||||
expect(wrapper.text()).toContain('1,200.00')
|
||||
})
|
||||
|
||||
it('does not query finance without statement permission', async () => {
|
||||
const wrapper = mountPanel([])
|
||||
await flushPromises()
|
||||
|
||||
expect(listFinanceStatements).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('暂无财务账单查看权限')
|
||||
})
|
||||
|
||||
it('loads the real reconciliation detail from the statement row', async () => {
|
||||
vi.mocked(getReconciliationStatement).mockResolvedValue({
|
||||
...statement, paidAmount: 200, remark: '', periodStart: '2026-07-01', periodEnd: '2026-07-31',
|
||||
creditDays: 30, settled: false, lines: [{ statementLineId: 'line-1', sourceFeeId: 'fee-1', sourceFeeVersion: 1, orderId: null, inboundId: 'inbound-1', feeItemName: '入库操作费', amount: 1200 }],
|
||||
paymentHistory: [], revisions: [],
|
||||
})
|
||||
const wrapper = mountPanel(['platform:finance:customer-statement:list'])
|
||||
await flushPromises()
|
||||
await wrapper.get('button[aria-label="查看对账单ST202608001详情"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getReconciliationStatement).toHaveBeenCalledWith('statement-1')
|
||||
expect(wrapper.text()).toContain('入库操作费')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Eye, FileText } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import { financeStatusLabel, financeStatusTone } from '../status'
|
||||
import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
|
||||
|
||||
const props = defineProps<{ customerId: string; customerName: string }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<FinanceStatementReadModel[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<FinanceReconciliationDetail | null>(null)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
let requestSerial = 0
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'billNo', prop: 'billNo', label: '对账单号', minWidth: 180 },
|
||||
{ key: 'billDate', prop: 'billDate', label: '账单日期', width: 120 },
|
||||
{ key: 'dueDate', prop: 'dueDate', label: '到期日', width: 120 },
|
||||
{ key: 'totalAmount', label: '应收(CNY)', width: 130, align: 'right', slotName: 'totalAmount' },
|
||||
{ key: 'receivedAmount', label: '已收(CNY)', width: 130, align: 'right', slotName: 'receivedAmount' },
|
||||
{ key: 'outstandingAmount', label: '未收(CNY)', width: 130, align: 'right', slotName: 'outstandingAmount' },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
const customerId = props.customerId.trim()
|
||||
const serial = ++requestSerial
|
||||
rows.value = []
|
||||
loadError.value = null
|
||||
if (!customerId || !access.can('platform:finance:customer-statement:list')) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listFinanceStatements(customerId)
|
||||
if (serial === requestSerial && customerId === props.customerId.trim()) rows.value = result
|
||||
} catch (error) {
|
||||
if (serial === requestSerial) loadError.value = getApiErrorDetails(error, '客户财务账单加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (serial === requestSerial) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: FinanceStatementReadModel) {
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
try { detail.value = await getReconciliationStatement(row.id) }
|
||||
catch (error) { detailError.value = getApiErrorDetails(error, '对账单详情加载失败,请稍后重试') }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
function formatAmount(value: number) {
|
||||
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
watch(() => props.customerId, () => { void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="customer-statements-panel">
|
||||
<div class="panel-title"><div><h2>财务账单</h2><p>{{ customerName }}的客户对账单</p></div><FileText :size="19" /></div>
|
||||
<div v-if="!access.can('platform:finance:customer-statement:list')" class="module-empty"><FileText :size="30" /><strong>暂无财务账单查看权限</strong><p>请联系管理员开通客户对账单查看权限。</p></div>
|
||||
<LoadErrorState v-else-if="loadError" title="客户财务账单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="id" aria-label="客户财务账单列表" empty-text="当前客户暂无财务账单" border stripe>
|
||||
<template #actions="{ row }"><el-button link type="primary" :aria-label="`查看对账单${(row as FinanceStatementReadModel).billNo}详情`" @click="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template>
|
||||
<template #totalAmount="{ row }">{{ formatAmount((row as FinanceStatementReadModel).totalAmount) }}</template>
|
||||
<template #receivedAmount="{ row }">{{ formatAmount((row as FinanceStatementReadModel).receivedAmount) }}</template>
|
||||
<template #outstandingAmount="{ row }">{{ formatAmount((row as FinanceStatementReadModel).outstandingAmount) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="financeStatusTone((row as FinanceStatementReadModel).status)">{{ financeStatusLabel((row as FinanceStatementReadModel).status) }}</el-tag></template>
|
||||
<template #pagination-left>币种:CNY</template>
|
||||
</DataTable>
|
||||
|
||||
<AppDrawer v-model="detailOpen" title="客户对账单详情" size="min(820px, 94vw)" :loading="detailLoading">
|
||||
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item><el-descriptions-item label="账期">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item><el-descriptions-item label="未收金额">{{ detail.outstandingAmount }} CNY</el-descriptions-item></el-descriptions>
|
||||
<el-table :data="detail.lines" size="small" border stripe><el-table-column prop="feeItemName" label="费用项目" /><el-table-column prop="amount" label="账单金额(CNY)" /></el-table>
|
||||
<el-table v-if="detail.revisions.length" :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column prop="action" label="动作" width="130" /><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /></el-table>
|
||||
</template>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customer-statements-panel { min-width:0; }
|
||||
.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid var(--cc-border); }
|
||||
.panel-title h2 { margin:0; color:#344054; font-size:16px; }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; }.panel-title > svg { color:var(--cc-primary); }
|
||||
.module-empty { display:flex; min-height:260px; align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }.module-empty strong { color:#475467; font-size:13px; }.module-empty p { max-width:520px; margin:0; color:var(--cc-muted); font-size:12px; line-height:1.7; }
|
||||
:deep(.el-descriptions) { margin-bottom:18px; }:deep(.el-table + .el-table) { margin-top:18px; }
|
||||
</style>
|
||||
@@ -19,15 +19,18 @@ describe('finance five-page delivery contract', () => {
|
||||
['finance/settlement-payables', '结算单应付'],
|
||||
] as const
|
||||
|
||||
it('registers exactly the five formal routes and menu entries', () => {
|
||||
it('registers the five menu routes plus hidden detail routes', () => {
|
||||
for (const [path, title] of pages) {
|
||||
expect(routes).toContain(`path: '${path}'`)
|
||||
expect(routes).toContain(`title: '${title}'`)
|
||||
expect(navigation).toContain(`path: '/${path}'`)
|
||||
expect(navigation).toContain(`title: '${title}'`)
|
||||
}
|
||||
expect(routes.match(/path: 'finance\//g)).toHaveLength(5)
|
||||
expect(navigation.match(/path: '\/finance\//g)).toHaveLength(5)
|
||||
expect(routes.match(/path: 'finance\/customer-accounts\/:customerId'/g)).toHaveLength(1)
|
||||
expect(routes.match(/path: 'finance\/customer-arap\/:feeSourceId'/g)).toHaveLength(1)
|
||||
expect(navigation).not.toContain('/finance/customer-accounts/:customerId')
|
||||
expect(navigation).not.toContain('/finance/customer-arap/:feeSourceId')
|
||||
})
|
||||
|
||||
it('does not expose the old three routes or out-of-scope reports', () => {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { financeRoutes } from './routes'
|
||||
export { default as CustomerStatementsPanel } from './components/CustomerStatementsPanel.vue'
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('FinanceCustomerAccountsView delivery', () => {
|
||||
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerAccountsView.vue'), 'utf8')
|
||||
const routes = readFileSync(resolve(process.cwd(), 'src/modules/finance/routes.ts'), 'utf8')
|
||||
|
||||
it('keeps the account detail as a full page with traceable ledger controls', () => {
|
||||
expect(page).toContain('v-else-if="!detailOpen"')
|
||||
expect(page).toContain('返回客户账户')
|
||||
expect(page).not.toContain('<AppDrawer')
|
||||
for (const marker of ['流水类型', '发生日期', '导出流水', 'listFinanceAccountLedger']) expect(page).toContain(marker)
|
||||
})
|
||||
|
||||
it('shows only derived CNY summaries and no wallet actions', () => {
|
||||
for (const marker of ['未收', '待出账', '本期已收', '应付', 'CNY']) expect(page).toContain(marker)
|
||||
for (const forbidden of ['手动充值', '钱包', '外币', '发票', '利润']) expect(page).not.toContain(forbidden)
|
||||
})
|
||||
|
||||
it('supports a refreshable customer detail route without inventing an API parameter', () => {
|
||||
expect(routes).toContain("path: 'finance/customer-accounts/:customerId'")
|
||||
expect(page).toContain("name: 'finance-customer-account-detail'")
|
||||
expect(page).toContain('restoreAccountDetail(customerId)')
|
||||
expect(page).toContain("listFinanceAccounts({ page: currentPage, size: 200 })")
|
||||
expect(page).not.toContain('listFinanceAccounts({ customerId')
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Eye, Pencil, Save } from '@lucide/vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Download, Eye, Pencil, Save } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
|
||||
import { financeBusinessTypeLabel, financeDocumentTypeLabel } from '../status'
|
||||
import { financeBusinessTypeLabel } from '../status'
|
||||
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const filters = ref<QueryModel>({ keyword: '', status: '' })
|
||||
const rows = ref<FinanceAccountSummary[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -24,7 +26,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const drawerOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const editing = ref(false)
|
||||
const saving = ref(false)
|
||||
const selected = ref<FinanceAccountSummary | null>(null)
|
||||
@@ -33,8 +35,12 @@ const ledgerLoading = ref(false)
|
||||
const ledgerError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
|
||||
const creditDays = ref(30)
|
||||
const ledgerFilters = ref<QueryModel>({ businessType: '', occurredRange: [] })
|
||||
const ledgerPage = ref(1)
|
||||
const ledgerPageSize = ref(20)
|
||||
const ledgerTotal = ref(0)
|
||||
|
||||
const canView = computed(() => access.can('platform:finance:account:view'))
|
||||
const canView = computed(() => access.can('platform:finance:customer-account:list') || access.can('platform:finance:account:view'))
|
||||
const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view'))
|
||||
const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update'))
|
||||
const queryFields: QueryField[] = [
|
||||
@@ -54,6 +60,23 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
|
||||
{ key: 'lastChangedAt', label: '最近变动', width: 170 },
|
||||
]
|
||||
const ledgerQueryFields: QueryField[] = [
|
||||
{ key: 'businessType', type: 'select', label: '流水类型', width: 180, placeholder: '全部类型', options: [
|
||||
{ label: '费用来源', value: 'FEE_SOURCE' }, { label: '对账单', value: 'RECONCILIATION' }, { label: '结算单', value: 'SETTLEMENT' }, { label: '收付款', value: 'PAYMENT' },
|
||||
] },
|
||||
{ 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: '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 },
|
||||
]
|
||||
const summary = computed(() => rows.value.reduce((result, row) => ({
|
||||
receivable: result.receivable + row.outstandingReceivable,
|
||||
pending: result.pending + row.pendingBilling,
|
||||
received: result.received + row.receivedInPeriod,
|
||||
payable: result.payable + row.outstandingPayable,
|
||||
}), { receivable: 0, pending: 0, received: 0, payable: 0 }))
|
||||
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
@@ -71,7 +94,11 @@ function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
async function openAccount(row: Record<string, unknown>, edit: boolean) {
|
||||
selected.value = row as unknown as FinanceAccountSummary
|
||||
editing.value = edit
|
||||
drawerOpen.value = true
|
||||
if (!edit) {
|
||||
await router.push({ name: 'finance-customer-account-detail', params: { customerId: selected.value.customerId } })
|
||||
return
|
||||
}
|
||||
detailOpen.value = true
|
||||
ledger.value = []
|
||||
ledgerError.value = null
|
||||
selectedTerms.value = null
|
||||
@@ -81,10 +108,17 @@ async function openAccount(row: Record<string, unknown>, edit: boolean) {
|
||||
catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期加载失败,请重试') }
|
||||
return
|
||||
}
|
||||
ledgerLoading.value = true
|
||||
await loadLedger()
|
||||
}
|
||||
async function loadLedger(model: QueryModel = ledgerFilters.value) {
|
||||
if (!selected.value) return
|
||||
Object.assign(ledgerFilters.value, model)
|
||||
ledgerLoading.value = true; ledgerError.value = null
|
||||
const range = Array.isArray(ledgerFilters.value.occurredRange) ? ledgerFilters.value.occurredRange.map(String) : []
|
||||
try {
|
||||
const result = await listFinanceAccountLedger(selected.value.customerId, { page: 1, size: 200 })
|
||||
const result = await listFinanceAccountLedger(selected.value.customerId, { page: ledgerPage.value, size: ledgerPageSize.value, businessType: String(ledgerFilters.value.businessType || ''), startDate: range[0], endDate: range[1] })
|
||||
ledger.value = result.records
|
||||
ledgerTotal.value = result.total
|
||||
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户流水加载失败,请重试') }
|
||||
finally { ledgerLoading.value = false }
|
||||
}
|
||||
@@ -98,35 +132,71 @@ async function saveTerms() {
|
||||
finally { saving.value = false }
|
||||
}
|
||||
function money(value: unknown) { return Number(value || 0).toFixed(2) }
|
||||
onMounted(() => void load())
|
||||
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])) }
|
||||
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' })
|
||||
}
|
||||
async function restoreAccountDetail(customerId: string) {
|
||||
detailOpen.value = true; editing.value = false; ledgerError.value = null
|
||||
try {
|
||||
let currentPage = 1; let pages = 1; selected.value = null
|
||||
do {
|
||||
const result = await listFinanceAccounts({ page: currentPage, size: 200 })
|
||||
selected.value = result.records.find((row) => row.customerId === customerId) || null
|
||||
pages = result.pages; currentPage += 1
|
||||
} while (!selected.value && currentPage <= pages)
|
||||
if (!selected.value) { ledgerError.value = { message: '未找到该客户账户,可能已停用或无权访问' }; return }
|
||||
await loadLedger()
|
||||
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户详情加载失败,请重试') }
|
||||
}
|
||||
onMounted(() => {
|
||||
const customerId = String(route.params.customerId || '')
|
||||
if (route.name === 'finance-customer-account-detail' && customerId) void restoreAccountDetail(customerId)
|
||||
else void load()
|
||||
})
|
||||
watch(() => [route.name, String(route.params.customerId || '')], ([name, customerId], previous) => {
|
||||
if (!previous) return
|
||||
if (name === 'finance-customer-account-detail' && customerId) void restoreAccountDetail(customerId)
|
||||
else { detailOpen.value = false; selected.value = null; ledger.value = []; void load() }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-account-page" spacing="normal">
|
||||
<el-result v-if="!canView" icon="warning" title="无权访问客户账户" sub-title="请联系管理员开通客户账户查看权限" />
|
||||
<ListPageLayout v-else aria-label="客户账户">
|
||||
<ListPageLayout v-else-if="!detailOpen" aria-label="客户账户">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="search" /></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="客户账户加载失败" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button :disabled="loading || !rows.length" @click="exportAccounts"><Download :size="16" />导出</el-button></template>
|
||||
<template #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 #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
|
||||
<template #pagination-left>币种:CNY · 账户数据由对账单、收付款和费用来源派生</template>
|
||||
<template #pagination-left><span>汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 应付 {{ money(summary.payable) }} CNY</span></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<AppDrawer v-model="drawerOpen" :title="editing ? '维护客户账期' : '客户账户详情'" size="min(760px, 94vw)" :loading="ledgerLoading" :confirm-loading="saving">
|
||||
<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>
|
||||
<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="展示应收、应付及每次变动后的未收金额。"><div v-for="entry in ledger" :key="entry.ledgerId" class="ledger-entry"><div><strong>{{ entry.documentNo || `单据 ${entry.documentId}` }}</strong><span>{{ financeBusinessTypeLabel(entry.businessType) }} · {{ financeDocumentTypeLabel(entry.documentType) }}</span></div><span>{{ entry.occurredAt }}</span><div class="ledger-money"><b>应收 {{ money(entry.receivableChange) }}</b><b>应付 {{ money(entry.payableChange) }}</b><span>变动后未收 {{ money(entry.outstandingReceivableAfter) }} CNY</span></div><small>{{ entry.operatorName }}{{ entry.remark ? ` · ${entry.remark}` : '' }}</small></div><el-empty v-if="!ledger.length" description="暂无账户流水" /></FormSection>
|
||||
<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>
|
||||
<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>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="drawerOpen = false">关闭</el-button><el-button v-if="editing && canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
|
||||
</AppDrawer>
|
||||
<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>
|
||||
</AdminPage>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.finance-account-page{min-width:0}.row-actions,.drawer-footer{display:flex;align-items:center;gap:8px}.drawer-footer{justify-content:flex-end}.row-actions .el-button{margin-left:0}.account-detail{margin-bottom:16px}.term-unit{margin-left:8px;color:var(--cc-muted)}.ledger-entry{display:grid;grid-template-columns:minmax(160px,1fr) auto minmax(210px,auto);gap:8px 16px;padding:12px 0;border-bottom:1px solid var(--el-border-color-lighter)}.ledger-entry>div:first-child,.ledger-money{display:grid;gap:3px}.ledger-entry span,.ledger-entry small{color:var(--cc-muted)}.ledger-entry small{grid-column:1/-1}.ledger-money{text-align:right}.ledger-money b{white-space:nowrap}@media(max-width:720px){.drawer-footer .el-button{min-height:44px}.ledger-entry{grid-template-columns:1fr 1fr}.ledger-money{grid-column:1/-1;text-align:left}:deep(.account-detail .el-descriptions__body .el-descriptions__table){table-layout:fixed}}@media(max-width:480px){.drawer-footer .el-button{flex:1 1 120px}.ledger-entry{grid-template-columns:1fr}:deep(.account-detail .el-descriptions__cell){display:block;width:100%}}
|
||||
.finance-account-page,.finance-account-detail{min-width:0}.row-actions,.detail-footer,.detail-heading,.detail-toolbar{display:flex;align-items:center;gap:8px}.row-actions .el-button{margin-left:0}.detail-heading{align-items:flex-start;margin-bottom:16px}.detail-heading h2{margin:0;font-size:18px}.detail-heading p{margin:4px 0 0;color:var(--cc-muted)}.detail-toolbar,.detail-footer{justify-content:space-between;margin:12px 0}.detail-footer{justify-content:flex-end}.account-detail{margin-bottom:16px}.term-unit{margin-left:8px;color:var(--cc-muted)}@media(max-width:720px){.detail-heading{align-items:stretch;flex-direction:column}.detail-footer .el-button{min-height:44px}:deep(.account-detail .el-descriptions__body .el-descriptions__table){table-layout:fixed}}@media(max-width:480px){.detail-footer .el-button{flex:1 1 120px}:deep(.account-detail .el-descriptions__cell){display:block;width:100%}}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import type { FinanceFeeSourceDetail } from '../types'
|
||||
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
|
||||
|
||||
@@ -16,6 +17,7 @@ vi.mock('../api/finance', () => ({
|
||||
getFinanceFeeSource: vi.fn(),
|
||||
reviewFinanceFeeSource: vi.fn(),
|
||||
updateFinanceFeeSourceSettlementInclusion: vi.fn(),
|
||||
createReconciliationStatement: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
@@ -39,13 +41,18 @@ const detail: FinanceFeeSourceDetail = {
|
||||
sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: false, operationVersion: 3, createdAt: '2026-08-11', operationLogs: [],
|
||||
}
|
||||
|
||||
async function mountView(permissions: string[]) {
|
||||
async function mountView(permissions: string[], initialPath = '/finance/customer-arap') {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(FinanceCustomerArapView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView },
|
||||
{ path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView },
|
||||
] })
|
||||
await router.push(initialPath); await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerArapView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
return Object.assign(wrapper, { router })
|
||||
}
|
||||
|
||||
async function loadRows(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
||||
@@ -69,23 +76,25 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
vi.mocked(getFinanceFeeSource).mockReset().mockResolvedValue(detail)
|
||||
vi.mocked(reviewFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', operationVersion: 4 })
|
||||
vi.mocked(updateFinanceFeeSourceSettlementInclusion).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4 })
|
||||
vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({} as never)
|
||||
})
|
||||
|
||||
it('does not expose or call G2 operations without their permissions', async () => {
|
||||
it('shows detail from the list permission but keeps G2 operations independent', async () => {
|
||||
const wrapper = await mountView(['platform:finance:bill:view'])
|
||||
await loadRows(wrapper)
|
||||
|
||||
const buttonLabels = wrapper.findAll('button').map((button) => button.text())
|
||||
expect(buttonLabels).not.toContain('详情')
|
||||
expect(buttonLabels).not.toContain('审核')
|
||||
expect(buttonLabels).not.toContain('结算')
|
||||
expect(getFinanceFeeSource).not.toHaveBeenCalled()
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: Record<string, unknown>): Promise<void> }
|
||||
await vm.openDetail({ id: 'RECEIVABLE:42', feeSourceId: '42' })
|
||||
expect(getFinanceFeeSource).toHaveBeenCalledWith('42')
|
||||
expect(reviewFinanceFeeSource).not.toHaveBeenCalled()
|
||||
expect(updateFinanceFeeSourceSettlementInclusion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads all customers by default and keeps explicit customer filtering', async () => {
|
||||
const wrapper = await mountView(['platform:finance:bill:view'])
|
||||
const wrapper = await mountView(['platform:finance:bill:view', 'platform:customer:list'])
|
||||
expect(listFinanceFeePool).toHaveBeenCalledWith(undefined, undefined)
|
||||
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void> }
|
||||
await vm.load({ customerId: '10001', direction: 'RECEIVABLE', status: '', keyword: '' })
|
||||
@@ -146,4 +155,26 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
expect(document.body.textContent).toContain('req-g2-error')
|
||||
expect(document.body.textContent).toContain('trace-g2-error')
|
||||
})
|
||||
|
||||
it('restores the independent detail route from its fee source id', async () => {
|
||||
const wrapper = await mountView(['platform:finance:bill:view'], '/finance/customer-arap/42')
|
||||
expect(getFinanceFeeSource).toHaveBeenCalledWith('42')
|
||||
expect(wrapper.text()).toContain('费用来源详情')
|
||||
expect(wrapper.text()).toContain('IB-91')
|
||||
expect(wrapper.router.currentRoute.value.name).toBe('finance-customer-arap-detail')
|
||||
})
|
||||
|
||||
it('creates a reconciliation draft from selected eligible receivables', async () => {
|
||||
const wrapper = await mountView(['platform:finance:bill:view', 'platform:finance:reconciliation:create'])
|
||||
await loadRows(wrapper)
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selectedRows: Array<Record<string, unknown>>
|
||||
generationForm: { mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; remark: string }
|
||||
submitGeneration(): Promise<void>
|
||||
}
|
||||
vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, amount: 100 }]
|
||||
Object.assign(vm.generationForm, { mode: 'SELECTED', customerId: '10001', period: ['2026-08-01', '2026-08-31'], remark: '八月费用' })
|
||||
await vm.submitGeneration()
|
||||
expect(createReconciliationStatement).toHaveBeenCalledWith({ customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'], periodStart: '2026-08-01', periodEnd: '2026-08-31', remark: '八月费用' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Eye, FileCheck2, Link2 } from '@lucide/vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Eye, FileCheck2, FilePlus2, Link2 } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -8,12 +9,11 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
|
||||
import type { FinanceFeeSourceDetail } from '../types'
|
||||
|
||||
@@ -32,17 +32,23 @@ interface ArapRow {
|
||||
amount: number
|
||||
status: string
|
||||
occurredAt: string
|
||||
businessDate: string
|
||||
reviewStatus: string
|
||||
settlementIncluded: boolean | null
|
||||
statementGenerated: boolean | null
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const customersError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', keyword: '' })
|
||||
const filters = ref<QueryModel>({ customerId: '', direction: '', reviewStatus: '', settlementIncluded: '', statementGenerated: '', businessRange: [], keyword: '' })
|
||||
const allRows = ref<ArapRow[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const drawerOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const selected = ref<ArapRow | null>(null)
|
||||
const detail = ref<FinanceFeeSourceDetail | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
@@ -55,34 +61,47 @@ const inclusionOpen = ref(false)
|
||||
const inclusionSaving = ref(false)
|
||||
const inclusionError = ref<ApiErrorDetails | null>(null)
|
||||
const inclusionForm = reactive({ included: true, reason: '' })
|
||||
const generationOpen = ref(false)
|
||||
const generationSaving = ref(false)
|
||||
const generationError = ref<ApiErrorDetails | null>(null)
|
||||
const generationForm = reactive<{ mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; cutoffDate: string; remark: string }>({ mode: 'SELECTED', customerId: '', period: [], cutoffDate: '', remark: '' })
|
||||
const selectedRows = ref<ArapRow[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const canView = computed(() => access.can('platform:finance:bill:view'))
|
||||
const canViewSource = computed(() => access.can('platform:finance:fee-source:view'))
|
||||
const canView = computed(() => access.can('platform:finance:customer-arap:list') || access.can('platform:finance:bill:view'))
|
||||
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
|
||||
const canViewSource = computed(() => access.can('platform:finance:fee-source:view') || access.can('platform:finance:bill:view'))
|
||||
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
|
||||
const canSettleSource = computed(() => access.can('platform:finance:fee-source:settlement'))
|
||||
const canCreateStatement = computed(() => access.can('platform:finance:reconciliation:create'))
|
||||
const stateError = computed(() => customersError.value || loadError.value)
|
||||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', width: 260, placeholder: '全部客户', props: { 'aria-label': '客户', filterable: true, clearable: true, loading: customersLoading.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '全部', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: financeArapStatusOptions },
|
||||
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '全部状态', options: financeArapStatusOptions },
|
||||
{ key: 'settlementIncluded', type: 'select', label: '参与结算', width: 140, placeholder: '全部', options: [{ label: '参与', value: 'true' }, { label: '不参与', value: 'false' }] },
|
||||
{ key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '全部', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] },
|
||||
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '单号/费用/来源ID', props: { 'aria-label': '关键词' } },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
|
||||
{ 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: 'sourceId', label: '来源追溯', minWidth: 200 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 },
|
||||
{ key: 'status', label: '状态', width: 120, slotName: 'status' }, { key: 'occurredAt', label: '发生日期', 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 },
|
||||
]
|
||||
const filteredRows = computed(() => {
|
||||
const keyword = String(filters.value.keyword || '').trim().toLowerCase(); const direction = String(filters.value.direction || ''); const status = String(filters.value.status || '')
|
||||
return allRows.value.filter((row) => (!direction || row.direction === direction) && (!status || row.status === status) && (!keyword || [row.documentNo, row.sourceId, row.feeName].some((value) => value.toLowerCase().includes(keyword))))
|
||||
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) : []
|
||||
return allRows.value.filter((row) => (!direction || row.direction === direction) && (!reviewStatus || row.reviewStatus === reviewStatus) && (!included || String(row.settlementIncluded) === included) && (!generated || String(row.statementGenerated) === generated) && (!range[0] || row.businessDate >= range[0]) && (!range[1] || row.businessDate <= range[1]) && (!keyword || [row.documentNo, row.sourceId, row.feeName].some((value) => value.toLowerCase().includes(keyword))))
|
||||
})
|
||||
const rows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
const selectedAmount = computed(() => selectedRows.value.reduce((sum, row) => sum + row.amount, 0))
|
||||
|
||||
async function loadCustomers() {
|
||||
if (!canListCustomers.value) return
|
||||
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 }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages); customers.value = records }
|
||||
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
|
||||
@@ -95,7 +114,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: 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 }))
|
||||
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 }))
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -107,13 +126,14 @@ async function loadDetail(row: ArapRow) {
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
async function openDetail(row: Record<string, unknown>) {
|
||||
selected.value = row as unknown as ArapRow; drawerOpen.value = true
|
||||
await loadDetail(selected.value)
|
||||
const target = row as unknown as ArapRow
|
||||
if (!target.feeSourceId) return
|
||||
await router.push({ name: 'finance-customer-arap-detail', params: { feeSourceId: target.feeSourceId } })
|
||||
}
|
||||
async function openReview(row: Record<string, unknown>) {
|
||||
const target = row as unknown as ArapRow; selected.value = target
|
||||
const loaded = await loadDetail(target)
|
||||
if (!loaded) { drawerOpen.value = true; return }
|
||||
if (!loaded) { detailOpen.value = true; return }
|
||||
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; reviewOpen.value = true
|
||||
}
|
||||
async function submitReview() {
|
||||
@@ -129,7 +149,7 @@ async function submitReview() {
|
||||
async function openInclusion(row: Record<string, unknown>) {
|
||||
const target = row as unknown as ArapRow; selected.value = target
|
||||
const loaded = await loadDetail(target)
|
||||
if (!loaded) { drawerOpen.value = true; return }
|
||||
if (!loaded) { detailOpen.value = true; return }
|
||||
Object.assign(inclusionForm, { included: !loaded.settlementIncluded, reason: '' }); inclusionError.value = null; inclusionOpen.value = true
|
||||
}
|
||||
async function submitInclusion() {
|
||||
@@ -144,32 +164,66 @@ async function submitInclusion() {
|
||||
}
|
||||
function statusLabel(status: string) { return financeStatusLabel(status) }
|
||||
function money(value: unknown) { return Number(value || 0).toFixed(2) }
|
||||
function onSelectionChange(rows: ArapRow[]) { selectedRows.value = rows }
|
||||
function openGeneration(mode: 'BY_CUTOFF' | 'SELECTED') {
|
||||
generationForm.mode = mode; generationForm.customerId = String(filters.value.customerId || ''); generationForm.period = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []; generationForm.cutoffDate = generationForm.period[1] || ''; generationForm.remark = ''; generationError.value = null; generationOpen.value = true
|
||||
}
|
||||
async function submitGeneration() {
|
||||
const eligible = selectedRows.value.filter((row) => row.feeSourceId && row.direction === 'RECEIVABLE' && row.reviewStatus === 'APPROVED' && row.settlementIncluded === true && row.statementGenerated === false)
|
||||
const period = generationForm.period
|
||||
if (!generationForm.customerId || !period[0] || !period[1]) { ElMessage.warning('请选择客户和业务期间'); return }
|
||||
if (generationForm.mode === 'SELECTED' && !eligible.length) { ElMessage.warning('请选择审核通过、参与结算且未出账的应收费用'); return }
|
||||
generationSaving.value = true; generationError.value = null
|
||||
try { await createReconciliationStatement({ customerId: generationForm.customerId, currency: 'CNY', generationMode: generationForm.mode, feeSourceIds: generationForm.mode === 'SELECTED' ? eligible.map((row) => row.feeSourceId as string) : undefined, periodStart: period[0], periodEnd: period[1], remark: generationForm.remark.trim() }); generationOpen.value = false; selectedRows.value = []; ElMessage.success('对账单草稿已生成'); await load() } catch (error) { generationError.value = getApiErrorDetails(error, '对账单生成失败,请重试') } finally { generationSaving.value = false }
|
||||
}
|
||||
async function closeDetail() { detailOpen.value = false; selected.value = null; detail.value = null; await router.push({ name: 'finance-customer-arap' }) }
|
||||
async function restoreFeeSourceDetail(feeSourceId: string) {
|
||||
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
|
||||
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 }
|
||||
} catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试') }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => { await loadCustomers(); await load() })
|
||||
onMounted(async () => {
|
||||
const feeSourceId = String(route.params.feeSourceId || '')
|
||||
if (route.name === 'finance-customer-arap-detail' && feeSourceId) { await restoreFeeSourceDetail(feeSourceId); return }
|
||||
if (canListCustomers.value) await loadCustomers()
|
||||
await load()
|
||||
})
|
||||
watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => {
|
||||
if (!previous) return
|
||||
if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(feeSourceId)
|
||||
else { detailOpen.value = false; selected.value = null; detail.value = null; void load() }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-arap-page" spacing="normal">
|
||||
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
|
||||
<ListPageLayout v-else aria-label="客户应收应付">
|
||||
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="load" /></template>
|
||||
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据">
|
||||
<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 #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
<template #pagination-left>费用来源来自真实账单明细、入库费用快照和成本台账 · CNY</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>
|
||||
<template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AppDrawer v-model="drawerOpen" title="费用来源详情" size="min(760px, 94vw)" :loading="detailLoading">
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<template #footer><el-button @click="drawerOpen = false">关闭</el-button></template>
|
||||
</AppDrawer>
|
||||
</AdminPage>
|
||||
<AppDialog v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<el-alert v-if="reviewError" type="error" :closable="false"><ApiErrorMeta :details="reviewError" /></el-alert>
|
||||
<el-form label-position="top"><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></el-form>
|
||||
@@ -178,17 +232,26 @@ onMounted(async () => { await loadCustomers(); await load() })
|
||||
<el-alert v-if="inclusionError" type="error" :closable="false"><ApiErrorMeta :details="inclusionError" /></el-alert>
|
||||
<el-form label-position="top"><el-form-item label="结算状态"><el-switch v-model="inclusionForm.included" active-text="参与结算" inactive-text="排除结算" /></el-form-item><el-form-item label="调整原因"><el-input v-model="inclusionForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请说明参与或排除结算的原因" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="generationOpen" :title="generationForm.mode === 'BY_CUTOFF' ? '按截止日期生成对账单' : '按勾选生成对账单'" confirm-text="生成草稿" :confirm-loading="generationSaving" @confirm="submitGeneration">
|
||||
<el-alert v-if="generationError" type="error" :closable="false"><ApiErrorMeta :details="generationError" /></el-alert>
|
||||
<el-form label-position="top"><el-form-item label="客户"><el-select v-model="generationForm.customerId" filterable placeholder="请选择客户" style="width:100%"><el-option v-for="customer in activeCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item><el-form-item label="业务期间"><el-date-picker v-model="generationForm.period" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="截止日期" style="width:100%" /></el-form-item><el-form-item v-if="generationForm.mode === 'SELECTED'" label="已选费用"><span>{{ selectedRows.length }} 笔,合计 {{ money(selectedAmount) }} CNY</span></el-form-item><el-form-item label="备注"><el-input v-model="generationForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.finance-arap-page { min-width: 0; }
|
||||
.finance-arap-page,.finance-arap-detail { min-width: 0; }
|
||||
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.row-actions .el-button { margin-left: 0; }
|
||||
.generation-actions,.detail-heading,.detail-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.detail-heading { align-items: flex-start; margin-bottom: 16px; }
|
||||
.detail-heading h2 { margin: 0; font-size: 18px; }
|
||||
.detail-heading p { margin: 4px 0 0; color: var(--cc-muted); }
|
||||
.detail-actions { margin-left: auto; }
|
||||
.muted-action { color: var(--cc-muted); }
|
||||
.arap-detail { margin-bottom: 16px; }
|
||||
.operation-log h3 { margin: 0 0 14px; font-size: 15px; }
|
||||
.operation-log small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
||||
@media (max-width: 720px) { :deep(.el-drawer__footer .el-button) { min-height: 44px; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
|
||||
@media (max-width: 720px) { .detail-heading { align-items: stretch; flex-direction: column; } .detail-actions { margin-left: 0; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
|
||||
@media (max-width: 480px) { :deep(.arap-detail .el-descriptions__cell) { display: block; width: 100%; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import { getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
|
||||
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
|
||||
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
actionReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(),
|
||||
getFinanceFeeSource: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceStatements: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
function memoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) }
|
||||
}
|
||||
|
||||
const statement: FinanceStatementReadModel = {
|
||||
id: '501', customerId: '10001', billNo: 'REC-501', customerName: '客户A', billDate: '2026-08-31', dueDate: '',
|
||||
totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3,
|
||||
createdBy: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
||||
}
|
||||
const detail: FinanceReconciliationDetail = {
|
||||
...statement, paidAmount: 100, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, remark: '八月账单',
|
||||
createdBy: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
||||
lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, orderId: '11', inboundId: '21', feeItemName: '运费', amount: 300 }],
|
||||
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: '财务员', occurredAt: '2026-08-20 10:00:00' }],
|
||||
revisions: [],
|
||||
}
|
||||
|
||||
async function mountPage(permissions = ['platform:finance:bill:view']) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('FinanceCustomerStatementsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', memoryStorage())
|
||||
vi.stubGlobal('sessionStorage', memoryStorage())
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:statement-export'), revokeObjectURL: vi.fn() })
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listFinanceStatements).mockResolvedValue([statement])
|
||||
vi.mocked(getReconciliationStatement).mockResolvedValue(detail)
|
||||
vi.mocked(getFinanceFeeSource).mockResolvedValue({
|
||||
poolItemId: '81', feeSourceId: '81', customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '',
|
||||
direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceId: 'FEE-81', sourceFeeId: 'FEE-81', sourceVersion: 2,
|
||||
orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: '31', containerNo: 'CONT-31', businessDate: '2026-08-15',
|
||||
feeItemName: '运费', quantity: 10, unitPrice: 30, amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true,
|
||||
sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: true, operationVersion: 2, createdAt: '2026-08-15',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the main table and linked detail/payment tables on the same page', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('账单明细表')
|
||||
expect(document.body.textContent).toContain('账单支付历史表')
|
||||
expect(document.body.textContent).toContain('REC-501')
|
||||
expect(document.body.textContent).toContain('CONT-31')
|
||||
expect(document.body.textContent).toContain('IN-21')
|
||||
expect(document.body.textContent).toContain('来源版本')
|
||||
expect(document.body.textContent).toContain('创建人A')
|
||||
expect(document.body.textContent).toContain('2026-08-20 11:00:00')
|
||||
expect(document.querySelector('.el-drawer')).toBeNull()
|
||||
})
|
||||
|
||||
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[] }
|
||||
await vm.load({
|
||||
status: 'ISSUED', billNo: '501', customerId: '', containerNo: 'CONT-31',
|
||||
periodStartRange: ['2026-08-01', '2026-08-02'], periodEndRange: ['2026-08-30', '2026-09-01'],
|
||||
settled: 'false', lastPaymentRange: ['2026-08-20', '2026-08-20'],
|
||||
})
|
||||
await flushPromises()
|
||||
expect(vm.filteredRows).toHaveLength(1)
|
||||
vm.filters.containerNo = 'CONT-99'
|
||||
await flushPromises()
|
||||
expect(vm.filteredRows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('exports the currently filtered real statement rows as CSV', async () => {
|
||||
const clicked = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
const wrapper = await mountPage()
|
||||
;(wrapper.vm as unknown as { exportStatements(): void }).exportStatements()
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||
expect(clicked).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables statement generation when customer selection permission is missing', async () => {
|
||||
vi.mocked(listPlatformTenants).mockClear()
|
||||
await mountPage(['platform:finance:bill:view', 'platform:finance:reconciliation:create'])
|
||||
const button = [...document.querySelectorAll('button')].find((item) => item.textContent?.includes('生成对账单'))
|
||||
expect(button).toBeTruthy()
|
||||
expect(button?.disabled).toBe(true)
|
||||
expect(listPlatformTenants).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces detail hydration failures instead of silently returning an empty filtered list', async () => {
|
||||
const wrapper = await mountPage()
|
||||
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable'))
|
||||
await (wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void> }).load({ status: 'ISSUED' })
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('筛选字段加载失败')
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
|
||||
import { Download, Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import { actionReconciliationStatement, createReconciliationStatement, getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import { financeStatusLabel, financeStatusTone } from '../status'
|
||||
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
|
||||
|
||||
@@ -26,16 +25,41 @@ interface AdjustmentRow {
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface StatementRow extends FinanceStatementReadModel {
|
||||
periodStart: string
|
||||
periodEnd: string
|
||||
creditDays: number | null
|
||||
settled: boolean | null
|
||||
lastPaymentAt: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface StatementLineSource {
|
||||
containerNo: string
|
||||
inboundCode: string
|
||||
businessDate: string
|
||||
quantity: number | null
|
||||
unitPrice: number | null
|
||||
sourceType: string
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const filters = ref<QueryModel>({ customerId: '' })
|
||||
const rows = ref<FinanceStatementReadModel[]>([])
|
||||
const filters = ref<QueryModel>({
|
||||
status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [],
|
||||
settled: '', lastPaymentRange: [], createdRange: [],
|
||||
})
|
||||
const rows = ref<StatementRow[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const customerError = ref<ApiErrorDetails | null>(null)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedId = ref('')
|
||||
const detail = ref<FinanceReconciliationDetail | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const activeDetailTab = ref<'lines' | 'payments'>('lines')
|
||||
const detailCache = new Map<string, FinanceReconciliationDetail>()
|
||||
const lineSourceCache = new Map<string, StatementLineSource>()
|
||||
const createOpen = ref(false)
|
||||
const createBusy = ref(false)
|
||||
const actionBusy = ref(false)
|
||||
@@ -46,26 +70,101 @@ const adjustStatement = ref<FinanceStatementReadModel | null>(null)
|
||||
const adjustReason = ref('')
|
||||
const adjustLines = ref<AdjustmentRow[]>([])
|
||||
|
||||
const canView = computed(() => access.can('platform:finance:bill:view'))
|
||||
const canView = computed(() => access.can('platform:finance:customer-statement:list') || access.can('platform:finance:bill:view'))
|
||||
const canListCustomers = computed(() => access.can('platform:customer:list'))
|
||||
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
|
||||
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
|
||||
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
|
||||
const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust'))
|
||||
const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
|
||||
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '全部客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
|
||||
const canGenerate = computed(() => canCreate.value && canListCustomers.value)
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '审核状态', width: 180, placeholder: '全部审核状态', options: [
|
||||
{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' }, { label: '审核通过', value: 'APPROVED' },
|
||||
{ label: '审核拒绝', value: 'REJECTED' }, { label: '已出账', value: 'ISSUED' }, { label: '已作废', value: 'VOIDED' },
|
||||
] },
|
||||
{ key: 'billNo', type: 'input', label: '对账单号', width: 210, placeholder: '请输入对账单号' },
|
||||
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: '请输入柜次号' },
|
||||
{ key: 'periodStartRange', type: 'daterange', label: '结算起始日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
|
||||
{ key: 'periodEndRange', type: 'daterange', label: '结算结束日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
|
||||
{ key: 'settled', type: 'select', label: '是否结清', width: 160, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
|
||||
{ key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
|
||||
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
|
||||
])
|
||||
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: 160 },
|
||||
{ key: 'billDate', label: '账单日期', width: 130 },
|
||||
{ key: 'dueDate', label: '到期日', width: 130 },
|
||||
{ key: 'totalAmount', label: '应收(CNY)', width: 140 },
|
||||
{ key: 'receivedAmount', label: '已收(CNY)', width: 140 },
|
||||
{ key: 'outstandingAmount', label: '未收(CNY)', width: 140 },
|
||||
{ key: 'status', label: '状态', width: 120, slotName: 'status' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180 },
|
||||
{ key: 'periodStart', label: '结算起始日', width: 130 },
|
||||
{ key: 'periodEnd', label: '结算结束日', width: 130 },
|
||||
{ key: 'creditDays', label: '账期(天)', width: 100 },
|
||||
{ key: 'currency', label: '货币', width: 86 },
|
||||
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 },
|
||||
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 },
|
||||
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 },
|
||||
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170 },
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconciliationDetail): StatementRow {
|
||||
const latestPayment = current?.paymentHistory.map((item) => item.occurredAt || item.transactionDate).filter(Boolean).sort().at(-1) || ''
|
||||
return {
|
||||
...row,
|
||||
periodStart: current?.periodStart || '', periodEnd: current?.periodEnd || row.billDate,
|
||||
creditDays: current ? current.creditDays : null, settled: current ? current.settled : null,
|
||||
lastPaymentAt: latestPayment, remark: current?.remark || '',
|
||||
createdBy: current?.createdBy || row.createdBy, createdAt: current?.createdAt || row.createdAt,
|
||||
updatedBy: current?.updatedBy || row.updatedBy, updatedAt: current?.updatedAt || row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function stringFilter(key: string) { return String(filters.value[key] || '').trim().toLowerCase() }
|
||||
function inDateRange(value: string, key: string) {
|
||||
const range = filters.value[key]
|
||||
if (!Array.isArray(range) || range.length < 2 || !value) return !Array.isArray(range) || range.length < 2
|
||||
const date = value.slice(0, 10)
|
||||
return date >= String(range[0]) && date <= String(range[1])
|
||||
}
|
||||
const filteredRows = computed(() => rows.value.filter((row) => {
|
||||
const status = stringFilter('status')
|
||||
const billNo = stringFilter('billNo')
|
||||
const settled = stringFilter('settled')
|
||||
const containerNo = stringFilter('containerNo')
|
||||
const current = detailCache.get(row.id)
|
||||
return (!status || String(row.status).toLowerCase() === status)
|
||||
&& (!billNo || row.billNo.toLowerCase().includes(billNo))
|
||||
&& (!settled || String(row.settled) === settled)
|
||||
&& (!containerNo || Boolean(current?.lines.some((line) => lineSourceCache.get(line.sourceFeeId)?.containerNo.toLowerCase().includes(containerNo))))
|
||||
&& inDateRange(row.periodStart, 'periodStartRange')
|
||||
&& inDateRange(row.periodEnd, 'periodEndRange')
|
||||
&& inDateRange(row.lastPaymentAt, 'lastPaymentRange')
|
||||
&& inDateRange(row.createdAt || '', 'createdRange')
|
||||
}))
|
||||
|
||||
async function hydrateLineSources(current: FinanceReconciliationDetail) {
|
||||
await Promise.all(current.lines.map(async (line) => {
|
||||
if (!line.sourceFeeId || lineSourceCache.has(line.sourceFeeId)) return
|
||||
try {
|
||||
const source = await getFinanceFeeSource(line.sourceFeeId)
|
||||
lineSourceCache.set(line.sourceFeeId, {
|
||||
containerNo: source.containerNo, inboundCode: source.inboundCode, businessDate: source.businessDate,
|
||||
quantity: source.quantity, unitPrice: source.unitPrice, sourceType: source.sourceType,
|
||||
})
|
||||
} catch {
|
||||
lineSourceCache.set(line.sourceFeeId, { containerNo: '', inboundCode: '', businessDate: '', quantity: null, unitPrice: null, sourceType: '' })
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
if (!canListCustomers.value) return
|
||||
customerError.value = null
|
||||
try {
|
||||
const result = await listPlatformTenants({ page: 1, size: 200 })
|
||||
@@ -75,26 +174,88 @@ async function loadCustomers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
filters.value = { ...model }
|
||||
async function load(model?: QueryModel) {
|
||||
const hydrateDetails = model !== undefined
|
||||
if (model) filters.value = { ...model }
|
||||
rows.value = []
|
||||
selectedId.value = ''
|
||||
detail.value = null
|
||||
detailCache.clear()
|
||||
lineSourceCache.clear()
|
||||
error.value = null
|
||||
loading.value = true
|
||||
try { rows.value = await listFinanceStatements(String(filters.value.customerId || '').trim() || undefined) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '客户对账单加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
try {
|
||||
const statements = await listFinanceStatements(String(filters.value.customerId || '').trim() || undefined)
|
||||
const detailErrors: ApiErrorDetails[] = []
|
||||
const hydrated = await Promise.all(statements.map(async (row) => {
|
||||
if (!hydrateDetails) return detailToRow(row)
|
||||
try {
|
||||
const current = await getReconciliationStatement(row.id)
|
||||
detailCache.set(row.id, current)
|
||||
await hydrateLineSources(current)
|
||||
return detailToRow(row, current)
|
||||
} catch (cause) {
|
||||
detailErrors.push(getApiErrorDetails(cause, `对账单 ${row.billNo || row.id} 的筛选字段加载失败`))
|
||||
return detailToRow(row)
|
||||
}
|
||||
}))
|
||||
if (detailErrors.length) {
|
||||
error.value = detailErrors[0]!
|
||||
return
|
||||
}
|
||||
rows.value = hydrated
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '客户对账单加载失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDetail(statementId: string) {
|
||||
return getReconciliationStatement(statementId)
|
||||
async function fetchDetail(statementId: string, force = false) {
|
||||
if (!force && detailCache.has(statementId)) return detailCache.get(statementId)!
|
||||
const current = await getReconciliationStatement(statementId)
|
||||
detailCache.set(statementId, current)
|
||||
await hydrateLineSources(current)
|
||||
return current
|
||||
}
|
||||
|
||||
async function openDetail(row: FinanceStatementReadModel) {
|
||||
drawerOpen.value = true
|
||||
selectedId.value = row.id
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
try { detail.value = await fetchDetail(row.id) }
|
||||
catch (cause) { detailError.value = getApiErrorDetails(cause, '对账单详情加载失败,请重试') }
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await fetchDetail(row.id, true)
|
||||
const index = rows.value.findIndex((item) => item.id === row.id)
|
||||
if (index >= 0) rows.value[index] = detailToRow(rows.value[index]!, detail.value)
|
||||
} catch (cause) {
|
||||
detailError.value = getApiErrorDetails(cause, '对账单明细加载失败,请重试')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectedRowClass({ row }: { row: StatementRow }) {
|
||||
return row.id === selectedId.value ? 'is-selected-statement' : ''
|
||||
}
|
||||
|
||||
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
|
||||
function exportStatements() {
|
||||
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 blob = new Blob([`\uFEFF${[header, ...body].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `客户对账单-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success(`已导出 ${body.length} 条客户对账单`)
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
|
||||
createOpen.value = true
|
||||
}
|
||||
|
||||
async function createStatement() {
|
||||
@@ -104,7 +265,7 @@ async function createStatement() {
|
||||
await createReconciliationStatement({ customerId: String(filters.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined })
|
||||
createOpen.value = false
|
||||
ElMessage.success('对账单已生成')
|
||||
await load()
|
||||
await load(filters.value)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '创建对账单失败') }
|
||||
finally { createBusy.value = false }
|
||||
}
|
||||
@@ -118,7 +279,7 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
|
||||
try {
|
||||
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
|
||||
ElMessage.success('对账单状态已更新')
|
||||
await load()
|
||||
await load(filters.value)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
|
||||
finally { actionBusy.value = false }
|
||||
}
|
||||
@@ -126,13 +287,10 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
|
||||
async function openAdjustment(row: FinanceStatementReadModel) {
|
||||
adjustBusy.value = true
|
||||
try {
|
||||
const current = await fetchDetail(row.id)
|
||||
const current = await fetchDetail(row.id, true)
|
||||
adjustStatement.value = { ...row, version: current.version }
|
||||
adjustReason.value = ''
|
||||
adjustLines.value = current.lines.flatMap((line) => {
|
||||
const statementLineId = line.statementLineId
|
||||
return statementLineId ? [{ selected: false, statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : []
|
||||
})
|
||||
adjustLines.value = current.lines.flatMap((line) => line.statementLineId ? [{ selected: false, statementLineId: line.statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : [])
|
||||
adjustOpen.value = true
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
|
||||
finally { adjustBusy.value = false }
|
||||
@@ -146,20 +304,18 @@ async function submitAdjustment() {
|
||||
adjustBusy.value = true
|
||||
try {
|
||||
await actionReconciliationStatement(statement.id, {
|
||||
action: 'ADJUST',
|
||||
reason: adjustReason.value.trim(),
|
||||
expectedVersion: statement.version,
|
||||
action: 'ADJUST', reason: adjustReason.value.trim(), expectedVersion: statement.version,
|
||||
adjustmentLines: selected.map((line) => ({ statementLineId: line.statementLineId, adjustedAmount: line.adjustedAmount, reason: line.reason.trim() })),
|
||||
})
|
||||
adjustOpen.value = false
|
||||
ElMessage.success('对账单调整已保存')
|
||||
await load()
|
||||
await load(filters.value)
|
||||
await openDetail(statement)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单调整失败') }
|
||||
finally { adjustBusy.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { void loadCustomers(); void load() })
|
||||
onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -168,36 +324,79 @@ onMounted(() => { void loadCustomers(); void load() })
|
||||
<ListPageLayout v-else aria-label="客户对账单">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template>
|
||||
<template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
|
||||
<section v-if="!customerError && !error" class="surface-panel table-panel">
|
||||
<DataTable :data="rows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="createOpen = true"><Plus :size="15" />生成对账单</el-button></template>
|
||||
<template v-if="!customerError && !error">
|
||||
<section class="surface-panel table-panel">
|
||||
<DataTable :data="filteredRows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" :row-class-name="selectedRowClass" @row-click="openDetail">
|
||||
<template #toolbar-actions>
|
||||
<el-button :disabled="loading || !filteredRows.length" @click="exportStatements"><Download :size="15" />导出</el-button>
|
||||
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="row.status === 'DRAFT' && canCreate" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'SUBMIT')"><Send :size="15" />提交</el-button>
|
||||
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'APPROVE')"><ShieldCheck :size="15" />通过</el-button>
|
||||
<el-button link type="primary" @click.stop="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="row.status === 'DRAFT' && canCreate" link :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'SUBMIT')"><Send :size="15" />提交</el-button>
|
||||
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" link :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'APPROVE')"><ShieldCheck :size="15" />通过</el-button>
|
||||
<el-dropdown v-if="row.status === 'PENDING_REVIEW' && (canReview || canVoid)" trigger="click" @command="(command: ReconciliationActionPayload['action']) => performAction(row as FinanceStatementReadModel, command)">
|
||||
<el-button link aria-label="更多对账单操作" title="更多对账单操作"><MoreHorizontal :size="16" /></el-button>
|
||||
<el-button link aria-label="更多对账单操作" title="更多对账单操作" @click.stop><MoreHorizontal :size="16" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canReview" command="REJECT"><XCircle :size="15" />驳回</el-dropdown-item><el-dropdown-item v-if="canVoid" command="VOID" divided><XCircle :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
<el-button v-if="row.status === 'APPROVED' && canIssue" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'ISSUE')"><Send :size="15" />出账</el-button>
|
||||
<el-button v-if="row.status === 'ISSUED' && canAdjust" link :loading="adjustBusy" @click="openAdjustment(row as FinanceStatementReadModel)"><RotateCcw :size="15" />调整</el-button>
|
||||
<el-button v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
||||
<el-button v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" link type="danger" :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
|
||||
<el-button v-if="row.status === 'APPROVED' && canIssue" link :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'ISSUE')"><Send :size="15" />出账</el-button>
|
||||
<el-button v-if="row.status === 'ISSUED' && canAdjust" link :loading="adjustBusy" @click.stop="openAdjustment(row as FinanceStatementReadModel)"><RotateCcw :size="15" />调整</el-button>
|
||||
<el-button v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" link :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
||||
<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 #pagination-left>币种:CNY</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 #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer v-model="drawerOpen" title="客户对账单详情" size="min(820px, 94vw)">
|
||||
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="false"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<section class="surface-panel statement-detail-panel" aria-label="所选对账单联动明细">
|
||||
<header class="statement-detail-header">
|
||||
<div><strong>已选择 {{ detail ? 1 : 0 }} 项</strong><span v-if="detail">{{ detail.billNo }} · {{ detail.customerName }}</span></div>
|
||||
<el-button v-if="detail" link @click="selectedId = ''; detail = null">清空</el-button>
|
||||
</header>
|
||||
<el-tabs v-model="activeDetailTab">
|
||||
<el-tab-pane label="账单明细表" name="lines">
|
||||
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="selectedId && openDetail(rows.find((row) => row.id === selectedId)!)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
|
||||
<template v-else-if="detail">
|
||||
<el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item></el-descriptions>
|
||||
<el-table :data="detail.lines" size="small" border stripe><el-table-column prop="feeItemName" label="费用项目" /><el-table-column prop="amount" label="账单金额(CNY)" /></el-table>
|
||||
<el-table v-if="detail.revisions.length" :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column prop="action" label="动作" width="130" /><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /></el-table>
|
||||
<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="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 || '—' }}</template></el-table-column>
|
||||
<el-table-column label="业务发生日" width="130"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template></el-table-column>
|
||||
<el-table-column label="来源类型" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.sourceType || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="feeItemName" label="费用项目" min-width="150" />
|
||||
<el-table-column label="计费量" width="110"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template></el-table-column>
|
||||
<el-table-column label="单价(CNY)" width="120"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template></el-table-column>
|
||||
<el-table-column prop="amount" label="账单金额(CNY)" width="150" />
|
||||
<el-table-column prop="sourceFeeId" label="来源费用ID" min-width="140" />
|
||||
<el-table-column prop="sourceFeeVersion" label="来源版本" width="110" />
|
||||
<el-table-column label="对账单号" min-width="180"><template #default>{{ detail.billNo }}</template></el-table-column>
|
||||
<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 prop="action" label="动作" width="130" /><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>
|
||||
</template>
|
||||
</AppDrawer>
|
||||
<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 prop="amount" label="支付金额(CNY)" min-width="150" />
|
||||
<el-table-column prop="method" label="支付方式" min-width="130" />
|
||||
<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 prop="remark" label="备注" min-width="180" />
|
||||
</el-table>
|
||||
<el-empty v-else description="请在主表中选择一张对账单" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(520px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"><el-form label-position="top"><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" /></el-form-item></el-form></AppDialog>
|
||||
|
||||
@@ -211,4 +410,14 @@ onMounted(() => { void loadCustomers(); void load() })
|
||||
<style scoped>
|
||||
.finance-statements-page { min-width: 0; }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
.statement-detail-panel { min-width: 0; padding: 0 16px 16px; }
|
||||
.statement-detail-header { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-detail-header div { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.statement-detail-header span { color: var(--cc-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.revision-collapse { margin-top: 12px; }
|
||||
:deep(.is-selected-statement > td.el-table__cell) { background: var(--el-color-primary-light-9) !important; }
|
||||
@media (max-width: 640px) {
|
||||
.statement-detail-panel { padding-inline: 12px; }
|
||||
.statement-detail-header div { align-items: flex-start; flex-direction: column; gap: 2px; padding-block: 8px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, type UploadFile } from 'element-plus'
|
||||
import { CreditCard, Eye, HandCoins, Plus } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { ArrowLeft, CreditCard, Download, Eye, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||
@@ -9,14 +9,14 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import {
|
||||
createFinanceSettlementPayment,
|
||||
actionFinanceSettlement,
|
||||
createFinanceReceipt,
|
||||
createFinanceSettlementPayment,
|
||||
createPayableSettlement,
|
||||
createReceivableSettlement,
|
||||
getFinanceSettlement,
|
||||
@@ -35,16 +35,19 @@ import type {
|
||||
FinanceStatementReadModel,
|
||||
} from '../types'
|
||||
|
||||
const props = defineProps<{ kind: 'receivable' | 'payable' }>()
|
||||
type SettlementRow = FinanceReceivableSettlement | FinancePayableSettlement
|
||||
|
||||
const props = defineProps<{ kind: 'receivable' | 'payable'; settlementId?: string }>()
|
||||
const emit = defineEmits<{ openDetail: [settlementId: string]; back: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const filters = ref<QueryModel>({ customerId: '' })
|
||||
const rows = ref<Array<FinanceReceivableSettlement | FinancePayableSettlement>>([])
|
||||
const filters = ref<QueryModel>({ customerId: '', supplierName: '', settlementNo: '', status: '' })
|
||||
const rows = ref<SettlementRow[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const customerError = ref<ApiErrorDetails | null>(null)
|
||||
const detailOpen = ref(false)
|
||||
const detail = ref<FinanceSettlementDetail | null>(null)
|
||||
const selectedRow = ref<SettlementRow | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const createOpen = ref(false)
|
||||
@@ -52,7 +55,7 @@ const createBusy = ref(false)
|
||||
const statementOptions = ref<FinanceStatementReadModel[]>([])
|
||||
const feeOptions = ref<FinanceFeePoolItem[]>([])
|
||||
const supplierOptions = ref<PlatformPartnerResource[]>([])
|
||||
const createForm = ref({ reconciliationStatementId: '', supplierId: '', feeSourceIds: [] as string[], dueDate: '', })
|
||||
const createForm = ref({ reconciliationStatementId: '', supplierId: '', feeSourceIds: [] as string[], dueDate: '' })
|
||||
const paymentOpen = ref(false)
|
||||
const paymentBusy = ref(false)
|
||||
const paymentFile = ref<File | null>(null)
|
||||
@@ -61,22 +64,92 @@ const receiptOpen = ref(false)
|
||||
const receiptBusy = ref(false)
|
||||
const receiptFile = ref<File | null>(null)
|
||||
const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' })
|
||||
const actionBusy = ref(false)
|
||||
|
||||
const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:view' : 'platform:finance:settlement-ap:view')
|
||||
const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create')
|
||||
const canView = computed(() => access.can(viewPermission.value))
|
||||
const listPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:list' : 'platform:finance:settlement-ap:list')
|
||||
const canView = computed(() => access.can(listPermission.value) || access.can(viewPermission.value))
|
||||
const canListCustomers = computed(() => access.can('platform:customer:list'))
|
||||
const canCreate = computed(() => access.can(createPermission.value))
|
||||
const canPay = computed(() => props.kind === 'payable' && access.can('platform:finance:payment:create'))
|
||||
const canReceive = computed(() => props.kind === 'receivable' && access.can('platform:finance:receipt:create'))
|
||||
const canVoid = computed(() => access.can('platform:finance:settlement:void'))
|
||||
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
|
||||
const detailMode = computed(() => Boolean(props.settlementId))
|
||||
const createLabel = computed(() => props.kind === 'receivable' ? '生成应收结算单' : '生成应付结算单')
|
||||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '全部客户', props: { filterable: true, clearable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
|
||||
const supplierNames = computed(() => [...new Set(rows.value.map((item) => 'supplierName' in item ? item.supplierName : '').filter(Boolean))])
|
||||
const statusOptions = computed(() => [...new Set(rows.value.map((item) => item.status).filter(Boolean))])
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: props.kind === 'receivable' ? '收款状态' : '付款状态', width: 180, placeholder: '全部状态', props: { clearable: true }, options: statusOptions.value.map((status) => ({ label: financeStatusLabel(status), value: status })) },
|
||||
...(props.kind === 'receivable'
|
||||
? [{ key: 'customerId', type: 'select' as const, label: '客户', width: 240, placeholder: '全部客户', props: { filterable: true, clearable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }]
|
||||
: [
|
||||
{ key: 'supplierName', type: 'select' as const, label: '供应商', width: 240, placeholder: '全部供应商', props: { filterable: true, clearable: true }, options: supplierNames.value.map((name) => ({ label: name, value: name })) },
|
||||
{ key: 'customerId', type: 'select' as const, label: '客户', width: 220, placeholder: '全部客户', props: { filterable: true, clearable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
]),
|
||||
{ key: 'settlementNo', type: 'input', label: '结算单号', width: 220, placeholder: '请输入结算单号' },
|
||||
])
|
||||
const filteredRows = computed(() => {
|
||||
const status = String(filters.value.status || '')
|
||||
const settlementNo = String(filters.value.settlementNo || '').trim().toLowerCase()
|
||||
const supplierName = String(filters.value.supplierName || '')
|
||||
return rows.value.filter((item) => (!status || item.status === status)
|
||||
&& (!settlementNo || item.settlementNo.toLowerCase().includes(settlementNo))
|
||||
&& (!supplierName || ('supplierName' in item && item.supplierName === supplierName)))
|
||||
})
|
||||
const totals = computed(() => filteredRows.value.reduce((result, row) => ({
|
||||
amount: result.amount + totalAmount(row),
|
||||
paid: result.paid + paidAmount(row),
|
||||
outstanding: result.outstanding + row.outstandingAmount,
|
||||
}), { amount: 0, paid: 0, outstanding: 0 }))
|
||||
const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
|
||||
? [{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
|
||||
: [{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }])
|
||||
? [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'settlementNo', label: '结算单号', minWidth: 180 },
|
||||
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
|
||||
{ key: 'currency', label: '币种', width: 80 },
|
||||
{ key: 'receivableAmount', label: '应收金额', width: 130, slotName: 'money' },
|
||||
{ key: 'receivedAmount', label: '已收', width: 120, slotName: 'paidMoney' },
|
||||
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
|
||||
{ key: 'billDate', label: '生成日期', width: 130, slotName: 'billDate' },
|
||||
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
|
||||
{ key: 'status', label: '收款状态', width: 120, slotName: 'status' },
|
||||
]
|
||||
: [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'settlementNo', label: '结算单号', minWidth: 180 },
|
||||
{ key: 'supplierName', label: '供应商', minWidth: 180, slotName: 'supplier' },
|
||||
{ key: 'feeItemName', label: '费用类型', minWidth: 150, slotName: 'feeType' },
|
||||
{ key: 'currency', label: '币种', width: 80 },
|
||||
{ key: 'payableAmount', label: '应付金额', width: 130, slotName: 'money' },
|
||||
{ key: 'paidAmount', label: '已付', width: 120, slotName: 'paidMoney' },
|
||||
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
|
||||
{ key: 'createdAt', label: '生成日期', width: 130, slotName: 'createdAt' },
|
||||
{ key: 'status', label: '付款状态', width: 120, slotName: 'status' },
|
||||
])
|
||||
|
||||
function money(value: number) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
|
||||
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 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 || '—' : '—' }
|
||||
function detailPartyName() { return !detail.value ? '—' : props.kind === 'receivable' ? customerName(detail.value.customerId, detail.value.customerName) : detail.value.supplierName || selectedSupplierName() }
|
||||
function detailActionRow(): SettlementRow {
|
||||
const current = detail.value!
|
||||
return props.kind === 'receivable'
|
||||
? { id: current.id, customerId: current.customerId, customerName: current.customerName || '', settlementNo: current.settlementNo, billDate: current.generatedDate || '', dueDate: current.dueDate || '', receivableAmount: current.amount, receivedAmount: current.paidAmount, outstandingAmount: current.outstandingAmount, currency: 'CNY', status: current.status as FinanceReceivableSettlement['status'], version: current.version }
|
||||
: { id: current.id, customerId: current.customerId, settlementNo: current.settlementNo, costType: 'SINGLE_ORDER', orderId: null, inboundId: null, containerId: null, supplierName: current.supplierName || '', feeItemName: '', payableAmount: current.amount, paidAmount: current.paidAmount, outstandingAmount: current.outstandingAmount, currency: 'CNY', status: current.status, createdAt: current.generatedDate || '', version: current.version }
|
||||
}
|
||||
function remainingAfter(amount: number) { return money(Math.max(0, Number(detail.value?.outstandingAmount || 0) - Number(amount || 0))) }
|
||||
|
||||
async function loadCustomers() {
|
||||
if (!canListCustomers.value) return
|
||||
customerError.value = null
|
||||
try { customers.value = (await listPlatformTenants({ page: 1, size: 200 })).records }
|
||||
catch (cause) { customerError.value = getApiErrorDetails(cause, '客户列表加载失败') }
|
||||
@@ -97,22 +170,25 @@ async function load(model: QueryModel = filters.value) {
|
||||
}
|
||||
|
||||
async function openDetail(row: unknown) {
|
||||
const settlement = row as FinanceReceivableSettlement | FinancePayableSettlement
|
||||
detailOpen.value = true
|
||||
selectedRow.value = row as SettlementRow
|
||||
emit('openDetail', selectedRow.value.id)
|
||||
await loadDetail(selectedRow.value.id)
|
||||
}
|
||||
|
||||
async function loadDetail(settlementId: string) {
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
detailLoading.value = true
|
||||
try { detail.value = await getFinanceSettlement(settlement.id) }
|
||||
try { detail.value = await getFinanceSettlement(settlementId) }
|
||||
catch (cause) { detailError.value = getApiErrorDetails(cause, `${title.value}详情加载失败`) }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
function backToList() { emit('back') }
|
||||
|
||||
async function openCreate() {
|
||||
const customerId = String(filters.value.customerId || '')
|
||||
if (!customerId) {
|
||||
ElMessage.warning('请先选择客户')
|
||||
return
|
||||
}
|
||||
if (!customerId) return ElMessage.warning('请先选择客户')
|
||||
createForm.value = { reconciliationStatementId: '', supplierId: '', feeSourceIds: [], dueDate: '' }
|
||||
createOpen.value = true
|
||||
error.value = null
|
||||
@@ -136,11 +212,8 @@ async function submitCreate() {
|
||||
if (props.kind === 'payable' && (!createForm.value.supplierId || !createForm.value.dueDate || !createForm.value.feeSourceIds.length)) return ElMessage.warning('请完整填写供应商、到期日和费用来源')
|
||||
createBusy.value = true
|
||||
try {
|
||||
if (props.kind === 'receivable') {
|
||||
await createReceivableSettlement({ reconciliationStatementId: createForm.value.reconciliationStatementId, currency: 'CNY' })
|
||||
} else {
|
||||
await createPayableSettlement({ customerId, supplierId: createForm.value.supplierId, feeSourceIds: createForm.value.feeSourceIds, dueDate: createForm.value.dueDate, currency: 'CNY' })
|
||||
}
|
||||
if (props.kind === 'receivable') await createReceivableSettlement({ reconciliationStatementId: createForm.value.reconciliationStatementId, currency: 'CNY' })
|
||||
else await createPayableSettlement({ customerId, supplierId: createForm.value.supplierId, feeSourceIds: createForm.value.feeSourceIds, dueDate: createForm.value.dueDate, currency: 'CNY' })
|
||||
createOpen.value = false
|
||||
ElMessage.success(`${title.value}已生成`)
|
||||
await load()
|
||||
@@ -150,16 +223,23 @@ async function submitCreate() {
|
||||
|
||||
function openPayment(row: unknown) {
|
||||
const settlement = row as FinancePayableSettlement
|
||||
selectedRow.value = settlement
|
||||
detail.value = { id: settlement.id, settlementNo: settlement.settlementNo, customerId: settlement.customerId, direction: 'PAYABLE', amount: settlement.payableAmount, paidAmount: settlement.paidAmount, outstandingAmount: settlement.outstandingAmount, currency: 'CNY', status: settlement.status, version: settlement.version, lines: [], transactions: [], revisions: [] }
|
||||
paymentFile.value = null
|
||||
paymentForm.value = { transactionDate: new Date().toISOString().slice(0, 10), amount: settlement.outstandingAmount, method: 'BANK_TRANSFER', referenceNo: '', remark: '' }
|
||||
paymentOpen.value = true
|
||||
}
|
||||
|
||||
function openPaymentFromDetail() {
|
||||
if (!detail.value) return
|
||||
openPayment({ id: detail.value.id, settlementNo: detail.value.settlementNo, customerId: detail.value.customerId, supplierName: detail.value.supplierName || '', feeItemName: '', payableAmount: detail.value.amount, paidAmount: detail.value.paidAmount, outstandingAmount: detail.value.outstandingAmount, currency: 'CNY', status: detail.value.status, version: detail.value.version })
|
||||
}
|
||||
|
||||
function selectPaymentFile(file: UploadFile) { paymentFile.value = file.raw ?? null }
|
||||
|
||||
async function openReceipt(row: unknown) {
|
||||
const settlement = row as FinanceReceivableSettlement
|
||||
selectedRow.value = settlement
|
||||
receiptBusy.value = true
|
||||
try {
|
||||
const current = await getFinanceSettlement(settlement.id)
|
||||
@@ -172,6 +252,11 @@ async function openReceipt(row: unknown) {
|
||||
finally { receiptBusy.value = false }
|
||||
}
|
||||
|
||||
async function openReceiptFromDetail() {
|
||||
if (!detail.value) return
|
||||
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 }
|
||||
|
||||
async function submitReceipt() {
|
||||
@@ -180,17 +265,11 @@ async function submitReceipt() {
|
||||
receiptBusy.value = true
|
||||
try {
|
||||
const voucher = await uploadFinanceVoucher(current.customerId, receiptFile.value)
|
||||
await createFinanceReceipt({
|
||||
customerId: current.customerId,
|
||||
receivedDate: receiptForm.value.receivedDate,
|
||||
amount: receiptForm.value.amount,
|
||||
paymentMethod: receiptForm.value.paymentMethod,
|
||||
remark: receiptForm.value.remark || undefined,
|
||||
allocations: [{ billId: current.reconciliationStatementId, amount: receiptForm.value.amount }],
|
||||
}, [voucher.fileId])
|
||||
await createFinanceReceipt({ customerId: current.customerId, receivedDate: receiptForm.value.receivedDate, amount: receiptForm.value.amount, paymentMethod: receiptForm.value.paymentMethod, remark: receiptForm.value.remark || undefined, allocations: [{ billId: current.reconciliationStatementId, amount: receiptForm.value.amount }] }, [voucher.fileId])
|
||||
receiptOpen.value = false
|
||||
ElMessage.success('收款已登记')
|
||||
await load()
|
||||
if (detailMode.value) await loadDetail(current.id)
|
||||
else await load()
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '收款登记失败') }
|
||||
finally { receiptBusy.value = false }
|
||||
}
|
||||
@@ -203,14 +282,65 @@ async function submitPayment() {
|
||||
await createFinanceSettlementPayment(detail.value.id, { ...paymentForm.value, currency: 'CNY', voucherFileIds: [voucher.fileId], expectedVersion: detail.value.version })
|
||||
paymentOpen.value = false
|
||||
ElMessage.success('付款已登记')
|
||||
await load()
|
||||
if (detailMode.value) await loadDetail(detail.value.id)
|
||||
else await load()
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '付款登记失败') }
|
||||
finally { paymentBusy.value = false }
|
||||
}
|
||||
|
||||
function customerName(customerId: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户信息暂不可用' }
|
||||
function totalAmount(row: FinanceReceivableSettlement | FinancePayableSettlement) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
|
||||
onMounted(() => { void loadCustomers(); void load() })
|
||||
async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
|
||||
const reason = await ElMessageBox.prompt('请输入操作原因', action === 'VOID' ? `作废${title.value}` : `重开${title.value}`, {
|
||||
inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消',
|
||||
}).then((result) => result.value.trim()).catch(() => null)
|
||||
if (reason === null) return
|
||||
actionBusy.value = true
|
||||
try {
|
||||
await actionFinanceSettlement(row.id, { action, reason, expectedVersion: row.version })
|
||||
ElMessage.success(action === 'VOID' ? '结算单已作废' : '结算单已重开')
|
||||
if (detailMode.value) await loadDetail(row.id)
|
||||
else await load()
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, `${action === 'VOID' ? '作废' : '重开'}结算单失败`) }
|
||||
finally { actionBusy.value = false }
|
||||
}
|
||||
|
||||
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
|
||||
function exportRows() {
|
||||
if (!filteredRows.value.length) return ElMessage.warning('当前没有可导出的结算单')
|
||||
const header = props.kind === 'receivable'
|
||||
? ['结算单号', '客户', '币种', '应收金额', '已收', '余额', '生成日期', '到期日', '收款状态']
|
||||
: ['结算单号', '供应商', '费用类型', '币种', '应付金额', '已付', '余额', '生成日期', '付款状态']
|
||||
const records = filteredRows.value.map((row) => props.kind === 'receivable'
|
||||
? [row.settlementNo, customerName(row.customerId, (row as FinanceReceivableSettlement).customerName), row.currency, totalAmount(row), paidAmount(row), row.outstandingAmount, (row as FinanceReceivableSettlement).billDate, (row as FinanceReceivableSettlement).dueDate, financeStatusLabel(row.status)]
|
||||
: [row.settlementNo, (row as FinancePayableSettlement).supplierName, (row as FinancePayableSettlement).feeItemName, row.currency, totalAmount(row), paidAmount(row), row.outstandingAmount, (row as FinancePayableSettlement).createdAt, financeStatusLabel(row.status)])
|
||||
const blob = new Blob([`\ufeff${[header, ...records].map((record) => record.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${title.value}-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function exportDetail() {
|
||||
if (!detail.value) return
|
||||
const current = detail.value
|
||||
const header = ['结算单号', props.kind === 'receivable' ? '客户' : '供应商', '币种', props.kind === 'receivable' ? '应收金额' : '应付金额', props.kind === 'receivable' ? '已收' : '已付', '余额', '业务期间', '生成日期', '到期日', '状态']
|
||||
const record = [current.settlementNo, props.kind === 'receivable' ? customerName(current.customerId, current.customerName) : current.supplierName, current.currency, current.amount, current.paidAmount, current.outstandingAmount, current.businessPeriod, current.generatedDate, current.dueDate, financeStatusLabel(current.status)]
|
||||
const blob = new Blob([`\ufeff${[header, record].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${current.settlementNo}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (canListCustomers.value) void loadCustomers()
|
||||
const settlementId = String(props.settlementId || '')
|
||||
if (settlementId) void loadDetail(settlementId)
|
||||
else void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -218,31 +348,102 @@ onMounted(() => { void loadCustomers(); void load() })
|
||||
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
|
||||
<ListPageLayout v-else :aria-label="title">
|
||||
<template #toolbar>
|
||||
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" preference-key="finance-settlement-query" @search="load" />
|
||||
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" @search="load" @reset="load" />
|
||||
</template>
|
||||
<template v-if="customerError || error" #state>
|
||||
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
|
||||
</template>
|
||||
<section v-if="!customerError && !error" class="surface-panel table-panel">
|
||||
<DataTable :data="rows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="暂无结算单数据">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button></template>
|
||||
<template #actions="{ row }"><el-button link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link :loading="receiptBusy" @click="openReceipt(row)"><HandCoins :size="15" />收款</el-button><el-button v-if="canPay && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link @click="openPayment(row)"><CreditCard :size="15" />付款</el-button></template>
|
||||
<template #customer="{ row }">{{ customerName(String(row.customerId)) }}</template>
|
||||
<template v-if="!customerError && !error">
|
||||
<section class="settlement-summary" aria-label="结算汇总">
|
||||
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</span><strong>{{ money(totals.amount) }}</strong></div>
|
||||
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收' : '已付' }}</span><strong class="is-success">{{ money(totals.paid) }}</strong></div>
|
||||
<div class="summary-item"><span>{{ kind === 'receivable' ? '待收' : '待付' }}</span><strong class="is-danger">{{ money(totals.outstanding) }}</strong></div>
|
||||
</section>
|
||||
<section class="surface-panel table-panel">
|
||||
<DataTable :data="filteredRows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
|
||||
<template #toolbar-actions>
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button>
|
||||
<el-button @click="exportRows"><Download :size="15" />导出</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link :loading="receiptBusy" @click="openReceipt(row)"><HandCoins :size="15" />收款</el-button>
|
||||
<el-button v-if="canPay && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link @click="openPayment(row)"><CreditCard :size="15" />付款</el-button>
|
||||
<el-button v-if="canVoid && row.status === 'ISSUED'" link type="danger" :loading="actionBusy" @click="performAction(row as SettlementRow, 'VOID')"><XCircle :size="15" />作废</el-button>
|
||||
<el-button v-if="canVoid && row.status === 'VOIDED'" link :loading="actionBusy" @click="performAction(row as SettlementRow, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
||||
</template>
|
||||
<template #customer="{ row }">{{ customerName(String(row.customerId), row.customerName) }}</template>
|
||||
<template #supplier="{ row }">{{ row.supplierName || '—' }}</template>
|
||||
<template #feeType="{ row }">{{ row.feeItemName || '详见费用明细' }}</template>
|
||||
<template #money="{ row }">{{ money(rowTotal(row)) }}</template>
|
||||
<template #paidMoney="{ row }">{{ money(rowPaid(row)) }}</template>
|
||||
<template #outstandingMoney="{ row }">{{ money(rowOutstanding(row)) }}</template>
|
||||
<template #billDate="{ row }">{{ displayDate((row as FinanceReceivableSettlement).billDate) }}</template>
|
||||
<template #dueDate="{ row }">{{ displayDate((row as FinanceReceivableSettlement).dueDate) }}</template>
|
||||
<template #createdAt="{ row }">{{ displayDate((row as FinancePayableSettlement).createdAt) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
|
||||
<template #pagination-left>币种:CNY</template>
|
||||
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer v-model="detailOpen" :title="`${title}详情`" size="min(760px, 94vw)">
|
||||
<AppDrawer v-model="detailOpen" :title="`${title}详情`" size="min(900px, 96vw)">
|
||||
<LoadErrorState v-if="detailError" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<el-skeleton v-else-if="detailLoading" :rows="6" animated />
|
||||
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(detail.customerId) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.amount }} CNY</el-descriptions-item><el-descriptions-item label="已收付">{{ detail.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收付">{{ detail.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item></el-descriptions></template>
|
||||
<el-skeleton v-else-if="detailLoading" :rows="8" animated />
|
||||
<div v-else-if="detail" class="detail-content">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ kind === 'receivable' ? customerName(detail.customerId) : selectedSupplierName() }}</el-descriptions-item>
|
||||
<el-descriptions-item label="币种">CNY</el-descriptions-item>
|
||||
<el-descriptions-item label="状态"><el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item :label="kind === 'receivable' ? '应收金额' : '应付金额'">{{ money(detail.amount) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="kind === 'receivable' ? '已收' : '已付'">{{ money(detail.paidAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="余额">{{ money(detail.outstandingAmount) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.reconciliationStatementId" label="关联对账单">{{ detail.reconciliationStatementId }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<section class="detail-section">
|
||||
<h3>费用明细</h3>
|
||||
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
|
||||
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
|
||||
<el-table-column prop="sourceType" label="来源类型" min-width="140" />
|
||||
<el-table-column prop="inboundCode" label="入仓码" min-width="130"><template #default="scope">{{ scope.row.inboundCode || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="containerNo" label="柜次号" min-width="130"><template #default="scope">{{ scope.row.containerNo || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="businessDate" label="业务发生日" width="120"><template #default="scope">{{ displayDate(scope.row.businessDate) }}</template></el-table-column>
|
||||
<el-table-column prop="sourceVersion" label="来源版本" width="100" />
|
||||
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h3>{{ kind === 'receivable' ? '收款记录' : '付款记录' }}</h3>
|
||||
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
|
||||
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
|
||||
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
|
||||
<el-table-column prop="method" :label="kind === 'receivable' ? '收款方式' : '付款方式'" min-width="130" />
|
||||
<el-table-column label="凭证" width="100"><template #default="scope">{{ scope.row.voucherFileIds.length ? `${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180"><template #default="scope">{{ scope.row.remark || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h3>修订记录</h3>
|
||||
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无修订记录">
|
||||
<el-table-column prop="revisionNo" label="版本" width="90" />
|
||||
<el-table-column prop="action" label="动作" min-width="120" />
|
||||
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
|
||||
<el-table-column prop="afterAmount" label="调整后(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.afterAmount) }}</template></el-table-column>
|
||||
<el-table-column prop="reason" label="原因" min-width="180"><template #default="scope">{{ scope.row.reason || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="occurredAt" label="时间" min-width="160" />
|
||||
</el-table>
|
||||
</section>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDialog v-model="createOpen" :title="`生成${title}`" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
|
||||
<el-form label-position="top">
|
||||
<el-form-item v-if="props.kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
|
||||
<template v-else><el-form-item label="供应商"><el-select v-model="createForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="`${item.partnerName} · ${item.partnerCode}`" :value="String(item.id)" /></el-select></el-form-item><el-form-item label="到期日"><el-date-picker v-model="createForm.dueDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="应付费用来源"><el-select v-model="createForm.feeSourceIds" multiple filterable><el-option v-for="item in feeOptions" :key="item.feeSourceId!" :label="`${item.feeItemName} · ${item.amount} CNY`" :value="item.feeSourceId!" /></el-select></el-form-item></template>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
@@ -259,6 +460,19 @@ onMounted(() => { void loadCustomers(); void load() })
|
||||
|
||||
<style scoped>
|
||||
.finance-settlement-page { min-width: 0; }
|
||||
.settlement-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.summary-item { min-width: 0; padding: 16px 18px; border: 1px solid var(--cc-border, #e5e7eb); border-radius: 8px; background: var(--cc-surface, #fff); }
|
||||
.summary-item span { display: block; color: var(--cc-text-secondary, #64748b); font-size: 13px; }
|
||||
.summary-item strong { display: block; margin-top: 8px; color: var(--cc-text-primary, #1f2937); font-size: 24px; line-height: 1.2; }
|
||||
.summary-item strong.is-success { color: #16803c; }
|
||||
.summary-item strong.is-danger { color: #c2413a; }
|
||||
.detail-content { min-width: 0; }
|
||||
.detail-section { margin-top: 22px; }
|
||||
.detail-section h3 { margin: 0 0 10px; color: var(--cc-text-primary, #1f2937); font-size: 15px; font-weight: 600; }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
@media (max-width: 720px) { :deep(.el-drawer__footer .el-button) { min-height: 44px; } }
|
||||
@media (max-width: 720px) {
|
||||
.settlement-summary { grid-template-columns: 1fr; }
|
||||
.summary-item strong { font-size: 21px; }
|
||||
:deep(.el-drawer__footer .el-button) { min-height: 44px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -6,6 +6,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import {
|
||||
actionFinanceSettlement,
|
||||
actionReconciliationStatement,
|
||||
createFinanceReceipt,
|
||||
getFinanceSettlement,
|
||||
@@ -20,7 +21,7 @@ import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
|
||||
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
actionReconciliationStatement: vi.fn(), createFinanceReceipt: vi.fn(), createFinanceSettlementPayment: vi.fn(),
|
||||
actionFinanceSettlement: vi.fn(), actionReconciliationStatement: vi.fn(), createFinanceReceipt: vi.fn(), createFinanceSettlementPayment: vi.fn(),
|
||||
createPayableSettlement: vi.fn(), createReceivableSettlement: vi.fn(), createReconciliationStatement: vi.fn(),
|
||||
getFinanceSettlement: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceFeePool: vi.fn(),
|
||||
listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(), listFinanceStatements: vi.fn(),
|
||||
@@ -83,6 +84,7 @@ describe('finance G3/G4 page operations', () => {
|
||||
const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(actionReconciliationStatement).mockReset().mockResolvedValue(reconciliation)
|
||||
vi.mocked(actionFinanceSettlement).mockReset().mockResolvedValue({ ...receivable, status: 'VOIDED', version: 1 })
|
||||
vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(reconciliation)
|
||||
vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue(receivable)
|
||||
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
|
||||
@@ -140,4 +142,17 @@ describe('finance G3/G4 page operations', () => {
|
||||
allocations: [{ billId: '501', amount: 300 }],
|
||||
}, ['9001'])
|
||||
})
|
||||
|
||||
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:view', 'platform:finance:settlement:void'])
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as { performAction(row: Record<string, unknown>, action: 'VOID' | 'REOPEN'): Promise<void> }
|
||||
|
||||
await vm.performAction({ id: '701', version: 3 }, 'VOID')
|
||||
|
||||
expect(actionFinanceSettlement).toHaveBeenCalledWith('701', { action: 'VOID', reason: '账务冲销', expectedVersion: 3 })
|
||||
prompt.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,2 +1,12 @@
|
||||
<script setup lang="ts">import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'</script>
|
||||
<template><FinanceFormalSettlementPage kind="payable" /></template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const settlementId = computed(() => String(route.params.settlementId || ''))
|
||||
function openDetail(id: string) { void router.push({ name: 'finance-settlement-payable-detail', params: { settlementId: id } }) }
|
||||
function back() { void router.push({ name: 'finance-settlement-payables' }) }
|
||||
</script>
|
||||
<template><FinanceFormalSettlementPage kind="payable" :settlement-id="settlementId" @open-detail="openDetail" @back="back" /></template>
|
||||
|
||||
@@ -1,2 +1,12 @@
|
||||
<script setup lang="ts">import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'</script>
|
||||
<template><FinanceFormalSettlementPage kind="receivable" /></template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const settlementId = computed(() => String(route.params.settlementId || ''))
|
||||
function openDetail(id: string) { void router.push({ name: 'finance-settlement-receivable-detail', params: { settlementId: id } }) }
|
||||
function back() { void router.push({ name: 'finance-settlement-receivables' }) }
|
||||
</script>
|
||||
<template><FinanceFormalSettlementPage kind="receivable" :settlement-id="settlementId" @open-detail="openDetail" @back="back" /></template>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const financeRoutes = [
|
||||
{ path: 'finance/customer-accounts', name: 'finance-customer-accounts', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户', description: '查看客户 CNY 应收台账并维护独立账期', permission: 'platform:finance:account:view' } },
|
||||
{ path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:bill:view' } },
|
||||
{ path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:bill:view' } },
|
||||
{ path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:view' } },
|
||||
{ path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:settlement-ap:view' } },
|
||||
{ path: 'finance/customer-accounts', name: 'finance-customer-accounts', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户', description: '查看客户 CNY 应收台账并维护独立账期', permission: 'platform:finance:customer-account:list', permissionAnyOf: ['platform:finance:account:view'] } },
|
||||
{ path: 'finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户详情', description: '查看客户派生财务汇总与账户流水', permission: 'platform:finance:customer-account:list', permissionAnyOf: ['platform:finance:account:view'], showPageHeading: false } },
|
||||
{ path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:customer-arap:list', permissionAnyOf: ['platform:finance:bill:view'] } },
|
||||
{ path: 'finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '费用来源详情', description: '查看费用来源、审核进度、关联业务与操作记录', permission: 'platform:finance:customer-arap:list', permissionAnyOf: ['platform:finance:bill:view'], showPageHeading: false } },
|
||||
{ path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:customer-statement:list', permissionAnyOf: ['platform:finance:bill:view'] } },
|
||||
{ path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:list', permissionAnyOf: ['platform:finance:settlement-ar:view'] } },
|
||||
{ path: 'finance/settlement-receivables/:settlementId', name: 'finance-settlement-receivable-detail', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '应收结算单详情', description: '查看应收结算信息、费用明细和收款记录', permission: 'platform:finance:settlement-ar:list', permissionAnyOf: ['platform:finance:settlement-ar:view'], showPageHeading: false } },
|
||||
{ path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:settlement-ap:list', permissionAnyOf: ['platform:finance:settlement-ap:view'] } },
|
||||
{ path: 'finance/settlement-payables/:settlementId', name: 'finance-settlement-payable-detail', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '应付结算单详情', description: '查看应付结算信息、费用明细和付款记录', permission: 'platform:finance:settlement-ap:list', permissionAnyOf: ['platform:finance:settlement-ap:view'], showPageHeading: false } },
|
||||
] satisfies RouteRecordRaw[]
|
||||
|
||||
@@ -4,7 +4,7 @@ export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER'
|
||||
export type FinanceFeeDirection = 'RECEIVABLE' | 'PAYABLE'
|
||||
export type FinanceReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
export type FinanceReconciliationStatus = 'DRAFT' | 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'ISSUED' | 'VOIDED'
|
||||
export type FinanceSettlementStatus = 'ISSUED' | 'VOIDED'
|
||||
export type FinanceSettlementStatus = 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
|
||||
export type FinancePaymentStatus = 'UNPAID' | 'PARTIALLY_PAID' | 'PAID'
|
||||
|
||||
export interface FinanceVoucherResource {
|
||||
@@ -47,6 +47,7 @@ export interface FinanceFeePoolItem {
|
||||
poolItemId: string
|
||||
feeSourceId: string | null
|
||||
customerId: string
|
||||
customerName?: string
|
||||
direction: 'RECEIVABLE' | 'PAYABLE'
|
||||
sourceType: string
|
||||
sourceId: string
|
||||
@@ -74,17 +75,26 @@ export interface FinanceStatementReadModel {
|
||||
currency: 'CNY'
|
||||
status: FinanceBillStatus
|
||||
version: number
|
||||
createdBy?: string
|
||||
createdAt?: string
|
||||
updatedBy?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface FinanceReceivableSettlement {
|
||||
id: string
|
||||
customerId: string
|
||||
settlementNo: string
|
||||
customerName?: string
|
||||
reconciliationStatementId?: string
|
||||
businessPeriod?: string
|
||||
generatedDate?: string
|
||||
billDate: string
|
||||
dueDate: string
|
||||
receivableAmount: number
|
||||
receivedAmount: number
|
||||
outstandingAmount: number
|
||||
paymentStatus?: FinancePaymentStatus
|
||||
currency: 'CNY'
|
||||
status: FinanceBillStatus
|
||||
version: number
|
||||
@@ -94,15 +104,21 @@ export interface FinancePayableSettlement {
|
||||
id: string
|
||||
customerId: string
|
||||
settlementNo: string
|
||||
customerName?: string
|
||||
supplierId?: string
|
||||
costType: FinanceCostType
|
||||
orderId: string | null
|
||||
inboundId: string | null
|
||||
containerId: string | null
|
||||
supplierName: string
|
||||
feeItemName: string
|
||||
businessPeriod?: string
|
||||
generatedDate?: string
|
||||
dueDate?: string
|
||||
payableAmount: number
|
||||
paidAmount: number
|
||||
outstandingAmount: number
|
||||
paymentStatus?: FinancePaymentStatus
|
||||
currency: 'CNY'
|
||||
status: string
|
||||
createdAt: string
|
||||
@@ -165,7 +181,13 @@ export interface FinanceSettlementDetail {
|
||||
settlementNo: string
|
||||
customerId: string
|
||||
supplierId?: string
|
||||
customerName?: string
|
||||
supplierName?: string
|
||||
reconciliationStatementId?: string
|
||||
businessPeriod?: string
|
||||
generatedDate?: string
|
||||
dueDate?: string
|
||||
paymentStatus?: FinancePaymentStatus
|
||||
direction: 'RECEIVABLE' | 'PAYABLE'
|
||||
amount: number
|
||||
paidAmount: number
|
||||
@@ -178,11 +200,21 @@ export interface FinanceSettlementDetail {
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; occurredAt: string }>
|
||||
}
|
||||
|
||||
export interface FinanceSettlementActionPayload {
|
||||
action: 'VOID' | 'REOPEN'
|
||||
reason: string
|
||||
expectedVersion: number
|
||||
}
|
||||
|
||||
export interface FinanceReconciliationDetail extends FinanceBillDetail {
|
||||
periodStart: string
|
||||
periodEnd: string
|
||||
creditDays: number
|
||||
settled: boolean
|
||||
createdBy?: string
|
||||
createdAt?: string
|
||||
updatedBy?: string
|
||||
updatedAt?: string
|
||||
paymentHistory: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; createdBy: string; occurredAt: string; remark?: string }>
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; occurredAt: string }>
|
||||
}
|
||||
|
||||
@@ -186,12 +186,12 @@ export interface UpdatePlatformRoleRequest {
|
||||
}
|
||||
|
||||
export interface CreatePlatformPermissionRequest {
|
||||
type: 'MENU' | 'BUTTON'
|
||||
type: 'MENU' | 'PAGE' | 'BUTTON' | 'API'
|
||||
code: string
|
||||
name: string
|
||||
parentId: number | string
|
||||
routePath: string | null
|
||||
httpMethod: null
|
||||
httpMethod: string | null
|
||||
sortNo: number
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const filters = ref<PermissionFilters>({ keyword: '', type: '', status: '' })
|
||||
const appliedFilters = ref<PermissionFilters>({ ...filters.value })
|
||||
const queryFields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '权限关键词', placeholder: '权限名称、编码或路径' },
|
||||
{ key: 'type', type: 'select', label: '类型', placeholder: '全部类型', options: [{ label: '菜单', value: '菜单' }, { label: '按钮', value: '按钮' }] },
|
||||
{ key: 'type', type: 'select', label: '类型', placeholder: '全部类型', options: [{ label: '菜单', value: '菜单' }, { label: '页面', value: '页面' }, { label: '按钮', value: '按钮' }, { label: '接口', value: '接口' }] },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '启用', value: '启用' }, { label: '停用', value: '停用' }] },
|
||||
]
|
||||
const expandedRowKeys = ref<string[]>([])
|
||||
@@ -51,8 +51,9 @@ const formRules = computed<FormRules>(() => ({
|
||||
name: [{ required: true, whitespace: true, message: '请输入权限名称', trigger: 'blur' }],
|
||||
code: [{ required: true, whitespace: true, message: '请输入权限编码', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择资源类型', trigger: 'change' }],
|
||||
parentId: form.type === '按钮' ? [{ required: true, message: '请选择上级菜单', trigger: 'change' }] : [],
|
||||
path: form.type === '菜单' ? [{ required: true, whitespace: true, message: '请输入菜单路由', trigger: 'blur' }] : [],
|
||||
parentId: ['按钮', '页面', '接口'].includes(form.type) ? [{ required: true, message: '请选择上级菜单', trigger: 'change' }] : [],
|
||||
path: ['菜单', '页面'].includes(form.type) ? [{ required: true, whitespace: true, message: '请输入菜单路由', trigger: 'blur' }] : [],
|
||||
method: form.type === '接口' ? [{ required: true, message: '请选择 HTTP 方法', trigger: 'change' }] : [],
|
||||
sort: [{ required: true, type: 'number', message: '请输入显示排序', trigger: 'change' }],
|
||||
}))
|
||||
const editorLabel = computed(() => form.id ? '编辑权限' : '新增权限')
|
||||
@@ -64,7 +65,7 @@ const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
model: form,
|
||||
requiredPermission,
|
||||
})
|
||||
const availablePermissionTypes: PermissionType[] = ['菜单', '按钮']
|
||||
const availablePermissionTypes: PermissionType[] = ['菜单', '页面', '按钮', '接口']
|
||||
const permissions = computed(() => serverPermissions.value)
|
||||
const canCreate = computed(() => access.can('platform:permission:create'))
|
||||
const canEdit = computed(() => access.can('platform:permission:edit'))
|
||||
@@ -82,7 +83,7 @@ const blockedParentIds = computed(() => new Set(
|
||||
form.id ? [form.id, ...getDescendantIds(permissions.value, form.id)] : [],
|
||||
))
|
||||
const parentOptions = computed(() => permissions.value.filter((item) => (
|
||||
item.type === '菜单' && !blockedParentIds.value.has(item.id)
|
||||
['目录', '菜单', '页面'].includes(item.type) && !blockedParentIds.value.has(item.id)
|
||||
)))
|
||||
const permissionColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
|
||||
@@ -127,7 +128,7 @@ function permissionSummary(row: PermissionTreeNode) {
|
||||
const descendants = collectDescendantIds(row.children ?? [])
|
||||
.map((id) => permissions.value.find((item) => item.id === id))
|
||||
.filter((item): item is Permission => Boolean(item))
|
||||
const menuCount = descendants.filter((item) => item.type === '菜单').length
|
||||
const menuCount = descendants.filter((item) => ['菜单', '页面'].includes(item.type)).length
|
||||
const actionCount = descendants.filter((item) => ['按钮', '接口'].includes(item.type)).length
|
||||
if (menuCount === 0 && actionCount === 0) return '无下级权限'
|
||||
return [menuCount ? `${menuCount} 个菜单` : '', actionCount ? `${actionCount} 项操作` : ''].filter(Boolean).join(' · ')
|
||||
@@ -157,8 +158,9 @@ function openEdit(row: Permission) {
|
||||
nextTick(() => formRef.value?.clearValidate())
|
||||
}
|
||||
function handleTypeChange() {
|
||||
if (form.type === '按钮') form.path = ''
|
||||
nextTick(() => formRef.value?.clearValidate(['parentId', 'path']))
|
||||
if (!['菜单', '页面'].includes(form.type)) form.path = ''
|
||||
form.method = form.type === '接口' ? (form.method === '-' ? 'GET' : form.method) : '-'
|
||||
nextTick(() => formRef.value?.clearValidate(['parentId', 'path', 'method']))
|
||||
}
|
||||
async function savePermission() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
@@ -170,7 +172,7 @@ async function savePermission() {
|
||||
const updated = await updatePlatformPermission(form.id, {
|
||||
name: form.name.trim(),
|
||||
parentId: form.parentId,
|
||||
routePath: form.type === '菜单' ? form.path.trim() : null,
|
||||
routePath: ['菜单', '页面'].includes(form.type) ? form.path.trim() : null,
|
||||
httpMethod: form.method === '-' ? null : form.method,
|
||||
sortNo: form.sort,
|
||||
})
|
||||
@@ -179,12 +181,12 @@ async function savePermission() {
|
||||
ElMessage.success('权限资源已更新')
|
||||
} else {
|
||||
const created = await createPlatformPermission({
|
||||
type: form.type === '按钮' ? 'BUTTON' : 'MENU',
|
||||
type: ({ 目录: 'MENU', 菜单: 'MENU', 页面: 'PAGE', 按钮: 'BUTTON', 接口: 'API' } as const)[form.type],
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
parentId: form.parentId || '0',
|
||||
routePath: form.type === '菜单' ? form.path.trim() : null,
|
||||
httpMethod: null,
|
||||
routePath: ['菜单', '页面'].includes(form.type) ? form.path.trim() : null,
|
||||
httpMethod: form.type === '接口' ? form.method : null,
|
||||
sortNo: form.sort,
|
||||
})
|
||||
serverPermissions.value.push(mapPlatformPermission(created))
|
||||
@@ -229,10 +231,10 @@ async function removePermission(row: Permission) {
|
||||
rowActionId.value = ''
|
||||
}
|
||||
}
|
||||
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '按钮': 'action', '接口': 'api' }[type] }
|
||||
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '页面': 'menu', '按钮': 'action', '接口': 'api' }[type] }
|
||||
|
||||
function canAddChild(row: Permission) {
|
||||
return canCreate.value && row.type === '菜单'
|
||||
return canCreate.value && ['菜单', '页面'].includes(row.type)
|
||||
}
|
||||
function canEditRow(row: Permission) {
|
||||
return canEdit.value && !row.builtIn
|
||||
@@ -292,7 +294,7 @@ onMounted(loadPermissions)
|
||||
</div>
|
||||
<span v-else class="secondary-text">仅查看</span>
|
||||
</template>
|
||||
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="(row as Permission).type === '目录' || (row as Permission).type === '菜单'" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
|
||||
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="['目录', '菜单', '页面'].includes((row as Permission).type)" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
|
||||
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
|
||||
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
|
||||
<template #status="{ row }"><StatusBadge :status="(row as Permission).status" /></template>
|
||||
@@ -308,12 +310,13 @@ onMounted(loadPermissions)
|
||||
<el-form-item label="权限名称" prop="name" required><el-input v-model="form.name" placeholder="用户可理解的中文名称" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="权限编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:platform:customer:list" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="资源类型" prop="type" required><el-select v-model="form.type" :disabled="Boolean(form.id)" @change="handleTypeChange"><el-option v-for="item in availablePermissionTypes" :key="item" :label="item" :value="item" /></el-select></el-form-item>
|
||||
<el-form-item label="上级资源" prop="parentId" :required="form.type === '按钮'"><el-select v-model="form.parentId" clearable placeholder="作为一级资源"><el-option v-for="item in parentOptions" :key="item.id" :label="`${item.name} (${item.code})`" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.type === '菜单'" label="路由路径" prop="path" required><el-input v-model="form.path" placeholder="如:/customers" /></el-form-item>
|
||||
<el-form-item label="上级资源" prop="parentId" :required="['按钮', '页面', '接口'].includes(form.type)"><el-select v-model="form.parentId" clearable placeholder="作为一级资源"><el-option v-for="item in parentOptions" :key="item.id" :label="`${item.name} (${item.code})`" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="['菜单', '页面'].includes(form.type)" label="路由路径" prop="path" required><el-input v-model="form.path" placeholder="如:/customers" /></el-form-item>
|
||||
<el-form-item v-if="form.type === '接口'" label="HTTP 方法" prop="method" required><el-select v-model="form.method"><el-option v-for="method in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']" :key="method" :label="method" :value="method" /></el-select></el-form-item>
|
||||
<el-form-item label="显示排序" prop="sort" required><el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<div class="form-callout"><Braces :size="18" /><div><strong>权限编码约定</strong><p>使用稳定的“业务域:资源:动作”编码;一期真实资源只创建菜单和按钮,接口鉴权由服务端实现。</p></div></div>
|
||||
<div class="form-callout"><Braces :size="18" /><div><strong>权限编码约定</strong><p>使用稳定的“业务域:资源:动作”编码;资源类型与后端 MENU、PAGE、BUTTON、API 契约一致。</p></div></div>
|
||||
</el-form>
|
||||
</AppDrawer>
|
||||
</AdminPage>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type RecordStatus = '正常' | '停用'
|
||||
export type RoleStatus = '启用' | '停用'
|
||||
export type PermissionType = '目录' | '菜单' | '按钮' | '接口'
|
||||
export type PermissionType = '目录' | '菜单' | '页面' | '按钮' | '接口'
|
||||
export type DataScope = '全部数据' | '本部门数据' | '本人数据' | '自定义'
|
||||
|
||||
export interface InternalUser {
|
||||
|
||||
@@ -69,7 +69,7 @@ export function summarizePermissionSelection(permissions: Permission[], selected
|
||||
const selectedPermissions = permissions.filter((permission) => selected.has(permission.id))
|
||||
|
||||
return {
|
||||
menuCount: selectedPermissions.filter((permission) => permission.type === '菜单').length,
|
||||
menuCount: selectedPermissions.filter((permission) => ['菜单', '页面'].includes(permission.type)).length,
|
||||
actionCount: selectedPermissions.filter((permission) => ['按钮', '接口'].includes(permission.type)).length,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,4 +33,18 @@ describe('mapPlatformPermission', () => {
|
||||
status: 'ACTIVE',
|
||||
})).toMatchObject({ id: '2082813911786024962', parentId: '2082813911786024961', type: '按钮' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['MENU', '菜单'],
|
||||
['PAGE', '页面'],
|
||||
['BUTTON', '按钮'],
|
||||
['API', '接口'],
|
||||
] as const)('maps backend permission type %s without collapsing it', (permissionType, type) => {
|
||||
expect(mapPlatformPermission({
|
||||
id: '1', tenantId: '0', appCode: 'PLATFORM', permissionType, parentId: '0',
|
||||
permissionCode: `platform:test:${permissionType.toLowerCase()}`, permissionName: type,
|
||||
routePath: null, httpMethod: permissionType === 'API' ? 'GET' : null,
|
||||
sortNo: 10, status: 'ACTIVE',
|
||||
})).toMatchObject({ type })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ export function mapPlatformPermission(item: PlatformPermissionResource): Permiss
|
||||
parentId: parentId === '0' ? null : parentId,
|
||||
name: item.permissionName,
|
||||
code: item.permissionCode,
|
||||
type: item.permissionType === 'BUTTON' ? '按钮' : '菜单',
|
||||
type: ({ MENU: '菜单', PAGE: '页面', BUTTON: '按钮', API: '接口' } as const)[item.permissionType],
|
||||
path: item.routePath ?? '',
|
||||
method: item.httpMethod ?? '-',
|
||||
status: item.status === 'ACTIVE' ? '启用' : '停用',
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ClipboardCheck, Eye, Ruler, SearchCheck } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { listPlatformInbounds } from '../core/api'
|
||||
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'
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
customerId: string
|
||||
inboundCode: string
|
||||
orderNo: string
|
||||
warehouseId: string
|
||||
expectedArrivalRange: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ mode: InboundTaskMode }>()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
|
||||
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', expectedArrivalRange: [] })
|
||||
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customerLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
const selectedInbound = ref<InboundOrderSummary | null>(null)
|
||||
const taskDialogOpen = ref(false)
|
||||
let requestSerial = 0
|
||||
let disposed = false
|
||||
|
||||
const isReview = computed(() => props.mode === 'review')
|
||||
const actionPermission = computed(() => isReview.value
|
||||
? 'firstleg:inbound:measurement-review'
|
||||
: 'platform:firstleg:inbound:measure:view')
|
||||
const taskLabel = computed(() => isReview.value ? '审核' : '测量')
|
||||
const emptyText = computed(() => isReview.value ? '暂无待审核的测量数据' : '暂无当前可测量的订单')
|
||||
const preferenceKey = computed(() => isReview.value ? 'inbound-measurement-reviews' : 'inbound-measurements')
|
||||
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'customerId',
|
||||
type: 'select',
|
||||
label: '客户',
|
||||
width: 260,
|
||||
placeholder: customerLoadError.value ? '客户列表加载失败' : '全部客户',
|
||||
options: customers.value.map((customer) => ({
|
||||
value: String(customer.id),
|
||||
label: `${customer.legalName?.trim() || customer.tenantName}(${customer.tenantCode || '无客户编码'})`,
|
||||
})),
|
||||
props: { filterable: true, disabled: Boolean(customerLoadError.value) },
|
||||
},
|
||||
{ key: 'warehouseId', type: 'select', label: '定位仓库', width: 220, placeholder: '全部仓库', options: warehouseOptions.value, props: { filterable: true } },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 220, placeholder: '请输入入仓码' },
|
||||
{ key: 'orderNo', type: 'input', label: '订单号', width: 220, placeholder: '请输入订单号' },
|
||||
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到达时间', width: 320, placeholder: '请选择日期范围' },
|
||||
])
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'inboundCode', label: '入仓码', minWidth: 170, slotName: 'inboundCode' },
|
||||
{ key: 'processStatus', label: '流程状态', minWidth: 132, slotName: 'processStatus' },
|
||||
{ key: 'measureReviewStatus', label: '测量审核状态', minWidth: 142, slotName: 'measureReviewStatus' },
|
||||
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 164, slotName: 'expectedArrivalAt' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 160, slotName: 'customerName' },
|
||||
{ key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' },
|
||||
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' },
|
||||
{ key: 'businessType', label: '业务类型', minWidth: 120, slotName: 'businessType' },
|
||||
{ key: 'channelName', label: '渠道', minWidth: 140, slotName: 'channelName' },
|
||||
{ key: 'totalPackageCount', label: '总箱数', width: 92, slotName: 'totalPackageCount' },
|
||||
{ key: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 128, slotName: 'actualGrossWeightKg' },
|
||||
{ key: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 128, slotName: 'actualVolumeCbm' },
|
||||
{ key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt' },
|
||||
]
|
||||
|
||||
function text(value: unknown) {
|
||||
return value === null || value === undefined || value === '' ? '—' : String(value)
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined) {
|
||||
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function dateParam(value: string | undefined, end = false) {
|
||||
return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
customerLoadError.value = null
|
||||
try {
|
||||
const [customerPage, warehousePage] = await Promise.all([
|
||||
listPlatformTenants({ page: 1, size: 200 }),
|
||||
listPlatformWarehouses(1, 200),
|
||||
])
|
||||
if (disposed) return
|
||||
customers.value = customerPage.records
|
||||
warehouseOptions.value = warehousePage.records.map((warehouse) => ({
|
||||
label: `${warehouse.warehouseName} · ${warehouse.warehouseCode}`,
|
||||
value: String(warehouse.id),
|
||||
}))
|
||||
} catch (error) {
|
||||
if (disposed) return
|
||||
customerLoadError.value = getApiErrorDetails(error, '客户或仓库筛选项加载失败,不影响查看全部待办')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const serial = ++requestSerial
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
const applied = appliedFilters.value
|
||||
try {
|
||||
const response = await listPlatformInbounds(applied.customerId || undefined, buildInboundTaskQuery(props.mode, {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
warehouseId: applied.warehouseId || undefined,
|
||||
inboundCode: applied.inboundCode.trim() || undefined,
|
||||
orderNo: applied.orderNo.trim() || undefined,
|
||||
expectedArrivalFrom: dateParam(applied.expectedArrivalRange[0]),
|
||||
expectedArrivalTo: dateParam(applied.expectedArrivalRange[1], true),
|
||||
}))
|
||||
if (disposed || serial !== requestSerial) return
|
||||
result.value = { records: response.records, total: response.total }
|
||||
} catch (error) {
|
||||
if (disposed || serial !== requestSerial) return
|
||||
loadError.value = getApiErrorDetails(error, `${taskLabel.value}待办加载失败`)
|
||||
} finally {
|
||||
if (!disposed && serial === requestSerial) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
appliedFilters.value = { ...filters.value, expectedArrivalRange: [...filters.value.expectedArrivalRange] }
|
||||
if (page.value === 1) void loadTasks()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = { customerId: '', inboundCode: '', orderNo: '', warehouseId: '', expectedArrivalRange: [] }
|
||||
appliedFilters.value = { ...filters.value, expectedArrivalRange: [] }
|
||||
if (page.value === 1) void loadTasks()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function openTask(row: InboundOrderSummary) {
|
||||
selectedInbound.value = row
|
||||
taskDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openDetail(row: InboundOrderSummary) {
|
||||
void router.push({ name: 'inbound-order-detail', params: { inboundId: row.id }, query: { tenantId: row.tenantId } })
|
||||
}
|
||||
|
||||
function handleTaskChanged() {
|
||||
taskDialogOpen.value = false
|
||||
selectedInbound.value = null
|
||||
void loadTasks()
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => { void loadTasks() })
|
||||
onMounted(() => { void loadOptions(); void loadTasks() })
|
||||
onBeforeUnmount(() => { disposed = true; requestSerial += 1 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="inbound-task-page">
|
||||
<ListPageLayout :aria-label="`${taskLabel}待办`">
|
||||
<template #toolbar>
|
||||
<QueryToolbar
|
||||
v-model="filters"
|
||||
class="surface-panel"
|
||||
:ariaLabel="`${taskLabel}待办筛选`"
|
||||
:fields="queryFields"
|
||||
:preference-key="`${preferenceKey}-query`"
|
||||
:search-on-reset="false"
|
||||
@search="applyFilters"
|
||||
@reset="resetFilters"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="customerLoadError || loadError" #state>
|
||||
<div class="state-stack">
|
||||
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon>
|
||||
<template #title><span>{{ customerLoadError.message }}</span></template>
|
||||
</el-alert>
|
||||
<LoadErrorState v-if="loadError" :title="`${taskLabel}待办暂时无法加载`" :message="loadError.message" @retry="loadTasks">
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
|
||||
<DataTable
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:data="result.records"
|
||||
:columns="columns"
|
||||
:preference-key="`${preferenceKey}-table`"
|
||||
:loading="loading"
|
||||
:total="result.total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
row-key="id"
|
||||
:empty-text="emptyText"
|
||||
:aria-label="`${taskLabel}待办列表`"
|
||||
>
|
||||
<template #pagination-left>当前显示 {{ result.records.length }} 条 · 共 {{ result.total }} 条</template>
|
||||
<template #actions="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button v-if="access.can(actionPermission)" size="small" type="primary" @click="openTask(row as InboundOrderSummary)">
|
||||
<SearchCheck v-if="isReview" :size="15" />
|
||||
<Ruler v-else :size="15" />
|
||||
{{ taskLabel }}
|
||||
</el-button>
|
||||
<el-button v-if="access.can('platform:firstleg:inbound:view')" size="small" plain @click="openDetail(row as InboundOrderSummary)">
|
||||
<Eye :size="15" />查看
|
||||
</el-button>
|
||||
<span v-if="!access.can(actionPermission) && !access.can('platform:firstleg:inbound:view')" class="muted-action">无操作权限</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #inboundCode="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
|
||||
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
|
||||
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
|
||||
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
|
||||
<template #customerName="{ row }">{{ text((row as InboundOrderSummary).customerName) }}</template>
|
||||
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
|
||||
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
|
||||
<template #businessType="{ row }">{{ text((row as InboundOrderSummary).businessType) }}</template>
|
||||
<template #channelName="{ row }">{{ text((row as InboundOrderSummary).channelName || (row as InboundOrderSummary).channelCode) }}</template>
|
||||
<template #totalPackageCount="{ row }">{{ text((row as InboundOrderSummary).totalPackageCount) }}</template>
|
||||
<template #actualGrossWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualGrossWeightKg) }}</template>
|
||||
<template #actualVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).actualVolumeCbm) }}</template>
|
||||
<template #measureCompletedAt="{ row }">{{ formatDate((row as InboundOrderSummary).measureCompletedAt) }}</template>
|
||||
<template #empty><el-empty :description="emptyText" /></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<el-dialog
|
||||
v-model="taskDialogOpen"
|
||||
:title="selectedInbound ? `${taskLabel} · ${selectedInbound.inboundCode || selectedInbound.id}` : taskLabel"
|
||||
width="min(1500px, calc(100vw - 32px))"
|
||||
top="4vh"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
class="inbound-task-dialog"
|
||||
>
|
||||
<MeasurementReviewPanel
|
||||
v-if="isReview && selectedInbound"
|
||||
:customer-id="selectedInbound.customerId || selectedInbound.tenantId"
|
||||
:inbound-id="selectedInbound.id"
|
||||
@changed="handleTaskChanged"
|
||||
/>
|
||||
<InboundMeasurementPanel
|
||||
v-else-if="selectedInbound"
|
||||
:customer-id="selectedInbound.customerId || selectedInbound.tenantId"
|
||||
:inbound-id="selectedInbound.id"
|
||||
@changed="handleTaskChanged"
|
||||
/>
|
||||
</el-dialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-task-page { min-width: 0; }
|
||||
.state-stack { display: grid; gap: 12px; }
|
||||
.row-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
.row-actions .el-button { margin: 0; }
|
||||
.primary-cell { display: grid; gap: 3px; min-width: 0; }
|
||||
.primary-cell strong, .primary-cell span { overflow-wrap: anywhere; }
|
||||
.primary-cell span, .muted-action { color: var(--cc-muted); font-size: 12px; }
|
||||
@media (max-width: 520px) {
|
||||
.row-actions { align-items: stretch; flex-direction: column; }
|
||||
.row-actions :deep(.el-button) { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.inbound-task-dialog { max-height: 92vh; overflow: hidden; }
|
||||
.inbound-task-dialog .el-dialog__body { max-height: calc(92vh - 62px); overflow: auto; padding: 20px 24px 24px; }
|
||||
@media (max-width: 520px) {
|
||||
.inbound-task-dialog .el-dialog__body { padding: 14px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import InboundTaskList from '../components/InboundTaskList.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InboundTaskList mode="measurement" />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import InboundTaskList from '../components/InboundTaskList.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InboundTaskList mode="review" />
|
||||
</template>
|
||||
@@ -1,6 +1,26 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const inboundRoutes = [
|
||||
{
|
||||
path: 'inbounds/measurements',
|
||||
name: 'inbound-measurements',
|
||||
component: () => import('./pages/InboundMeasurementsView.vue'),
|
||||
meta: {
|
||||
title: '订单测量',
|
||||
description: '只展示当前可录入或修正测量数据的国内仓订单',
|
||||
permission: 'platform:firstleg:inbound:measure:view',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'inbounds/measurement-reviews',
|
||||
name: 'inbound-measurement-reviews',
|
||||
component: () => import('./pages/MeasurementReviewsView.vue'),
|
||||
meta: {
|
||||
title: '测量数据审核',
|
||||
description: '处理待审核且当前可审核的国内仓测量数据',
|
||||
permission: 'firstleg:inbound:measurement-review',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'inbounds',
|
||||
name: 'inbound-orders',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildInboundTaskQuery } from './task-list'
|
||||
|
||||
describe('inbound task list query', () => {
|
||||
it('limits the measurement page to currently measurable inbound orders', () => {
|
||||
expect(buildInboundTaskQuery('measurement', { page: 1, size: 20, inboundCode: 'RK-1' })).toEqual({
|
||||
page: 1,
|
||||
size: 20,
|
||||
inboundCode: 'RK-1',
|
||||
tab: 'WAIT_MEASURE',
|
||||
})
|
||||
})
|
||||
|
||||
it('limits the review page to pending and reviewable measurement data', () => {
|
||||
expect(buildInboundTaskQuery('review', { page: 2, size: 50, warehouseId: '20001' })).toEqual({
|
||||
page: 2,
|
||||
size: 50,
|
||||
warehouseId: '20001',
|
||||
tab: 'WAIT_REVIEW',
|
||||
measureReviewStatus: 'PENDING',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { InboundListQuery } from './core/types'
|
||||
|
||||
export type InboundTaskMode = 'measurement' | 'review'
|
||||
|
||||
export function buildInboundTaskQuery(
|
||||
mode: InboundTaskMode,
|
||||
query: Omit<InboundListQuery, 'tab' | 'processStatus' | 'measureReviewStatus'>,
|
||||
): InboundListQuery {
|
||||
if (mode === 'review') {
|
||||
return {
|
||||
...query,
|
||||
tab: 'WAIT_REVIEW',
|
||||
measureReviewStatus: 'PENDING',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
tab: 'WAIT_MEASURE',
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
getConfirmedLoadingList,
|
||||
uploadLoadingDeclaration,
|
||||
completeLoadingDeclaration,
|
||||
getPreloadPlan,
|
||||
listConfirmedLoadingLists,
|
||||
listPreloadPlans,
|
||||
@@ -89,4 +91,18 @@ describe('platform loading lists API', () => {
|
||||
expect(page.records[0]?.containerBookingId).toBe('1900000000000000004')
|
||||
expect(loaded.summary.status).toBe('COMPLETED')
|
||||
})
|
||||
|
||||
it('uses stable plan and warehouse ids for customs document actions', async () => {
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { id: 6, documentType: 'CUSTOMS_DECLARATION', fileId: 9, fileName: 'declaration.pdf', version: 2 } })
|
||||
const file = new File(['declaration'], 'declaration.pdf', { type: 'application/pdf' })
|
||||
await uploadLoadingDeclaration('100', '300', file, 1, '上传资料')
|
||||
await completeLoadingDeclaration('100', '300', 2, '完成报关')
|
||||
expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100/customs/document/upload')
|
||||
const body = post.mock.calls[0]?.[1] as FormData
|
||||
expect(body.get('file')).toBe(file)
|
||||
expect(body.get('expectedVersion')).toBe('1')
|
||||
expect(body.get('operationReason')).toBe('上传资料')
|
||||
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { warehouseId: '300' }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
|
||||
expect(post.mock.calls[1]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100/customs/complete')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,10 +95,26 @@ export interface LoadingHistory {
|
||||
occurredAt: string
|
||||
}
|
||||
|
||||
export interface BusinessDocument {
|
||||
id: string | null
|
||||
documentType: string
|
||||
fileId: string
|
||||
fileName: string
|
||||
fileHash: string | null
|
||||
uploadedBy: string | null
|
||||
uploadedByName: string | null
|
||||
uploadedAt: string | null
|
||||
completedBy: string | null
|
||||
completedByName: string | null
|
||||
completedAt: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface LoadingDetail<T extends LoadingListSummary = LoadingListSummary> {
|
||||
summary: T
|
||||
items: LoadingItem[]
|
||||
histories: LoadingHistory[]
|
||||
customsDocument?: BusinessDocument | null
|
||||
}
|
||||
|
||||
export interface LoadingListPage<T extends LoadingListSummary = LoadingListSummary> {
|
||||
@@ -189,6 +205,10 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory {
|
||||
operatorId: requiredId(raw.operatorId),
|
||||
}
|
||||
}
|
||||
function normalizeDocument(raw: BusinessDocument | null | undefined): BusinessDocument | null {
|
||||
if (!raw) return null
|
||||
return { ...raw, id: raw.id == null ? null : requiredId(raw.id), fileId: requiredId(raw.fileId), uploadedBy: optionalId(raw.uploadedBy), completedBy: optionalId(raw.completedBy), version: Number(raw.version ?? 0) }
|
||||
}
|
||||
|
||||
function normalizePage<T extends LoadingListSummary>(raw: RawPage<T>, normalize: (row: T) => T): LoadingListPage<T> {
|
||||
return {
|
||||
@@ -222,6 +242,7 @@ export async function getPreloadPlan(planId: string, warehouseId: string) {
|
||||
summary: normalizePreloadSummary(result.summary),
|
||||
items: (result.items || []).map(normalizeItem),
|
||||
histories: (result.histories || []).map(normalizeHistory),
|
||||
customsDocument: normalizeDocument(result.customsDocument),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,9 +257,29 @@ export async function updatePreloadTransport(planId: string, warehouseId: string
|
||||
summary: normalizePreloadSummary(result.summary),
|
||||
items: (result.items || []).map(normalizeItem),
|
||||
histories: (result.histories || []).map(normalizeHistory),
|
||||
customsDocument: normalizeDocument(result.customsDocument),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLoadingDeclaration(planId: string, warehouseId: string) {
|
||||
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs`, { params: { warehouseId } })
|
||||
return normalizeDocument(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function uploadLoadingDeclaration(planId: string, warehouseId: string, file: File, expectedVersion: number, operationReason?: string | null) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
body.append('expectedVersion', String(expectedVersion))
|
||||
if (operationReason) body.append('operationReason', operationReason)
|
||||
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/document/upload`, body, { params: { warehouseId }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': `loading-customs-upload-${planId}-${Date.now()}` } })
|
||||
return normalizeDocument(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function completeLoadingDeclaration(planId: string, warehouseId: string, expectedVersion: number, operationReason?: string | null) {
|
||||
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/complete`, { expectedVersion, operationReason }, { params: { warehouseId }, headers: { 'Idempotency-Key': `loading-customs-complete-${planId}-${Date.now()}` } })
|
||||
return normalizeDocument(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function listConfirmedLoadingLists(query: LoadingListQuery) {
|
||||
const response = await http.get<ApiEnvelope<RawPage<ConfirmedSummary>>, ApiEnvelope<RawPage<ConfirmedSummary>>>(
|
||||
`${basePath}/loading-lists`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Pencil } from '@lucide/vue'
|
||||
import { Check, FileUp, Pencil } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -17,9 +17,11 @@ const props = defineProps<{
|
||||
detail: LoadingDetail | null
|
||||
error: ApiErrorDetails | null
|
||||
canEdit: boolean
|
||||
canManageCustoms?: boolean
|
||||
customsSaving?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ retry: []; edit: [] }>()
|
||||
const emit = defineEmits<{ retry: []; edit: []; attachCustoms: []; completeCustoms: [] }>()
|
||||
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false },
|
||||
@@ -37,6 +39,7 @@ const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'fbaTrackingNo', prop: 'fbaTrackingNo', label: 'FBA 追踪编码', minWidth: 155 },
|
||||
{ key: 'shipmentNo', prop: 'shipmentNo', label: '货件号', minWidth: 140 },
|
||||
{ key: 'shippingMark', prop: 'shippingMark', label: '唛头', minWidth: 130 },
|
||||
{ key: 'customsDocument', label: '报关资料', minWidth: 170, slotName: 'customsDocument' },
|
||||
{ key: 'frozenAt', label: '冻结时间', minWidth: 150, slotName: 'frozenAt' },
|
||||
]
|
||||
|
||||
@@ -99,7 +102,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<div v-if="mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)" class="detail-actions">
|
||||
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />编辑运输信息</el-button>
|
||||
</div>
|
||||
<el-alert v-else-if="mode === 'confirmed'" type="info" :closable="false" show-icon title="装柜完成后清单、明细和运输信息永久只读" />
|
||||
<div v-else-if="mode === 'confirmed'" class="detail-actions"><el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button><el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" type="primary" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button><StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" /></div>
|
||||
|
||||
<section class="detail-section surface-panel">
|
||||
<header class="detail-section__header"><h2>装柜货物明细</h2><span>{{ detail.items.length }} 条</span></header>
|
||||
@@ -117,6 +120,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
|
||||
<template #volume="{ row }">{{ formatNumber(row.volumeCbm) }}</template>
|
||||
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
|
||||
<template #customsDocument><span class="document-cell">{{ detail.customsDocument?.fileName || '未上传' }}</span></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ describe('LoadingListsView', () => {
|
||||
|
||||
it('only exposes transport editing for LOADING preload details', () => {
|
||||
expect(detailSource).toContain("mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)")
|
||||
expect(detailSource).toContain('装柜完成后清单、明细和运输信息永久只读')
|
||||
expect(detailSource).toContain('报关资料')
|
||||
expect(detailSource).toContain('完成报关')
|
||||
expect(source).toContain('expectedVersion: detail.value.summary.version')
|
||||
})
|
||||
|
||||
@@ -116,4 +117,14 @@ describe('LoadingListsView', () => {
|
||||
expect(detailSource).not.toContain('item.customerName || item.accountId')
|
||||
expect(detailSource).not.toContain('history.operatorName || history.operatorId')
|
||||
})
|
||||
|
||||
it('uploads declaration documents from a file picker and freezes them after completion', () => {
|
||||
expect(source).toContain('<el-upload :auto-upload="false" :limit="1"')
|
||||
expect(source).toContain('@confirm="uploadCustomsDocument"')
|
||||
expect(source).toContain('uploadLoadingDeclaration(selectedPlanId.value, selectedWarehouseId.value, customsFile.value')
|
||||
expect(detailSource).toContain("canManageCustoms && !detail.customsDocument?.completedAt")
|
||||
expect(source).not.toContain('文件 ID')
|
||||
expect(source).not.toContain('fileId')
|
||||
expect(source).not.toContain('上传入口待')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, Pencil } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
|
||||
import { Eye, FileUp, Pencil } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -16,6 +16,8 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { loadingListStatusLabel } from '../status'
|
||||
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
|
||||
import {
|
||||
uploadLoadingDeclaration,
|
||||
completeLoadingDeclaration,
|
||||
getConfirmedLoadingList,
|
||||
getPreloadPlan,
|
||||
listConfirmedLoadingLists,
|
||||
@@ -51,14 +53,18 @@ const editError = ref<ApiErrorDetails | null>(null)
|
||||
const editFormRef = ref<FormInstance>()
|
||||
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
|
||||
const handledDeepLink = ref('')
|
||||
const customsDialogOpen = ref(false)
|
||||
const customsFile = ref<File | null>(null)
|
||||
const customsSaving = ref(false)
|
||||
const customsError = ref<ApiErrorDetails | null>(null)
|
||||
|
||||
const isPreload = computed(() => props.mode === 'preload')
|
||||
const title = computed(() => isPreload.value ? '预装清单' : '装柜清单')
|
||||
const listPermission = computed(() => isPreload.value ? 'platform:firstleg:preload:list' : 'platform:firstleg:loading-list:list')
|
||||
const viewPermission = computed(() => isPreload.value ? 'platform:firstleg:preload:view' : 'platform:firstleg:loading-list:view')
|
||||
const canList = computed(() => access.can(listPermission.value))
|
||||
const canView = computed(() => access.can(viewPermission.value))
|
||||
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: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/集装箱号/柜号' : '装柜清单号/集装箱号/柜号', width: 250 },
|
||||
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 260 },
|
||||
@@ -264,6 +270,19 @@ async function saveTransport() {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
function openCustomsDocument() { customsFile.value = null; customsError.value = null; customsDialogOpen.value = true }
|
||||
function selectCustomsFile(uploadFile: UploadFile) { customsFile.value = uploadFile.raw ?? null; customsError.value = null }
|
||||
function removeCustomsFile() { customsFile.value = null }
|
||||
async function uploadCustomsDocument() {
|
||||
if (!detail.value || !customsFile.value) { customsError.value = { code: 'FILE_REQUIRED', message: '请选择报关资料文件' }; return }
|
||||
customsSaving.value = true; customsError.value = null
|
||||
try { detail.value.customsDocument = await uploadLoadingDeclaration(selectedPlanId.value, selectedWarehouseId.value, customsFile.value, detail.value.customsDocument?.version || 0, '上传装柜清单报关资料'); customsFile.value = null; customsDialogOpen.value = false; ElMessage.success('报关资料已上传') } catch (cause) { customsError.value = getApiErrorDetails(cause, '报关资料上传失败') } finally { customsSaving.value = false }
|
||||
}
|
||||
async function completeCustomsDocument() {
|
||||
if (!detail.value?.customsDocument) return
|
||||
customsSaving.value = true; customsError.value = null
|
||||
try { detail.value.customsDocument = await completeLoadingDeclaration(selectedPlanId.value, selectedWarehouseId.value, detail.value.customsDocument.version, '确认装柜清单报关完成'); ElMessage.success('已完成报关') } catch (cause) { customsError.value = getApiErrorDetails(cause, '报关完成失败') } finally { customsSaving.value = false }
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => void load())
|
||||
watch(() => [route.query.planId, route.query.warehouseId], () => void openDeepLinkedDetail())
|
||||
@@ -332,10 +351,16 @@ onMounted(() => {
|
||||
:detail="detail"
|
||||
:error="detailError"
|
||||
:can-edit="canEdit"
|
||||
:can-manage-customs="canManageCustoms"
|
||||
:customs-saving="customsSaving"
|
||||
@retry="loadDetail"
|
||||
@edit="openEdit"
|
||||
@attach-customs="openCustomsDocument"
|
||||
@complete-customs="completeCustomsDocument"
|
||||
/>
|
||||
|
||||
<AppDialog v-model="customsDialogOpen" title="装柜清单报关资料" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="customsSaving" :confirm-disabled="!customsFile" @confirm="uploadCustomsDocument"><el-form label-position="top"><el-form-item label="报关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectCustomsFile" :on-remove="removeCustomsFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ detail?.customsDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的装柜清单报关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="customsError" compact title="操作失败" :message="customsError.message" :show-retry="false"><template #details><ApiErrorMeta :details="customsError" /></template></LoadErrorState></AppDialog>
|
||||
|
||||
<AppDialog
|
||||
v-model="editOpen"
|
||||
class="loading-transport-dialog"
|
||||
|
||||
@@ -5,7 +5,7 @@ describe('loading list routes', () => {
|
||||
it('declares preload list/detail and completed list entries with backend permissions', () => {
|
||||
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
|
||||
['loading-plans', 'platform:firstleg:preload:list'],
|
||||
['loading-plans/:planId', 'platform:firstleg:preload:view'],
|
||||
['loading-plans/:planId', 'platform:firstleg:preload:list'],
|
||||
['loading-lists', 'platform:firstleg:loading-list:list'],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ export const loadingListRoutes = [
|
||||
meta: {
|
||||
title: '预装清单详情',
|
||||
description: '查看预装清单基本信息、装柜货物明细和操作历史',
|
||||
permission: 'platform:firstleg:preload:view',
|
||||
permission: 'platform:firstleg:preload:list',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'; import { CheckCheck, Eye } from '@lucide/vue'; import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, ref, watch } from 'vue'; import { CheckCheck, Eye } from '@lucide/vue'; import { ElMessage } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import AppDialog from '@/shared/components/AppDialog.vue'; import DataTable,{type DataTableColumn} from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import { getInboxDetail,listInbox,markAllInboxRead,markInboxRead } from '../api/notificationCenter'; import type { InboxDetail,InboxItem,InboxStatus } from '../types'; import { getApiErrorDetails,getApiErrorMessage,type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
const access=useAccessPreviewStore(),canMarkRead=computed(()=>access.can('platform:notification:read')),canMarkAllRead=computed(()=>access.can('platform:notification:read-all'))
|
||||
const page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),detailOpen=ref(false),detailLoading=ref(false),detail=ref<InboxDetail|null>(null),result=ref({records:[] as InboxItem[],total:0,pages:0}),filters=ref<QueryModel>({status:'',category:''})
|
||||
const queryFields:QueryField[]=[{key:'status',type:'select',label:'消息状态',placeholder:'全部状态',options:[{label:'未读',value:'UNREAD'},{label:'已读',value:'READ'}]},{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'}]
|
||||
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:210,fixed:'left',slotName:'actions'},{key:'title',prop:'title',label:'消息标题',minWidth:240},{key:'category',prop:'category',label:'业务分类',width:140},{key:'sentAt',label:'发送时间',width:170,slotName:'sentAt'},{key:'status',label:'状态',width:100,slotName:'status'}]
|
||||
function formatDate(v:string|null){return v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'—'}
|
||||
async function load(){loading.value=true;error.value=null;try{const d=await listInbox({page:page.value,size:pageSize.value,status:(filters.value.status||undefined) as InboxStatus|undefined,category:String(filters.value.category||'').trim()||undefined});result.value={records:d.records,total:d.total,pages:d.pages}}catch(e){error.value=getApiErrorDetails(e,'我的消息加载失败')}finally{loading.value=false}}
|
||||
function search(){page.value===1?void load():page.value=1}
|
||||
async function openDetail(row:InboxItem){detailOpen.value=true;detailLoading.value=true;detail.value=null;try{detail.value=await getInboxDetail(row.id);if(detail.value.status==='UNREAD'){detail.value=await markInboxRead(row.id);await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}}catch(e){ElMessage.error(getApiErrorMessage(e,'消息详情加载失败'))}finally{detailLoading.value=false}}
|
||||
async function openDetail(row:InboxItem){detailOpen.value=true;detailLoading.value=true;detail.value=null;try{detail.value=await getInboxDetail(row.id);if(detail.value.status==='UNREAD'&&canMarkRead.value){detail.value=await markInboxRead(row.id);await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}}catch(e){ElMessage.error(getApiErrorMessage(e,'消息详情加载失败'))}finally{detailLoading.value=false}}
|
||||
async function markRead(row:InboxItem){actionId.value=row.id;try{await markInboxRead(row.id);ElMessage.success('已标为已读');await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}catch(e){ElMessage.error(getApiErrorMessage(e,'标记已读失败'))}finally{actionId.value=''}}
|
||||
async function markAll(){try{const count=await markAllInboxRead();ElMessage.success(count?`已将 ${count} 条消息标为已读`:'没有需要更新的未读消息');await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}catch(e){ElMessage.error(getApiErrorMessage(e,'全部标为已读失败'))}}
|
||||
watch([page,pageSize],load);onMounted(load)
|
||||
</script>
|
||||
<template><AdminPage><ListPageLayout aria-label="我的消息列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-mine-query" @search="search"/></template><template v-if="error" #state><LoadErrorState title="我的消息加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error"/></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-mine-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息"><template #toolbar-actions><el-button @click="markAll"><CheckCheck :size="16"/>全部标为已读</el-button></template><template #pagination-left><span>共 {{result.total}} 条消息</span></template><template #actions="{row}"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as InboxItem)"><Eye :size="15"/>查看</el-button><el-button v-if="(row as InboxItem).status==='UNREAD'" link type="primary" :loading="actionId===(row as InboxItem).id" @click="markRead(row as InboxItem)"><CheckCheck :size="15"/>标为已读</el-button></div></template><template #sentAt="{row}">{{formatDate((row as InboxItem).sentAt)}}</template><template #status="{row}"><el-tag :type="(row as InboxItem).status==='UNREAD'?'danger':'info'">{{(row as InboxItem).status==='UNREAD'?'未读':'已读'}}</el-tag></template></DataTable></section></ListPageLayout><AppDialog v-model="detailOpen" title="消息详情" width="min(680px, calc(100vw - 28px))" :loading="detailLoading" :show-confirm="false"><article v-if="detail" class="message-detail"><header><div><h3>{{detail.title}}</h3><p>{{detail.category}} · {{formatDate(detail.sentAt)}}</p></div><el-tag :type="detail.status==='UNREAD'?'danger':'info'">{{detail.status==='UNREAD'?'未读':'已读'}}</el-tag></header><div class="message-body">{{detail.body}}</div><dl v-if="detail.sourceType||detail.sourceId"><div><dt>来源类型</dt><dd>{{detail.sourceType||'—'}}</dd></div><div><dt>来源标识</dt><dd>{{detail.sourceId||'—'}}</dd></div></dl></article></AppDialog></AdminPage></template>
|
||||
<template><AdminPage><ListPageLayout aria-label="我的消息列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-mine-query" @search="search"/></template><template v-if="error" #state><LoadErrorState title="我的消息加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error"/></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-mine-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息"><template #toolbar-actions><el-button v-if="canMarkAllRead" @click="markAll"><CheckCheck :size="16"/>全部标为已读</el-button></template><template #pagination-left><span>共 {{result.total}} 条消息</span></template><template #actions="{row}"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as InboxItem)"><Eye :size="15"/>查看</el-button><el-button v-if="canMarkRead&&(row as InboxItem).status==='UNREAD'" link type="primary" :loading="actionId===(row as InboxItem).id" @click="markRead(row as InboxItem)"><CheckCheck :size="15"/>标为已读</el-button></div></template><template #sentAt="{row}">{{formatDate((row as InboxItem).sentAt)}}</template><template #status="{row}"><el-tag :type="(row as InboxItem).status==='UNREAD'?'danger':'info'">{{(row as InboxItem).status==='UNREAD'?'未读':'已读'}}</el-tag></template></DataTable></section></ListPageLayout><AppDialog v-model="detailOpen" title="消息详情" width="min(680px, calc(100vw - 28px))" :loading="detailLoading" :show-confirm="false"><article v-if="detail" class="message-detail"><header><div><h3>{{detail.title}}</h3><p>{{detail.category}} · {{formatDate(detail.sentAt)}}</p></div><el-tag :type="detail.status==='UNREAD'?'danger':'info'">{{detail.status==='UNREAD'?'未读':'已读'}}</el-tag></header><div class="message-body">{{detail.body}}</div><dl v-if="detail.sourceType||detail.sourceId"><div><dt>来源类型</dt><dd>{{detail.sourceType||'—'}}</dd></div><div><dt>来源标识</dt><dd>{{detail.sourceId||'—'}}</dd></div></dl></article></AppDialog></AdminPage></template>
|
||||
<style scoped>.table-actions{display:flex;min-height:32px;align-items:center;gap:8px}.table-actions :deep(.el-button+.el-button){margin-left:0}.message-detail header{display:flex;justify-content:space-between;gap:16px;padding-bottom:16px;border-bottom:1px solid var(--cc-border)}.message-detail h3{margin:0;font-size:17px}.message-detail p{margin:6px 0 0;color:var(--cc-muted);font-size:12px}.message-body{padding:20px 0;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.8}.message-detail dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--cc-border)}.message-detail dl>div{padding:12px}.message-detail dt{color:var(--cc-muted);font-size:12px}.message-detail dd{margin:5px 0 0;overflow-wrap:anywhere}@media(max-width:480px){.message-detail header{flex-direction:column}.message-detail dl{grid-template-columns:1fr}}</style>
|
||||
|
||||
@@ -56,7 +56,7 @@ watch([page, pageSize], () => void load()); onMounted(load)
|
||||
<section v-if="!error" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
|
||||
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:ocean-booking:create')" type="primary" @click="open()"><Plus :size="16" />新增订舱</el-button></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:ocean-booking:view')" size="small" type="primary" plain @click="open(row.id)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:ocean-booking:edit')" size="small" @click="open(row.id)"><Pencil :size="15" />编辑</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:ocean-booking:edit')" size="small" @click="open(row.id)"><Pencil :size="15" />编辑</el-button></div></template>
|
||||
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
|
||||
<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>
|
||||
<template #eta="{ row }">{{ formatDateTime(row.eta) }}</template>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const oceanBookingRoutes = [{ path: 'ocean-bookings', name: 'ocean-bookings', component: () => import('./pages/OceanBookingsView.vue'), meta: { title: '海运订舱', description: '维护海运订舱申请、船期与放舱状态', permission: 'platform:firstleg:ocean-booking:list' } }, { path: 'ocean-bookings/:bookingId', name: 'ocean-booking-detail', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '订舱详情', description: '查看和维护订舱详情', permission: 'platform:firstleg:ocean-booking:view' } }] satisfies RouteRecordRaw[]
|
||||
export const oceanBookingRoutes = [{ path: 'ocean-bookings', name: 'ocean-bookings', component: () => import('./pages/OceanBookingsView.vue'), meta: { title: '海运订舱', description: '维护海运订舱申请、船期与放舱状态', permission: 'platform:firstleg:ocean-booking:list' } }, { path: 'ocean-bookings/:bookingId', name: 'ocean-booking-detail', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '订舱详情', description: '查看和维护订舱详情', permission: 'platform:firstleg:ocean-booking:list' } }] satisfies RouteRecordRaw[]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { changeSupplierStatus, createSupplier, getSupplier, getSupplierStats, listSuppliers, updateSupplier } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import type { SaveSupplierRequest } from '@/modules/suppliers/types'
|
||||
|
||||
vi.mock('@/shared/api/http', async (original) => ({
|
||||
...await original<typeof import('@/shared/api/http')>(),
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() },
|
||||
}))
|
||||
|
||||
const payload: SaveSupplierRequest = { name: '测试供应商', category: 'BOOKING', scope: 'DOMESTIC', region: '宁波', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: '9007199254740995', status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null }
|
||||
const resource = { id: 9007199254740993, tenantId: 0, ...payload, createdBy: 9007199254740994, createdAt: '2026-08-13T10:00:00', updatedBy: null, updatedAt: '2026-08-13T10:00:00', deleted: false, version: 1 }
|
||||
|
||||
describe('platform supplier API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('uses real list and stats endpoints and preserves long identifiers as strings', async () => {
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ code: '200', data: { records: [resource], page: 1, size: 20, total: 1, pages: 1 } })
|
||||
.mockResolvedValueOnce({ code: '200', data: { booking: 1, customs: 2, trucking: 3, certificate: 4 } })
|
||||
const page = await listSuppliers({ page: 1, size: 20, category: 'BOOKING', scope: 'DOMESTIC', keyword: '测试' })
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/suppliers', { params: expect.objectContaining({ category: 'BOOKING', scope: 'DOMESTIC', keyword: '测试' }) })
|
||||
expect(page.records[0]).toMatchObject({ id: expect.any(String), tenantId: '0', licenseFileId: expect.any(String), createdBy: expect.any(String) })
|
||||
expect(await getSupplierStats()).toEqual({ booking: 1, customs: 2, trucking: 3, certificate: 4 })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/suppliers/stats')
|
||||
})
|
||||
|
||||
it('sends tenantId=0 on detail and every write', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: resource })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: '77' })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', data: null })
|
||||
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: null })
|
||||
await getSupplier('77'); await createSupplier(payload); await updateSupplier('77', payload); await changeSupplierStatus('77', 'INACTIVE')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/suppliers/77', { params: { tenantId: 0 } })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/suppliers', payload, { params: { tenantId: 0 } })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/suppliers/77', payload, { params: { tenantId: 0 } })
|
||||
expect(http.patch).toHaveBeenCalledWith('/platform/v1/suppliers/77/status', { status: 'INACTIVE' }, { params: { tenantId: 0 } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { SaveSupplierRequest, Supplier, SupplierPage, SupplierQuery, SupplierStats, SupplierStatus } from '@/modules/suppliers/types'
|
||||
|
||||
const basePath = '/platform/v1/suppliers'
|
||||
const platformScope = 0
|
||||
const id = (value: string | number | null | undefined) => value == null ? null : String(value)
|
||||
|
||||
function normalizeSupplier(raw: Supplier): Supplier {
|
||||
return {
|
||||
...raw,
|
||||
id: String(raw.id), tenantId: String(raw.tenantId),
|
||||
licenseFileId: id(raw.licenseFileId), createdBy: id(raw.createdBy), updatedBy: id(raw.updatedBy),
|
||||
version: Number(raw.version), deleted: Boolean(raw.deleted),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSuppliers(query: SupplierQuery): Promise<SupplierPage> {
|
||||
const response = await http.get<ApiEnvelope<SupplierPage>, ApiEnvelope<SupplierPage>>(basePath, {
|
||||
params: {
|
||||
page: query.page ?? 1, size: query.size ?? 20, category: query.category,
|
||||
scope: query.scope || undefined, region: query.region?.trim() || undefined,
|
||||
status: query.status || undefined, keyword: query.keyword?.trim() || undefined,
|
||||
},
|
||||
})
|
||||
const data = unwrapApiResponse(response)
|
||||
return { records: data.records.map(normalizeSupplier), page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) }
|
||||
}
|
||||
|
||||
export async function getSupplierStats(): Promise<SupplierStats> {
|
||||
const response = await http.get<ApiEnvelope<SupplierStats>, ApiEnvelope<SupplierStats>>(`${basePath}/stats`)
|
||||
const data = unwrapApiResponse(response)
|
||||
return { booking: Number(data.booking), customs: Number(data.customs), trucking: Number(data.trucking), certificate: Number(data.certificate) }
|
||||
}
|
||||
|
||||
export async function getSupplier(supplierId: string): Promise<Supplier> {
|
||||
const response = await http.get<ApiEnvelope<Supplier>, ApiEnvelope<Supplier>>(`${basePath}/${encodeURIComponent(supplierId)}`, { params: { tenantId: platformScope } })
|
||||
return normalizeSupplier(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function createSupplier(payload: SaveSupplierRequest): Promise<string> {
|
||||
const response = await http.post<ApiEnvelope<string | number>, ApiEnvelope<string | number>>(basePath, payload, { params: { tenantId: platformScope } })
|
||||
return String(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function updateSupplier(supplierId: string, payload: SaveSupplierRequest): Promise<null> {
|
||||
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${encodeURIComponent(supplierId)}`, payload, { params: { tenantId: platformScope } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function changeSupplierStatus(supplierId: string, status: SupplierStatus): Promise<null> {
|
||||
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${encodeURIComponent(supplierId)}/status`, { status }, { params: { tenantId: platformScope } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import type { Supplier } from '@/modules/suppliers/types'
|
||||
|
||||
defineProps<{ supplier: Supplier | null }>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" :title="`营业执照查看${supplier ? ` - ${supplier.name}` : ''}`" width="min(560px, calc(100vw - 24px))" :show-confirm="false" cancel-text="关闭">
|
||||
<div v-if="supplier" class="license-dialog">
|
||||
<p class="license-dialog__supplier">供应商:<strong>{{ supplier.name }}</strong></p>
|
||||
<section class="license-card" aria-label="营业执照信息">
|
||||
<h2>营业执照</h2><p class="license-card__copy">(副本)</p>
|
||||
<dl>
|
||||
<div><dt>名称:</dt><dd>{{ supplier.name }}</dd></div>
|
||||
<div><dt>法定代表人:</dt><dd>{{ supplier.legalRepresentative }}</dd></div>
|
||||
<div><dt>统一社会信用代码:</dt><dd>{{ supplier.businessLicenseNo || '—' }}</dd></div>
|
||||
<div><dt>资质文件:</dt><dd>{{ supplier.licenseFileId ? '已关联' : '未上传' }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<el-alert v-if="supplier.licenseFileId" class="license-dialog__notice" type="info" :closable="false" show-icon title="资质文件已保留;平台公共供应商文件读取接口尚未提供,暂无法展示原始影像。" />
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.license-dialog__supplier { margin: 0 0 14px; color: var(--cc-muted); font-size: 13px; }
|
||||
.license-card { max-width: 460px; margin: 0 auto; padding: 24px 28px; border: 2px solid #c0392b; border-radius: 8px; background: #fff; box-shadow: 0 4px 16px rgba(192,57,43,.12); }
|
||||
.license-card h2 { margin: 0; color: #c0392b; font-size: 22px; letter-spacing: 6px; text-align: center; }
|
||||
.license-card__copy { margin: 2px 0 18px; color: #c0392b; font-size: 12px; text-align: center; }
|
||||
.license-card dl { margin: 0; }
|
||||
.license-card dl div { display: grid; grid-template-columns: 126px minmax(0, 1fr); margin-bottom: 10px; font-size: 14px; }
|
||||
.license-card dt { color: #444; font-weight: 700; }.license-card dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
|
||||
.license-dialog__notice { margin-top: 16px; }
|
||||
@media (max-width: 480px) { .license-card { padding: 20px 16px; } .license-card dl div { grid-template-columns: 1fr; gap: 3px; } .license-card h2 { font-size: 19px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, FileText, Pencil } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
|
||||
import { getSupplier } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import { categoryLabels, scopeLabels, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types'
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
|
||||
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)
|
||||
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)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage width="comfortable"><DetailPageLayout aria-label="供应商详情">
|
||||
<template #header><div class="detail-header"><div><h2>{{ supplier?.name || '供应商详情' }}</h2><StatusBadge v-if="supplier" :status="statusLabels[supplier.status]" /></div><div class="detail-actions"><el-button @click="router.push({ name: 'supplier-list' })"><ArrowLeft :size="16" />返回</el-button><el-button v-if="supplier && access.can('platform:supplier:edit')" type="primary" @click="router.push({ name: 'supplier-edit', params: { id: supplier.id } })"><Pencil :size="16" />编辑</el-button></div></div></template>
|
||||
<LoadErrorState v-if="!canView" title="暂无供应商查看权限" message="请联系管理员开通供应商查看权限。" :show-retry="false" />
|
||||
<LoadErrorState v-else-if="error" title="供应商详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section v-else v-loading="loading" class="surface-panel detail-panel">
|
||||
<dl v-if="supplier" class="detail-grid"><div v-for="item in items" :key="item[0]"><dt>{{ item[0] }}</dt><dd>{{ item[1] }}</dd></div></dl>
|
||||
<div v-if="supplier" class="license-row"><div><strong>营业执照</strong><span>{{ supplier.licenseFileId ? '已关联资质文件' : '未上传资质文件' }}</span></div><el-button type="primary" plain @click="licenseOpen = true"><FileText :size="16" />查看营业执照</el-button></div>
|
||||
</section>
|
||||
<SupplierLicenseDialog v-model="licenseOpen" :supplier="supplier" />
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.detail-header > div { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }.detail-header h2 { margin: 0; font-size: 16px; overflow-wrap: anywhere; }.detail-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.detail-panel { padding: 22px; }.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; }.detail-grid div { min-width: 0; padding: 14px 16px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }.detail-grid div:nth-child(3n) { border-right: 0; }.detail-grid dt { margin-bottom: 6px; color: var(--cc-muted); font-size: 12px; }.detail-grid dd { margin: 0; line-height: 1.55; overflow-wrap: anywhere; }.license-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 20px; padding: 16px; border: 1px dashed var(--cc-border); border-radius: 6px; background: #fafafa; }.license-row strong,.license-row span { display: block; }.license-row span { margin-top: 4px; color: var(--cc-muted); font-size: 12px; }
|
||||
@media (max-width: 720px) { .detail-header,.license-row { align-items: stretch; flex-direction: column; }.detail-actions { display: grid !important; grid-template-columns: 1fr 1fr; }.detail-actions :deep(.el-button) { min-height: 44px; }.detail-grid { grid-template-columns: 1fr; }.detail-grid div,.detail-grid div:nth-child(3n) { border-right: 0; }.detail-panel { padding: 16px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, 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'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { createSupplier, getSupplier, updateSupplier } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import { categoryLabels, scopeLabels, settlementLabels, statusLabels, type SaveSupplierRequest, type SupplierCategory } from '@/modules/suppliers/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const supplierId = computed(() => typeof route.params.id === 'string' ? route.params.id : '')
|
||||
const editing = computed(() => Boolean(supplierId.value))
|
||||
const requiredPermission = computed(() => editing.value ? 'platform:supplier:edit' : 'platform:supplier:create')
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', scope: 'DOMESTIC', region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null })
|
||||
const form = reactive(initial())
|
||||
const rules: FormRules<SaveSupplierRequest> = {
|
||||
name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }], category: [{ required: true, message: '请选择供应商类别', trigger: 'change' }],
|
||||
region: [{ required: true, message: '请输入地区', trigger: 'blur' }], legalRepresentative: [{ required: true, message: '请输入法人代表', trigger: 'blur' }],
|
||||
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }], phone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
|
||||
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }], status: [{ required: true, message: '请选择合作状态', trigger: 'change' }],
|
||||
settlementPeriod: [{ required: true, message: '请选择结算周期', trigger: 'change' }],
|
||||
}
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'supplier-editor', label: computed(() => editing.value ? '编辑供应商' : '新增供应商'), isOpen: computed(() => true), model: form, requiredPermission })
|
||||
|
||||
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) }
|
||||
}
|
||||
async function load() {
|
||||
if (!editing.value || !access.can(requiredPermission.value)) { captureBaseline(); return }
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const data = await getSupplier(supplierId.value)
|
||||
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 }
|
||||
}
|
||||
async function save() {
|
||||
if (!access.can(requiredPermission.value)) return
|
||||
if (form.category === 'BOOKING' && !form.scope) { ElMessage.error('请选择订舱国内/国外范围'); return }
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
saving.value = true; submitError.value = null
|
||||
try {
|
||||
if (editing.value) await updateSupplier(supplierId.value, payload())
|
||||
else await createSupplier(payload())
|
||||
captureBaseline(); ElMessage.success(editing.value ? '供应商修改成功' : '供应商新增成功')
|
||||
await router.push({ name: 'supplier-list' })
|
||||
} catch (error) { submitError.value = getApiErrorDetails(error, editing.value ? '供应商修改失败' : '供应商新增失败') } finally { saving.value = false }
|
||||
}
|
||||
|
||||
watch(() => form.category, (category) => { if (category === 'BOOKING' && !form.scope) form.scope = 'DOMESTIC'; if (category !== 'BOOKING') form.scope = null })
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage width="comfortable">
|
||||
<DetailPageLayout :aria-label="editing ? '编辑供应商' : '新增供应商'">
|
||||
<template #header><div class="form-page-header"><h2>{{ editing ? '编辑供应商' : '新增供应商' }}</h2><el-button @click="router.push({ name: 'supplier-list' })"><ArrowLeft :size="16" />返回</el-button></div></template>
|
||||
<LoadErrorState v-if="!access.can(requiredPermission)" title="暂无操作权限" :message="`请联系管理员开通${editing ? '供应商编辑' : '供应商新增'}权限。`" :show-retry="false" />
|
||||
<LoadErrorState v-else-if="loadError" title="供应商加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<section v-else v-loading="loading" class="surface-panel supplier-form-panel">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
|
||||
<FormSection title="基础信息">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="供应商名称" prop="name"><el-input v-model="form.name" maxlength="160" placeholder="请输入供应商名称" /></el-form-item>
|
||||
<el-form-item label="供应商类别" prop="category"><el-select v-model="form.category"><el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="地区" prop="region"><el-input v-model="form.region" maxlength="80" placeholder="请输入地区" /></el-form-item>
|
||||
<el-form-item v-if="form.category === 'BOOKING'" label="国内/国外" prop="scope"><el-select v-model="form.scope"><el-option v-for="(label, value) in scopeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="法人代表" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" maxlength="80" placeholder="请输入法人代表" /></el-form-item>
|
||||
<el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="80" placeholder="请输入联系人" /></el-form-item>
|
||||
<el-form-item label="联系电话" prop="phone"><el-input v-model="form.phone" maxlength="40" placeholder="请输入联系电话" /></el-form-item>
|
||||
<el-form-item label="电子邮箱" prop="email"><el-input v-model="form.email" maxlength="160" placeholder="请输入邮箱" /></el-form-item>
|
||||
<el-form-item label="合作状态" prop="status"><el-select v-model="form.status"><el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="结算周期" prop="settlementPeriod"><el-select v-model="form.settlementPeriod"><el-option v-for="(label, value) in settlementLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item class="span-2" label="公司地址" prop="address"><el-input v-model="form.address" maxlength="255" placeholder="请输入公司地址" /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection title="营业执照">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="营业执照号" prop="businessLicenseNo"><el-input v-model="form.businessLicenseNo" maxlength="64" placeholder="统一社会信用代码" /></el-form-item>
|
||||
<el-form-item label="资质文件"><div class="file-placeholder"><FileText :size="24" /><span>{{ form.licenseFileId ? '已保留现有营业执照文件' : '暂无营业执照文件' }}</span><small>平台公共供应商资质上传接口尚未提供,当前不能新增或替换文件。</small></div></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection title="其他信息"><el-form-item label="备注" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></FormSection>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="submitError" compact title="保存失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState>
|
||||
<footer class="form-actions"><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="router.push({ name: 'supplier-list' })">取消</el-button></footer>
|
||||
</section>
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.form-page-header h2 { margin: 0; font-size: 16px; }
|
||||
.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 (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,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/suppliers/pages/SuppliersView.vue'), 'utf8')
|
||||
const formSource = readFileSync(resolve(process.cwd(), 'src/modules/suppliers/pages/SupplierFormView.vue'), 'utf8')
|
||||
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/suppliers/pages/SupplierDetailView.vue'), 'utf8')
|
||||
const routesSource = readFileSync(resolve(process.cwd(), 'src/modules/suppliers/routes.ts'), 'utf8')
|
||||
|
||||
describe('supplier formal pages', () => {
|
||||
it('keeps actions first and fixed left while guarding all operations with backend permissions', () => {
|
||||
expect(source).toContain("{ key: 'actions', label: '操作', width: 260, fixed: 'left'")
|
||||
expect(source).toContain("access.can('platform:supplier:create')")
|
||||
expect(source).toContain("access.can('platform:supplier:edit')")
|
||||
expect(source).toContain("access.can('platform:supplier:status')")
|
||||
expect(source).toContain("access.can('platform:supplier:list')")
|
||||
})
|
||||
|
||||
it('uses independent routes for list, detail, create and edit', () => {
|
||||
expect(routesSource).toContain("path: 'suppliers/new'")
|
||||
expect(routesSource).toContain("path: 'suppliers/:id'")
|
||||
expect(routesSource).toContain("path: 'suppliers/:id/edit'")
|
||||
expect(routesSource).toContain("permission: 'platform:supplier:create'")
|
||||
expect(routesSource).toContain("permission: 'platform:supplier:edit'")
|
||||
expect(routesSource).toContain("permission: 'platform:supplier:list'")
|
||||
expect(routesSource).not.toContain('platform:supplier:view')
|
||||
expect(detailSource).toContain('供应商详情')
|
||||
})
|
||||
|
||||
it('does not simulate supplier data or license upload and retains API errors', () => {
|
||||
expect(source).not.toContain('supplierData')
|
||||
expect(formSource).not.toContain('type="file"')
|
||||
expect(formSource).toContain('平台公共供应商资质上传接口尚未提供')
|
||||
expect(formSource).toContain('<ApiErrorMeta')
|
||||
expect(detailSource).toContain('<ApiErrorMeta')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Eye, FileText, Pencil, Plus, Power } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
|
||||
import { changeSupplierStatus, getSupplierStats, listSuppliers } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import { categoryLabels, scopeLabels, settlementLabels, statusLabels, type Supplier, type SupplierCategory, type SupplierScope, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const canView = computed(() => access.can('platform:supplier:list'))
|
||||
const loading = ref(false)
|
||||
const rows = ref<Supplier[]>([])
|
||||
const stats = ref<SupplierStats>({ booking: 0, customs: 0, trucking: 0, certificate: 0 })
|
||||
const activeCategory = ref<SupplierCategory>('BOOKING')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const actionId = ref('')
|
||||
const licenseOpen = ref(false)
|
||||
const licenseSupplier = ref<Supplier | null>(null)
|
||||
type Filters = QueryModel & { status: SupplierStatus | ''; scope: SupplierScope | ''; region: string; keyword: string }
|
||||
const filters = ref<Filters>({ status: '', scope: '', region: '', keyword: '' })
|
||||
const applied = ref<Filters>({ ...filters.value })
|
||||
let sequence = 0
|
||||
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 150, options: [{ label: '合作中', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }] },
|
||||
...(activeCategory.value === 'BOOKING' ? [{ key: 'scope', type: 'select' as const, label: '订舱范围', placeholder: '全部国内/国外', width: 170, options: [{ label: '国内', value: 'DOMESTIC' }, { label: '国外', value: 'INTERNATIONAL' }] }] : []),
|
||||
{ key: 'region', type: 'input', label: '地区', placeholder: '请输入地区', width: 170, props: { maxlength: 80 } },
|
||||
{ key: 'keyword', type: 'input', label: '名称或联系人', placeholder: '供应商名称/联系人', width: 220, props: { maxlength: 160 } },
|
||||
])
|
||||
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'name', label: '供应商名称', minWidth: 230, slotName: 'name' },
|
||||
{ key: 'category', label: '类别', width: 90, slotName: 'category' },
|
||||
...(activeCategory.value === 'BOOKING' ? [{ key: 'scope', label: '国内/国外', width: 110, slotName: 'scope' }] : []),
|
||||
{ key: 'region', label: '地区', minWidth: 110 },
|
||||
{ key: 'legalRepresentative', label: '法人', minWidth: 120 },
|
||||
{ key: 'contactName', label: '联系人', minWidth: 110 },
|
||||
{ key: 'phone', label: '联系电话', minWidth: 150 },
|
||||
{ key: 'businessLicenseNo', label: '营业执照号', minWidth: 190 },
|
||||
{ key: 'license', label: '营业执照', width: 125, slotName: 'license' },
|
||||
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
|
||||
{ key: 'settlementPeriod', label: '结算周期', width: 120, slotName: 'settlement' },
|
||||
])
|
||||
const metricItems = computed(() => [
|
||||
{ category: 'BOOKING' as const, label: '订舱供应商', value: stats.value.booking },
|
||||
{ category: 'CUSTOMS' as const, label: '报关供应商', value: stats.value.customs },
|
||||
{ category: 'TRUCKING' as const, label: '拖车供应商', value: stats.value.trucking },
|
||||
{ category: 'CERTIFICATE' as const, label: '证书供应商', value: stats.value.certificate },
|
||||
])
|
||||
|
||||
async function load() {
|
||||
if (!canView.value) return
|
||||
const current = ++sequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const [result, counts] = await Promise.all([
|
||||
listSuppliers({ page: page.value, size: pageSize.value, category: activeCategory.value, status: applied.value.status, scope: activeCategory.value === 'BOOKING' ? applied.value.scope : '', region: applied.value.region, keyword: applied.value.keyword }),
|
||||
getSupplierStats(),
|
||||
])
|
||||
if (current !== sequence) return
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
stats.value = counts
|
||||
} catch (error) {
|
||||
if (current !== sequence) return
|
||||
rows.value = []; total.value = 0
|
||||
loadError.value = getApiErrorDetails(error, '供应商列表加载失败')
|
||||
} finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
|
||||
function search() { applied.value = { ...filters.value }; page.value = 1; void load() }
|
||||
function switchCategory(category: SupplierCategory) {
|
||||
activeCategory.value = category
|
||||
filters.value.scope = ''; applied.value.scope = ''; page.value = 1
|
||||
void load()
|
||||
}
|
||||
function showLicense(row: Supplier) { licenseSupplier.value = row; licenseOpen.value = true }
|
||||
function supplierRow(row: object) { return row as Supplier }
|
||||
async function toggleStatus(row: Supplier) {
|
||||
const next: SupplierStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||||
const verb = next === 'ACTIVE' ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${verb}供应商“${row.name}”吗?`, `${verb}供应商`, { type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
|
||||
actionId.value = row.id
|
||||
await changeSupplierStatus(row.id, next)
|
||||
ElMessage.success(`供应商已${verb}`)
|
||||
await load()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
const details = getApiErrorDetails(error, `${verb}供应商失败`)
|
||||
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
|
||||
} finally { actionId.value = '' }
|
||||
}
|
||||
|
||||
watch([page, pageSize], ([newPage, newSize], [oldPage, oldSize]) => { if (newPage !== oldPage || newSize !== oldSize) void load() })
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<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" />
|
||||
</template>
|
||||
<template #state>
|
||||
<LoadErrorState v-if="!canView" title="暂无供应商查看权限" message="请联系管理员开通供应商查看权限。" :show-retry="false" />
|
||||
<LoadErrorState v-else-if="loadError" title="供应商加载失败" :message="loadError.message" :loading="loading" @retry="load">
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</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>
|
||||
</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 #status="{ row }"><StatusBadge :status="statusLabels[row.status as SupplierStatus]" /></template>
|
||||
<template #settlement="{ row }">{{ settlementLabels[supplierRow(row).settlementPeriod] }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<SupplierLicenseDialog v-model="licenseOpen" :supplier="licenseSupplier" />
|
||||
</AdminPage>
|
||||
</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; }
|
||||
.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; } }
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const supplierRoutes = [
|
||||
{ path: 'suppliers', name: 'supplier-list', component: () => import('@/modules/suppliers/pages/SuppliersView.vue'), meta: { title: '供应商中心', description: '维护平台公共订舱、报关、拖车和证书供应商', permission: 'platform:supplier:list' } },
|
||||
{ path: 'suppliers/new', name: 'supplier-create', component: () => import('@/modules/suppliers/pages/SupplierFormView.vue'), meta: { title: '新增供应商', description: '新增平台公共供应商档案', permission: 'platform:supplier:create' } },
|
||||
{ path: 'suppliers/:id', name: 'supplier-detail', component: () => import('@/modules/suppliers/pages/SupplierDetailView.vue'), meta: { title: '供应商详情', description: '查看平台公共供应商完整档案', permission: 'platform:supplier:list' } },
|
||||
{ path: 'suppliers/:id/edit', name: 'supplier-edit', component: () => import('@/modules/suppliers/pages/SupplierFormView.vue'), meta: { title: '编辑供应商', description: '编辑平台公共供应商档案', permission: 'platform:supplier:edit' } },
|
||||
] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,73 @@
|
||||
export type SupplierCategory = 'BOOKING' | 'CUSTOMS' | 'TRUCKING' | 'CERTIFICATE'
|
||||
export type SupplierScope = 'DOMESTIC' | 'INTERNATIONAL'
|
||||
export type SupplierStatus = 'ACTIVE' | 'INACTIVE'
|
||||
export type SettlementPeriod = 'MONTHLY_30' | 'MONTHLY_45' | 'MONTHLY_60' | 'MONTHLY_90' | 'PER_TRANSACTION' | 'IMMEDIATE'
|
||||
|
||||
export interface Supplier {
|
||||
id: string
|
||||
tenantId: string
|
||||
name: string
|
||||
category: SupplierCategory
|
||||
scope: SupplierScope | null
|
||||
region: string
|
||||
legalRepresentative: string
|
||||
contactName: string
|
||||
phone: string
|
||||
email: string | null
|
||||
address: string | null
|
||||
businessLicenseNo: string | null
|
||||
licenseFileId: string | null
|
||||
status: SupplierStatus
|
||||
settlementPeriod: SettlementPeriod
|
||||
remark: string | null
|
||||
createdBy: string | null
|
||||
createdAt: string
|
||||
updatedBy: string | null
|
||||
updatedAt: string
|
||||
deleted: boolean
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface SupplierPage {
|
||||
records: Supplier[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface SupplierStats { booking: number; customs: number; trucking: number; certificate: number }
|
||||
|
||||
export interface SupplierQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
category?: SupplierCategory
|
||||
scope?: SupplierScope | ''
|
||||
region?: string
|
||||
status?: SupplierStatus | ''
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface SaveSupplierRequest {
|
||||
name: string
|
||||
category: SupplierCategory
|
||||
scope: SupplierScope | null
|
||||
region: string
|
||||
legalRepresentative: string
|
||||
contactName: string
|
||||
phone: string
|
||||
email: string | null
|
||||
address: string | null
|
||||
businessLicenseNo: string | null
|
||||
licenseFileId: string | null
|
||||
status: SupplierStatus
|
||||
settlementPeriod: SettlementPeriod
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export const categoryLabels: Record<SupplierCategory, string> = { BOOKING: '订舱', CUSTOMS: '报关', TRUCKING: '拖车', CERTIFICATE: '证书' }
|
||||
export const scopeLabels: Record<SupplierScope, string> = { DOMESTIC: '国内', INTERNATIONAL: '国外' }
|
||||
export const statusLabels: Record<SupplierStatus, string> = { ACTIVE: '合作中', INACTIVE: '停用' }
|
||||
export const settlementLabels: Record<SettlementPeriod, string> = {
|
||||
MONTHLY_30: '月结30天', MONTHLY_45: '月结45天', MONTHLY_60: '月结60天', MONTHLY_90: '月结90天', PER_TRANSACTION: '票结', IMMEDIATE: '现结',
|
||||
}
|
||||
@@ -86,7 +86,7 @@ describe('TruckAppointmentsView', () => {
|
||||
})
|
||||
|
||||
it('shows the two frequent row actions directly without a more menu', () => {
|
||||
expect(source).toContain("v-if=\"access.can('platform:firstleg:truck-appointment:view')\"")
|
||||
expect(source).not.toContain('platform:firstleg:truck-appointment:view')
|
||||
expect(source).toContain("access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)")
|
||||
expect(source).toContain('<Eye :size="15" />详情')
|
||||
expect(source).toContain('<Pencil :size="15" />编辑')
|
||||
@@ -106,11 +106,11 @@ describe('TruckAppointmentsView', () => {
|
||||
expect(wrapper.find('button[aria-label="编辑 TA-9001"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the detail action when the account lacks the backend view permission', async () => {
|
||||
it('shows the detail action with the list permission', async () => {
|
||||
useAuthStore().permissions = ['platform:firstleg:truck-appointment:list']
|
||||
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(false)
|
||||
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('prioritizes outbound business fields and keeps finance amounts out of the list', () => {
|
||||
|
||||
@@ -65,7 +65,7 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
|
||||
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
|
||||
<template #store="{ row }">{{ row.storeName || '—' }}</template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:truck-appointment:view')" size="small" type="primary" plain :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)" size="small" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)" size="small" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
@@ -18,6 +18,6 @@ export const truckingRoutes = [
|
||||
name: 'trucking-detail',
|
||||
component: () => import('./pages/TruckAppointmentDetailView.vue'),
|
||||
props: true,
|
||||
meta: { title: '拖车预约详情', description: '查看和编辑拖车预约', permission: 'platform:firstleg:truck-appointment:view' },
|
||||
meta: { title: '拖车预约详情', description: '查看和编辑拖车预约', permission: 'platform:firstleg:truck-appointment:list' },
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
|
||||
@@ -2,7 +2,7 @@ export interface PlatformPermissionResource {
|
||||
id: number | string
|
||||
tenantId: number | string
|
||||
appCode: 'PLATFORM'
|
||||
permissionType: 'MENU' | 'BUTTON'
|
||||
permissionType: 'MENU' | 'PAGE' | 'BUTTON' | 'API'
|
||||
parentId: number | string
|
||||
permissionCode: string
|
||||
permissionName: string
|
||||
|
||||
Reference in New Issue
Block a user