feat(supply-chain): 人员信息总负责人支持多中心总监并列显示(如 子龙/妙玉)
This commit is contained in:
parent
49ec0be34f
commit
bfe3073aa1
@ -88,6 +88,29 @@ describe('负责人在职状态与离职接管链', () => {
|
|||||||
expect(resolveOwnerTakeover('五竹', noSuccessor).display).toBe('五竹(已离职)')
|
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('无员工目录关联的人员按在职处理(不标注、不接管)', () => {
|
it('无员工目录关联的人员按在职处理(不标注、不接管)', () => {
|
||||||
const idx = index([{ name: '手工行', sourceStatus: 'EMPLOYED' }])
|
const idx = index([{ name: '手工行', sourceStatus: 'EMPLOYED' }])
|
||||||
expect(resolveOwnerTakeover('手工行', idx).status).toBe('在职')
|
expect(resolveOwnerTakeover('手工行', idx).status).toBe('在职')
|
||||||
|
|||||||
@ -67,21 +67,30 @@ export function resolveOwnerTakeover(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ownerKey = normalizeOwnerName(raw)
|
const ownerKey = normalizeOwnerName(raw)
|
||||||
const pickSuccessor = (name: string | null | undefined) => {
|
// 部门负责人/总负责人支持「子龙/妙玉」这类多人并列值:按顺序逐个作为接管候选。
|
||||||
const trimmed = (name ?? '').trim()
|
const pickSuccessors = (value: string | null | undefined) => {
|
||||||
if (!trimmed || normalizeOwnerName(trimmed) === ownerKey) return null
|
const successors: Array<{ name: string; departed: boolean }> = []
|
||||||
return {
|
const seen = new Set<string>()
|
||||||
name: trimmed,
|
for (const name of String(value ?? '').split('/').map((part) => part.trim()).filter(Boolean)) {
|
||||||
departed: index.get(normalizeOwnerName(trimmed))?.sourceStatus === 'DIMISSIONED',
|
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 departmentOwners = pickSuccessors(entry.departmentOwner)
|
||||||
const generalOwner = pickSuccessor(entry.generalOwner)
|
const generalOwners = pickSuccessors(entry.generalOwner)
|
||||||
// 接管链:部门现任负责人在职 → 直接接管;否则交给总负责人(总负责人也离职则
|
// 接管链:部门现任负责人在职 → 直接接管;否则交给总负责人(总负责人也离职则
|
||||||
// 带标注展示,不再上抛);两者都缺位时退回展示部门负责人(如有)。
|
// 带标注展示,不再上抛);两者都缺位时退回展示部门负责人(如有)。
|
||||||
const successor = (departmentOwner && !departmentOwner.departed ? departmentOwner : null)
|
const successor = departmentOwners.find((item) => !item.departed)
|
||||||
?? generalOwner
|
?? generalOwners.find((item) => !item.departed)
|
||||||
?? departmentOwner
|
?? generalOwners[0]
|
||||||
|
?? departmentOwners[0]
|
||||||
|
?? null
|
||||||
if (!successor) {
|
if (!successor) {
|
||||||
return { status: '已离职', display: `${raw}(已离职)`, successor: '', successorDeparted: false }
|
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 () => {
|
it('董事长不参与负责人计算并由多部门归属中的中心总监接管', async () => {
|
||||||
const { repository, service } = setup()
|
const { repository, service } = setup()
|
||||||
repository.listDirectoryContext.mockResolvedValue({
|
repository.listDirectoryContext.mockResolvedValue({
|
||||||
|
|||||||
@ -85,6 +85,11 @@ function nameStatus(snapshot: DirectorySnapshot, name: string): 'employed' | 'di
|
|||||||
return records.some((record) => record.sourceStatus === 'EMPLOYED') ? 'employed' : 'dimissioned'
|
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。 */
|
/** 沿钉钉直属领导链上溯,返回第一位在职领导的名字;走不通返回 null。 */
|
||||||
function firstEmployedManager(snapshot: DirectorySnapshot, name: string): string | null {
|
function firstEmployedManager(snapshot: DirectorySnapshot, name: string): string | null {
|
||||||
const records = snapshot.byName.get(name.trim()) ?? []
|
const records = snapshot.byName.get(name.trim()) ?? []
|
||||||
@ -164,17 +169,31 @@ async function ensureCache(): Promise<ResolutionCache> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!resolved && meta?.opsDeptHead) {
|
if (!resolved && meta?.opsDeptHead) {
|
||||||
const status = nameStatus(directory, meta.opsDeptHead)
|
// 负责人字段支持「子龙/妙玉」这类多人并列值:按顺序逐个尝试。
|
||||||
if (status === 'employed') resolved = { name: meta.opsDeptHead, basis: 'dept_head' }
|
for (const name of splitOwnerNames(meta.opsDeptHead)) {
|
||||||
else if (status === 'dimissioned') {
|
const status = nameStatus(directory, name)
|
||||||
const managerName = firstEmployedManager(directory, meta.opsDeptHead)
|
if (status === 'employed') {
|
||||||
resolved = managerName ? { name: managerName, basis: 'director' } : null
|
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) {
|
if (!resolved && meta?.opsDirector) {
|
||||||
const status = nameStatus(directory, meta.opsDirector)
|
for (const name of splitOwnerNames(meta.opsDirector)) {
|
||||||
if (status === 'employed') resolved = { name: meta.opsDirector, basis: 'director' }
|
const status = nameStatus(directory, name)
|
||||||
|
if (status === 'employed') {
|
||||||
|
resolved = { name, basis: 'director' }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolution.set(spu, resolved ?? { name: '', basis: 'unresolved' })
|
resolution.set(spu, resolved ?? { name: '', basis: 'unresolved' })
|
||||||
|
|||||||
@ -371,12 +371,12 @@ export class SupplyChainManagementOwnerConfigService {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const findScopedRoleManager = (
|
const listScopedRoleManagerCandidates = (
|
||||||
targetDepartmentId: string | null,
|
targetDepartmentId: string | null,
|
||||||
rolePriority: Map<string, number>,
|
rolePriority: Map<string, number>,
|
||||||
includeEmployeeDepartmentScopes = false,
|
includeEmployeeDepartmentScopes = false,
|
||||||
) => {
|
) => {
|
||||||
if (!targetDepartmentId) return null
|
if (!targetDepartmentId) return []
|
||||||
const targetAncestors = new Map<string, number>()
|
const targetAncestors = new Map<string, number>()
|
||||||
let currentId: string | null = targetDepartmentId
|
let currentId: string | null = targetDepartmentId
|
||||||
let distance = 0
|
let distance = 0
|
||||||
@ -426,21 +426,74 @@ export class SupplyChainManagementOwnerConfigService {
|
|||||||
memberOrder: role.memberOrder,
|
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]
|
const selected = candidates[0]
|
||||||
return selected
|
return selected
|
||||||
? { employeeId: selected.employee.id, name: selected.employee.name, departmentId: selected.departmentId }
|
? { employeeId: selected.employee.id, name: selected.employee.name, departmentId: selected.departmentId }
|
||||||
: null
|
: 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) => {
|
const findDepartmentSpecificManager = (targetDepartmentId: string | null) => {
|
||||||
if (!targetDepartmentId) return null
|
if (!targetDepartmentId) return null
|
||||||
const targetDepartment = departmentsById.get(targetDepartmentId)
|
const targetDepartment = departmentsById.get(targetDepartmentId)
|
||||||
@ -596,11 +649,18 @@ export class SupplyChainManagementOwnerConfigService {
|
|||||||
DEPARTMENT_OWNER_ROLE_PRIORITY,
|
DEPARTMENT_OWNER_ROLE_PRIORITY,
|
||||||
)
|
)
|
||||||
const departmentSpecificOwner = findDepartmentSpecificManager(departmentOwnerTargetId)
|
const departmentSpecificOwner = findDepartmentSpecificManager(departmentOwnerTargetId)
|
||||||
const scopedGeneralOwner = findScopedRoleManager(
|
const scopedGeneralOwners = findScopedRoleManagers(
|
||||||
department?.id ?? null,
|
department?.id ?? null,
|
||||||
GENERAL_OWNER_ROLE_PRIORITY,
|
GENERAL_OWNER_ROLE_PRIORITY,
|
||||||
true,
|
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 existingDepartmentOwner = findManager(organizationLevels.departmentId, 'department')
|
||||||
const resolvedDepartmentOwner = departmentSpecificOwner ?? scopedDepartmentOwner ?? findCategoryDepartmentSupervisor(
|
const resolvedDepartmentOwner = departmentSpecificOwner ?? scopedDepartmentOwner ?? findCategoryDepartmentSupervisor(
|
||||||
organizationLevels.categoryDepartmentId,
|
organizationLevels.categoryDepartmentId,
|
||||||
@ -611,7 +671,10 @@ export class SupplyChainManagementOwnerConfigService {
|
|||||||
?? findManager(organizationLevels.profitCenterDepartmentId, 'profitCenter')
|
?? findManager(organizationLevels.profitCenterDepartmentId, 'profitCenter')
|
||||||
?? existingDepartmentOwner
|
?? existingDepartmentOwner
|
||||||
?? resolvedDepartmentOwner
|
?? resolvedDepartmentOwner
|
||||||
const departmentOwner = resolvedDepartmentOwner ?? generalOwner
|
// 部门负责人保持单人:优先部门主管/经理;仅当全都没有时才回退到第一位中心总监(而不是并列值)。
|
||||||
|
const departmentOwner = resolvedDepartmentOwner
|
||||||
|
?? (scopedGeneralOwners[0] ?? null)
|
||||||
|
?? generalOwner
|
||||||
return {
|
return {
|
||||||
employeeId: employee.id,
|
employeeId: employee.id,
|
||||||
name: employee.name,
|
name: employee.name,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user