96 lines
6.4 KiB
TypeScript
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])) }
|
|
}
|