This commit is contained in:
2026-08-11 11:41:52 +08:00
parent de0a90e853
commit 6ab7d52a95
6 changed files with 65 additions and 16 deletions
+3 -14
View File
@@ -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<Record<string, string[]>>((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<string>(['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]
}
+3 -1
View File
@@ -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 {
@@ -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/)
})
@@ -89,3 +89,15 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
'company:permission:assign': ['merchant:role:grant'],
'finance:bill:view': ['merchant:finance:statement:list', 'merchant:finance:statement:view'],
}
export const FRONTEND_PERMISSIONS_REQUIRING_ALL_BACKEND_CODES = new Set<string>([
'finance:bill:view',
])
export function hasMappedMerchantPermission(frontendCode: string, grantedBackendCodes: ReadonlySet<string>) {
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))
}
+1
View File
@@ -9,6 +9,7 @@ export {
export {
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
TENANT_PERMISSION_GROUPS,
hasMappedMerchantPermission,
type MerchantTenantStatus,
} from './contracts'
export { useTenantAdministrationStore } from './store/tenantAdministration'
@@ -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 {