diff --git a/.env.example b/.env.example index 93c4541..e72f7de 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ SQLSERVER_PORT=1433 SQLSERVER_DATABASE= SQLSERVER_USER= SQLSERVER_PASSWORD= +SQLSERVER_SCHEMA=rpt SQLSERVER_ENCRYPT=false SQLSERVER_TRUST_CERT=true HR_SYNC_ENABLED=false diff --git a/backend/src/services/hrSyncService.ts b/backend/src/services/hrSyncService.ts index 577def7..b472345 100644 --- a/backend/src/services/hrSyncService.ts +++ b/backend/src/services/hrSyncService.ts @@ -11,6 +11,7 @@ import { buildHrSyncPreviewLine, resolveHrSyncPersonDisplayName, resolveSourcePersonStatus, + shouldUseSourceDepartmentBinding, type OpenBorrowAssetPreview } from './hrSyncHelpers' @@ -29,7 +30,6 @@ type DepartmentSyncBucket = 'formal' | 'fallback' | 'other' type SourcePersonSpecialExclusionReason = | 'business_support_blank_job_number' | 'longchen_brand_center_register_only' - | 'brand_center_register_only' type SourcePerson = { sourceUserId: string @@ -154,6 +154,8 @@ const REQUIRED_SQLSERVER_FIELDS = [ 'SQLSERVER_PASSWORD' ] as const const DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE = 'Asia/Shanghai' +// 默认上游 schema:老业务库为 dbo,迁移后的报表库为 rpt +const DEFAULT_SOURCE_SCHEMA = 'rpt' const departmentCollator = new Intl.Collator('zh-CN', { numeric: true, @@ -185,9 +187,6 @@ const getConfiguredSet = (value: string, defaultValue: string) => .filter(Boolean) ) -const getFormalStatusValues = () => - getConfiguredSet(process.env.HR_SYNC_ALLOWED_STATUS_VALUES || '', '3') - const getExcludedSourceDepartmentNames = () => getConfiguredSet( process.env.HR_SYNC_EXCLUDED_DEPARTMENT_NAMES || process.env.HR_SYNC_EXCLUDED_DEPARTMENTS || '', @@ -298,6 +297,16 @@ const getSqlServerConfig = (): any => { } } +// 解析上游报表库 schema,用于把 dbo.表名 迁移为可配置 schema(默认 rpt)。 +// 只允许标识符字符(字母/数字/下划线),避免环境变量内容被直接拼进 SQL 造成注入。 +const getSourceSchema = (): string => { + const raw = (process.env.SQLSERVER_SCHEMA || DEFAULT_SOURCE_SCHEMA).trim() + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw)) { + throw new Error(`SQLSERVER_SCHEMA 非法:${raw}(只允许字母、数字、下划线,且不能以数字开头)`) + } + return raw +} + const safeTrim = (value: unknown) => { if (value === null || value === undefined) return null const text = String(value).trim() @@ -364,9 +373,7 @@ const resolveDepartmentBinding = ( sourceMainDepartmentName: string | null, sourceEmployeeStatus: string | null ): { resolvedDepartmentName: string; departmentSyncBucket: DepartmentSyncBucket } => { - const allowedStatusValues = getFormalStatusValues() - - if (allowedStatusValues.has(sourceEmployeeStatus || '')) { + if (shouldUseSourceDepartmentBinding(sourceEmployeeStatus)) { if (sourceDepartmentName) { return { resolvedDepartmentName: sourceDepartmentName, @@ -457,17 +464,11 @@ const getSpecialExclusionReason = (person: { return 'longchen_brand_center_register_only' as const } - if ( - person.sourceDepartmentName === '品牌中心' && - person.sourcePresence === 'register_only' - ) { - return 'brand_center_register_only' as const - } - return null } const getSourceDepartments = async (pool: any): Promise => { + const schema = getSourceSchema() const result = await pool .request() .query(` @@ -477,10 +478,10 @@ const getSourceDepartments = async (pool: any): Promise => { name FROM ( SELECT LTRIM(RTRIM([sys00-dept])) AS name - FROM dbo.hr_employee_register + FROM ${schema}.hr_employee_register UNION SELECT LTRIM(RTRIM([sys00-mainDepts])) AS name - FROM dbo.hr_employee_register + FROM ${schema}.hr_employee_register ) source_departments WHERE ISNULL(name, '') <> '' ORDER BY name @@ -531,6 +532,7 @@ const buildSourcePersonCollection = ( } const getSourcePersons = async (pool: any): Promise => { + const schema = getSourceSchema() const result = await pool .request() .query(` @@ -550,20 +552,20 @@ const getSourcePersons = async (pool: any): Promise => { r.[sys00-mobile] AS registerMobile, r.[sys00-position] AS registerPosition, r.[sys00-jobNumber] AS registerJobNumber, + e.job_number AS employeeJobNumber, r.[sys00-dept] AS registerDept, r.[sys00-mainDepts] AS mainDepts, r.[sys00-mainDeptId] AS mainDeptId, r.[sys00-deptIds] AS registerDeptIds, r.[sys01-employeeStatus] AS employeeStatus - FROM dbo.hr_employee_register r - LEFT JOIN dbo.hr_employee e + FROM ${schema}.hr_employee_register r + LEFT JOIN ${schema}.hr_employee e ON e.userid = r.userid `) const specialExclusionSummary: Record = { business_support_blank_job_number: 0, - longchen_brand_center_register_only: 0, - brand_center_register_only: 0 + longchen_brand_center_register_only: 0 } const persons: SourcePerson[] = [] @@ -589,7 +591,7 @@ const getSourcePersons = async (pool: any): Promise => { const realName = safeTrim(row.realName) || fallbackName || nickname const sourceDepartmentName = safeTrim(row.registerDept) const sourceMainDepartmentName = safeTrim(row.mainDepts) - const sourceJobNumber = safeTrim(row.registerJobNumber) + const sourceJobNumber = safeTrim(row.registerJobNumber) || safeTrim(row.employeeJobNumber) const departmentBinding = resolveDepartmentBinding( sourceDepartmentName, sourceMainDepartmentName, @@ -1478,12 +1480,55 @@ const resolveUniqueIdNumber = async ( return conflict ? null : idNumber } +const resolveUniqueJobNumber = async ( + sourceUserId: string, + jobNumber: string | null, + targetPersonId?: number | null +) => { + if (!jobNumber) { + return null + } + + const conflict = await prisma.person.findFirst({ + where: { + job_number: jobNumber, + NOT: { + OR: [ + { source_userid: sourceUserId }, + ...(targetPersonId ? [{ id: targetPersonId }] : []) + ] + } + }, + select: { id: true } + }) + + return conflict ? null : jobNumber +} + +const preserveSyncedIdentifier = ( + nextValue: string | null, + existingValue: string | null | undefined, + canPreserve: boolean +) => { + if (nextValue) { + return nextValue + } + + if (!canPreserve) { + return null + } + + const normalizedExistingValue = (existingValue || '').trim() + return normalizedExistingValue || null +} + const syncPersons = async (sourcePersons: SourcePerson[], localDepartmentMap: LocalDepartmentMap) => { const now = new Date() const existing = await prisma.person.findMany({ select: { id: true, source_userid: true, + job_number: true, id_number: true, source_system: true } @@ -1511,13 +1556,29 @@ const syncPersons = async (sourcePersons: SourcePerson[], localDepartmentMap: Lo person.idNumber, targetPersonId ) + const normalizedJobNumber = await resolveUniqueJobNumber( + person.sourceUserId, + person.sourceJobNumber, + targetPersonId + ) + const preservedIdNumber = preserveSyncedIdentifier( + normalizedIdNumber, + matchedBySource?.id_number, + Boolean(matchedBySource) + ) + const preservedJobNumber = preserveSyncedIdentifier( + normalizedJobNumber, + matchedBySource?.job_number, + Boolean(matchedBySource) + ) const departmentBinding = resolvePersonDepartments(person, localDepartmentMap) let syncedPersonId = targetPersonId || 0 const payload = { nickname: person.nickname, real_name: person.realName, - id_number: normalizedIdNumber, + job_number: preservedJobNumber, + id_number: preservedIdNumber, department: departmentBinding.displayDepartment, main_department_id: departmentBinding.mainDepartmentId, position: person.position, @@ -1573,7 +1634,8 @@ const syncPersons = async (sourcePersons: SourcePerson[], localDepartmentMap: Lo existingBySourceUserId.set(person.sourceUserId, { id: syncedPersonId, source_userid: person.sourceUserId, - id_number: normalizedIdNumber, + job_number: preservedJobNumber, + id_number: preservedIdNumber, source_system: HR_SOURCE_SYSTEM }) diff --git a/docker-compose.yml b/docker-compose.yml index a275cc4..412220d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,13 +41,14 @@ services: SQLSERVER_DATABASE: ${SQLSERVER_DATABASE:-} SQLSERVER_USER: ${SQLSERVER_USER:-} SQLSERVER_PASSWORD: ${SQLSERVER_PASSWORD:-} + SQLSERVER_SCHEMA: ${SQLSERVER_SCHEMA:-rpt} SQLSERVER_ENCRYPT: ${SQLSERVER_ENCRYPT:-false} SQLSERVER_TRUST_CERT: ${SQLSERVER_TRUST_CERT:-true} HR_SYNC_ENABLED: ${HR_SYNC_ENABLED:-false} HR_SYNC_SCHEDULE: ${HR_SYNC_SCHEDULE:-03:00} HR_SYNC_SCHEDULES: ${HR_SYNC_SCHEDULES:-} - HR_SYNC_ALLOWED_STATUS_VALUES: ${HR_SYNC_ALLOWED_STATUS_VALUES:-3} - HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-2,5} + HR_SYNC_ALLOWED_STATUS_VALUES: ${HR_SYNC_ALLOWED_STATUS_VALUES:-2,3} + HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5} HR_SYNC_EXCLUDED_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流} HR_SYNC_EXCLUDED_NAME_KEYWORDS: ${HR_SYNC_EXCLUDED_NAME_KEYWORDS:-} DOWNLOADS_DIR: /app/downloads