fix: apply content delivery queue deferrals
This commit is contained in:
parent
a1ada79974
commit
4c08965cc7
@ -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)}
|
||||
|
||||
@ -53,7 +53,7 @@ export const CORE_METRICS: CoreMetric[] = [
|
||||
exampleNumericValue: 94.6,
|
||||
description: '本月未超时审核节点完成流程占本月审核节点完成流程的比例,待计算流程不计入分母。',
|
||||
icon: Clock3,
|
||||
detailDescription: '查看本月审核节点完成流程的标准时效、实际用时与超时判定;待计算流程不计入分母,美术和内容流程分别沿用对应外部看板的计算规则。',
|
||||
detailDescription: '查看本月审核节点完成流程的标准时效、实际用时与超时判定;待计算流程不计入分母,美术沿用外部看板规则,内容视频沿用包含排队顺延的外部看板规则。',
|
||||
plannedFields: ['流程', '发起人', '计时节点', '标准时效', '实际用时', '判定结果'],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user