From 089845e7eeb789e652307f4b2a9ca09ac10a26ad Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Sat, 29 Aug 2026 13:25:24 +0800 Subject: [PATCH] merge: integrate employee directory account protection --- .../components/UserPermissionTabs.test.tsx | 3 +- .../src/lib/business-role-inference.test.ts | 28 +++ .../src/lib/business-role-inference.ts | 31 +++- .../employee-account-provisioning.test.ts | 13 +- .../hr-sync/employee-account-provisioning.ts | 18 ++ .../intake-node-permission-settings.test.ts | 82 +++++++++ .../lib/intake-node-permission-settings.ts | 52 ++++-- .../lib/non-integrated-permissions.test.ts | 50 ++++++ .../src/lib/non-integrated-permissions.ts | 31 +++- .../employee-directory.repository.test.ts | 40 ++++- .../employee-directory.repository.ts | 120 ++++++++++++- ...yee-directory-account-sync.service.test.ts | 108 +++++++++++ .../src/server/services/department.service.ts | 8 +- ...employee-directory-account-sync.service.ts | 169 ++++++++++++++++++ .../employee-directory-sync.service.ts | 12 +- .../services/intake-owner-options.service.ts | 6 +- .../services/user-management.service.ts | 12 +- 17 files changed, 743 insertions(+), 40 deletions(-) create mode 100644 furniture-3d-system/src/server/services/__tests__/employee-directory-account-sync.service.test.ts create mode 100644 furniture-3d-system/src/server/services/employee-directory-account-sync.service.ts diff --git a/furniture-3d-system/src/app/users/components/UserPermissionTabs.test.tsx b/furniture-3d-system/src/app/users/components/UserPermissionTabs.test.tsx index 4b980657..ef80d679 100644 --- a/furniture-3d-system/src/app/users/components/UserPermissionTabs.test.tsx +++ b/furniture-3d-system/src/app/users/components/UserPermissionTabs.test.tsx @@ -3,11 +3,12 @@ import userEvent from '@testing-library/user-event' import { UserPermissionTabs } from './UserPermissionTabs' describe('UserPermissionTabs', () => { - it('shows only account and role management', () => { + it('shows account and role management without a separate directory tab', () => { render() expect(screen.getByRole('tab', { name: '账号管理' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: '岗位管理' })).toBeInTheDocument() + expect(screen.queryByRole('tab', { name: '组织花名册' })).not.toBeInTheDocument() expect(screen.queryByText('产品开发节点权限')).not.toBeInTheDocument() expect(screen.queryByText('供应链开发权限')).not.toBeInTheDocument() }) diff --git a/furniture-3d-system/src/lib/business-role-inference.test.ts b/furniture-3d-system/src/lib/business-role-inference.test.ts index fd3eee61..ee2724c8 100644 --- a/furniture-3d-system/src/lib/business-role-inference.test.ts +++ b/furniture-3d-system/src/lib/business-role-inference.test.ts @@ -53,6 +53,34 @@ describe('business-role-inference', () => { ).toEqual(['WAREHOUSE']) }) + it('keeps the current supply-chain department business-role口径', () => { + expect( + inferBusinessRolesFromDepartmentPath( + '江苏简尚品牌管理有限公司 > 供应链中心 > 外观设计部', + ).businessRoles, + ).toEqual(['DESIGN']) + + expect( + inferBusinessRolesFromDepartmentPath( + '江苏简尚品牌管理有限公司 > 供应链中心 > 商品计划部', + ).businessRoles, + ).toEqual(['MANAGEMENT']) + + expect( + inferBusinessRolesFromDepartmentPath( + '江苏简尚品牌管理有限公司 > 供应链中心 > 结构设计部 > 家具组', + ).businessRoles, + ).toEqual(['DESIGN']) + + for (const department of ['核价部', '驻场质检部', '跟单质检部', '寻源部']) { + expect( + inferBusinessRolesFromDepartmentPath( + `江苏简尚品牌管理有限公司 > 供应链中心 > ${department}`, + ).businessRoles, + ).toEqual(['PURCHASING']) + } + }) + it('maps brand-center non-excluded departments to strategy', () => { const result = inferBusinessRolesFromDepartmentPath( '江苏简尚品牌管理有限公司 > 品牌中心 > 品牌运营部', diff --git a/furniture-3d-system/src/lib/business-role-inference.ts b/furniture-3d-system/src/lib/business-role-inference.ts index 0d675ac7..408e26f5 100644 --- a/furniture-3d-system/src/lib/business-role-inference.ts +++ b/furniture-3d-system/src/lib/business-role-inference.ts @@ -45,7 +45,7 @@ function getBusinessParts(fullPath: string | null | undefined): string[] { } function includesSegment(parts: string[], pattern: RegExp): boolean { - return parts.some((part) => pattern.test(part)) + return parts.some((part) => pattern.test(part.replace(/\s+/g, ''))) } function isBrandPlanningExcludedDepartment(parts: string[]): boolean { @@ -61,6 +61,18 @@ function inferGeneralUser(reason: string): BusinessRoleInferenceResult { } } +function inferBusinessRole( + businessRole: BusinessAccountType, + reason: string, +): BusinessRoleInferenceResult { + return { + businessRoles: [businessRole], + excluded: false, + shouldWarnWhenEmpty: true, + reason, + } +} + export function isNonInternalDepartmentRoot(root: string | null | undefined): boolean { const normalizedRoot = String(root ?? '').trim() return NON_INTERNAL_ROOTS.has(normalizedRoot) || /^快递/.test(normalizedRoot) @@ -140,12 +152,19 @@ export function inferBusinessRolesFromDepartmentPath( } if (root === '供应链中心') { - return { - businessRoles: ['PURCHASING'], - excluded: false, - shouldWarnWhenEmpty: true, - reason: '供应链中心默认归采购', + if (includesSegment(parts, /^商品计划部$/)) { + return inferBusinessRole('MANAGEMENT', '供应链中心商品计划部归经管') } + + if (includesSegment(parts, /^(外观设计部|结构设计部)$/)) { + return inferBusinessRole('DESIGN', '供应链中心外观设计部/结构设计部归设计') + } + + if (includesSegment(parts, /^(核价部|驻场质检部|跟单质检部|寻源部)$/)) { + return inferBusinessRole('PURCHASING', '供应链中心核价/质检/跟单质检/寻源部门归采购') + } + + return inferBusinessRole('PURCHASING', '供应链中心默认归采购') } if (root === '产品研发部') { diff --git a/furniture-3d-system/src/lib/hr-sync/__tests__/employee-account-provisioning.test.ts b/furniture-3d-system/src/lib/hr-sync/__tests__/employee-account-provisioning.test.ts index 24710002..d83dac49 100644 --- a/furniture-3d-system/src/lib/hr-sync/__tests__/employee-account-provisioning.test.ts +++ b/furniture-3d-system/src/lib/hr-sync/__tests__/employee-account-provisioning.test.ts @@ -1,4 +1,7 @@ -import { resolveEmployeeAccountProvisioning } from '../employee-account-provisioning' +import { + isProtectedEmployeeSyncAccount, + resolveEmployeeAccountProvisioning, +} from '../employee-account-provisioning' describe('employee-account-provisioning', () => { it('在职内部部门默认自动激活并按部门写入业务类型', () => { @@ -48,4 +51,12 @@ describe('employee-account-provisioning', () => { businessRoles: [], }) }) + + it('供应商和外部录入账号会被花名册账号同步保护', () => { + expect(isProtectedEmployeeSyncAccount({ businessRoles: ['SUPPLIER'] })).toBe(true) + expect(isProtectedEmployeeSyncAccount({ supplierCode: 'N1280' })).toBe(true) + expect(isProtectedEmployeeSyncAccount({ businessRoles: ['EXTERNAL_INTAKE'] })).toBe(true) + expect(isProtectedEmployeeSyncAccount({ businessRoles: ['PURCHASING'] })).toBe(false) + expect(isProtectedEmployeeSyncAccount({ supplierCode: ' ' })).toBe(false) + }) }) diff --git a/furniture-3d-system/src/lib/hr-sync/employee-account-provisioning.ts b/furniture-3d-system/src/lib/hr-sync/employee-account-provisioning.ts index 5bb35381..570ab325 100644 --- a/furniture-3d-system/src/lib/hr-sync/employee-account-provisioning.ts +++ b/furniture-3d-system/src/lib/hr-sync/employee-account-provisioning.ts @@ -13,6 +13,24 @@ export interface EmployeeAccountProvisioningDecision { reason: string } +export interface EmployeeSyncAccountIdentity { + businessRoles?: readonly BusinessAccountType[] | null + supplierCode?: string | null +} + +/** + * Supplier and external-intake accounts are maintained outside the internal + * employee directory. Roster sync must never rewrite their status or roles. + */ +export function isProtectedEmployeeSyncAccount( + account: EmployeeSyncAccountIdentity | null | undefined, +): boolean { + return Boolean( + account?.businessRoles?.some((role) => role === 'SUPPLIER' || role === 'EXTERNAL_INTAKE') || + account?.supplierCode?.trim(), + ) +} + export function resolveEmployeeAccountProvisioning( input: EmployeeAccountProvisioningInput, ): EmployeeAccountProvisioningDecision { diff --git a/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts b/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts index 4ce7837a..4e320046 100644 --- a/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts +++ b/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts @@ -70,6 +70,88 @@ describe('intake node permission settings', () => { }), ).toEqual(['merchandiser']) + expect( + resolveIntakeNodePermissionSubjectKeys({ + role: 'USER', + businessRoles: ['PURCHASING'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 跟单质检部', + ], + }), + ).toEqual(['purchase']) + + expect( + resolveIntakeNodePermissionSubjectKeys({ + role: 'USER', + businessRoles: ['PURCHASING'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 寻源部', + ], + }), + ).toEqual(['purchase']) + + expect( + resolveIntakeNodePermissionSubjectKeys({ + name: '雁兰', + role: 'USER', + businessRoles: ['PURCHASING'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 跟单质检部', + ], + }), + ).toEqual(['merchandiser']) + + expect( + resolveIntakeNodePermissionSubjectKeys({ + name: '槐米', + role: 'USER', + businessRoles: ['PURCHASING'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 跟单质检部', + ], + }), + ).toEqual(['merchandiser']) + + expect( + resolveIntakeNodePermissionSubjectKeys({ + role: 'USER', + businessRoles: ['MANAGEMENT'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 商品计划部', + ], + }), + ).toEqual(['management']) + + expect( + resolveIntakeNodePermissionSubjectKeys({ + role: 'USER', + businessRoles: ['DESIGN'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 外观设计部', + ], + }), + ).toEqual(['design']) + + expect( + resolveIntakeNodePermissionSubjectKeys({ + role: 'USER', + businessRoles: ['PURCHASING'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 核价部', + ], + }), + ).toEqual(['pricing']) + + expect( + resolveIntakeNodePermissionSubjectKeys({ + role: 'USER', + businessRoles: ['PURCHASING'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 驻场质检部', + ], + }), + ).toEqual(['quality']) + expect( resolveIntakeNodePermissionSubjectKeys({ role: 'USER', diff --git a/furniture-3d-system/src/lib/intake-node-permission-settings.ts b/furniture-3d-system/src/lib/intake-node-permission-settings.ts index 07e06957..2c81dad2 100644 --- a/furniture-3d-system/src/lib/intake-node-permission-settings.ts +++ b/furniture-3d-system/src/lib/intake-node-permission-settings.ts @@ -303,7 +303,7 @@ export const CORE_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] = [ { key: "design", name: "设计师", - department: "产品研发中心-外观 / 结构", + department: "供应链中心-外观设计部 / 结构设计部", }, { key: "brandCenter", @@ -327,13 +327,13 @@ export const CORE_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] = [ }, { key: "pricing", - name: "核价组", - department: "供应链中心-核价组", + name: "采购-核价", + department: "供应链中心-核价部", }, { key: "quality", - name: "质检组", - department: "供应链中心-品牌采购部-质检组", + name: "采购-质检", + department: "供应链中心-驻场质检部", }, ]; @@ -357,8 +357,8 @@ export const ORDINARY_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] = }, { key: "merchandiser", - name: "跟单组", - department: "供应链中心-品牌采购部-跟单组", + name: "采购-跟单", + department: "供应链中心-跟单质检部(仅雁兰、槐米)", }, { key: "management", @@ -848,7 +848,8 @@ function hasBusinessRoleFallback( } function isProductResearchDepartmentPath(path: string) { - return /产品研发部/.test(path); + return /产品研发部/.test(path) || + (/供应链中心/.test(path) && /外观设计部|结构设计部/.test(path)); } function isBrandPlanningDepartmentPath(path: string) { @@ -864,22 +865,34 @@ function isBrandCenterDepartmentPath(path: string) { } function isPricingGroupDepartmentPath(path: string) { - return /供应链中心/.test(path) && /核价组/.test(path); + return /供应链中心/.test(path) && /核价组|核价部/.test(path); } function isQualityGroupDepartmentPath(path: string) { - return /供应链中心/.test(path) && /质检组/.test(path); + return /供应链中心/.test(path) && /质检组|驻场质检部/.test(path); } function isMerchandiserDepartmentPath(path: string) { return /供应链中心/.test(path) && /跟单组/.test(path); } +function isFollowupQualityDepartmentPath(path: string) { + return /供应链中心/.test(path) && /跟单质检部/.test(path); +} + +function isFollowupMerchandiserUser(user: IntakeNonIntegratedPermissionUser) { + const normalizedName = normalizeText(user.name).replace(/\s+/g, ""); + return ( + (normalizedName === "雁兰" || normalizedName === "槐米") && + hasDepartmentPath(user, isFollowupQualityDepartmentPath) + ); +} + function isSupplyChainPurchaseDepartmentPath(path: string) { return ( /供应链中心/.test(path) && - /采购部/.test(path) && - !/核价组|质检组|跟单组/.test(path) + /采购部|寻源部|跟单质检部/.test(path) && + !/核价组|核价部|质检组|驻场质检部|跟单组/.test(path) ); } @@ -888,7 +901,8 @@ function isOperationDepartmentPath(path: string) { } function isManagementDepartmentPath(path: string) { - return /经营管理中心/.test(path); + return /经营管理中心/.test(path) || + (/供应链中心/.test(path) && /商品计划部/.test(path)); } function isCustomerServiceDepartmentPath(path: string) { @@ -1024,11 +1038,19 @@ export function resolveIntakeNodePermissionSubjectKeys( pushUniqueSubject(keys, "quality"); } - if (hasDepartmentPath(user, isMerchandiserDepartmentPath)) { + const followupMerchandiser = isFollowupMerchandiserUser(user); + + if ( + followupMerchandiser || + hasDepartmentPath(user, isMerchandiserDepartmentPath) + ) { pushUniqueSubject(keys, "merchandiser"); } - if (hasDepartmentPath(user, isSupplyChainPurchaseDepartmentPath)) { + if ( + !followupMerchandiser && + hasDepartmentPath(user, isSupplyChainPurchaseDepartmentPath) + ) { pushUniqueSubject(keys, "purchase"); } diff --git a/furniture-3d-system/src/lib/non-integrated-permissions.test.ts b/furniture-3d-system/src/lib/non-integrated-permissions.test.ts index 10b1f615..bb1d92b8 100644 --- a/furniture-3d-system/src/lib/non-integrated-permissions.test.ts +++ b/furniture-3d-system/src/lib/non-integrated-permissions.test.ts @@ -6,6 +6,10 @@ import { canEditNonIntegratedWorkspace, canPushNonIntegratedProductPricingToJushuitan, canUploadNonIntegratedDetachedAsset, + isIntakeNonIntegratedMerchandiserGroupUser, + isIntakeNonIntegratedPricingGroupUser, + isIntakeNonIntegratedQualityGroupUser, + isIntakeNonIntegratedSupplyChainUser, } from './non-integrated-permissions' import { NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY } from '@/app/intake/non-integrated/nonIntegratedStorage' @@ -384,6 +388,52 @@ describe('non-integrated intake permissions', () => { expect(canDownloadNonIntegratedWorkspace(baseProduct, merchandiser, '基础信息')).toBe(true) }) + it('aligns the new supply-chain departments and named merchandiser exceptions', () => { + const purchaser = { + name: '寻源A', + role: 'USER', + businessRoles: ['PURCHASING'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 寻源部', + ], + } + const followupPurchaser = { + name: '跟单质检A', + role: 'USER', + businessRoles: ['PURCHASING'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 跟单质检部', + ], + } + const namedMerchandiser = { + ...followupPurchaser, + name: '雁兰', + } + const pricingUser = { + name: '核价A', + role: 'USER', + businessRoles: ['PURCHASING'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 核价部', + ], + } + const qualityUser = { + name: '质检A', + role: 'USER', + businessRoles: ['PURCHASING'], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 驻场质检部', + ], + } + + expect(isIntakeNonIntegratedSupplyChainUser(purchaser)).toBe(true) + expect(isIntakeNonIntegratedSupplyChainUser(followupPurchaser)).toBe(true) + expect(isIntakeNonIntegratedSupplyChainUser(namedMerchandiser)).toBe(false) + expect(isIntakeNonIntegratedMerchandiserGroupUser(namedMerchandiser)).toBe(true) + expect(isIntakeNonIntegratedPricingGroupUser(pricingUser)).toBe(true) + expect(isIntakeNonIntegratedQualityGroupUser(qualityUser)).toBe(true) + }) + it('lets any purchasing user download product data without being the purchase owner', () => { const unrelatedPurchaser = { id: 'purchase-b-id', diff --git a/furniture-3d-system/src/lib/non-integrated-permissions.ts b/furniture-3d-system/src/lib/non-integrated-permissions.ts index 2d9a6f38..aee70717 100644 --- a/furniture-3d-system/src/lib/non-integrated-permissions.ts +++ b/furniture-3d-system/src/lib/non-integrated-permissions.ts @@ -194,7 +194,8 @@ function hasBusinessRoleFallback( } function isProductResearchDepartmentPath(path: string) { - return /产品研发部/.test(path) + return /产品研发部/.test(path) || + (/供应链中心/.test(path) && /外观设计部|结构设计部/.test(path)) } function isBrandPlanningDepartmentPath(path: string) { @@ -212,8 +213,20 @@ function isSpaceVisualDepartmentPath(path: string) { function isSupplyChainPurchaseDepartmentPath(path: string) { return ( /供应链中心/.test(path) && - /采购部/.test(path) && - !/核价组|质检组|跟单组/.test(path) + /采购部|寻源部|跟单质检部/.test(path) && + !/核价组|核价部|质检组|驻场质检部|跟单组/.test(path) + ) +} + +function isFollowupQualityDepartmentPath(path: string) { + return /供应链中心/.test(path) && /跟单质检部/.test(path) +} + +function isNamedFollowupMerchandiser(user: IntakeNonIntegratedPermissionUser) { + const normalizedName = normalizeName(user.name) + return ( + (normalizedName === '雁兰' || normalizedName === '槐米') && + hasDepartmentPath(user, isFollowupQualityDepartmentPath) ) } @@ -222,11 +235,11 @@ function isMerchandiserGroupDepartmentPath(path: string) { } function isPricingGroupDepartmentPath(path: string) { - return /供应链中心/.test(path) && /核价组/.test(path) + return /供应链中心/.test(path) && /核价组|核价部/.test(path) } function isQualityGroupDepartmentPath(path: string) { - return /质检组/.test(path) + return /供应链中心/.test(path) && /质检组|驻场质检部/.test(path) } function isOperationDepartmentPath(path: string) { @@ -305,7 +318,8 @@ export function isIntakeNonIntegratedSupplyChainUser( user: IntakeNonIntegratedPermissionUser, ) { return ( - hasDepartmentPath(user, isSupplyChainPurchaseDepartmentPath) || + (!isNamedFollowupMerchandiser(user) && + hasDepartmentPath(user, isSupplyChainPurchaseDepartmentPath)) || hasBusinessRoleFallback(user, 'PURCHASING') ) } @@ -313,7 +327,10 @@ export function isIntakeNonIntegratedSupplyChainUser( export function isIntakeNonIntegratedMerchandiserGroupUser( user: IntakeNonIntegratedPermissionUser, ) { - return hasDepartmentPath(user, isMerchandiserGroupDepartmentPath) + return ( + hasDepartmentPath(user, isMerchandiserGroupDepartmentPath) || + isNamedFollowupMerchandiser(user) + ) } export function isIntakeNonIntegratedPricingGroupUser( diff --git a/furniture-3d-system/src/server/repositories/__tests__/employee-directory.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/employee-directory.repository.test.ts index 67baf2ba..e24fcb22 100644 --- a/furniture-3d-system/src/server/repositories/__tests__/employee-directory.repository.test.ts +++ b/furniture-3d-system/src/server/repositories/__tests__/employee-directory.repository.test.ts @@ -5,6 +5,7 @@ jest.mock('@/lib/prisma', () => ({ findMany: jest.fn(), findUnique: jest.fn(), upsert: jest.fn(), + updateMany: jest.fn(), }, employeeDirectory: { findFirst: jest.fn(), @@ -29,6 +30,7 @@ const mockPrisma = prisma as unknown as { findMany: jest.Mock findUnique: jest.Mock upsert: jest.Mock + updateMany: jest.Mock } employeeDirectory: { findFirst: jest.Mock @@ -52,9 +54,20 @@ describe('employeeDirectoryRepository.upsertMany', () => { mockPrisma.employeeLookupCache.deleteMany.mockResolvedValue({ count: 0 }) mockPrisma.department.findMany.mockResolvedValue([]) mockPrisma.department.findUnique.mockResolvedValue(null) + mockPrisma.department.updateMany.mockResolvedValue({ count: 0 }) mockPrisma.user.updateMany.mockResolvedValue({ count: 0 }) }) + it('部门清单为空时不批量停用已有部门', async () => { + await employeeDirectoryRepository.upsertMany({ + departments: [], + employees: [], + dimissionEntries: [], + }) + + expect(mockPrisma.department.updateMany).not.toHaveBeenCalled() + }) + it('同一员工同时出现在在职和离职数据时,以在职数据为准', async () => { await employeeDirectoryRepository.upsertMany({ departments: [], @@ -180,7 +193,14 @@ describe('employeeDirectoryRepository.upsertMany', () => { }), ) expect(mockPrisma.user.updateMany).toHaveBeenCalledWith({ - where: { employeeDirectoryId: 'employee-yaoyao' }, + where: { + employeeDirectoryId: 'employee-yaoyao', + NOT: [ + { businessRoles: { has: 'SUPPLIER' } }, + { businessRoles: { has: 'EXTERNAL_INTAKE' } }, + { supplierCode: { not: null } }, + ], + }, data: { departmentId: 'department-purchase-2', businessRoles: ['PURCHASING'], @@ -217,7 +237,14 @@ describe('employeeDirectoryRepository.upsertMany', () => { }) expect(mockPrisma.user.updateMany).toHaveBeenCalledWith({ - where: { employeeDirectoryId: 'employee-supplier' }, + where: { + employeeDirectoryId: 'employee-supplier', + NOT: [ + { businessRoles: { has: 'SUPPLIER' } }, + { businessRoles: { has: 'EXTERNAL_INTAKE' } }, + { supplierCode: { not: null } }, + ], + }, data: { departmentId: 'department-supplier', businessRoles: [], @@ -246,7 +273,14 @@ describe('employeeDirectoryRepository.upsertMany', () => { }) expect(mockPrisma.user.updateMany).toHaveBeenCalledWith({ - where: { employeeDirectoryId: 'employee-1' }, + where: { + employeeDirectoryId: 'employee-1', + NOT: [ + { businessRoles: { has: 'SUPPLIER' } }, + { businessRoles: { has: 'EXTERNAL_INTAKE' } }, + { supplierCode: { not: null } }, + ], + }, data: { status: 'INACTIVE', businessRoles: [], diff --git a/furniture-3d-system/src/server/repositories/employee-directory.repository.ts b/furniture-3d-system/src/server/repositories/employee-directory.repository.ts index ccff81be..d946f9b9 100644 --- a/furniture-3d-system/src/server/repositories/employee-directory.repository.ts +++ b/furniture-3d-system/src/server/repositories/employee-directory.repository.ts @@ -7,6 +7,14 @@ import { } from '@/lib/hr-sync/primary-department' import { resolveEmployeeAccountProvisioning } from '@/lib/hr-sync/employee-account-provisioning' +const INTERNAL_EMPLOYEE_SYNC_USER_FILTER: Prisma.UserWhereInput = { + NOT: [ + { businessRoles: { has: 'SUPPLIER' } }, + { businessRoles: { has: 'EXTERNAL_INTAKE' } }, + { supplierCode: { not: null } }, + ], +} + export interface EmployeeDirectoryMatchInput { dingtalkUserId?: string | null dingtalkUnionId?: string | null @@ -137,7 +145,10 @@ class EmployeeDirectoryRepository { }) await prisma.user.updateMany({ - where: { employeeDirectoryId: input.employeeId }, + where: { + employeeDirectoryId: input.employeeId, + ...INTERNAL_EMPLOYEE_SYNC_USER_FILTER, + }, data: { departmentId: department.id, businessRoles: provisioning.businessRoles, @@ -148,7 +159,10 @@ class EmployeeDirectoryRepository { private async syncLinkedUserForDimission(employeeId: string) { await prisma.user.updateMany({ - where: { employeeDirectoryId: employeeId }, + where: { + employeeDirectoryId: employeeId, + ...INTERNAL_EMPLOYEE_SYNC_USER_FILTER, + }, data: { status: 'INACTIVE', businessRoles: [], @@ -265,6 +279,94 @@ class EmployeeDirectoryRepository { }) } + async findLatestSnapshot() { + const [latestDepartment, latestEmployee] = await Promise.all([ + prisma.department.aggregate({ _max: { syncedAt: true } }), + prisma.employeeDirectory.aggregate({ _max: { syncedAt: true } }), + ]) + + const departmentSyncedAt = latestDepartment._max.syncedAt + const employeeSyncedAt = latestEmployee._max.syncedAt + const [departments, employees] = await Promise.all([ + departmentSyncedAt + ? prisma.department.findMany({ + where: { syncedAt: departmentSyncedAt }, + select: { + id: true, + name: true, + fullPath: true, + externalId: true, + parentId: true, + orderIndex: true, + }, + orderBy: [{ orderIndex: 'asc' }, { name: 'asc' }], + }) + : Promise.resolve([]), + employeeSyncedAt + ? prisma.employeeDirectory.findMany({ + where: { syncedAt: employeeSyncedAt }, + select: { + id: true, + name: true, + jobNumber: true, + title: true, + dingtalkUserId: true, + dingtalkUnionId: true, + sourceStatus: true, + primaryDepartmentExternalId: true, + syncedAt: true, + user: { + select: { + id: true, + status: true, + role: true, + }, + }, + }, + orderBy: [{ name: 'asc' }, { jobNumber: 'asc' }], + }) + : Promise.resolve([]), + ]) + + return { + departmentSyncedAt, + employeeSyncedAt, + departments, + employees, + } + } + + async findEmployeesForSyncBatch(syncedAt: Date) { + return prisma.employeeDirectory.findMany({ + where: { syncedAt }, + select: { + id: true, + name: true, + jobNumber: true, + email: true, + mobile: true, + title: true, + dingtalkUserId: true, + dingtalkUnionId: true, + sourceStatus: true, + primaryDepartmentExternalId: true, + user: { + select: { + id: true, + name: true, + email: true, + status: true, + role: true, + loginSource: true, + businessRoles: true, + supplierCode: true, + }, + }, + }, + orderBy: [{ name: 'asc' }, { jobNumber: 'asc' }], + }) + } + async upsertOne(input: EmployeeDirectoryUpsertInput): Promise { const now = new Date() const primaryDepartmentExternalId = await this.choosePrimaryDepartmentExternalId(input) @@ -400,6 +502,7 @@ class EmployeeDirectoryRepository { orderIndex: item.orderIndex ?? 0, rawSnapshot: item.rawSnapshot, syncedAt: now, + isActive: true, }, create: { name: departmentName, @@ -409,6 +512,7 @@ class EmployeeDirectoryRepository { orderIndex: item.orderIndex ?? 0, rawSnapshot: item.rawSnapshot, syncedAt: now, + isActive: true, }, }) @@ -423,6 +527,17 @@ class EmployeeDirectoryRepository { unresolvedDepartments = remaining } + if (departments.length > 0) { + const syncedDepartmentExternalIds = departments.map((department) => department.externalId) + await prisma.department.updateMany({ + where: { + externalId: { notIn: syncedDepartmentExternalIds }, + syncedAt: { not: null }, + }, + data: { isActive: false }, + }) + } + for (const employee of input.employees) { const employeeDepartmentExternalIds = this.getEmployeeDepartmentExternalIds(employee) const primaryDepartmentExternalId = @@ -568,6 +683,7 @@ class EmployeeDirectoryRepository { departments: departmentMap.size, employees: input.employees.length, dimissions: input.dimissionEntries.length, + syncedAt: now, } } } diff --git a/furniture-3d-system/src/server/services/__tests__/employee-directory-account-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/employee-directory-account-sync.service.test.ts new file mode 100644 index 00000000..c27ed992 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/employee-directory-account-sync.service.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +const mockFindEmployeesForSyncBatch = jest.fn() +const mockFindAllFlat = jest.fn() +const mockFindByEmployeeIdentity = jest.fn() +const mockCreateWithScalars = jest.fn() +const mockUpdateWithScalars = jest.fn() + +jest.mock('../../repositories/employee-directory.repository', () => ({ + employeeDirectoryRepository: { + findEmployeesForSyncBatch: (...args: unknown[]) => mockFindEmployeesForSyncBatch(...args), + }, +})) + +jest.mock('../../repositories/user.repository', () => ({ + userRepository: { + findByEmployeeIdentity: (...args: unknown[]) => mockFindByEmployeeIdentity(...args), + emailExists: jest.fn().mockResolvedValue(false), + nameExists: jest.fn().mockResolvedValue(false), + createWithScalars: (...args: unknown[]) => mockCreateWithScalars(...args), + updateWithScalars: (...args: unknown[]) => mockUpdateWithScalars(...args), + }, +})) + +jest.mock('../department.service', () => ({ + departmentService: { + findAllFlat: (...args: unknown[]) => mockFindAllFlat(...args), + }, +})) + +jest.mock('@/lib/password', () => ({ + generateRandomPassword: () => 'generated-password', + hashPassword: async (value: string) => `hashed:${value}`, +})) + +const { employeeDirectoryAccountSyncService } = require('../employee-directory-account-sync.service') + +function buildEmployee(overrides: Record = {}) { + return { + id: 'employee-1', + name: '员工甲', + jobNumber: 'JS001', + email: null, + mobile: null, + title: null, + dingtalkUserId: 'dt-1', + dingtalkUnionId: null, + sourceStatus: 'EMPLOYED', + primaryDepartmentExternalId: 'dept-1', + user: null, + ...overrides, + } +} + +describe('employee-directory-account-sync.service', () => { + beforeEach(() => { + jest.clearAllMocks() + mockFindAllFlat.mockResolvedValue([ + { + id: 'dept-1', + externalId: 'dept-1', + fullPath: '江苏简尚品牌管理有限公司 > 供应链中心 > 寻源部', + }, + ]) + mockFindByEmployeeIdentity.mockResolvedValue(null) + mockCreateWithScalars.mockResolvedValue({ id: 'created-user' }) + mockUpdateWithScalars.mockResolvedValue({ id: 'updated-user' }) + }) + + it.each([ + { + label: '供应商业务类型', + businessRoles: ['SUPPLIER'], + supplierCode: 'N1280', + }, + { + label: '外部录入业务类型', + businessRoles: ['EXTERNAL_INTAKE'], + supplierCode: null, + }, + ])('$label账号不会被花名册同步改写', async ({ businessRoles, supplierCode }) => { + mockFindEmployeesForSyncBatch.mockResolvedValue([ + buildEmployee({ + sourceStatus: 'DIMISSIONED', + user: { + id: 'protected-user', + name: '受保护账号', + email: 'protected@example.com', + status: 'ACTIVE', + role: 'USER', + loginSource: 'LOCAL', + businessRoles, + supplierCode, + }, + }), + ]) + + await expect(employeeDirectoryAccountSyncService.syncBatch(new Date())).resolves.toMatchObject({ + total: 1, + skipped: 1, + updated: 0, + disabled: 0, + created: 0, + }) + expect(mockUpdateWithScalars).not.toHaveBeenCalled() + expect(mockCreateWithScalars).not.toHaveBeenCalled() + }) +}) diff --git a/furniture-3d-system/src/server/services/department.service.ts b/furniture-3d-system/src/server/services/department.service.ts index 332e5fa5..1fc4941e 100644 --- a/furniture-3d-system/src/server/services/department.service.ts +++ b/furniture-3d-system/src/server/services/department.service.ts @@ -35,6 +35,7 @@ export interface DepartmentDTO { id: string name: string fullPath: string + externalId: string | null description: string | null parentId: string | null managerId: string | null @@ -68,9 +69,11 @@ class DepartmentService { /** * 获取部门列表(扁平结构) */ - async findAllFlat(): Promise { + async findAllFlat(options: { includeInactive?: boolean } = {}): Promise { const departments = await departmentRepository.findAllWithRelations() - return departments.map(dept => this.toDTO(dept)) + return departments + .filter((department) => options.includeInactive || department.isActive) + .map(dept => this.toDTO(dept)) } /** @@ -321,6 +324,7 @@ class DepartmentService { id: department.id, name: department.name, fullPath: department.fullPath, + externalId: department.externalId, parentId: department.parentId, description: department.description, managerId: department.managerId, diff --git a/furniture-3d-system/src/server/services/employee-directory-account-sync.service.ts b/furniture-3d-system/src/server/services/employee-directory-account-sync.service.ts new file mode 100644 index 00000000..ab968d4f --- /dev/null +++ b/furniture-3d-system/src/server/services/employee-directory-account-sync.service.ts @@ -0,0 +1,169 @@ +import 'server-only' + +import type { UserStatus } from '@prisma/client' +import { generateRandomPassword, hashPassword } from '@/lib/password' +import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference' +import { + isProtectedEmployeeSyncAccount, + resolveEmployeeAccountProvisioning, +} from '@/lib/hr-sync/employee-account-provisioning' +import { employeeDirectoryRepository } from '@/server/repositories/employee-directory.repository' +import { userRepository } from '@/server/repositories/user.repository' +import { departmentService } from './department.service' + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +type SyncEmployee = Awaited>[number] + +function normalizeEmail(value: string | null): string | null { + const email = value?.trim().toLowerCase() ?? '' + return email && EMAIL_PATTERN.test(email) ? email : null +} + +function buildIdentityToken(employee: Pick) { + const raw = employee.jobNumber || employee.dingtalkUserId || employee.dingtalkUnionId || employee.id + return raw.replace(/[^a-zA-Z0-9]/g, '').toLowerCase().slice(0, 24) || employee.id.slice(-12) +} + +function sanitizeName(rawName: string, fallbackToken: string) { + const normalized = rawName.replace(/\s+/g, '_').replace(/[^\u4e00-\u9fa5a-zA-Z0-9_]/g, '').slice(0, 20) + return normalized.length >= 2 ? normalized : `员工_${fallbackToken.slice(-12)}`.slice(0, 20) +} + +class EmployeeDirectoryAccountSyncService { + private async buildUniqueEmail(employee: SyncEmployee) { + const preferred = normalizeEmail(employee.email) + if (preferred && !(await userRepository.emailExists(preferred))) { + return preferred + } + + const base = `employee-${buildIdentityToken(employee)}@employee.local` + let candidate = base + let index = 1 + while (await userRepository.emailExists(candidate)) { + candidate = `employee-${buildIdentityToken(employee)}-${index}@employee.local` + index += 1 + } + return candidate + } + + private async buildUniqueName(employee: SyncEmployee) { + const base = sanitizeName(employee.name, buildIdentityToken(employee)) + if (!(await userRepository.nameExists(base))) { + return base + } + + for (let index = 1; index <= 9999; index += 1) { + const suffix = `_${index}` + const candidate = `${base.slice(0, Math.max(2, 20 - suffix.length))}${suffix}` + if (!(await userRepository.nameExists(candidate))) { + return candidate + } + } + + return `员工_${buildIdentityToken(employee).slice(-12)}`.slice(0, 20) + } + + private async findExistingUser(employee: SyncEmployee) { + if (employee.user) return employee.user + + return employee.dingtalkUserId || employee.dingtalkUnionId + ? userRepository.findByEmployeeIdentity({ + dingtalkUserId: employee.dingtalkUserId, + dingtalkUnionId: employee.dingtalkUnionId, + }) + : null + } + + private async syncEmployee(employee: SyncEmployee, department: { id: string; fullPath: string } | null) { + const existingUser = await this.findExistingUser(employee) + if (isProtectedEmployeeSyncAccount(existingUser ?? employee.user)) { + return { created: false, updated: false, disabled: false, skipped: true } + } + + const isExcludedDepartment = inferBusinessRolesFromDepartmentPath(department?.fullPath).excluded + if (employee.sourceStatus === 'EMPLOYED' && isExcludedDepartment) { + return { created: false, updated: false, disabled: false, skipped: true } + } + + const provisioning = resolveEmployeeAccountProvisioning({ + sourceStatus: employee.sourceStatus, + departmentFullPath: department?.fullPath, + }) + const targetStatus: UserStatus = employee.sourceStatus === 'DIMISSIONED' + ? 'INACTIVE' + : existingUser?.status === 'SUSPENDED' + ? 'SUSPENDED' + : provisioning.canAutoActivate + ? 'ACTIVE' + : 'PENDING' + + if (existingUser) { + await userRepository.updateWithScalars(existingUser.id, { + employeeDirectoryId: employee.id, + departmentId: department?.id ?? undefined, + dingtalkUserId: employee.dingtalkUserId ?? undefined, + dingtalkUnionId: employee.dingtalkUnionId ?? undefined, + dingtalkBoundAt: employee.dingtalkUserId || employee.dingtalkUnionId ? new Date() : undefined, + phone: employee.mobile ?? undefined, + businessRoles: employee.sourceStatus === 'DIMISSIONED' ? [] : provisioning.businessRoles, + status: targetStatus, + }) + return { created: false, updated: true, disabled: employee.sourceStatus === 'DIMISSIONED', skipped: false } + } + + if (employee.sourceStatus === 'EMPLOYED' && !provisioning.canAutoActivate) { + return { created: false, updated: false, disabled: false, skipped: true } + } + + await userRepository.createWithScalars({ + email: await this.buildUniqueEmail(employee), + name: await this.buildUniqueName(employee), + password: await hashPassword(generateRandomPassword()), + role: 'USER', + businessRoles: employee.sourceStatus === 'DIMISSIONED' ? [] : provisioning.businessRoles, + status: targetStatus, + isFirstLogin: false, + phone: employee.mobile, + loginSource: employee.dingtalkUserId || employee.dingtalkUnionId ? 'DINGTALK' : 'HR_SYNC', + dingtalkUserId: employee.dingtalkUserId, + dingtalkUnionId: employee.dingtalkUnionId, + dingtalkBoundAt: employee.dingtalkUserId || employee.dingtalkUnionId ? new Date() : null, + employeeDirectoryId: employee.id, + departmentId: department?.id ?? null, + supplierCode: null, + supplierName: null, + }) + return { created: true, updated: false, disabled: employee.sourceStatus === 'DIMISSIONED', skipped: false } + } + + async syncBatch(syncedAt: Date) { + const [employees, departments] = await Promise.all([ + employeeDirectoryRepository.findEmployeesForSyncBatch(syncedAt), + departmentService.findAllFlat(), + ]) + const departmentsByExternalId = new Map( + departments + .filter((department) => department.externalId) + .map((department) => [department.externalId as string, department]), + ) + const summary = { created: 0, updated: 0, disabled: 0, skipped: 0 } + + for (const employee of employees) { + const result = await this.syncEmployee( + employee, + employee.primaryDepartmentExternalId + ? departmentsByExternalId.get(employee.primaryDepartmentExternalId) ?? null + : null, + ) + if (result.created) summary.created += 1 + if (result.updated) summary.updated += 1 + if (result.disabled) summary.disabled += 1 + if (result.skipped) summary.skipped += 1 + } + + return { ...summary, total: employees.length } + } +} + +export const employeeDirectoryAccountSyncService = new EmployeeDirectoryAccountSyncService() diff --git a/furniture-3d-system/src/server/services/employee-directory-sync.service.ts b/furniture-3d-system/src/server/services/employee-directory-sync.service.ts index 3deb8871..2050824a 100644 --- a/furniture-3d-system/src/server/services/employee-directory-sync.service.ts +++ b/furniture-3d-system/src/server/services/employee-directory-sync.service.ts @@ -3,6 +3,7 @@ import 'server-only' import { Prisma } from '@prisma/client' import { AppError, ErrorCode } from '@/server/core/errors' import { employeeDirectoryRepository } from '@/server/repositories/employee-directory.repository' +import { employeeDirectoryAccountSyncService } from './employee-directory-account-sync.service' export interface HrSyncDepartmentInput { externalId: string @@ -104,11 +105,20 @@ class EmployeeDirectorySyncService { } } - return employeeDirectoryRepository.upsertMany({ + const result = await employeeDirectoryRepository.upsertMany({ departments, employees, dimissionEntries: dimissions, }) + + const accountSync = result.syncedAt + ? await employeeDirectoryAccountSyncService.syncBatch(result.syncedAt) + : null + + return { + ...result, + accountSync, + } } } 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 893b0b24..ff2371c4 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 @@ -37,7 +37,11 @@ function departmentTextMatchesScope( if (!text) return false; if (scope === "productResearch") { - return text.includes("产品研发部"); + return ( + text.includes("产品研发部") || + (text.includes("供应链中心") && + /外观设计部|结构设计部/.test(text)) + ); } if (scope === "merchandisePlanning") { diff --git a/furniture-3d-system/src/server/services/user-management.service.ts b/furniture-3d-system/src/server/services/user-management.service.ts index 56e3b162..4a4d0fe5 100644 --- a/furniture-3d-system/src/server/services/user-management.service.ts +++ b/furniture-3d-system/src/server/services/user-management.service.ts @@ -454,8 +454,18 @@ class UserManagementService { roots.push(node) } + const activeDepartmentIds = new Set(scopedDepartments.map((department) => department.id)) + const visibleUsers = allUsers.map((user) => { + if (!user.departmentId || activeDepartmentIds.has(user.departmentId)) return user + return { + ...user, + departmentId: null, + department: null, + } + }) + const sanitizedUsers = this.sanitizeUserData( - allUsers as unknown as Array>, + visibleUsers as unknown as Array>, canViewFull )