feat(owners): include growth center employees in business owner options

This commit is contained in:
WuYongJie 2026-10-14 11:33:22 +08:00
parent a917949a5e
commit fc50035d65
8 changed files with 448 additions and 163 deletions

View 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('供应链中心')
})
}

View File

@ -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 },
})
},
}

View File

@ -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([])
})
})

View File

@ -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: '产品研发部',

View File

@ -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: [] }

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 }