feat: 接入本月按时交付率
This commit is contained in:
parent
53b19ccd5a
commit
d374a0bba2
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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} />}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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]">
|
||||
|
||||
@ -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',
|
||||
|
||||
154
lib/__tests__/ding-workflow-delivery.test.ts
Normal file
154
lib/__tests__/ding-workflow-delivery.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
415
lib/ding-workflow-delivery.ts
Normal file
415
lib/ding-workflow-delivery.ts
Normal 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, '分配任务', '审核')
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user