fix: apply content delivery queue deferrals

This commit is contained in:
江林铖 2026-08-28 14:12:02 +08:00
parent a1ada79974
commit 4c08965cc7
5 changed files with 336 additions and 22 deletions

View File

@ -158,7 +158,7 @@ export default function OnTimeDeliveryInsightsView({ insights }: { insights: OnT
<div>
<h2 id="on-time-delivery-list-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">本月审核节点完成时效明细</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-600">
仅统计对应审核节点在本月完成、审批状态为已完结且未被拒绝的流程;待计算流程不计入分母。美术按“品牌策划主管分配任务”至“品牌策划主管审核”计算,内容按规则计至“编导审核”或“内容主管审核”。
仅统计对应审核节点在本月完成、审批状态为已完结且未被拒绝的流程;待计算流程不计入分母。美术按“品牌策划主管分配任务”至“品牌策划主管审核”计算,内容按外部看板规则计至“编导审核”或“内容主管审核”,并应用排队顺延后的起算时间。
</p>
</div>
<p className="text-sm font-semibold tabular-nums text-slate-600" aria-live="polite">{activeLabel} · 共 {filteredSummary.total} 条</p>
@ -201,6 +201,11 @@ export default function OnTimeDeliveryInsightsView({ insights }: { insights: OnT
<p className="mt-1 tabular-nums text-slate-500">{formatDate(item.assessment.startedAt)}</p>
<p className="mt-3 font-semibold text-slate-800">{item.assessment.endNodeLabel}</p>
<p className="mt-1 tabular-nums text-slate-500">{formatDate(item.assessment.finishedAt)}</p>
{item.assessment.queueDeferred && (
<p className="mt-2 text-xs font-semibold text-amber-700">
已按排队规则顺延 {item.assessment.queueShiftDays || 0} 天
</p>
)}
</td>
<td className="px-4 py-4 font-bold tabular-nums text-slate-900">
{formatDuration(item.assessment.standardDuration, item.assessment.durationUnit)}

View File

@ -53,7 +53,7 @@ export const CORE_METRICS: CoreMetric[] = [
exampleNumericValue: 94.6,
description: '本月未超时审核节点完成流程占本月审核节点完成流程的比例,待计算流程不计入分母。',
icon: Clock3,
detailDescription: '查看本月审核节点完成流程的标准时效、实际用时与超时判定;待计算流程不计入分母,美术和内容流程分别沿用对应外部看板的计算规则。',
detailDescription: '查看本月审核节点完成流程的标准时效、实际用时与超时判定;待计算流程不计入分母,美术沿用外部看板规则,内容视频沿用包含排队顺延的外部看板规则。',
plannedFields: ['流程', '发起人', '计时节点', '标准时效', '实际用时', '判定结果'],
},
{

View File

@ -1,4 +1,4 @@
import { assessDingWorkflowDelivery } from '@/lib/ding-workflow-delivery'
import { assessDingWorkflowDelivery, assessDingWorkflowDeliveryBatch } from '@/lib/ding-workflow-delivery'
import type { DingWorkflowDetailFormFields, OngoingDingWorkflowActiveTask } from '@/lib/ding-workflow-sync'
const ART_PROCESS_CODE = 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838'
@ -197,4 +197,88 @@ describe('Ding workflow delivery SLA', () => {
actualDuration: null,
})
})
it('defers content assessment start when the daily queue is full', () => {
const inputs = [
{
processCode: CONTENT_PROCESS_CODE,
sourceCreatedAt: '2026-08-01T00:00:00.000Z',
sourceFinishedAt: '2026-08-02T00:00:00.000Z',
formFields: formFields({ demandType: '抖音-千川素材', taskType: '布局拍摄' }),
tasks: [
task('内容主管分配任务', '2026-08-01T01:00:00.000Z', '2026-08-01T01:00:00.000Z'),
task('编导审核', '2026-08-02T00:00:00.000Z', '2026-08-02T00:00:00.000Z'),
],
workflowId: 'a',
workflowStatus: 'COMPLETED',
},
{
processCode: CONTENT_PROCESS_CODE,
sourceCreatedAt: '2026-08-01T00:00:00.000Z',
sourceFinishedAt: '2026-08-03T00:00:00.000Z',
formFields: formFields({ demandType: '抖音-千川素材', taskType: '布局拍摄' }),
tasks: [
task('内容主管分配任务', '2026-08-01T02:00:00.000Z', '2026-08-01T02:00:00.000Z'),
task('编导审核', '2026-08-03T00:00:00.000Z', '2026-08-03T00:00:00.000Z'),
],
workflowId: 'b',
workflowStatus: 'COMPLETED',
},
{
processCode: CONTENT_PROCESS_CODE,
sourceCreatedAt: '2026-08-01T00:00:00.000Z',
sourceFinishedAt: '2026-08-04T00:00:00.000Z',
formFields: formFields({ demandType: '抖音-千川素材', taskType: '布局拍摄' }),
tasks: [
task('内容主管分配任务', '2026-08-01T03:00:00.000Z', '2026-08-01T03:00:00.000Z'),
task('编导审核', '2026-08-04T00:00:00.000Z', '2026-08-04T00:00:00.000Z'),
],
workflowId: 'c',
workflowStatus: 'COMPLETED',
},
{
processCode: CONTENT_PROCESS_CODE,
sourceCreatedAt: '2026-08-01T00:00:00.000Z',
sourceFinishedAt: '2026-08-05T00:00:00.000Z',
formFields: formFields({ demandType: '抖音-千川素材', taskType: '布局拍摄' }),
tasks: [
task('内容主管分配任务', '2026-08-01T04:00:00.000Z', '2026-08-01T04:00:00.000Z'),
task('编导审核', '2026-08-05T00:00:00.000Z', '2026-08-05T00:00:00.000Z'),
],
workflowId: 'd',
workflowStatus: 'COMPLETED',
},
]
const assessments = assessDingWorkflowDeliveryBatch(inputs)
expect(assessments.slice(0, 3).map((assessment) => assessment.queueShiftDays)).toEqual([0, 0, 0])
expect(assessments[3]).toMatchObject({
queueDeferred: true,
queueLimit: 3,
queueSequence: 1,
queueShiftDays: 1,
startedAt: '2026-08-02T01:00:00.000Z',
actualDuration: 71,
status: 'on_time',
})
})
it('uses a two-day interval for installation-video queue deferrals', () => {
const makeInput = (id: string, day: string) => ({
processCode: CONTENT_PROCESS_CODE,
sourceCreatedAt: '2026-08-01T00:00:00.000Z',
sourceFinishedAt: `2026-08-${day === '01' ? '03' : '05'}T00:00:00.000Z`,
formFields: formFields({ demandType: '安装视频', taskType: '布局拍摄', installVideo: 'A档' }),
tasks: [
task('内容主管分配任务', `2026-08-${day}T01:00:00.000Z`, `2026-08-${day}T01:00:00.000Z`),
task('内容主管审核', `2026-08-${day === '01' ? '03' : '05'}T00:00:00.000Z`, `2026-08-${day === '01' ? '03' : '05'}T00:00:00.000Z`),
],
workflowId: id,
workflowStatus: 'COMPLETED',
})
const [first, second] = assessDingWorkflowDeliveryBatch([makeInput('a', '01'), makeInput('b', '01')])
expect(first.queueShiftDays).toBe(0)
expect(second.queueShiftDays).toBe(2)
expect(second.startedAt).toBe('2026-08-03T01:00:00.000Z')
})
})

View File

@ -39,8 +39,53 @@ export type DingWorkflowDeliveryAssessment = {
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[]
/** 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 ART_RULES: ArtRule[] = [
{ type: '视觉 打板', detail: '主图+sku+详情+视频', days: 15, byQuantity: true },
{ type: '品牌/品类视觉制作规范', detail: '主图或详情视觉规范制作', days: 7, byQuantity: true },
@ -296,6 +341,11 @@ function earliestTaskTime(tasks: OngoingDingWorkflowActiveTask[], names: string[
.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) && taskTime(task, 'finishedAt') ? [taskTime(task, 'finishedAt')!] : [])
.filter((entry) => after === null || entry.timestamp >= after)
@ -348,6 +398,75 @@ function resolveContentRule(fields: DingWorkflowDetailFormFields, createdAt: num
)).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',
@ -371,16 +490,14 @@ function finishAssessment(
finishedAt: finishedAt?.value || null,
startNodeLabel,
endNodeLabel,
queueDeferred: false,
queueLimit: null,
queueSequence: null,
queueShiftDays: 0,
}
}
export function assessDingWorkflowDelivery(input: {
processCode: string
sourceCreatedAt: string | null
sourceFinishedAt: string | null
formFields: DingWorkflowDetailFormFields
tasks: OngoingDingWorkflowActiveTask[]
}): DingWorkflowDeliveryAssessment {
export function assessDingWorkflowDelivery(input: DingWorkflowDeliveryInput): DingWorkflowDeliveryAssessment {
const createdAt = parseIsoTime(input.sourceCreatedAt)
if (input.processCode === ART_PROCESS_CODE) {
const start = earliestTaskTime(input.tasks, ['品牌策划主管分配任务'], 'createdAt')
@ -411,3 +528,98 @@ export function assessDingWorkflowDelivery(input: {
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 assignmentNames = ['内容主管分配任务', '内容负责人分配任务', '内容部负责人']
const baseStart = latestTaskTimeByField(input.tasks, assignmentNames, 'createdAt')?.timestamp
?? parseIsoTime(assessments[index].startedAt)
if (baseStart === null) return
const entries = queueGroups.get(policy.key) || []
entries.push({
index,
policy,
baseStart,
workflowId: 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
}

View File

@ -5,7 +5,7 @@ import mssql, { type ConnectionPool } from 'mssql'
import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access'
import { isBrandCenterOrganizationPath } from '@/lib/brand-center-access-policy'
import {
assessDingWorkflowDelivery,
assessDingWorkflowDeliveryBatch,
type DingWorkflowDeliveryAssessment,
} from '@/lib/ding-workflow-delivery'
import { prisma } from '@/lib/prisma'
@ -1913,7 +1913,6 @@ async function readOnTimeDeliveryInsightsWithoutAccessCheck(now = new Date()): P
where: {
processCode: { in: ALLOWED_PROCESS_CODE_VALUES },
sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO },
status: 'COMPLETED',
},
select: MONTHLY_LAUNCH_WORKFLOW_SELECT,
orderBy: [
@ -1926,12 +1925,31 @@ async function readOnTimeDeliveryInsightsWithoutAccessCheck(now = new Date()): P
select: { status: true, lastCompletedAt: true },
}),
])
const rows = candidates.filter((row) => isDingWorkflowApprovedCompletion(row.status, row.result, row.bizAction))
// Keep in-flight rows while calculating the shared content queue: the
// external dashboard lets earlier in-flight assignments consume capacity,
// even though only approved COMPLETED rows enter this month's denominator.
const rows = candidates.filter((row) => (
String(row.status || '').trim().toUpperCase() !== 'COMPLETED'
|| isDingWorkflowApprovedCompletion(row.status, row.result, row.bizAction)
))
const initiatorDirectory = await readInitiatorDirectory(rows)
const assessedItems = rows.map((row): OnTimeDeliveryWorkflowItem => {
const assessmentInputs = rows.map((row) => {
const processCode = normalizeDingWorkflowProcessCode(row.processCode) || 'UNKNOWN'
const allTasks = normalizeWorkflowTasks(row.activeTasks)
const sourceCreatedAt = storedWorkflowTimestampToIso(row.sourceCreatedAtRaw)
const sourceFinishedAt = storedWorkflowTimestampToIso(row.sourceFinishedAtRaw)
return {
processCode,
sourceCreatedAt,
sourceFinishedAt,
formFields: normalizeDetailFormFields(row.formFields),
tasks: allTasks,
workflowId: clean(row.processInstanceId),
workflowStatus: clean(row.status),
}
})
const assessments = assessDingWorkflowDeliveryBatch(assessmentInputs)
const assessedItems = rows.map((row, index): OnTimeDeliveryWorkflowItem => {
const sourceFinishedAt = storedWorkflowTimestampToIso(row.sourceFinishedAtRaw)
return {
...mapSnapshotListItem(
@ -1940,17 +1958,12 @@ async function readOnTimeDeliveryInsightsWithoutAccessCheck(now = new Date()): P
),
result: row.result,
sourceFinishedAt,
assessment: assessDingWorkflowDelivery({
processCode,
sourceCreatedAt,
sourceFinishedAt,
formFields: normalizeDetailFormFields(row.formFields),
tasks: allTasks,
}),
assessment: assessments[index],
}
})
const items = assessedItems.filter((item) => (
isDingWorkflowDeliveryAssessmentInRange(item.assessment, range)
String(item.status || '').trim().toUpperCase() === 'COMPLETED'
&& isDingWorkflowDeliveryAssessmentInRange(item.assessment, range)
))
const onTime = items.filter((item) => item.assessment.status === 'on_time').length
const overtime = items.filter((item) => item.assessment.status === 'overtime').length