fix: normalize roster resignations without ding ids

This commit is contained in:
江林铖 2026-08-20 15:49:49 +08:00
parent 887d911b76
commit d4e9a9bc87
3 changed files with 125 additions and 22 deletions

View File

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

53
lib/employment-state.ts Normal file
View File

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

View File

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