gongxiangp2/lib/performance-salary-workflows.ts

96 lines
6.4 KiB
TypeScript

import { prisma } from './prisma'
import { SalaryError } from './personnel-salary'
import { readArtPointConfiguration } from './art-workflow-point-configuration'
import { assessDingWorkflowDeliveryBatch } from './ding-workflow-delivery'
import { applyDeliveryExemption, deliveryExemptWorkflowKeys } from './workflow-delivery-exemptions'
import {
DING_WORKFLOW_ALLOWED_PROCESS_CODES, DING_WORKFLOW_STARTED_AT_CUTOFF_ISO,
getShanghaiMonthRangeForValue, isDingWorkflowApprovedCompletion, isDingWorkflowDeliveryAssessmentInRange,
normalizeDetailFormFields, normalizeWorkflowTasks, storedWorkflowTimestampToIso,
} from './ding-workflow-sync'
import {
artPointPersonResolver, assessArtWorkflowPoints, calculateArtWorkflowPoints, independentArtAdjustmentDate,
isRejectedArtPointWorkflow, parseArtPointRows, type ArtPointPerson,
} from './art-workflow-points'
const ART_PRODUCTION = new Set(['31cf_5b23', 'a9e1_5be0', 'de46_708d', '2f9e_36f0', 'b306_c849', 'f073_738b', '6864_5429', '162b_23ea', 'c9fe_a6b8'])
const CONTENT_PRODUCTION = new Set(['编导', '拍摄', '摄像', '剪辑', '修图', '布局拍摄', '后期制作'])
export async function readSalaryWorkflowMetrics(sourceMonth: string, people: ArtPointPerson[]) {
const [rows, config] = await Promise.all([
prisma.dingWorkflowSnapshot.findMany({ where: { processCode: { in: [...DING_WORKFLOW_ALLOWED_PROCESS_CODES] }, sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO } } }),
readArtPointConfiguration(),
])
const workflowInput = (row: typeof rows[number]) => ({
processCode: row.processCode!.toUpperCase(), workflowStatus: row.status, workflowId: row.processInstanceId, businessId: row.businessId,
sourceCreatedAt: storedWorkflowTimestampToIso(row.sourceCreatedAtRaw), sourceFinishedAt: storedWorkflowTimestampToIso(row.sourceFinishedAtRaw),
tasks: normalizeWorkflowTasks(row.activeTasks), formFields: normalizeDetailFormFields(row.formFields),
})
// Keep the delivery population and queue calculation; only override a judged overtime result.
const candidates = rows.filter(row => row.status.trim().toUpperCase() !== 'COMPLETED' || isDingWorkflowApprovedCompletion(row.status, row.result, row.bizAction))
const inputs = candidates.map(workflowInput)
const exemptKeys = deliveryExemptWorkflowKeys(config)
const assessments = assessDingWorkflowDeliveryBatch(inputs).map((assessment, index) =>
applyDeliveryExemption(assessment, candidates[index].businessId || candidates[index].processInstanceId, exemptKeys))
const range = getShanghaiMonthRangeForValue(sourceMonth)
const byDingId = new Map(people.filter(person => person.dingtalkUserId).map(person => [person.dingtalkUserId, person.id]))
const points = new Map<string, number>()
const delivery = new Map<string, { onTime: number; total: number }>()
const resolve = artPointPersonResolver(people)
candidates.forEach((row, index) => {
const input = inputs[index], assessment = assessments[index]
if (!isDingWorkflowApprovedCompletion(row.status, row.result, row.bizAction) || !isDingWorkflowDeliveryAssessmentInRange(assessment, range)) return
const art = input.processCode === DING_WORKFLOW_ALLOWED_PROCESS_CODES[0]
const owners = new Set(input.tasks.filter(task => art
? ART_PRODUCTION.has(task.activityId || '')
: CONTENT_PRODUCTION.has(task.nodeName)).flatMap(task => {
const id = byDingId.get(task.assigneeUserId); return id ? [id] : []
}))
if (art) {
const graphicRows = parseArtPointRows(input.formFields.graphicTaskStats)
const imageRows = parseArtPointRows(input.formFields.imageTaskStats)
for (const stat of [...graphicRows, ...imageRows]) {
const id = resolve(stat['全案策划']); if (id) owners.add(id)
}
}
owners.forEach(id => {
const counts = delivery.get(id) ?? { onTime: 0, total: 0 }; counts.total++; if (assessment.status === 'on_time') counts.onTime++; delivery.set(id, counts)
})
})
// Points follow the source dashboard's review-month filter, independently of final completion/SLA availability.
const artRows = rows.filter(row => row.processCode?.toUpperCase() === DING_WORKFLOW_ALLOWED_PROCESS_CODES[0])
const workflowKeys = new Set(artRows.map(row => row.businessId || row.processInstanceId))
const selectedWorkflowKeys = new Set<string>()
const scoredWorkflowKeys = new Set<string>()
for (const row of artRows) {
const input = workflowInput(row)
const assessment = assessArtWorkflowPoints(input.tasks, input.formFields)
const finishedAt = assessment.finishedAt ? Date.parse(assessment.finishedAt) : NaN
if (!(finishedAt >= range.start.getTime() && finishedAt < range.end.getTime())) continue
if (!Object.prototype.hasOwnProperty.call(input.formFields, 'plannerOwner')) {
throw new SalaryError('流程积分正在同步,请稍后重新加载。', 503, 'WORKFLOW_POINTS_SYNCING')
}
const workflowKey = row.businessId || row.processInstanceId
selectedWorkflowKeys.add(workflowKey)
if (isRejectedArtPointWorkflow(row.status, row.result)) continue
const plannerId = resolve(input.formFields.plannerOwner)
const scores = calculateArtWorkflowPoints({
workflowKey, plannerId, planningRows: parseArtPointRows(input.formFields.planningTaskStats),
graphicRows: parseArtPointRows(input.formFields.graphicTaskStats), imageRows: parseArtPointRows(input.formFields.imageTaskStats), assessment,
}, people, config)
scores.forEach((score, id) => { points.set(id, (points.get(id) ?? 0) + score) })
scoredWorkflowKeys.add(workflowKey)
}
// Known workflows use their selected review month; unknown keys use their encoded date.
// Adjustments already applied to contributions must not be counted a second time.
for (const item of config.deductions) {
const date = independentArtAdjustmentDate(item.workflowKey)
if (item.enabled === false || !date || scoredWorkflowKeys.has(item.workflowKey)) continue
if (workflowKeys.has(item.workflowKey) ? !selectedWorkflowKeys.has(item.workflowKey) : date.slice(0, 7) !== sourceMonth) continue
const id = resolve(item.name)
if (id) points.set(id, (points.get(id) ?? 0) + (item.type === 'add' ? 1 : -1) * Math.round((item.points ?? 0) * 100) / 100)
}
points.forEach((score, id) => points.set(id, Math.round(score * 100) / 100))
return { sourceMonth, points, onTimeRates: new Map(Array.from(delivery).map(([id, counts]) => [id, counts.onTime / counts.total * 100])) }
}