merge: integrate employee directory account protection
This commit is contained in:
parent
dc16d2b75c
commit
089845e7ee
@ -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()
|
||||
})
|
||||
|
||||
@ -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(
|
||||
'江苏简尚品牌管理有限公司 > 品牌中心 > 品牌运营部',
|
||||
|
||||
@ -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 === '产品研发部') {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user