132 lines
8.1 KiB
TypeScript
132 lines
8.1 KiB
TypeScript
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)]
|
||
}))
|
||
}
|