feat(supply-chain): 人员信息总负责人支持多中心总监并列显示(如 子龙/妙玉)

This commit is contained in:
居居 2026-10-14 13:02:45 +08:00
parent 49ec0be34f
commit bfe3073aa1
5 changed files with 197 additions and 30 deletions

View File

@ -88,6 +88,29 @@ describe('负责人在职状态与离职接管链', () => {
expect(resolveOwnerTakeover('五竹', noSuccessor).display).toBe('五竹(已离职)')
})
it('总负责人为多人并列(子龙/妙玉)时,接管取第一位在职者', () => {
const idx = index([
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '', generalOwner: '子龙/妙玉' },
{ name: '子龙', sourceStatus: 'DIMISSIONED', generalOwner: '子龙/妙玉' },
{ name: '妙玉', generalOwner: '子龙/妙玉' },
])
expect(resolveOwnerTakeover('五竹', idx)).toEqual({
status: '已离职',
display: '五竹(已离职)→ 妙玉',
successor: '妙玉',
successorDeparted: false,
})
const allDeparted = index([
{ name: '五竹', sourceStatus: 'DIMISSIONED', departmentOwner: '', generalOwner: '子龙/妙玉' },
{ name: '子龙', sourceStatus: 'DIMISSIONED', generalOwner: '子龙/妙玉' },
{ name: '妙玉', sourceStatus: 'DIMISSIONED', generalOwner: '子龙/妙玉' },
])
const view = resolveOwnerTakeover('五竹', allDeparted)
expect(view.display).toBe('五竹(已离职)→ 子龙(已离职)')
expect(view.successorDeparted).toBe(true)
})
it('无员工目录关联的人员按在职处理(不标注、不接管)', () => {
const idx = index([{ name: '手工行', sourceStatus: 'EMPLOYED' }])
expect(resolveOwnerTakeover('手工行', idx).status).toBe('在职')

View File

@ -67,21 +67,30 @@ export function resolveOwnerTakeover(
}
const ownerKey = normalizeOwnerName(raw)
const pickSuccessor = (name: string | null | undefined) => {
const trimmed = (name ?? '').trim()
if (!trimmed || normalizeOwnerName(trimmed) === ownerKey) return null
return {
name: trimmed,
departed: index.get(normalizeOwnerName(trimmed))?.sourceStatus === 'DIMISSIONED',
// 部门负责人/总负责人支持「子龙/妙玉」这类多人并列值:按顺序逐个作为接管候选。
const pickSuccessors = (value: string | null | undefined) => {
const successors: Array<{ name: string; departed: boolean }> = []
const seen = new Set<string>()
for (const name of String(value ?? '').split('/').map((part) => part.trim()).filter(Boolean)) {
const key = normalizeOwnerName(name)
if (!key || key === ownerKey || seen.has(key)) continue
seen.add(key)
successors.push({
name,
departed: index.get(key)?.sourceStatus === 'DIMISSIONED',
})
}
return successors
}
const departmentOwner = pickSuccessor(entry.departmentOwner)
const generalOwner = pickSuccessor(entry.generalOwner)
const departmentOwners = pickSuccessors(entry.departmentOwner)
const generalOwners = pickSuccessors(entry.generalOwner)
// 接管链:部门现任负责人在职 → 直接接管;否则交给总负责人(总负责人也离职则
// 带标注展示,不再上抛);两者都缺位时退回展示部门负责人(如有)。
const successor = (departmentOwner && !departmentOwner.departed ? departmentOwner : null)
?? generalOwner
?? departmentOwner
const successor = departmentOwners.find((item) => !item.departed)
?? generalOwners.find((item) => !item.departed)
?? generalOwners[0]
?? departmentOwners[0]
?? null
if (!successor) {
return { status: '已离职', display: `${raw}(已离职)`, successor: '', successorDeparted: false }
}

View File

@ -154,6 +154,59 @@ describe('负责人配置', () => {
})
})
it('同一部门有多位中心总监时,总负责人并列显示且职级高者在前', async () => {
const { repository, service } = setup()
repository.listDirectoryContext.mockResolvedValue({
employees: [
{ id: 'member', name: '品类员工', title: '运营', dingtalkUserId: 'dt-member', managerDingtalkUserId: null, primaryDepartmentExternalId: 'dept-8' },
{
id: 'zi-long',
name: '子龙',
title: '运营总监',
dingtalkUserId: 'dt-zi-long',
managerDingtalkUserId: null,
primaryDepartmentExternalId: 'business-3',
departmentExternalIds: ['business-3'],
rawSnapshot: {
management_role_list: [
{ name: '部门主管', member_order: 4, manage_scope_department_ids: ['dept-8'] },
{ name: '中心总监', member_order: 9, manage_scope_department_ids: ['business-3'] },
],
},
},
{
id: 'miao-yu',
name: '妙玉',
title: '商品企划',
dingtalkUserId: 'dt-miao-yu',
managerDingtalkUserId: null,
primaryDepartmentExternalId: 'business-3',
departmentExternalIds: ['business-3'],
rawSnapshot: {
management_role_list: [
{ name: '中心总监', member_order: 2, manage_scope_department_ids: ['business-3'] },
],
},
},
],
departments: [
{ id: 'root', externalId: '1', name: '集团', fullPath: '集团', parentId: null, directoryManagerDingtalkUserIds: [], manager: null },
{ id: 'business-3', externalId: 'business-3', name: '事业三部', fullPath: '集团 > 事业三部', parentId: 'root', directoryManagerDingtalkUserIds: [], manager: null },
{ id: 'dept-8', externalId: 'dept-8', name: '品类八部', fullPath: '集团 > 事业三部 > 品类八部', parentId: 'business-3', directoryManagerDingtalkUserIds: [], manager: null },
],
})
const result = await service.listDirectoryOptions()
// 部门负责人保持单人(部门主管 子龙);总负责人并列显示,职级高的子龙在前。
expect(result.items.find((item) => item.name === '品类员工')).toMatchObject({
departmentOwner: '子龙',
generalOwner: '子龙/妙玉',
})
expect(result.items.find((item) => item.name === '子龙')?.generalOwner).toBe('子龙/妙玉')
expect(result.items.find((item) => item.name === '妙玉')?.generalOwner).toBe('子龙/妙玉')
})
it('董事长不参与负责人计算并由多部门归属中的中心总监接管', async () => {
const { repository, service } = setup()
repository.listDirectoryContext.mockResolvedValue({

View File

@ -85,6 +85,11 @@ function nameStatus(snapshot: DirectorySnapshot, name: string): 'employed' | 'di
return records.some((record) => record.sourceStatus === 'EMPLOYED') ? 'employed' : 'dimissioned'
}
/** 负责人字段可能是「子龙/妙玉」这类多人并列值:按 / 拆成候选名单逐个尝试。 */
function splitOwnerNames(value: string | null | undefined): string[] {
return String(value ?? '').split('/').map((part) => part.trim()).filter(Boolean)
}
/** 沿钉钉直属领导链上溯,返回第一位在职领导的名字;走不通返回 null。 */
function firstEmployedManager(snapshot: DirectorySnapshot, name: string): string | null {
const records = snapshot.byName.get(name.trim()) ?? []
@ -164,17 +169,31 @@ async function ensureCache(): Promise<ResolutionCache> {
}
if (!resolved && meta?.opsDeptHead) {
const status = nameStatus(directory, meta.opsDeptHead)
if (status === 'employed') resolved = { name: meta.opsDeptHead, basis: 'dept_head' }
else if (status === 'dimissioned') {
const managerName = firstEmployedManager(directory, meta.opsDeptHead)
resolved = managerName ? { name: managerName, basis: 'director' } : null
// 负责人字段支持「子龙/妙玉」这类多人并列值:按顺序逐个尝试。
for (const name of splitOwnerNames(meta.opsDeptHead)) {
const status = nameStatus(directory, name)
if (status === 'employed') {
resolved = { name, basis: 'dept_head' }
break
}
if (status === 'dimissioned') {
const managerName = firstEmployedManager(directory, name)
if (managerName) {
resolved = { name: managerName, basis: 'director' }
break
}
}
}
}
if (!resolved && meta?.opsDirector) {
const status = nameStatus(directory, meta.opsDirector)
if (status === 'employed') resolved = { name: meta.opsDirector, basis: 'director' }
for (const name of splitOwnerNames(meta.opsDirector)) {
const status = nameStatus(directory, name)
if (status === 'employed') {
resolved = { name, basis: 'director' }
break
}
}
}
resolution.set(spu, resolved ?? { name: '', basis: 'unresolved' })

View File

@ -371,12 +371,12 @@ export class SupplyChainManagementOwnerConfigService {
return false
}
const findScopedRoleManager = (
const listScopedRoleManagerCandidates = (
targetDepartmentId: string | null,
rolePriority: Map<string, number>,
includeEmployeeDepartmentScopes = false,
) => {
if (!targetDepartmentId) return null
if (!targetDepartmentId) return []
const targetAncestors = new Map<string, number>()
let currentId: string | null = targetDepartmentId
let distance = 0
@ -426,21 +426,74 @@ export class SupplyChainManagementOwnerConfigService {
memberOrder: role.memberOrder,
}]
})
}).sort((left, right) => (
left.distance - right.distance
|| left.primaryDepartmentMatch - right.primaryDepartmentMatch
|| left.scopeCount - right.scopeCount
|| left.rolePriority - right.rolePriority
|| left.memberOrder - right.memberOrder
|| left.employee.name.localeCompare(right.employee.name, 'zh-CN')
))
})
return candidates
}
type ScopedRoleCandidate = ReturnType<typeof listScopedRoleManagerCandidates>[number]
const compareScopedRoleManagerRank = (left: ScopedRoleCandidate, right: ScopedRoleCandidate) => (
left.distance - right.distance
|| left.primaryDepartmentMatch - right.primaryDepartmentMatch
|| left.scopeCount - right.scopeCount
|| left.rolePriority - right.rolePriority
)
const compareScopedRoleManagerCandidates = (left: ScopedRoleCandidate, right: ScopedRoleCandidate) => (
compareScopedRoleManagerRank(left, right)
|| left.memberOrder - right.memberOrder
|| left.employee.name.localeCompare(right.employee.name, 'zh-CN')
)
const findScopedRoleManager = (
targetDepartmentId: string | null,
rolePriority: Map<string, number>,
includeEmployeeDepartmentScopes = false,
) => {
const candidates = [...listScopedRoleManagerCandidates(targetDepartmentId, rolePriority, includeEmployeeDepartmentScopes)]
.sort(compareScopedRoleManagerCandidates)
const selected = candidates[0]
return selected
? { employeeId: selected.employee.id, name: selected.employee.name, departmentId: selected.departmentId }
: null
}
/**
* 同层多人并列:同一部门被多位「中心总监」管辖时全部保留,
* 总负责人显示为「子龙/妙玉」这类并列值。
* 排序:管辖距离 → 主部门 → 管辖范围数 → 角色优先级 → 职级高者在前 → 钉钉角色成员顺序。
*/
const findScopedRoleManagers = (
targetDepartmentId: string | null,
rolePriority: Map<string, number>,
includeEmployeeDepartmentScopes = false,
) => {
const candidates = listScopedRoleManagerCandidates(targetDepartmentId, rolePriority, includeEmployeeDepartmentScopes)
const bestByEmployeeId = new Map<string, ScopedRoleCandidate>()
for (const candidate of candidates) {
const existing = bestByEmployeeId.get(candidate.employee.id)
if (!existing || compareScopedRoleManagerRank(candidate, existing) < 0) {
bestByEmployeeId.set(candidate.employee.id, candidate)
}
}
return Array.from(bestByEmployeeId.values())
.sort((left, right) => (
left.distance - right.distance
|| left.primaryDepartmentMatch - right.primaryDepartmentMatch
|| left.rolePriority - right.rolePriority
|| readEmployeeGradeScore(right.employee) - readEmployeeGradeScore(left.employee)
|| left.scopeCount - right.scopeCount
|| left.memberOrder - right.memberOrder
|| left.employee.name.localeCompare(right.employee.name, 'zh-CN')
))
.map((candidate) => ({
employeeId: candidate.employee.id,
name: candidate.employee.name,
departmentId: candidate.departmentId,
}))
}
const findDepartmentSpecificManager = (targetDepartmentId: string | null) => {
if (!targetDepartmentId) return null
const targetDepartment = departmentsById.get(targetDepartmentId)
@ -596,11 +649,18 @@ export class SupplyChainManagementOwnerConfigService {
DEPARTMENT_OWNER_ROLE_PRIORITY,
)
const departmentSpecificOwner = findDepartmentSpecificManager(departmentOwnerTargetId)
const scopedGeneralOwner = findScopedRoleManager(
const scopedGeneralOwners = findScopedRoleManagers(
department?.id ?? null,
GENERAL_OWNER_ROLE_PRIORITY,
true,
)
const scopedGeneralOwner = scopedGeneralOwners.length > 0
? {
employeeId: scopedGeneralOwners[0].employeeId,
name: scopedGeneralOwners.map((owner) => owner.name).join('/'),
departmentId: scopedGeneralOwners[0].departmentId,
}
: null
const existingDepartmentOwner = findManager(organizationLevels.departmentId, 'department')
const resolvedDepartmentOwner = departmentSpecificOwner ?? scopedDepartmentOwner ?? findCategoryDepartmentSupervisor(
organizationLevels.categoryDepartmentId,
@ -611,7 +671,10 @@ export class SupplyChainManagementOwnerConfigService {
?? findManager(organizationLevels.profitCenterDepartmentId, 'profitCenter')
?? existingDepartmentOwner
?? resolvedDepartmentOwner
const departmentOwner = resolvedDepartmentOwner ?? generalOwner
// 部门负责人保持单人:优先部门主管/经理;仅当全都没有时才回退到第一位中心总监(而不是并列值)。
const departmentOwner = resolvedDepartmentOwner
?? (scopedGeneralOwners[0] ?? null)
?? generalOwner
return {
employeeId: employee.id,
name: employee.name,