feat(supply-chain): 人员信息总负责人支持多中心总监并列显示(如 子龙/妙玉)
This commit is contained in:
parent
49ec0be34f
commit
bfe3073aa1
@ -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('在职')
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user