gongxiangp2/lib/__tests__/performance-salary-workflows.test.ts

227 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @jest-environment node */
import { prisma } from '../prisma'
import { readSalaryWorkflowMetrics } from '../performance-salary-workflows'
import { DING_WORKFLOW_ALLOWED_PROCESS_CODES, normalizeDetailFormFields } from '../ding-workflow-sync'
import { readFile } from 'node:fs/promises'
jest.mock('../prisma', () => ({ prisma: { dingWorkflowSnapshot: { findMany: jest.fn() } } }))
jest.mock('node:fs/promises', () => ({ readFile: jest.fn() }))
const people = [
{ id: 'worker', dingtalkUserId: 'ding-worker', realName: '测试甲', flowerName: '策划甲', displayName: '策划甲' },
{ id: 'manager', dingtalkUserId: 'ding-manager', realName: '测试乙', flowerName: '主管乙', displayName: '主管乙' },
{ id: 'requester', dingtalkUserId: 'ding-requester', realName: '需求人', flowerName: '', displayName: '需求人' },
]
const stat = (prefix: string, quantity: number, score: number) => JSON.stringify([{ rowValue: [
{ label: `${prefix}数量`, value: String(quantity) }, { label: `${prefix}单分值`, value: String(score) },
{ label: '全案策划', value: [{ emplId: 'ding-worker', name: '策划甲' }] },
{ label: `${prefix}类型`, value: '主图(平面)' }, { label: `${prefix}细分`, value: '制作(以优化主图的标准/全新设计)' },
] }])
const task = (id: string, activity: string, user: string, createdAt: string, finishedAt: string) => ({ taskId: id, activityId: activity, assigneeUserId: user, nodeName: activity === '31cf_5b23' ? '出策划案' : activity === '38da_544a' ? '品牌策划主管分配任务' : '品牌策划主管审核', isActive: false, reviewPassed: true, createdAt, finishedAt })
const art = (id: string, finish = '2026-09-10T02:00:00.000Z') => ({
processInstanceId: id, businessId: id, status: 'COMPLETED', result: 'agree', bizAction: null,
processCode: DING_WORKFLOW_ALLOWED_PROCESS_CODES[0], sourceCreatedAtRaw: '2026-09-09T00:00:00.000Z', sourceFinishedAtRaw: '2026-10-01T00:00:00.000Z', originatorUserId: 'ding-requester',
formFields: normalizeDetailFormFields({ plannerOwner: 'ding-worker', planningTaskStats: stat('策划', 2, 5), graphicTaskStats: stat('平面', 1, 20) }),
activeTasks: [task('assign', '38da_544a', 'ding-manager', '2026-09-09T02:00:00.000Z', '2026-09-09T02:00:00.000Z'), task('plan', '31cf_5b23', 'ding-worker', '2026-09-09T02:00:00.000Z', finish), task('review', 'c255_84b3', 'ding-manager', finish, finish)],
})
beforeEach(() => { delete process.env.ART_WORKFLOW_POINTS_CONFIG_DIR; delete process.env.ART_WORKFLOW_POINTS_DASHBOARD_URL; jest.resetAllMocks() })
test('按审核节点月份统计个人积分和未超时率,排除发起人、主管、拒绝及其他月份;同流程只计一次', async () => {
const rows = [art('one'), art('late', '2026-09-15T02:00:00.000Z'), { ...art('reject'), result: 'REFUSE' }, art('october', '2026-10-02T02:00:00.000Z')]
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue(rows)
const september = await readSalaryWorkflowMetrics('2026-09', people)
expect(september.sourceMonth).toBe('2026-09')
expect(september.points.has('worker')).toBe(true)
expect(september.onTimeRates.get('worker')).toBe(50)
expect(september.points.has('manager')).toBe(false)
expect(september.onTimeRates.has('manager')).toBe(false)
expect(september.onTimeRates.has('requester')).toBe(false)
const october = await readSalaryWorkflowMetrics('2026-10', people)
expect(october.onTimeRates.get('worker')).toBe(0)
})
test('空数据不伪造0积分或0%交付率', async () => {
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([])
const result = await readSalaryWorkflowMetrics('2026-09', people)
expect(result.points.size).toBe(0)
expect(result.onTimeRates.size).toBe(0)
})
test('免扣使整流程所有参与人按时,但不改变积分角色匹配和月份、分母条件', async () => {
process.env.ART_WORKFLOW_POINTS_CONFIG_DIR = '/isolated-exempt-config'
const late = art('late', '2026-09-15T02:00:00Z')
late.activeTasks.push(task('other-plan', '31cf_5b23', 'ding-manager', '2026-09-09T02:00:00Z', '2026-09-15T02:00:00Z'))
const pending = { ...art('pending'), activeTasks: [] }
const running = { ...late, processInstanceId: 'running', businessId: 'running', status: 'RUNNING' }
const rejected = { ...late, processInstanceId: 'rejected', businessId: 'rejected', result: 'REFUSE' }
const otherMonth = art('october', '2026-10-02T02:00:00Z')
const exemptions = [late, pending, running, rejected, otherMonth].map(row => ({ workflowKey: row.businessId, name: '策划甲', role: 'planner' }))
let active = true
;(readFile as jest.Mock).mockImplementation(async (path: string) => path.endsWith('deductions.json') ? '[]' : JSON.stringify(active ? [...exemptions, exemptions[0]] : []))
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([late, pending, running, rejected, otherMonth])
const exempted = await readSalaryWorkflowMetrics('2026-09', people)
expect(exempted.onTimeRates.get('worker')).toBe(100)
expect(exempted.onTimeRates.get('manager')).toBe(100)
expect(exempted.onTimeRates.has('requester')).toBe(false)
const points = exempted.points.get('worker')
active = false
const revoked = await readSalaryWorkflowMetrics('2026-09', people)
expect(revoked.onTimeRates.get('worker')).toBe(0)
expect(revoked.onTimeRates.get('manager')).toBe(0)
// Full-case is the score-bearing role; a planner-only exemption still does not waive its points deduction.
expect(revoked.points.get('worker')).toBe(points)
})
test('人工调整人员只获得调整积分,不计入流程个人交付率', async () => {
process.env.ART_WORKFLOW_POINTS_CONFIG_DIR = '/isolated-point-config'
;(readFile as jest.Mock).mockImplementation(async (path: string) => path.endsWith('deductions.json')
? JSON.stringify([{ workflowKey: '20260901-late', name: '主管乙', type: 'add', points: 3 }]) : '[]')
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([art('20260901-late', '2026-09-15T02:00:00.000Z')])
const result = await readSalaryWorkflowMetrics('2026-09', people)
expect(result.points.get('manager')).toBe(3)
expect(result.onTimeRates.has('manager')).toBe(false)
expect(result.onTimeRates.get('worker')).toBe(0)
})
test.each(['RUNNING', 'PROCESS', 'NEW'])('已结束审核的%s流程计入积分,但不改变既有交付率分母', async status => {
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([{ ...art('reviewed'), status }])
const result = await readSalaryWorkflowMetrics('2026-09', people)
expect(result.points.get('worker')).toBe(30)
expect(result.onTimeRates.size).toBe(0)
})
test('无标准时效仍计算数量积分;无审核结束记录不按流程最终完结时间替代', async () => {
const unconfigured = art('unconfigured')
unconfigured.formFields = normalizeDetailFormFields({ plannerOwner: 'ding-worker', planningTaskStats: stat('策划', 2, 5).replaceAll('主图(平面)', '未配置任务'), graphicTaskStats: stat('平面', 1, 20).replaceAll('主图(平面)', '未配置任务') })
const noReview = { ...art('no-review'), activeTasks: art('no-review').activeTasks.filter(t => t.activityId !== 'c255_84b3') }
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([unconfigured, noReview])
const result = await readSalaryWorkflowMetrics('2026-09', people)
expect(result.points.get('worker')).toBe(30)
expect(result.onTimeRates.size).toBe(0)
})
test('积分按活动ID取最后一次结束审核,不要求审核通过;交付率仍取最后一次通过审核', async () => {
const row = art('returned')
row.activeTasks.push({ ...task('returned-review', 'c255_84b3', 'ding-manager', '2026-10-02T02:00:00.000Z', '2026-10-02T02:00:00.000Z'), reviewPassed: false, nodeName: '上游节点更名' })
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([row])
const september = await readSalaryWorkflowMetrics('2026-09', people)
expect(september.points.size).toBe(0)
expect(september.onTimeRates.get('worker')).toBe(100)
const october = await readSalaryWorkflowMetrics('2026-10', people)
expect(october.points.get('worker')).toBe(30 - 21 * 5)
expect(october.onTimeRates.size).toBe(0)
})
test.each([
['COMPLETED', 'REFUSE'], ['COMPLETED', 'REJECTED'], ['TERMINATED', 'agree'],
])('拒绝/终止流程%s/%s不获得基础积分', async (status, result) => {
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([{ ...art('rejected'), status, result }])
expect((await readSalaryWorkflowMetrics('2026-09', people)).points.size).toBe(0)
})
test('退回后RUNNING沿用源码状态优先级;不把旧拒绝结果当作最终拒绝', async () => {
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([{ ...art('returned-running'), status: 'RUNNING', result: 'REFUSE' }])
expect((await readSalaryWorkflowMetrics('2026-09', people)).points.get('worker')).toBe(30)
})
test('表单策划负责人优先于任务执行人;明确空负责人不猜归属', async () => {
const formOwned = art('form-owned')
formOwned.formFields = normalizeDetailFormFields({ ...formOwned.formFields, plannerOwner: JSON.stringify([{ emplId: 'ding-manager', name: '主管乙' }]) })
const blankOwner = art('blank-owner')
blankOwner.formFields = normalizeDetailFormFields({ ...blankOwner.formFields, plannerOwner: null })
const ambiguous = art('ambiguous')
ambiguous.formFields = normalizeDetailFormFields({ ...ambiguous.formFields, plannerOwner: null })
ambiguous.activeTasks.push(task('other-plan', '31cf_5b23', 'unknown-worker', '2026-09-09T02:00:00.000Z', '2026-09-10T02:00:00.000Z'))
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([formOwned, blankOwner, ambiguous])
const result = await readSalaryWorkflowMetrics('2026-09', people)
expect(result.points.get('worker')).toBe(60) // three graphic contributions only
expect(result.points.get('manager')).toBe(10)
})
test('积分月份遵循上海时区左闭右开区间,审核前的记录和其他月份不计入', async () => {
const rows = [art('start', '2026-08-31T16:00:00.000Z'), art('end', '2026-09-30T16:00:00.000Z'), art('last', '2026-09-30T15:59:59.999Z')]
for (const row of rows) {
row.activeTasks[0].createdAt = '2026-08-30T16:00:00.000Z'
row.formFields = normalizeDetailFormFields({ plannerOwner: null, graphicTaskStats: stat('平面', 1, 20).replaceAll('主图(平面)', '未配置任务') })
}
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue(rows)
expect((await readSalaryWorkflowMetrics('2026-09', people)).points.get('worker')).toBe(40)
})
test('所选月缺少负责人字段时拒绝返回不完整积分;其他月份不被阻断', async () => {
const row = art('missing-owner')
delete row.formFields.plannerOwner
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([row])
await expect(readSalaryWorkflowMetrics('2026-09', people)).rejects.toMatchObject({ status: 503, code: 'WORKFLOW_POINTS_SYNCING' })
expect((await readSalaryWorkflowMetrics('2026-10', people)).points.size).toBe(0)
})
test('有多位历史任务执行人时仍把完整策划积分归给表单指定人员', async () => {
const row = art('multiple-assignees')
row.formFields = normalizeDetailFormFields({ plannerOwner: 'ding-worker', planningTaskStats: stat('策划', 1, 25) })
row.activeTasks.push(task('other', '31cf_5b23', 'ding-manager', '2026-09-09T02:00:00.000Z', '2026-09-10T02:00:00.000Z'))
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([row])
expect((await readSalaryWorkflowMetrics('2026-09', people)).points.get('worker')).toBe(25)
})
test('人工调整按选中审核月归属,拒绝流程仅加减分,未知流程按编码月份,均不重复计算', async () => {
process.env.ART_WORKFLOW_POINTS_CONFIG_DIR = '/isolated-point-config'
const adjustments = [
{ workflowKey: '20260801-known', name: '策划甲', type: 'add', points: 2 },
{ workflowKey: '20260801-known', name: '主管乙', type: 'add', points: 3 },
{ workflowKey: '20260901-other-month', name: '策划甲', type: 'add', points: 99 },
{ workflowKey: '20260901-rejected', name: '策划甲', type: 'deduct', points: 4 },
{ workflowKey: '20260901-no-review', name: '策划甲', type: 'add', points: 99 },
{ workflowKey: '20260901-independent', name: '策划甲', type: 'add', points: 5 },
{ workflowKey: '20260901-disabled', name: '策划甲', type: 'add', points: 99, enabled: false },
{ workflowKey: '20260501-before-cutoff', name: '策划甲', type: 'add', points: 99 },
]
;(readFile as jest.Mock).mockImplementation(async (path: string) => path.endsWith('deductions.json') ? JSON.stringify(adjustments) : '[]')
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([
{ ...art('20260801-known'), status: 'RUNNING' }, art('20260901-other-month', '2026-10-02T02:00:00.000Z'),
{ ...art('20260901-rejected'), result: 'REFUSE' },
{ ...art('20260901-no-review'), activeTasks: [] },
])
const result = await readSalaryWorkflowMetrics('2026-09', people)
expect(result.points.get('worker')).toBe(33) // 30+2-4+5
expect(result.points.get('manager')).toBe(3)
expect(result.onTimeRates.size).toBe(0)
})
test('远程免扣恢复10月15分并视为按时,其他人员和9月归属不变', async () => {
const originalFetch = global.fetch
const october = (id: string, score: number, late = false) => {
const row = art(id)
row.formFields = normalizeDetailFormFields({
plannerOwner: 'ding-worker', planningTaskStats: stat('策划', 1, score)
.replaceAll('主图(平面)', '新品/优化策划全案(策划方案+主图sku详情全套)'),
})
row.sourceCreatedAtRaw = '2026-10-15T00:00:00.000Z'
row.activeTasks = [
task('assign', '38da_544a', 'ding-manager', '2026-10-15T05:36:00.000Z', '2026-10-19T02:10:00.000Z'),
task('plan', '31cf_5b23', 'ding-worker', '2026-10-19T02:10:00.000Z', late ? '2026-10-24T08:20:00.000Z' : '2026-10-20T05:36:00.000Z'),
task('review', 'c255_84b3', 'ding-manager', '2026-10-20T05:36:00.000Z', late ? '2026-10-24T08:20:00.000Z' : '2026-10-20T05:36:00.000Z'),
]
return row
}
const other = october('other-person', 25)
other.formFields.plannerOwner = 'ding-manager'
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([
october('on-time-one', 21), october('on-time-two', 25), october('exempt', 25, true), other, art('september'),
])
const before = await readSalaryWorkflowMetrics('2026-10', people)
expect(before.points.get('worker')).toBe(56)
process.env.ART_WORKFLOW_POINTS_DASHBOARD_URL = 'https://dashboard.example.test/metrics-integration'
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({
generatedAt: '2026-10-28 12:00:06', workflows: [{ businessId: 'exempt', scoreContributions: [
{ workflowKey: 'exempt', name: '策划甲', role: 'planner', overtimeExempted: true },
] }],
}) })
try {
const after = await readSalaryWorkflowMetrics('2026-10', people)
expect(after.points.get('worker')).toBe(71)
expect(after.points.get('manager')).toBe(before.points.get('manager'))
expect(before.onTimeRates.get('worker')).toBe(75)
expect(after.onTimeRates.get('worker')).toBe(100)
expect((await readSalaryWorkflowMetrics('2026-09', people)).points.get('worker')).toBe(30)
} finally {
global.fetch = originalFetch
delete process.env.ART_WORKFLOW_POINTS_DASHBOARD_URL
}
})