diff --git a/AGENTS.md b/AGENTS.md index f61ad4df..7f3eab7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 真实接口时删除对应模拟路径,不允许让生产逻辑静默回退到假数据。 - 删除、停用、改权限等高风险操作必须有明确确认、处理中、成功和失败反馈;失败时保留用户上下文,避免误以为已生效。 diff --git a/src/app/DomesticOutboundListStates.test.ts b/src/app/DomesticOutboundListStates.test.ts index cebc2df3..90a99baa 100644 --- a/src/app/DomesticOutboundListStates.test.ts +++ b/src/app/DomesticOutboundListStates.test.ts @@ -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)', '查看', '@retry="load"'], + ['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', '详情', '@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) }) diff --git a/src/app/router/index.ts b/src/app/router/index.ts index 5ab92102..46ff10a7 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -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, diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index f2c4b8f0..9222747e 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -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', diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index b95abccd..9bcb7cfc 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -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'] }, ], }, { diff --git a/src/modules/audit/api/platformAudit.test.ts b/src/modules/audit/api/platformAudit.test.ts new file mode 100644 index 00000000..47c99416 --- /dev/null +++ b/src/modules/audit/api/platformAudit.test.ts @@ -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: (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"}') + }) +}) diff --git a/src/modules/audit/api/platformAudit.ts b/src/modules/audit/api/platformAudit.ts new file mode 100644 index 00000000..3dab17c9 --- /dev/null +++ b/src/modules/audit/api/platformAudit.ts @@ -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 & { + id: RawId + accountId: RawId + operatorTenantId: RawId + userId: RawId + resourceId: RawId +} +type RawDetail = Omit & RawListItem +type RawPage = Omit & { + 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>(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>(`${basePath}/${encodeURIComponent(idValue)}`) + return normalizeListItem(unwrapApiResponse(response)) as PlatformAuditLogDetail +} diff --git a/src/modules/audit/components/CustomerAuditLogsPanel.test.ts b/src/modules/audit/components/CustomerAuditLogsPanel.test.ts new file mode 100644 index 00000000..540479d6 --- /dev/null +++ b/src/modules/audit/components/CustomerAuditLogsPanel.test.ts @@ -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 }) + }) +}) diff --git a/src/modules/audit/components/CustomerAuditLogsPanel.vue b/src/modules/audit/components/CustomerAuditLogsPanel.vue new file mode 100644 index 00000000..b8711f40 --- /dev/null +++ b/src/modules/audit/components/CustomerAuditLogsPanel.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts new file mode 100644 index 00000000..7695cf5a --- /dev/null +++ b/src/modules/audit/index.ts @@ -0,0 +1 @@ +export { default as CustomerAuditLogsPanel } from './components/CustomerAuditLogsPanel.vue' diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index bc94b422..a6a60432 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -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') + }) }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index c6e55300..e21c0497 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -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>(`${containerPath}/${encodeURIComponent(containerId)}/overview`) + return overview(unwrapApiResponse(response)) +} + +export async function markContainerException(containerId: string, expectedVersion: number, reason: string) { + const response = await http.post, ApiEnvelope>(`${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>(`${containerPath}/${encodeURIComponent(containerId)}/exception`, { data: { expectedVersion, reason }, headers: { 'Idempotency-Key': operationId('container-exception-cancel') } }) + unwrapApiResponse(response) +} + +export async function uploadContainerDocument(containerId: string, documentType: Extract, 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>(`${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, fileId: string) { + return http.get(`${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(downloadPath, { responseType: 'blob' }) +} + export async function createContainer(payload: ContainerSaveRequest) { const response = await http.post, ApiEnvelope>(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>(`${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>(`${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>(`${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>(`${smartLoadingPath}/candidates`, { params: { bookingId, warehouseId } }) return unwrapApiResponse(response).map((raw) => ({ diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts new file mode 100644 index 00000000..86f0b093 --- /dev/null +++ b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts @@ -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') + }) +}) diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.vue b/src/modules/container-loading/components/ContainerOverviewPanel.vue new file mode 100644 index 00000000..dabb865b --- /dev/null +++ b/src/modules/container-loading/components/ContainerOverviewPanel.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 0ecca07d..8b32937d 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -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(' { + 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') + }) }) diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 32ce800e..2a4dd132 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -1,12 +1,13 @@ + + diff --git a/src/modules/domestic-containers/pages/DomesticContainersView.test.ts b/src/modules/domestic-containers/pages/DomesticContainersView.test.ts new file mode 100644 index 00000000..d2674603 --- /dev/null +++ b/src/modules/domestic-containers/pages/DomesticContainersView.test.ts @@ -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') }) +}) diff --git a/src/modules/domestic-containers/pages/DomesticContainersView.vue b/src/modules/domestic-containers/pages/DomesticContainersView.vue new file mode 100644 index 00000000..87508815 --- /dev/null +++ b/src/modules/domestic-containers/pages/DomesticContainersView.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/src/modules/domestic-containers/routes.ts b/src/modules/domestic-containers/routes.ts new file mode 100644 index 00000000..7700008b --- /dev/null +++ b/src/modules/domestic-containers/routes.ts @@ -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[] diff --git a/src/modules/domestic-containers/types.ts b/src/modules/domestic-containers/types.ts new file mode 100644 index 00000000..8c8110bf --- /dev/null +++ b/src/modules/domestic-containers/types.ts @@ -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 { 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 { 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 } diff --git a/src/modules/downloads/pages/DownloadCenterView.test.ts b/src/modules/downloads/pages/DownloadCenterView.test.ts index 293341de..bb8b16ca 100644 --- a/src/modules/downloads/pages/DownloadCenterView.test.ts +++ b/src/modules/downloads/pages/DownloadCenterView.test.ts @@ -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('button')] + .find((item) => item.textContent?.trim() === '下载文件') + detailDownload?.click() + await flushPromises() + expect(downloadPlatformExportTask).toHaveBeenCalledWith('9') wrapper.unmount() }) diff --git a/src/modules/downloads/pages/DownloadCenterView.vue b/src/modules/downloads/pages/DownloadCenterView.vue index 24d7bf6e..45e8780b 100644 --- a/src/modules/downloads/pages/DownloadCenterView.vue +++ b/src/modules/downloads/pages/DownloadCenterView.vue @@ -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(() => { - @@ -309,7 +313,18 @@ onBeforeUnmount(() => {

{{ detailTask.failureMessage }}

-
+ + diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts index c9800c8e..05877433 100644 --- a/src/modules/finance/api/finance.test.ts +++ b/src/modules/finance/api/finance.test.ts @@ -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 () => { diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index 760863aa..bc31676f 100644 --- a/src/modules/finance/api/finance.ts +++ b/src/modules/finance/api/finance.ts @@ -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>(`${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>(`${basePath}/settlements/${settlementId}/actions`, payload, mutationHeaders('settlement-action')) + return normalizeSettlementDetail(unwrapApiResponse(response)) +} export async function listFinanceStatements(customerId?: string): Promise { const response = await http.get, ApiEnvelope>(`${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 { const response = await http.get, ApiEnvelope>(`${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 { const response = await http.get, ApiEnvelope>(`${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 { diff --git a/src/modules/finance/components/CustomerStatementsPanel.test.ts b/src/modules/finance/components/CustomerStatementsPanel.test.ts new file mode 100644 index 00000000..226094ec --- /dev/null +++ b/src/modules/finance/components/CustomerStatementsPanel.test.ts @@ -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('入库操作费') + }) +}) diff --git a/src/modules/finance/components/CustomerStatementsPanel.vue b/src/modules/finance/components/CustomerStatementsPanel.vue new file mode 100644 index 00000000..5959d90c --- /dev/null +++ b/src/modules/finance/components/CustomerStatementsPanel.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/src/modules/finance/financeDelivery.test.ts b/src/modules/finance/financeDelivery.test.ts index 50449aca..56ff0eee 100644 --- a/src/modules/finance/financeDelivery.test.ts +++ b/src/modules/finance/financeDelivery.test.ts @@ -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', () => { diff --git a/src/modules/finance/index.ts b/src/modules/finance/index.ts index 7eb44320..4da06a7b 100644 --- a/src/modules/finance/index.ts +++ b/src/modules/finance/index.ts @@ -1 +1,2 @@ export { financeRoutes } from './routes' +export { default as CustomerStatementsPanel } from './components/CustomerStatementsPanel.vue' diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts new file mode 100644 index 00000000..24bfbce7 --- /dev/null +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts @@ -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(' { + 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') + }) +}) diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.vue b/src/modules/finance/pages/FinanceCustomerAccountsView.vue index ef4a973d..0e415e89 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.vue +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.vue @@ -1,22 +1,24 @@ diff --git a/src/modules/finance/pages/FinanceCustomerArapView.test.ts b/src/modules/finance/pages/FinanceCustomerArapView.test.ts index 2b930260..74bc932e 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerArapView.test.ts @@ -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>) { @@ -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): Promise } + 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): Promise } 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> + generationForm: { mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; remark: string } + submitGeneration(): Promise + } + 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: '八月费用' }) + }) }) diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue index 18a3e726..d794d97e 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.vue +++ b/src/modules/finance/pages/FinanceCustomerArapView.vue @@ -1,6 +1,7 @@ diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts new file mode 100644 index 00000000..0559ea58 --- /dev/null +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts @@ -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() + 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 }).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): Promise; filters: Record; 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): Promise }).load({ status: 'ISSUED' }) + await flushPromises() + expect(document.body.textContent).toContain('筛选字段加载失败') + }) +}) diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.vue b/src/modules/finance/pages/FinanceCustomerStatementsView.vue index e8028014..654b96dc 100644 --- a/src/modules/finance/pages/FinanceCustomerStatementsView.vue +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.vue @@ -1,19 +1,18 @@