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