feat(owners): include growth center employees in business owner options
This commit is contained in:
parent
a917949a5e
commit
fc50035d65
69
furniture-3d-system/src/lib/business-owner-options.ts
Normal file
69
furniture-3d-system/src/lib/business-owner-options.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { isExternalIntakeUser } from './non-integrated-external-entry-permissions'
|
||||
|
||||
export type IntakeOwnerDepartmentScope =
|
||||
| 'productResearch'
|
||||
| 'merchandisePlanning'
|
||||
| 'brandPlanning'
|
||||
| 'spaceVisual'
|
||||
| 'operation'
|
||||
| 'purchasing'
|
||||
| 'externalIntake'
|
||||
|
||||
export type BusinessOwnerRole = 'OPERATIONS' | 'PURCHASING'
|
||||
|
||||
export type BusinessOwnerOption = {
|
||||
id: string
|
||||
name: string
|
||||
departmentName: string
|
||||
}
|
||||
|
||||
type OwnerCandidateAccount = {
|
||||
status: string
|
||||
businessRoles?: readonly string[] | null
|
||||
supplierCode?: string | null
|
||||
employeeDirectory?: { sourceStatus?: string | null } | null
|
||||
}
|
||||
|
||||
export function isActiveInternalOwnerCandidate(user: OwnerCandidateAccount) {
|
||||
return user.status === 'ACTIVE'
|
||||
&& user.employeeDirectory?.sourceStatus !== 'DIMISSIONED'
|
||||
&& !isExternalIntakeUser(user)
|
||||
&& !user.businessRoles?.includes('SUPPLIER')
|
||||
&& !user.supplierCode
|
||||
}
|
||||
|
||||
export function belongsToGrowthCenter(departmentPaths: readonly string[]) {
|
||||
return departmentPaths.some((path) => {
|
||||
const parts = path.split('>').map((part) => part.replace(/\s+/g, '')).filter(Boolean)
|
||||
return parts.some((part, index) => part === '爆品增长中心'
|
||||
&& parts[index - 1] === '江苏简尚品牌管理有限公司')
|
||||
})
|
||||
}
|
||||
|
||||
export function matchesIntakeOwnerDepartmentScope(
|
||||
departmentPaths: readonly string[],
|
||||
scope: Exclude<IntakeOwnerDepartmentScope, 'externalIntake'>,
|
||||
) {
|
||||
if (belongsToGrowthCenter(departmentPaths) || scope === 'merchandisePlanning') return true
|
||||
|
||||
return departmentPaths.some((value) => {
|
||||
const text = value.replace(/\s+/g, '')
|
||||
if (scope === 'productResearch') {
|
||||
return text.includes('产品研发部')
|
||||
|| (text.includes('供应链中心') && /外观设计部|结构设计部/.test(text))
|
||||
}
|
||||
if (scope === 'spaceVisual') {
|
||||
return text.includes('品牌中心') && text.includes('空间视觉部')
|
||||
}
|
||||
if (scope === 'brandPlanning') {
|
||||
return text.includes('品牌中心')
|
||||
&& !text.includes('商品企划部')
|
||||
&& !text.includes('空间视觉部')
|
||||
}
|
||||
if (scope === 'operation') {
|
||||
return text.includes('运营中心')
|
||||
|| text.includes('江苏简尚品牌管理有限公司>分销部')
|
||||
}
|
||||
return scope === 'purchasing' && text.includes('供应链中心')
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const ownerCandidateSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
businessRoles: true,
|
||||
supplierCode: true,
|
||||
department: { select: { name: true, fullPath: true } },
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
department: { select: { name: true, fullPath: true } },
|
||||
},
|
||||
},
|
||||
employeeDirectory: {
|
||||
select: {
|
||||
sourceStatus: true,
|
||||
primaryDepartmentExternalId: true,
|
||||
departmentExternalIds: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.UserSelect
|
||||
|
||||
export type OwnerCandidateUser = Prisma.UserGetPayload<{ select: typeof ownerCandidateSelect }>
|
||||
|
||||
type OwnerCandidateDepartment = { externalId: string | null; name: string; fullPath: string }
|
||||
|
||||
export const businessOwnerOptionsRepository = {
|
||||
listActiveUsers(search = '') {
|
||||
return prisma.user.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
...(search ? {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' as const } },
|
||||
{ email: { contains: search, mode: 'insensitive' as const } },
|
||||
],
|
||||
} : {}),
|
||||
},
|
||||
select: ownerCandidateSelect,
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
},
|
||||
|
||||
findActiveUserById(id: string) {
|
||||
return prisma.user.findFirst({
|
||||
where: { id, status: 'ACTIVE' },
|
||||
select: ownerCandidateSelect,
|
||||
})
|
||||
},
|
||||
|
||||
findDepartmentsByExternalIds(externalIds: string[]): Promise<OwnerCandidateDepartment[]> {
|
||||
if (!externalIds.length) return Promise.resolve([])
|
||||
return prisma.department.findMany({
|
||||
where: { externalId: { in: externalIds } },
|
||||
select: { externalId: true, name: true, fullPath: true },
|
||||
})
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { IntakeOwnerDepartmentScope } from '@/lib/business-owner-options'
|
||||
import type { OwnerCandidateUser } from '@/server/repositories/business-owner-options.repository'
|
||||
import {
|
||||
findBusinessOwnerOptionByRole,
|
||||
listBusinessOwnerOptionsByRole,
|
||||
listIntakeOwnerCandidateNames,
|
||||
} from '@/server/services/business-owner-options.service'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
user: { findMany: jest.fn(), findFirst: jest.fn() },
|
||||
department: { findMany: jest.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
const findMany = prisma.user.findMany as jest.Mock
|
||||
const findFirst = prisma.user.findFirst as jest.Mock
|
||||
const findDepartments = prisma.department.findMany as jest.Mock
|
||||
const company = '江苏简尚品牌管理有限公司'
|
||||
const growthPath = `${company} > 爆品增长中心`
|
||||
const growthDepartment = { externalId: 'growth', name: '爆品增长中心', fullPath: growthPath }
|
||||
const planningDepartment = { externalId: 'planning', name: '产品企划部', fullPath: `${growthPath} > 产品企划部` }
|
||||
const internalScopes: Array<Exclude<IntakeOwnerDepartmentScope, 'externalIntake'>> = [
|
||||
'productResearch', 'merchandisePlanning', 'brandPlanning', 'spaceVisual', 'operation', 'purchasing',
|
||||
]
|
||||
|
||||
function candidate(overrides: Partial<OwnerCandidateUser> = {}): OwnerCandidateUser {
|
||||
return {
|
||||
id: 'chengji',
|
||||
name: '成吉',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: ['GENERAL'],
|
||||
supplierCode: null,
|
||||
department: { name: '杭州项目组', fullPath: `${company} > 杭州项目组` },
|
||||
team: null,
|
||||
employeeDirectory: {
|
||||
sourceStatus: 'EMPLOYED',
|
||||
primaryDepartmentExternalId: 'hangzhou',
|
||||
departmentExternalIds: ['hangzhou', 'growth'],
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function growthUsers() {
|
||||
return [candidate(), candidate({
|
||||
id: 'xiaoxing', name: '啸行', department: planningDepartment,
|
||||
employeeDirectory: {
|
||||
sourceStatus: 'EMPLOYED', primaryDepartmentExternalId: 'planning', departmentExternalIds: ['planning'],
|
||||
},
|
||||
})]
|
||||
}
|
||||
|
||||
describe('shared business owner candidates', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
findMany.mockResolvedValue(growthUsers())
|
||||
findFirst.mockResolvedValue(candidate())
|
||||
findDepartments.mockResolvedValue([growthDepartment, planningDepartment])
|
||||
})
|
||||
|
||||
it.each(internalScopes)('includes multi-department and child-department employees in %s', async (scope) => {
|
||||
await expect(listIntakeOwnerCandidateNames(scope)).resolves.toEqual(['成吉', '啸行'])
|
||||
expect(findDepartments).toHaveBeenCalledTimes(1)
|
||||
expect(findDepartments).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { externalId: { in: ['hangzhou', 'growth', 'planning'] } },
|
||||
}))
|
||||
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { status: 'ACTIVE' } }))
|
||||
})
|
||||
|
||||
it('resolves the primary department and legacy encoded department memberships', async () => {
|
||||
findMany.mockResolvedValue([
|
||||
candidate({ employeeDirectory: { sourceStatus: 'EMPLOYED', primaryDepartmentExternalId: 'growth', departmentExternalIds: null } }),
|
||||
candidate({ id: 'encoded', name: '编码部门人员', employeeDirectory: {
|
||||
sourceStatus: 'EMPLOYED', primaryDepartmentExternalId: null, departmentExternalIds: '["hangzhou","planning"]',
|
||||
} }),
|
||||
])
|
||||
await expect(listIntakeOwnerCandidateNames('purchasing')).resolves.toEqual(['成吉', '编码部门人员'])
|
||||
})
|
||||
|
||||
it('accepts the account or team department when directory membership is unavailable', async () => {
|
||||
findMany.mockResolvedValue([
|
||||
candidate({ department: growthDepartment, employeeDirectory: null }),
|
||||
candidate({ id: 'team-user', name: '小组人员', employeeDirectory: null, team: { name: '项目组', department: planningDepartment } }),
|
||||
])
|
||||
await expect(listIntakeOwnerCandidateNames('productResearch')).resolves.toEqual(['成吉', '小组人员'])
|
||||
expect(findDepartments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('excludes departed, inactive, suspended, supplier and external accounts from internal candidates', async () => {
|
||||
findMany.mockResolvedValue([
|
||||
candidate(),
|
||||
candidate({ name: '已离职', employeeDirectory: { sourceStatus: 'DIMISSIONED', primaryDepartmentExternalId: 'growth', departmentExternalIds: ['growth'] } }),
|
||||
candidate({ name: '停用', status: 'INACTIVE' }),
|
||||
candidate({ name: '暂停', status: 'SUSPENDED' }),
|
||||
candidate({ name: '供应商', businessRoles: ['SUPPLIER', 'OPERATIONS'] }),
|
||||
candidate({ name: '供应商关联', supplierCode: 'N123' }),
|
||||
candidate({ name: '外部录入', businessRoles: ['EXTERNAL_INTAKE'] }),
|
||||
])
|
||||
await expect(listIntakeOwnerCandidateNames('operation')).resolves.toEqual(['成吉'])
|
||||
await expect(listBusinessOwnerOptionsByRole('OPERATIONS')).resolves.toEqual([
|
||||
{ id: 'chengji', name: '成吉', departmentName: '杭州项目组' },
|
||||
])
|
||||
})
|
||||
|
||||
it('matches the exact company and department rather than a similar department name', async () => {
|
||||
findMany.mockResolvedValue([
|
||||
candidate({ employeeDirectory: null, department: { name: '爆品增长中心', fullPath: '另一家公司 > 爆品增长中心' } }),
|
||||
candidate({ name: '相似部门', employeeDirectory: null, department: { name: '爆品增长中心助理', fullPath: `${company} > 爆品增长中心助理` } }),
|
||||
candidate({ name: '正确部门', employeeDirectory: null, department: { name: '爆品增长中心', fullPath: `${company}>爆品增长中心>新项目组` } }),
|
||||
])
|
||||
await expect(listIntakeOwnerCandidateNames('operation')).resolves.toEqual(['正确部门'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['productResearch', `${company} > 供应链中心 > 结构设计部`],
|
||||
['operation', `${company} > 运营中心A`],
|
||||
['operation', `${company} > 分销部`],
|
||||
['purchasing', `${company} > 供应链中心 > 采购部`],
|
||||
['brandPlanning', `${company} > 品牌中心 > 策划部`],
|
||||
['spaceVisual', `${company} > 品牌中心 > 空间视觉部`],
|
||||
] as const)('keeps existing %s department candidates', async (scope, fullPath) => {
|
||||
findMany.mockResolvedValue([candidate({ name: '原候选人', employeeDirectory: null, department: { name: '原部门', fullPath } })])
|
||||
await expect(listIntakeOwnerCandidateNames(scope)).resolves.toEqual(['原候选人'])
|
||||
})
|
||||
|
||||
it('keeps merchandise planning available to every active internal employee and deduplicates names', async () => {
|
||||
findMany.mockResolvedValue([candidate({ employeeDirectory: null }), candidate({ employeeDirectory: null })])
|
||||
await expect(listIntakeOwnerCandidateNames('merchandisePlanning')).resolves.toEqual(['成吉'])
|
||||
})
|
||||
|
||||
it('keeps the external intake candidate group separate', async () => {
|
||||
findMany.mockResolvedValue([...growthUsers(), candidate({ name: '外部录入', businessRoles: ['EXTERNAL_INTAKE'] })])
|
||||
await expect(listIntakeOwnerCandidateNames('externalIntake')).resolves.toEqual(['外部录入'])
|
||||
expect(findDepartments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['OPERATIONS', 'PURCHASING'] as const)('adds growth employees alongside existing %s role candidates', async (role) => {
|
||||
findMany.mockResolvedValue([...growthUsers(), candidate({
|
||||
id: 'existing', name: '原岗位人员', businessRoles: [role], employeeDirectory: null,
|
||||
}), candidate({ id: 'unrelated', name: '其他人员', employeeDirectory: null })])
|
||||
const options = await listBusinessOwnerOptionsByRole(role, ' 成 ')
|
||||
expect(options.map((option) => option.name)).toEqual(['成吉', '啸行', '原岗位人员'])
|
||||
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ where: {
|
||||
status: 'ACTIVE', OR: [
|
||||
{ name: { contains: '成', mode: 'insensitive' } },
|
||||
{ email: { contains: '成', mode: 'insensitive' } },
|
||||
],
|
||||
} }))
|
||||
})
|
||||
|
||||
it('accepts the same multi-department growth employee during selection validation', async () => {
|
||||
await expect(findBusinessOwnerOptionByRole(' chengji ', 'OPERATIONS')).resolves.toEqual({
|
||||
id: 'chengji', name: '成吉', departmentName: '杭州项目组',
|
||||
})
|
||||
expect(findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'chengji', status: 'ACTIVE' } }))
|
||||
})
|
||||
|
||||
it('rejects unrelated employees and departed employees during selection validation', async () => {
|
||||
findFirst.mockResolvedValue(candidate({ employeeDirectory: null }))
|
||||
await expect(findBusinessOwnerOptionByRole('chengji', 'OPERATIONS')).resolves.toBeNull()
|
||||
findFirst.mockResolvedValue(candidate({ employeeDirectory: {
|
||||
sourceStatus: 'DIMISSIONED', primaryDepartmentExternalId: 'growth', departmentExternalIds: ['growth'],
|
||||
} }))
|
||||
await expect(findBusinessOwnerOptionByRole('chengji', 'OPERATIONS')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('uses updated department membership on the next request', async () => {
|
||||
await expect(listIntakeOwnerCandidateNames('purchasing')).resolves.toEqual(['成吉', '啸行'])
|
||||
findMany.mockResolvedValue([candidate({ employeeDirectory: null })])
|
||||
await expect(listIntakeOwnerCandidateNames('purchasing')).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
@ -7,6 +7,7 @@ jest.mock('@/lib/prisma', () => ({
|
||||
user: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
department: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}))
|
||||
|
||||
@ -32,6 +33,7 @@ describe('intake-owner-options.service', () => {
|
||||
mockedFindMany.mockResolvedValue([
|
||||
{
|
||||
name: '企划A',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: [],
|
||||
department: {
|
||||
name: '企划部',
|
||||
@ -41,6 +43,7 @@ describe('intake-owner-options.service', () => {
|
||||
},
|
||||
{
|
||||
name: '企划B',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: [],
|
||||
department: {
|
||||
name: '桌类企划',
|
||||
@ -50,6 +53,7 @@ describe('intake-owner-options.service', () => {
|
||||
},
|
||||
{
|
||||
name: '企划C',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: ['PLANNING'],
|
||||
department: {
|
||||
name: '企划部',
|
||||
@ -59,6 +63,7 @@ describe('intake-owner-options.service', () => {
|
||||
},
|
||||
{
|
||||
name: '策划A',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: [],
|
||||
department: {
|
||||
name: '策划部',
|
||||
@ -68,6 +73,7 @@ describe('intake-owner-options.service', () => {
|
||||
},
|
||||
{
|
||||
name: '设计A',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: [],
|
||||
department: {
|
||||
name: '产品研发部',
|
||||
@ -86,6 +92,7 @@ describe('intake-owner-options.service', () => {
|
||||
mockedFindMany.mockResolvedValue([
|
||||
{
|
||||
name: '企划旧',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: [],
|
||||
department: {
|
||||
name: '商品企划部',
|
||||
@ -104,6 +111,7 @@ describe('intake-owner-options.service', () => {
|
||||
mockedFindMany.mockResolvedValue([
|
||||
{
|
||||
name: '策划A',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: [],
|
||||
department: {
|
||||
name: '策划部',
|
||||
@ -113,6 +121,7 @@ describe('intake-owner-options.service', () => {
|
||||
},
|
||||
{
|
||||
name: '市场企划A',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: [],
|
||||
department: {
|
||||
name: '市场企划部',
|
||||
@ -134,12 +143,14 @@ describe('intake-owner-options.service', () => {
|
||||
mockedFindMany.mockResolvedValue([
|
||||
{
|
||||
name: '外部A',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: ['EXTERNAL_INTAKE'],
|
||||
department: null,
|
||||
team: null,
|
||||
},
|
||||
{
|
||||
name: '内部A',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: [],
|
||||
department: {
|
||||
name: '产品研发部',
|
||||
@ -158,12 +169,14 @@ describe('intake-owner-options.service', () => {
|
||||
mockedFindMany.mockResolvedValue([
|
||||
{
|
||||
name: '兰考华兰家具',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: ['SUPPLIER'],
|
||||
department: null,
|
||||
team: null,
|
||||
},
|
||||
{
|
||||
name: '内部A',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: [],
|
||||
department: {
|
||||
name: '产品研发部',
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const mockGetUserAccess = jest.fn()
|
||||
const mockGetUserById = jest.fn()
|
||||
const mockFindBusinessOwnerOption = jest.fn()
|
||||
const mockListBusinessOwnerOptions = jest.fn()
|
||||
|
||||
jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({
|
||||
supplyChainPermissionSettingsService: {
|
||||
@ -7,11 +8,9 @@ jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/user.service', () => ({
|
||||
userService: {
|
||||
getById: (...args: unknown[]) => mockGetUserById(...args),
|
||||
listAll: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
jest.mock('@/server/services/business-owner-options.service', () => ({
|
||||
findBusinessOwnerOptionByRole: (...args: unknown[]) => mockFindBusinessOwnerOption(...args),
|
||||
listBusinessOwnerOptionsByRole: (...args: unknown[]) => mockListBusinessOwnerOptions(...args),
|
||||
}))
|
||||
|
||||
const mockSystemConfigGet = jest.fn()
|
||||
@ -38,7 +37,8 @@ describe('SupplierRecommendedProductsService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockGetUserAccess.mockResolvedValue({ view: true, download: true, edit: true })
|
||||
mockGetUserById.mockResolvedValue({ id: 'operator-2', name: '运营乙', status: 'ACTIVE', businessRoles: ['OPERATIONS'] })
|
||||
mockFindBusinessOwnerOption.mockResolvedValue({ id: 'operator-2', name: '运营乙', departmentName: '运营中心' })
|
||||
mockListBusinessOwnerOptions.mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -224,7 +224,7 @@ describe('SupplierRecommendedProductsService', () => {
|
||||
await expect(service.updateSelection({ ids: ['recommended-1', 'recommended-2'], action: 'select', selectedOperatorId: 'operator-2' }, operatorUser)).resolves.toEqual({
|
||||
action: 'select', requestedCount: 2, updatedCount: 2,
|
||||
})
|
||||
expect(mockGetUserById).toHaveBeenCalledWith('operator-2')
|
||||
expect(mockFindBusinessOwnerOption).toHaveBeenCalledWith('operator-2', 'OPERATIONS')
|
||||
expect(updateSelection).toHaveBeenCalledWith(['recommended-1', 'recommended-2'], true, operatorUser, { id: 'operator-2', name: '运营乙' })
|
||||
})
|
||||
|
||||
@ -237,7 +237,7 @@ describe('SupplierRecommendedProductsService', () => {
|
||||
await expect(service.updateSelection({ ids: ['recommended-1'], action: 'select', selectedOperatorId: 'admin-1' }, administrator)).resolves.toEqual({
|
||||
action: 'select', requestedCount: 1, updatedCount: 1,
|
||||
})
|
||||
expect(mockGetUserById).not.toHaveBeenCalled()
|
||||
expect(mockFindBusinessOwnerOption).not.toHaveBeenCalled()
|
||||
expect(updateSelection).toHaveBeenCalledWith(['recommended-1'], true, administrator, { id: 'admin-1', name: '子龙' })
|
||||
})
|
||||
|
||||
@ -245,7 +245,7 @@ describe('SupplierRecommendedProductsService', () => {
|
||||
const repository = { updateSelection: jest.fn() } as unknown as SupplierRecommendedProductsRepository
|
||||
const service = new SupplierRecommendedProductsService(repository)
|
||||
const administrator = { id: 'admin-1', name: '子龙', role: 'ADMIN' as const, businessRoles: [] }
|
||||
mockGetUserById.mockResolvedValue({ id: 'user-2', name: '普通员工', status: 'ACTIVE', businessRoles: [] })
|
||||
mockFindBusinessOwnerOption.mockResolvedValue(null)
|
||||
|
||||
await expect(service.updateSelection({ ids: ['recommended-1'], action: 'select', selectedOperatorId: 'user-2' }, administrator)).rejects.toThrow('请选择有效的运营人员')
|
||||
})
|
||||
@ -258,6 +258,36 @@ describe('SupplierRecommendedProductsService', () => {
|
||||
await expect(service.updateSelection({ ids: ['recommended-1'], action: 'select' }, operatorUser)).rejects.toThrow('请选择对应的运营人员')
|
||||
})
|
||||
|
||||
it('uses the shared candidate rules for purchaser and selection operator lists', async () => {
|
||||
const service = new SupplierRecommendedProductsService()
|
||||
const administrator = { id: 'admin-1', name: '子龙', role: 'ADMIN' as const }
|
||||
const options = [{ id: 'chengji', name: '成吉', departmentName: '杭州项目组' }]
|
||||
mockListBusinessOwnerOptions.mockResolvedValue(options)
|
||||
await expect(service.listPurchaserOptions()).resolves.toEqual(options)
|
||||
expect(mockListBusinessOwnerOptions).toHaveBeenCalledWith('PURCHASING')
|
||||
await expect(service.listSelectionOperators(' 成吉 ', administrator)).resolves.toEqual(options)
|
||||
expect(mockListBusinessOwnerOptions).toHaveBeenCalledWith('OPERATIONS', '成吉')
|
||||
})
|
||||
|
||||
it('persists a growth-center employee selected by an authorized administrator', async () => {
|
||||
const updateSelection = jest.fn().mockResolvedValue({ foundCount: 1, updatedCount: 1 })
|
||||
const service = new SupplierRecommendedProductsService({ updateSelection } as unknown as SupplierRecommendedProductsRepository)
|
||||
const administrator = { id: 'admin-1', name: '子龙', role: 'ADMIN' as const }
|
||||
mockFindBusinessOwnerOption.mockResolvedValue({ id: 'chengji', name: '成吉', departmentName: '杭州项目组' })
|
||||
await service.updateSelection({ ids: ['recommended-1'], action: 'select', selectedOperatorId: 'chengji' }, administrator)
|
||||
expect(updateSelection).toHaveBeenCalledWith(['recommended-1'], true, administrator, { id: 'chengji', name: '成吉' })
|
||||
})
|
||||
|
||||
it('requires existing selection permission even when the person is a valid growth-center candidate', async () => {
|
||||
const updateSelection = jest.fn()
|
||||
const service = new SupplierRecommendedProductsService({ updateSelection } as unknown as SupplierRecommendedProductsRepository)
|
||||
const growthActor = { id: 'chengji', name: '成吉', role: 'USER' as const, businessRoles: ['GENERAL'] as const }
|
||||
await expect(service.updateSelection({ ids: ['recommended-1'], action: 'select', selectedOperatorId: 'chengji' }, growthActor))
|
||||
.rejects.toThrow('只有运营或管理员可以执行选款操作')
|
||||
expect(updateSelection).not.toHaveBeenCalled()
|
||||
expect(mockFindBusinessOwnerOption).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('采购看板PK榜', () => {
|
||||
const purchasingUser = { ...USER, businessRoles: ['PURCHASING'] as const }
|
||||
const administrator = { id: 'admin-1', name: '子龙', role: 'ADMIN' as const, businessRoles: [] }
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
import {
|
||||
belongsToGrowthCenter,
|
||||
isActiveInternalOwnerCandidate,
|
||||
matchesIntakeOwnerDepartmentScope,
|
||||
type BusinessOwnerOption,
|
||||
type BusinessOwnerRole,
|
||||
type IntakeOwnerDepartmentScope,
|
||||
} from '@/lib/business-owner-options'
|
||||
import { parseDepartmentExternalIds } from '@/lib/hr-sync/primary-department'
|
||||
import { isExternalIntakeUser } from '@/lib/non-integrated-external-entry-permissions'
|
||||
import {
|
||||
businessOwnerOptionsRepository,
|
||||
type OwnerCandidateUser,
|
||||
} from '@/server/repositories/business-owner-options.repository'
|
||||
|
||||
function getDepartmentExternalIds(user: OwnerCandidateUser) {
|
||||
return [...new Set([
|
||||
user.employeeDirectory?.primaryDepartmentExternalId,
|
||||
...parseDepartmentExternalIds(user.employeeDirectory?.departmentExternalIds),
|
||||
].filter((value): value is string => Boolean(value)))]
|
||||
}
|
||||
|
||||
async function resolveCandidateDepartments(users: OwnerCandidateUser[]) {
|
||||
const externalIds = [...new Set(users.flatMap(getDepartmentExternalIds))]
|
||||
const departments = await businessOwnerOptionsRepository.findDepartmentsByExternalIds(externalIds)
|
||||
const departmentsByExternalId = new Map(departments.map((department) => [department.externalId, department] as const))
|
||||
|
||||
return users.map((user) => ({
|
||||
user,
|
||||
departmentPaths: [
|
||||
user.department?.name,
|
||||
user.department?.fullPath,
|
||||
user.team?.name,
|
||||
user.team?.department?.name,
|
||||
user.team?.department?.fullPath,
|
||||
...getDepartmentExternalIds(user).flatMap((externalId) => {
|
||||
const department = departmentsByExternalId.get(externalId)
|
||||
return [department?.name, department?.fullPath]
|
||||
}),
|
||||
].filter((path): path is string => Boolean(path)),
|
||||
}))
|
||||
}
|
||||
|
||||
function toBusinessOwnerOption(user: OwnerCandidateUser): BusinessOwnerOption {
|
||||
return { id: user.id, name: user.name, departmentName: user.department?.name ?? '' }
|
||||
}
|
||||
|
||||
export async function listIntakeOwnerCandidateNames(scope: IntakeOwnerDepartmentScope) {
|
||||
const users = await businessOwnerOptionsRepository.listActiveUsers()
|
||||
if (scope === 'externalIntake') {
|
||||
return [...new Set(users.filter(isExternalIntakeUser).map((user) => user.name.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
const candidates = await resolveCandidateDepartments(users.filter(isActiveInternalOwnerCandidate))
|
||||
return [...new Set(candidates
|
||||
.filter(({ departmentPaths }) => matchesIntakeOwnerDepartmentScope(departmentPaths, scope))
|
||||
.map(({ user }) => user.name.trim())
|
||||
.filter(Boolean))]
|
||||
}
|
||||
|
||||
export async function listBusinessOwnerOptionsByRole(role: BusinessOwnerRole, search = '') {
|
||||
const users = await businessOwnerOptionsRepository.listActiveUsers(search.trim().slice(0, 100))
|
||||
const candidates = await resolveCandidateDepartments(users.filter(isActiveInternalOwnerCandidate))
|
||||
return candidates
|
||||
.filter(({ user, departmentPaths }) => user.businessRoles.includes(role) || belongsToGrowthCenter(departmentPaths))
|
||||
.map(({ user }) => toBusinessOwnerOption(user))
|
||||
}
|
||||
|
||||
export async function findBusinessOwnerOptionByRole(id: string, role: BusinessOwnerRole) {
|
||||
const user = await businessOwnerOptionsRepository.findActiveUserById(id.trim())
|
||||
if (!user || !isActiveInternalOwnerCandidate(user)) return null
|
||||
const [{ departmentPaths }] = await resolveCandidateDepartments([user])
|
||||
return user.businessRoles.includes(role) || belongsToGrowthCenter(departmentPaths)
|
||||
? toBusinessOwnerOption(user)
|
||||
: null
|
||||
}
|
||||
@ -1,146 +1,16 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isExternalIntakeUser } from "@/lib/non-integrated-external-entry-permissions";
|
||||
import type { IntakeOwnerDepartmentScope } from '@/lib/business-owner-options'
|
||||
import {
|
||||
getExternalIntakeSelfNameOptions,
|
||||
type ExternalIntakeOwnerUser,
|
||||
} from "@/lib/non-integrated-external-owner-fields";
|
||||
|
||||
type IntakeOwnerDepartmentScope =
|
||||
| "productResearch"
|
||||
| "merchandisePlanning"
|
||||
| "brandPlanning"
|
||||
| "spaceVisual"
|
||||
| "operation"
|
||||
| "purchasing"
|
||||
| "externalIntake";
|
||||
|
||||
function getUniqueNames(values: Array<string | null | undefined>) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((value) => value?.trim() ?? "")
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDepartmentText(value: string | null | undefined) {
|
||||
return value?.replace(/\s+/g, "") ?? "";
|
||||
}
|
||||
|
||||
function departmentTextMatchesScope(
|
||||
values: Array<string | null | undefined>,
|
||||
scope: IntakeOwnerDepartmentScope,
|
||||
) {
|
||||
return values.some((value) => {
|
||||
const text = normalizeDepartmentText(value);
|
||||
if (!text) return false;
|
||||
|
||||
if (scope === "productResearch") {
|
||||
return (
|
||||
text.includes("产品研发部") ||
|
||||
(text.includes("供应链中心") &&
|
||||
/外观设计部|结构设计部/.test(text))
|
||||
);
|
||||
}
|
||||
|
||||
if (scope === "spaceVisual") {
|
||||
return text.includes("品牌中心") && text.includes("空间视觉部");
|
||||
}
|
||||
|
||||
if (scope === "brandPlanning") {
|
||||
return (
|
||||
text.includes("品牌中心") &&
|
||||
!text.includes("商品企划部") &&
|
||||
!text.includes("空间视觉部")
|
||||
);
|
||||
}
|
||||
|
||||
if (scope === "operation") {
|
||||
return (
|
||||
text.includes("运营中心") ||
|
||||
text.includes("江苏简尚品牌管理有限公司>分销部")
|
||||
);
|
||||
}
|
||||
|
||||
if (scope === "purchasing") {
|
||||
return text.includes("供应链中心");
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} from '@/lib/non-integrated-external-owner-fields'
|
||||
import { listIntakeOwnerCandidateNames } from '@/server/services/business-owner-options.service'
|
||||
|
||||
export async function listIntakeOwnerOptionNamesByDepartmentScope(
|
||||
scope: IntakeOwnerDepartmentScope,
|
||||
currentUser?: ExternalIntakeOwnerUser,
|
||||
) {
|
||||
const externalSelfOptions = getExternalIntakeSelfNameOptions(currentUser);
|
||||
if (externalSelfOptions.length > 0) {
|
||||
return externalSelfOptions;
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
businessRoles: true,
|
||||
department: {
|
||||
select: {
|
||||
name: true,
|
||||
fullPath: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
department: {
|
||||
select: {
|
||||
name: true,
|
||||
fullPath: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
const scopedNames = users
|
||||
.filter((user) => {
|
||||
const isOptionExternalIntakeUser = isExternalIntakeUser(user);
|
||||
if (scope === "externalIntake") {
|
||||
return isOptionExternalIntakeUser;
|
||||
}
|
||||
|
||||
if (isOptionExternalIntakeUser) return false;
|
||||
|
||||
// 供应商自注册账号(SUPPLIER 角色)不是内部人员,
|
||||
// 不应出现在任何内部负责人候选里。
|
||||
if (user.businessRoles?.includes("SUPPLIER")) return false;
|
||||
|
||||
// 商品企划部已解散(2026-10 用户口径):商品企划不再按部门圈定候选人,
|
||||
// 直接开放为全部内部人员可搜索填写;外部人员与供应商已在上面排除。
|
||||
if (scope === "merchandisePlanning") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return departmentTextMatchesScope(
|
||||
[
|
||||
user.department?.name,
|
||||
user.department?.fullPath,
|
||||
user.team?.name,
|
||||
user.team?.department?.name,
|
||||
user.team?.department?.fullPath,
|
||||
],
|
||||
scope,
|
||||
);
|
||||
})
|
||||
.map((user) => user.name);
|
||||
|
||||
return getUniqueNames(scopedNames);
|
||||
const externalSelfOptions = getExternalIntakeSelfNameOptions(currentUser)
|
||||
return externalSelfOptions.length > 0
|
||||
? externalSelfOptions
|
||||
: listIntakeOwnerCandidateNames(scope)
|
||||
}
|
||||
|
||||
@ -19,7 +19,10 @@ import { supplierRecommendedProductsRepository, type SupplierRecommendedProductR
|
||||
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
|
||||
import { systemConfigService } from '@/server/services/system-config.service'
|
||||
import { hasBusinessRole, isAdmin } from '@/lib/role-scope'
|
||||
import { userService } from '@/server/services/user.service'
|
||||
import {
|
||||
findBusinessOwnerOptionByRole,
|
||||
listBusinessOwnerOptionsByRole,
|
||||
} from '@/server/services/business-owner-options.service'
|
||||
|
||||
const MAX_PAGE_SIZE = 200
|
||||
const MAX_BATCH_DELETE_SIZE = 200
|
||||
@ -282,22 +285,11 @@ export class SupplierRecommendedProductsService {
|
||||
async listSelectionOperators(search: unknown, user: AuthUser) {
|
||||
await this.requireSelectionAccess(user)
|
||||
const normalizedSearch = String(search ?? '').trim().slice(0, 100)
|
||||
const users = await userService.listAll({ search: normalizedSearch, businessRole: 'OPERATIONS', status: 'ACTIVE' })
|
||||
return users.map((operator) => ({
|
||||
id: operator.id,
|
||||
name: operator.name,
|
||||
departmentName: operator.department?.name ?? '',
|
||||
}))
|
||||
return listBusinessOwnerOptionsByRole('OPERATIONS', normalizedSearch)
|
||||
}
|
||||
|
||||
// 采购字段点选项:系统内 PURCHASING 业务角色的在职用户(与选款人用 OPERATIONS 角色同一个口径)。
|
||||
async listPurchaserOptions() {
|
||||
const users = await userService.listAll({ businessRole: 'PURCHASING', status: 'ACTIVE' })
|
||||
return users.map((purchaser) => ({
|
||||
id: purchaser.id,
|
||||
name: purchaser.name,
|
||||
departmentName: purchaser.department?.name ?? '',
|
||||
}))
|
||||
return listBusinessOwnerOptionsByRole('PURCHASING')
|
||||
}
|
||||
|
||||
private async requireAccess(user: AuthUser, action: 'view' | 'download' | 'edit') {
|
||||
@ -376,8 +368,8 @@ export class SupplierRecommendedProductsService {
|
||||
selectedOperator = { id: user.id, name: user.name }
|
||||
} else {
|
||||
try {
|
||||
const operator = await userService.getById(selectedOperatorId)
|
||||
if (operator.status !== 'ACTIVE' || !operator.businessRoles.includes('OPERATIONS')) {
|
||||
const operator = await findBusinessOwnerOptionByRole(selectedOperatorId, 'OPERATIONS')
|
||||
if (!operator) {
|
||||
throw new Error('invalid operator')
|
||||
}
|
||||
selectedOperator = { id: operator.id, name: operator.name }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user