fix: normalize roster resignations without ding ids
This commit is contained in:
parent
887d911b76
commit
d4e9a9bc87
52
lib/__tests__/organization-sync.test.ts
Normal file
52
lib/__tests__/organization-sync.test.ts
Normal 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
53
lib/employment-state.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user