diff --git a/components/department-dashboard/OnTimeDeliveryInsights.tsx b/components/department-dashboard/OnTimeDeliveryInsights.tsx index 6c3617a..6bc9f83 100644 --- a/components/department-dashboard/OnTimeDeliveryInsights.tsx +++ b/components/department-dashboard/OnTimeDeliveryInsights.tsx @@ -158,7 +158,7 @@ export default function OnTimeDeliveryInsightsView({ insights }: { insights: OnT

本月审核节点完成时效明细

- 仅统计对应审核节点在本月完成、审批状态为已完结且未被拒绝的流程;待计算流程不计入分母。美术按“品牌策划主管分配任务”至“品牌策划主管审核”计算,内容按规则计至“编导审核”或“内容主管审核”。 + 仅统计对应审核节点在本月完成、审批状态为已完结且未被拒绝的流程;待计算流程不计入分母。美术按“品牌策划主管分配任务”至“品牌策划主管审核”计算,内容按外部看板规则计至“编导审核”或“内容主管审核”,并应用排队顺延后的起算时间。

{activeLabel} · 共 {filteredSummary.total} 条

@@ -201,6 +201,11 @@ export default function OnTimeDeliveryInsightsView({ insights }: { insights: OnT

{formatDate(item.assessment.startedAt)}

{item.assessment.endNodeLabel}

{formatDate(item.assessment.finishedAt)}

+ {item.assessment.queueDeferred && ( +

+ 已按排队规则顺延 {item.assessment.queueShiftDays || 0} 天 +

+ )} {formatDuration(item.assessment.standardDuration, item.assessment.durationUnit)} diff --git a/components/department-dashboard/core-metrics.ts b/components/department-dashboard/core-metrics.ts index bd60f2d..484998a 100644 --- a/components/department-dashboard/core-metrics.ts +++ b/components/department-dashboard/core-metrics.ts @@ -53,7 +53,7 @@ export const CORE_METRICS: CoreMetric[] = [ exampleNumericValue: 94.6, description: '本月未超时审核节点完成流程占本月审核节点完成流程的比例,待计算流程不计入分母。', icon: Clock3, - detailDescription: '查看本月审核节点完成流程的标准时效、实际用时与超时判定;待计算流程不计入分母,美术和内容流程分别沿用对应外部看板的计算规则。', + detailDescription: '查看本月审核节点完成流程的标准时效、实际用时与超时判定;待计算流程不计入分母,美术沿用外部看板规则,内容视频沿用包含排队顺延的外部看板规则。', plannedFields: ['流程', '发起人', '计时节点', '标准时效', '实际用时', '判定结果'], }, { diff --git a/lib/__tests__/ding-workflow-delivery.test.ts b/lib/__tests__/ding-workflow-delivery.test.ts index 3fa8d64..1ad83ef 100644 --- a/lib/__tests__/ding-workflow-delivery.test.ts +++ b/lib/__tests__/ding-workflow-delivery.test.ts @@ -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') + }) }) diff --git a/lib/ding-workflow-delivery.ts b/lib/ding-workflow-delivery.ts index f026cfc..b1fcf0a 100644 --- a/lib/ding-workflow-delivery.ts +++ b/lib/ding-workflow-delivery.ts @@ -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 = { + '淘系-逛逛': 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>() + + 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() + 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 +} diff --git a/lib/ding-workflow-sync.ts b/lib/ding-workflow-sync.ts index 943e550..cb5178e 100644 --- a/lib/ding-workflow-sync.ts +++ b/lib/ding-workflow-sync.ts @@ -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