merge: integrate employee directory account protection

This commit is contained in:
WuYongJie 2026-08-29 13:25:24 +08:00
parent dc16d2b75c
commit 089845e7ee
17 changed files with 743 additions and 40 deletions

View File

@ -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(<UserPermissionTabs activeView="accounts" onChange={jest.fn()} />)
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()
})

View File

@ -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(
'江苏简尚品牌管理有限公司 > 品牌中心 > 品牌运营部',

View File

@ -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 === '产品研发部') {

View File

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

View File

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

View File

@ -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',

View File

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

View File

@ -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',

View File

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

View File

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

View File

@ -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<EmployeeDirectory> {
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,
}
}
}

View File

@ -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<string, unknown> = {}) {
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()
})
})

View File

@ -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<DepartmentDTO[]> {
async findAllFlat(options: { includeInactive?: boolean } = {}): Promise<DepartmentDTO[]> {
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,

View File

@ -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<ReturnType<typeof employeeDirectoryRepository.findEmployeesForSyncBatch>>[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<SyncEmployee, 'id' | 'jobNumber' | 'dingtalkUserId' | 'dingtalkUnionId'>) {
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()

View File

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

View File

@ -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") {

View File

@ -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<Record<string, unknown>>,
visibleUsers as unknown as Array<Record<string, unknown>>,
canViewFull
)