diff --git a/src/modules/auth/store/auth.ts b/src/modules/auth/store/auth.ts index 2199641..ef6bc45 100644 --- a/src/modules/auth/store/auth.ts +++ b/src/modules/auth/store/auth.ts @@ -5,7 +5,7 @@ import { exchangeImpersonationCode, loginMerchant, logoutMerchant } from '@/modu import { ApiError } from '@/api/http' import { FRONTEND_TO_MERCHANT_PERMISSION_CODES, - TENANT_PERMISSION_GROUPS, + hasMappedMerchantPermission, getCurrentMerchantPermissionCodes, getMerchantPersonalProfile, getTenantProfile, @@ -14,16 +14,6 @@ import { import { clearSessionTokens, getAccessToken, getRefreshToken, persistTemporaryAccessToken, persistTokenPair } from '@/api/session' import { decodeMerchantToken, type MerchantJwtPayload } from '@/modules/auth/utils/jwt' -const frontendPermissionCodes = new Set(TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code))) -const backendPermissionMapping = Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES) - .reduce>((mapping, [frontendCode, backendCodes]) => { - for (const backendCode of backendCodes) { - if (!mapping[backendCode]) mapping[backendCode] = [] - mapping[backendCode].push(frontendCode) - } - return mapping - }, {}) - export interface ClientAccountProfile { username: string realName: string @@ -43,9 +33,8 @@ const emptyProfile: ClientAccountProfile = { function mapPermissions(backendPermissionCodes: string[] = []) { const backendPermissions = new Set(backendPermissionCodes) const result = new Set(['dashboard:view', 'account:profile:view']) - for (const permission of backendPermissions) { - if (frontendPermissionCodes.has(permission)) result.add(permission) - for (const mapped of backendPermissionMapping[permission] ?? []) result.add(mapped) + for (const [frontendCode] of Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES)) { + if (hasMappedMerchantPermission(frontendCode, backendPermissions)) result.add(frontendCode) } return [...result] } diff --git a/src/modules/finance/api/adapters.ts b/src/modules/finance/api/adapters.ts index dd2a58d..a17f880 100644 --- a/src/modules/finance/api/adapters.ts +++ b/src/modules/finance/api/adapters.ts @@ -74,7 +74,9 @@ function mapBillLine(line: FinanceBillLineWire): FinanceBillLine { } export function mapFinanceBills(bills: FinanceBillWire[]) { - return bills.map(mapBillSummary) + return bills + .filter((bill) => bill.status === 'ISSUED' || bill.status === 'PARTIALLY_PAID' || bill.status === 'PAID') + .map(mapBillSummary) } export function mapFinanceBillDetail(bill: FinanceBillWire): FinanceBillDetail { diff --git a/src/modules/finance/views/FinanceBillsView.test.mjs b/src/modules/finance/views/FinanceBillsView.test.mjs index 4364a1f..e1f0635 100644 --- a/src/modules/finance/views/FinanceBillsView.test.mjs +++ b/src/modules/finance/views/FinanceBillsView.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import ts from 'typescript' const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8') const viewSource = readFileSync(new URL('./FinanceBillsView.vue', import.meta.url), 'utf8') @@ -8,6 +9,17 @@ const apiSource = readFileSync(new URL('../api/finance.ts', import.meta.url), 'u const adapterSource = readFileSync(new URL('../api/adapters.ts', import.meta.url), 'utf8') const permissionSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8') +async function importTypeScriptModule(url) { + const source = readFileSync(url, 'utf8') + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }).outputText + return import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`) +} + +const financeAdapters = await importTypeScriptModule(new URL('../api/adapters.ts', import.meta.url)) +const permissionContracts = await importTypeScriptModule(new URL('../../tenant-management/contracts.ts', import.meta.url)) + test('activates the customer finance route with a read-only permission', () => { assert.match(routesSource, /path: 'finance\/bills'/) assert.match(routesSource, /FinanceBillsView\.vue/) @@ -35,6 +47,37 @@ test('adapts the finance snake-case wire contract and filters receipts locally b assert.doesNotMatch(viewSource, /loadDetail\(row\.billNo\)/) }) +test('does not expose draft or voided bills returned by the server', () => { + const bill = (id, status) => ({ + id, + bill_no: `B-${id}`, + customer_name_snapshot: '测试客户', + currency_code: 'CNY', + bill_date: '2026-08-11', + due_date: '2026-09-10', + status, + total_amount: '100.00', + received_amount: '0.00', + outstanding_amount: '100.00', + }) + const visible = financeAdapters.mapFinanceBills([ + bill(1, 'DRAFT'), + bill(2, 'ISSUED'), + bill(3, 'PARTIALLY_PAID'), + bill(4, 'PAID'), + bill(5, 'VOIDED'), + ]) + assert.deepEqual(visible.map((item) => item.status), ['ISSUED', 'PARTIALLY_PAID', 'PAID']) +}) + +test('requires both finance statement authorities for the page permission', () => { + const allowed = (codes) => permissionContracts.hasMappedMerchantPermission('finance:bill:view', new Set(codes)) + assert.equal(allowed([]), false) + assert.equal(allowed(['merchant:finance:statement:list']), false) + assert.equal(allowed(['merchant:finance:statement:view']), false) + assert.equal(allowed(['merchant:finance:statement:list', 'merchant:finance:statement:view']), true) +}) + test('keeps actions first and exposes list, balance, detail, receipt, empty and error states', () => { const actionIndex = viewSource.indexOf('label="操作"') const billNumberIndex = viewSource.indexOf('label="账单编号"') @@ -52,4 +95,5 @@ test('catalogs customer finance as an assignable view-only role permission', () assert.match(permissionSource, /不包含任何财务写操作/) assert.match(permissionSource, /merchant:finance:statement:list/) assert.match(permissionSource, /merchant:finance:statement:view/) + assert.match(permissionSource, /FRONTEND_PERMISSIONS_REQUIRING_ALL_BACKEND_CODES/) }) diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts index f62733d..f0c2d0c 100644 --- a/src/modules/tenant-management/contracts.ts +++ b/src/modules/tenant-management/contracts.ts @@ -89,3 +89,15 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly([ + 'finance:bill:view', +]) + +export function hasMappedMerchantPermission(frontendCode: string, grantedBackendCodes: ReadonlySet) { + const backendCodes = FRONTEND_TO_MERCHANT_PERMISSION_CODES[frontendCode] + if (!backendCodes?.length) return grantedBackendCodes.has(frontendCode) + return FRONTEND_PERMISSIONS_REQUIRING_ALL_BACKEND_CODES.has(frontendCode) + ? backendCodes.every((code) => grantedBackendCodes.has(code)) + : backendCodes.some((code) => grantedBackendCodes.has(code)) +} diff --git a/src/modules/tenant-management/public.ts b/src/modules/tenant-management/public.ts index ab709b2..d71d9b3 100644 --- a/src/modules/tenant-management/public.ts +++ b/src/modules/tenant-management/public.ts @@ -9,6 +9,7 @@ export { export { FRONTEND_TO_MERCHANT_PERMISSION_CODES, TENANT_PERMISSION_GROUPS, + hasMappedMerchantPermission, type MerchantTenantStatus, } from './contracts' export { useTenantAdministrationStore } from './store/tenantAdministration' diff --git a/src/modules/tenant-management/store/tenantAdministration.ts b/src/modules/tenant-management/store/tenantAdministration.ts index 25d51cb..a543383 100644 --- a/src/modules/tenant-management/store/tenantAdministration.ts +++ b/src/modules/tenant-management/store/tenantAdministration.ts @@ -24,6 +24,7 @@ import { FRONTEND_TO_MERCHANT_PERMISSION_CODES, REQUIRED_TENANT_PERMISSION_CODES, TENANT_PERMISSION_GROUPS, + hasMappedMerchantPermission, type MerchantTenantStatus, } from '@/modules/tenant-management/contracts' import { normalizeQualificationStatus, type TenantQualificationStatus } from '@/modules/tenant-management/utils/qualificationStatus' @@ -130,7 +131,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration', .filter((resource) => grantedIds.includes(String(resource.id))) .map((resource) => resource.permissionCode)) const permissionCodes = Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES) - .filter(([, backendCodes]) => backendCodes.some((code) => grantedBackendCodes.has(code))) + .filter(([frontendCode]) => hasMappedMerchantPermission(frontendCode, grantedBackendCodes)) .map(([frontendCode]) => frontendCode) const effectivePermissionCodes = [...new Set([...REQUIRED_TENANT_PERMISSION_CODES, ...permissionCodes])] return {