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:
WuYongJie 2026-06-25 13:27:10 +08:00
parent 0dc3c09315
commit aa0f27dce0
3 changed files with 89 additions and 25 deletions

View File

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

View File

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

View File

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