gongxiangp2/lib/admin-material-stats.ts
2026-03-06 14:14:50 +08:00

578 lines
17 KiB
TypeScript

import { prisma } from '@/lib/prisma'
export interface MaterialStatsFilters {
startDate?: string | null
endDate?: string | null
departmentKey?: string | null
scopeDepartmentName?: string | null
}
export interface MaterialUserUploadStat {
userId: string
username: string
realName: string | null
departmentName: string
departmentKey: string
uploadCount: number
percentage?: number
}
export interface MaterialDepartmentUploadStat {
departmentKey: string
departmentId: string | null
departmentName: string
uploadCount: number
userCount: number
percentage: number
users: MaterialUserUploadStat[]
}
export interface MaterialTrendPoint {
bucket: string
count: number
}
export interface MaterialTypeUploadStat {
materialType: string
uploadCount: number
percentage: number
}
export interface MaterialTypeDistributionStat {
materialType: string
uploadCount: number
percentage: number
users: MaterialUserUploadStat[]
}
export interface MaterialDistributionSection<TItem> {
granularity: 'month' | 'day'
period: string
label: string
totalUploads: number
items: TItem[]
}
export interface MaterialUploadStatsResult {
filters: {
startDate: string | null
endDate: string | null
departmentKey: string | null
}
summary: {
totalUploads: number
totalDepartments: number
totalUsers: number
}
departments: MaterialDepartmentUploadStat[]
selectedDepartmentName: string
selectedUsers: MaterialUserUploadStat[]
dailyUploads: MaterialTrendPoint[]
monthlyUploads: MaterialTrendPoint[]
materialTypeStats: MaterialTypeUploadStat[]
departmentDistribution: MaterialDistributionSection<MaterialDepartmentUploadStat>
materialTypeDistribution: MaterialDistributionSection<MaterialTypeDistributionStat>
}
interface UserWithDepartment {
id: string
username: string
realName: string | null
department: string
departmentId: string | null
departmentRef: {
id: string
name: string
} | null
}
const UNASSIGNED_DEPARTMENT_KEY = 'unassigned'
const UNASSIGNED_DEPARTMENT_NAME = '未分配部门'
function toDateStart(dateString: string): Date {
return new Date(`${dateString}T00:00:00`)
}
function toDateEnd(dateString: string): Date {
return new Date(`${dateString}T23:59:59.999`)
}
function normalizeValue(value: unknown): string {
if (value === null || value === undefined) return ''
return String(value).trim()
}
function resolveDepartment(
user: UserWithDepartment,
departmentById: Map<string, { id: string; name: string }>,
departmentByName: Map<string, { id: string; name: string }>
): { departmentKey: string; departmentId: string | null; departmentName: string } {
if (user.departmentRef?.id && user.departmentRef?.name) {
return {
departmentKey: `dept:${user.departmentRef.id}`,
departmentId: user.departmentRef.id,
departmentName: user.departmentRef.name,
}
}
if (user.departmentId) {
const matchedDepartment = departmentById.get(user.departmentId)
if (matchedDepartment) {
return {
departmentKey: `dept:${matchedDepartment.id}`,
departmentId: matchedDepartment.id,
departmentName: matchedDepartment.name,
}
}
}
const departmentNameFromUser = normalizeValue(user.department)
if (departmentNameFromUser) {
const matchedDepartment = departmentByName.get(departmentNameFromUser)
if (matchedDepartment) {
return {
departmentKey: `dept:${matchedDepartment.id}`,
departmentId: matchedDepartment.id,
departmentName: matchedDepartment.name,
}
}
return {
departmentKey: `name:${departmentNameFromUser}`,
departmentId: null,
departmentName: departmentNameFromUser,
}
}
return {
departmentKey: UNASSIGNED_DEPARTMENT_KEY,
departmentId: null,
departmentName: UNASSIGNED_DEPARTMENT_NAME,
}
}
function toBucketKey(date: Date, granularity: 'month' | 'day'): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
if (granularity === 'month') {
return `${year}-${month}`
}
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function buildDistributionLabel(startDate?: string | null, endDate?: string | null): string {
if (startDate && endDate) {
return `${startDate} 至 ${endDate}`
}
if (startDate) {
return `${startDate} 至今`
}
if (endDate) {
return `截至 ${endDate}`
}
return '全部时间'
}
function resolveSelectedDepartmentName(
departmentKey: string | null | undefined,
selectedDepartmentName: string | null | undefined,
defaultName: string,
departmentById: Map<string, { id: string; name: string }>
): string {
if (!departmentKey) {
return defaultName
}
if (selectedDepartmentName) {
return selectedDepartmentName
}
if (departmentKey === UNASSIGNED_DEPARTMENT_KEY) {
return UNASSIGNED_DEPARTMENT_NAME
}
if (departmentKey.startsWith('name:')) {
const name = departmentKey.slice('name:'.length).trim()
return name || defaultName
}
if (departmentKey.startsWith('dept:')) {
const departmentId = departmentKey.slice('dept:'.length).trim()
const department = departmentId ? departmentById.get(departmentId) : null
return department?.name || defaultName
}
return defaultName
}
export async function getMaterialUploadStats(
filters: MaterialStatsFilters
): Promise<MaterialUploadStatsResult> {
const createdAtFilter: { gte?: Date; lte?: Date } = {}
if (filters.startDate) {
createdAtFilter.gte = toDateStart(filters.startDate)
}
if (filters.endDate) {
createdAtFilter.lte = toDateEnd(filters.endDate)
}
const materialWhere: { deletedAt: null; createdAt?: { gte?: Date; lte?: Date } } = {
deletedAt: null,
}
if (createdAtFilter.gte || createdAtFilter.lte) {
materialWhere.createdAt = createdAtFilter
}
const groupedUploads = await prisma.material.groupBy({
by: ['userId'],
_count: { id: true },
where: materialWhere,
})
const userIds = groupedUploads.map((item) => item.userId)
const [users, departments] = await Promise.all([
userIds.length
? prisma.user.findMany({
where: { id: { in: userIds } },
select: {
id: true,
username: true,
realName: true,
department: true,
departmentId: true,
departmentRef: {
select: {
id: true,
name: true,
},
},
},
})
: Promise.resolve([]),
prisma.department.findMany({
select: {
id: true,
name: true,
},
}),
])
const userMap = new Map(users.map((user) => [user.id, user]))
const departmentById = new Map(departments.map((department) => [department.id, department]))
const departmentByName = new Map(departments.map((department) => [department.name, department]))
const resolvedDepartmentByUserId = new Map<string, { departmentKey: string; departmentId: string | null; departmentName: string }>()
for (const user of users) {
resolvedDepartmentByUserId.set(user.id, resolveDepartment(user, departmentById, departmentByName))
}
const scopeDepartmentName = normalizeValue(filters.scopeDepartmentName)
const allowedUserIds = new Set<string>()
const departmentStatsMap = new Map<string, MaterialDepartmentUploadStat>()
for (const uploadGroup of groupedUploads) {
const user = userMap.get(uploadGroup.userId)
if (!user) {
continue
}
const resolvedDepartment = resolvedDepartmentByUserId.get(user.id)
if (!resolvedDepartment) {
continue
}
if (scopeDepartmentName && resolvedDepartment.departmentName !== scopeDepartmentName) {
continue
}
allowedUserIds.add(user.id)
if (!departmentStatsMap.has(resolvedDepartment.departmentKey)) {
departmentStatsMap.set(resolvedDepartment.departmentKey, {
departmentKey: resolvedDepartment.departmentKey,
departmentId: resolvedDepartment.departmentId,
departmentName: resolvedDepartment.departmentName,
uploadCount: 0,
userCount: 0,
percentage: 0,
users: [],
})
}
const uploadCount = uploadGroup._count.id
const departmentStat = departmentStatsMap.get(resolvedDepartment.departmentKey)
if (!departmentStat) {
continue
}
departmentStat.uploadCount += uploadCount
departmentStat.users.push({
userId: user.id,
username: user.username,
realName: user.realName,
departmentName: resolvedDepartment.departmentName,
departmentKey: resolvedDepartment.departmentKey,
uploadCount,
})
}
const departmentStats = Array.from(departmentStatsMap.values())
for (const departmentStat of departmentStats) {
departmentStat.users.sort((a, b) => b.uploadCount - a.uploadCount)
departmentStat.userCount = departmentStat.users.length
}
departmentStats.sort((a, b) => b.uploadCount - a.uploadCount)
const totalUploads = departmentStats.reduce((sum, departmentStat) => sum + departmentStat.uploadCount, 0)
for (const departmentStat of departmentStats) {
departmentStat.percentage = totalUploads > 0
? Number(((departmentStat.uploadCount / totalUploads) * 100).toFixed(2))
: 0
for (const user of departmentStat.users) {
user.percentage = departmentStat.uploadCount > 0
? Number(((user.uploadCount / departmentStat.uploadCount) * 100).toFixed(2))
: 0
}
}
const hasDepartmentFilter = Boolean(normalizeValue(filters.departmentKey))
const selectedDepartment = hasDepartmentFilter
? departmentStats.find((department) => department.departmentKey === filters.departmentKey)
: null
const allUsers = departmentStats
.flatMap((department) => department.users)
.sort((a, b) => b.uploadCount - a.uploadCount)
const selectedUsers = hasDepartmentFilter
? (selectedDepartment ? selectedDepartment.users : [])
: allUsers
const selectedUserIds = Array.from(new Set(selectedUsers.map((user) => user.userId)))
const materialSnapshots = selectedUserIds.length
? await prisma.material.findMany({
where: {
deletedAt: null,
userId: { in: selectedUserIds },
...(materialWhere.createdAt ? { createdAt: materialWhere.createdAt } : {}),
},
select: {
createdAt: true,
materialType: true,
userId: true,
},
})
: []
const filteredSnapshots = materialSnapshots
const selectedTotalUploads = selectedUsers.reduce((sum, user) => sum + user.uploadCount, 0)
const dailyMap = new Map<string, number>()
const monthlyMap = new Map<string, number>()
const materialTypeMap = new Map<string, number>()
const departmentDistributionMap = new Map<string, {
departmentKey: string
departmentId: string | null
departmentName: string
uploadCount: number
users: Map<string, MaterialUserUploadStat>
}>()
const materialTypeDistributionMap = new Map<string, {
materialType: string
uploadCount: number
users: Map<string, MaterialUserUploadStat>
}>()
for (const item of filteredSnapshots) {
const createdAt = new Date(item.createdAt)
const dayKey = toBucketKey(createdAt, 'day')
const monthKey = toBucketKey(createdAt, 'month')
const materialTypeKey = normalizeValue(item.materialType) || '未知类型'
dailyMap.set(dayKey, (dailyMap.get(dayKey) || 0) + 1)
monthlyMap.set(monthKey, (monthlyMap.get(monthKey) || 0) + 1)
materialTypeMap.set(materialTypeKey, (materialTypeMap.get(materialTypeKey) || 0) + 1)
const user = userMap.get(item.userId)
const resolvedDepartment = resolvedDepartmentByUserId.get(item.userId)
if (!user || !resolvedDepartment) {
continue
}
if (!departmentDistributionMap.has(resolvedDepartment.departmentKey)) {
departmentDistributionMap.set(resolvedDepartment.departmentKey, {
departmentKey: resolvedDepartment.departmentKey,
departmentId: resolvedDepartment.departmentId,
departmentName: resolvedDepartment.departmentName,
uploadCount: 0,
users: new Map(),
})
}
const departmentItem = departmentDistributionMap.get(resolvedDepartment.departmentKey)
if (!departmentItem) {
continue
}
departmentItem.uploadCount += 1
const existingDepartmentUser = departmentItem.users.get(user.id)
if (existingDepartmentUser) {
existingDepartmentUser.uploadCount += 1
} else {
departmentItem.users.set(user.id, {
userId: user.id,
username: user.username,
realName: user.realName,
departmentName: resolvedDepartment.departmentName,
departmentKey: resolvedDepartment.departmentKey,
uploadCount: 1,
})
}
if (!materialTypeDistributionMap.has(materialTypeKey)) {
materialTypeDistributionMap.set(materialTypeKey, {
materialType: materialTypeKey,
uploadCount: 0,
users: new Map(),
})
}
const materialTypeItem = materialTypeDistributionMap.get(materialTypeKey)
if (!materialTypeItem) {
continue
}
materialTypeItem.uploadCount += 1
const existingMaterialTypeUser = materialTypeItem.users.get(user.id)
if (existingMaterialTypeUser) {
existingMaterialTypeUser.uploadCount += 1
} else {
materialTypeItem.users.set(user.id, {
userId: user.id,
username: user.username,
realName: user.realName,
departmentName: resolvedDepartment.departmentName,
departmentKey: resolvedDepartment.departmentKey,
uploadCount: 1,
})
}
}
const dailyUploads = Array.from(dailyMap.entries())
.map(([bucket, count]) => ({ bucket, count }))
.sort((a, b) => a.bucket.localeCompare(b.bucket))
const monthlyUploads = Array.from(monthlyMap.entries())
.map(([bucket, count]) => ({ bucket, count }))
.sort((a, b) => a.bucket.localeCompare(b.bucket))
const materialTypeStats = Array.from(materialTypeMap.entries())
.map(([materialType, uploadCount]) => ({
materialType,
uploadCount,
percentage: selectedTotalUploads > 0 ? Number(((uploadCount / selectedTotalUploads) * 100).toFixed(2)) : 0,
}))
.sort((a, b) => b.uploadCount - a.uploadCount)
const departmentDistributionItemsBase = Array.from(departmentDistributionMap.values())
const departmentDistributionTotal = departmentDistributionItemsBase.reduce((sum, item) => sum + item.uploadCount, 0)
const departmentDistributionItems: MaterialDepartmentUploadStat[] = departmentDistributionItemsBase
.map((item) => {
const users = Array.from(item.users.values())
.sort((a, b) => b.uploadCount - a.uploadCount)
.map((user) => ({
...user,
percentage: item.uploadCount > 0
? Number(((user.uploadCount / item.uploadCount) * 100).toFixed(2))
: 0,
}))
return {
departmentKey: item.departmentKey,
departmentId: item.departmentId,
departmentName: item.departmentName,
uploadCount: item.uploadCount,
userCount: users.length,
percentage: departmentDistributionTotal > 0
? Number(((item.uploadCount / departmentDistributionTotal) * 100).toFixed(2))
: 0,
users,
}
})
.sort((a, b) => b.uploadCount - a.uploadCount)
const materialTypeDistributionItemsBase = Array.from(materialTypeDistributionMap.values())
const materialTypeDistributionTotal = materialTypeDistributionItemsBase.reduce((sum, item) => sum + item.uploadCount, 0)
const materialTypeDistributionItems: MaterialTypeDistributionStat[] = materialTypeDistributionItemsBase
.map((item) => {
const users = Array.from(item.users.values())
.sort((a, b) => b.uploadCount - a.uploadCount)
.map((user) => ({
...user,
percentage: item.uploadCount > 0
? Number(((user.uploadCount / item.uploadCount) * 100).toFixed(2))
: 0,
}))
return {
materialType: item.materialType,
uploadCount: item.uploadCount,
percentage: materialTypeDistributionTotal > 0
? Number(((item.uploadCount / materialTypeDistributionTotal) * 100).toFixed(2))
: 0,
users,
}
})
.sort((a, b) => b.uploadCount - a.uploadCount)
return {
filters: {
startDate: filters.startDate || null,
endDate: filters.endDate || null,
departmentKey: filters.departmentKey || null,
},
summary: {
totalUploads: hasDepartmentFilter ? selectedTotalUploads : totalUploads,
totalDepartments: hasDepartmentFilter ? (selectedDepartment ? 1 : 0) : departmentStats.length,
totalUsers: selectedUsers.length,
},
departments: departmentStats,
selectedDepartmentName: resolveSelectedDepartmentName(
filters.departmentKey || null,
selectedDepartment?.departmentName || null,
scopeDepartmentName || '全部部门',
departmentById
),
selectedUsers,
dailyUploads,
monthlyUploads,
materialTypeStats,
departmentDistribution: {
granularity: 'month',
period: '',
label: buildDistributionLabel(filters.startDate, filters.endDate),
totalUploads: departmentDistributionTotal,
items: departmentDistributionItems,
},
materialTypeDistribution: {
granularity: 'month',
period: '',
label: buildDistributionLabel(filters.startDate, filters.endDate),
totalUploads: materialTypeDistributionTotal,
items: materialTypeDistributionItems,
},
}
}