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() const delivery = new Map() 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() const scoredWorkflowKeys = new Set() 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])) } }