feat(supply-chain): 各部门数据分类去掉「数据权限例外」,权限统一以用户管理后台为准

- 配置弹窗移除「数据权限例外」输入项,保存时不再写入该字段(旧配置下次保存即清空)
- 数据范围不再按人收窄:部门负责人/总负责人/普通账号一律看全部利润中心
- 设置结构、settings 接口返回与权限节点描述同步移除 dataScopeGrants
This commit is contained in:
居居 2026-10-09 16:18:56 +08:00 committed by WuYongJie
parent 1779d8a697
commit 1a53aa31a3
4 changed files with 22 additions and 105 deletions

View File

@ -28,13 +28,12 @@ type SettingsDraft = {
virtualOwnerProfitCenter: string
ownerProfitCenterOverride: string
profitCenterAlias: string
dataScopeGrants: string
targetRows: TargetRowDraft[]
}
const EMPTY_DRAFT: SettingsDraft = {
brandScope: '', brandOwnerAlias: '', virtualOwnerProfitCenter: '', ownerProfitCenterOverride: '',
profitCenterAlias: '', dataScopeGrants: '',
profitCenterAlias: '',
targetRows: [{ profitCenter: '', newProductSuccess: '', newProductMoving: '', saRatio: '', saTargetRatio: '' }],
}
@ -81,18 +80,6 @@ function mapToLines(value: Record<string, string>) {
return Object.entries(value).map(([key, item]) => `${key}=${item}`).join('\n')
}
function grantsToLines(value: Array<{ user: string; scopes: string[] }>) {
return value.map((grant) => `${grant.user}=${grant.scopes.join(',')}`).join('\n')
}
function linesToGrants(value: string) {
return value.split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
const separator = line.includes('=') ? '=' : line.includes(':') ? ':' : ':'
const [user, ...rest] = line.split(separator)
return { user: user.trim(), scopes: rest.join(separator).split(/[,,;;]+/).map((item) => item.trim()).filter(Boolean) }
}).filter((grant) => grant.user && grant.scopes.length > 0)
}
function targetRowPayload(row: TargetRowDraft | undefined) {
return {
newProductSuccess: Number(row?.newProductSuccess ?? 0),
@ -245,7 +232,6 @@ export function DepartmentDistributionView({
virtualOwnerProfitCenter: mapToLines(settings.virtualOwnerProfitCenter ?? {}),
ownerProfitCenterOverride: mapToLines(settings.ownerProfitCenterOverride ?? {}),
profitCenterAlias: mapToLines(settings.profitCenterAlias ?? {}),
dataScopeGrants: grantsToLines(settings.dataScopeGrants ?? []),
targetRows: [
{
profitCenter: '',
@ -277,7 +263,6 @@ export function DepartmentDistributionView({
virtualOwnerProfitCenter: linesToMap(draft.virtualOwnerProfitCenter),
ownerProfitCenterOverride: linesToMap(draft.ownerProfitCenterOverride),
profitCenterAlias: linesToMap(draft.profitCenterAlias),
dataScopeGrants: linesToGrants(draft.dataScopeGrants),
targets: targetRowPayload(draft.targetRows.find((row) => !row.profitCenter) ?? draft.targetRows[0]),
profitCenterTargets: draft.targetRows
.filter((row) => row.profitCenter)
@ -405,10 +390,6 @@ export function DepartmentDistributionView({
利润中心合并(一行一条:原利润中心=并入的利润中心,如 运营中心E=运营中心G)
<textarea value={draft.profitCenterAlias} onChange={(event) => setDraft((current) => ({ ...current, profitCenterAlias: event.target.value }))} />
</label>
<label className={styles.deptConfigField}>
数据权限例外(一行一条:花名=范围;范围支持 全部(看所有利润中心)/ 利润中心:xx / 事业部:xx / 品类部:xx / 负责人:xx,多个用逗号)
<textarea value={draft.dataScopeGrants} onChange={(event) => setDraft((current) => ({ ...current, dataScopeGrants: event.target.value }))} />
</label>
<div className={styles.deptConfigField} style={{ gridColumn: '1 / -1' }}>
目标参数(一行一个利润中心,可分别设置;利润中心选「默认」= 其它中心统一用这行目标)
<table className={modalStyles.targetTable}>

View File

@ -44,7 +44,7 @@ export type SupplyChainPermissionTargetKey =
| 'productSabc'
// 模块权限:商品SABC打标「各部门数据分类」(可见/下载分开控制)
| 'departmentDistribution'
// 商品SABC打标「各部门数据分类」的配置(品牌范围/兜底规则/数据权限例外/目标参数)
// 商品SABC打标「各部门数据分类」的配置(品牌范围/兜底规则/目标参数)
| 'departmentDistributionConfig'
export type SupplyChainPermissionSubject = Omit<
@ -107,7 +107,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [
{ key: 'productSabcCalculation', label: '商品SABC打标 · 执行核算', description: '每月核算与重新生成商品SABC打标数据(页面内按钮)' },
{ key: 'departmentDistribution', label: '商品SABC打标 · 各部门数据分类', description: '按利润中心汇总的部门数据(可见、下载分别控制)' },
{ key: 'productSabc', label: '商品SABC打标 · 商品明细', description: '商品SABC打标明细页与明细导出(默认全员开放,可在用户管理后台关闭)' },
{ key: 'departmentDistributionConfig', label: '商品SABC打标 · 部门数据配置', description: '维护品牌范围、负责人兜底、数据权限例外与目标参数(操作列控制)' },
{ key: 'departmentDistributionConfig', label: '商品SABC打标 · 部门数据配置', description: '维护品牌范围、负责人兜底与目标参数(操作列控制)' },
]
/**

View File

@ -100,11 +100,6 @@ export function clearDepartmentDistributionCache() {
departmentRawCache.clear()
}
export type DepartmentDataScopeGrant = {
user: string
scopes: string[]
}
export type DepartmentDistributionSettings = {
brandScope: string[]
brandOwnerAlias: Record<string, string>
@ -113,7 +108,6 @@ export type DepartmentDistributionSettings = {
profitCenterAlias: Record<string, string>
targets: DepartmentTargets
profitCenterTargets: DepartmentProfitCenterTargets[]
dataScopeGrants: DepartmentDataScopeGrant[]
}
type OwnerConfigRow = {
@ -171,7 +165,6 @@ export function normalizeDepartmentSettings(value: unknown): DepartmentDistribut
}
})
.filter((item) => item.profitCenter)
const grants = Array.isArray(raw.dataScopeGrants) ? raw.dataScopeGrants : []
return {
brandScope: cleanStringArray(raw.brandScope, DEFAULT_BRAND_SCOPE),
brandOwnerAlias: cleanStringMap(raw.brandOwnerAlias, DEFAULT_BRAND_OWNER_ALIAS),
@ -180,14 +173,6 @@ export function normalizeDepartmentSettings(value: unknown): DepartmentDistribut
profitCenterAlias: cleanStringMap(raw.profitCenterAlias, DEFAULT_PROFIT_CENTER_ALIAS),
targets: defaultTargets,
profitCenterTargets,
dataScopeGrants: grants
.map((grant) => {
const item = asRecord(grant)
const user = String(item.user ?? '').trim()
const scopes = cleanStringArray(item.scopes, [])
return { user, scopes }
})
.filter((grant) => grant.user && grant.scopes.length > 0),
}
}
@ -385,55 +370,21 @@ export type DepartmentViewerScope = {
}
/**
* 方案 C 数据权限:
* 1) 管理员看全部;
* 2) 部门负责人(人员信息「部门负责人」)看本部门全部数据;
* 3) 事业部/总负责人(「总负责人」)看本事业部全部数据;
* 4) 配置里的例外授权可按 利润中心 / 事业部 / 品类部 / 负责人 追加范围;
* 5) 其余人员只看自己名下的数据。
* 「各部门数据分类」数据范围:不做任何按人收窄,所有人都能看到全部利润中心数据。
* 查看/导出权限统一以「用户管理后台」的「商品SABC打标 · 各部门数据分类」节点为准(默认开放)。
*/
export async function resolveDepartmentViewerScope(
user: AuthUser,
configs: OwnerConfigRow[],
settings: DepartmentDistributionSettings,
): Promise<DepartmentViewerScope> {
if (isAdmin(user)) return { nickname: '', isAdmin: true, allowedOwners: null, scopeLabel: '全部利润中心(管理员)' }
const nickname = await resolveNickname(user, configs)
const allowed = new Set<string>(nickname ? [nickname] : [])
const scopes = new Set<string>()
for (const config of configs) {
if (!config.operationOwner) continue
if (nickname && config.departmentOwner === nickname && config.department) scopes.add(`事业部:${config.department}`)
if (nickname && config.generalOwner === nickname && config.department) scopes.add(`事业部:${config.department}`)
return {
nickname,
isAdmin: false,
allowedOwners: null,
scopeLabel: nickname ? `全部利润中心(${nickname})` : '全部利润中心',
}
for (const grant of settings.dataScopeGrants) {
if (grant.user !== nickname) continue
for (const scope of grant.scopes) scopes.add(scope)
}
// 支持「全部」授权:scope 写 全部 / 所有 / * 即可看全部利润中心(不需要逐条列利润中心)
const grantsAllData = [...scopes].some((scope) => {
const normalized = scope.replace(/\s/g, '')
return normalized === '全部' || normalized === '所有' || normalized === '*' || normalized === '全公司'
})
if (grantsAllData) {
return { nickname, isAdmin: false, allowedOwners: null, scopeLabel: nickname ? `全部数据(授权给 ${nickname})` : '全部数据' }
}
for (const scope of scopes) {
const [kind, rawValue] = scope.split(':')
const value = String(rawValue ?? '').trim()
if (!kind || !value) continue
for (const config of configs) {
if (!config.operationOwner) continue
if (kind === '负责人' && config.operationOwner === value) allowed.add(config.operationOwner)
if (kind === '事业部' && config.department === value) allowed.add(config.operationOwner)
if (kind === '品类部' && config.secondaryDepartment === value) allowed.add(config.operationOwner)
if (kind === '利润中心' && config.profitCenter === value) allowed.add(config.operationOwner)
}
}
const scopeLabel = scopes.size > 0
? [nickname ? `本人(${nickname})` : '', ...scopes].filter(Boolean).join('、')
: nickname ? `本人(${nickname})` : '未匹配到人员信息(无数据)'
return { nickname, isAdmin: false, allowedOwners: allowed, scopeLabel }
}
function resolveProfitCenter(
@ -812,9 +763,8 @@ export class SupplyChainDepartmentDistributionService {
const cached = departmentResultCache.get(cacheKey)
if (cached && Date.now() - cached.at < DEPARTMENT_RESULT_CACHE_TTL_MS) return cached.value
const settings = await loadDepartmentSettings()
const configs = await loadOwnerConfigs()
const viewer = await resolveDepartmentViewerScope(input.user, configs, settings)
const viewer = await resolveDepartmentViewerScope(input.user, configs)
const snapshot = await this.loadRawSnapshot(input.year, input.month)
let result: DepartmentDistributionResult
if (snapshot) {

View File

@ -82,54 +82,40 @@ describe('supply-chain department distribution', () => {
expect(settings.targets.newProductSuccess).toBe(0.3)
})
it('keeps admin, department owner, general owner and grant scopes apart', async () => {
it('gives everyone all profit centers (权限只看用户管理后台)', async () => {
const configs = [
config({ operationOwner: '比干', profitCenter: '运营中心A', department: '事业一部', departmentOwner: '比干', generalOwner: '比干' }),
config({ operationOwner: '西门吹雪', profitCenter: '运营中心A', department: '事业二部', secondaryDepartment: '品类三部', departmentOwner: '西门吹雪', generalOwner: '無尘' }),
config({ operationOwner: '玄女', profitCenter: '运营中心A', department: '事业二部', secondaryDepartment: '品类二部', departmentOwner: '西门吹雪', generalOwner: '無尘' }),
config({ operationOwner: '香茗', profitCenter: '运营中心B', department: '运营一部' }),
]
const settings = normalizeDepartmentSettings(null)
const admin = await resolveDepartmentViewerScope(
{ id: 'u', email: '', name: '管理员', role: 'ADMIN', status: 'ACTIVE' },
configs,
settings,
)
expect(admin.allowedOwners).toBeNull()
expect(admin.scopeLabel).toContain('管理员')
// 部门负责人:比干 管 事业一部
// 部门负责人 / 总负责人 / 普通人员都不再收窄,全部可见
const departmentOwner = await resolveDepartmentViewerScope(
{ id: 'u', email: '', name: '比干', role: 'USER', status: 'ACTIVE' },
configs,
settings,
)
expect([...(departmentOwner.allowedOwners ?? [])].sort()).toEqual(['比干'])
expect(departmentOwner.allowedOwners).toBeNull()
expect(departmentOwner.scopeLabel).toContain('全部利润中心')
// 总负责人:無尘 管 事业二部 → 西门吹雪 + 玄女
const generalOwner = await resolveDepartmentViewerScope(
{ id: 'u', email: '', name: '無尘', role: 'USER', status: 'ACTIVE' },
configs,
settings,
)
expect([...(generalOwner.allowedOwners ?? [])].sort()).toEqual(['無尘', '玄女', '西门吹雪'])
expect(generalOwner.allowedOwners).toBeNull()
// 例外授权:香茗 额外看 利润中心:运营中心A
const granted = await resolveDepartmentViewerScope(
const ordinary = await resolveDepartmentViewerScope(
{ id: 'u', email: '', name: '香茗', role: 'USER', status: 'ACTIVE' },
configs,
normalizeDepartmentSettings({ dataScopeGrants: [{ user: '香茗', scopes: ['利润中心:运营中心A'] }] }),
)
expect([...(granted.allowedOwners ?? [])].sort()).toEqual(['香茗', '比干', '玄女', '西门吹雪'].sort())
// 「全部」授权:不限制利润中心
const full = await resolveDepartmentViewerScope(
{ id: 'u', email: '', name: '望舒', role: 'USER', status: 'ACTIVE' },
configs,
normalizeDepartmentSettings({ dataScopeGrants: [{ user: '望舒', scopes: ['全部'] }] }),
)
expect(full.allowedOwners).toBeNull()
expect(full.scopeLabel).toContain('全部数据')
expect(ordinary.allowedOwners).toBeNull()
})
it('aggregates rows by profit center, department and owner with the manual-table metrics', async () => {
@ -295,15 +281,15 @@ describe('supply-chain department distribution', () => {
expect(adminResult.profitCenters.map((center) => center.profitCenter)).toEqual(['运营中心A', '运营中心B'])
expect(adminResult.designerTotal.productTotal).toBe(10)
// 普通人员只能看到自己那部分,小计按可见数据重算
// 默认不做任何配置:普通人员也能看到全部利润中心(查看/导出由用户管理后台控制)
clearDepartmentDistributionCache()
const selfResult = await supplyChainDepartmentDistributionService.build({
year: 2026, month: 8,
user: { id: 'u', email: '', name: '比干', role: 'USER', status: 'ACTIVE' },
canExport: false,
})
expect(selfResult.summary.productTotal).toBe(10)
expect(selfResult.profitCenters.map((center) => center.profitCenter)).toEqual(['运营中心A'])
expect(selfResult.summary.productTotal).toBe(20)
expect(selfResult.profitCenters.map((center) => center.profitCenter)).toEqual(['运营中心A', '运营中心B'])
expect(selfResult.designerTotal.productTotal).toBe(10)
})
})