feat(组织同步): 上游业务库迁移到报表库, schema 改为可配置
- hrSyncService: 新增 SQLSERVER_SCHEMA(默认 rpt, 带格式校验防注入)
- 3 处 dbo.hr_employee_register/hr_employee 改为动态 ${schema}.表名
- .env.example / docker-compose.yml 注入 SQLSERVER_SCHEMA
- 配合上游通知: 老业务库迁到 JSReportReplica.rpt, 只读使用
This commit is contained in:
parent
0dc3c09315
commit
aa0f27dce0
@ -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
|
||||
|
||||
@ -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<SourceDepartment[]> => {
|
||||
const schema = getSourceSchema()
|
||||
const result = await pool
|
||||
.request()
|
||||
.query(`
|
||||
@ -477,10 +478,10 @@ const getSourceDepartments = async (pool: any): Promise<SourceDepartment[]> => {
|
||||
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<SourcePersonCollection> => {
|
||||
const schema = getSourceSchema()
|
||||
const result = await pool
|
||||
.request()
|
||||
.query(`
|
||||
@ -550,20 +552,20 @@ const getSourcePersons = async (pool: any): Promise<SourcePersonCollection> => {
|
||||
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<SourcePersonSpecialExclusionReason, number> = {
|
||||
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<SourcePersonCollection> => {
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user