diff --git a/app/api/department-dashboard/core-metrics/monthly-launches/route.ts b/app/api/department-dashboard/core-metrics/monthly-launches/route.ts new file mode 100644 index 00000000..a2f1af9c --- /dev/null +++ b/app/api/department-dashboard/core-metrics/monthly-launches/route.ts @@ -0,0 +1,36 @@ +import { getServerSession } from 'next-auth' +import { NextResponse } from 'next/server' +import { authOptions } from '@/lib/auth' +import { + DingWorkflowMetricApiError, + getMonthlyLaunchSummary, +} 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 getMonthlyLaunchSummary(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 a10fe6cb..0ce2b457 100644 --- a/app/workspaces/[section]/[item]/[metric]/page.tsx +++ b/app/workspaces/[section]/[item]/[metric]/page.tsx @@ -4,7 +4,10 @@ import { notFound } from 'next/navigation' import CoreMetricDetail from '@/components/department-dashboard/CoreMetricDetail' import { getCoreMetric } from '@/components/department-dashboard/core-metrics' import { authOptions } from '@/lib/auth' -import { getOngoingDingWorkflowInsights } from '@/lib/ding-workflow-sync' +import { + getMonthlyLaunchInsights, + getOngoingDingWorkflowInsights, +} from '@/lib/ding-workflow-sync' import { getFirstPassReviewProjects, } from '@/lib/work-review-service' @@ -38,6 +41,12 @@ export default async function CoreMetricPage({ params, searchParams }: CoreMetri return } + if (metric.key === 'monthly-launches') { + const session = await getServerSession(authOptions) + const monthlyLaunches = await getMonthlyLaunchInsights(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 b07fd970..c686afe8 100644 --- a/components/department-dashboard/CoreMetricDetail.test.tsx +++ b/components/department-dashboard/CoreMetricDetail.test.tsx @@ -80,6 +80,93 @@ describe('CoreMetricDetail', () => { expect(screen.getByText('数据源待接入')).toBeInTheDocument() }) + it('按六品牌切换查看本月完成与当前进行中的美术设计流程', () => { + const baseProject = { + businessId: 'BUSINESS-1', + title: '新品主视觉申请', + status: 'COMPLETED', + processCode: 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838', + projectType: '美术设计流程', + brand: '亿家达', + requesterName: '林需求', + requesterDepartment: '品牌中心', + initiatorName: '荀瑶', + initiatorDepartment: '运营中心A-事业四部-品类九部', + originatorUserId: 'ding-1', + originatorDeptName: '运营中心A', + sourceCreatedAt: '2026-04-20T00:00:00.000Z', + sourceUpdatedAt: '2026-08-14T02:30:00.000Z', + activeTaskCount: 0, + taskCount: 0, + schemeFieldCount: 0, + latestTaskStatus: null, + activeTasks: [], + result: 'AGREE', + } + render( + , + ) + + expect(screen.getByRole('heading', { level: 1, name: '本月上新' })).toBeInTheDocument() + expect(screen.getByText('1/2')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: '亿家达 · 已完成新品' })).toBeInTheDocument() + expect(screen.getAllByText('新品主视觉申请').length).toBeGreaterThan(0) + + fireEvent.click(screen.getByRole('button', { name: '查看亿家达进行中新品明细,共1条' })) + expect(screen.getByRole('heading', { name: '亿家达 · 进行中新品' })).toBeInTheDocument() + expect(screen.getAllByText('新品场景设计申请').length).toBeGreaterThan(0) + expect(screen.getAllByText(/品牌负责人审批/).length).toBeGreaterThan(0) + }) + it('在按时交付明细中只用黄色数值呈现阈值状态', () => { render() diff --git a/components/department-dashboard/CoreMetricDetail.tsx b/components/department-dashboard/CoreMetricDetail.tsx index f10bee43..957404a8 100644 --- a/components/department-dashboard/CoreMetricDetail.tsx +++ b/components/department-dashboard/CoreMetricDetail.tsx @@ -13,8 +13,12 @@ import type { FirstPassReviewProjectListItem, FirstPassReviewProjectPage, } from '@/lib/work-review-service' -import type { OngoingDingWorkflowInsights } from '@/lib/ding-workflow-sync' +import type { + MonthlyLaunchInsights, + OngoingDingWorkflowInsights, +} from '@/lib/ding-workflow-sync' import DepartmentShell from './DepartmentShell' +import MonthlyLaunchesInsights from './MonthlyLaunchesInsights' import OngoingProjectsInsights from './OngoingProjectsInsights' import OnTimeDeliveryRate from './OnTimeDeliveryRate' import type { CoreMetric } from './core-metrics' @@ -23,6 +27,7 @@ type CoreMetricDetailProps = { metric: CoreMetric query?: string projectInsights?: OngoingDingWorkflowInsights + monthlyLaunches?: MonthlyLaunchInsights firstPassPage?: FirstPassReviewProjectPage } @@ -244,8 +249,9 @@ function PendingDataSource({ metric }: { metric: CoreMetric }) { ) } -export default function CoreMetricDetail({ metric, query, projectInsights, firstPassPage }: CoreMetricDetailProps) { +export default function CoreMetricDetail({ metric, query, projectInsights, monthlyLaunches, 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' @@ -259,14 +265,14 @@ export default function CoreMetricDetail({ metric, query, projectInsights, first
-

{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : '总览示例值'}

+

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

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

{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectInsights.total : metric.exampleValue}

} + :

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

}

明细数据

-

{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : '等待业务数据源'}

+

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

同步状态

@@ -276,6 +282,10 @@ export default function CoreMetricDetail({ metric, query, projectInsights, first ? projectInsights.sync.lastSyncedAt ? `每小时增量同步 · ${formatDate(projectInsights.sync.lastSyncedAt, true)}` : projectInsights.sync.syncStatus === 'error' ? '首轮同步失败' : '等待首轮同步' + : hasMonthlyLaunchSource + ? monthlyLaunches.summary.lastSyncedAt + ? `每小时增量同步 · ${formatDate(monthlyLaunches.summary.lastSyncedAt, true)}` + : monthlyLaunches.summary.syncStatus === 'error' ? '首轮同步失败' : '等待首轮同步' : hasFirstPassSource ? '打开页面时读取' : '每日同步待接入'}

@@ -283,6 +293,8 @@ export default function CoreMetricDetail({ metric, query, projectInsights, first {hasRealProjectSource ? + : hasMonthlyLaunchSource + ? : hasFirstPassSource ? : } diff --git a/components/department-dashboard/CoreMetricsDashboard.test.tsx b/components/department-dashboard/CoreMetricsDashboard.test.tsx index 223bb29e..4b036d42 100644 --- a/components/department-dashboard/CoreMetricsDashboard.test.tsx +++ b/components/department-dashboard/CoreMetricsDashboard.test.tsx @@ -19,16 +19,29 @@ describe('CoreMetricsDashboard', () => { syncStatus: 'ready', }, } - : { - success: true, - data: { - totalUploads: 30, - firstPassApproved: 11, - rate: 36.7, - statusBreakdown: { approved: 11, revisionRequired: 7, pendingReview: 10, unknown: 2 }, - readAt: '2026-08-14T02:00:00.000Z', + : url.includes('monthly-launches') + ? { + success: true, + data: { + completed: 8, + ongoing: 5, + total: 13, + 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: { + totalUploads: 30, + firstPassApproved: 11, + rate: 36.7, + statusBreakdown: { approved: 11, revisionRequired: 7, pendingReview: 10, unknown: 2 }, + readAt: '2026-08-14T02:00:00.000Z', + }, }, - }, })) as jest.Mock }) @@ -36,7 +49,7 @@ describe('CoreMetricsDashboard', () => { jest.restoreAllMocks() }) - it('展示真实进行中流程、首轮通过率与其余四项诚实的示例状态', async () => { + it('展示真实流程指标、首轮通过率与其余三项诚实的示例状态', async () => { render() expect(screen.getByRole('heading', { level: 1, name: '核心指标' })).toBeInTheDocument() @@ -44,19 +57,21 @@ describe('CoreMetricsDashboard', () => { expect(await screen.findByText('36.7%')).toBeInTheDocument() expect(screen.getByText('V1通过 11 / 全部 30')).toBeInTheDocument() expect(screen.getByText('实时读取')).toBeInTheDocument() - expect(screen.getByText('每小时同步')).toBeInTheDocument() - expect(screen.getAllByText('示例值')).toHaveLength(4) + expect(screen.getAllByText('每小时同步').length).toBeGreaterThan(0) + expect(screen.getAllByText('示例值')).toHaveLength(3) for (const label of ['进行中项目', '本月上新', '按时交付', '首轮通过', '团队负荷', '风险']) { expect(screen.getByRole('heading', { level: 3, name: label })).toBeInTheDocument() } - for (const value of ['2338', '18/24', '94.6%', '36.7%', '87%', '4项']) { + for (const value of ['2338', '8/13', '94.6%', '36.7%', '87%', '4项']) { expect(screen.getByText(value)).toBeInTheDocument() } - expect(screen.getAllByText('公式待接入')).toHaveLength(4) - expect(screen.getByText('钉钉流程快照')).toBeInTheDocument() + expect(screen.getByText('已完成 8 · 进行中 5')).toBeInTheDocument() + + expect(screen.getAllByText('公式待接入')).toHaveLength(3) + expect(screen.getAllByText('钉钉流程快照').length).toBeGreaterThan(0) expect(screen.getByText('口径已定义')).toBeInTheDocument() expect(screen.getAllByText('查看明细')).toHaveLength(6) const onTimeDelivery = screen.getByRole('link', { name: '查看按时交付明细' }) @@ -77,8 +92,8 @@ describe('CoreMetricsDashboard', () => { global.fetch = jest.fn().mockResolvedValue({ ok: false, json: async () => ({ success: false }) }) as jest.Mock render() - expect(await screen.findAllByText('读取失败')).toHaveLength(2) + expect(await screen.findAllByText('读取失败')).toHaveLength(3) expect(screen.queryByText('82.3%')).not.toBeInTheDocument() - expect(screen.getAllByText('数据未加载')).toHaveLength(2) + expect(screen.getAllByText('数据未加载')).toHaveLength(3) }) }) diff --git a/components/department-dashboard/CoreMetricsDashboard.tsx b/components/department-dashboard/CoreMetricsDashboard.tsx index 1146d77e..a8606ddb 100644 --- a/components/department-dashboard/CoreMetricsDashboard.tsx +++ b/components/department-dashboard/CoreMetricsDashboard.tsx @@ -7,7 +7,10 @@ import { CalendarClock, } from 'lucide-react' import type { FirstPassReviewSummary } from '@/lib/work-review-metrics' -import type { OngoingDingWorkflowSummary } from '@/lib/ding-workflow-sync' +import type { + MonthlyLaunchSummary, + OngoingDingWorkflowSummary, +} from '@/lib/ding-workflow-sync' import DepartmentShell from './DepartmentShell' import { CORE_METRICS, getCoreMetricHref } from './core-metrics' import OnTimeDeliveryRate from './OnTimeDeliveryRate' @@ -22,6 +25,11 @@ type OngoingWorkflowState = | { status: 'success'; data: OngoingDingWorkflowSummary } | { status: 'error'; data: null } +type MonthlyLaunchState = + | { status: 'loading'; data: null } + | { status: 'success'; data: MonthlyLaunchSummary } + | { status: 'error'; data: null } + function formatRate(rate: number | null) { if (rate === null) return '—' return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%` @@ -42,6 +50,7 @@ function formatSyncTime(value: string | null) { 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 }) useEffect(() => { const controller = new AbortController() @@ -74,8 +83,23 @@ export default function CoreMetricsDashboard() { } } + async function loadMonthlyLaunchSummary() { + try { + const response = await fetch('/api/department-dashboard/core-metrics/monthly-launches', { + cache: 'no-store', + signal: controller.signal, + }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success || !payload.data) throw new Error('指标读取失败') + setMonthlyLaunches({ status: 'success', data: payload.data }) + } catch (error) { + if ((error as Error).name !== 'AbortError') setMonthlyLaunches({ status: 'error', data: null }) + } + } + void loadFirstPassSummary() void loadOngoingWorkflowSummary() + void loadMonthlyLaunchSummary() return () => controller.abort() }, []) @@ -108,6 +132,7 @@ export default function CoreMetricsDashboard() { {CORE_METRICS.map((metric) => { const Icon = metric.icon const isOngoingWorkflow = metric.key === 'ongoing-projects' + const isMonthlyLaunch = metric.key === 'monthly-launches' const isFirstPass = metric.key === 'first-pass-rate' const isOnTimeDelivery = metric.key === 'on-time-delivery' const badge = isOngoingWorkflow @@ -120,11 +145,23 @@ export default function CoreMetricsDashboard() { ? '同步异常' : '等待首轮同步' : ongoingWorkflows.status === 'error' ? '读取失败' : '正在读取' + : isMonthlyLaunch + ? monthlyLaunches.status === 'success' + ? monthlyLaunches.data.syncStatus === 'ready' + ? '每小时同步' + : monthlyLaunches.data.syncStatus === 'syncing' + ? '正在同步' + : monthlyLaunches.data.syncStatus === 'error' + ? '同步异常' + : '等待首轮同步' + : monthlyLaunches.status === 'error' ? '读取失败' : '正在读取' : isFirstPass ? firstPass.status === 'success' ? '实时读取' : firstPass.status === 'error' ? '读取失败' : '正在读取' : '示例值' const value = isOngoingWorkflow ? ongoingWorkflows.status === 'success' ? String(ongoingWorkflows.data.total) : '—' + : isMonthlyLaunch + ? monthlyLaunches.status === 'success' ? `${monthlyLaunches.data.completed}/${monthlyLaunches.data.total}` : '—' : isFirstPass ? firstPass.status === 'success' ? formatRate(firstPass.data.rate) : '—' : metric.exampleValue @@ -146,11 +183,11 @@ export default function CoreMetricsDashboard() {

{metric.label}

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

+

{value}

)} @@ -170,6 +207,14 @@ export default function CoreMetricsDashboard() { {isOngoingWorkflow && ongoingWorkflows.status === 'loading' && (

正在读取本地流程快照

)} + {isMonthlyLaunch && monthlyLaunches.status === 'success' && ( +

+ 已完成 {monthlyLaunches.data.completed} · 进行中 {monthlyLaunches.data.ongoing} +

+ )} + {isMonthlyLaunch && monthlyLaunches.status === 'loading' && ( +

正在读取本地流程快照

+ )}

{metric.description}

@@ -177,13 +222,13 @@ export default function CoreMetricsDashboard() { - {isOngoingWorkflow - ? ongoingWorkflows.status === 'error' ? '数据未加载' : '钉钉流程快照' + {isOngoingWorkflow || isMonthlyLaunch + ? (isOngoingWorkflow ? ongoingWorkflows.status : monthlyLaunches.status) === 'error' ? '数据未加载' : '钉钉流程快照' : isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '口径已定义') : '公式待接入'} @@ -196,7 +241,7 @@ export default function CoreMetricsDashboard() { })}

- “进行中项目”每小时读取钉钉审批流程快照,“首轮通过”读取作品过稿 V1 审核结果;其余示例值将在对应数据源与公式确认后替换。 + “进行中项目”和“本月上新”每小时读取钉钉审批流程三表快照,“首轮通过”读取作品过稿 V1 审核结果;其余示例值将在对应数据源与公式确认后替换。

diff --git a/components/department-dashboard/MonthlyLaunchesInsights.tsx b/components/department-dashboard/MonthlyLaunchesInsights.tsx new file mode 100644 index 00000000..3c9072d3 --- /dev/null +++ b/components/department-dashboard/MonthlyLaunchesInsights.tsx @@ -0,0 +1,213 @@ +'use client' + +import { useMemo, useState } from 'react' +import { + CheckCircle2, + ChevronDown, + Clock3, + PackageOpen, +} from 'lucide-react' +import type { + MonthlyLaunchBrandSummary, + MonthlyLaunchInsights, + MonthlyLaunchWorkflowItem, + MonthlyLaunchWorkflowStatus, +} from '@/lib/ding-workflow-sync' + +function formatDate(value: string | null) { + if (!value) return '—' + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'Asia/Shanghai', + }).format(new Date(value)) +} + +function statusLabel(status: MonthlyLaunchWorkflowStatus) { + return status === 'completed' ? '已完成新品' : '进行中新品' +} + +function CurrentProgress({ item }: { item: MonthlyLaunchWorkflowItem }) { + if (item.launchStatus === 'completed') { + return ( +
+

流程已完成

+

完成于 {formatDate(item.sourceFinishedAt)}

+
+ ) + } + if (item.activeTasks.length === 0) return 待进入处理节点 + return ( +
+ {item.activeTasks.map((task) => ( +

+ {task.nodeName} + · + {task.assigneeName} +

+ ))} +
+ ) +} + +export default function MonthlyLaunchesInsights({ insights }: { insights: MonthlyLaunchInsights }) { + const [selectedBrand, setSelectedBrand] = useState(insights.brands[0]?.brand || '亿家达') + const [selectedStatus, setSelectedStatus] = useState('completed') + const [visibleCount, setVisibleCount] = useState(30) + + const selectedItems = useMemo(() => insights.items.filter((item) => ( + item.brand === selectedBrand && item.launchStatus === selectedStatus + )), [insights.items, selectedBrand, selectedStatus]) + + const selectGroup = (brand: MonthlyLaunchBrandSummary['brand'], status: MonthlyLaunchWorkflowStatus) => { + setSelectedBrand(brand) + setSelectedStatus(status) + setVisibleCount(30) + } + + return ( +
+
+
+

六品牌本月新品流程

+

+ 已完成按本月完成时间统计;进行中读取当前未完结流程,不限制发起月份。取消和终止流程不计入。 +

+
+

+ 已完成 {insights.summary.completed} · 进行中 {insights.summary.ongoing} +

+
+ +
+ {insights.brands.map((brand) => ( +
+
+

{brand.brand}

+

合计 {brand.total}

+
+
+ {([ + { status: 'completed' as const, label: '已完成新品', count: brand.completed, icon: CheckCircle2 }, + { status: 'ongoing' as const, label: '进行中新品', count: brand.ongoing, icon: Clock3 }, + ]).map((group) => { + const Icon = group.icon + const selected = selectedBrand === brand.brand && selectedStatus === group.status + return ( + + ) + })} +
+
+ ))} +
+ +
+
+
+

+ {selectedBrand} · {statusLabel(selectedStatus)} +

+

流程编码、发起人、所在部门、当前节点与业务时间

+
+

共 {selectedItems.length} 条

+
+ + {selectedItems.length === 0 ? ( +
+
+ ) : ( + <> +
+ + + + + + + + + + + + + {selectedItems.slice(0, visibleCount).map((item) => ( + + + + + + + + ))} + +
{selectedBrand}{statusLabel(selectedStatus)}流程明细
流程任务类型发起人 / 所在部门当前进度发起 / 最近更新
+

{item.title}

+

{item.businessId || item.id}

+
{item.projectType} +

{item.initiatorName}

+

{item.initiatorDepartment}

+
+

发起 {formatDate(item.sourceCreatedAt)}

+

更新 {formatDate(item.sourceUpdatedAt)}

+
+
+ +
    + {selectedItems.slice(0, visibleCount).map((item) => ( +
  • +
    +
    +

    {item.title}

    +

    {item.businessId || item.id}

    +
    + {item.projectType} +
    +
    +
    发起人 / 所在部门
    {item.initiatorName} · {item.initiatorDepartment}
    +
    当前进度
    +
    业务时间
    发起 {formatDate(item.sourceCreatedAt)} · 更新 {formatDate(item.sourceUpdatedAt)}
    +
    +
  • + ))} +
+ + )} + + {visibleCount < selectedItems.length && ( +
+ +
+ )} +
+
+ ) +} diff --git a/components/department-dashboard/core-metrics.ts b/components/department-dashboard/core-metrics.ts index 69b62ed9..93113aed 100644 --- a/components/department-dashboard/core-metrics.ts +++ b/components/department-dashboard/core-metrics.ts @@ -41,10 +41,10 @@ export const CORE_METRICS: CoreMetric[] = [ key: 'monthly-launches', label: '本月上新', exampleValue: '18/24', - description: '本月已上新数量与计划目标的对照。', + description: '本月已完成新品流程与当前进行中新品流程的对照。', icon: PackagePlus, - detailDescription: '用于查看本月上新计划、已完成项目与当前节点。', - plannedFields: ['商品或项目', '品牌', '计划日期', '当前节点', '负责人'], + detailDescription: '查看 2026 年 4 月 1 日后发起的美术设计流程;已完成按本月完成时间统计,进行中按当前未完结状态统计。', + plannedFields: ['流程编码', '品牌', '发起人', '所在部门', '当前节点', '业务时间'], }, { key: 'on-time-delivery', diff --git a/lib/__tests__/ding-workflow-sync.test.ts b/lib/__tests__/ding-workflow-sync.test.ts index ed8d2070..e1ee5ede 100644 --- a/lib/__tests__/ding-workflow-sync.test.ts +++ b/lib/__tests__/ding-workflow-sync.test.ts @@ -1,11 +1,15 @@ import { DING_WORKFLOW_ALLOWED_PROCESS_CODES, + DING_WORKFLOW_BRANDS, DING_WORKFLOW_STARTED_AT_CUTOFF_ISO, buildBrandCenterOngoingWorkflowWhere, + buildMonthlyLaunchWorkflowWhere, + getShanghaiMonthRange, getDingWorkflowProjectType, isAllowedDingWorkflowProcessCode, isDingWorkflowStartedOnOrAfterCutoff, isDingWorkflowOngoing, + isDingWorkflowCompletedInRange, normalizeDingWorkflowBrand, normalizeDingWorkflowDepartment, normalizeDingWorkflowProcessCode, @@ -49,6 +53,16 @@ describe('ding workflow snapshot sync semantics', () => { expect(isDingWorkflowStartedOnOrAfterCutoff('invalid')).toBe(false) }) + it('uses Shanghai calendar boundaries for monthly completion', () => { + const range = getShanghaiMonthRange(new Date('2026-08-26T03:00:00.000Z')) + expect(range.start.toISOString()).toBe('2026-07-31T16:00:00.000Z') + expect(range.end.toISOString()).toBe('2026-08-31T16:00:00.000Z') + expect(isDingWorkflowCompletedInRange('COMPLETED', '2026-08-01 00:00:00', range)).toBe(true) + expect(isDingWorkflowCompletedInRange('COMPLETED', '2026-09-01 00:00:00', range)).toBe(false) + expect(isDingWorkflowCompletedInRange('TERMINATED', '2026-08-12 12:00:00', range)).toBe(false) + expect(isDingWorkflowCompletedInRange('COMPLETED', null, range)).toBe(false) + }) + it('only accepts the two approved process types', () => { expect(DING_WORKFLOW_ALLOWED_PROCESS_CODES).toEqual([ 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838', @@ -69,6 +83,14 @@ describe('ding workflow snapshot sync semantics', () => { }) }) + it('only queries post-April art-design workflows for the six-brand monthly launch metric', () => { + expect(buildMonthlyLaunchWorkflowWhere()).toEqual({ + processCode: 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838', + sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO }, + brand: { in: [...DING_WORKFLOW_BRANDS] }, + }) + }) + it('normalizes process codes before persistence and comparison', () => { expect(normalizeDingWorkflowProcessCode(' proc-ae9ff3e1-a442-4e6a-85cf-0f2b86409673 ')) .toBe('PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673') diff --git a/lib/ding-workflow-sync.ts b/lib/ding-workflow-sync.ts index 5907ab53..f070744a 100644 --- a/lib/ding-workflow-sync.ts +++ b/lib/ding-workflow-sync.ts @@ -184,6 +184,37 @@ export type OngoingDingWorkflowInsights = { sync: OngoingDingWorkflowSummary } +export type MonthlyLaunchWorkflowStatus = 'completed' | 'ongoing' + +export type MonthlyLaunchWorkflowItem = OngoingDingWorkflowListItem & { + launchStatus: MonthlyLaunchWorkflowStatus + result: string | null + sourceFinishedAt: string | null +} + +export type MonthlyLaunchBrandSummary = { + brand: typeof DING_WORKFLOW_BRANDS[number] + completed: number + ongoing: number + total: number +} + +export type MonthlyLaunchSummary = { + completed: number + ongoing: number + total: number + monthStart: string + monthEnd: string + lastSyncedAt: string | null + syncStatus: DingWorkflowSyncStatus +} + +export type MonthlyLaunchInsights = { + items: MonthlyLaunchWorkflowItem[] + brands: MonthlyLaunchBrandSummary[] + summary: MonthlyLaunchSummary +} + export class DingWorkflowMetricApiError extends Error { status: number @@ -365,6 +396,33 @@ export function sourceWorkflowTimestampToIso(value: unknown): string | null { return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString() } +export function getShanghaiMonthRange(now = new Date()) { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: 'numeric', + }).formatToParts(now) + const year = Number(parts.find((part) => part.type === 'year')?.value) + const month = Number(parts.find((part) => part.type === 'month')?.value) + const shanghaiOffsetMs = 8 * 60 * 60 * 1000 + return { + start: new Date(Date.UTC(year, month - 1, 1) - shanghaiOffsetMs), + end: new Date(Date.UTC(year, month, 1) - shanghaiOffsetMs), + } +} + +export function isDingWorkflowCompletedInRange( + status: unknown, + finishedAt: unknown, + range: { start: Date; end: Date }, +) { + if (String(status || '').trim().toUpperCase() !== 'COMPLETED') return false + const finishedAtIso = sourceWorkflowTimestampToIso(finishedAt) + if (!finishedAtIso) return false + const finishedAtMs = new Date(finishedAtIso).getTime() + return finishedAtMs >= range.start.getTime() && finishedAtMs < range.end.getTime() +} + export function isDingWorkflowStartedOnOrAfterCutoff(value: unknown): boolean { const startedAt = sourceWorkflowTimestampToIso(value) return Boolean(startedAt && startedAt >= DING_WORKFLOW_STARTED_AT_CUTOFF_ISO) @@ -441,8 +499,20 @@ async function assertDashboardAccess(session: Session | null) { async function fetchSourceWorkflows(pool: ConnectionPool, cursor: string | null) { const request = pool.request() + const currentMonth = getShanghaiMonthRange() + const currentMonthStart = new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(currentMonth.start) request.input('cursor', mssql.NVarChar(64), cursor) request.input('startedAtCutoff', mssql.NVarChar(64), DING_WORKFLOW_STARTED_AT_CUTOFF_LOCAL) + request.input('currentMonthStart', mssql.NVarChar(64), currentMonthStart) DING_WORKFLOW_ALLOWED_PROCESS_CODES.forEach((processCode, index) => { request.input(`allowedProcessCode${index}`, mssql.NVarChar(255), processCode) }) @@ -474,6 +544,11 @@ async function fetchSourceWorkflows(pool: ConnectionPool, cursor: string | null) AND ( @cursor IS NULL OR UPPER(LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(100), status), '')))) NOT IN ('COMPLETED', 'TERMINATED', 'CANCELED') + OR ( + UPPER(LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(100), status), '')))) = 'COMPLETED' + AND TRY_CONVERT(datetime2, NULLIF(CONVERT(nvarchar(64), finishTime), '')) >= + TRY_CONVERT(datetime2, @currentMonthStart) + ) OR TRY_CONVERT(datetime2, NULLIF(CONVERT(nvarchar(64), updateAt), '')) >= DATEADD(minute, -5, TRY_CONVERT(datetime2, @cursor)) ) @@ -481,7 +556,7 @@ async function fetchSourceWorkflows(pool: ConnectionPool, cursor: string | null) return result.recordset } -async function fetchOngoingAggregates(pool: ConnectionPool, processInstanceIds: string[]) { +async function fetchWorkflowAggregates(pool: ConnectionPool, processInstanceIds: string[]) { if (processInstanceIds.length === 0) { return { taskByWorkflow: new Map(), @@ -743,7 +818,7 @@ function buildSnapshotPayload( joinedAtRaw: clean(row.joinedAtRaw), sourceCreatedAtRaw, sourceUpdatedAtRaw: clean(row.sourceUpdatedAtRaw), - sourceFinishedAtRaw: clean(row.sourceFinishedAtRaw), + sourceFinishedAtRaw: sourceWorkflowTimestampToIso(row.sourceFinishedAtRaw), isOngoing: ongoing, schemeFieldCount: ongoing ? nonNegativeInteger(scheme?.schemeFieldCount) : 0, taskCount: ongoing ? nonNegativeInteger(task?.taskCount) : 0, @@ -860,16 +935,16 @@ export async function syncDingWorkflowSnapshots() { try { await pool.connect() const sourceRows = await fetchSourceWorkflows(pool, state.sourceCursorRaw) - const ongoingIds = sourceRows.flatMap((row) => { + const workflowIds = sourceRows.flatMap((row) => { const id = clean(row.processInstanceId) - return id && isDingWorkflowOngoing(row.status, row.sourceFinishedAtRaw) ? [id] : [] + return id ? [id] : [] }) const { taskByWorkflow, taskRowsByWorkflow, schemeByWorkflow, schemeRowsByWorkflow, - } = await fetchOngoingAggregates(pool, ongoingIds) + } = await fetchWorkflowAggregates(pool, workflowIds) const employeeDirectory = await readEmployeeDirectory(sourceRows, taskRowsByWorkflow) const activityNodeNames = inferActivityNodeNames(sourceRows, taskRowsByWorkflow, schemeRowsByWorkflow) const rows = buildSnapshotPayload( @@ -982,6 +1057,12 @@ const ONGOING_WORKFLOW_SELECT = { latestTaskStatus: true, } as const +const MONTHLY_LAUNCH_WORKFLOW_SELECT = { + ...ONGOING_WORKFLOW_SELECT, + result: true, + sourceFinishedAtRaw: true, +} as const + type DingWorkflowInitiatorProfile = { displayName: string | null departmentName: string | null @@ -1160,6 +1241,90 @@ export async function getOngoingDingWorkflowProjects( } } +export function buildMonthlyLaunchWorkflowWhere() { + return { + processCode: DING_WORKFLOW_ALLOWED_PROCESS_CODES[0], + sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO }, + brand: { in: [...DING_WORKFLOW_BRANDS] }, + } +} + +async function readMonthlyLaunchInsightsWithoutAccessCheck(now = new Date()): Promise { + const range = getShanghaiMonthRange(now) + const baseWhere = buildMonthlyLaunchWorkflowWhere() + const [completedCandidates, ongoingRows, state] = await Promise.all([ + prisma.dingWorkflowSnapshot.findMany({ + where: { ...baseWhere, status: 'COMPLETED' }, + select: MONTHLY_LAUNCH_WORKFLOW_SELECT, + }), + prisma.dingWorkflowSnapshot.findMany({ + where: { ...baseWhere, isOngoing: true }, + select: MONTHLY_LAUNCH_WORKFLOW_SELECT, + }), + prisma.dingWorkflowSyncState.findUnique({ + where: { id: SYNC_STATE_ID }, + select: { status: true, lastCompletedAt: true }, + }), + ]) + const completedRows = completedCandidates.filter((row) => ( + isDingWorkflowCompletedInRange(row.status, row.sourceFinishedAtRaw, range) + )) + const allRows = [...completedRows, ...ongoingRows] + const initiatorDirectory = await readInitiatorDirectory(allRows) + const mapMonthlyItem = ( + row: typeof allRows[number], + launchStatus: MonthlyLaunchWorkflowStatus, + ): MonthlyLaunchWorkflowItem => ({ + ...mapSnapshotListItem( + row, + row.originatorUserId ? initiatorDirectory.get(row.originatorUserId.trim()) : null, + ), + launchStatus, + result: row.result, + sourceFinishedAt: sourceWorkflowTimestampToIso(row.sourceFinishedAtRaw), + }) + const items = [ + ...completedRows.map((row) => mapMonthlyItem(row, 'completed')), + ...ongoingRows.map((row) => mapMonthlyItem(row, 'ongoing')), + ].sort((a, b) => { + const aTime = a.launchStatus === 'completed' ? a.sourceFinishedAt : a.sourceUpdatedAt + const bTime = b.launchStatus === 'completed' ? b.sourceFinishedAt : b.sourceUpdatedAt + return String(bTime || '').localeCompare(String(aTime || '')) || b.id.localeCompare(a.id) + }) + const brands = DING_WORKFLOW_BRANDS.map((brand) => { + const brandItems = items.filter((item) => item.brand === brand) + const completed = brandItems.filter((item) => item.launchStatus === 'completed').length + const ongoing = brandItems.filter((item) => item.launchStatus === 'ongoing').length + return { brand, completed, ongoing, total: completed + ongoing } + }) + const completed = completedRows.length + const ongoing = ongoingRows.length + + return { + items, + brands, + summary: { + completed, + ongoing, + total: completed + ongoing, + monthStart: range.start.toISOString(), + monthEnd: range.end.toISOString(), + lastSyncedAt: state?.lastCompletedAt?.toISOString() || null, + syncStatus: normalizeSyncStatus(state), + }, + } +} + +export async function getMonthlyLaunchSummary(session: Session | null): Promise { + await assertDashboardAccess(session) + return (await readMonthlyLaunchInsightsWithoutAccessCheck()).summary +} + +export async function getMonthlyLaunchInsights(session: Session | null): Promise { + await assertDashboardAccess(session) + return readMonthlyLaunchInsightsWithoutAccessCheck() +} + type DingWorkflowSchedulerGlobal = typeof globalThis & { __dingWorkflowHourlySyncStarted?: boolean }