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 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>() 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(); 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 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() 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)] })) }