feat: add team workload core metric
This commit is contained in:
parent
d2ca6a1bd8
commit
87919a3943
@ -0,0 +1,36 @@
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import {
|
||||
DingWorkflowMetricApiError,
|
||||
getTeamWorkloadInsights,
|
||||
} 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 getTeamWorkloadInsights(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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { DingWorkflowMetricApiError, getTeamWorkloadSummary } from '@/lib/ding-workflow-sync'
|
||||
|
||||
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
|
||||
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
|
||||
jest.mock('mssql', () => ({ __esModule: true, default: {} }))
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
|
||||
jest.mock('@/lib/brand-center-access', () => ({ resolveBrandCenterAccessForUser: jest.fn() }))
|
||||
jest.mock('@/lib/ding-workflow-sync', () => {
|
||||
const actual = jest.requireActual('@/lib/ding-workflow-sync')
|
||||
return { ...actual, getTeamWorkloadSummary: jest.fn() }
|
||||
})
|
||||
|
||||
const { GET } = require('@/app/api/department-dashboard/core-metrics/team-workload/route') as typeof import('@/app/api/department-dashboard/core-metrics/team-workload/route')
|
||||
|
||||
describe('GET /api/department-dashboard/core-metrics/team-workload', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'manager-1' } })
|
||||
})
|
||||
|
||||
it('返回团队负荷摘要并禁止缓存', async () => {
|
||||
const data = { averageLoadPercent: 30.7, personnelCount: 44, busyPersonnel: 17 }
|
||||
;(getTeamWorkloadSummary as jest.Mock).mockResolvedValue(data)
|
||||
const response = await GET()
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0')
|
||||
await expect(response.json()).resolves.toEqual({ success: true, data })
|
||||
})
|
||||
|
||||
it('按业务权限错误返回对应状态', async () => {
|
||||
;(getTeamWorkloadSummary as jest.Mock).mockRejectedValue(new DingWorkflowMetricApiError('无权查看品牌中心流程指标', 403))
|
||||
const response = await GET()
|
||||
expect(response.status).toBe(403)
|
||||
await expect(response.json()).resolves.toEqual({ success: false, error: '无权查看品牌中心流程指标' })
|
||||
})
|
||||
|
||||
it('隐藏未知异常细节', async () => {
|
||||
;(getTeamWorkloadSummary as jest.Mock).mockRejectedValue(new Error('database password leaked'))
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const response = await GET()
|
||||
expect(response.status).toBe(500)
|
||||
await expect(response.json()).resolves.toEqual({ success: false, error: '团队负荷指标读取失败,请稍后重试' })
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,36 @@
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import {
|
||||
DingWorkflowMetricApiError,
|
||||
getTeamWorkloadSummary,
|
||||
} 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 getTeamWorkloadSummary(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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
getMonthlyLaunchInsights,
|
||||
getOnTimeDeliveryInsights,
|
||||
getOngoingDingWorkflowInsights,
|
||||
getTeamWorkloadInsights,
|
||||
} from '@/lib/ding-workflow-sync'
|
||||
import {
|
||||
getFirstPassReviewProjects,
|
||||
@ -60,5 +61,11 @@ export default async function CoreMetricPage({ params, searchParams }: CoreMetri
|
||||
return <CoreMetricDetail metric={metric} query={query} firstPassPage={firstPassPage} />
|
||||
}
|
||||
|
||||
if (metric.key === 'team-workload') {
|
||||
const session = await getServerSession(authOptions)
|
||||
const teamWorkload = await getTeamWorkloadInsights(session)
|
||||
return <CoreMetricDetail metric={metric} teamWorkload={teamWorkload} />
|
||||
}
|
||||
|
||||
return <CoreMetricDetail metric={metric} />
|
||||
}
|
||||
|
||||
@ -17,12 +17,14 @@ import type {
|
||||
MonthlyLaunchInsights,
|
||||
OnTimeDeliveryInsights,
|
||||
OngoingDingWorkflowInsights,
|
||||
TeamWorkloadInsights,
|
||||
} from '@/lib/ding-workflow-sync'
|
||||
import DepartmentShell from './DepartmentShell'
|
||||
import MonthlyLaunchesInsights from './MonthlyLaunchesInsights'
|
||||
import OngoingProjectsInsights from './OngoingProjectsInsights'
|
||||
import OnTimeDeliveryInsightsView from './OnTimeDeliveryInsights'
|
||||
import OnTimeDeliveryRate from './OnTimeDeliveryRate'
|
||||
import TeamWorkloadInsightsView from './TeamWorkloadInsights'
|
||||
import type { CoreMetric } from './core-metrics'
|
||||
|
||||
type CoreMetricDetailProps = {
|
||||
@ -32,6 +34,7 @@ type CoreMetricDetailProps = {
|
||||
monthlyLaunches?: MonthlyLaunchInsights
|
||||
onTimeDelivery?: OnTimeDeliveryInsights
|
||||
firstPassPage?: FirstPassReviewProjectPage
|
||||
teamWorkload?: TeamWorkloadInsights
|
||||
}
|
||||
|
||||
function formatDate(value: string | null, includeTime = false) {
|
||||
@ -288,11 +291,12 @@ function PendingDataSource({ metric }: { metric: CoreMetric }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function CoreMetricDetail({ metric, query, projectInsights, monthlyLaunches, onTimeDelivery, firstPassPage }: CoreMetricDetailProps) {
|
||||
export default function CoreMetricDetail({ metric, query, projectInsights, monthlyLaunches, onTimeDelivery, firstPassPage, teamWorkload }: CoreMetricDetailProps) {
|
||||
const hasRealProjectSource = metric.key === 'ongoing-projects' && projectInsights
|
||||
const hasMonthlyLaunchSource = metric.key === 'monthly-launches' && monthlyLaunches
|
||||
const hasFirstPassSource = metric.key === 'first-pass-rate' && firstPassPage
|
||||
const hasOnTimeDeliverySource = metric.key === 'on-time-delivery' && onTimeDelivery
|
||||
const hasTeamWorkloadSource = metric.key === 'team-workload' && teamWorkload
|
||||
|
||||
return (
|
||||
<DepartmentShell>
|
||||
@ -304,14 +308,14 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month
|
||||
|
||||
<section aria-label="指标口径" className="mt-6 grid overflow-hidden rounded-lg border border-[#e2e8f0] bg-white sm:grid-cols-3">
|
||||
<div className="border-b border-slate-200 p-5 sm:border-b-0 sm:border-r">
|
||||
<p className="text-xs font-bold text-slate-500">{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : hasMonthlyLaunchSource ? '本月已完结 / 新品流程总数' : hasOnTimeDeliverySource ? '本月按时交付率' : '总览示例值'}</p>
|
||||
<p className="text-xs font-bold text-slate-500">{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : hasMonthlyLaunchSource ? '本月已完结 / 新品流程总数' : hasOnTimeDeliverySource ? '本月按时交付率' : hasTeamWorkloadSource ? '品牌中心平均负荷' : '总览示例值'}</p>
|
||||
{hasOnTimeDeliverySource && onTimeDelivery.summary.rate !== null
|
||||
? <OnTimeDeliveryRate rate={onTimeDelivery.summary.rate} contextLabel="按时交付当前值" size="summary" />
|
||||
: <p className="mt-2 text-2xl font-bold tabular-nums">{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectInsights.total : hasMonthlyLaunchSource ? `${monthlyLaunches.summary.completed}/${monthlyLaunches.summary.total}` : hasOnTimeDeliverySource ? '—' : metric.exampleValue}</p>}
|
||||
: <p className="mt-2 text-2xl font-bold tabular-nums">{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectInsights.total : hasMonthlyLaunchSource ? `${monthlyLaunches.summary.completed}/${monthlyLaunches.summary.total}` : hasOnTimeDeliverySource ? '—' : hasTeamWorkloadSource ? formatRate(teamWorkload.summary.averageLoadPercent) : 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.totalApproved}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : hasMonthlyLaunchSource ? `本月已完结 ${monthlyLaunches.summary.completed} / 当前进行中 ${monthlyLaunches.summary.ongoing}` : hasOnTimeDeliverySource ? `未超时审核节点完成 ${onTimeDelivery.summary.onTime} / 审核节点完成 ${onTimeDelivery.summary.total}` : '等待业务数据源'}</p>
|
||||
<p className="mt-2 text-base font-bold">{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 已通过 ${firstPassPage.summary.totalApproved}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : hasMonthlyLaunchSource ? `本月已完结 ${monthlyLaunches.summary.completed} / 当前进行中 ${monthlyLaunches.summary.ongoing}` : hasOnTimeDeliverySource ? `未超时审核节点完成 ${onTimeDelivery.summary.onTime} / 审核节点完成 ${onTimeDelivery.summary.total}` : hasTeamWorkloadSource ? `${teamWorkload.summary.busyPersonnel} 人有进行中流程 / 共 ${teamWorkload.summary.personnelCount} 人` : '等待业务数据源'}</p>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<p className="text-xs font-bold text-slate-500">同步状态</p>
|
||||
@ -333,6 +337,10 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month
|
||||
? firstPassPage.summary.lastSyncedAt
|
||||
? `每小时增量同步 · ${formatDate(firstPassPage.summary.lastSyncedAt, true)}`
|
||||
: firstPassPage.summary.syncStatus === 'error' ? '增量同步失败' : '等待首轮同步'
|
||||
: hasTeamWorkloadSource
|
||||
? teamWorkload.summary.lastSyncedAt
|
||||
? `每小时增量同步 · ${formatDate(teamWorkload.summary.lastSyncedAt, true)}`
|
||||
: teamWorkload.summary.syncStatus === 'error' ? '同步失败' : '等待首轮同步'
|
||||
: '每日同步待接入'}
|
||||
</p>
|
||||
</div>
|
||||
@ -346,6 +354,8 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month
|
||||
? <OnTimeDeliveryInsightsView insights={onTimeDelivery} />
|
||||
: hasFirstPassSource
|
||||
? <FirstPassProjectList metric={metric} query={query} firstPassPage={firstPassPage} />
|
||||
: hasTeamWorkloadSource
|
||||
? <TeamWorkloadInsightsView insights={teamWorkload} />
|
||||
: <PendingDataSource metric={metric} />}
|
||||
</main>
|
||||
</DepartmentShell>
|
||||
|
||||
@ -47,6 +47,21 @@ describe('CoreMetricsDashboard', () => {
|
||||
syncStatus: 'ready',
|
||||
},
|
||||
}
|
||||
: url.includes('team-workload')
|
||||
? {
|
||||
success: true,
|
||||
data: {
|
||||
averageLoadPercent: 30.7,
|
||||
personnelCount: 44,
|
||||
busyPersonnel: 17,
|
||||
idlePersonnel: 27,
|
||||
activeProjectAssignments: 27,
|
||||
sourceOngoingProjects: 27,
|
||||
excludedNames: ['元泽', '江陵', '无邪', '清云', '伯言', '希文', '文轩'],
|
||||
lastSyncedAt: '2026-08-14T02:00:00.000Z',
|
||||
syncStatus: 'ready',
|
||||
},
|
||||
}
|
||||
: {
|
||||
success: true,
|
||||
data: {
|
||||
@ -67,7 +82,7 @@ describe('CoreMetricsDashboard', () => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('展示真实流程指标、首轮通过率与其余两项诚实的示例状态', async () => {
|
||||
it('展示真实流程、首轮通过率与团队负荷指标', async () => {
|
||||
render(<CoreMetricsDashboard />)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: '核心指标' })).toBeInTheDocument()
|
||||
@ -76,19 +91,19 @@ describe('CoreMetricsDashboard', () => {
|
||||
expect(screen.getByText('V1通过 11 / 已通过 15')).toBeInTheDocument()
|
||||
expect(screen.getByText('作品过稿后台')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('每小时同步').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('示例值')).toHaveLength(2)
|
||||
expect(screen.getAllByText('示例值')).toHaveLength(1)
|
||||
|
||||
for (const label of ['进行中项目', '本月上新', '按时交付', '首轮通过', '团队负荷', '风险']) {
|
||||
expect(screen.getByRole('heading', { level: 3, name: label })).toBeInTheDocument()
|
||||
}
|
||||
|
||||
for (const value of ['2338', '8/13', '92.3%', '73.3%', '87%', '4项']) {
|
||||
for (const value of ['2338', '8/13', '92.3%', '73.3%', '30.7%', '4项']) {
|
||||
expect(screen.getByText(value)).toBeInTheDocument()
|
||||
}
|
||||
|
||||
expect(screen.getByText('本月已完结 8 · 当前进行中 5')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getAllByText('公式待接入')).toHaveLength(2)
|
||||
expect(screen.getAllByText('公式待接入')).toHaveLength(1)
|
||||
expect(screen.getAllByText('钉钉流程快照').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('未超时 12 / 本月完结 13')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('查看明细')).toHaveLength(5)
|
||||
@ -115,8 +130,8 @@ describe('CoreMetricsDashboard', () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({ ok: false, json: async () => ({ success: false }) }) as jest.Mock
|
||||
render(<CoreMetricsDashboard />)
|
||||
|
||||
expect(await screen.findAllByText('读取失败')).toHaveLength(4)
|
||||
expect(await screen.findAllByText('读取失败')).toHaveLength(5)
|
||||
expect(screen.queryByText('82.3%')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText('数据未加载')).toHaveLength(4)
|
||||
expect(screen.getAllByText('数据未加载')).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
@ -11,6 +11,7 @@ import type {
|
||||
MonthlyLaunchSummary,
|
||||
OnTimeDeliverySummary,
|
||||
OngoingDingWorkflowSummary,
|
||||
TeamWorkloadSummary,
|
||||
} from '@/lib/ding-workflow-sync'
|
||||
import DepartmentShell from './DepartmentShell'
|
||||
import { CORE_METRICS, getCoreMetricHref } from './core-metrics'
|
||||
@ -36,6 +37,11 @@ type OnTimeDeliveryState =
|
||||
| { status: 'success'; data: OnTimeDeliverySummary }
|
||||
| { status: 'error'; data: null }
|
||||
|
||||
type TeamWorkloadState =
|
||||
| { status: 'loading'; data: null }
|
||||
| { status: 'success'; data: TeamWorkloadSummary }
|
||||
| { status: 'error'; data: null }
|
||||
|
||||
function formatRate(rate: number | null) {
|
||||
if (rate === null) return '—'
|
||||
return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%`
|
||||
@ -58,6 +64,7 @@ export default function CoreMetricsDashboard() {
|
||||
const [ongoingWorkflows, setOngoingWorkflows] = useState<OngoingWorkflowState>({ status: 'loading', data: null })
|
||||
const [monthlyLaunches, setMonthlyLaunches] = useState<MonthlyLaunchState>({ status: 'loading', data: null })
|
||||
const [onTimeDelivery, setOnTimeDelivery] = useState<OnTimeDeliveryState>({ status: 'loading', data: null })
|
||||
const [teamWorkload, setTeamWorkload] = useState<TeamWorkloadState>({ status: 'loading', data: null })
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
@ -118,10 +125,25 @@ export default function CoreMetricsDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTeamWorkloadSummary() {
|
||||
try {
|
||||
const response = await fetch('/api/department-dashboard/core-metrics/team-workload', {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok || !payload?.success || !payload.data) throw new Error('指标读取失败')
|
||||
setTeamWorkload({ status: 'success', data: payload.data })
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') setTeamWorkload({ status: 'error', data: null })
|
||||
}
|
||||
}
|
||||
|
||||
void loadFirstPassSummary()
|
||||
void loadOngoingWorkflowSummary()
|
||||
void loadMonthlyLaunchSummary()
|
||||
void loadOnTimeDeliverySummary()
|
||||
void loadTeamWorkloadSummary()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
@ -157,6 +179,7 @@ export default function CoreMetricsDashboard() {
|
||||
const isMonthlyLaunch = metric.key === 'monthly-launches'
|
||||
const isFirstPass = metric.key === 'first-pass-rate'
|
||||
const isOnTimeDelivery = metric.key === 'on-time-delivery'
|
||||
const isTeamWorkload = metric.key === 'team-workload'
|
||||
const badge = isOngoingWorkflow
|
||||
? ongoingWorkflows.status === 'success'
|
||||
? ongoingWorkflows.data.syncStatus === 'ready'
|
||||
@ -191,6 +214,12 @@ export default function CoreMetricsDashboard() {
|
||||
? firstPass.status === 'success'
|
||||
? firstPass.data.syncStatus === 'ready' ? '每小时同步' : firstPass.data.syncStatus === 'error' ? '同步异常' : '正在同步'
|
||||
: firstPass.status === 'error' ? '读取失败' : '正在读取'
|
||||
: isTeamWorkload
|
||||
? teamWorkload.status === 'success'
|
||||
? teamWorkload.data.syncStatus === 'ready'
|
||||
? '每小时同步'
|
||||
: teamWorkload.data.syncStatus === 'error' ? '同步异常' : '正在同步'
|
||||
: teamWorkload.status === 'error' ? '读取失败' : '正在读取'
|
||||
: '示例值'
|
||||
const value = isOngoingWorkflow
|
||||
? ongoingWorkflows.status === 'success' ? String(ongoingWorkflows.data.total) : '—'
|
||||
@ -200,6 +229,10 @@ export default function CoreMetricsDashboard() {
|
||||
? onTimeDelivery.status === 'success' ? formatRate(onTimeDelivery.data.rate) : '—'
|
||||
: isFirstPass
|
||||
? firstPass.status === 'success' ? formatRate(firstPass.data.rate) : '—'
|
||||
: isTeamWorkload
|
||||
? teamWorkload.status === 'success' && teamWorkload.data.averageLoadPercent !== null
|
||||
? `${teamWorkload.data.averageLoadPercent}%`
|
||||
: '—'
|
||||
: metric.exampleValue
|
||||
|
||||
return (
|
||||
@ -219,11 +252,11 @@ export default function CoreMetricsDashboard() {
|
||||
</div>
|
||||
|
||||
<h3 className="mt-6 text-base font-bold text-[#4f6077]">{metric.label}</h3>
|
||||
<div aria-live={isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery ? 'polite' : undefined} aria-atomic={isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery ? 'true' : undefined}>
|
||||
<div aria-live={isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery || isTeamWorkload ? 'polite' : undefined} aria-atomic={isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery || isTeamWorkload ? 'true' : undefined}>
|
||||
{isOnTimeDelivery && onTimeDelivery.status === 'success' && onTimeDelivery.data.rate !== null
|
||||
? <OnTimeDeliveryRate rate={onTimeDelivery.data.rate} contextLabel={`${metric.label}当前值`} />
|
||||
: (
|
||||
<p className="mt-2 text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl" aria-label={`${metric.label}${isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery ? '当前值' : '示例值'}${value}`}>
|
||||
<p className="mt-2 text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl" aria-label={`${metric.label}${isFirstPass || isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery || isTeamWorkload ? '当前值' : '示例值'}${value}`}>
|
||||
{value}
|
||||
</p>
|
||||
)}
|
||||
@ -264,6 +297,19 @@ export default function CoreMetricsDashboard() {
|
||||
{isOnTimeDelivery && onTimeDelivery.status === 'loading' && (
|
||||
<p className="mt-2 text-xs font-semibold text-slate-500">正在计算本月交付时效</p>
|
||||
)}
|
||||
{isTeamWorkload && teamWorkload.status === 'success' && (
|
||||
<p className="mt-2 text-xs font-semibold tabular-nums text-slate-500">
|
||||
{teamWorkload.data.busyPersonnel} 人有进行中流程 / 共 {teamWorkload.data.personnelCount} 人
|
||||
</p>
|
||||
)}
|
||||
{isTeamWorkload && teamWorkload.status === 'success' && teamWorkload.data.lastSyncedAt && (
|
||||
<p className="mt-2 text-xs font-semibold tabular-nums text-slate-500">
|
||||
最近同步 {formatSyncTime(teamWorkload.data.lastSyncedAt)}
|
||||
</p>
|
||||
)}
|
||||
{isTeamWorkload && teamWorkload.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>
|
||||
|
||||
@ -271,14 +317,21 @@ export default function CoreMetricsDashboard() {
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-slate-500">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CalendarClock className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery
|
||||
? (isOngoingWorkflow ? ongoingWorkflows.status : isMonthlyLaunch ? monthlyLaunches.status : onTimeDelivery.status) === 'error' ? '刷新页面重试' : '每小时增量同步'
|
||||
{isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery || isTeamWorkload
|
||||
? (isOngoingWorkflow
|
||||
? ongoingWorkflows.status
|
||||
: isMonthlyLaunch
|
||||
? monthlyLaunches.status
|
||||
: isOnTimeDelivery
|
||||
? onTimeDelivery.status
|
||||
: teamWorkload.status) === 'error' ? '刷新页面重试' : '每小时增量同步'
|
||||
: isFirstPass ? (firstPass.status === 'error' ? '刷新页面重试' : '每小时增量同步') : '每日同步'}
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{isOngoingWorkflow || isMonthlyLaunch || isOnTimeDelivery
|
||||
? (isOngoingWorkflow ? ongoingWorkflows.status : isMonthlyLaunch ? monthlyLaunches.status : onTimeDelivery.status) === 'error' ? '数据未加载' : '钉钉流程快照'
|
||||
: isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '作品过稿后台') : '公式待接入'}</span>
|
||||
: isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '作品过稿后台')
|
||||
: isTeamWorkload ? (teamWorkload.status === 'error' ? '数据未加载' : '钉钉流程快照') : '公式待接入'}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[#176fc1]">
|
||||
{isFirstPass ? '查看所有作品明细' : '查看明细'}
|
||||
|
||||
120
components/department-dashboard/TeamWorkloadInsights.tsx
Normal file
120
components/department-dashboard/TeamWorkloadInsights.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useState } from 'react'
|
||||
import { ChevronDown, Clock3 } from 'lucide-react'
|
||||
import type {
|
||||
TeamWorkloadDepartment,
|
||||
TeamWorkloadInsights as TeamWorkloadInsightsData,
|
||||
TeamWorkloadPerson,
|
||||
} from '@/lib/ding-workflow-sync'
|
||||
|
||||
function formatPercent(value: number | null) {
|
||||
if (value === null) return '—'
|
||||
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function personStatusLabel(person: TeamWorkloadPerson) {
|
||||
if (person.status === 'idle') return '空闲'
|
||||
if (person.status === 'available') return '1 个进行中 · 负荷 50%'
|
||||
if (person.status === 'full') return '2 个进行中 · 满负荷 100%'
|
||||
return `${person.activeProjectCount} 个进行中 · 负荷 ${person.loadPercent}%`
|
||||
}
|
||||
|
||||
function personLoadBadgeClass(status: TeamWorkloadPerson['status']) {
|
||||
if (status === 'idle') return 'bg-slate-200 text-slate-700'
|
||||
if (status === 'overloaded') return 'bg-red-100 text-red-800'
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
}
|
||||
|
||||
function DepartmentRow({ department }: { department: TeamWorkloadDepartment }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const detailsId = useId()
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
className="flex w-full items-center justify-between gap-4 p-5 text-left hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#5a9ce6]"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block break-words text-base font-bold text-slate-950" title={department.name}>{department.name}</span>
|
||||
<span className="mt-1 block text-xs text-slate-500">
|
||||
{department.personnelCount} 人 · {department.busyPersonnel} 人有进行中 · {department.activeProjectAssignments} 个流程归属
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-3">
|
||||
<span className="text-lg font-bold tabular-nums text-slate-950">{formatPercent(department.averageLoadPercent)}</span>
|
||||
<ChevronDown className={`h-5 w-5 text-slate-500 transition-transform ${expanded ? 'rotate-180' : ''}`} aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div id={detailsId} role="region" aria-label={`${department.name}人员与流程明细`} className="border-t border-slate-200 p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{department.people.map((person) => (
|
||||
<article key={person.id} className="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="break-words font-bold text-slate-950" title={person.name}>{person.name}</p>
|
||||
<p className="mt-1 text-xs text-slate-500">{person.activeProjectCount} 个进行中流程</p>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-md px-2 py-1 text-xs font-bold ${personLoadBadgeClass(person.status)}`}>
|
||||
{formatPercent(person.loadPercent)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-xs font-semibold text-slate-600">{personStatusLabel(person)}</p>
|
||||
{person.projects.length > 0 && (
|
||||
<ul className="mt-3 space-y-2 border-t border-slate-200 pt-3">
|
||||
{person.projects.map((project) => (
|
||||
<li key={project.id} className="break-words text-xs leading-5 text-slate-700">
|
||||
<span className="font-semibold text-slate-900" title={project.title}>{project.title}</span>
|
||||
<span className="block text-slate-500">{project.projectType} · {project.nodeNames.join(' / ') || '当前处理节点'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TeamWorkloadInsights({ insights }: { insights: TeamWorkloadInsightsData }) {
|
||||
const { summary, departments } = insights
|
||||
return (
|
||||
<section aria-labelledby="team-workload-detail-title" className="mt-10">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 id="team-workload-detail-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">
|
||||
统计品牌中心在职人员当前手里的进行中流程;0 个为空闲,1 个为 50%,2 个为 100%,超过 2 个后每个流程继续增加 50%。点击部门可展开人员与流程明细。
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 rounded-xl bg-white px-3 py-2 text-xs font-bold text-slate-600">
|
||||
<Clock3 className="h-4 w-4" aria-hidden="true" />每小时增量同步
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4"><p className="text-xs font-bold text-slate-500">品牌中心平均负荷</p><p className="mt-2 text-2xl font-bold tabular-nums text-slate-950">{formatPercent(summary.averageLoadPercent)}</p></div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4"><p className="text-xs font-bold text-slate-500">统计人员</p><p className="mt-2 text-2xl font-bold tabular-nums text-slate-950">{summary.personnelCount} 人</p></div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4"><p className="text-xs font-bold text-slate-500">有进行中流程</p><p className="mt-2 text-2xl font-bold tabular-nums text-slate-950">{summary.busyPersonnel} 人</p></div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4"><p className="text-xs font-bold text-slate-500">进行中流程归属</p><p className="mt-2 text-2xl font-bold tabular-nums text-slate-950">{summary.activeProjectAssignments} 个</p></div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-xl border border-blue-100 bg-blue-50 px-4 py-3 text-xs leading-5 text-blue-900">
|
||||
已排除:{summary.excludedNames.join('、')}。进行中流程与“进行中项目”板块使用同一份钉钉流程快照;当前快照共 {summary.sourceOngoingProjects} 个流程。
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{departments.length === 0 ? (
|
||||
<div role="status" className="rounded-xl border border-slate-200 bg-white px-5 py-12 text-center text-sm text-slate-600">暂无匹配的品牌中心在职人员</div>
|
||||
) : departments.map((department) => <DepartmentRow key={department.name} department={department} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -68,11 +68,11 @@ export const CORE_METRICS: CoreMetric[] = [
|
||||
{
|
||||
key: 'team-workload',
|
||||
label: '团队负荷',
|
||||
exampleValue: '87%',
|
||||
description: '当前任务负荷相对可用产能的占比。',
|
||||
exampleValue: '—',
|
||||
description: '品牌中心在职人员(排除指定管理人员)的进行中流程负荷平均值;每个进行中流程计 50%,空闲人员按 0% 计入。',
|
||||
icon: UsersRound,
|
||||
detailDescription: '用于对照团队可用产能、已分配任务与负荷风险。',
|
||||
plannedFields: ['团队或人员', '可用产能', '已分配', '负荷率', '风险状态'],
|
||||
detailDescription: '按部门展开品牌中心人员的进行中流程数量、当前节点与负荷;同一人员同一流程只计一次,数据每小时增量同步。',
|
||||
plannedFields: ['部门', '人员', '进行中流程数', '当前节点', '负荷率', '状态'],
|
||||
},
|
||||
{
|
||||
key: 'risks',
|
||||
|
||||
113
lib/__tests__/ding-workflow-team-workload.test.ts
Normal file
113
lib/__tests__/ding-workflow-team-workload.test.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { buildTeamWorkloadInsights } from '@/lib/ding-workflow-sync'
|
||||
|
||||
jest.mock('mssql', () => ({
|
||||
__esModule: true,
|
||||
default: {},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
|
||||
jest.mock('@/lib/brand-center-access', () => ({ resolveBrandCenterAccessForUser: jest.fn() }))
|
||||
|
||||
function rosterRow(overrides: Partial<{
|
||||
id: string
|
||||
dingtalkUserId: string | null
|
||||
displayName: string
|
||||
flowerName: string | null
|
||||
realName: string | null
|
||||
departmentName: string | null
|
||||
departmentPath: string | null
|
||||
employmentState: string | null
|
||||
departmentMemberships: Array<{ departmentName: string | null; departmentPath: string | null; isPrimary: boolean }>
|
||||
}> = {}) {
|
||||
return {
|
||||
id: 'person-1',
|
||||
dingtalkUserId: 'ding-1',
|
||||
displayName: '一号员工',
|
||||
flowerName: null,
|
||||
realName: null,
|
||||
departmentName: '设计部',
|
||||
departmentPath: '品牌中心/设计部',
|
||||
employmentState: 'ACTIVE',
|
||||
departmentMemberships: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotRow(id: string, assignees: string[], title = id) {
|
||||
return {
|
||||
processInstanceId: id,
|
||||
businessId: `business-${id}`,
|
||||
title,
|
||||
processCode: 'PROC-ART',
|
||||
projectType: '美术设计流程',
|
||||
activeTasks: assignees.map((assigneeUserId, index) => ({
|
||||
taskId: `${id}-task-${index}`,
|
||||
activityId: `activity-${index}`,
|
||||
nodeName: `节点${index + 1}`,
|
||||
assigneeUserId,
|
||||
assigneeName: assigneeUserId,
|
||||
assigneeDepartment: '品牌中心/设计部',
|
||||
isBrandCenter: true,
|
||||
isLeadership: false,
|
||||
isActive: true,
|
||||
reviewPassed: false,
|
||||
result: null,
|
||||
createdAt: null,
|
||||
finishedAt: null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildTeamWorkloadInsights', () => {
|
||||
it('排除指定人员、把空闲人员计入平均值,并按流程去重', () => {
|
||||
const insights = buildTeamWorkloadInsights(
|
||||
[
|
||||
rosterRow({ id: 'busy', dingtalkUserId: 'busy', displayName: '忙碌员工' }),
|
||||
rosterRow({ id: 'idle', dingtalkUserId: 'idle', displayName: '空闲员工', departmentName: '内容部', departmentPath: '品牌中心/内容部' }),
|
||||
rosterRow({ id: 'excluded', dingtalkUserId: 'excluded', displayName: '元泽' }),
|
||||
rosterRow({ id: 'resigned', dingtalkUserId: 'resigned', displayName: '离职员工', employmentState: 'RESIGNED' }),
|
||||
],
|
||||
[
|
||||
snapshotRow('project-1', ['busy', 'busy']),
|
||||
snapshotRow('project-2', ['busy']),
|
||||
snapshotRow('project-3', ['busy']),
|
||||
snapshotRow('project-excluded', ['excluded']),
|
||||
],
|
||||
{ lastSyncedAt: '2026-08-31T02:00:00.000Z', syncStatus: 'ready' },
|
||||
)
|
||||
|
||||
expect(insights.summary).toMatchObject({
|
||||
averageLoadPercent: 75,
|
||||
personnelCount: 2,
|
||||
busyPersonnel: 1,
|
||||
idlePersonnel: 1,
|
||||
activeProjectAssignments: 3,
|
||||
sourceOngoingProjects: 4,
|
||||
})
|
||||
expect(insights.departments).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: '设计部', personnelCount: 1, averageLoadPercent: 150 }),
|
||||
expect.objectContaining({ name: '内容部', personnelCount: 1, averageLoadPercent: 0 }),
|
||||
]))
|
||||
const busy = insights.departments.find((department) => department.name === '设计部')!.people[0]
|
||||
expect(busy.activeProjectCount).toBe(3)
|
||||
expect(busy.loadPercent).toBe(150)
|
||||
expect(busy.status).toBe('overloaded')
|
||||
expect(busy.projects).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('只统计当前活跃节点,并以当前花名册组织归属为准', () => {
|
||||
const insights = buildTeamWorkloadInsights(
|
||||
[rosterRow()],
|
||||
[{
|
||||
...snapshotRow('project-1', ['ding-1']),
|
||||
activeTasks: [
|
||||
{ ...snapshotRow('project-1', ['ding-1']).activeTasks[0], isActive: false },
|
||||
{ ...snapshotRow('project-1', ['ding-1']).activeTasks[0], taskId: 'other', isBrandCenter: false, isActive: true },
|
||||
],
|
||||
}],
|
||||
{ lastSyncedAt: null, syncStatus: 'pending' },
|
||||
)
|
||||
expect(insights.summary.activeProjectAssignments).toBe(1)
|
||||
expect(insights.summary.averageLoadPercent).toBe(50)
|
||||
})
|
||||
})
|
||||
@ -38,6 +38,7 @@ export const DING_WORKFLOW_ALLOWED_PROCESS_CODES = [
|
||||
] as const
|
||||
export const DING_WORKFLOW_BRANDS = ['亿家达', '卓禾', '欧意朗', '顾家', '索菲亚', '朴美'] as const
|
||||
export const DING_WORKFLOW_NEW_PRODUCT_DEMAND_TYPE = '新品'
|
||||
export const TEAM_WORKLOAD_EXCLUDED_NAMES = ['元泽', '江陵', '无邪', '清云', '伯言', '希文', '文轩'] as const
|
||||
const ALLOWED_PROCESS_CODE_VALUES: string[] = [...DING_WORKFLOW_ALLOWED_PROCESS_CODES]
|
||||
const ALLOWED_PROCESS_CODE_SET = new Set<string>(DING_WORKFLOW_ALLOWED_PROCESS_CODES)
|
||||
const BRAND_CENTER_ACTIVE_TASK_JSON = [{ isBrandCenter: true, isActive: true }]
|
||||
@ -362,6 +363,55 @@ export type OngoingDingWorkflowInsights = {
|
||||
sync: OngoingDingWorkflowSummary
|
||||
}
|
||||
|
||||
export type TeamWorkloadStatus = 'idle' | 'available' | 'full' | 'overloaded'
|
||||
|
||||
export type TeamWorkloadProject = {
|
||||
id: string
|
||||
businessId: string | null
|
||||
title: string
|
||||
projectType: string
|
||||
nodeNames: string[]
|
||||
}
|
||||
|
||||
export type TeamWorkloadPerson = {
|
||||
id: string
|
||||
dingtalkUserId: string | null
|
||||
name: string
|
||||
departmentName: string
|
||||
departmentPath: string
|
||||
activeProjectCount: number
|
||||
loadPercent: number
|
||||
status: TeamWorkloadStatus
|
||||
projects: TeamWorkloadProject[]
|
||||
}
|
||||
|
||||
export type TeamWorkloadDepartment = {
|
||||
name: string
|
||||
personnelCount: number
|
||||
busyPersonnel: number
|
||||
idlePersonnel: number
|
||||
activeProjectAssignments: number
|
||||
averageLoadPercent: number
|
||||
people: TeamWorkloadPerson[]
|
||||
}
|
||||
|
||||
export type TeamWorkloadSummary = {
|
||||
averageLoadPercent: number | null
|
||||
personnelCount: number
|
||||
busyPersonnel: number
|
||||
idlePersonnel: number
|
||||
activeProjectAssignments: number
|
||||
sourceOngoingProjects: number
|
||||
excludedNames: readonly string[]
|
||||
lastSyncedAt: string | null
|
||||
syncStatus: DingWorkflowSyncStatus
|
||||
}
|
||||
|
||||
export type TeamWorkloadInsights = {
|
||||
summary: TeamWorkloadSummary
|
||||
departments: TeamWorkloadDepartment[]
|
||||
}
|
||||
|
||||
export type MonthlyLaunchWorkflowStatus = 'completed' | 'ongoing'
|
||||
|
||||
export type MonthlyLaunchWorkflowItem = OngoingDingWorkflowListItem & {
|
||||
@ -628,6 +678,200 @@ function normalizeWorkflowTasks(value: unknown): OngoingDingWorkflowActiveTask[]
|
||||
})
|
||||
}
|
||||
|
||||
type TeamWorkloadRosterRow = {
|
||||
id: string
|
||||
dingtalkUserId: string | null
|
||||
displayName: string
|
||||
flowerName: string | null
|
||||
realName: string | null
|
||||
departmentName: string | null
|
||||
departmentPath: string | null
|
||||
employmentState: string | null
|
||||
departmentMemberships: Array<{
|
||||
departmentName: string | null
|
||||
departmentPath: string | null
|
||||
isPrimary: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
type TeamWorkloadSnapshotRow = {
|
||||
processInstanceId: string
|
||||
businessId: string | null
|
||||
title: string | null
|
||||
processCode: string | null
|
||||
projectType: string | null
|
||||
activeTasks: unknown
|
||||
}
|
||||
|
||||
function cleanTeamWorkloadName(value: unknown) {
|
||||
const normalized = String(value ?? '').trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function profileHasBrandCenterPath(profile: TeamWorkloadRosterRow) {
|
||||
return [
|
||||
profile.departmentPath,
|
||||
...profile.departmentMemberships.map((membership) => membership.departmentPath),
|
||||
].some(isBrandCenterOrganizationPath)
|
||||
}
|
||||
|
||||
function profileIsExcludedFromTeamWorkload(profile: TeamWorkloadRosterRow) {
|
||||
const names = [profile.displayName, profile.flowerName, profile.realName]
|
||||
.map(cleanTeamWorkloadName)
|
||||
.filter((name): name is string => Boolean(name))
|
||||
return names.some((name) => TEAM_WORKLOAD_EXCLUDED_NAMES.includes(name as typeof TEAM_WORKLOAD_EXCLUDED_NAMES[number]))
|
||||
}
|
||||
|
||||
function profileDepartmentForTeamWorkload(profile: TeamWorkloadRosterRow) {
|
||||
const matchingPaths = [
|
||||
{ departmentPath: profile.departmentPath, departmentName: profile.departmentName, isPrimary: true },
|
||||
...profile.departmentMemberships,
|
||||
].filter((entry) => isBrandCenterOrganizationPath(entry.departmentPath))
|
||||
const selected = matchingPaths.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))[0]
|
||||
const parts = String(selected?.departmentPath || profile.departmentPath || '')
|
||||
.split('/')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
const brandCenterIndex = parts.indexOf('品牌中心')
|
||||
const departmentName = brandCenterIndex >= 0
|
||||
? parts[brandCenterIndex + 1] || selected?.departmentName || '品牌中心'
|
||||
: selected?.departmentName || profile.departmentName || '品牌中心'
|
||||
const departmentPath = brandCenterIndex >= 0
|
||||
? parts.slice(brandCenterIndex).join('/')
|
||||
: String(selected?.departmentPath || profile.departmentPath || '品牌中心')
|
||||
return { departmentName, departmentPath }
|
||||
}
|
||||
|
||||
function workloadStatus(activeProjectCount: number): TeamWorkloadStatus {
|
||||
if (activeProjectCount <= 0) return 'idle'
|
||||
if (activeProjectCount === 1) return 'available'
|
||||
if (activeProjectCount === 2) return 'full'
|
||||
return 'overloaded'
|
||||
}
|
||||
|
||||
function roundWorkloadPercent(value: number) {
|
||||
return Math.round(value * 10) / 10
|
||||
}
|
||||
|
||||
/**
|
||||
* 品牌中心团队负荷的纯计算部分,供同步读取和单元测试复用。
|
||||
* 每个当前节点处理人按流程去重后,每条进行中流程计 50% 负荷;平均值的
|
||||
* 分母是排除指定管理人员后的全部在职品牌中心人员,因此没有进行中流程的人
|
||||
* 会以 0%(空闲)计入平均值。
|
||||
*/
|
||||
export function buildTeamWorkloadInsights(
|
||||
rosterRows: TeamWorkloadRosterRow[],
|
||||
snapshotRows: TeamWorkloadSnapshotRow[],
|
||||
sync: Pick<OngoingDingWorkflowSummary, 'lastSyncedAt' | 'syncStatus'>,
|
||||
): TeamWorkloadInsights {
|
||||
const roster = rosterRows
|
||||
.filter((profile) => profile.employmentState !== 'RESIGNED')
|
||||
.filter(profileHasBrandCenterPath)
|
||||
.filter((profile) => !profileIsExcludedFromTeamWorkload(profile))
|
||||
.map((profile) => {
|
||||
const department = profileDepartmentForTeamWorkload(profile)
|
||||
return {
|
||||
profile,
|
||||
name: cleanTeamWorkloadName(profile.flowerName)
|
||||
|| cleanTeamWorkloadName(profile.displayName)
|
||||
|| cleanTeamWorkloadName(profile.realName)
|
||||
|| '未命名人员',
|
||||
...department,
|
||||
}
|
||||
})
|
||||
|
||||
const peopleByDingtalkUserId = new Map(
|
||||
roster
|
||||
.filter((person) => person.profile.dingtalkUserId)
|
||||
.map((person) => [person.profile.dingtalkUserId as string, person]),
|
||||
)
|
||||
const projectByPerson = new Map<string, Map<string, TeamWorkloadProject>>()
|
||||
|
||||
snapshotRows.forEach((snapshot) => {
|
||||
const projectId = snapshot.processInstanceId
|
||||
// 当前 HR 花名册负责判定人员是否属于品牌中心;不再依赖快照同步时物化的
|
||||
// isBrandCenter 标记,避免人员调入品牌中心后、流程未更新时发生漏算。
|
||||
const tasks = normalizeWorkflowTasks(snapshot.activeTasks)
|
||||
.filter((task) => task.isActive && task.assigneeUserId)
|
||||
const tasksByPerson = new Map<string, OngoingDingWorkflowActiveTask[]>()
|
||||
tasks.forEach((task) => {
|
||||
const person = peopleByDingtalkUserId.get(task.assigneeUserId as string)
|
||||
if (!person) return
|
||||
const current = tasksByPerson.get(person.profile.id) || []
|
||||
current.push(task)
|
||||
tasksByPerson.set(person.profile.id, current)
|
||||
})
|
||||
tasksByPerson.forEach((personTasks, personId) => {
|
||||
const projects = projectByPerson.get(personId) || new Map<string, TeamWorkloadProject>()
|
||||
const existing = projects.get(projectId)
|
||||
const nodeNames = Array.from(new Set(personTasks.map((task) => task.nodeName).filter(Boolean)))
|
||||
projects.set(projectId, {
|
||||
id: projectId,
|
||||
businessId: snapshot.businessId,
|
||||
title: snapshot.title || '未命名流程',
|
||||
projectType: snapshot.projectType || getDingWorkflowProjectType(snapshot.processCode),
|
||||
nodeNames: existing ? Array.from(new Set([...existing.nodeNames, ...nodeNames])) : nodeNames,
|
||||
})
|
||||
projectByPerson.set(personId, projects)
|
||||
})
|
||||
})
|
||||
|
||||
const people = roster.map((entry): TeamWorkloadPerson => {
|
||||
const projects = Array.from(projectByPerson.get(entry.profile.id)?.values() || [])
|
||||
.sort((a, b) => a.title.localeCompare(b.title, 'zh-CN') || a.id.localeCompare(b.id))
|
||||
const activeProjectCount = projects.length
|
||||
return {
|
||||
id: entry.profile.id,
|
||||
dingtalkUserId: entry.profile.dingtalkUserId,
|
||||
name: entry.name,
|
||||
departmentName: entry.departmentName,
|
||||
departmentPath: entry.departmentPath,
|
||||
activeProjectCount,
|
||||
loadPercent: activeProjectCount * 50,
|
||||
status: workloadStatus(activeProjectCount),
|
||||
projects,
|
||||
}
|
||||
}).sort((a, b) => b.loadPercent - a.loadPercent || a.name.localeCompare(b.name, 'zh-CN'))
|
||||
|
||||
const departments = Array.from(new Set(people.map((person) => person.departmentName)))
|
||||
.map((name): TeamWorkloadDepartment => {
|
||||
const departmentPeople = people
|
||||
.filter((person) => person.departmentName === name)
|
||||
.sort((a, b) => b.loadPercent - a.loadPercent || a.name.localeCompare(b.name, 'zh-CN'))
|
||||
const activeProjectAssignments = departmentPeople.reduce((total, person) => total + person.activeProjectCount, 0)
|
||||
return {
|
||||
name,
|
||||
personnelCount: departmentPeople.length,
|
||||
busyPersonnel: departmentPeople.filter((person) => person.activeProjectCount > 0).length,
|
||||
idlePersonnel: departmentPeople.filter((person) => person.activeProjectCount === 0).length,
|
||||
activeProjectAssignments,
|
||||
averageLoadPercent: departmentPeople.length > 0
|
||||
? roundWorkloadPercent(activeProjectAssignments * 50 / departmentPeople.length)
|
||||
: 0,
|
||||
people: departmentPeople,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.averageLoadPercent - a.averageLoadPercent || a.name.localeCompare(b.name, 'zh-CN'))
|
||||
|
||||
const activeProjectAssignments = people.reduce((total, person) => total + person.activeProjectCount, 0)
|
||||
return {
|
||||
summary: {
|
||||
averageLoadPercent: people.length > 0
|
||||
? roundWorkloadPercent(activeProjectAssignments * 50 / people.length)
|
||||
: null,
|
||||
personnelCount: people.length,
|
||||
busyPersonnel: people.filter((person) => person.activeProjectCount > 0).length,
|
||||
idlePersonnel: people.filter((person) => person.activeProjectCount === 0).length,
|
||||
activeProjectAssignments,
|
||||
sourceOngoingProjects: snapshotRows.length,
|
||||
excludedNames: TEAM_WORKLOAD_EXCLUDED_NAMES,
|
||||
lastSyncedAt: sync.lastSyncedAt,
|
||||
syncStatus: sync.syncStatus,
|
||||
},
|
||||
departments,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDetailFormFields(value: unknown): DingWorkflowDetailFormFields {
|
||||
const record = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
@ -1629,6 +1873,67 @@ async function readSummaryWithoutAccessCheck(): Promise<OngoingDingWorkflowSumma
|
||||
}
|
||||
}
|
||||
|
||||
const TEAM_WORKLOAD_ROSTER_SELECT = {
|
||||
id: true,
|
||||
dingtalkUserId: true,
|
||||
displayName: true,
|
||||
flowerName: true,
|
||||
realName: true,
|
||||
departmentName: true,
|
||||
departmentPath: true,
|
||||
employmentState: true,
|
||||
departmentMemberships: {
|
||||
select: {
|
||||
departmentName: true,
|
||||
departmentPath: true,
|
||||
isPrimary: true,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const TEAM_WORKLOAD_SNAPSHOT_SELECT = {
|
||||
processInstanceId: true,
|
||||
businessId: true,
|
||||
title: true,
|
||||
processCode: true,
|
||||
projectType: true,
|
||||
activeTasks: true,
|
||||
} as const
|
||||
|
||||
async function readTeamWorkloadInsightsWithoutAccessCheck(): Promise<TeamWorkloadInsights> {
|
||||
const [rosterCandidates, snapshots, sync] = await Promise.all([
|
||||
prisma.performanceSurveyEmployeeProfile.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ departmentPath: { contains: '品牌中心' } },
|
||||
{ departmentMemberships: { some: { departmentPath: { contains: '品牌中心' } } } },
|
||||
],
|
||||
},
|
||||
select: TEAM_WORKLOAD_ROSTER_SELECT,
|
||||
}),
|
||||
prisma.dingWorkflowSnapshot.findMany({
|
||||
where: buildBrandCenterOngoingWorkflowWhere(),
|
||||
select: TEAM_WORKLOAD_SNAPSHOT_SELECT,
|
||||
orderBy: [
|
||||
{ sourceUpdatedAtRaw: { sort: 'desc', nulls: 'last' } },
|
||||
{ processInstanceId: 'desc' },
|
||||
],
|
||||
}),
|
||||
readSummaryWithoutAccessCheck(),
|
||||
])
|
||||
return buildTeamWorkloadInsights(rosterCandidates, snapshots, sync)
|
||||
}
|
||||
|
||||
export async function getTeamWorkloadSummary(session: Session | null) {
|
||||
await assertDashboardAccess(session)
|
||||
return (await readTeamWorkloadInsightsWithoutAccessCheck()).summary
|
||||
}
|
||||
|
||||
export async function getTeamWorkloadInsights(session: Session | null) {
|
||||
await assertDashboardAccess(session)
|
||||
return readTeamWorkloadInsightsWithoutAccessCheck()
|
||||
}
|
||||
|
||||
export async function getOngoingDingWorkflowSummary(session: Session | null) {
|
||||
await assertDashboardAccess(session)
|
||||
return readSummaryWithoutAccessCheck()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user