diff --git a/lib/__tests__/organization-sync.test.ts b/lib/__tests__/organization-sync.test.ts new file mode 100644 index 0000000..5edea16 --- /dev/null +++ b/lib/__tests__/organization-sync.test.ts @@ -0,0 +1,52 @@ +import { EMPLOYMENT_STATE, resolveEmploymentState } from '@/lib/employment-state' + +describe('resolveEmploymentState', () => { + it('marks an HR roster resignation as resigned even without a registered status code', () => { + expect( + resolveEmploymentState({ + hasResignationRecord: false, + rawEmploymentStatus: '离职', + registeredStatusCode: null, + }), + ).toBe(EMPLOYMENT_STATE.RESIGNED) + }) + + it('lets an explicit resignation override an active registered status code', () => { + expect( + resolveEmploymentState({ + hasResignationRecord: false, + rawEmploymentStatus: '已离职', + registeredStatusCode: 3, + }), + ).toBe(EMPLOYMENT_STATE.RESIGNED) + }) + + it('supports the numeric resigned status stored as raw text', () => { + expect( + resolveEmploymentState({ + hasResignationRecord: false, + rawEmploymentStatus: '5', + }), + ).toBe(EMPLOYMENT_STATE.RESIGNED) + }) + + it('keeps active registered status codes active when no resignation evidence exists', () => { + expect( + resolveEmploymentState({ + hasResignationRecord: false, + rawEmploymentStatus: null, + registeredStatusCode: 2, + }), + ).toBe(EMPLOYMENT_STATE.ACTIVE) + }) + + it('keeps missing and ambiguous signals unknown', () => { + expect( + resolveEmploymentState({ + hasResignationRecord: false, + rawEmploymentStatus: '待确认', + registeredStatusCode: null, + }), + ).toBe(EMPLOYMENT_STATE.UNKNOWN) + }) +}) diff --git a/lib/employment-state.ts b/lib/employment-state.ts new file mode 100644 index 0000000..1edbf81 --- /dev/null +++ b/lib/employment-state.ts @@ -0,0 +1,53 @@ +/** 上游员工状态码。5 已离职,2 试用、3 正式均视为在职。 */ +const STATUS_CODE_RESIGNED = 5 +const STATUS_CODE_ACTIVE = [2, 3] +const RESIGNED_STATUS_KEYWORDS = ['离职', '停用', '退休'] + +export const EMPLOYMENT_STATE = { + ACTIVE: 'ACTIVE', + RESIGNED: 'RESIGNED', + UNKNOWN: 'UNKNOWN', +} as const + +function clean(value: unknown): string | null { + if (value === null || value === undefined) return null + const normalized = String(value).trim() + return normalized === '' ? null : normalized +} + +/** + * 将各上游来源的明确信号归一成系统使用的在职状态。 + * + * 花名册里有一批离职人员没有钉钉 userid:其 employmentStatus 已明确为「离职」, + * 但旧逻辑只在 userid 存在时查询离职表/注册表,最终把这些人保守落成 UNKNOWN。 + * 明确的离职信号必须优先于在职状态码;其余模糊中文和空值仍保持 UNKNOWN。 + */ +export function resolveEmploymentState(signals: { + hasResignationRecord: boolean + rawEmploymentStatus?: unknown + registeredStatusCode?: number | null +}): string { + const rawStatus = clean(signals.rawEmploymentStatus) + const rawStatusCode = rawStatus ? Number(rawStatus) : Number.NaN + const rawExplicitlyResigned = Boolean( + rawStatus && + (rawStatusCode === STATUS_CODE_RESIGNED || + RESIGNED_STATUS_KEYWORDS.some((keyword) => rawStatus.includes(keyword))), + ) + + if ( + signals.hasResignationRecord || + rawExplicitlyResigned || + signals.registeredStatusCode === STATUS_CODE_RESIGNED + ) { + return EMPLOYMENT_STATE.RESIGNED + } + if ( + signals.registeredStatusCode !== null && + signals.registeredStatusCode !== undefined && + STATUS_CODE_ACTIVE.includes(signals.registeredStatusCode) + ) { + return EMPLOYMENT_STATE.ACTIVE + } + return EMPLOYMENT_STATE.UNKNOWN +} diff --git a/lib/organization-sync.ts b/lib/organization-sync.ts index 252c859..51139d2 100644 --- a/lib/organization-sync.ts +++ b/lib/organization-sync.ts @@ -9,14 +9,17 @@ * * 2. 在职状态——上游口径混杂:数字码(2 试用 / 3 正式 / 5 已离职)、中文(正式/离职/试用) * 与空值并存。此前用「离职/停用/退休」做关键词包含匹配,对数字码和空值完全失效, - * 已核实 216 条已离职档案中有 215 条被判定为在职。现改为接入离职表 + 状态码, - * 结果写入规范化的 employmentState 字段,原始值保留作审计。 + * 已核实 216 条已离职档案中有 215 条被判定为在职。现综合离职表、花名册原始状态与 + * 注册表状态码,结果写入规范化的 employmentState 字段,原始值保留作审计。 * * 本模块独立于既有的 performance-survey-report-sync,不改动那条链路。 */ import mssql from 'mssql' import { prisma } from '@/lib/prisma' +import { EMPLOYMENT_STATE, resolveEmploymentState } from '@/lib/employment-state' + +export { EMPLOYMENT_STATE, resolveEmploymentState } from '@/lib/employment-state' const GROUP_TABLE = { schema: process.env.REPORT_DB_GROUP_SCHEMA || 'rpt', @@ -31,16 +34,6 @@ const REGISTER_TABLE = { table: process.env.REPORT_DB_REGISTER_TABLE || 'hr_employee_register', } -/** 上游员工状态码。5 已离职,2 试用、3 正式均视为在职 */ -const STATUS_CODE_RESIGNED = 5 -const STATUS_CODE_ACTIVE = [2, 3] - -export const EMPLOYMENT_STATE = { - ACTIVE: 'ACTIVE', - RESIGNED: 'RESIGNED', - UNKNOWN: 'UNKNOWN', -} as const - function quoteIdentifier(value: string) { return '[' + String(value).replace(/]/g, ']]') + ']' } @@ -315,9 +308,10 @@ export interface EmploymentSyncResult { * * 判定优先级: * 1. 出现在离职表中 → RESIGNED(同时落最后工作日与交接人) - * 2. 状态码 5 → RESIGNED - * 3. 状态码 2 / 3 → ACTIVE - * 4. 其余 → UNKNOWN(不臆断,交由人工核对) + * 2. 花名册原始状态明确离职 → RESIGNED(包括缺少钉钉 userid 的档案) + * 3. 状态码 5 → RESIGNED + * 4. 状态码 2 / 3 → ACTIVE + * 5. 其余 → UNKNOWN(不臆断,交由人工核对) */ export async function syncEmploymentStates(options: { dryRun?: boolean } = {}): Promise { const { dryRun = false } = options @@ -363,7 +357,7 @@ export async function syncEmploymentStates(options: { dryRun?: boolean } = {}): }) const profiles = await prisma.performanceSurveyEmployeeProfile.findMany({ - select: { id: true, dingtalkUserId: true, employmentState: true }, + select: { id: true, dingtalkUserId: true, employmentStatus: true, employmentState: true }, }) let markedResigned = 0 @@ -375,22 +369,26 @@ export async function syncEmploymentStates(options: { dryRun?: boolean } = {}): let nextState: string = EMPLOYMENT_STATE.UNKNOWN let resignedAt: Date | null = null let handover: string | null = null + let hasResignationRecord = false + let registeredStatusCode: number | null = null if (ding) { const resigned = resignedMap.get(ding) if (resigned) { - nextState = EMPLOYMENT_STATE.RESIGNED + hasResignationRecord = true resignedAt = resigned.at handover = resigned.handover } else { - const code = statusMap.get(ding) - if (code === STATUS_CODE_RESIGNED) nextState = EMPLOYMENT_STATE.RESIGNED - else if (code !== null && code !== undefined && STATUS_CODE_ACTIVE.includes(code)) { - nextState = EMPLOYMENT_STATE.ACTIVE - } + registeredStatusCode = statusMap.get(ding) ?? null } } + nextState = resolveEmploymentState({ + hasResignationRecord, + rawEmploymentStatus: profile.employmentStatus, + registeredStatusCode, + }) + if (nextState === EMPLOYMENT_STATE.RESIGNED) markedResigned += 1 else if (nextState === EMPLOYMENT_STATE.ACTIVE) markedActive += 1 else markedUnknown += 1