Update
This commit is contained in:
@@ -0,0 +1,751 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AnalyticsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAnalyticsRepository(db *gorm.DB) *AnalyticsRepository {
|
||||
return &AnalyticsRepository{db: db}
|
||||
}
|
||||
|
||||
// GetLetterSummaryStats gets overall summary statistics using summary tables for better performance
|
||||
func (r *AnalyticsRepository) GetLetterSummaryStats(ctx context.Context, startDate, endDate time.Time, userID, departmentID *uuid.UUID) (map[string]interface{}, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
stats := make(map[string]interface{})
|
||||
|
||||
// Use summary tables for better performance when possible
|
||||
if userID == nil && departmentID != nil {
|
||||
// Use department_letter_summary for department-specific stats
|
||||
query := db.Table("department_letter_summary").
|
||||
Where("department_id = ?", *departmentID)
|
||||
|
||||
if !startDate.IsZero() {
|
||||
query = query.Where("summary_date >= ?", startDate)
|
||||
}
|
||||
if !endDate.IsZero() {
|
||||
query = query.Where("summary_date <= ?", endDate)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
TotalIncoming int64 `gorm:"column:total_incoming"`
|
||||
TotalOutgoing int64 `gorm:"column:total_outgoing"`
|
||||
PendingOutgoing int64 `gorm:"column:pending_outgoing"`
|
||||
ApprovedOutgoing int64 `gorm:"column:approved_outgoing"`
|
||||
RejectedOutgoing int64 `gorm:"column:rejected_outgoing"`
|
||||
AvgResponseHours float64 `gorm:"column:avg_response_hours"`
|
||||
CompletionRate float64 `gorm:"column:completion_rate"`
|
||||
}
|
||||
|
||||
query.Select(`
|
||||
COALESCE(SUM(incoming_count), 0) as total_incoming,
|
||||
COALESCE(SUM(outgoing_count), 0) as total_outgoing,
|
||||
COALESCE(SUM(pending_outgoing), 0) as pending_outgoing,
|
||||
COALESCE(SUM(approved_outgoing), 0) as approved_outgoing,
|
||||
COALESCE(SUM(rejected_outgoing), 0) as rejected_outgoing,
|
||||
COALESCE(AVG(avg_response_hours), 0) as avg_response_hours,
|
||||
COALESCE(AVG(completion_rate), 0) as completion_rate
|
||||
`).Scan(&result)
|
||||
|
||||
stats["total_incoming"] = result.TotalIncoming
|
||||
stats["total_outgoing"] = result.TotalOutgoing
|
||||
stats["total_pending"] = result.PendingOutgoing
|
||||
stats["total_approved"] = result.ApprovedOutgoing
|
||||
stats["total_rejected"] = result.RejectedOutgoing
|
||||
stats["total_archived"] = int64(0) // Calculate separately if needed
|
||||
stats["avg_processing_time"] = result.AvgResponseHours
|
||||
stats["completion_rate"] = result.CompletionRate
|
||||
} else if userID == nil && departmentID == nil {
|
||||
// Use letter_summary for overall stats
|
||||
query := db.Table("letter_summary")
|
||||
|
||||
if !startDate.IsZero() {
|
||||
query = query.Where("summary_date >= ?", startDate)
|
||||
}
|
||||
if !endDate.IsZero() {
|
||||
query = query.Where("summary_date <= ?", endDate)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
TotalIncoming int64 `gorm:"column:total_incoming"`
|
||||
TotalOutgoing int64 `gorm:"column:total_outgoing"`
|
||||
TotalPending int64 `gorm:"column:total_pending"`
|
||||
TotalApproved int64 `gorm:"column:total_approved"`
|
||||
TotalRejected int64 `gorm:"column:total_rejected"`
|
||||
TotalArchived int64 `gorm:"column:total_archived"`
|
||||
TotalSent int64 `gorm:"column:total_sent"`
|
||||
AvgProcessing float64 `gorm:"column:avg_processing"`
|
||||
}
|
||||
|
||||
query.Select(`
|
||||
COALESCE(SUM(CASE WHEN letter_type = 'incoming' THEN total_count ELSE 0 END), 0) as total_incoming,
|
||||
COALESCE(SUM(CASE WHEN letter_type = 'outgoing' THEN total_count ELSE 0 END), 0) as total_outgoing,
|
||||
COALESCE(SUM(pending_count), 0) as total_pending,
|
||||
COALESCE(SUM(approved_count), 0) as total_approved,
|
||||
COALESCE(SUM(rejected_count), 0) as total_rejected,
|
||||
COALESCE(SUM(archived_count), 0) as total_archived,
|
||||
COALESCE(SUM(sent_count), 0) as total_sent,
|
||||
COALESCE(AVG(avg_processing_hours), 0) as avg_processing
|
||||
`).Scan(&result)
|
||||
|
||||
stats["total_incoming"] = result.TotalIncoming
|
||||
stats["total_outgoing"] = result.TotalOutgoing
|
||||
stats["total_pending"] = result.TotalPending
|
||||
stats["total_approved"] = result.TotalApproved
|
||||
stats["total_rejected"] = result.TotalRejected
|
||||
stats["total_archived"] = result.TotalArchived
|
||||
stats["avg_processing_time"] = result.AvgProcessing
|
||||
|
||||
// Calculate completion rate
|
||||
completionRate := float64(0)
|
||||
if result.TotalOutgoing > 0 {
|
||||
completedCount := result.TotalSent + result.TotalArchived
|
||||
completionRate = float64(completedCount) / float64(result.TotalOutgoing) * 100
|
||||
}
|
||||
stats["completion_rate"] = completionRate
|
||||
} else {
|
||||
// Fall back to original implementation for user-specific queries
|
||||
// Base query builders
|
||||
incomingQuery := db.Table("letters_incoming").Where("letters_incoming.deleted_at IS NULL")
|
||||
outgoingQuery := db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL")
|
||||
|
||||
// Apply date filters
|
||||
if !startDate.IsZero() {
|
||||
incomingQuery = incomingQuery.Where("letters_incoming.created_at >= ?", startDate)
|
||||
outgoingQuery = outgoingQuery.Where("letters_outgoing.created_at >= ?", startDate)
|
||||
}
|
||||
if !endDate.IsZero() {
|
||||
incomingQuery = incomingQuery.Where("letters_incoming.created_at <= ?", endDate)
|
||||
outgoingQuery = outgoingQuery.Where("letters_outgoing.created_at <= ?", endDate)
|
||||
}
|
||||
|
||||
// Apply user/department filters for outgoing letters
|
||||
if userID != nil {
|
||||
outgoingQuery = outgoingQuery.
|
||||
Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id").
|
||||
Where("letter_outgoing_recipients.user_id = ?", *userID)
|
||||
}
|
||||
|
||||
// Count incoming letters
|
||||
var totalIncoming int64
|
||||
incomingQuery.Count(&totalIncoming)
|
||||
stats["total_incoming"] = totalIncoming
|
||||
|
||||
// Count outgoing letters
|
||||
var totalOutgoing int64
|
||||
outgoingQuery.Count(&totalOutgoing)
|
||||
stats["total_outgoing"] = totalOutgoing
|
||||
|
||||
// Count by status - need to clone query for each count
|
||||
var pendingCount, approvedCount, rejectedCount, archivedCount int64
|
||||
|
||||
db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL").
|
||||
Where("letters_outgoing.status = ?", "pending_approval").
|
||||
Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id").
|
||||
Where("letter_outgoing_recipients.user_id = ?", *userID).
|
||||
Count(&pendingCount)
|
||||
|
||||
db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL").
|
||||
Where("letters_outgoing.status = ?", "approved").
|
||||
Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id").
|
||||
Where("letter_outgoing_recipients.user_id = ?", *userID).
|
||||
Count(&approvedCount)
|
||||
|
||||
db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL").
|
||||
Where("letters_outgoing.status = ?", "rejected").
|
||||
Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id").
|
||||
Where("letter_outgoing_recipients.user_id = ?", *userID).
|
||||
Count(&rejectedCount)
|
||||
|
||||
db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL").
|
||||
Where("letters_outgoing.status = ?", "archived").
|
||||
Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id").
|
||||
Where("letter_outgoing_recipients.user_id = ?", *userID).
|
||||
Count(&archivedCount)
|
||||
|
||||
stats["total_pending"] = pendingCount
|
||||
stats["total_approved"] = approvedCount
|
||||
stats["total_rejected"] = rejectedCount
|
||||
stats["total_archived"] = archivedCount
|
||||
|
||||
// Calculate average processing time
|
||||
var avgProcessingTime float64
|
||||
db.Table("letters_outgoing").
|
||||
Select("AVG(EXTRACT(EPOCH FROM (letters_outgoing.updated_at - letters_outgoing.created_at))/3600) as avg_hours").
|
||||
Where("letters_outgoing.status IN ('approved', 'sent', 'archived')").
|
||||
Where("letters_outgoing.deleted_at IS NULL").
|
||||
Scan(&avgProcessingTime)
|
||||
|
||||
stats["avg_processing_time"] = avgProcessingTime
|
||||
|
||||
// Calculate completion rate
|
||||
var completedCount int64
|
||||
db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL").
|
||||
Where("letters_outgoing.status IN ('sent', 'archived')").
|
||||
Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id").
|
||||
Where("letter_outgoing_recipients.user_id = ?", *userID).
|
||||
Count(&completedCount)
|
||||
|
||||
completionRate := float64(0)
|
||||
if totalOutgoing > 0 {
|
||||
completionRate = float64(completedCount) / float64(totalOutgoing) * 100
|
||||
}
|
||||
stats["completion_rate"] = completionRate
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetStatusDistribution gets letter distribution by status
|
||||
func (r *AnalyticsRepository) GetStatusDistribution(ctx context.Context, startDate, endDate time.Time, userID *uuid.UUID) ([]map[string]interface{}, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var results []map[string]interface{}
|
||||
|
||||
query := `
|
||||
WITH combined_letters AS (
|
||||
SELECT
|
||||
status,
|
||||
'incoming' as type,
|
||||
COUNT(*) as count
|
||||
FROM letters_incoming
|
||||
WHERE deleted_at IS NULL
|
||||
%s
|
||||
GROUP BY status
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
lo.status,
|
||||
'outgoing' as type,
|
||||
COUNT(DISTINCT lo.id) as count
|
||||
FROM letters_outgoing lo
|
||||
%s
|
||||
WHERE lo.deleted_at IS NULL
|
||||
%s
|
||||
GROUP BY lo.status
|
||||
)
|
||||
SELECT
|
||||
status,
|
||||
type,
|
||||
count,
|
||||
ROUND(count * 100.0 / SUM(count) OVER (PARTITION BY type), 2) as percentage
|
||||
FROM combined_letters
|
||||
ORDER BY type, count DESC
|
||||
`
|
||||
|
||||
incomingDateFilter := ""
|
||||
outgoingDateFilter := ""
|
||||
if !startDate.IsZero() {
|
||||
incomingDateFilter += fmt.Sprintf(" AND created_at >= '%s'", startDate.Format("2006-01-02"))
|
||||
outgoingDateFilter += fmt.Sprintf(" AND lo.created_at >= '%s'", startDate.Format("2006-01-02"))
|
||||
}
|
||||
if !endDate.IsZero() {
|
||||
incomingDateFilter += fmt.Sprintf(" AND created_at <= '%s'", endDate.Format("2006-01-02"))
|
||||
outgoingDateFilter += fmt.Sprintf(" AND lo.created_at <= '%s'", endDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
joinClause := ""
|
||||
userFilter := ""
|
||||
if userID != nil {
|
||||
joinClause = "LEFT JOIN letter_outgoing_recipients lor ON lor.letter_id = lo.id"
|
||||
userFilter = fmt.Sprintf(" AND lor.user_id = '%s'", userID.String())
|
||||
}
|
||||
|
||||
query = fmt.Sprintf(query, incomingDateFilter, joinClause, outgoingDateFilter+userFilter)
|
||||
|
||||
if err := db.Raw(query).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetPriorityDistribution gets letter distribution by priority
|
||||
func (r *AnalyticsRepository) GetPriorityDistribution(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var results []map[string]interface{}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
p.id as priority_id,
|
||||
p.name as priority_name,
|
||||
p.level,
|
||||
COUNT(lo.id) as count,
|
||||
ROUND(COUNT(lo.id) * 100.0 / SUM(COUNT(lo.id)) OVER (), 2) as percentage,
|
||||
AVG(EXTRACT(EPOCH FROM (lo.updated_at - lo.created_at))/3600) as avg_response_time
|
||||
FROM priorities p
|
||||
LEFT JOIN letters_outgoing lo ON lo.priority_id = p.id AND lo.deleted_at IS NULL
|
||||
WHERE 1=1
|
||||
%s
|
||||
GROUP BY p.id, p.name, p.level
|
||||
ORDER BY p.level ASC
|
||||
`
|
||||
|
||||
dateFilter := ""
|
||||
if !startDate.IsZero() {
|
||||
dateFilter += fmt.Sprintf(" AND lo.created_at >= '%s'", startDate.Format("2006-01-02"))
|
||||
}
|
||||
if !endDate.IsZero() {
|
||||
dateFilter += fmt.Sprintf(" AND lo.created_at <= '%s'", endDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
query = fmt.Sprintf(query, dateFilter)
|
||||
|
||||
if err := db.Raw(query).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetDepartmentStats gets statistics per department using summary tables
|
||||
func (r *AnalyticsRepository) GetDepartmentStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var results []map[string]interface{}
|
||||
|
||||
// First try using summary table for better performance
|
||||
query := `
|
||||
SELECT
|
||||
d.id as department_id,
|
||||
d.name as department_name,
|
||||
d.code as department_code,
|
||||
COALESCE(SUM(dls.incoming_count), 0) as incoming_count,
|
||||
COALESCE(SUM(dls.outgoing_count), 0) as outgoing_count,
|
||||
COALESCE(SUM(dls.pending_outgoing), 0) as pending_count,
|
||||
COALESCE(AVG(dls.avg_response_hours), 0) as avg_response_time,
|
||||
COALESCE(AVG(dls.completion_rate), 0) as completion_rate
|
||||
FROM departments d
|
||||
LEFT JOIN department_letter_summary dls ON dls.department_id = d.id
|
||||
WHERE 1=1
|
||||
%s
|
||||
GROUP BY d.id, d.name, d.code
|
||||
ORDER BY (COALESCE(SUM(dls.incoming_count), 0) + COALESCE(SUM(dls.outgoing_count), 0)) DESC
|
||||
`
|
||||
|
||||
dateFilter := ""
|
||||
if !startDate.IsZero() {
|
||||
dateFilter += fmt.Sprintf(" AND dls.summary_date >= '%s'", startDate.Format("2006-01-02"))
|
||||
}
|
||||
if !endDate.IsZero() {
|
||||
dateFilter += fmt.Sprintf(" AND dls.summary_date <= '%s'", endDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
query = fmt.Sprintf(query, dateFilter)
|
||||
|
||||
if err := db.Raw(query).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no results from summary table, fall back to direct query
|
||||
if len(results) == 0 {
|
||||
fallbackQuery := `
|
||||
SELECT
|
||||
d.id as department_id,
|
||||
d.name as department_name,
|
||||
d.code as department_code,
|
||||
COUNT(DISTINCT lir.letter_id) as incoming_count,
|
||||
COUNT(DISTINCT lor.letter_id) as outgoing_count,
|
||||
COUNT(DISTINCT CASE WHEN lo.status = 'pending_approval' THEN lo.id END) as pending_count,
|
||||
COALESCE(AVG(CASE
|
||||
WHEN lo.status IN ('approved', 'sent', 'archived')
|
||||
THEN EXTRACT(EPOCH FROM (lo.updated_at - lo.created_at))/3600
|
||||
END), 0) as avg_response_time,
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT lo.id) > 0
|
||||
THEN ROUND(COUNT(DISTINCT CASE WHEN lo.status IN ('sent', 'archived') THEN lo.id END) * 100.0 / COUNT(DISTINCT lo.id), 2)
|
||||
ELSE 0
|
||||
END as completion_rate
|
||||
FROM departments d
|
||||
LEFT JOIN letter_incoming_recipients lir ON lir.recipient_department_id = d.id
|
||||
LEFT JOIN letter_outgoing_recipients lor ON lor.department_id = d.id
|
||||
LEFT JOIN letters_outgoing lo ON lo.id = lor.letter_id AND lo.deleted_at IS NULL
|
||||
WHERE 1=1
|
||||
%s
|
||||
GROUP BY d.id, d.name, d.code
|
||||
ORDER BY (COUNT(DISTINCT lir.letter_id) + COUNT(DISTINCT lor.letter_id)) DESC
|
||||
`
|
||||
|
||||
fallbackDateFilter := ""
|
||||
if !startDate.IsZero() {
|
||||
fallbackDateFilter += fmt.Sprintf(" AND (lo.created_at >= '%s' OR lo.created_at IS NULL)", startDate.Format("2006-01-02"))
|
||||
}
|
||||
if !endDate.IsZero() {
|
||||
fallbackDateFilter += fmt.Sprintf(" AND (lo.created_at <= '%s' OR lo.created_at IS NULL)", endDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
fallbackQuery = fmt.Sprintf(fallbackQuery, fallbackDateFilter)
|
||||
|
||||
if err := db.Raw(fallbackQuery).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetMonthlyTrend gets monthly trend data using summary tables for better performance
|
||||
func (r *AnalyticsRepository) GetMonthlyTrend(ctx context.Context, months int) ([]map[string]interface{}, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var results []map[string]interface{}
|
||||
|
||||
// Use summary table for better performance
|
||||
query := `
|
||||
WITH monthly_aggregated AS (
|
||||
SELECT
|
||||
TO_CHAR(summary_date, 'Month') as month,
|
||||
EXTRACT(YEAR FROM summary_date) as year,
|
||||
EXTRACT(MONTH FROM summary_date) as month_num,
|
||||
SUM(CASE WHEN letter_type = 'incoming' THEN total_count ELSE 0 END) as incoming_count,
|
||||
SUM(CASE WHEN letter_type = 'outgoing' THEN total_count ELSE 0 END) as outgoing_count,
|
||||
SUM(total_count) as total_count
|
||||
FROM letter_summary
|
||||
WHERE summary_date >= NOW() - INTERVAL '%d months'
|
||||
GROUP BY TO_CHAR(summary_date, 'Month'),
|
||||
EXTRACT(YEAR FROM summary_date),
|
||||
EXTRACT(MONTH FROM summary_date)
|
||||
)
|
||||
SELECT
|
||||
month,
|
||||
year,
|
||||
incoming_count,
|
||||
outgoing_count,
|
||||
total_count,
|
||||
LAG(total_count) OVER (ORDER BY year, month_num) as prev_total
|
||||
FROM monthly_aggregated
|
||||
ORDER BY year DESC, month_num DESC
|
||||
LIMIT %d
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, months, months)
|
||||
|
||||
if err := db.Raw(query).Scan(&results).Error; err != nil {
|
||||
// If summary table is empty, fall back to direct query
|
||||
if len(results) == 0 {
|
||||
fallbackQuery := `
|
||||
WITH monthly_data AS (
|
||||
SELECT
|
||||
TO_CHAR(date_trunc('month', created_at), 'Month') as month,
|
||||
EXTRACT(YEAR FROM created_at) as year,
|
||||
EXTRACT(MONTH FROM created_at) as month_num,
|
||||
COUNT(*) as incoming_count,
|
||||
0 as outgoing_count
|
||||
FROM letters_incoming
|
||||
WHERE deleted_at IS NULL
|
||||
AND created_at >= NOW() - INTERVAL '%d months'
|
||||
GROUP BY date_trunc('month', created_at), EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
TO_CHAR(date_trunc('month', created_at), 'Month') as month,
|
||||
EXTRACT(YEAR FROM created_at) as year,
|
||||
EXTRACT(MONTH FROM created_at) as month_num,
|
||||
0 as incoming_count,
|
||||
COUNT(*) as outgoing_count
|
||||
FROM letters_outgoing
|
||||
WHERE deleted_at IS NULL
|
||||
AND created_at >= NOW() - INTERVAL '%d months'
|
||||
GROUP BY date_trunc('month', created_at), EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at)
|
||||
)
|
||||
SELECT
|
||||
month,
|
||||
year,
|
||||
SUM(incoming_count) as incoming_count,
|
||||
SUM(outgoing_count) as outgoing_count,
|
||||
SUM(incoming_count + outgoing_count) as total_count,
|
||||
LAG(SUM(incoming_count + outgoing_count)) OVER (ORDER BY year, month_num) as prev_total
|
||||
FROM monthly_data
|
||||
GROUP BY month, year, month_num
|
||||
ORDER BY year DESC, month_num DESC
|
||||
LIMIT %d
|
||||
`
|
||||
|
||||
fallbackQuery = fmt.Sprintf(fallbackQuery, months, months, months)
|
||||
|
||||
if err := db.Raw(fallbackQuery).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate growth rate
|
||||
for i := range results {
|
||||
if results[i]["prev_total"] != nil {
|
||||
prevVal, ok := results[i]["prev_total"].(float64)
|
||||
if ok && prevVal > 0 {
|
||||
current := getFloat64FromInterface(results[i]["total_count"])
|
||||
results[i]["growth_rate"] = ((current - prevVal) / prevVal) * 100
|
||||
} else {
|
||||
results[i]["growth_rate"] = float64(0)
|
||||
}
|
||||
} else {
|
||||
results[i]["growth_rate"] = float64(0)
|
||||
}
|
||||
delete(results[i], "prev_total")
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Helper function to safely convert interface{} to float64
|
||||
func getFloat64FromInterface(v interface{}) float64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val
|
||||
case int64:
|
||||
return float64(val)
|
||||
case int:
|
||||
return float64(val)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// GetTopSenders gets top letter senders
|
||||
func (r *AnalyticsRepository) GetTopSenders(ctx context.Context, limit int, startDate, endDate time.Time) ([]map[string]interface{}, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var results []map[string]interface{}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
COALESCE(d.name, 'No Department') as department,
|
||||
COUNT(lo.id) as letter_count,
|
||||
AVG(EXTRACT(EPOCH FROM (lo.updated_at - lo.created_at))/3600) as avg_response_time
|
||||
FROM users u
|
||||
LEFT JOIN letters_outgoing lo ON lo.created_by = u.id
|
||||
LEFT JOIN user_department ud ON ud.user_id = u.id
|
||||
LEFT JOIN departments d ON d.id = ud.department_id
|
||||
WHERE lo.deleted_at IS NULL
|
||||
%s
|
||||
GROUP BY u.id, u.name, u.email, d.name
|
||||
ORDER BY letter_count DESC
|
||||
LIMIT %d
|
||||
`
|
||||
|
||||
dateFilter := ""
|
||||
if !startDate.IsZero() {
|
||||
dateFilter += fmt.Sprintf(" AND lo.created_at >= '%s'", startDate.Format("2006-01-02"))
|
||||
}
|
||||
if !endDate.IsZero() {
|
||||
dateFilter += fmt.Sprintf(" AND lo.created_at <= '%s'", endDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
query = fmt.Sprintf(query, dateFilter, limit)
|
||||
|
||||
if err := db.Raw(query).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetInstitutionStats gets statistics per institution using summary tables
|
||||
func (r *AnalyticsRepository) GetInstitutionStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var results []map[string]interface{}
|
||||
|
||||
// Use summary table for better performance
|
||||
query := `
|
||||
SELECT
|
||||
i.id as institution_id,
|
||||
i.name as institution_name,
|
||||
i.type as institution_type,
|
||||
COALESCE(SUM(ils.incoming_sent), 0) as incoming_count,
|
||||
COALESCE(SUM(ils.outgoing_received), 0) as outgoing_count,
|
||||
COALESCE(SUM(ils.total_correspondence), 0) as total_count,
|
||||
MAX(ils.last_activity_at) as last_activity
|
||||
FROM institutions i
|
||||
LEFT JOIN institution_letter_summary ils ON ils.institution_id = i.id
|
||||
WHERE 1=1
|
||||
%s
|
||||
GROUP BY i.id, i.name, i.type
|
||||
HAVING COALESCE(SUM(ils.total_correspondence), 0) > 0
|
||||
ORDER BY total_count DESC
|
||||
`
|
||||
|
||||
dateFilter := ""
|
||||
if !startDate.IsZero() {
|
||||
dateFilter += fmt.Sprintf(" AND ils.summary_date >= '%s'", startDate.Format("2006-01-02"))
|
||||
}
|
||||
if !endDate.IsZero() {
|
||||
dateFilter += fmt.Sprintf(" AND ils.summary_date <= '%s'", endDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
query = fmt.Sprintf(query, dateFilter)
|
||||
|
||||
if err := db.Raw(query).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetApprovalMetrics gets approval-related metrics using summary tables
|
||||
func (r *AnalyticsRepository) GetApprovalMetrics(ctx context.Context, startDate, endDate time.Time) (map[string]interface{}, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
metrics := make(map[string]interface{})
|
||||
|
||||
// Use summary table for better performance
|
||||
query := db.Table("approval_sla_summary")
|
||||
|
||||
if !startDate.IsZero() {
|
||||
query = query.Where("summary_date >= ?", startDate)
|
||||
}
|
||||
if !endDate.IsZero() {
|
||||
query = query.Where("summary_date <= ?", endDate)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
TotalApprovals int64 `gorm:"column:total_approvals"`
|
||||
ApprovedCount int64 `gorm:"column:approved_count"`
|
||||
RejectedCount int64 `gorm:"column:rejected_count"`
|
||||
PendingCount int64 `gorm:"column:pending_count"`
|
||||
AvgApprovalHours float64 `gorm:"column:avg_approval_hours"`
|
||||
AvgApprovalSteps float64 `gorm:"column:avg_approval_steps"`
|
||||
SLACompliance float64 `gorm:"column:sla_compliance"`
|
||||
WithinSLA int64 `gorm:"column:within_sla"`
|
||||
ExceededSLA int64 `gorm:"column:exceeded_sla"`
|
||||
}
|
||||
|
||||
query.Select(`
|
||||
COALESCE(SUM(total_approvals), 0) as total_approvals,
|
||||
COALESCE(SUM(approved_count), 0) as approved_count,
|
||||
COALESCE(SUM(rejected_count), 0) as rejected_count,
|
||||
COALESCE(SUM(pending_count), 0) as pending_count,
|
||||
COALESCE(AVG(avg_approval_hours), 0) as avg_approval_hours,
|
||||
COALESCE(AVG(avg_approval_steps), 0) as avg_approval_steps,
|
||||
COALESCE(AVG(sla_compliance_rate), 0) as sla_compliance,
|
||||
COALESCE(SUM(within_sla_count), 0) as within_sla,
|
||||
COALESCE(SUM(exceeded_sla_count), 0) as exceeded_sla
|
||||
`).Scan(&result)
|
||||
|
||||
metrics["total_submitted"] = result.TotalApprovals
|
||||
metrics["total_approved"] = result.ApprovedCount
|
||||
metrics["total_rejected"] = result.RejectedCount
|
||||
metrics["total_pending"] = result.PendingCount
|
||||
metrics["avg_approval_time"] = result.AvgApprovalHours
|
||||
metrics["avg_approval_steps"] = result.AvgApprovalSteps
|
||||
metrics["sla_compliance_rate"] = result.SLACompliance
|
||||
metrics["within_sla_count"] = result.WithinSLA
|
||||
metrics["exceeded_sla_count"] = result.ExceededSLA
|
||||
|
||||
// Calculate rates
|
||||
if result.TotalApprovals > 0 {
|
||||
metrics["approval_rate"] = float64(result.ApprovedCount) / float64(result.TotalApprovals) * 100
|
||||
metrics["rejection_rate"] = float64(result.RejectedCount) / float64(result.TotalApprovals) * 100
|
||||
} else {
|
||||
metrics["approval_rate"] = float64(0)
|
||||
metrics["rejection_rate"] = float64(0)
|
||||
}
|
||||
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
// GetDailyActivity gets daily activity data
|
||||
func (r *AnalyticsRepository) GetDailyActivity(ctx context.Context, days int) ([]map[string]interface{}, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var results []map[string]interface{}
|
||||
|
||||
query := `
|
||||
WITH daily_data AS (
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
TO_CHAR(created_at, 'Day') as day_of_week,
|
||||
COUNT(CASE WHEN type = 'incoming' THEN 1 END) as incoming_count,
|
||||
COUNT(CASE WHEN type = 'outgoing' THEN 1 END) as outgoing_count,
|
||||
0 as approved_count,
|
||||
0 as rejected_count
|
||||
FROM (
|
||||
SELECT created_at, 'incoming' as type FROM letters_incoming WHERE deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT created_at, 'outgoing' as type FROM letters_outgoing WHERE deleted_at IS NULL
|
||||
) combined
|
||||
WHERE created_at >= CURRENT_DATE - INTERVAL '%d days'
|
||||
GROUP BY DATE(created_at), TO_CHAR(created_at, 'Day')
|
||||
),
|
||||
approval_data AS (
|
||||
SELECT
|
||||
DATE(acted_at) as date,
|
||||
COUNT(CASE WHEN status = 'approved' THEN 1 END) as approved_count,
|
||||
COUNT(CASE WHEN status = 'rejected' THEN 1 END) as rejected_count
|
||||
FROM letter_outgoing_approvals
|
||||
WHERE acted_at IS NOT NULL
|
||||
AND acted_at >= CURRENT_DATE - INTERVAL '%d days'
|
||||
GROUP BY DATE(acted_at)
|
||||
)
|
||||
SELECT
|
||||
d.date,
|
||||
d.day_of_week,
|
||||
d.incoming_count,
|
||||
d.outgoing_count,
|
||||
COALESCE(a.approved_count, 0) as approved_count,
|
||||
COALESCE(a.rejected_count, 0) as rejected_count
|
||||
FROM daily_data d
|
||||
LEFT JOIN approval_data a ON a.date = d.date
|
||||
ORDER BY d.date DESC
|
||||
LIMIT %d
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, days, days, days)
|
||||
|
||||
if err := db.Raw(query).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetResponseTimeStats gets response time statistics
|
||||
func (r *AnalyticsRepository) GetResponseTimeStats(ctx context.Context, startDate, endDate time.Time) (map[string]interface{}, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
stats := make(map[string]interface{})
|
||||
|
||||
query := `
|
||||
WITH response_times AS (
|
||||
SELECT
|
||||
EXTRACT(EPOCH FROM (updated_at - created_at))/3600 as response_time_hours
|
||||
FROM letters_outgoing
|
||||
WHERE status IN ('approved', 'sent', 'archived')
|
||||
AND deleted_at IS NULL
|
||||
%s
|
||||
)
|
||||
SELECT
|
||||
MIN(response_time_hours) as min_response_time,
|
||||
MAX(response_time_hours) as max_response_time,
|
||||
AVG(response_time_hours) as avg_response_time,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_time_hours) as median_response_time,
|
||||
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_hours) as p95_response_time,
|
||||
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY response_time_hours) as p99_response_time
|
||||
FROM response_times
|
||||
`
|
||||
|
||||
dateFilter := ""
|
||||
if !startDate.IsZero() {
|
||||
dateFilter += fmt.Sprintf(" AND created_at >= '%s'", startDate.Format("2006-01-02"))
|
||||
}
|
||||
if !endDate.IsZero() {
|
||||
dateFilter += fmt.Sprintf(" AND created_at <= '%s'", endDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
query = fmt.Sprintf(query, dateFilter)
|
||||
|
||||
if err := db.Raw(query).Scan(&stats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -54,12 +54,12 @@ func (r *LetterOutgoingRepository) SoftDelete(ctx context.Context, id uuid.UUID)
|
||||
func (r *LetterOutgoingRepository) GetWithRelations(ctx context.Context, id uuid.UUID, relations []string) (*entities.LetterOutgoing, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
query := db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id)
|
||||
|
||||
|
||||
// Preload all specified relations
|
||||
for _, relation := range relations {
|
||||
query = query.Preload(relation)
|
||||
}
|
||||
|
||||
|
||||
var e entities.LetterOutgoing
|
||||
if err := query.First(&e).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -67,23 +67,28 @@ func (r *LetterOutgoingRepository) GetWithRelations(ctx context.Context, id uuid
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
type ListOutgoingLettersFilter struct {
|
||||
Status *string
|
||||
Query *string
|
||||
CreatedBy *uuid.UUID
|
||||
Status *string
|
||||
Query *string
|
||||
CreatedBy *uuid.UUID
|
||||
DepartmentID *uuid.UUID
|
||||
UserID *uuid.UUID
|
||||
ReceiverInstitutionID *uuid.UUID
|
||||
FromDate *time.Time
|
||||
ToDate *time.Time
|
||||
FromDate *time.Time
|
||||
ToDate *time.Time
|
||||
PriorityID *uuid.UUID
|
||||
SortBy *string
|
||||
SortOrder *string
|
||||
}
|
||||
|
||||
func (r *LetterOutgoingRepository) List(ctx context.Context, filter ListOutgoingLettersFilter, limit, offset int) ([]entities.LetterOutgoing, int64, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
query := db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("deleted_at IS NULL")
|
||||
|
||||
|
||||
if filter.Status != nil {
|
||||
query = query.Where("status = ?", *filter.Status)
|
||||
}
|
||||
@@ -94,31 +99,68 @@ func (r *LetterOutgoingRepository) List(ctx context.Context, filter ListOutgoing
|
||||
if filter.CreatedBy != nil {
|
||||
query = query.Where("created_by = ?", *filter.CreatedBy)
|
||||
}
|
||||
// Filter by UserID through recipients
|
||||
if filter.UserID != nil {
|
||||
query = query.Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id").
|
||||
Where("letter_outgoing_recipients.user_id = ?", *filter.UserID).
|
||||
Distinct()
|
||||
}
|
||||
if filter.ReceiverInstitutionID != nil {
|
||||
query = query.Where("receiver_institution_id = ?", *filter.ReceiverInstitutionID)
|
||||
}
|
||||
if filter.PriorityID != nil {
|
||||
query = query.Where("priority_id = ?", *filter.PriorityID)
|
||||
}
|
||||
if filter.FromDate != nil {
|
||||
query = query.Where("issue_date >= ?", *filter.FromDate)
|
||||
}
|
||||
if filter.ToDate != nil {
|
||||
query = query.Where("issue_date <= ?", *filter.ToDate)
|
||||
}
|
||||
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
|
||||
orderBy := "created_at DESC" // default
|
||||
if filter.SortBy != nil {
|
||||
sortField := *filter.SortBy
|
||||
sortDirection := "ASC"
|
||||
if filter.SortOrder != nil && (*filter.SortOrder == "desc" || *filter.SortOrder == "DESC") {
|
||||
sortDirection = "DESC"
|
||||
}
|
||||
|
||||
switch sortField {
|
||||
case "letter_number":
|
||||
orderBy = "letter_number " + sortDirection
|
||||
case "subject":
|
||||
orderBy = "subject " + sortDirection
|
||||
case "issue_date":
|
||||
orderBy = "issue_date " + sortDirection
|
||||
case "status":
|
||||
orderBy = "status " + sortDirection
|
||||
case "created_at":
|
||||
orderBy = "created_at " + sortDirection
|
||||
default:
|
||||
orderBy = "created_at " + sortDirection
|
||||
}
|
||||
}
|
||||
|
||||
var list []entities.LetterOutgoing
|
||||
if err := query.
|
||||
Preload("Priority").
|
||||
Preload("ReceiverInstitution").
|
||||
Preload("Creator").
|
||||
Preload("Creator.Profile").
|
||||
Preload("Creator.Departments").
|
||||
Preload("Recipients").
|
||||
Preload("Recipients.User").
|
||||
Preload("Recipients.Department").
|
||||
Preload("Attachments").
|
||||
Preload("Approvals.Step").
|
||||
Preload("Approvals.Approver").
|
||||
Order("created_at DESC").
|
||||
Order(orderBy).
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&list).Error; err != nil {
|
||||
@@ -301,4 +343,4 @@ func (r *LetterOutgoingActivityLogRepository) ListByLetter(ctx context.Context,
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user