diff --git a/furniture-3d-system/src/lib/business-owner-options.ts b/furniture-3d-system/src/lib/business-owner-options.ts new file mode 100644 index 00000000..306c9c0a --- /dev/null +++ b/furniture-3d-system/src/lib/business-owner-options.ts @@ -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, +) { + 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('供应链中心') + }) +} diff --git a/furniture-3d-system/src/server/repositories/business-owner-options.repository.ts b/furniture-3d-system/src/server/repositories/business-owner-options.repository.ts new file mode 100644 index 00000000..ea2ed429 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/business-owner-options.repository.ts @@ -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 { + if (!externalIds.length) return Promise.resolve([]) + return prisma.department.findMany({ + where: { externalId: { in: externalIds } }, + select: { externalId: true, name: true, fullPath: true }, + }) + }, +} diff --git a/furniture-3d-system/src/server/services/__tests__/business-owner-options.service.test.ts b/furniture-3d-system/src/server/services/__tests__/business-owner-options.service.test.ts new file mode 100644 index 00000000..3c4fe57f --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/business-owner-options.service.test.ts @@ -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> = [ + 'productResearch', 'merchandisePlanning', 'brandPlanning', 'spaceVisual', 'operation', 'purchasing', +] + +function candidate(overrides: Partial = {}): 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([]) + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/intake-owner-options.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-owner-options.service.test.ts index 2c444375..efe11d9e 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-owner-options.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-owner-options.service.test.ts @@ -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: '产品研发部', diff --git a/furniture-3d-system/src/server/services/__tests__/supplier-recommended-products.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supplier-recommended-products.service.test.ts index a6436fa4..34141f41 100644 --- a/furniture-3d-system/src/server/services/__tests__/supplier-recommended-products.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supplier-recommended-products.service.test.ts @@ -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: [] } diff --git a/furniture-3d-system/src/server/services/business-owner-options.service.ts b/furniture-3d-system/src/server/services/business-owner-options.service.ts new file mode 100644 index 00000000..89d85619 --- /dev/null +++ b/furniture-3d-system/src/server/services/business-owner-options.service.ts @@ -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 +} diff --git a/furniture-3d-system/src/server/services/intake-owner-options.service.ts b/furniture-3d-system/src/server/services/intake-owner-options.service.ts index 37a1e7db..7e1d54b3 100644 --- a/furniture-3d-system/src/server/services/intake-owner-options.service.ts +++ b/furniture-3d-system/src/server/services/intake-owner-options.service.ts @@ -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) { - 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, - 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) } diff --git a/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts b/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts index b5ef41a9..85a0273d 100644 --- a/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts +++ b/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts @@ -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 }