227 lines
15 KiB
TypeScript
227 lines
15 KiB
TypeScript
/** @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
|
||
}
|
||
})
|