diff --git a/components/department-dashboard/CoreMetricDetail.test.tsx b/components/department-dashboard/CoreMetricDetail.test.tsx index e7726b0d..fe457a21 100644 --- a/components/department-dashboard/CoreMetricDetail.test.tsx +++ b/components/department-dashboard/CoreMetricDetail.test.tsx @@ -306,6 +306,29 @@ describe('CoreMetricDetail', () => { statusBreakdown: { approved: 1, revisionRequired: 1, pendingReview: 1, unknown: 0 }, readAt: '2026-08-14T02:00:00.000Z', }, + submitterRates: [ + { + submitterName: '林一', + totalUploads: 1, + totalApproved: 1, + firstPassApproved: 1, + rate: 100, + }, + { + submitterName: '林二', + totalUploads: 1, + totalApproved: 1, + firstPassApproved: 0, + rate: 0, + }, + { + submitterName: '林三', + totalUploads: 1, + totalApproved: 0, + firstPassApproved: 0, + rate: null, + }, + ], items: [ { id: 'approved-v1', @@ -363,7 +386,13 @@ describe('CoreMetricDetail', () => { ) expect(screen.getByRole('heading', { level: 1, name: '首轮通过' })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 2, name: '各提交人的首轮通过率' })).toBeInTheDocument() expect(screen.getByRole('heading', { level: 2, name: '所有作品明细' })).toBeInTheDocument() + const submitterTable = screen.getByRole('table', { name: '各提交人的首轮通过率,按通过率从高到低排列' }) + expect(within(submitterTable).getAllByRole('rowheader').map((cell) => cell.textContent)).toEqual(['林一', '林二', '林三']) + expect(within(submitterTable).getByText('100%')).toBeInTheDocument() + expect(within(submitterTable).getByText('0%')).toBeInTheDocument() + expect(within(submitterTable).getByText('—')).toBeInTheDocument() expect(screen.getByText(/全部已上传且包含 V1 的作品/)).toBeInTheDocument() expect(screen.getByText('首轮通过率 = V1 审核通过作品数 ÷ 所有已通过作品数 × 100%')).toBeInTheDocument() expect(screen.getByText('当前口径:1 ÷ 2 = 50%;共读取 3 个作品')).toBeInTheDocument() diff --git a/components/department-dashboard/CoreMetricDetail.tsx b/components/department-dashboard/CoreMetricDetail.tsx index 81f42333..e47f32b7 100644 --- a/components/department-dashboard/CoreMetricDetail.tsx +++ b/components/department-dashboard/CoreMetricDetail.tsx @@ -115,11 +115,67 @@ function FirstPassStatusBadge({ status }: { status: FirstPassReviewProjectListIt } function FirstPassProjectList({ metric, query, firstPassPage }: Required> & Pick) { - const { items, pagination, summary } = firstPassPage + const { items, pagination, summary, submitterRates } = firstPassPage return ( -
-
+
+
+

各提交人的首轮通过率

+

+ 按提交人汇总 V1 审核通过作品数 ÷ 当前已通过作品数,按通过率从高到低排列。 +

+ + {submitterRates.length === 0 ? ( +
+ 暂无可统计的提交人数据。 +
+ ) : ( + <> +
+ + + + + + + + + + + + + {submitterRates.map((submitter) => ( + + + + + + + + ))} + +
各提交人的首轮通过率,按通过率从高到低排列
提交人首轮通过率V1 通过当前已通过V1 作品数
{submitter.submitterName}{formatRate(submitter.rate)}{submitter.firstPassApproved}{submitter.totalApproved}{submitter.totalUploads}
+
+ +
    + {submitterRates.map((submitter) => ( +
  • +
    +

    {submitter.submitterName}

    +

    {formatRate(submitter.rate)}

    +
    +

    + V1 通过 {submitter.firstPassApproved} · 当前已通过 {submitter.totalApproved} · V1 作品 {submitter.totalUploads} +

    +
  • + ))} +
+ + )} +
+ +
+

所有作品明细

@@ -264,7 +320,8 @@ function FirstPassProjectList({ metric, query, firstPassPage }: Required )} -

+
+ ) } diff --git a/lib/__tests__/work-review-first-pass.test.ts b/lib/__tests__/work-review-first-pass.test.ts index 5a12233d..fd0ae9e3 100644 --- a/lib/__tests__/work-review-first-pass.test.ts +++ b/lib/__tests__/work-review-first-pass.test.ts @@ -45,31 +45,38 @@ describe('department dashboard first-pass review details', () => { ;(prisma.workReviewProject.count as jest.Mock).mockImplementation(({ where }) => ( JSON.stringify(where).includes('"status":"approved"') ? Promise.resolve(5) : Promise.resolve(1) )) - ;(prisma.workReviewProject.findMany as jest.Mock).mockResolvedValue([{ - id: 'project-1', - title: '品牌主视觉', - brand: null, - description: '第一版视觉作品', - status: 'revision_required', - latestVersion: 2, - submitterName: '提交人', - managerName: '主管', - finalReviewerName: '元泽', - workDate: new Date('2026-08-28T00:00:00.000Z'), - createdAt: new Date('2026-08-28T01:00:00.000Z'), - updatedAt: new Date('2026-08-30T01:00:00.000Z'), - versions: [{ - status: 'approved', - submittedAt: new Date('2026-08-28T01:00:00.000Z'), - completedAt: new Date('2026-08-28T03:00:00.000Z'), - reviews: [{ - reviewerRole: 'manager', - reviewerName: '主管', - decision: 'approved', - reviewedAt: new Date('2026-08-28T02:00:00.000Z'), + ;(prisma.workReviewProject.findMany as jest.Mock) + .mockResolvedValueOnce([{ + id: 'project-1', + title: '品牌主视觉', + brand: null, + description: '第一版视觉作品', + status: 'revision_required', + latestVersion: 2, + submitterName: '提交人', + managerName: '主管', + finalReviewerName: '元泽', + workDate: new Date('2026-08-28T00:00:00.000Z'), + createdAt: new Date('2026-08-28T01:00:00.000Z'), + updatedAt: new Date('2026-08-30T01:00:00.000Z'), + versions: [{ + status: 'approved', + submittedAt: new Date('2026-08-28T01:00:00.000Z'), + completedAt: new Date('2026-08-28T03:00:00.000Z'), + reviews: [{ + reviewerRole: 'manager', + reviewerName: '主管', + decision: 'approved', + reviewedAt: new Date('2026-08-28T02:00:00.000Z'), + }], }], - }], - }]) + }]) + .mockResolvedValueOnce([ + { submitterName: '林二', status: 'approved', versions: [{ status: 'revision_required' }] }, + { submitterName: '林一', status: 'approved', versions: [{ status: 'approved' }] }, + { submitterName: '林一', status: 'approved', versions: [] }, + { submitterName: '提交人', status: 'revision_required', versions: [{ status: 'approved' }] }, + ]) }) test('summary keeps the full brand-center V1 population while search narrows only detail rows', async () => { @@ -138,6 +145,23 @@ describe('department dashboard first-pass review details', () => { expect(prisma.workReviewProject.findMany).not.toHaveBeenCalledWith(expect.objectContaining({ select: expect.objectContaining({ imageUrls: true }), })) + expect(prisma.workReviewProject.findMany).toHaveBeenNthCalledWith(2, { + where: { + AND: [ + {}, + { versions: { some: { version: 1 } } }, + ], + }, + select: { + submitterName: true, + status: true, + versions: { + where: { version: 1 }, + select: { status: true }, + take: 1, + }, + }, + }) expect(result.summary).toMatchObject({ totalUploads: 3, @@ -145,6 +169,29 @@ describe('department dashboard first-pass review details', () => { firstPassApproved: 2, rate: 40, }) + expect(result.submitterRates).toEqual([ + { + submitterName: '林一', + totalUploads: 1, + totalApproved: 2, + firstPassApproved: 1, + rate: 50, + }, + { + submitterName: '林二', + totalUploads: 1, + totalApproved: 1, + firstPassApproved: 0, + rate: 0, + }, + { + submitterName: '提交人', + totalUploads: 1, + totalApproved: 0, + firstPassApproved: 1, + rate: null, + }, + ]) expect(result.items).toEqual([{ id: 'project-1', title: '品牌主视觉', diff --git a/lib/work-review-metrics.ts b/lib/work-review-metrics.ts index 5981dd83..63d44217 100644 --- a/lib/work-review-metrics.ts +++ b/lib/work-review-metrics.ts @@ -23,6 +23,20 @@ export type FirstPassReviewSummary = { syncRowsChecked?: number } +export type FirstPassSubmitterRate = { + submitterName: string + totalUploads: number + totalApproved: number + firstPassApproved: number + rate: number | null +} + +export type FirstPassSubmitterProject = { + submitterName?: string | null + currentStatus: string + firstVersionStatus?: string | null +} + export function normalizeFirstPassReviewStatus(status: string): FirstPassReviewStatus { if (status === 'approved' || status === 'revision_required' || status === 'pending_review') { return status @@ -71,3 +85,45 @@ export function buildFirstPassReviewSummary( readAt: readAt.toISOString(), } } + +/** + * 按提交人复用总指标口径:V1 审核通过作品数 / 当前已通过作品数。 + * 调用方应只传入包含 V1 的作品,与页面明细的统计范围保持一致。 + */ +export function buildFirstPassSubmitterRates( + projects: FirstPassSubmitterProject[], +): FirstPassSubmitterRate[] { + const ratesBySubmitter = new Map>() + + for (const project of projects) { + const submitterName = project.submitterName?.trim() || '未填写提交人' + const current = ratesBySubmitter.get(submitterName) || { + submitterName, + totalUploads: 0, + totalApproved: 0, + firstPassApproved: 0, + } + + if (project.firstVersionStatus) { + current.totalUploads += 1 + if (normalizeFirstPassReviewStatus(project.firstVersionStatus) === 'approved') { + current.firstPassApproved += 1 + } + } + if (project.currentStatus === 'approved') current.totalApproved += 1 + ratesBySubmitter.set(submitterName, current) + } + + return Array.from(ratesBySubmitter.values(), (item) => ({ + ...item, + rate: item.totalApproved === 0 + ? null + : Math.round((item.firstPassApproved / item.totalApproved) * 1000) / 10, + })).sort((left, right) => { + if (left.rate === null && right.rate !== null) return 1 + if (left.rate !== null && right.rate === null) return -1 + if (left.rate !== right.rate) return (right.rate || 0) - (left.rate || 0) + if (left.totalApproved !== right.totalApproved) return right.totalApproved - left.totalApproved + return left.submitterName.localeCompare(right.submitterName, 'zh-CN') + }) +} diff --git a/lib/work-review-service.ts b/lib/work-review-service.ts index ccf6de68..a1e421ee 100644 --- a/lib/work-review-service.ts +++ b/lib/work-review-service.ts @@ -57,10 +57,12 @@ import { type NormalizedWorkReviewAnnotation, } from '@/lib/work-review-policy' import { + buildFirstPassSubmitterRates, buildFirstPassReviewSummary, normalizeFirstPassReviewStatus, type FirstPassReviewStatus, type FirstPassReviewSummary, + type FirstPassSubmitterRate, } from '@/lib/work-review-metrics' import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope' @@ -604,6 +606,7 @@ export type FirstPassReviewProjectListItem = { export type FirstPassReviewProjectPage = { summary: FirstPassReviewSummary + submitterRates: FirstPassSubmitterRate[] items: FirstPassReviewProjectListItem[] pagination: { page: number @@ -704,7 +707,7 @@ export async function getFirstPassReviewProjects( ], } } - const [summary, total, projects] = await Promise.all([ + const [summary, total, projects, submitterProjects] = await Promise.all([ // Searching and detail-only V1 filters must not change the headline rate. // The denominator is every approved project in the complete department scope. getFirstPassReviewSummaryForProjectWhere(scopeWhere), @@ -747,10 +750,27 @@ export async function getFirstPassReviewProjects( skip: (page - 1) * pageSize, take: pageSize, }), + prisma.workReviewProject.findMany({ + where: { AND: [scopeWhere, { versions: { some: { version: 1 } } }] }, + select: { + submitterName: true, + status: true, + versions: { + where: { version: 1 }, + select: { status: true }, + take: 1, + }, + }, + }), ]) return { summary, + submitterRates: buildFirstPassSubmitterRates(submitterProjects.map((project) => ({ + submitterName: project.submitterName, + currentStatus: project.status, + firstVersionStatus: project.versions[0]?.status || null, + }))), items: projects.map((project) => { const firstVersion = project.versions[0] return {