gongxiangp2/lib/art-workflow-points.ts

132 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { resolveArtWorkflowStandardDays, type DingWorkflowDeliveryAssessment } from './ding-workflow-delivery'
import type { DingWorkflowDetailFormFields, OngoingDingWorkflowActiveTask } from './ding-workflow-sync'
export type ArtPointPerson = { id: string; dingtalkUserId: string | null; realName: string | null; flowerName: string | null; displayName: string }
type StatRow = Record<string, unknown>
export type ArtPointAdjustment = { workflowKey: string; name?: string; role?: string; type?: string; points?: number; enabled?: boolean }
export type ArtPointConfiguration = { deductions: ArtPointAdjustment[]; overtimeExemptions: ArtPointAdjustment[] }
export const EMPTY_ART_POINT_CONFIG: ArtPointConfiguration = { deductions: [], overtimeExemptions: [] }
export function parseArtPointRows(value: unknown): StatRow[] {
try {
const rows = typeof value === 'string' ? JSON.parse(value) : value
if (!Array.isArray(rows)) return []
return rows.flatMap(row => Array.isArray(row?.rowValue)
? [Object.fromEntries(row.rowValue.filter((cell: any) => typeof cell?.label === 'string').map((cell: any) => [cell.label.trim(), cell.value]))]
: [])
} catch { return [] }
}
const text = (value: unknown): string => typeof value === 'string' ? value.trim() : ''
const number = (value: unknown) => {
const parsed = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value.replace(/,/g, '')) : 0
return Number.isFinite(parsed) ? parsed : 0
}
const round = (value: number) => Math.round(value * 100) / 100
/** shujuku2 uses activity IDs and the latest finished review, not final approval. */
export function assessArtWorkflowPoints(tasks: OngoingDingWorkflowActiveTask[], fields: DingWorkflowDetailFormFields): DingWorkflowDeliveryAssessment {
const times = (activity: string, field: 'createdAt' | 'finishedAt') => tasks
.filter(task => task.activityId === activity)
.flatMap(task => { const time = task[field] ? Date.parse(task[field]!) : NaN; return Number.isFinite(time) ? [time] : [] })
const assignments = times('38da_544a', 'createdAt')
const start = assignments.length ? Math.min(...assignments) : null
const reviews = times('c255_84b3', 'finishedAt').filter(time => start === null || time >= start)
const finish = reviews.length ? Math.max(...reviews) : null
const standardDuration = resolveArtWorkflowStandardDays(fields, start)
const actualDuration = start !== null && finish !== null ? round((finish - start) / 86_400_000) : null
return {
status: standardDuration !== null && actualDuration !== null ? actualDuration <= standardDuration ? 'on_time' : 'overtime' : 'pending',
standardDuration, actualDuration, durationUnit: 'day',
startedAt: start === null ? null : new Date(start).toISOString(),
finishedAt: finish === null ? null : new Date(finish).toISOString(),
startNodeLabel: '品牌策划主管分配任务', endNodeLabel: '品牌策划主管审核',
}
}
/** Preserve the source status-bucket ordering, including returned RUNNING flows. */
export function isRejectedArtPointWorkflow(status: string, result: string | null) {
const state = status.trim().toUpperCase(), outcome = (result || '').trim().toUpperCase()
if (state.includes('RUNNING') || state.includes('PROCESS') || state === 'NEW') return false
return outcome.includes('REFUSE') || outcome.includes('REJECT') || state.includes('TERMINATE')
}
export function independentArtAdjustmentDate(workflowKey: string) {
const match = workflowKey.match(/^(\d{4})(\d{2})(\d{2})/)
const date = match ? `${match[1]}-${match[2]}-${match[3]}` : ''
return date >= '2026-06-01' ? date : null
}
/** Unique HR identities only. Ambiguous names are never merged into a salary person. */
export function artPointPersonResolver(people: ArtPointPerson[]) {
const aliases = new Map<string, Set<string>>()
const dingtalk = new Map(people.filter(person => person.dingtalkUserId).map(person => [person.dingtalkUserId!, person.id]))
for (const person of people) for (const alias of [person.realName, person.flowerName, person.displayName].filter(Boolean) as string[]) {
const ids = aliases.get(alias.trim()) ?? new Set<string>(); ids.add(person.id); aliases.set(alias.trim(), ids)
}
return (value: unknown): string | null => {
if (Array.isArray(value)) return value.length === 1 ? resolve(value[0]) : null
return resolve(value)
}
function resolve(value: unknown): string | null {
if (value && typeof value === 'object') {
const contact = value as Record<string, unknown>
const id = text(contact.emplId ?? contact.userId)
if (id) return dingtalk.get(id) ?? null
return resolve(contact.name)
}
if (typeof value === 'string' && /^[\[{]/.test(value.trim())) {
try {
const contact = JSON.parse(value)
return Array.isArray(contact) ? contact.length === 1 ? resolve(contact[0]) : null : resolve(contact)
} catch { return null }
}
const alias = text(value), ids = aliases.get(alias)
return dingtalk.get(alias) ?? (ids?.size === 1 ? Array.from(ids)[0] : null)
}
}
/** Port of shujuku2 artDesignDashboardSync: quantities × unit scores, ceil overtime × 5,
* one penalty per person/workflow, role exemptions, then manual additions/deductions.
* Caller supplies the review-month and non-rejected workflow filters. */
export function calculateArtWorkflowPoints(input: {
workflowKey: string; planningRows: StatRow[]; graphicRows: StatRow[]; imageRows: StatRow[]
plannerId: string | null; assessment: DingWorkflowDeliveryAssessment
}, people: ArtPointPerson[], config = EMPTY_ART_POINT_CONFIG) {
const resolve = artPointPersonResolver(people)
const roles = new Map<string, { planner: number; fullCase: number }>()
const add = (id: string, role: 'planner' | 'fullCase', value: number) => {
const row = roles.get(id) ?? { planner: 0, fullCase: 0 }; row[role] += value; roles.set(id, row)
}
if (input.plannerId) add(input.plannerId, 'planner', input.planningRows.reduce((sum, row) => sum + number(row['策划数量']) * number(row['策划单分值']), 0))
for (const [rows, prefix] of [[input.graphicRows, '平面'], [input.imageRows, '出图']] as const) {
for (const row of rows) {
const id = resolve(row['全案策划'])
if (id) add(id, 'fullCase', number(row[`${prefix}数量`]) * number(row[`${prefix}单分值`]))
}
}
const participants = new Set(roles.keys())
// Manual-only adjustments do not make someone a workflow participant or incur overtime.
const adjustments = config.deductions.filter(item => item.enabled !== false && item.workflowKey === input.workflowKey)
for (const item of adjustments) {
const id = resolve(item.name)
if (id && !roles.has(id) && independentArtAdjustmentDate(input.workflowKey)) roles.set(id, { planner: 0, fullCase: 0 })
}
const assessment = input.assessment
const penalty = assessment.actualDuration !== null && assessment.standardDuration !== null
? Math.max(0, Math.ceil(assessment.actualDuration - assessment.standardDuration)) * 5 : 0
return new Map(Array.from(roles).map(([id, scores]) => {
const role = id === input.plannerId && !(scores.planner > 0 && scores.fullCase > scores.planner)
? 'planner' : scores.planner >= scores.fullCase && scores.planner > 0 ? 'planner' : 'fullCase'
const exempt = config.overtimeExemptions.some(item => item.workflowKey === input.workflowKey && (!item.name || resolve(item.name) === id) && (!item.role || item.role === role))
const personAdjustments = adjustments.filter(item => resolve(item.name) === id)
const sumAdjustment = (addition: boolean) => round(personAdjustments.filter(item => (item.type === 'add') === addition).reduce((sum, item) => sum + Math.max(0, number(item.points)), 0))
const adjustment = round(sumAdjustment(true) - sumAdjustment(false))
const deduction = exempt || !participants.has(id) ? 0 : penalty
// Source rounds each role contribution before adding them to the person's total.
const planner = round(scores.planner + (role === 'planner' ? adjustment - deduction : 0))
const fullCase = round(scores.fullCase + (role === 'fullCase' ? adjustment - deduction : 0))
return [id, round(planner + fullCase)]
}))
}