优化
This commit is contained in:
@@ -5,7 +5,7 @@ import { exchangeImpersonationCode, loginMerchant, logoutMerchant } from '@/modu
|
|||||||
import { ApiError } from '@/api/http'
|
import { ApiError } from '@/api/http'
|
||||||
import {
|
import {
|
||||||
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
|
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
|
||||||
TENANT_PERMISSION_GROUPS,
|
hasMappedMerchantPermission,
|
||||||
getCurrentMerchantPermissionCodes,
|
getCurrentMerchantPermissionCodes,
|
||||||
getMerchantPersonalProfile,
|
getMerchantPersonalProfile,
|
||||||
getTenantProfile,
|
getTenantProfile,
|
||||||
@@ -14,16 +14,6 @@ import {
|
|||||||
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTemporaryAccessToken, persistTokenPair } from '@/api/session'
|
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTemporaryAccessToken, persistTokenPair } from '@/api/session'
|
||||||
import { decodeMerchantToken, type MerchantJwtPayload } from '@/modules/auth/utils/jwt'
|
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 {
|
export interface ClientAccountProfile {
|
||||||
username: string
|
username: string
|
||||||
realName: string
|
realName: string
|
||||||
@@ -43,9 +33,8 @@ const emptyProfile: ClientAccountProfile = {
|
|||||||
function mapPermissions(backendPermissionCodes: string[] = []) {
|
function mapPermissions(backendPermissionCodes: string[] = []) {
|
||||||
const backendPermissions = new Set(backendPermissionCodes)
|
const backendPermissions = new Set(backendPermissionCodes)
|
||||||
const result = new Set<string>(['dashboard:view', 'account:profile:view'])
|
const result = new Set<string>(['dashboard:view', 'account:profile:view'])
|
||||||
for (const permission of backendPermissions) {
|
for (const [frontendCode] of Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES)) {
|
||||||
if (frontendPermissionCodes.has(permission)) result.add(permission)
|
if (hasMappedMerchantPermission(frontendCode, backendPermissions)) result.add(frontendCode)
|
||||||
for (const mapped of backendPermissionMapping[permission] ?? []) result.add(mapped)
|
|
||||||
}
|
}
|
||||||
return [...result]
|
return [...result]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ function mapBillLine(line: FinanceBillLineWire): FinanceBillLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapFinanceBills(bills: FinanceBillWire[]) {
|
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 {
|
export function mapFinanceBillDetail(bill: FinanceBillWire): FinanceBillDetail {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
|
const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
|
||||||
const viewSource = readFileSync(new URL('./FinanceBillsView.vue', 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 adapterSource = readFileSync(new URL('../api/adapters.ts', import.meta.url), 'utf8')
|
||||||
const permissionSource = readFileSync(new URL('../../tenant-management/contracts.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', () => {
|
test('activates the customer finance route with a read-only permission', () => {
|
||||||
assert.match(routesSource, /path: 'finance\/bills'/)
|
assert.match(routesSource, /path: 'finance\/bills'/)
|
||||||
assert.match(routesSource, /FinanceBillsView\.vue/)
|
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\)/)
|
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', () => {
|
test('keeps actions first and exposes list, balance, detail, receipt, empty and error states', () => {
|
||||||
const actionIndex = viewSource.indexOf('label="操作"')
|
const actionIndex = viewSource.indexOf('label="操作"')
|
||||||
const billNumberIndex = 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, /不包含任何财务写操作/)
|
||||||
assert.match(permissionSource, /merchant:finance:statement:list/)
|
assert.match(permissionSource, /merchant:finance:statement:list/)
|
||||||
assert.match(permissionSource, /merchant:finance:statement:view/)
|
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'],
|
'company:permission:assign': ['merchant:role:grant'],
|
||||||
'finance:bill:view': ['merchant:finance:statement:list', 'merchant:finance:statement:view'],
|
'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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
|
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
|
||||||
TENANT_PERMISSION_GROUPS,
|
TENANT_PERMISSION_GROUPS,
|
||||||
|
hasMappedMerchantPermission,
|
||||||
type MerchantTenantStatus,
|
type MerchantTenantStatus,
|
||||||
} from './contracts'
|
} from './contracts'
|
||||||
export { useTenantAdministrationStore } from './store/tenantAdministration'
|
export { useTenantAdministrationStore } from './store/tenantAdministration'
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
|
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
|
||||||
REQUIRED_TENANT_PERMISSION_CODES,
|
REQUIRED_TENANT_PERMISSION_CODES,
|
||||||
TENANT_PERMISSION_GROUPS,
|
TENANT_PERMISSION_GROUPS,
|
||||||
|
hasMappedMerchantPermission,
|
||||||
type MerchantTenantStatus,
|
type MerchantTenantStatus,
|
||||||
} from '@/modules/tenant-management/contracts'
|
} from '@/modules/tenant-management/contracts'
|
||||||
import { normalizeQualificationStatus, type TenantQualificationStatus } from '@/modules/tenant-management/utils/qualificationStatus'
|
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)))
|
.filter((resource) => grantedIds.includes(String(resource.id)))
|
||||||
.map((resource) => resource.permissionCode))
|
.map((resource) => resource.permissionCode))
|
||||||
const permissionCodes = Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES)
|
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)
|
.map(([frontendCode]) => frontendCode)
|
||||||
const effectivePermissionCodes = [...new Set([...REQUIRED_TENANT_PERMISSION_CODES, ...permissionCodes])]
|
const effectivePermissionCodes = [...new Set([...REQUIRED_TENANT_PERMISSION_CODES, ...permissionCodes])]
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user