feat: 接入本月按时交付率

This commit is contained in:
江林铖 2026-08-27 18:00:31 +08:00
parent 53b19ccd5a
commit d374a0bba2
11 changed files with 1015 additions and 43 deletions

View File

@ -0,0 +1,36 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import {
DingWorkflowMetricApiError,
getOnTimeDeliverySummary,
} from '@/lib/ding-workflow-sync'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const PRIVATE_HEADERS = {
'Cache-Control': 'private, no-store, max-age=0',
}
export async function GET() {
try {
const session = await getServerSession(authOptions)
const data = await getOnTimeDeliverySummary(session)
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
} catch (error) {
if (error instanceof DingWorkflowMetricApiError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.status, headers: PRIVATE_HEADERS },
)
}
console.error('[department-dashboard] 按时交付指标读取失败:', error)
return NextResponse.json(
{ success: false, error: '按时交付指标读取失败,请稍后重试' },
{ status: 500, headers: PRIVATE_HEADERS },
)
}
}

View File

@ -6,6 +6,7 @@ import { getCoreMetric } from '@/components/department-dashboard/core-metrics'
import { authOptions } from '@/lib/auth'
import {
getMonthlyLaunchInsights,
getOnTimeDeliveryInsights,
getOngoingDingWorkflowInsights,
} from '@/lib/ding-workflow-sync'
import {
@ -47,6 +48,12 @@ export default async function CoreMetricPage({ params, searchParams }: CoreMetri
return <CoreMetricDetail metric={metric} monthlyLaunches={monthlyLaunches} />
}
if (metric.key === 'on-time-delivery') {
const session = await getServerSession(authOptions)
const onTimeDelivery = await getOnTimeDeliveryInsights(session)
return <CoreMetricDetail metric={metric} onTimeDelivery={onTimeDelivery} />
}
if (metric.key === 'first-pass-rate') {
const session = await getServerSession(authOptions)
const firstPassPage = await getFirstPassReviewProjects(session, { page, pageSize: 20, query })

View File

@ -33,6 +33,14 @@ describe('CoreMetricDetail', () => {
spu: 'YJD-001',
demandType: '新品',
standardOutputDeadline: '5个工作日',
sourceType: null,
taskType: null,
installVideo: null,
quantity: null,
cameraQuantity: null,
planningTaskStats: null,
graphicTaskStats: null,
imageTaskStats: null,
},
assignmentNodeLabel: '品牌策划主管分配任务',
assignmentStartedAt: '2026-08-11T02:00:00.000Z',
@ -134,6 +142,14 @@ describe('CoreMetricDetail', () => {
spu: 'YJD-001',
demandType: '新品',
standardOutputDeadline: '5个工作日',
sourceType: null,
taskType: null,
installVideo: null,
quantity: null,
cameraQuantity: null,
planningTaskStats: null,
graphicTaskStats: null,
imageTaskStats: null,
},
assignmentNodeLabel: '品牌策划主管分配任务',
assignmentStartedAt: '2026-08-11T02:00:00.000Z',
@ -245,10 +261,30 @@ describe('CoreMetricDetail', () => {
expect(within(ongoingTable).getByText('陈负责人')).toBeInTheDocument()
})
it('在按时交付明细中只用黄色数值呈现阈值状态', () => {
render(<CoreMetricDetail metric={getCoreMetric('on-time-delivery')!} />)
it('在按时交付明细中展示真实公式并只用黄色数值呈现阈值状态', () => {
render(
<CoreMetricDetail
metric={getCoreMetric('on-time-delivery')!}
onTimeDelivery={{
items: [],
summary: {
onTime: 946,
overtime: 54,
pending: 0,
total: 1000,
rate: 94.6,
monthStart: '2026-07-31T16:00:00.000Z',
monthEnd: '2026-08-31T16:00:00.000Z',
lastSyncedAt: '2026-08-14T02:30:00.000Z',
syncStatus: 'ready',
},
}}
/>,
)
expect(screen.getByLabelText('按时交付示例值94.6%,黄色区间90%–95%')).toHaveClass('text-[#a88a00]')
expect(screen.getByLabelText('按时交付当前值94.6%,黄色区间90%–95%')).toHaveClass('text-[#a88a00]')
expect(screen.getByText('本月按时交付率 = 本月未超时完结流程数 ÷ 本月全部完结流程数 × 100%')).toBeInTheDocument()
expect(screen.getByText('当前口径:946 ÷ 1000 = 94.6% · 超时 54 · 待计算 0')).toBeInTheDocument()
expect(screen.queryByText('黄色区间 · 90%–95%')).not.toBeInTheDocument()
})

View File

@ -15,6 +15,7 @@ import type {
} from '@/lib/work-review-service'
import type {
MonthlyLaunchInsights,
OnTimeDeliveryInsights,
OngoingDingWorkflowInsights,
} from '@/lib/ding-workflow-sync'
import DepartmentShell from './DepartmentShell'
@ -28,6 +29,7 @@ type CoreMetricDetailProps = {
query?: string
projectInsights?: OngoingDingWorkflowInsights
monthlyLaunches?: MonthlyLaunchInsights
onTimeDelivery?: OnTimeDeliveryInsights
firstPassPage?: FirstPassReviewProjectPage
}
@ -249,11 +251,111 @@ function PendingDataSource({ metric }: { metric: CoreMetric }) {
)
}
export default function CoreMetricDetail({ metric, query, projectInsights, monthlyLaunches, firstPassPage }: CoreMetricDetailProps) {
function formatDuration(value: number | null, unit: 'day' | 'hour') {
if (value === null) return '—'
const amount = Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2)
return `${amount} ${unit === 'day' ? '天' : '小时'}`
}
function DeliveryStatusBadge({ status }: { status: 'on_time' | 'overtime' | 'pending' }) {
const styles = status === 'on_time'
? 'bg-emerald-50 text-emerald-800'
: status === 'overtime'
? 'bg-red-50 text-red-700'
: 'bg-amber-50 text-amber-800'
const label = status === 'on_time' ? '未超时' : status === 'overtime' ? '已超时' : '待计算'
return <span className={`inline-flex min-h-7 items-center rounded-lg px-2.5 text-xs font-bold ${styles}`}>{label}</span>
}
function OnTimeDeliveryInsightsView({ insights }: { insights: OnTimeDeliveryInsights }) {
const { items, summary } = insights
return (
<section aria-labelledby="on-time-delivery-list-title" className="mt-10">
<div className="rounded-lg border border-[#e2e8f0] bg-white px-5 py-4">
<p className="text-xs font-bold text-slate-500">统计公式</p>
<p className="mt-1 text-sm font-bold text-slate-900">
本月按时交付率 = 本月未超时完结流程数 ÷ 本月全部完结流程数 × 100%
</p>
<p className="mt-2 text-xs tabular-nums text-slate-500">
当前口径:{summary.onTime} ÷ {summary.total} = {formatRate(summary.rate)} · 超时 {summary.overtime} · 待计算 {summary.pending}
</p>
</div>
<div className="mt-8 flex flex-wrap items-end justify-between gap-3">
<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">共 {summary.total} 条</p>
</div>
{items.length === 0 ? (
<div role="status" className="mt-6 rounded-lg border border-[#e2e8f0] bg-white px-5 py-14 text-center">
<Database className="mx-auto h-8 w-8 text-slate-400" aria-hidden="true" />
<h3 className="mt-4 text-base font-bold text-slate-900">本月暂无完结流程</h3>
<p className="mt-2 text-sm text-slate-600">流程完结并完成每小时同步后,将自动进入本月交付时效统计。</p>
</div>
) : (
<div className="mt-6 overflow-x-auto rounded-lg border border-[#e2e8f0] bg-white">
<table aria-label="本月完结流程按时交付明细" className="min-w-[1180px] w-full text-left text-sm">
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
<tr>
<th scope="col" className="w-[25%] px-5 py-3.5">流程</th>
<th scope="col" className="w-[14%] px-4 py-3.5">发起人 / 部门</th>
<th scope="col" className="w-[22%] px-4 py-3.5">计时节点</th>
<th scope="col" className="w-[13%] px-4 py-3.5">标准时效</th>
<th scope="col" className="w-[13%] px-4 py-3.5">实际用时</th>
<th scope="col" className="w-[13%] px-5 py-3.5">判定</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{items.map((item) => (
<tr key={item.id} className="align-top transition-colors hover:bg-slate-50">
<td className="px-5 py-4">
<p className="font-bold text-slate-950">{item.title}</p>
<p className="mt-1 text-xs tabular-nums text-slate-500">{item.businessId || item.id}</p>
<p className="mt-1 text-xs font-semibold text-[#176fc1]">{item.projectType}</p>
</td>
<td className="px-4 py-4">
<p className="font-bold text-slate-900">{item.initiatorName}</p>
<p className="mt-1 leading-5 text-slate-500">{item.initiatorDepartment}</p>
</td>
<td className="px-4 py-4">
<p className="font-semibold text-slate-800">{item.assessment.startNodeLabel}</p>
<p className="mt-1 tabular-nums text-slate-500">{formatDate(item.assessment.startedAt, true)}</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, true)}</p>
</td>
<td className="px-4 py-4 font-bold tabular-nums text-slate-900">
{formatDuration(item.assessment.standardDuration, item.assessment.durationUnit)}
</td>
<td className="px-4 py-4 font-bold tabular-nums text-slate-900">
{formatDuration(item.assessment.actualDuration, item.assessment.durationUnit)}
</td>
<td className="px-5 py-4">
<DeliveryStatusBadge status={item.assessment.status} />
{item.assessment.status === 'pending' && (
<p className="mt-2 max-w-40 text-xs leading-5 text-slate-500">缺少标准配置或关键节点时间</p>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
export default function CoreMetricDetail({ metric, query, projectInsights, monthlyLaunches, onTimeDelivery, firstPassPage }: CoreMetricDetailProps) {
const hasRealProjectSource = metric.key === 'ongoing-projects' && projectInsights
const hasMonthlyLaunchSource = metric.key === 'monthly-launches' && monthlyLaunches
const hasFirstPassSource = metric.key === 'first-pass-rate' && firstPassPage
const isOnTimeDelivery = metric.key === 'on-time-delivery'
const hasOnTimeDeliverySource = metric.key === 'on-time-delivery' && onTimeDelivery
return (
<DepartmentShell>
@ -265,14 +367,14 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month
<section aria-label="指标口径" className="mt-6 grid overflow-hidden rounded-lg border border-[#e2e8f0] bg-white sm:grid-cols-3">
<div className="border-b border-slate-200 p-5 sm:border-b-0 sm:border-r">
<p className="text-xs font-bold text-slate-500">{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : hasMonthlyLaunchSource ? '本月已完结 / 新品流程总数' : '总览示例值'}</p>
{isOnTimeDelivery && metric.exampleNumericValue !== undefined
? <OnTimeDeliveryRate rate={metric.exampleNumericValue} contextLabel="按时交付示例值" size="summary" />
: <p className="mt-2 text-2xl font-bold tabular-nums">{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectInsights.total : hasMonthlyLaunchSource ? `${monthlyLaunches.summary.completed}/${monthlyLaunches.summary.total}` : metric.exampleValue}</p>}
<p className="text-xs font-bold text-slate-500">{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : hasMonthlyLaunchSource ? '本月已完结 / 新品流程总数' : hasOnTimeDeliverySource ? '本月按时交付率' : '总览示例值'}</p>
{hasOnTimeDeliverySource && onTimeDelivery.summary.rate !== null
? <OnTimeDeliveryRate rate={onTimeDelivery.summary.rate} contextLabel="按时交付当前值" size="summary" />
: <p className="mt-2 text-2xl font-bold tabular-nums">{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectInsights.total : hasMonthlyLaunchSource ? `${monthlyLaunches.summary.completed}/${monthlyLaunches.summary.total}` : hasOnTimeDeliverySource ? '—' : metric.exampleValue}</p>}
</div>
<div className="border-b border-slate-200 p-5 sm:border-b-0 sm:border-r">
<p className="text-xs font-bold text-slate-500">明细数据</p>
<p className="mt-2 text-base font-bold">{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : hasMonthlyLaunchSource ? `本月已完结 ${monthlyLaunches.summary.completed} / 当前进行中 ${monthlyLaunches.summary.ongoing}` : '等待业务数据源'}</p>
<p className="mt-2 text-base font-bold">{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : hasMonthlyLaunchSource ? `本月已完结 ${monthlyLaunches.summary.completed} / 当前进行中 ${monthlyLaunches.summary.ongoing}` : hasOnTimeDeliverySource ? `未超时 ${onTimeDelivery.summary.onTime} / 本月完结 ${onTimeDelivery.summary.total}` : '等待业务数据源'}</p>
</div>
<div className="p-5">
<p className="text-xs font-bold text-slate-500">同步状态</p>
@ -286,6 +388,10 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month
? monthlyLaunches.summary.lastSyncedAt
? `每小时增量同步 · ${formatDate(monthlyLaunches.summary.lastSyncedAt, true)}`
: monthlyLaunches.summary.syncStatus === 'error' ? '首轮同步失败' : '等待首轮同步'
: hasOnTimeDeliverySource
? onTimeDelivery.summary.lastSyncedAt
? `每小时增量同步 · ${formatDate(onTimeDelivery.summary.lastSyncedAt, true)}`
: onTimeDelivery.summary.syncStatus === 'error' ? '首轮同步失败' : '等待首轮同步'
: hasFirstPassSource ? '打开页面时读取' : '每日同步待接入'}
</p>
</div>
@ -295,6 +401,8 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month
? <OngoingProjectsInsights insights={projectInsights} />
: hasMonthlyLaunchSource
? <MonthlyLaunchesInsights insights={monthlyLaunches} />
: hasOnTimeDeliverySource
? <OnTimeDeliveryInsightsView insights={onTimeDelivery} />
: hasFirstPassSource
? <FirstPassProjectList metric={metric} query={query} firstPassPage={firstPassPage} />
: <PendingDataSource metric={metric} />}

View File

@ -32,6 +32,21 @@ describe('CoreMetricsDashboard', () => {
syncStatus: 'ready',
},
}
: url.includes('on-time-delivery')
? {
success: true,
data: {
onTime: 12,
overtime: 1,
pending: 0,
total: 13,
rate: 92.3,
monthStart: '2026-07-31T16:00:00.000Z',
monthEnd: '2026-08-31T16:00:00.000Z',
lastSyncedAt: '2026-08-14T02:00:00.000Z',
syncStatus: 'ready',
},
}
: {
success: true,
data: {
@ -49,7 +64,7 @@ describe('CoreMetricsDashboard', () => {
jest.restoreAllMocks()
})
it('展示真实流程指标、首轮通过率与其余三项诚实的示例状态', async () => {
it('展示真实流程指标、首轮通过率与其余两项诚实的示例状态', async () => {
render(<CoreMetricsDashboard />)
expect(screen.getByRole('heading', { level: 1, name: '核心指标' })).toBeInTheDocument()
@ -58,24 +73,24 @@ describe('CoreMetricsDashboard', () => {
expect(screen.getByText('V1通过 11 / 全部 30')).toBeInTheDocument()
expect(screen.getByText('实时读取')).toBeInTheDocument()
expect(screen.getAllByText('每小时同步').length).toBeGreaterThan(0)
expect(screen.getAllByText('示例值')).toHaveLength(3)
expect(screen.getAllByText('示例值')).toHaveLength(2)
for (const label of ['进行中项目', '本月上新', '按时交付', '首轮通过', '团队负荷', '风险']) {
expect(screen.getByRole('heading', { level: 3, name: label })).toBeInTheDocument()
}
for (const value of ['2338', '8/13', '94.6%', '36.7%', '87%', '4项']) {
for (const value of ['2338', '8/13', '92.3%', '36.7%', '87%', '4项']) {
expect(screen.getByText(value)).toBeInTheDocument()
}
expect(screen.getByText('本月已完结 8 · 当前进行中 5')).toBeInTheDocument()
expect(screen.getAllByText('公式待接入')).toHaveLength(3)
expect(screen.getAllByText('公式待接入')).toHaveLength(2)
expect(screen.getAllByText('钉钉流程快照').length).toBeGreaterThan(0)
expect(screen.getByText('口径已定义')).toBeInTheDocument()
expect(screen.getByText('未超时 12 / 本月完结 13')).toBeInTheDocument()
expect(screen.getAllByText('查看明细')).toHaveLength(6)
const onTimeDelivery = screen.getByRole('link', { name: '查看按时交付明细' })
expect(within(onTimeDelivery).getByLabelText('按时交付示例值94.6%,黄色区间90%–95%')).toHaveClass('text-[#a88a00]')
expect(within(onTimeDelivery).getByLabelText('按时交付当前值92.3%,黄色区间90%–95%')).toHaveClass('text-[#a88a00]')
expect(within(onTimeDelivery).queryByText('黄色区间 · 90%–95%')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: '查看进行中项目明细' })).toHaveAttribute(
'href',
@ -92,8 +107,8 @@ describe('CoreMetricsDashboard', () => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, json: async () => ({ success: false }) }) as jest.Mock
render(<CoreMetricsDashboard />)
expect(await screen.findAllByText('读取失败')).toHaveLength(3)
expect(await screen.findAllByText('读取失败')).toHaveLength(4)
expect(screen.queryByText('82.3%')).not.toBeInTheDocument()
expect(screen.getAllByText('数据未加载')).toHaveLength(3)
expect(screen.getAllByText('数据未加载')).toHaveLength(4)
})
})

View File

@ -9,6 +9,7 @@ import {
import type { FirstPassReviewSummary } from '@/lib/work-review-metrics'
import type {
MonthlyLaunchSummary,
OnTimeDeliverySummary,
OngoingDingWorkflowSummary,
} from '@/lib/ding-workflow-sync'
import DepartmentShell from './DepartmentShell'
@ -30,6 +31,11 @@ type MonthlyLaunchState =
| { status: 'success'; data: MonthlyLaunchSummary }
| { status: 'error'; data: null }
type OnTimeDeliveryState =
| { status: 'loading'; data: null }
| { status: 'success'; data: OnTimeDeliverySummary }
| { status: 'error'; data: null }
function formatRate(rate: number | null) {
if (rate === null) return '—'
return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%`
@ -51,6 +57,7 @@ export default function CoreMetricsDashboard() {
const [firstPass, setFirstPass] = useState<FirstPassState>({ status: 'loading', data: null })
const [ongoingWorkflows, setOngoingWorkflows] = useState<OngoingWorkflowState>({ status: 'loading', data: null })
const [monthlyLaunches, setMonthlyLaunches] = useState<MonthlyLaunchState>({ status: 'loading', data: null })
const [onTimeDelivery, setOnTimeDelivery] = useState<OnTimeDeliveryState>({ status: 'loading', data: null })
useEffect(() => {
const controller = new AbortController()
@ -97,9 +104,24 @@ export default function CoreMetricsDashboard() {
}
}
async function loadOnTimeDeliverySummary() {
try {
const response = await fetch('/api/department-dashboard/core-metrics/on-time-delivery', {
cache: 'no-store',
signal: controller.signal,
})
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success || !payload.data) throw new Error('指标读取失败')
setOnTimeDelivery({ status: 'success', data: payload.data })
} catch (error) {
if ((error as Error).name !== 'AbortError') setOnTimeDelivery({ status: 'error', data: null })
}
}
void loadFirstPassSummary()
void loadOngoingWorkflowSummary()
void loadMonthlyLaunchSummary()
void loadOnTimeDeliverySummary()
return () => controller.abort()
}, [])
@ -155,6 +177,16 @@ export default function CoreMetricsDashboard() {
? '同步异常'
: '等待首轮同步'
: monthlyLaunches.status === 'error' ? '读取失败' : '正在读取'
: isOnTimeDelivery
? onTimeDelivery.status === 'success'
? onTimeDelivery.data.syncStatus === 'ready'
? '每小时同步'
: onTimeDelivery.data.syncStatus === 'syncing'
? '正在同步'
: onTimeDelivery.data.syncStatus === 'error'
? '同步异常'
: '等待首轮同步'
: onTimeDelivery.status === 'error' ? '读取失败' : '正在读取'
: isFirstPass
? firstPass.status === 'success' ? '实时读取' : firstPass.status === 'error' ? '读取失败' : '正在读取'
: '示例值'
@ -162,6 +194,8 @@ export default function CoreMetricsDashboard() {
? ongoingWorkflows.status === 'success' ? String(ongoingWorkflows.data.total) : '—'
: isMonthlyLaunch
? monthlyLaunches.status === 'success' ? `${monthlyLaunches.data.completed}/${monthlyLaunches.data.total}` : '—'
: isOnTimeDelivery
? onTimeDelivery.status === 'success' ? formatRate(onTimeDelivery.data.rate) : '—'
: isFirstPass
? firstPass.status === 'success' ? formatRate(firstPass.data.rate) : '—'
: metric.exampleValue
@ -183,11 +217,11 @@ export default function CoreMetricsDashboard() {
</div>
<h3 className="mt-6 text-base font-bold text-[#4f6077]">{metric.label}</h3>
<div aria-live={isFirstPass || isOngoingWorkflow || isMonthlyLaunch ? 'polite' : undefined} aria-atomic={isFirstPass || isOngoingWorkflow || isMonthlyLaunch ? 'true' : undefined}>
{isOnTimeDelivery && metric.exampleNumericValue !== undefined
? <OnTimeDeliveryRate rate={metric.exampleNumericValue} contextLabel={`${metric.label}示例值`} />
<div aria-live={isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery ? 'polite' : undefined} aria-atomic={isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery ? 'true' : undefined}>
{isOnTimeDelivery && onTimeDelivery.status === 'success' && onTimeDelivery.data.rate !== null
? <OnTimeDeliveryRate rate={onTimeDelivery.data.rate} contextLabel={`${metric.label}当前值`} />
: (
<p className="mt-2 text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl" aria-label={`${metric.label}${isFirstPass || isOngoingWorkflow || isMonthlyLaunch ? '当前值' : '示例值'}${value}`}>
<p className="mt-2 text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl" aria-label={`${metric.label}${isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery ? '当前值' : '示例值'}${value}`}>
{value}
</p>
)}
@ -215,6 +249,14 @@ export default function CoreMetricsDashboard() {
{isMonthlyLaunch && monthlyLaunches.status === 'loading' && (
<p className="mt-2 text-xs font-semibold text-slate-500">正在读取本地流程快照</p>
)}
{isOnTimeDelivery && onTimeDelivery.status === 'success' && (
<p className="mt-2 text-xs font-semibold tabular-nums text-slate-500">
未超时 {onTimeDelivery.data.onTime} / 本月完结 {onTimeDelivery.data.total}
</p>
)}
{isOnTimeDelivery && onTimeDelivery.status === 'loading' && (
<p className="mt-2 text-xs font-semibold text-slate-500">正在计算本月交付时效</p>
)}
</div>
<p className="mt-3 text-sm leading-6 text-[#66758b]">{metric.description}</p>
@ -222,13 +264,13 @@ export default function CoreMetricsDashboard() {
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-slate-500">
<span className="inline-flex items-center gap-1.5">
<CalendarClock className="h-3.5 w-3.5" aria-hidden="true" />
{isOngoingWorkflow || isMonthlyLaunch
? (isOngoingWorkflow ? ongoingWorkflows.status : monthlyLaunches.status) === 'error' ? '刷新页面重试' : '每小时增量同步'
{isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery
? (isOngoingWorkflow ? ongoingWorkflows.status : isMonthlyLaunch ? monthlyLaunches.status : onTimeDelivery.status) === 'error' ? '刷新页面重试' : '每小时增量同步'
: isFirstPass ? (firstPass.status === 'error' ? '刷新页面重试' : '打开页面时读取') : '每日同步'}
</span>
<span aria-hidden="true">·</span>
<span>{isOngoingWorkflow || isMonthlyLaunch
? (isOngoingWorkflow ? ongoingWorkflows.status : monthlyLaunches.status) === 'error' ? '数据未加载' : '钉钉流程快照'
<span>{isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery
? (isOngoingWorkflow ? ongoingWorkflows.status : isMonthlyLaunch ? monthlyLaunches.status : onTimeDelivery.status) === 'error' ? '数据未加载' : '钉钉流程快照'
: isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '口径已定义') : '公式待接入'}</span>
</span>
<span className="inline-flex items-center gap-1 text-[#176fc1]">

View File

@ -51,10 +51,10 @@ export const CORE_METRICS: CoreMetric[] = [
label: '按时交付',
exampleValue: '94.6%',
exampleNumericValue: 94.6,
description: '在约定节点内完成交付的项目占比。',
description: '本月未超时完结流程占本月全部完结流程的比例。',
icon: Clock3,
detailDescription: '用于核对计划交付时间、实际交付时间与时间偏差。',
plannedFields: ['项目', '计划时间', '实际时间', '时间偏差', '结果'],
detailDescription: '查看本月完结流程的标准时效、实际用时与超时判定;美术和内容流程分别沿用对应外部看板的计算规则。',
plannedFields: ['流程', '发起人', '计时节点', '标准时效', '实际用时', '判定结果'],
},
{
key: 'first-pass-rate',

View File

@ -0,0 +1,154 @@
import { assessDingWorkflowDelivery } from '@/lib/ding-workflow-delivery'
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'
function formFields(overrides: Partial<DingWorkflowDetailFormFields> = {}): DingWorkflowDetailFormFields {
return {
platform: null,
brand: null,
category: null,
spu: null,
demandType: null,
standardOutputDeadline: null,
sourceType: null,
taskType: null,
installVideo: null,
quantity: null,
cameraQuantity: null,
planningTaskStats: null,
graphicTaskStats: null,
imageTaskStats: null,
...overrides,
}
}
function task(
nodeName: string,
createdAt: string,
finishedAt: string | null,
taskId = nodeName,
): OngoingDingWorkflowActiveTask {
return {
taskId,
activityId: null,
nodeName,
assigneeUserId: null,
assigneeName: '测试人员',
assigneeDepartment: '品牌中心',
isBrandCenter: true,
isLeadership: false,
isActive: finishedAt === null,
reviewPassed: finishedAt !== null,
result: finishedAt === null ? null : 'AGREE',
createdAt,
finishedAt,
}
}
describe('Ding workflow delivery SLA', () => {
it('uses post-July art task statistics and wall-clock days', () => {
const assessment = assessDingWorkflowDelivery({
processCode: ART_PROCESS_CODE,
sourceCreatedAt: '2026-07-10T00:00:00.000Z',
sourceFinishedAt: '2026-07-11T00:00:00.000Z',
formFields: formFields({
planningTaskStats: JSON.stringify([{
rowValue: [
{ label: '策划类型', value: '新品策划全案/新品全套图文' },
{ label: '策划细分', value: '全套新品的策划方案(主图视频脚本/策划方案/买家秀文案)' },
{ label: '策划数量', value: '1' },
],
}]),
}),
tasks: [
task('品牌策划主管分配任务', '2026-07-10T00:00:00.000Z', '2026-07-10T00:05:00.000Z'),
task('品牌策划主管审核', '2026-07-11T12:00:00.000Z', '2026-07-11T12:00:00.000Z'),
],
})
expect(assessment).toMatchObject({
status: 'on_time',
standardDuration: 2,
durationUnit: 'day',
actualDuration: 1.5,
startNodeLabel: '品牌策划主管分配任务',
endNodeLabel: '品牌策划主管审核',
})
})
it('uses the legacy art demand/source matrix before July', () => {
const assessment = assessDingWorkflowDelivery({
processCode: ART_PROCESS_CODE,
sourceCreatedAt: '2026-04-20T00:00:00.000Z',
sourceFinishedAt: '2026-04-24T00:00:00.000Z',
formFields: formFields({ demandType: '新品', sourceType: '仅出图' }),
tasks: [
task('品牌策划主管分配任务', '2026-04-20T00:00:00.000Z', '2026-04-20T00:05:00.000Z'),
task('品牌策划主管审核', '2026-04-24T00:00:00.000Z', '2026-04-24T00:00:00.000Z'),
],
})
expect(assessment).toMatchObject({ status: 'overtime', standardDuration: 3, actualDuration: 4 })
})
it('ends director-review content rules at 编导审核', () => {
const assessment = assessDingWorkflowDelivery({
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-01T00:00:00.000Z', '2026-08-01T00:05:00.000Z'),
task('编导审核', '2026-08-03T00:00:00.000Z', '2026-08-03T00:00:00.000Z'),
],
})
expect(assessment).toMatchObject({
status: 'on_time',
standardDuration: 72,
durationUnit: 'hour',
actualDuration: 48,
endNodeLabel: '编导审核',
})
})
it('ends content-supervisor rules at 内容主管审核', () => {
const assessment = assessDingWorkflowDelivery({
processCode: CONTENT_PROCESS_CODE,
sourceCreatedAt: '2026-08-01T00:00:00.000Z',
sourceFinishedAt: '2026-08-02T06:00:00.000Z',
formFields: formFields({ demandType: '白底图-策划专用', taskType: '布局拍摄' }),
tasks: [
task('内容负责人分配任务', '2026-08-01T00:00:00.000Z', '2026-08-01T00:05:00.000Z'),
task('内容主管审核', '2026-08-02T06:00:00.000Z', '2026-08-02T06:00:00.000Z'),
],
})
expect(assessment).toMatchObject({
status: 'overtime',
standardDuration: 24,
durationUnit: 'hour',
actualDuration: 30,
startNodeLabel: '内容主管分配任务',
endNodeLabel: '内容主管审核',
})
})
it('keeps missing configuration or milestone data pending', () => {
const assessment = assessDingWorkflowDelivery({
processCode: CONTENT_PROCESS_CODE,
sourceCreatedAt: '2026-08-01T00:00:00.000Z',
sourceFinishedAt: null,
formFields: formFields({ demandType: '未知类型', taskType: '布局拍摄' }),
tasks: [],
})
expect(assessment).toMatchObject({
status: 'pending',
standardDuration: null,
actualDuration: null,
})
})
})

View File

@ -84,7 +84,7 @@ describe('ding workflow snapshot sync semantics', () => {
it('upgrades the legacy workflow-only cursor to a three-table cursor', () => {
expect(parseDingWorkflowSourceCursor('2026-08-26 19:42:36')).toEqual({
version: 2,
version: 3,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: null,
schemeId: null,
@ -93,7 +93,7 @@ describe('ding workflow snapshot sync semantics', () => {
it('round-trips the Workflow, Task and Scheme incremental cursors', () => {
const cursor = {
version: 2 as const,
version: 3 as const,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: 2248339,
schemeId: 10634556,
@ -103,7 +103,7 @@ describe('ding workflow snapshot sync semantics', () => {
it('preserves empty Task and Scheme cursors so the next run performs alignment', () => {
const cursor = {
version: 2 as const,
version: 3 as const,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: null,
schemeId: null,
@ -111,14 +111,28 @@ describe('ding workflow snapshot sync semantics', () => {
expect(parseDingWorkflowSourceCursor(serializeDingWorkflowSourceCursor(cursor))).toEqual(cursor)
})
it('rejects malformed three-table cursor ids and safely requests re-alignment', () => {
it('upgrades version 2 cursors and requests one full Task and Scheme alignment', () => {
expect(parseDingWorkflowSourceCursor(JSON.stringify({
version: 2,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: 2248339,
schemeId: 10634556,
}))).toEqual({
version: 3,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: null,
schemeId: null,
})
})
it('rejects malformed version 3 cursor ids and safely requests re-alignment', () => {
expect(parseDingWorkflowSourceCursor(JSON.stringify({
version: 3,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: -1,
schemeId: 'invalid',
}))).toEqual({
version: 2,
version: 3,
workflowUpdatedAt: '2026-08-26 19:42:36',
taskId: null,
schemeId: null,

View File

@ -0,0 +1,415 @@
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
}
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 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)
.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 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,
}
}
export function assessDingWorkflowDelivery(input: {
processCode: string
sourceCreatedAt: string | null
sourceFinishedAt: string | null
formFields: DingWorkflowDetailFormFields
tasks: OngoingDingWorkflowActiveTask[]
}): 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)
const sourceFinishTimestamp = parseIsoTime(input.sourceFinishedAt)
const finish = taskFinish || (sourceFinishTimestamp === null || !input.sourceFinishedAt
? null
: { value: input.sourceFinishedAt, timestamp: sourceFinishTimestamp })
return finishAssessment(rule?.totalHours ?? null, 'hour', start, finish, '内容主管分配任务', endNodeLabel)
}
return finishAssessment(null, 'hour', null, null, '分配任务', '审核')
}

View File

@ -4,6 +4,10 @@ import type { Session } from 'next-auth'
import mssql, { type ConnectionPool } from 'mssql'
import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access'
import { isBrandCenterOrganizationPath } from '@/lib/brand-center-access-policy'
import {
assessDingWorkflowDelivery,
type DingWorkflowDeliveryAssessment,
} from '@/lib/ding-workflow-delivery'
import { prisma } from '@/lib/prisma'
const SYNC_STATE_ID = 'ding-workflow-hourly'
@ -121,7 +125,7 @@ type SourceSchemeField = {
}
type DingWorkflowSourceCursor = {
version: 2
version: 3
workflowUpdatedAt: string | null
taskId: number | null
schemeId: number | null
@ -163,6 +167,14 @@ export type DingWorkflowDetailFormFields = {
spu: string | null
demandType: string | null
standardOutputDeadline: string | null
sourceType: string | null
taskType: string | null
installVideo: string | null
quantity: string | null
cameraQuantity: string | null
planningTaskStats: string | null
graphicTaskStats: string | null
imageTaskStats: string | null
}
export type DingWorkflowDetailMilestones = {
@ -287,6 +299,29 @@ export type MonthlyLaunchInsights = {
summary: MonthlyLaunchSummary
}
export type OnTimeDeliveryWorkflowItem = OngoingDingWorkflowListItem & {
result: string | null
sourceFinishedAt: string | null
assessment: DingWorkflowDeliveryAssessment
}
export type OnTimeDeliverySummary = {
onTime: number
overtime: number
pending: number
total: number
rate: number | null
monthStart: string
monthEnd: string
lastSyncedAt: string | null
syncStatus: DingWorkflowSyncStatus
}
export type OnTimeDeliveryInsights = {
items: OnTimeDeliveryWorkflowItem[]
summary: OnTimeDeliverySummary
}
export class DingWorkflowMetricApiError extends Error {
status: number
@ -457,6 +492,16 @@ function buildDingWorkflowDetailFormFields(fields: SourceSchemeField[]): DingWor
spu: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['SPU', 'Spu', 'spu'])),
demandType: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['需求类型'])),
standardOutputDeadline: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['标准出图最长时效限制'])),
sourceType: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['原图需求', '原图类型'])),
taskType: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['任务类型'])),
installVideo: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, [
'安装视频', '安装视频分类', '安装图纸/安装视频', '【安装图纸/视频】标准',
])),
quantity: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['数量', '需求数量', '拍摄数量'])),
cameraQuantity: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['摄像数量', '摄影数量'])),
planningTaskStats: firstSchemeValue(fields, ['策划任务统计']),
graphicTaskStats: firstSchemeValue(fields, ['平面任务统计']),
imageTaskStats: firstSchemeValue(fields, ['出图任务统计']),
}
}
@ -500,6 +545,14 @@ function normalizeDetailFormFields(value: unknown): DingWorkflowDetailFormFields
spu: normalizeDingWorkflowDetailValue(record.spu),
demandType: normalizeDingWorkflowDetailValue(record.demandType),
standardOutputDeadline: normalizeDingWorkflowDetailValue(record.standardOutputDeadline),
sourceType: normalizeDingWorkflowDetailValue(record.sourceType),
taskType: normalizeDingWorkflowDetailValue(record.taskType),
installVideo: normalizeDingWorkflowDetailValue(record.installVideo),
quantity: normalizeDingWorkflowDetailValue(record.quantity),
cameraQuantity: normalizeDingWorkflowDetailValue(record.cameraQuantity),
planningTaskStats: clean(record.planningTaskStats),
graphicTaskStats: clean(record.graphicTaskStats),
imageTaskStats: clean(record.imageTaskStats),
}
}
@ -521,7 +574,7 @@ const DETAIL_MILESTONE_NODES: Readonly<Record<string, {
},
[DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: {
assignmentNodeLabel: '内容主管分配任务',
assignmentNodeNames: ['内容主管分配任务', '内容部负责人'],
assignmentNodeNames: ['内容主管分配任务', '内容负责人分配任务', '内容部负责人'],
reviewNodes: [
{
label: '编导审核',
@ -682,28 +735,37 @@ function normalizeSourceCursorId(value: unknown): number | null {
/**
* 兼容旧版只保存 ding_Workflow.updateAt 的游标。
*
* taskId / schemeId 为空会触发一次全量对齐,然后升级为三表复合游标;
* v3 新增交付时效所需表单字段;读取旧版 v2 时保留主表游标,但清空
* Task / Scheme 游标触发一次全量对齐,之后继续按三表复合游标增量同步。
* 后续每小时只读取主表变化和 Task / Scheme 新增版本所关联的流程。
*/
export function parseDingWorkflowSourceCursor(raw: string | null): DingWorkflowSourceCursor {
const normalized = clean(raw)
if (!normalized) {
return { version: 2, workflowUpdatedAt: null, taskId: null, schemeId: null }
return { version: 3, workflowUpdatedAt: null, taskId: null, schemeId: null }
}
try {
const parsed = JSON.parse(normalized) as Record<string, unknown>
if (parsed.version === 2) {
if (parsed.version === 3) {
return {
version: 2,
version: 3,
workflowUpdatedAt: clean(parsed.workflowUpdatedAt),
taskId: normalizeSourceCursorId(parsed.taskId),
schemeId: normalizeSourceCursorId(parsed.schemeId),
}
}
if (parsed.version === 2) {
return {
version: 3,
workflowUpdatedAt: clean(parsed.workflowUpdatedAt),
taskId: null,
schemeId: null,
}
}
} catch {
// Legacy cursor is a plain ding_Workflow.updateAt string.
}
return { version: 2, workflowUpdatedAt: normalized, taskId: null, schemeId: null }
return { version: 3, workflowUpdatedAt: normalized, taskId: null, schemeId: null }
}
export function serializeDingWorkflowSourceCursor(cursor: DingWorkflowSourceCursor): string {
@ -964,7 +1026,10 @@ async function fetchWorkflowAggregates(pool: ConnectionPool, processInstanceIds:
WHERE
LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(2000), scheme.name), ''))) IN (
'平台', '品牌', '类目', 'SPU', 'Spu', 'spu', '需求类型', '标准出图最长时效限制',
'需求部门', '需求人', '任务类型'
'需求部门', '需求人', '任务类型', '原图需求', '原图类型',
'安装视频', '安装视频分类', '安装图纸/安装视频', '【安装图纸/视频】标准',
'数量', '需求数量', '拍摄数量', '摄像数量', '摄影数量',
'策划任务统计', '平面任务统计', '出图任务统计'
)
OR CONVERT(nvarchar(500), scheme.componentType) IN ('InnerContactField', 'TableField');
`)
@ -1334,7 +1399,7 @@ export async function syncDingWorkflowSnapshots() {
where: buildBrandCenterOngoingWorkflowWhere(),
})
const sourceCursorRaw = serializeDingWorkflowSourceCursor({
version: 2,
version: 3,
workflowUpdatedAt: maxSourceCursor(rows, sourceCursor.workflowUpdatedAt),
taskId: sourceDelta.maxTaskId,
schemeId: sourceDelta.maxSchemeId,
@ -1712,6 +1777,86 @@ export async function getMonthlyLaunchInsights(session: Session | null): Promise
return readMonthlyLaunchInsightsWithoutAccessCheck()
}
async function readOnTimeDeliveryInsightsWithoutAccessCheck(now = new Date()): Promise<OnTimeDeliveryInsights> {
const range = getShanghaiMonthRange(now)
const [candidates, state] = await Promise.all([
prisma.dingWorkflowSnapshot.findMany({
where: {
processCode: { in: ALLOWED_PROCESS_CODE_VALUES },
sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO },
status: 'COMPLETED',
sourceFinishedAtRaw: {
gte: range.start.toISOString(),
lt: range.end.toISOString(),
},
},
select: MONTHLY_LAUNCH_WORKFLOW_SELECT,
orderBy: [
{ sourceFinishedAtRaw: { sort: 'desc', nulls: 'last' } },
{ processInstanceId: 'desc' },
],
}),
prisma.dingWorkflowSyncState.findUnique({
where: { id: SYNC_STATE_ID },
select: { status: true, lastCompletedAt: true },
}),
])
const rows = candidates.filter((row) => (
isDingWorkflowCompletedInRange(row.status, row.sourceFinishedAtRaw, range)
))
const initiatorDirectory = await readInitiatorDirectory(rows)
const items = rows.map((row): OnTimeDeliveryWorkflowItem => {
const processCode = normalizeDingWorkflowProcessCode(row.processCode) || 'UNKNOWN'
const allTasks = normalizeWorkflowTasks(row.activeTasks)
const sourceCreatedAt = sourceWorkflowTimestampToIso(row.sourceCreatedAtRaw)
const sourceFinishedAt = sourceWorkflowTimestampToIso(row.sourceFinishedAtRaw)
return {
...mapSnapshotListItem(
row,
row.originatorUserId ? initiatorDirectory.get(row.originatorUserId.trim()) : null,
),
result: row.result,
sourceFinishedAt,
assessment: assessDingWorkflowDelivery({
processCode,
sourceCreatedAt,
sourceFinishedAt,
formFields: normalizeDetailFormFields(row.formFields),
tasks: allTasks,
}),
}
})
const onTime = items.filter((item) => item.assessment.status === 'on_time').length
const overtime = items.filter((item) => item.assessment.status === 'overtime').length
const pending = items.filter((item) => item.assessment.status === 'pending').length
const total = items.length
return {
items,
summary: {
onTime,
overtime,
pending,
total,
rate: total > 0 ? Math.round((onTime / total) * 1_000) / 10 : null,
monthStart: range.start.toISOString(),
monthEnd: range.end.toISOString(),
lastSyncedAt: state?.lastCompletedAt?.toISOString() || null,
syncStatus: normalizeSyncStatus(state),
},
}
}
export async function getOnTimeDeliverySummary(session: Session | null): Promise<OnTimeDeliverySummary> {
await assertDashboardAccess(session)
return (await readOnTimeDeliveryInsightsWithoutAccessCheck()).summary
}
export async function getOnTimeDeliveryInsights(session: Session | null): Promise<OnTimeDeliveryInsights> {
await assertDashboardAccess(session)
return readOnTimeDeliveryInsightsWithoutAccessCheck()
}
type DingWorkflowSchedulerGlobal = typeof globalThis & {
__dingWorkflowHourlySyncStarted?: boolean
}