import type { DingWorkflowDetailFormFields, OngoingDingWorkflowActiveTask, } from '@/lib/ding-workflow-sync' const ART_PROCESS_CODE = 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838' const CONTENT_PROCESS_CODE = 'PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673' const ART_NEW_STANDARD_FROM = Date.parse('2026-06-30T16:00:00.000Z') type ArtRule = { type: string detail: string days: number byQuantity?: boolean noQuantity?: boolean capDays?: number | null noCrossStack?: boolean } type ContentRule = { demandType: string taskType: string installVideo: string quantity: string effectiveFrom?: string effectiveTo?: string totalHours: number actualCalcTo: 'directorReview' | 'contentReview' } export type DingWorkflowDeliveryStatus = 'on_time' | 'overtime' | 'pending' export type DingWorkflowDeliveryAssessment = { status: DingWorkflowDeliveryStatus standardDuration: number | null durationUnit: 'day' | 'hour' actualDuration: number | null startedAt: string | null finishedAt: string | null startNodeLabel: string endNodeLabel: string /** 内容视频外部看板的排队结果;美术或未命中排队规则时为默认值。 */ queueDeferred?: boolean queueLimit?: number | null queueSequence?: number | null queueShiftDays?: number } type DingWorkflowDeliveryInput = { processCode: string sourceCreatedAt: string | null sourceFinishedAt: string | null formFields: DingWorkflowDetailFormFields tasks: OngoingDingWorkflowActiveTask[] /** Stable DingTalk business number used by the external queue as tie-breaker. */ businessId?: string | null /** Used only by the batch queue calculation to produce a deterministic tie-breaker. */ workflowId?: string | null /** Raw workflow status; processing rows occupy queue slots, rejected rows do not. */ workflowStatus?: string | null } type ContentQueuePolicy = { key: string displayLimit: number dailyLimit: number intervalDays: number effectiveFrom: number } const CONTENT_QUEUE_SHIFT_START_HOUR = 9 const CONTENT_QUEUE_LEGACY_EFFECTIVE_FROM = Date.parse('2026-06-01T00:00:00.000+08:00') const CONTENT_QUEUE_RULE_CHANGE_EFFECTIVE_FROM = Date.parse('2026-07-20T00:00:00.000+08:00') const LEGACY_CONTENT_QUEUE_LIMITS: Record = { '淘系-逛逛': 3, '淘系主图': 3, '实拍-策划专用': 2, } const CONTENT_QUEUE_GROUP_DOUYIN_LAYOUT = new Set([ '抖音-千川素材', '抖音-账号维护(内容部专用)', '抖音千川+抖音账户维护视频', ]) const CONTENT_QUEUE_GROUP_TAOXI_LAYOUT = new Set([ '淘系-逛逛', '淘系主图', '淘系-逛逛+淘系主图视频', ]) const CONTENT_QUEUE_GROUP_TAOXI_SECOND_EDIT = new Set(['淘系-逛逛', '淘系主图']) const CONTENT_QUEUE_ASSIGNMENT_NODE_NAMES = ['内容主管分配任务', '内容负责人分配任务'] const ART_RULES: ArtRule[] = [ { type: '视觉 打板', detail: '主图+sku+详情+视频', days: 15, byQuantity: true }, { type: '品牌/品类视觉制作规范', detail: '主图或详情视觉规范制作', days: 7, byQuantity: true }, { type: '品牌/品类视觉制作规范', detail: '首图视角规范制定', days: 3, byQuantity: true }, { type: '新品/优化策划全案', detail: '全套任务(主图sku详情/策划方案/买家秀)', days: 7, byQuantity: true, capDays: 7 }, { type: '1688ai图+详情全套', detail: '主图+sku+详情', days: 1, byQuantity: true }, { type: '新品策划全案/新品全套图文', detail: '全套新品的策划方案(主图视频脚本/策划方案/买家秀文案)', days: 2, byQuantity: true }, { type: '优化策划全案/优化全套图文', detail: '全套优化的策划方案(主图视频脚本/策划方案/买家秀文案)', days: 2, byQuantity: true }, { type: 'ai视频', detail: '买家秀视频5秒(8个/套)', days: 1, byQuantity: true }, { type: 'ai视频', detail: '内容视频15秒(个)', days: 1, byQuantity: true }, { type: 'ai视频', detail: '主图视频1分钟以内', days: 1, byQuantity: true }, { type: '主图/sku图/细节图(出图)', detail: '创新出图(90%创新)3d', days: 2, noQuantity: true, capDays: 3 }, { type: '主图/sku图/细节图(出图)', detail: '部分调整出图(修改占比50%)3d', days: 1, noQuantity: true, capDays: 3 }, { type: '主图/sku图/细节图(出图)', detail: '套场景出图 3d', days: 1, noQuantity: true, capDays: 3 }, { type: 'ai场景类(出图)', detail: '局部微调整', days: 1, noQuantity: true, capDays: 3 }, { type: 'ai场景类(出图)', detail: '局部调整', days: 1, noQuantity: true, capDays: 3 }, { type: 'ai场景类(出图)', detail: '多sku场景', days: 1, noQuantity: true, capDays: 3 }, { type: 'ai场景类(出图)', detail: '单sku场景', days: 1, noQuantity: true, capDays: 3 }, { type: '模型(出图)', detail: '软包或异型结构、材质超过3个以上以及特殊难度造型 3d', days: 2, noQuantity: true, capDays: 3 }, { type: '模型(出图)', detail: '软包或异型结构、材质不超过3个 3d', days: 1, noQuantity: true, capDays: 3 }, { type: '模型(出图)', detail: '纯板式或钢架结构 无特殊造型 3d', days: 1, noQuantity: true, capDays: 3 }, { type: '模型调整(出图)', detail: '模型上传', days: 1, noQuantity: true, capDays: 2 }, { type: '模型调整(出图)', detail: '模型调整', days: 1, noQuantity: true, capDays: 2 }, { type: '批量调整(平面)', detail: '自动化表格创建', days: 1, noQuantity: true, capDays: 2 }, { type: '批量调整(平面)', detail: '批量生成', days: 1, noQuantity: true, capDays: 2 }, { type: '主图(平面)', detail: '制作(以优化主图的标准/全新设计)', days: 1, noQuantity: true, capDays: 2 }, { type: '主图(平面)', detail: '活动打标(非批量线稿/尺寸 需手动)', days: 1, noQuantity: true, capDays: 2 }, { type: '详情(平面)', detail: '详情制作(主图+SKU+详情)(超过所做页面的90%)', days: 2, noQuantity: true, capDays: 2 }, { type: '详情(平面)', detail: '部分套版(主图+SKU+详情)(所做页面的50%)', days: 1, noQuantity: true, capDays: 2 }, { type: '详情(平面)', detail: '直接套版(主图+SKU+详情)(修改文字,部分调整)', days: 1, noQuantity: true, capDays: 2 }, { type: '详情(平面)', detail: '部分调整(修改文字或某一小部分)', days: 1, noQuantity: true, capDays: 2 }, { type: '首页/二级页面/大促页面(平面)', detail: '制作', days: 3, noQuantity: true, capDays: 3 }, { type: '首页/二级页面/大促页面(平面)', detail: '调整(视情况而定)', days: 1, noQuantity: true, capDays: 3 }, { type: '首页/二级页面/大促页面(平面)', detail: '套版', days: 1, noQuantity: true, capDays: 3 }, { type: '首页/二级页面/大促页面(平面)', detail: '上传装修', days: 1, noQuantity: true, capDays: 3 }, { type: '首页/二级页面/大促页面(平面)', detail: '首页头图', days: 1, noQuantity: true, capDays: 3 }, { type: '详情关联页(平面)', detail: '制作(根据产品数量而定)', days: 1, noQuantity: true }, { type: '详情关联页(平面)', detail: '调整', days: 1, noQuantity: true }, { type: '详情关联页(平面)', detail: '信任背书', days: 1, noQuantity: true }, { type: '标签/包装/logo(平面)', detail: '制作', days: 2, byQuantity: true }, { type: '首焦/轮播/banner/海报/详情头图(平面)', detail: '制作', days: 1, noQuantity: true }, { type: '首焦/轮播/banner/海报/详情头图(平面)', detail: '调整', days: 1, noQuantity: true }, { type: '首焦/轮播/banner/海报/详情头图(平面)', detail: '直播间海报', days: 1, noQuantity: true }, { type: '资质/授权P图(平面)', detail: '调整', days: 1, noQuantity: true }, ] const CONTENT_RULES: ContentRule[] = [ { demandType: '抖音-千川素材', taskType: '布局拍摄', installVideo: '/', quantity: '/', totalHours: 72, actualCalcTo: 'directorReview' }, { demandType: '抖音-千川素材', taskType: '二次剪辑', installVideo: '/', quantity: '/', totalHours: 48, actualCalcTo: 'directorReview' }, { demandType: '抖音-账号维护(内容部专用)', taskType: '布局拍摄', installVideo: '/', quantity: '/', effectiveTo: '2026-07-19 23:59:59', totalHours: 24, actualCalcTo: 'directorReview' }, { demandType: '抖音-账号维护(内容部专用)', taskType: '布局拍摄', installVideo: '/', quantity: '/', effectiveFrom: '2026-07-20 00:00:00', totalHours: 72, actualCalcTo: 'directorReview' }, { demandType: '抖音千川+抖音账户维护视频', taskType: '布局拍摄', installVideo: '/', quantity: '/', effectiveFrom: '2026-07-20 00:00:00', totalHours: 72, actualCalcTo: 'directorReview' }, { demandType: '淘系-逛逛', taskType: '布局拍摄', installVideo: '/', quantity: '/', totalHours: 72, actualCalcTo: 'directorReview' }, { demandType: '淘系-逛逛', taskType: '二次剪辑', installVideo: '/', quantity: '/', totalHours: 48, actualCalcTo: 'directorReview' }, { demandType: '淘系主图', taskType: '布局拍摄', installVideo: '/', quantity: '/', effectiveTo: '2026-07-19 23:59:59', totalHours: 24, actualCalcTo: 'directorReview' }, { demandType: '淘系主图', taskType: '布局拍摄', installVideo: '/', quantity: '/', effectiveFrom: '2026-07-20 00:00:00', totalHours: 72, actualCalcTo: 'directorReview' }, { demandType: '淘系主图', taskType: '二次剪辑', installVideo: '/', quantity: '/', effectiveTo: '2026-05-23 23:59:59', totalHours: 16, actualCalcTo: 'directorReview' }, { demandType: '淘系-逛逛+淘系主图视频', taskType: '布局拍摄', installVideo: '/', quantity: '/', effectiveFrom: '2026-07-20 00:00:00', totalHours: 72, actualCalcTo: 'directorReview' }, { demandType: '淘系主图', taskType: '二次剪辑', installVideo: '/', quantity: '/', effectiveFrom: '2026-05-24 00:00:00', totalHours: 24, actualCalcTo: 'directorReview' }, { demandType: 'AI视频', taskType: '布局拍摄', installVideo: '/', quantity: '/', totalHours: 24, actualCalcTo: 'directorReview' }, { demandType: 'AI视频', taskType: '二次剪辑', installVideo: '/', quantity: '/', totalHours: 24, actualCalcTo: 'directorReview' }, { demandType: '实拍-策划专用', taskType: '布局拍摄', installVideo: '/', quantity: '>0<=20', effectiveTo: '2026-07-19 23:59:59', totalHours: 60, actualCalcTo: 'contentReview' }, { demandType: '实拍-策划专用', taskType: '布局拍摄', installVideo: '/', quantity: '>20', effectiveTo: '2026-07-19 23:59:59', totalHours: 72, actualCalcTo: 'contentReview' }, { demandType: '实拍-策划专用', taskType: '布局拍摄', installVideo: '/', quantity: '/', effectiveFrom: '2026-07-20 00:00:00', totalHours: 48, actualCalcTo: 'contentReview' }, { demandType: '白底图-策划专用', taskType: '布局拍摄', installVideo: '/', quantity: '/', effectiveTo: '2026-05-23 23:59:59', totalHours: 6, actualCalcTo: 'contentReview' }, { demandType: '白底图-策划专用', taskType: '布局拍摄', installVideo: '/', quantity: '/', effectiveFrom: '2026-05-24 00:00:00', totalHours: 24, actualCalcTo: 'contentReview' }, { demandType: '安装视频', taskType: '布局拍摄', installVideo: 'A档', quantity: '/', effectiveTo: '2026-07-19 23:59:59', totalHours: 24, actualCalcTo: 'contentReview' }, { demandType: '安装视频', taskType: '布局拍摄', installVideo: 'B档', quantity: '/', effectiveTo: '2026-07-19 23:59:59', totalHours: 32, actualCalcTo: 'contentReview' }, { demandType: '安装视频', taskType: '布局拍摄', installVideo: 'C档', quantity: '/', effectiveTo: '2026-07-19 23:59:59', totalHours: 48, actualCalcTo: 'contentReview' }, { demandType: '安装视频', taskType: '布局拍摄', installVideo: '/', quantity: '/', effectiveFrom: '2026-07-20 00:00:00', totalHours: 48, actualCalcTo: 'contentReview' }, ] const LEGACY_ART_RULES = [ ['新品', '仅出图', '', 2], ['新品', '仅平面', '', 3], ['新品', '仅视频', '', 2], ['新品', '出图 + 平面 + 视频', '', 5], ['新品', '出图+平面', '', 4], ['优化', '仅出图', '', 3], ['优化', '仅平面', '', 2], ['优化', '仅视频', '', 1], ['优化', '出图 + 平面 + 视频', '', 5], ['优化', '出图+平面', '', 4], ['产品改图', '仅出图', '', 2], ['产品改图', '仅平面', '', 3], ['产品改图', '仅视频', '', 2], ['产品改图', '出图 + 平面 + 视频', '', 5], ['产品改图', '出图+平面', '', 4], ['新品', '仅出图', '2026-04-19 00:00:00', 3], ['新品', '仅平面', '2026-04-19 00:00:00', 1], ['新品', '仅视频', '2026-04-19 00:00:00', 1], ['新品', '出图 + 平面 + 视频', '2026-04-19 00:00:00', 5], ['新品', '出图+平面', '2026-04-19 00:00:00', 4], ['优化', '仅出图', '2026-04-19 00:00:00', 3], ['优化', '仅平面', '2026-04-19 00:00:00', 1], ['优化', '仅视频', '2026-04-19 00:00:00', 1], ['优化', '出图 + 平面 + 视频', '2026-04-19 00:00:00', 5], ['优化', '出图+平面', '2026-04-19 00:00:00', 4], ['产品改图', '仅出图', '2026-04-19 00:00:00', 3], ['产品改图', '仅平面', '2026-04-19 00:00:00', 1], ['产品改图', '仅视频', '2026-04-19 00:00:00', 1], ['产品改图', '出图 + 平面 + 视频', '2026-04-19 00:00:00', 5], ['产品改图', '出图+平面', '2026-04-19 00:00:00', 4], ] as const function normalized(value: unknown) { return String(value ?? '').replace(/\u00a0/g, ' ').trim().replace(/\s+/g, ' ') } function canonical(value: unknown) { return normalized(value).toLowerCase().replace(/\s+/g, '') } function parseIsoTime(value: string | null | undefined) { if (!value) return null const parsed = Date.parse(value) return Number.isFinite(parsed) ? parsed : null } function roundTwo(value: number) { return Math.round(value * 100) / 100 } function parseFirstNumber(value: unknown) { const match = normalized(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/) const numeric = match ? Number(match[0]) : Number.NaN return Number.isFinite(numeric) ? numeric : null } function parseStatRows(rawValue: string | null) { if (!rawValue) return [] as Record[] try { const parsed = JSON.parse(rawValue) if (!Array.isArray(parsed)) return [] return parsed.flatMap((item) => { const rowValue = item && typeof item === 'object' ? (item as { rowValue?: unknown }).rowValue : null if (!Array.isArray(rowValue)) return [] const row: Record = {} rowValue.forEach((cell) => { if (!cell || typeof cell !== 'object') return const record = cell as { label?: unknown; value?: unknown } const label = normalized(record.label) if (!label) return const value = Array.isArray(record.value) ? record.value.map((entry) => normalized((entry as { name?: unknown })?.name ?? entry)).filter(Boolean).join(',') : normalized(record.value) row[label] = value }) return Object.keys(row).length > 0 ? [row] : [] }) } catch { return [] } } function isFullCase(type: string, detail: string) { const text = canonical(`${type}${detail}`) return text.includes(canonical('新品/优化策划全案')) || text.includes(canonical('策划方案+主图sku详情全套')) || text.includes(canonical('全套任务(主图sku详情/策划方案/买家秀)')) } function findArtRule(type: string, detail: string) { const typeKey = canonical(type) const detailKey = canonical(detail) return ART_RULES.find((rule) => { const ruleType = canonical(rule.type) return (typeKey === ruleType || typeKey.includes(ruleType) || ruleType.includes(typeKey)) && detailKey === canonical(rule.detail) }) || null } function artCapGroup(rule: ArtRule) { if (!rule.capDays) return null const type = canonical(rule.type) if (rule.capDays === 7) return '策划全案7天封顶组' if (rule.capDays === 3) return '出图3天封顶组' if (rule.capDays === 2 && type.includes(canonical('模型调整'))) return '模型调整2天封顶组' if (rule.capDays === 2 && ['批量调整', '主图(平面)', '详情(平面)'].some((name) => type.includes(canonical(name)))) { return '平面基础2天封顶组' } return `${rule.capDays}天封顶组` } function resolveNewArtStandardDays(fields: DingWorkflowDetailFormFields) { const configs = [ { rows: parseStatRows(fields.planningTaskStats), type: '策划类型', detail: '策划细分', quantity: '策划数量' }, { rows: parseStatRows(fields.imageTaskStats), type: '出图类型', detail: '出图细分', quantity: '出图数量' }, { rows: parseStatRows(fields.graphicTaskStats), type: '平面类型', detail: '平面细分', quantity: '平面数量' }, ] const entries: Array<{ type: string; detail: string; quantity: number; rule: ArtRule; capGroup: string | null }> = [] for (const config of configs) { for (const row of config.rows) { const type = normalized(row[config.type]) const detail = normalized(row[config.detail]) const parsedQuantity = Number(normalized(row[config.quantity])) const quantity = Number.isFinite(parsedQuantity) ? parsedQuantity : 1 if (quantity <= 0) continue if (isFullCase(type, detail)) return 7 const rule = findArtRule(type, detail) if (rule) entries.push({ type, detail, quantity, rule, capGroup: artCapGroup(rule) }) } } const direct: number[] = [] const capGroups = new Map() const noCrossGroups = new Map() const uniqueGroups = new Map() entries.forEach((entry) => { if (entry.capGroup && entry.rule.capDays) { const bucket = capGroups.get(entry.capGroup) || { capDays: entry.rule.capDays, entries: [] } bucket.entries.push(entry) capGroups.set(entry.capGroup, bucket) } else if (entry.rule.noCrossStack) { noCrossGroups.set(entry.type, [...(noCrossGroups.get(entry.type) || []), entry]) } else if (entry.rule.noQuantity) { const key = `${entry.type}||${entry.detail}` const current = uniqueGroups.get(key) if (!current || entry.rule.days > current.rule.days) uniqueGroups.set(key, entry) } else { direct.push(roundTwo(entry.rule.days * entry.quantity)) } }) let total = roundTwo(direct.reduce((sum, value) => sum + value, 0)) capGroups.forEach((bucket) => { const byKey = new Map() bucket.entries.forEach((entry) => { const key = `${entry.type}|${entry.detail}` const value = entry.rule.byQuantity ? roundTwo(entry.rule.days * entry.quantity) : entry.rule.days byKey.set(key, roundTwo((byKey.get(key) || 0) + value)) }) total += roundTwo(Math.min(bucket.capDays, Array.from(byKey.values()).reduce((sum, value) => sum + value, 0))) }) noCrossGroups.forEach((entriesInGroup) => { total += Math.max(0, ...entriesInGroup.map((entry) => entry.rule.byQuantity ? roundTwo(entry.rule.days * entry.quantity) : entry.rule.days)) }) uniqueGroups.forEach((entry) => { total += entry.rule.days }) return total > 0 ? roundTwo(total) : null } function normalizeLegacySourceType(value: string | null) { const text = normalized(value) if (!text) return '' if (text.includes('原图')) return '需要原图' if (text.includes('非原图') || text.includes('无需')) return '不需要原图' return text.replace(/\s*\+\s*/g, '+') } function resolveLegacyArtStandardDays(fields: DingWorkflowDetailFormFields, startedAt: number) { const demandType = normalized(fields.demandType) const sourceType = normalizeLegacySourceType(fields.sourceType) const candidates = LEGACY_ART_RULES.filter(([ruleDemand, ruleSource, effectiveFrom]) => ( ruleDemand === demandType && normalizeLegacySourceType(ruleSource) === sourceType && (!effectiveFrom || startedAt >= Date.parse(`${String(effectiveFrom).replace(' ', 'T')}+08:00`)) )) return candidates.length ? Number(candidates.at(-1)?.[3]) : null } function taskTime(task: OngoingDingWorkflowActiveTask, field: 'createdAt' | 'finishedAt') { const value = task[field] const timestamp = parseIsoTime(value) return timestamp === null ? null : { value: value as string, timestamp } } function earliestTaskTime(tasks: OngoingDingWorkflowActiveTask[], names: string[], field: 'createdAt' | 'finishedAt') { return tasks.flatMap((task) => names.includes(task.nodeName) && taskTime(task, field) ? [taskTime(task, field)!] : []) .sort((a, b) => a.timestamp - b.timestamp)[0] || null } function latestTaskTimeByField(tasks: OngoingDingWorkflowActiveTask[], names: string[], field: 'createdAt' | 'finishedAt') { return tasks.flatMap((task) => names.includes(task.nodeName) && taskTime(task, field) ? [taskTime(task, field)!] : []) .sort((a, b) => b.timestamp - a.timestamp)[0] || null } function latestTaskTime(tasks: OngoingDingWorkflowActiveTask[], names: string[], after: number | null) { return tasks.flatMap((task) => names.includes(task.nodeName) && task.isActive !== true && task.reviewPassed === true && taskTime(task, 'finishedAt') ? [taskTime(task, 'finishedAt')!] : []) .filter((entry) => after === null || entry.timestamp >= after) .sort((a, b) => b.timestamp - a.timestamp)[0] || null } function normalizeContentRuleValue(value: unknown) { const text = normalized(value) const aliases: Record = { '布景拍摄': '布局拍摄', '抖音-千川素材视频': '抖音-千川素材', '抖音-账号维护视频(内容部专用)': '抖音-账号维护(内容部专用)', '淘系-逛逛视频': '淘系-逛逛', '淘系主图视频': '淘系主图', } return aliases[text] || text } function matchContentQuantity(rule: string, quantity: number | null) { if (!rule || rule === '/') return true if (quantity === null) return false if (rule === '>0<=20') return quantity > 0 && quantity <= 20 if (rule === '>20') return quantity > 20 return quantity === Number(rule) } function resolveContentRule(fields: DingWorkflowDetailFormFields, createdAt: number) { const demandType = normalizeContentRuleValue(fields.demandType) const taskType = normalizeContentRuleValue(fields.taskType) || '布局拍摄' const installVideo = normalizeContentRuleValue(fields.installVideo) let quantity = parseFirstNumber(fields.quantity) if (quantity === null && demandType === '实拍-策划专用') quantity = parseFirstNumber(fields.cameraQuantity) ?? 1 const candidates = CONTENT_RULES.filter((rule) => { if (rule.demandType !== demandType || rule.taskType !== taskType) return false if (rule.installVideo !== '/' && rule.installVideo !== installVideo) return false if (!matchContentQuantity(rule.quantity, quantity)) return false if (rule.effectiveFrom && createdAt < Date.parse(`${rule.effectiveFrom.replace(' ', 'T')}+08:00`)) return false if (rule.effectiveTo && createdAt > Date.parse(`${rule.effectiveTo.replace(' ', 'T')}+08:00`)) return false return true }) if (candidates.length > 0) { return candidates.sort((a, b) => Number(b.installVideo !== '/') + Number(b.quantity !== '/') - Number(a.installVideo !== '/') - Number(a.quantity !== '/'))[0] } if (!['安装视频', '实拍-策划专用'].includes(demandType)) return null return CONTENT_RULES.filter((rule) => ( rule.demandType === demandType && rule.taskType === taskType && (!rule.effectiveFrom || createdAt >= Date.parse(`${rule.effectiveFrom.replace(' ', 'T')}+08:00`)) && (!rule.effectiveTo || createdAt <= Date.parse(`${rule.effectiveTo.replace(' ', 'T')}+08:00`)) )).sort((a, b) => a.totalHours - b.totalHours)[0] || null } function resolveContentQueuePolicy(fields: DingWorkflowDetailFormFields, createdAt: number | null): ContentQueuePolicy | null { if (createdAt === null) return null const demandType = normalizeContentRuleValue(fields.demandType) const taskType = normalizeContentRuleValue(fields.taskType) || '布局拍摄' if (createdAt >= CONTENT_QUEUE_RULE_CHANGE_EFFECTIVE_FROM) { if (taskType === '布局拍摄' && CONTENT_QUEUE_GROUP_DOUYIN_LAYOUT.has(demandType)) { return { key: 'douyin-layout-20260720', displayLimit: 3, dailyLimit: 3, intervalDays: 1, effectiveFrom: CONTENT_QUEUE_RULE_CHANGE_EFFECTIVE_FROM } } if (taskType === '二次剪辑' && demandType === '抖音-千川素材') { return { key: 'douyin-second-edit-20260720', displayLimit: 1, dailyLimit: 1, intervalDays: 1, effectiveFrom: CONTENT_QUEUE_RULE_CHANGE_EFFECTIVE_FROM } } if (taskType === '布局拍摄' && CONTENT_QUEUE_GROUP_TAOXI_LAYOUT.has(demandType)) { return { key: 'taoxi-layout-20260720', displayLimit: 3, dailyLimit: 3, intervalDays: 1, effectiveFrom: CONTENT_QUEUE_RULE_CHANGE_EFFECTIVE_FROM } } if (taskType === '二次剪辑' && CONTENT_QUEUE_GROUP_TAOXI_SECOND_EDIT.has(demandType)) { return { key: 'taoxi-second-edit-20260720', displayLimit: 1, dailyLimit: 1, intervalDays: 1, effectiveFrom: CONTENT_QUEUE_RULE_CHANGE_EFFECTIVE_FROM } } if (demandType === '实拍-策划专用') { return { key: 'realshot-20260720', displayLimit: 1, dailyLimit: 1, intervalDays: 1, effectiveFrom: CONTENT_QUEUE_RULE_CHANGE_EFFECTIVE_FROM } } if (demandType === '安装视频') { return { key: 'install-video-20260720', displayLimit: 0.5, dailyLimit: 1, intervalDays: 2, effectiveFrom: CONTENT_QUEUE_RULE_CHANGE_EFFECTIVE_FROM } } return null } if (createdAt < CONTENT_QUEUE_LEGACY_EFFECTIVE_FROM) return null const legacyLimit = LEGACY_CONTENT_QUEUE_LIMITS[demandType] || 0 return legacyLimit ? { key: `legacy-${demandType}`, displayLimit: legacyLimit, dailyLimit: legacyLimit, intervalDays: 1, effectiveFrom: CONTENT_QUEUE_LEGACY_EFFECTIVE_FROM } : null } function formatShanghaiDateKey(timestamp: number) { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(new Date(timestamp)) const year = parts.find((part) => part.type === 'year')?.value const month = parts.find((part) => part.type === 'month')?.value const day = parts.find((part) => part.type === 'day')?.value return year && month && day ? `${year}-${month}-${day}` : '' } function dateAtShanghaiHour(dateKey: string, hour = CONTENT_QUEUE_SHIFT_START_HOUR) { const match = dateKey.match(/^(\d{4})-(\d{2})-(\d{2})$/) if (!match) return null const [, year, month, day] = match return Date.UTC(Number(year), Number(month) - 1, Number(day), hour, 0, 0) - 8 * 3_600_000 } function addUtcDays(dateKey: string, days: number) { const base = dateAtShanghaiHour(dateKey, 0) if (base === null) return '' return formatShanghaiDateKey(base + days * 86_400_000) } function isRejectedWorkflowStatus(value: string | null | undefined) { const status = normalized(value).toUpperCase() return status.includes('REJECT') || status.includes('TERMINAT') || status.includes('CANCEL') } function isoAtUtc(timestamp: number) { return new Date(timestamp).toISOString() } function finishAssessment( standardDuration: number | null, durationUnit: 'day' | 'hour', startedAt: { value: string; timestamp: number } | null, finishedAt: { value: string; timestamp: number } | null, startNodeLabel: string, endNodeLabel: string, ): DingWorkflowDeliveryAssessment { const divisor = durationUnit === 'day' ? 86_400_000 : 3_600_000 const actualDuration = startedAt && finishedAt ? roundTwo(Math.max(0, finishedAt.timestamp - startedAt.timestamp) / divisor) : null return { status: standardDuration !== null && actualDuration !== null ? actualDuration <= standardDuration ? 'on_time' : 'overtime' : 'pending', standardDuration, durationUnit, actualDuration, startedAt: startedAt?.value || null, finishedAt: finishedAt?.value || null, startNodeLabel, endNodeLabel, queueDeferred: false, queueLimit: null, queueSequence: null, queueShiftDays: 0, } } export function assessDingWorkflowDelivery(input: DingWorkflowDeliveryInput): DingWorkflowDeliveryAssessment { const createdAt = parseIsoTime(input.sourceCreatedAt) if (input.processCode === ART_PROCESS_CODE) { const start = earliestTaskTime(input.tasks, ['品牌策划主管分配任务'], 'createdAt') const finish = latestTaskTime(input.tasks, ['品牌策划主管审核'], start?.timestamp ?? null) const standard = start ? start.timestamp >= ART_NEW_STANDARD_FROM ? resolveNewArtStandardDays(input.formFields) : resolveLegacyArtStandardDays(input.formFields, start.timestamp) : null return finishAssessment(standard, 'day', start, finish, '品牌策划主管分配任务', '品牌策划主管审核') } if (input.processCode === CONTENT_PROCESS_CODE) { const assignmentNames = ['内容主管分配任务', '内容负责人分配任务'] const start = earliestTaskTime(input.tasks, assignmentNames, 'createdAt') || earliestTaskTime(input.tasks, ['编导'], 'createdAt') || (createdAt === null || !input.sourceCreatedAt ? null : { value: input.sourceCreatedAt, timestamp: createdAt }) const rule = createdAt === null ? null : resolveContentRule(input.formFields, createdAt) const endNodeLabel = rule?.actualCalcTo === 'contentReview' ? '内容主管审核' : '编导审核' const endNames = rule?.actualCalcTo === 'contentReview' ? ['内容主管审核'] : ['编导审核'] const taskFinish = latestTaskTime(input.tasks, endNames, start?.timestamp ?? null) // 内容流程的实际用时必须截止到规则指定的审核节点;流程实例的最终 // 完结时间可能晚于审核,不能在缺少审核任务时作为替代时间。 return finishAssessment(rule?.totalHours ?? null, 'hour', start, taskFinish, '内容主管分配任务', endNodeLabel) } return finishAssessment(null, 'hour', null, null, '分配任务', '审核') } /** * Apply the content-video dashboard's queue deferral rules to a set of * workflows. Queue capacity is shared by the same policy group and ordered by * assignment time (then workflow id), matching the external dashboard. The * shift changes the assessment start clock; the SLA standard itself is not * inflated. A single-workflow assessment intentionally remains unshifted, so * callers that do not have the surrounding queue still get deterministic data. */ export function assessDingWorkflowDeliveryBatch(inputs: DingWorkflowDeliveryInput[]): DingWorkflowDeliveryAssessment[] { const assessments = inputs.map((input) => assessDingWorkflowDelivery(input)) const queueGroups = new Map>() inputs.forEach((input, index) => { if (input.processCode !== CONTENT_PROCESS_CODE || isRejectedWorkflowStatus(input.workflowStatus)) return const policy = resolveContentQueuePolicy(input.formFields, parseIsoTime(input.sourceCreatedAt)) if (!policy) return // The external dashboard schedules by the latest assignment timestamp, // while the unqueued assessment still starts at the first assignment. const baseStart = latestTaskTimeByField(input.tasks, CONTENT_QUEUE_ASSIGNMENT_NODE_NAMES, 'createdAt')?.timestamp ?? parseIsoTime(assessments[index].startedAt) if (baseStart === null) return const entries = queueGroups.get(policy.key) || [] entries.push({ index, policy, baseStart, workflowId: normalized(input.businessId) || normalized(input.workflowId) || String(index).padStart(8, '0'), }) queueGroups.set(policy.key, entries) }) queueGroups.forEach((entries: Array<{ index: number policy: ContentQueuePolicy baseStart: number workflowId: string }>) => { entries.sort((a: typeof entries[number], b: typeof entries[number]) => a.baseStart - b.baseStart || a.workflowId.localeCompare(b.workflowId, 'zh-Hans-CN')) const dayCounts = new Map() let lastScheduledDateKey = '' for (const entry of entries) { const { index, policy, baseStart } = entry const baseDateKey = formatShanghaiDateKey(baseStart) let targetDateKey = baseDateKey const nextAvailableDateKey = policy.intervalDays > 1 && lastScheduledDateKey ? addUtcDays(lastScheduledDateKey, policy.intervalDays) : '' if (nextAvailableDateKey && targetDateKey < nextAvailableDateKey) { targetDateKey = nextAvailableDateKey } while ((dayCounts.get(targetDateKey) || 0) >= policy.dailyLimit) { targetDateKey = addUtcDays(targetDateKey, policy.intervalDays) } const queueSequence = (dayCounts.get(targetDateKey) || 0) + 1 dayCounts.set(targetDateKey, queueSequence) if (!lastScheduledDateKey || targetDateKey > lastScheduledDateKey) { lastScheduledDateKey = targetDateKey } const scheduledStart = targetDateKey === baseDateKey ? baseStart : dateAtShanghaiHour(targetDateKey) if (scheduledStart === null) continue const shiftDays = Math.max(0, Math.round( ((dateAtShanghaiHour(targetDateKey, 0) || 0) - (dateAtShanghaiHour(baseDateKey, 0) || 0)) / 86_400_000, )) const assessment = assessments[index] assessment.queueDeferred = shiftDays > 0 assessment.queueLimit = policy.displayLimit assessment.queueSequence = queueSequence assessment.queueShiftDays = shiftDays assessment.startedAt = isoAtUtc(scheduledStart) const finishedAt = parseIsoTime(assessment.finishedAt) assessment.actualDuration = finishedAt === null ? null : roundTwo(Math.max(0, finishedAt - scheduledStart) / 3_600_000) assessment.status = assessment.standardDuration !== null && assessment.actualDuration !== null ? assessment.actualDuration <= assessment.standardDuration ? 'on_time' : 'overtime' : 'pending' } }) return assessments }