接入本月新品流程指标
This commit is contained in:
parent
8bb0fc5c47
commit
8490b3b371
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 <CoreMetricDetail metric={metric} projectInsights={projectInsights} />
|
||||
}
|
||||
|
||||
if (metric.key === 'monthly-launches') {
|
||||
const session = await getServerSession(authOptions)
|
||||
const monthlyLaunches = await getMonthlyLaunchInsights(session)
|
||||
return <CoreMetricDetail metric={metric} monthlyLaunches={monthlyLaunches} />
|
||||
}
|
||||
|
||||
if (metric.key === 'first-pass-rate') {
|
||||
const session = await getServerSession(authOptions)
|
||||
const firstPassPage = await getFirstPassReviewProjects(session, { page, pageSize: 20, query })
|
||||
|
||||
@ -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(
|
||||
<CoreMetricDetail
|
||||
metric={getCoreMetric('monthly-launches')!}
|
||||
monthlyLaunches={{
|
||||
summary: {
|
||||
completed: 1,
|
||||
ongoing: 1,
|
||||
total: 2,
|
||||
monthStart: '2026-07-31T16:00:00.000Z',
|
||||
monthEnd: '2026-08-31T16:00:00.000Z',
|
||||
lastSyncedAt: '2026-08-14T02:30:00.000Z',
|
||||
syncStatus: 'ready',
|
||||
},
|
||||
brands: [
|
||||
{ brand: '亿家达', completed: 1, ongoing: 1, total: 2 },
|
||||
{ brand: '卓禾', completed: 0, ongoing: 0, total: 0 },
|
||||
{ brand: '欧意朗', completed: 0, ongoing: 0, total: 0 },
|
||||
{ brand: '顾家', completed: 0, ongoing: 0, total: 0 },
|
||||
{ brand: '索菲亚', completed: 0, ongoing: 0, total: 0 },
|
||||
{ brand: '朴美', completed: 0, ongoing: 0, total: 0 },
|
||||
],
|
||||
items: [
|
||||
{
|
||||
...baseProject,
|
||||
id: 'completed-1',
|
||||
launchStatus: 'completed',
|
||||
sourceFinishedAt: '2026-08-12T03:00:00.000Z',
|
||||
},
|
||||
{
|
||||
...baseProject,
|
||||
id: 'ongoing-1',
|
||||
businessId: 'BUSINESS-2',
|
||||
title: '新品场景设计申请',
|
||||
status: 'RUNNING',
|
||||
launchStatus: 'ongoing',
|
||||
sourceFinishedAt: null,
|
||||
activeTaskCount: 1,
|
||||
activeTasks: [{
|
||||
taskId: 'task-1',
|
||||
activityId: 'activity-1',
|
||||
nodeName: '品牌负责人审批',
|
||||
assigneeUserId: 'ding-owner',
|
||||
assigneeName: '陈负责人',
|
||||
assigneeDepartment: '品牌中心',
|
||||
isBrandCenter: true,
|
||||
createdAt: '2026-08-14T02:00:00.000Z',
|
||||
}],
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(<CoreMetricDetail metric={getCoreMetric('on-time-delivery')!} />)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
<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 ? '当前未完结流程' : '总览示例值'}</p>
|
||||
<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 : metric.exampleValue}</p>}
|
||||
: <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>}
|
||||
</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} 条流程快照` : '等待业务数据源'}</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>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<p className="text-xs font-bold text-slate-500">同步状态</p>
|
||||
@ -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 ? '打开页面时读取' : '每日同步待接入'}
|
||||
</p>
|
||||
</div>
|
||||
@ -283,6 +293,8 @@ export default function CoreMetricDetail({ metric, query, projectInsights, first
|
||||
|
||||
{hasRealProjectSource
|
||||
? <OngoingProjectsInsights insights={projectInsights} />
|
||||
: hasMonthlyLaunchSource
|
||||
? <MonthlyLaunchesInsights insights={monthlyLaunches} />
|
||||
: hasFirstPassSource
|
||||
? <FirstPassProjectList metric={metric} query={query} firstPassPage={firstPassPage} />
|
||||
: <PendingDataSource metric={metric} />}
|
||||
|
||||
@ -19,6 +19,19 @@ describe('CoreMetricsDashboard', () => {
|
||||
syncStatus: 'ready',
|
||||
},
|
||||
}
|
||||
: 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: {
|
||||
@ -36,7 +49,7 @@ describe('CoreMetricsDashboard', () => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('展示真实进行中流程、首轮通过率与其余四项诚实的示例状态', async () => {
|
||||
it('展示真实流程指标、首轮通过率与其余三项诚实的示例状态', async () => {
|
||||
render(<CoreMetricsDashboard />)
|
||||
|
||||
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(<CoreMetricsDashboard />)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<FirstPassState>({ status: 'loading', data: null })
|
||||
const [ongoingWorkflows, setOngoingWorkflows] = useState<OngoingWorkflowState>({ status: 'loading', data: null })
|
||||
const [monthlyLaunches, setMonthlyLaunches] = useState<MonthlyLaunchState>({ 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() {
|
||||
</div>
|
||||
|
||||
<h3 className="mt-6 text-base font-bold text-[#4f6077]">{metric.label}</h3>
|
||||
<div aria-live={isFirstPass || isOngoingWorkflow ? 'polite' : undefined} aria-atomic={isFirstPass || isOngoingWorkflow ? 'true' : undefined}>
|
||||
<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}示例值`} />
|
||||
: (
|
||||
<p className="mt-2 text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl" aria-label={`${metric.label}${isFirstPass || isOngoingWorkflow ? '当前值' : '示例值'}${value}`}>
|
||||
<p className="mt-2 text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl" aria-label={`${metric.label}${isFirstPass || isOngoingWorkflow || isMonthlyLaunch ? '当前值' : '示例值'}${value}`}>
|
||||
{value}
|
||||
</p>
|
||||
)}
|
||||
@ -170,6 +207,14 @@ export default function CoreMetricsDashboard() {
|
||||
{isOngoingWorkflow && ongoingWorkflows.status === 'loading' && (
|
||||
<p className="mt-2 text-xs font-semibold text-slate-500">正在读取本地流程快照</p>
|
||||
)}
|
||||
{isMonthlyLaunch && monthlyLaunches.status === 'success' && (
|
||||
<p className="mt-2 text-xs font-semibold tabular-nums text-slate-500">
|
||||
已完成 {monthlyLaunches.data.completed} · 进行中 {monthlyLaunches.data.ongoing}
|
||||
</p>
|
||||
)}
|
||||
{isMonthlyLaunch && monthlyLaunches.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>
|
||||
|
||||
@ -177,13 +222,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
|
||||
? ongoingWorkflows.status === 'error' ? '刷新页面重试' : '每小时增量同步'
|
||||
{isOngoingWorkflow || isMonthlyLaunch
|
||||
? (isOngoingWorkflow ? ongoingWorkflows.status : monthlyLaunches.status) === 'error' ? '刷新页面重试' : '每小时增量同步'
|
||||
: isFirstPass ? (firstPass.status === 'error' ? '刷新页面重试' : '打开页面时读取') : '每日同步'}
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{isOngoingWorkflow
|
||||
? ongoingWorkflows.status === 'error' ? '数据未加载' : '钉钉流程快照'
|
||||
<span>{isOngoingWorkflow || isMonthlyLaunch
|
||||
? (isOngoingWorkflow ? ongoingWorkflows.status : monthlyLaunches.status) === 'error' ? '数据未加载' : '钉钉流程快照'
|
||||
: isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '口径已定义') : '公式待接入'}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[#176fc1]">
|
||||
@ -196,7 +241,7 @@ export default function CoreMetricsDashboard() {
|
||||
})}
|
||||
</div>
|
||||
<p id="metrics-disclaimer" className="mt-3 text-xs leading-5 text-[#66758b]">
|
||||
“进行中项目”每小时读取钉钉审批流程快照,“首轮通过”读取作品过稿 V1 审核结果;其余示例值将在对应数据源与公式确认后替换。
|
||||
“进行中项目”和“本月上新”每小时读取钉钉审批流程三表快照,“首轮通过”读取作品过稿 V1 审核结果;其余示例值将在对应数据源与公式确认后替换。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
213
components/department-dashboard/MonthlyLaunchesInsights.tsx
Normal file
213
components/department-dashboard/MonthlyLaunchesInsights.tsx
Normal file
@ -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 (
|
||||
<div>
|
||||
<p className="font-bold text-emerald-700">流程已完成</p>
|
||||
<p className="mt-1 text-xs tabular-nums text-slate-500">完成于 {formatDate(item.sourceFinishedAt)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (item.activeTasks.length === 0) return <span className="text-slate-500">待进入处理节点</span>
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{item.activeTasks.map((task) => (
|
||||
<p key={task.taskId} className="leading-5 text-slate-700">
|
||||
<span className="font-bold text-slate-950">{task.nodeName}</span>
|
||||
<span className="mx-1.5 text-slate-300">·</span>
|
||||
{task.assigneeName}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MonthlyLaunchesInsights({ insights }: { insights: MonthlyLaunchInsights }) {
|
||||
const [selectedBrand, setSelectedBrand] = useState(insights.brands[0]?.brand || '亿家达')
|
||||
const [selectedStatus, setSelectedStatus] = useState<MonthlyLaunchWorkflowStatus>('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 (
|
||||
<section aria-labelledby="monthly-launch-title" className="mt-10">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 id="monthly-launch-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">
|
||||
已完成 {insights.summary.completed} · 进行中 {insights.summary.ongoing}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 lg:grid-cols-2" aria-label="六品牌新品流程数量">
|
||||
{insights.brands.map((brand) => (
|
||||
<article key={brand.brand} className="min-w-0 overflow-hidden rounded-xl border border-slate-200 bg-white">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-slate-200 px-5 py-4">
|
||||
<h3 className="min-w-0 truncate text-lg font-bold text-slate-950">{brand.brand}</h3>
|
||||
<p className="shrink-0 text-sm font-semibold tabular-nums text-slate-500">合计 {brand.total}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-px bg-slate-200">
|
||||
{([
|
||||
{ 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 (
|
||||
<button
|
||||
key={group.status}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
aria-label={`查看${brand.brand}${group.label}明细,共${group.count}条`}
|
||||
onClick={() => selectGroup(brand.brand, group.status)}
|
||||
className={`group min-w-0 px-5 py-4 text-left transition-colors focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#5a9ce6] ${selected ? 'bg-[#e5f2ff] text-[#075fab]' : 'bg-white hover:bg-slate-50'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="inline-flex min-w-0 items-center gap-2 text-sm font-bold">
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{group.label}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-2xl font-bold tabular-nums">{group.count}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="monthly-launch-detail-title" className="mt-6 overflow-hidden rounded-xl border border-slate-200 bg-white">
|
||||
<div className="flex flex-col gap-2 border-b border-slate-200 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 id="monthly-launch-detail-title" className="text-lg font-bold text-slate-950">
|
||||
{selectedBrand} · {statusLabel(selectedStatus)}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">流程编码、发起人、所在部门、当前节点与业务时间</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold tabular-nums text-slate-600">共 {selectedItems.length} 条</p>
|
||||
</div>
|
||||
|
||||
{selectedItems.length === 0 ? (
|
||||
<div role="status" className="px-5 py-14 text-center">
|
||||
<PackageOpen className="mx-auto h-8 w-8 text-slate-400" aria-hidden="true" />
|
||||
<h4 className="mt-4 font-bold text-slate-950">当前分组暂无流程</h4>
|
||||
<p className="mt-2 text-sm text-slate-500">切换品牌或流程状态查看其他明细。</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full min-w-[980px] table-fixed text-left text-sm">
|
||||
<caption className="sr-only">{selectedBrand}{statusLabel(selectedStatus)}流程明细</caption>
|
||||
<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-[15%] px-4 py-3.5">任务类型</th>
|
||||
<th scope="col" className="w-[18%] px-4 py-3.5">发起人 / 所在部门</th>
|
||||
<th scope="col" className="w-[24%] px-4 py-3.5">当前进度</th>
|
||||
<th scope="col" className="w-[18%] px-5 py-3.5">发起 / 最近更新</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{selectedItems.slice(0, visibleCount).map((item) => (
|
||||
<tr key={item.id} className="align-top transition-colors hover:bg-slate-50">
|
||||
<td className="px-5 py-4">
|
||||
<p className="line-clamp-2 font-bold leading-5 text-slate-950">{item.title}</p>
|
||||
<p className="mt-1 break-all text-xs text-slate-500">{item.businessId || item.id}</p>
|
||||
</td>
|
||||
<td className="px-4 py-4 font-semibold text-slate-700">{item.projectType}</td>
|
||||
<td className="px-4 py-4">
|
||||
<p className="font-bold text-slate-900">{item.initiatorName}</p>
|
||||
<p className="mt-1 break-words text-xs leading-5 text-slate-500">{item.initiatorDepartment}</p>
|
||||
</td>
|
||||
<td className="px-4 py-4"><CurrentProgress item={item} /></td>
|
||||
<td className="px-5 py-4 text-xs leading-5 tabular-nums text-slate-600">
|
||||
<p>发起 {formatDate(item.sourceCreatedAt)}</p>
|
||||
<p className="mt-1">更新 {formatDate(item.sourceUpdatedAt)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-slate-200 md:hidden" aria-label={`${selectedBrand}${statusLabel(selectedStatus)}流程列表`}>
|
||||
{selectedItems.slice(0, visibleCount).map((item) => (
|
||||
<li key={item.id} className="p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h4 className="break-words font-bold text-slate-950">{item.title}</h4>
|
||||
<p className="mt-1 break-all text-xs text-slate-500">{item.businessId || item.id}</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-lg bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-600">{item.projectType}</span>
|
||||
</div>
|
||||
<dl className="mt-4 grid gap-4 text-sm">
|
||||
<div><dt className="text-xs text-slate-500">发起人 / 所在部门</dt><dd className="mt-1 font-semibold text-slate-800">{item.initiatorName} · {item.initiatorDepartment}</dd></div>
|
||||
<div><dt className="text-xs text-slate-500">当前进度</dt><dd className="mt-1"><CurrentProgress item={item} /></dd></div>
|
||||
<div><dt className="text-xs text-slate-500">业务时间</dt><dd className="mt-1 tabular-nums text-slate-700">发起 {formatDate(item.sourceCreatedAt)} · 更新 {formatDate(item.sourceUpdatedAt)}</dd></div>
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{visibleCount < selectedItems.length && (
|
||||
<div className="border-t border-slate-200 p-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibleCount((count) => count + 30)}
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-xl bg-slate-100 px-4 text-sm font-bold text-slate-700 transition-colors hover:bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
|
||||
>
|
||||
展开更多<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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<string, SourceTaskAggregate>(),
|
||||
@ -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<MonthlyLaunchInsights> {
|
||||
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<MonthlyLaunchSummary> {
|
||||
await assertDashboardAccess(session)
|
||||
return (await readMonthlyLaunchInsightsWithoutAccessCheck()).summary
|
||||
}
|
||||
|
||||
export async function getMonthlyLaunchInsights(session: Session | null): Promise<MonthlyLaunchInsights> {
|
||||
await assertDashboardAccess(session)
|
||||
return readMonthlyLaunchInsightsWithoutAccessCheck()
|
||||
}
|
||||
|
||||
type DingWorkflowSchedulerGlobal = typeof globalThis & {
|
||||
__dingWorkflowHourlySyncStarted?: boolean
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user