635 lines
34 KiB
TypeScript
635 lines
34 KiB
TypeScript
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
|
||
}
|