578 lines
17 KiB
TypeScript
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,
|
|
},
|
|
}
|
|
}
|