diff --git a/app/api/department-dashboard/core-metrics/on-time-delivery/route.ts b/app/api/department-dashboard/core-metrics/on-time-delivery/route.ts new file mode 100644 index 00000000..83b2c683 --- /dev/null +++ b/app/api/department-dashboard/core-metrics/on-time-delivery/route.ts @@ -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 }, + ) + } +} diff --git a/app/workspaces/[section]/[item]/[metric]/page.tsx b/app/workspaces/[section]/[item]/[metric]/page.tsx index 0ce2b457..a25e8ad8 100644 --- a/app/workspaces/[section]/[item]/[metric]/page.tsx +++ b/app/workspaces/[section]/[item]/[metric]/page.tsx @@ -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 } + if (metric.key === 'on-time-delivery') { + const session = await getServerSession(authOptions) + const onTimeDelivery = await getOnTimeDeliveryInsights(session) + return + } + if (metric.key === 'first-pass-rate') { const session = await getServerSession(authOptions) const firstPassPage = await getFirstPassReviewProjects(session, { page, pageSize: 20, query }) diff --git a/components/department-dashboard/CoreMetricDetail.test.tsx b/components/department-dashboard/CoreMetricDetail.test.tsx index 6b8949cc..e7f09798 100644 --- a/components/department-dashboard/CoreMetricDetail.test.tsx +++ b/components/department-dashboard/CoreMetricDetail.test.tsx @@ -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() + it('在按时交付明细中展示真实公式并只用黄色数值呈现阈值状态', () => { + render( + , + ) - 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() }) diff --git a/components/department-dashboard/CoreMetricDetail.tsx b/components/department-dashboard/CoreMetricDetail.tsx index aae1cb32..ad5e5b93 100644 --- a/components/department-dashboard/CoreMetricDetail.tsx +++ b/components/department-dashboard/CoreMetricDetail.tsx @@ -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 {label} +} + +function OnTimeDeliveryInsightsView({ insights }: { insights: OnTimeDeliveryInsights }) { + const { items, summary } = insights + + return ( +
+
+

统计公式

+

+ 本月按时交付率 = 本月未超时完结流程数 ÷ 本月全部完结流程数 × 100% +

+

+ 当前口径:{summary.onTime} ÷ {summary.total} = {formatRate(summary.rate)} · 超时 {summary.overtime} · 待计算 {summary.pending} +

+
+ +
+
+

本月完结流程时效明细

+

+ 美术按“品牌策划主管分配任务”至“品牌策划主管审核”计算;内容按规则计至“编导审核”或“内容主管审核”。 +

+
+

共 {summary.total} 条

+
+ + {items.length === 0 ? ( +
+
+ ) : ( +
+ + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + ))} + +
流程发起人 / 部门计时节点标准时效实际用时判定
+

{item.title}

+

{item.businessId || item.id}

+

{item.projectType}

+
+

{item.initiatorName}

+

{item.initiatorDepartment}

+
+

{item.assessment.startNodeLabel}

+

{formatDate(item.assessment.startedAt, true)}

+

{item.assessment.endNodeLabel}

+

{formatDate(item.assessment.finishedAt, true)}

+
+ {formatDuration(item.assessment.standardDuration, item.assessment.durationUnit)} + + {formatDuration(item.assessment.actualDuration, item.assessment.durationUnit)} + + + {item.assessment.status === 'pending' && ( +

缺少标准配置或关键节点时间

+ )} +
+
+ )} +
+ ) +} + +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 ( @@ -265,14 +367,14 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month
-

{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : hasMonthlyLaunchSource ? '本月已完结 / 新品流程总数' : '总览示例值'}

- {isOnTimeDelivery && metric.exampleNumericValue !== undefined - ? - :

{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectInsights.total : hasMonthlyLaunchSource ? `${monthlyLaunches.summary.completed}/${monthlyLaunches.summary.total}` : metric.exampleValue}

} +

{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : hasMonthlyLaunchSource ? '本月已完结 / 新品流程总数' : hasOnTimeDeliverySource ? '本月按时交付率' : '总览示例值'}

+ {hasOnTimeDeliverySource && onTimeDelivery.summary.rate !== null + ? + :

{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectInsights.total : hasMonthlyLaunchSource ? `${monthlyLaunches.summary.completed}/${monthlyLaunches.summary.total}` : hasOnTimeDeliverySource ? '—' : metric.exampleValue}

}

明细数据

-

{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : hasMonthlyLaunchSource ? `本月已完结 ${monthlyLaunches.summary.completed} / 当前进行中 ${monthlyLaunches.summary.ongoing}` : '等待业务数据源'}

+

{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}` : '等待业务数据源'}

同步状态

@@ -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 ? '打开页面时读取' : '每日同步待接入'}

@@ -295,6 +401,8 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month ? : hasMonthlyLaunchSource ? + : hasOnTimeDeliverySource + ? : hasFirstPassSource ? : } diff --git a/components/department-dashboard/CoreMetricsDashboard.test.tsx b/components/department-dashboard/CoreMetricsDashboard.test.tsx index b07e9e08..90138243 100644 --- a/components/department-dashboard/CoreMetricsDashboard.test.tsx +++ b/components/department-dashboard/CoreMetricsDashboard.test.tsx @@ -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() 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() - 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) }) }) diff --git a/components/department-dashboard/CoreMetricsDashboard.tsx b/components/department-dashboard/CoreMetricsDashboard.tsx index 8479f684..ec1d2d74 100644 --- a/components/department-dashboard/CoreMetricsDashboard.tsx +++ b/components/department-dashboard/CoreMetricsDashboard.tsx @@ -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({ status: 'loading', data: null }) const [ongoingWorkflows, setOngoingWorkflows] = useState({ status: 'loading', data: null }) const [monthlyLaunches, setMonthlyLaunches] = useState({ status: 'loading', data: null }) + const [onTimeDelivery, setOnTimeDelivery] = useState({ 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() {

{metric.label}

-
- {isOnTimeDelivery && metric.exampleNumericValue !== undefined - ? +
+ {isOnTimeDelivery && onTimeDelivery.status === 'success' && onTimeDelivery.data.rate !== null + ? : ( -

+

{value}

)} @@ -215,6 +249,14 @@ export default function CoreMetricsDashboard() { {isMonthlyLaunch && monthlyLaunches.status === 'loading' && (

正在读取本地流程快照

)} + {isOnTimeDelivery && onTimeDelivery.status === 'success' && ( +

+ 未超时 {onTimeDelivery.data.onTime} / 本月完结 {onTimeDelivery.data.total} +

+ )} + {isOnTimeDelivery && onTimeDelivery.status === 'loading' && ( +

正在计算本月交付时效

+ )}

{metric.description}

@@ -222,13 +264,13 @@ export default function CoreMetricsDashboard() { - {isOngoingWorkflow || isMonthlyLaunch - ? (isOngoingWorkflow ? ongoingWorkflows.status : monthlyLaunches.status) === 'error' ? '数据未加载' : '钉钉流程快照' + {isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery + ? (isOngoingWorkflow ? ongoingWorkflows.status : isMonthlyLaunch ? monthlyLaunches.status : onTimeDelivery.status) === 'error' ? '数据未加载' : '钉钉流程快照' : isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '口径已定义') : '公式待接入'} diff --git a/components/department-dashboard/core-metrics.ts b/components/department-dashboard/core-metrics.ts index 76c58efb..1e8ad39f 100644 --- a/components/department-dashboard/core-metrics.ts +++ b/components/department-dashboard/core-metrics.ts @@ -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', diff --git a/lib/__tests__/ding-workflow-delivery.test.ts b/lib/__tests__/ding-workflow-delivery.test.ts new file mode 100644 index 00000000..84447b52 --- /dev/null +++ b/lib/__tests__/ding-workflow-delivery.test.ts @@ -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 { + 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, + }) + }) +}) diff --git a/lib/__tests__/ding-workflow-sync.test.ts b/lib/__tests__/ding-workflow-sync.test.ts index cb32796b..bd33e9fc 100644 --- a/lib/__tests__/ding-workflow-sync.test.ts +++ b/lib/__tests__/ding-workflow-sync.test.ts @@ -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, diff --git a/lib/ding-workflow-delivery.ts b/lib/ding-workflow-delivery.ts new file mode 100644 index 00000000..c32f86d6 --- /dev/null +++ b/lib/ding-workflow-delivery.ts @@ -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[] + 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 = {} + 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() + const noCrossGroups = new Map() + const uniqueGroups = new Map() + 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() + 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 = { + '布景拍摄': '布局拍摄', + '抖音-千川素材视频': '抖音-千川素材', + '抖音-账号维护视频(内容部专用)': '抖音-账号维护(内容部专用)', + '淘系-逛逛视频': '淘系-逛逛', + '淘系主图视频': '淘系主图', + } + 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, '分配任务', '审核') +} diff --git a/lib/ding-workflow-sync.ts b/lib/ding-workflow-sync.ts index 29852301..e7f2123f 100644 --- a/lib/ding-workflow-sync.ts +++ b/lib/ding-workflow-sync.ts @@ -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 - 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 { + 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 { + await assertDashboardAccess(session) + return (await readOnTimeDeliveryInsightsWithoutAccessCheck()).summary +} + +export async function getOnTimeDeliveryInsights(session: Session | null): Promise { + await assertDashboardAccess(session) + return readOnTimeDeliveryInsightsWithoutAccessCheck() +} + type DingWorkflowSchedulerGlobal = typeof globalThis & { __dingWorkflowHourlySyncStarted?: boolean }