gongxiangp2/lib/ding-workflow-delivery.ts

635 lines
34 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 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<string, number> = {
'淘系-逛逛': 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<string, string>[]
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<string, string> = {}
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<string, { capDays: number; entries: typeof entries }>()
const noCrossGroups = new Map<string, typeof entries>()
const uniqueGroups = new Map<string, typeof entries[number]>()
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<string, number>()
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<string, string> = {
'布景拍摄': '布局拍摄',
'抖音-千川素材视频': '抖音-千川素材',
'抖音-账号维护视频(内容部专用)': '抖音-账号维护(内容部专用)',
'淘系-逛逛视频': '淘系-逛逛',
'淘系主图视频': '淘系主图',
}
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<string, Array<{
index: number
policy: ContentQueuePolicy
baseStart: number
workflowId: string
}>>()
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<string, number>()
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
}