Update
This commit is contained in:
parent
2bdce63852
commit
aa662a321f
BIN
eslogad-backend
Executable file
BIN
eslogad-backend
Executable file
Binary file not shown.
@ -50,6 +50,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
adminApprovalFlowHandler := handler.NewAdminApprovalFlowHandler(services.approvalFlowService)
|
||||
dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService)
|
||||
onlyOfficeHandler := handler.NewOnlyOfficeHandler(services.onlyOfficeService)
|
||||
analyticsHandler := handler.NewAnalyticsHandler(services.analyticsService)
|
||||
|
||||
a.router = router.NewRouter(
|
||||
cfg,
|
||||
@ -65,6 +66,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
adminApprovalFlowHandler,
|
||||
dispositionRouteHandler,
|
||||
onlyOfficeHandler,
|
||||
analyticsHandler,
|
||||
)
|
||||
|
||||
return nil
|
||||
@ -141,6 +143,7 @@ type repositories struct {
|
||||
letterOutgoingActivityLogRepo *repository.LetterOutgoingActivityLogRepository
|
||||
approvalFlowRepo *repository.ApprovalFlowRepository
|
||||
letterOutgoingApprovalRepo *repository.LetterOutgoingApprovalRepository
|
||||
analyticsRepo *repository.AnalyticsRepository
|
||||
}
|
||||
|
||||
func (a *App) initRepositories() *repositories {
|
||||
@ -174,6 +177,7 @@ func (a *App) initRepositories() *repositories {
|
||||
letterOutgoingActivityLogRepo: repository.NewLetterOutgoingActivityLogRepository(a.db),
|
||||
approvalFlowRepo: repository.NewApprovalFlowRepository(a.db),
|
||||
letterOutgoingApprovalRepo: repository.NewLetterOutgoingApprovalRepository(a.db),
|
||||
analyticsRepo: repository.NewAnalyticsRepository(a.db),
|
||||
}
|
||||
}
|
||||
|
||||
@ -255,6 +259,7 @@ type services struct {
|
||||
approvalFlowService *service.ApprovalFlowServiceImpl
|
||||
dispositionRouteService *service.DispositionRouteServiceImpl
|
||||
onlyOfficeService *service.OnlyOfficeServiceImpl
|
||||
analyticsService *service.AnalyticsServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@ -289,6 +294,9 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
// Create OnlyOffice service with file storage
|
||||
onlyOfficeSvc := service.NewOnlyOfficeService(processors.onlyOfficeProcessor, &cfg.OnlyOffice, a.db, s3Client)
|
||||
|
||||
// Create Analytics service
|
||||
analyticsSvc := service.NewAnalyticsService(repos.analyticsRepo)
|
||||
|
||||
return &services{
|
||||
userService: userSvc,
|
||||
authService: authService,
|
||||
@ -300,6 +308,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
approvalFlowService: approvalFlowSvc,
|
||||
dispositionRouteService: dispRouteSvc,
|
||||
onlyOfficeService: onlyOfficeSvc,
|
||||
analyticsService: analyticsSvc,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ const (
|
||||
DeviceOSKey = key("deviceOS")
|
||||
UserLocaleKey = key("userLocale")
|
||||
UserRoleKey = key("userRole")
|
||||
UserNameKey = key("UserName")
|
||||
)
|
||||
|
||||
func LogFields(ctx interface{}) map[string]interface{} {
|
||||
|
||||
@ -27,6 +27,7 @@ type ContextInfo struct {
|
||||
DeviceOS string
|
||||
UserLocale string
|
||||
UserRole string
|
||||
UserName string
|
||||
}
|
||||
|
||||
type ctxKeyType struct{}
|
||||
@ -68,6 +69,7 @@ func FromGinContext(ctx context.Context) *ContextInfo {
|
||||
DeviceOS: value(ctx, DeviceOSKey),
|
||||
UserLocale: value(ctx, UserLocaleKey),
|
||||
UserRole: value(ctx, UserRoleKey),
|
||||
UserName: value(ctx, UserNameKey),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
185
internal/contract/analytics_contract.go
Normal file
185
internal/contract/analytics_contract.go
Normal file
@ -0,0 +1,185 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AnalyticsDashboardRequest represents the request for analytics data
|
||||
type AnalyticsDashboardRequest struct {
|
||||
StartDate string `form:"start_date" json:"start_date"`
|
||||
EndDate string `form:"end_date" json:"end_date"`
|
||||
DepartmentID *uuid.UUID `form:"department_id" json:"department_id,omitempty"`
|
||||
UserID *uuid.UUID `form:"user_id" json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// AnalyticsDashboardResponse represents the complete analytics dashboard
|
||||
type AnalyticsDashboardResponse struct {
|
||||
Summary LetterSummaryStats `json:"summary"`
|
||||
PriorityDistribution []PriorityDistribution `json:"priority_distribution"`
|
||||
DepartmentStats []DepartmentStats `json:"department_stats"`
|
||||
MonthlyTrend []MonthlyTrend `json:"monthly_trend"`
|
||||
DepartmentsStats []SimpleDepartmentStats `json:"departments_stats,omitempty"`
|
||||
InstitutionStats []InstitutionStats `json:"institution_stats"`
|
||||
DailyActivity []DailyActivity `json:"daily_activity"`
|
||||
StatusDistribution []StatusDistribution `json:"status_distribution,omitempty"`
|
||||
TopSenders []TopUserStats `json:"top_senders,omitempty"`
|
||||
TopRecipients []TopUserStats `json:"top_recipients,omitempty"`
|
||||
ApprovalMetrics *ApprovalMetrics `json:"approval_metrics,omitempty"`
|
||||
ResponseTimeStats *ResponseTimeStats `json:"response_time_stats,omitempty"`
|
||||
}
|
||||
|
||||
// LetterSummaryStats represents overall summary statistics
|
||||
type LetterSummaryStats struct {
|
||||
TotalIncoming int64 `json:"total_incoming"`
|
||||
TotalOutgoing int64 `json:"total_outgoing"`
|
||||
WeekOverWeekGrowth float64 `json:"week_over_week_growth"`
|
||||
MonthOverMonthGrowth float64 `json:"month_over_month_growth"`
|
||||
TotalPending int64 `json:"total_pending,omitempty"`
|
||||
TotalApproved int64 `json:"total_approved,omitempty"`
|
||||
TotalRejected int64 `json:"total_rejected,omitempty"`
|
||||
TotalArchived int64 `json:"total_archived,omitempty"`
|
||||
AvgProcessingTime float64 `json:"avg_processing_time_hours,omitempty"`
|
||||
CompletionRate float64 `json:"completion_rate,omitempty"`
|
||||
}
|
||||
|
||||
// StatusDistribution represents letter distribution by status
|
||||
type StatusDistribution struct {
|
||||
Status string `json:"status"`
|
||||
Count int64 `json:"count"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
Type string `json:"type"` // incoming or outgoing
|
||||
}
|
||||
|
||||
// PriorityDistribution represents letter distribution by priority
|
||||
type PriorityDistribution struct {
|
||||
PriorityID string `json:"priority_id"`
|
||||
PriorityName string `json:"priority_name"`
|
||||
Level int `json:"level"`
|
||||
Count int64 `json:"count"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
AvgResponseTime float64 `json:"avg_response_time_hours"`
|
||||
}
|
||||
|
||||
// DepartmentStats represents statistics per department
|
||||
type DepartmentStats struct {
|
||||
DepartmentID uuid.UUID `json:"department_id"`
|
||||
DepartmentName string `json:"department_name"`
|
||||
DepartmentCode string `json:"department_code"`
|
||||
IncomingCount int64 `json:"incoming_count"`
|
||||
OutgoingCount int64 `json:"outgoing_count"`
|
||||
PendingCount int64 `json:"pending_count"`
|
||||
AvgResponseTime float64 `json:"avg_response_time_hours"`
|
||||
CompletionRate float64 `json:"completion_rate"`
|
||||
}
|
||||
|
||||
// MonthlyTrend represents monthly trend data
|
||||
type MonthlyTrend struct {
|
||||
Month string `json:"month"`
|
||||
Year int `json:"year"`
|
||||
IncomingCount int64 `json:"incoming_count"`
|
||||
OutgoingCount int64 `json:"outgoing_count"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
GrowthRate float64 `json:"growth_rate"`
|
||||
}
|
||||
|
||||
// TopUserStats represents top users by letter activity
|
||||
type TopUserStats struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
Department string `json:"department"`
|
||||
LetterCount int64 `json:"letter_count"`
|
||||
AvgResponseTime float64 `json:"avg_response_time_hours"`
|
||||
}
|
||||
|
||||
// InstitutionStats represents statistics per institution
|
||||
type InstitutionStats struct {
|
||||
InstitutionID uuid.UUID `json:"institution_id"`
|
||||
InstitutionName string `json:"institution_name"`
|
||||
InstitutionType string `json:"institution_type"`
|
||||
IncomingCount int64 `json:"incoming_count"`
|
||||
OutgoingCount int64 `json:"outgoing_count"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
LastActivity time.Time `json:"last_activity"`
|
||||
}
|
||||
|
||||
// ApprovalMetrics represents approval-related metrics
|
||||
type ApprovalMetrics struct {
|
||||
TotalSubmitted int64 `json:"total_submitted"`
|
||||
TotalApproved int64 `json:"total_approved"`
|
||||
TotalRejected int64 `json:"total_rejected"`
|
||||
TotalPending int64 `json:"total_pending"`
|
||||
ApprovalRate float64 `json:"approval_rate"`
|
||||
RejectionRate float64 `json:"rejection_rate"`
|
||||
AvgApprovalTime float64 `json:"avg_approval_time_hours"`
|
||||
AvgApprovalSteps float64 `json:"avg_approval_steps"`
|
||||
BottleneckSteps []BottleneckStep `json:"bottleneck_steps"`
|
||||
}
|
||||
|
||||
// BottleneckStep represents approval steps that cause delays
|
||||
type BottleneckStep struct {
|
||||
StepOrder int `json:"step_order"`
|
||||
ApproverName string `json:"approver_name"`
|
||||
AvgProcessTime float64 `json:"avg_process_time_hours"`
|
||||
PendingCount int64 `json:"pending_count"`
|
||||
}
|
||||
|
||||
// ResponseTimeStats represents response time statistics
|
||||
type ResponseTimeStats struct {
|
||||
MinResponseTime float64 `json:"min_response_time_hours"`
|
||||
MaxResponseTime float64 `json:"max_response_time_hours"`
|
||||
AvgResponseTime float64 `json:"avg_response_time_hours"`
|
||||
MedianResponseTime float64 `json:"median_response_time_hours"`
|
||||
P95ResponseTime float64 `json:"p95_response_time_hours"`
|
||||
P99ResponseTime float64 `json:"p99_response_time_hours"`
|
||||
}
|
||||
|
||||
// SimpleDepartmentStats represents simplified department statistics
|
||||
type SimpleDepartmentStats struct {
|
||||
DepartmentID uuid.UUID `json:"department_id"`
|
||||
Department string `json:"department"`
|
||||
LetterCount int64 `json:"letter_count"`
|
||||
}
|
||||
|
||||
// DailyActivity represents daily activity data
|
||||
type DailyActivity struct {
|
||||
Date string `json:"date"`
|
||||
DayOfWeek string `json:"day_of_week"`
|
||||
IncomingCount int64 `json:"incoming_count"`
|
||||
OutgoingCount int64 `json:"outgoing_count"`
|
||||
ApprovedCount int64 `json:"approved_count,omitempty"`
|
||||
RejectedCount int64 `json:"rejected_count,omitempty"`
|
||||
HourlyDistribution []HourlyActivity `json:"hourly_distribution,omitempty"`
|
||||
}
|
||||
|
||||
// HourlyActivity represents hourly activity within a day
|
||||
type HourlyActivity struct {
|
||||
Hour int `json:"hour"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// LetterVolumeByTypeResponse represents letter volume grouped by type
|
||||
type LetterVolumeByTypeResponse struct {
|
||||
Incoming IncomingLetterVolume `json:"incoming"`
|
||||
Outgoing OutgoingLetterVolume `json:"outgoing"`
|
||||
}
|
||||
|
||||
// IncomingLetterVolume represents incoming letter statistics
|
||||
type IncomingLetterVolume struct {
|
||||
Today int64 `json:"today"`
|
||||
ThisWeek int64 `json:"this_week"`
|
||||
ThisMonth int64 `json:"this_month"`
|
||||
ThisYear int64 `json:"this_year"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// OutgoingLetterVolume represents outgoing letter statistics
|
||||
type OutgoingLetterVolume struct {
|
||||
Today int64 `json:"today"`
|
||||
ThisWeek int64 `json:"this_week"`
|
||||
ThisMonth int64 `json:"this_month"`
|
||||
ThisYear int64 `json:"this_year"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
@ -57,13 +57,15 @@ type OutgoingLetterAttachmentResponse struct {
|
||||
}
|
||||
|
||||
type OutgoingLetterApprovalResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
StepOrder int `json:"step_order"`
|
||||
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Remarks *string `json:"remarks,omitempty"`
|
||||
ActedAt *time.Time `json:"acted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
StepOrder int `json:"step_order"`
|
||||
ParallelGroup int `json:"parallel_group"`
|
||||
IsRequired bool `json:"is_required"`
|
||||
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Remarks *string `json:"remarks,omitempty"`
|
||||
ActedAt *time.Time `json:"acted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type OutgoingLetterResponse struct {
|
||||
@ -97,14 +99,18 @@ type UpdateOutgoingLetterRequest struct {
|
||||
}
|
||||
|
||||
type ListOutgoingLettersRequest struct {
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Query *string `json:"query,omitempty"`
|
||||
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
|
||||
ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"`
|
||||
FromDate *time.Time `json:"from_date,omitempty"`
|
||||
ToDate *time.Time `json:"to_date,omitempty"`
|
||||
Page int `form:"page" json:"page"`
|
||||
Limit int `form:"limit" json:"limit"`
|
||||
Status string `form:"status" json:"status,omitempty"`
|
||||
Query string `form:"q" json:"query,omitempty"`
|
||||
CreatedBy *uuid.UUID `form:"created_by" json:"created_by,omitempty"`
|
||||
DepartmentID *uuid.UUID `form:"department_id" json:"department_id,omitempty"`
|
||||
ReceiverInstitutionID *uuid.UUID `form:"receiver_institution_id" json:"receiver_institution_id,omitempty"`
|
||||
FromDate string `form:"from_date" json:"from_date,omitempty"`
|
||||
ToDate string `form:"to_date" json:"to_date,omitempty"`
|
||||
PriorityID *uuid.UUID `form:"priority_id" json:"priority_id,omitempty"`
|
||||
SortBy string `form:"sort_by" json:"sort_by,omitempty"`
|
||||
SortOrder string `form:"sort_order" json:"sort_order,omitempty"`
|
||||
}
|
||||
|
||||
type ListOutgoingLettersResponse struct {
|
||||
@ -242,32 +248,45 @@ type OutgoingLetterApprovalDiscussionsResponse struct {
|
||||
|
||||
// EnhancedOutgoingLetterApprovalResponse includes approval details with related data
|
||||
type EnhancedOutgoingLetterApprovalResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
LetterID uuid.UUID `json:"letter_id"`
|
||||
StepID uuid.UUID `json:"step_id"`
|
||||
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Remarks *string `json:"remarks,omitempty"`
|
||||
ActedAt *time.Time `json:"acted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Step *ApprovalFlowStepResponse `json:"step,omitempty"`
|
||||
Approver *UserResponse `json:"approver,omitempty"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
LetterID uuid.UUID `json:"letter_id"`
|
||||
StepID uuid.UUID `json:"step_id"`
|
||||
StepOrder int `json:"step_order"`
|
||||
ParallelGroup int `json:"parallel_group"`
|
||||
IsRequired bool `json:"is_required"`
|
||||
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Remarks *string `json:"remarks,omitempty"`
|
||||
ActedAt *time.Time `json:"acted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Step *ApprovalFlowStepResponse `json:"step,omitempty"`
|
||||
Approver *UserResponse `json:"approver,omitempty"`
|
||||
}
|
||||
|
||||
// GetLetterApprovalsResponse represents the list of approvals for a letter
|
||||
type GetLetterApprovalsResponse struct {
|
||||
LetterID uuid.UUID `json:"letter_id"`
|
||||
LetterNumber string `json:"letter_number"`
|
||||
LetterStatus string `json:"letter_status"`
|
||||
TotalSteps int `json:"total_steps"`
|
||||
CurrentStep int `json:"current_step"`
|
||||
Approvals []EnhancedOutgoingLetterApprovalResponse `json:"approvals"`
|
||||
}
|
||||
|
||||
// OutgoingLetterDiscussionResponse represents a discussion on an outgoing letter
|
||||
type OutgoingLetterDiscussionResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
LetterID uuid.UUID `json:"letter_id"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Message string `json:"message"`
|
||||
Mentions map[string]interface{} `json:"mentions,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
EditedAt *time.Time `json:"edited_at,omitempty"`
|
||||
User *UserResponse `json:"user,omitempty"`
|
||||
MentionedUsers []UserResponse `json:"mentioned_users,omitempty"`
|
||||
Attachments []OutgoingLetterDiscussionAttachmentResponse `json:"attachments,omitempty"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
LetterID uuid.UUID `json:"letter_id"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Message string `json:"message"`
|
||||
Mentions map[string]interface{} `json:"mentions,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
EditedAt *time.Time `json:"edited_at,omitempty"`
|
||||
User *UserResponse `json:"user,omitempty"`
|
||||
MentionedUsers []UserResponse `json:"mentioned_users,omitempty"`
|
||||
Attachments []OutgoingLetterDiscussionAttachmentResponse `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// OutgoingLetterDiscussionAttachmentResponse represents an attachment in a discussion
|
||||
@ -280,3 +299,39 @@ type OutgoingLetterDiscussionAttachmentResponse struct {
|
||||
UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"`
|
||||
UploadedAt time.Time `json:"uploaded_at"`
|
||||
}
|
||||
|
||||
// TimelineEvent represents a single event in the approval timeline
|
||||
type TimelineEvent struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "approval", "discussion", "submission", "rejection"
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Actor *UserResponse `json:"actor,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StepOrder int `json:"step_order,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// ApprovalTimelineResponse represents the complete timeline for a letter
|
||||
type ApprovalTimelineResponse struct {
|
||||
LetterID uuid.UUID `json:"letter_id"`
|
||||
LetterNumber string `json:"letter_number"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Timeline []TimelineEvent `json:"timeline"`
|
||||
Summary TimelineSummary `json:"summary"`
|
||||
}
|
||||
|
||||
// TimelineSummary provides overview statistics for the timeline
|
||||
type TimelineSummary struct {
|
||||
TotalSteps int `json:"total_steps"`
|
||||
CompletedSteps int `json:"completed_steps"`
|
||||
PendingSteps int `json:"pending_steps"`
|
||||
CurrentStep int `json:"current_step"`
|
||||
TotalDuration string `json:"total_duration"`
|
||||
AverageStepTime string `json:"average_step_time"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@ -42,20 +42,24 @@ func (ApprovalFlowStep) TableName() string { return "approval_flow_steps" }
|
||||
type ApprovalStatus string
|
||||
|
||||
const (
|
||||
ApprovalStatusPending ApprovalStatus = "pending"
|
||||
ApprovalStatusApproved ApprovalStatus = "approved"
|
||||
ApprovalStatusRejected ApprovalStatus = "rejected"
|
||||
ApprovalStatusNotStarted ApprovalStatus = "not_started"
|
||||
ApprovalStatusPending ApprovalStatus = "pending"
|
||||
ApprovalStatusApproved ApprovalStatus = "approved"
|
||||
ApprovalStatusRejected ApprovalStatus = "rejected"
|
||||
)
|
||||
|
||||
type LetterOutgoingApproval struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"`
|
||||
StepID uuid.UUID `gorm:"type:uuid;not null" json:"step_id"`
|
||||
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
|
||||
Status ApprovalStatus `gorm:"not null;default:'pending'" json:"status"`
|
||||
Remarks *string `json:"remarks,omitempty"`
|
||||
ActedAt *time.Time `json:"acted_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"`
|
||||
StepID uuid.UUID `gorm:"type:uuid;not null" json:"step_id"`
|
||||
StepOrder int `gorm:"not null" json:"step_order"`
|
||||
ParallelGroup int `gorm:"default:1" json:"parallel_group"`
|
||||
IsRequired bool `gorm:"default:true" json:"is_required"`
|
||||
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
|
||||
Status ApprovalStatus `gorm:"not null;default:'pending'" json:"status"`
|
||||
Remarks *string `json:"remarks,omitempty"`
|
||||
ActedAt *time.Time `json:"acted_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
// Relations
|
||||
Letter *LetterOutgoing `gorm:"foreignKey:LetterID" json:"letter,omitempty"`
|
||||
|
||||
195
internal/handler/analytics_handler.go
Normal file
195
internal/handler/analytics_handler.go
Normal file
@ -0,0 +1,195 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AnalyticsHandler struct {
|
||||
analyticsService service.AnalyticsService
|
||||
}
|
||||
|
||||
func NewAnalyticsHandler(analyticsService service.AnalyticsService) *AnalyticsHandler {
|
||||
return &AnalyticsHandler{
|
||||
analyticsService: analyticsService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDashboard handles GET /api/v1/analytics/dashboard
|
||||
func (h *AnalyticsHandler) GetDashboard(c *gin.Context) {
|
||||
var req contract.AnalyticsDashboardRequest
|
||||
|
||||
// Bind query parameters
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
|
||||
Error: "Invalid query parameters",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get analytics dashboard data
|
||||
response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{
|
||||
Error: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(response))
|
||||
}
|
||||
|
||||
// GetLetterVolume handles GET /api/v1/analytics/volume
|
||||
func (h *AnalyticsHandler) GetLetterVolume(c *gin.Context) {
|
||||
response, err := h.analyticsService.GetLetterVolume(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{
|
||||
Error: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(response))
|
||||
}
|
||||
|
||||
// GetStatusDistribution handles GET /api/v1/analytics/status-distribution
|
||||
func (h *AnalyticsHandler) GetStatusDistribution(c *gin.Context) {
|
||||
var req contract.AnalyticsDashboardRequest
|
||||
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
|
||||
Error: "Invalid query parameters",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get full dashboard and extract status distribution
|
||||
response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{
|
||||
Error: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{
|
||||
"status_distribution": response.StatusDistribution,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetPriorityDistribution handles GET /api/v1/analytics/priority-distribution
|
||||
func (h *AnalyticsHandler) GetPriorityDistribution(c *gin.Context) {
|
||||
var req contract.AnalyticsDashboardRequest
|
||||
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
|
||||
Error: "Invalid query parameters",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get full dashboard and extract priority distribution
|
||||
response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{
|
||||
Error: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{
|
||||
"priority_distribution": response.PriorityDistribution,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetDepartmentStats handles GET /api/v1/analytics/department-stats
|
||||
func (h *AnalyticsHandler) GetDepartmentStats(c *gin.Context) {
|
||||
var req contract.AnalyticsDashboardRequest
|
||||
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
|
||||
Error: "Invalid query parameters",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get full dashboard and extract department stats
|
||||
response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{
|
||||
Error: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{
|
||||
"department_stats": response.DepartmentStats,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetMonthlyTrend handles GET /api/v1/analytics/monthly-trend
|
||||
func (h *AnalyticsHandler) GetMonthlyTrend(c *gin.Context) {
|
||||
var req contract.AnalyticsDashboardRequest
|
||||
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
|
||||
Error: "Invalid query parameters",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get full dashboard and extract monthly trend
|
||||
response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{
|
||||
Error: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{
|
||||
"monthly_trend": response.MonthlyTrend,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetApprovalMetrics handles GET /api/v1/analytics/approval-metrics
|
||||
func (h *AnalyticsHandler) GetApprovalMetrics(c *gin.Context) {
|
||||
var req contract.AnalyticsDashboardRequest
|
||||
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
|
||||
Error: "Invalid query parameters",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get full dashboard and extract approval metrics
|
||||
response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{
|
||||
Error: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{
|
||||
"approval_metrics": response.ApprovalMetrics,
|
||||
}))
|
||||
}
|
||||
@ -3,10 +3,8 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"eslogad-be/internal/appcontext"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"eslogad-be/internal/contract"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@ -38,7 +36,9 @@ type LetterOutgoingService interface {
|
||||
DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error
|
||||
|
||||
GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error)
|
||||
GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error)
|
||||
GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error)
|
||||
GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error)
|
||||
}
|
||||
|
||||
type LetterOutgoingHandler struct {
|
||||
@ -84,47 +84,22 @@ func (h *LetterOutgoingHandler) GetOutgoingLetter(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *LetterOutgoingHandler) ListOutgoingLetters(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
offset := (page - 1) * limit
|
||||
var req contract.ListOutgoingLettersRequest
|
||||
|
||||
status := c.Query("status")
|
||||
query := c.Query("q")
|
||||
createdByStr := c.Query("created_by")
|
||||
receiverInstitutionStr := c.Query("receiver_institution_id")
|
||||
|
||||
var statusPtr *string
|
||||
var queryPtr *string
|
||||
var createdByPtr *uuid.UUID
|
||||
var receiverInstitutionPtr *uuid.UUID
|
||||
|
||||
if status != "" {
|
||||
statusPtr = &status
|
||||
}
|
||||
if query != "" {
|
||||
queryPtr = &query
|
||||
}
|
||||
if createdByStr != "" {
|
||||
if createdBy, err := uuid.Parse(createdByStr); err == nil {
|
||||
createdByPtr = &createdBy
|
||||
}
|
||||
}
|
||||
if receiverInstitutionStr != "" {
|
||||
if receiverInstitution, err := uuid.Parse(receiverInstitutionStr); err == nil {
|
||||
receiverInstitutionPtr = &receiverInstitution
|
||||
}
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid query parameters", Code: http.StatusBadRequest})
|
||||
return
|
||||
}
|
||||
|
||||
req := &contract.ListOutgoingLettersRequest{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Status: statusPtr,
|
||||
Query: queryPtr,
|
||||
CreatedBy: createdByPtr,
|
||||
ReceiverInstitutionID: receiverInstitutionPtr,
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
|
||||
resp, err := h.svc.ListOutgoingLetters(c.Request.Context(), req)
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 10
|
||||
}
|
||||
|
||||
resp, err := h.svc.ListOutgoingLetters(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
|
||||
return
|
||||
@ -203,7 +178,7 @@ func (h *LetterOutgoingHandler) ApproveOutgoingLetter(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "approved"})
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{Message: "approved"}))
|
||||
}
|
||||
|
||||
func (h *LetterOutgoingHandler) RejectOutgoingLetter(c *gin.Context) {
|
||||
@ -224,7 +199,7 @@ func (h *LetterOutgoingHandler) RejectOutgoingLetter(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "rejected"})
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{Message: "rejected"}))
|
||||
}
|
||||
|
||||
func (h *LetterOutgoingHandler) SendOutgoingLetter(c *gin.Context) {
|
||||
@ -442,6 +417,27 @@ func (h *LetterOutgoingHandler) GetLetterApprovalInfo(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
|
||||
}
|
||||
|
||||
// GetLetterApprovals returns all approvals and their status for a letter
|
||||
func (h *LetterOutgoingHandler) GetLetterApprovals(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.svc.GetLetterApprovals(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "letter not found", Code: http.StatusNotFound})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
|
||||
}
|
||||
|
||||
// GetApprovalDiscussions returns both approvals and discussions for an outgoing letter
|
||||
func (h *LetterOutgoingHandler) GetApprovalDiscussions(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
@ -461,4 +457,25 @@ func (h *LetterOutgoingHandler) GetApprovalDiscussions(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
|
||||
}
|
||||
}
|
||||
|
||||
// GetApprovalTimeline returns a chronological timeline of approval and discussion events
|
||||
func (h *LetterOutgoingHandler) GetApprovalTimeline(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.svc.GetApprovalTimeline(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "letter not found", Code: http.StatusNotFound})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String())
|
||||
setKeyInContext(c, appcontext.UserNameKey, userResponse.Name)
|
||||
if len(userResponse.DepartmentResponse) > 0 {
|
||||
departmentID := userResponse.DepartmentResponse[0].ID.String()
|
||||
setKeyInContext(c, appcontext.DepartmentIDKey, departmentID)
|
||||
|
||||
@ -39,7 +39,7 @@ type LetterOutgoingProcessor interface {
|
||||
|
||||
GetApprovalsByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingApproval, error)
|
||||
GetApprovalFlow(ctx context.Context, flowID uuid.UUID) (*entities.ApprovalFlow, error)
|
||||
|
||||
|
||||
// GetOutgoingLetterWithDetails fetches letter with all related data
|
||||
GetOutgoingLetterWithDetails(ctx context.Context, letterID uuid.UUID) (*entities.LetterOutgoing, error)
|
||||
GetUsersByIDs(ctx context.Context, userIDs []uuid.UUID) ([]entities.User, error)
|
||||
@ -178,7 +178,38 @@ func (p *LetterOutgoingProcessorImpl) createRecipientsFromApprovalFlow(ctx conte
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all recipients from the first step (can be multiple if parallel)
|
||||
// Create all approval steps in letter_outgoing_approvals
|
||||
var approvals []entities.LetterOutgoingApproval
|
||||
for _, step := range flow.Steps {
|
||||
approval := entities.LetterOutgoingApproval{
|
||||
LetterID: letter.ID,
|
||||
StepID: step.ID,
|
||||
StepOrder: step.StepOrder,
|
||||
ParallelGroup: step.ParallelGroup,
|
||||
IsRequired: step.Required,
|
||||
ApproverID: step.ApproverUserID,
|
||||
}
|
||||
|
||||
// Set status based on step order
|
||||
if step.StepOrder == minStepOrder {
|
||||
// First step(s) are set to pending
|
||||
approval.Status = entities.ApprovalStatusPending
|
||||
} else {
|
||||
// All other steps are set to not_started
|
||||
approval.Status = entities.ApprovalStatusNotStarted
|
||||
}
|
||||
|
||||
approvals = append(approvals, approval)
|
||||
}
|
||||
|
||||
// Bulk create all approvals
|
||||
if len(approvals) > 0 {
|
||||
if err := p.approvalRepo.CreateBulk(ctx, approvals); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Also create recipients from the first step (for backward compatibility)
|
||||
var recipients []entities.LetterOutgoingRecipient
|
||||
for i, step := range flow.Steps {
|
||||
// Only process steps with the minimum step order (first step)
|
||||
@ -198,13 +229,12 @@ func (p *LetterOutgoingProcessorImpl) createRecipientsFromApprovalFlow(ctx conte
|
||||
}
|
||||
}
|
||||
|
||||
// If no recipients were created, return without error
|
||||
if len(recipients) == 0 {
|
||||
return nil
|
||||
// Bulk create all recipients if any
|
||||
if len(recipients) > 0 {
|
||||
return p.recipientRepo.CreateBulk(ctx, recipients)
|
||||
}
|
||||
|
||||
// Bulk create all recipients
|
||||
return p.recipientRepo.CreateBulk(ctx, recipients)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createRecipientFromApprovalStep creates a recipient from an approval flow step
|
||||
@ -331,12 +361,31 @@ func (p *LetterOutgoingProcessorImpl) ProcessApprovalSubmission(ctx context.Cont
|
||||
}
|
||||
|
||||
return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
|
||||
// Find the minimum step order (first step)
|
||||
minStepOrder := flow.Steps[0].StepOrder
|
||||
for _, step := range flow.Steps {
|
||||
if step.StepOrder < minStepOrder {
|
||||
minStepOrder = step.StepOrder
|
||||
}
|
||||
}
|
||||
|
||||
approvals := make([]entities.LetterOutgoingApproval, len(flow.Steps))
|
||||
for i, step := range flow.Steps {
|
||||
approvals[i] = entities.LetterOutgoingApproval{
|
||||
LetterID: letterID,
|
||||
StepID: step.ID,
|
||||
Status: entities.ApprovalStatusPending,
|
||||
LetterID: letterID,
|
||||
StepID: step.ID,
|
||||
StepOrder: step.StepOrder,
|
||||
ParallelGroup: step.ParallelGroup,
|
||||
IsRequired: step.Required,
|
||||
ApproverID: step.ApproverUserID,
|
||||
Status: entities.ApprovalStatusPending,
|
||||
}
|
||||
|
||||
// Set status based on step order
|
||||
if step.StepOrder == minStepOrder {
|
||||
approvals[i].Status = entities.ApprovalStatusPending
|
||||
} else {
|
||||
approvals[i].Status = entities.ApprovalStatusNotStarted
|
||||
}
|
||||
}
|
||||
|
||||
@ -344,6 +393,39 @@ func (p *LetterOutgoingProcessorImpl) ProcessApprovalSubmission(ctx context.Cont
|
||||
return err
|
||||
}
|
||||
|
||||
// Add first step approvers as recipients
|
||||
existingRecipients, err := p.recipientRepo.ListByLetter(txCtx, letterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a map of existing user IDs for quick lookup
|
||||
existingUserIDs := make(map[uuid.UUID]bool)
|
||||
for _, recipient := range existingRecipients {
|
||||
if recipient.UserID != nil {
|
||||
existingUserIDs[*recipient.UserID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Add approvers from the first step as recipients
|
||||
for _, approval := range approvals {
|
||||
if approval.StepOrder == minStepOrder && approval.ApproverID != nil {
|
||||
if !existingUserIDs[*approval.ApproverID] {
|
||||
newRecipient := entities.LetterOutgoingRecipient{
|
||||
LetterID: letterID,
|
||||
UserID: approval.ApproverID,
|
||||
IsPrimary: false,
|
||||
Status: "unread",
|
||||
IsArchived: false,
|
||||
}
|
||||
if err := p.recipientRepo.Create(txCtx, &newRecipient); err != nil {
|
||||
return err
|
||||
}
|
||||
existingUserIDs[*approval.ApproverID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusPendingApproval); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -376,7 +458,78 @@ func (p *LetterOutgoingProcessorImpl) ProcessApproval(ctx context.Context, lette
|
||||
return err
|
||||
}
|
||||
|
||||
if allApproved {
|
||||
allApprovals, err := p.approvalRepo.ListByLetter(txCtx, letterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
approvalsByStep := make(map[int][]entities.LetterOutgoingApproval)
|
||||
for _, a := range allApprovals {
|
||||
approvalsByStep[a.StepOrder] = append(approvalsByStep[a.StepOrder], a)
|
||||
}
|
||||
|
||||
currentStepCompleted := true
|
||||
for _, a := range approvalsByStep[approval.StepOrder] {
|
||||
if a.IsRequired && a.Status != entities.ApprovalStatusApproved {
|
||||
currentStepCompleted = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If current step is completed, activate the next step and add approvers as recipients
|
||||
if currentStepCompleted {
|
||||
nextStepOrder := approval.StepOrder + 1
|
||||
if nextStepApprovals, exists := approvalsByStep[nextStepOrder]; exists {
|
||||
currentRecipients, err := p.recipientRepo.ListByLetter(txCtx, letterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingUserIDs := make(map[uuid.UUID]bool)
|
||||
for _, recipient := range currentRecipients {
|
||||
if recipient.UserID != nil {
|
||||
existingUserIDs[*recipient.UserID] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, nextApproval := range nextStepApprovals {
|
||||
|
||||
if nextApproval.Status == entities.ApprovalStatusNotStarted {
|
||||
nextApproval.Status = entities.ApprovalStatusPending
|
||||
if err := p.approvalRepo.Update(txCtx, &nextApproval); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if nextApproval.ApproverID != nil && !existingUserIDs[*nextApproval.ApproverID] {
|
||||
newRecipient := entities.LetterOutgoingRecipient{
|
||||
LetterID: letterID,
|
||||
UserID: nextApproval.ApproverID,
|
||||
IsPrimary: false,
|
||||
Status: "unread",
|
||||
IsArchived: false,
|
||||
}
|
||||
if err := p.recipientRepo.Create(txCtx, &newRecipient); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingUserIDs[*nextApproval.ApproverID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all required approvals are completed
|
||||
allRequiredApproved := true
|
||||
for _, a := range allApprovals {
|
||||
if a.IsRequired && a.Status != entities.ApprovalStatusApproved {
|
||||
allRequiredApproved = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Update letter status if all required approvals are done
|
||||
if allRequiredApproved {
|
||||
if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusApproved); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -587,11 +740,11 @@ func (p *LetterOutgoingProcessorImpl) GetOutgoingLetterWithDetails(ctx context.C
|
||||
"Discussions.Attachments",
|
||||
"ActivityLogs",
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
return letter, nil
|
||||
}
|
||||
|
||||
@ -600,16 +753,16 @@ func (p *LetterOutgoingProcessorImpl) GetUsersByIDs(ctx context.Context, userIDs
|
||||
if len(userIDs) == 0 {
|
||||
return []entities.User{}, nil
|
||||
}
|
||||
|
||||
|
||||
var users []entities.User
|
||||
err := p.db.WithContext(ctx).
|
||||
Preload("Profile").
|
||||
Where("id IN ?", userIDs).
|
||||
Find(&users).Error
|
||||
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
751
internal/repository/analytics_repository.go
Normal file
751
internal/repository/analytics_repository.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,6 +91,7 @@ type LetterOutgoingHandler interface {
|
||||
SendOutgoingLetter(c *gin.Context)
|
||||
ArchiveOutgoingLetter(c *gin.Context)
|
||||
GetLetterApprovalInfo(c *gin.Context)
|
||||
GetLetterApprovals(c *gin.Context)
|
||||
|
||||
AddRecipients(c *gin.Context)
|
||||
UpdateRecipient(c *gin.Context)
|
||||
@ -104,6 +105,7 @@ type LetterOutgoingHandler interface {
|
||||
DeleteDiscussion(c *gin.Context)
|
||||
|
||||
GetApprovalDiscussions(c *gin.Context)
|
||||
GetApprovalTimeline(c *gin.Context)
|
||||
}
|
||||
|
||||
type AdminApprovalFlowHandler interface {
|
||||
@ -134,3 +136,13 @@ type OnlyOfficeHandler interface {
|
||||
UnlockDocument(c *gin.Context)
|
||||
GetDocumentSession(c *gin.Context)
|
||||
}
|
||||
|
||||
type AnalyticsHandler interface {
|
||||
GetDashboard(c *gin.Context)
|
||||
GetLetterVolume(c *gin.Context)
|
||||
GetStatusDistribution(c *gin.Context)
|
||||
GetPriorityDistribution(c *gin.Context)
|
||||
GetDepartmentStats(c *gin.Context)
|
||||
GetMonthlyTrend(c *gin.Context)
|
||||
GetApprovalMetrics(c *gin.Context)
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ type Router struct {
|
||||
adminApprovalFlowHandler AdminApprovalFlowHandler
|
||||
dispRouteHandler DispositionRouteHandler
|
||||
onlyOfficeHandler OnlyOfficeHandler
|
||||
analyticsHandler AnalyticsHandler
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
@ -37,6 +38,7 @@ func NewRouter(
|
||||
adminApprovalFlowHandler AdminApprovalFlowHandler,
|
||||
dispRouteHandler DispositionRouteHandler,
|
||||
onlyOfficeHandler OnlyOfficeHandler,
|
||||
analyticsHandler AnalyticsHandler,
|
||||
) *Router {
|
||||
return &Router{
|
||||
config: cfg,
|
||||
@ -52,6 +54,7 @@ func NewRouter(
|
||||
adminApprovalFlowHandler: adminApprovalFlowHandler,
|
||||
dispRouteHandler: dispRouteHandler,
|
||||
onlyOfficeHandler: onlyOfficeHandler,
|
||||
analyticsHandler: analyticsHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,7 +173,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
lettersch.POST("/outgoing/:id/reject", r.letterOutgoingHandler.RejectOutgoingLetter)
|
||||
lettersch.POST("/outgoing/:id/send", r.letterOutgoingHandler.SendOutgoingLetter)
|
||||
lettersch.POST("/outgoing/:id/archive", r.letterOutgoingHandler.ArchiveOutgoingLetter)
|
||||
lettersch.GET("/outgoing/:id/approval-info", r.letterOutgoingHandler.GetLetterApprovalInfo)
|
||||
lettersch.GET("/outgoing/:id/cta", r.letterOutgoingHandler.GetLetterApprovalInfo)
|
||||
lettersch.GET("/outgoing/:id/approvals", r.letterOutgoingHandler.GetLetterApprovals)
|
||||
|
||||
lettersch.POST("/outgoing/:id/recipients", r.letterOutgoingHandler.AddRecipients)
|
||||
lettersch.PUT("/outgoing/:id/recipients/:recipient_id", r.letterOutgoingHandler.UpdateRecipient)
|
||||
@ -182,9 +186,9 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
lettersch.POST("/outgoing/:id/discussions", r.letterOutgoingHandler.CreateDiscussion)
|
||||
lettersch.PUT("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.UpdateDiscussion)
|
||||
lettersch.DELETE("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.DeleteDiscussion)
|
||||
|
||||
// Get approvals and discussions for outgoing letter
|
||||
|
||||
lettersch.GET("/outgoing/:id/approval-discussions", r.letterOutgoingHandler.GetApprovalDiscussions)
|
||||
lettersch.GET("/outgoing/:id/timeline", r.letterOutgoingHandler.GetApprovalTimeline)
|
||||
|
||||
lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions)
|
||||
lettersch.GET("/dispositions/:letter_id", r.letterHandler.GetEnhancedDispositionsByLetter)
|
||||
@ -225,7 +229,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
{
|
||||
// Callback endpoint - no auth required (OnlyOffice will call this)
|
||||
onlyoffice.POST("/callback/:key", r.onlyOfficeHandler.ProcessCallback)
|
||||
|
||||
|
||||
// Protected endpoints
|
||||
onlyofficeAuth := onlyoffice.Group("")
|
||||
onlyofficeAuth.Use(r.authMiddleware.RequireAuth())
|
||||
@ -237,5 +241,18 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
onlyofficeAuth.GET("/session/:key", r.onlyOfficeHandler.GetDocumentSession)
|
||||
}
|
||||
}
|
||||
|
||||
// Analytics routes
|
||||
analytics := v1.Group("/analytics")
|
||||
analytics.Use(r.authMiddleware.RequireAuth())
|
||||
{
|
||||
analytics.GET("/dashboard", r.analyticsHandler.GetDashboard)
|
||||
analytics.GET("/volume", r.analyticsHandler.GetLetterVolume)
|
||||
analytics.GET("/status-distribution", r.analyticsHandler.GetStatusDistribution)
|
||||
analytics.GET("/priority-distribution", r.analyticsHandler.GetPriorityDistribution)
|
||||
analytics.GET("/department-stats", r.analyticsHandler.GetDepartmentStats)
|
||||
analytics.GET("/monthly-trend", r.analyticsHandler.GetMonthlyTrend)
|
||||
analytics.GET("/approval-metrics", r.analyticsHandler.GetApprovalMetrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
413
internal/service/analytics_service.go
Normal file
413
internal/service/analytics_service.go
Normal file
@ -0,0 +1,413 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"eslogad-be/internal/appcontext"
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AnalyticsService interface {
|
||||
GetDashboard(ctx context.Context, req *contract.AnalyticsDashboardRequest) (*contract.AnalyticsDashboardResponse, error)
|
||||
GetLetterVolume(ctx context.Context) (*contract.LetterVolumeByTypeResponse, error)
|
||||
}
|
||||
|
||||
type AnalyticsServiceImpl struct {
|
||||
analyticsRepo *repository.AnalyticsRepository
|
||||
}
|
||||
|
||||
func NewAnalyticsService(analyticsRepo *repository.AnalyticsRepository) *AnalyticsServiceImpl {
|
||||
return &AnalyticsServiceImpl{
|
||||
analyticsRepo: analyticsRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) GetDashboard(ctx context.Context, req *contract.AnalyticsDashboardRequest) (*contract.AnalyticsDashboardResponse, error) {
|
||||
// Parse dates
|
||||
var startDate, endDate time.Time
|
||||
if req.StartDate != "" {
|
||||
if date, err := time.Parse("2006-01-02", req.StartDate); err == nil {
|
||||
startDate = date
|
||||
}
|
||||
} else {
|
||||
// Default to last 30 days
|
||||
startDate = time.Now().AddDate(0, 0, -30)
|
||||
}
|
||||
|
||||
if req.EndDate != "" {
|
||||
if date, err := time.Parse("2006-01-02", req.EndDate); err == nil {
|
||||
endDate = date.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
||||
}
|
||||
} else {
|
||||
endDate = time.Now()
|
||||
}
|
||||
|
||||
// Apply user context filters if not admin
|
||||
var userID *uuid.UUID
|
||||
|
||||
appCtx := appcontext.FromGinContext(ctx)
|
||||
if appCtx != nil && appCtx.UserRole != "admin" && appCtx.UserRole != "superadmin" {
|
||||
userID = &appCtx.UserID
|
||||
}
|
||||
|
||||
response := &contract.AnalyticsDashboardResponse{}
|
||||
|
||||
// Get summary statistics - don't filter by department for overall stats
|
||||
summaryData, err := s.analyticsRepo.GetLetterSummaryStats(ctx, startDate, endDate, userID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.Summary = s.mapSummaryStats(summaryData)
|
||||
|
||||
// Calculate growth metrics
|
||||
response.Summary.WeekOverWeekGrowth = s.calculateWeekOverWeekGrowth(ctx)
|
||||
response.Summary.MonthOverMonthGrowth = s.calculateMonthOverMonthGrowth(ctx)
|
||||
|
||||
// Get priority distribution
|
||||
priorityData, err := s.analyticsRepo.GetPriorityDistribution(ctx, startDate, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.PriorityDistribution = s.mapPriorityDistribution(priorityData)
|
||||
|
||||
// Get department statistics
|
||||
deptData, err := s.analyticsRepo.GetDepartmentStats(ctx, startDate, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.DepartmentStats = s.mapDepartmentStats(deptData)
|
||||
|
||||
// Get monthly trend (last 12 months)
|
||||
monthlyData, err := s.analyticsRepo.GetMonthlyTrend(ctx, 12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.MonthlyTrend = s.mapMonthlyTrend(monthlyData)
|
||||
|
||||
// Get simplified department stats (departments_stats)
|
||||
response.DepartmentsStats = s.getSimpleDepartmentStats(ctx, startDate, endDate)
|
||||
|
||||
// Get institution statistics
|
||||
instData, err := s.analyticsRepo.GetInstitutionStats(ctx, startDate, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.InstitutionStats = s.mapInstitutionStats(instData)
|
||||
|
||||
// Get daily activity (last 7 days)
|
||||
dailyData, err := s.analyticsRepo.GetDailyActivity(ctx, 7)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.DailyActivity = s.mapDailyActivity(dailyData)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) GetLetterVolume(ctx context.Context) (*contract.LetterVolumeByTypeResponse, error) {
|
||||
// This would be implemented with specific queries for volume metrics
|
||||
// For now, returning a placeholder
|
||||
return &contract.LetterVolumeByTypeResponse{
|
||||
Incoming: contract.IncomingLetterVolume{
|
||||
Today: 0,
|
||||
ThisWeek: 0,
|
||||
ThisMonth: 0,
|
||||
ThisYear: 0,
|
||||
Total: 0,
|
||||
},
|
||||
Outgoing: contract.OutgoingLetterVolume{
|
||||
Today: 0,
|
||||
ThisWeek: 0,
|
||||
ThisMonth: 0,
|
||||
ThisYear: 0,
|
||||
Total: 0,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Helper functions to map repository data to contract types
|
||||
|
||||
func (s *AnalyticsServiceImpl) mapSummaryStats(data map[string]interface{}) contract.LetterSummaryStats {
|
||||
return contract.LetterSummaryStats{
|
||||
TotalIncoming: getInt64Value(data["total_incoming"]),
|
||||
TotalOutgoing: getInt64Value(data["total_outgoing"]),
|
||||
}
|
||||
}
|
||||
|
||||
// calculateWeekOverWeekGrowth calculates the week over week growth rate
|
||||
func (s *AnalyticsServiceImpl) calculateWeekOverWeekGrowth(ctx context.Context) float64 {
|
||||
// Get this week's data
|
||||
thisWeekStart := time.Now().AddDate(0, 0, -int(time.Now().Weekday()))
|
||||
thisWeekEnd := time.Now()
|
||||
|
||||
// Get last week's data
|
||||
lastWeekStart := thisWeekStart.AddDate(0, 0, -7)
|
||||
lastWeekEnd := thisWeekStart.AddDate(0, 0, -1)
|
||||
|
||||
thisWeekData, _ := s.analyticsRepo.GetLetterSummaryStats(ctx, thisWeekStart, thisWeekEnd, nil, nil)
|
||||
lastWeekData, _ := s.analyticsRepo.GetLetterSummaryStats(ctx, lastWeekStart, lastWeekEnd, nil, nil)
|
||||
|
||||
thisWeekTotal := getInt64Value(thisWeekData["total_incoming"]) + getInt64Value(thisWeekData["total_outgoing"])
|
||||
lastWeekTotal := getInt64Value(lastWeekData["total_incoming"]) + getInt64Value(lastWeekData["total_outgoing"])
|
||||
|
||||
if lastWeekTotal > 0 {
|
||||
return float64((thisWeekTotal - lastWeekTotal) * 100 / lastWeekTotal)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// calculateMonthOverMonthGrowth calculates the month over month growth rate
|
||||
func (s *AnalyticsServiceImpl) calculateMonthOverMonthGrowth(ctx context.Context) float64 {
|
||||
// Get this month's data
|
||||
now := time.Now()
|
||||
thisMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
thisMonthEnd := now
|
||||
|
||||
// Get last month's data
|
||||
lastMonthStart := thisMonthStart.AddDate(0, -1, 0)
|
||||
lastMonthEnd := thisMonthStart.AddDate(0, 0, -1)
|
||||
|
||||
thisMonthData, _ := s.analyticsRepo.GetLetterSummaryStats(ctx, thisMonthStart, thisMonthEnd, nil, nil)
|
||||
lastMonthData, _ := s.analyticsRepo.GetLetterSummaryStats(ctx, lastMonthStart, lastMonthEnd, nil, nil)
|
||||
|
||||
thisMonthTotal := getInt64Value(thisMonthData["total_incoming"]) + getInt64Value(thisMonthData["total_outgoing"])
|
||||
lastMonthTotal := getInt64Value(lastMonthData["total_incoming"]) + getInt64Value(lastMonthData["total_outgoing"])
|
||||
|
||||
if lastMonthTotal > 0 {
|
||||
return float64((thisMonthTotal - lastMonthTotal) * 100 / lastMonthTotal)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getSimpleDepartmentStats gets simplified department statistics
|
||||
func (s *AnalyticsServiceImpl) getSimpleDepartmentStats(ctx context.Context, startDate, endDate time.Time) []contract.SimpleDepartmentStats {
|
||||
// Get department stats with letter counts
|
||||
deptData, err := s.analyticsRepo.GetDepartmentStats(ctx, startDate, endDate)
|
||||
if err != nil {
|
||||
return []contract.SimpleDepartmentStats{}
|
||||
}
|
||||
|
||||
result := make([]contract.SimpleDepartmentStats, 0, len(deptData))
|
||||
for _, item := range deptData {
|
||||
deptIDStr := getStringValue(item["department_id"])
|
||||
deptID, err := uuid.Parse(deptIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate total letter count (incoming + outgoing)
|
||||
letterCount := getInt64Value(item["incoming_count"]) + getInt64Value(item["outgoing_count"])
|
||||
|
||||
result = append(result, contract.SimpleDepartmentStats{
|
||||
DepartmentID: deptID,
|
||||
Department: getStringValue(item["department_name"]),
|
||||
LetterCount: letterCount,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) mapStatusDistribution(data []map[string]interface{}) []contract.StatusDistribution {
|
||||
result := make([]contract.StatusDistribution, 0, len(data))
|
||||
for _, item := range data {
|
||||
result = append(result, contract.StatusDistribution{
|
||||
Status: getStringValue(item["status"]),
|
||||
Count: getInt64Value(item["count"]),
|
||||
Percentage: getFloat64Value(item["percentage"]),
|
||||
Type: getStringValue(item["type"]),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) mapPriorityDistribution(data []map[string]interface{}) []contract.PriorityDistribution {
|
||||
result := make([]contract.PriorityDistribution, 0, len(data))
|
||||
for _, item := range data {
|
||||
result = append(result, contract.PriorityDistribution{
|
||||
PriorityID: getStringValue(item["priority_id"]),
|
||||
PriorityName: getStringValue(item["priority_name"]),
|
||||
Level: getIntValue(item["level"]),
|
||||
Count: getInt64Value(item["count"]),
|
||||
Percentage: getFloat64Value(item["percentage"]),
|
||||
AvgResponseTime: getFloat64Value(item["avg_response_time"]),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) mapDepartmentStats(data []map[string]interface{}) []contract.DepartmentStats {
|
||||
result := make([]contract.DepartmentStats, 0, len(data))
|
||||
for _, item := range data {
|
||||
if deptID, err := uuid.Parse(getStringValue(item["department_id"])); err == nil {
|
||||
result = append(result, contract.DepartmentStats{
|
||||
DepartmentID: deptID,
|
||||
DepartmentName: getStringValue(item["department_name"]),
|
||||
DepartmentCode: getStringValue(item["department_code"]),
|
||||
IncomingCount: getInt64Value(item["incoming_count"]),
|
||||
OutgoingCount: getInt64Value(item["outgoing_count"]),
|
||||
PendingCount: getInt64Value(item["pending_count"]),
|
||||
AvgResponseTime: getFloat64Value(item["avg_response_time"]),
|
||||
CompletionRate: getFloat64Value(item["completion_rate"]),
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) mapMonthlyTrend(data []map[string]interface{}) []contract.MonthlyTrend {
|
||||
result := make([]contract.MonthlyTrend, 0, len(data))
|
||||
for _, item := range data {
|
||||
result = append(result, contract.MonthlyTrend{
|
||||
Month: getStringValue(item["month"]),
|
||||
Year: getIntValue(item["year"]),
|
||||
IncomingCount: getInt64Value(item["incoming_count"]),
|
||||
OutgoingCount: getInt64Value(item["outgoing_count"]),
|
||||
TotalCount: getInt64Value(item["total_count"]),
|
||||
GrowthRate: getFloat64Value(item["growth_rate"]),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) mapTopUsers(data []map[string]interface{}) []contract.TopUserStats {
|
||||
result := make([]contract.TopUserStats, 0, len(data))
|
||||
for _, item := range data {
|
||||
if userID, err := uuid.Parse(getStringValue(item["user_id"])); err == nil {
|
||||
result = append(result, contract.TopUserStats{
|
||||
UserID: userID,
|
||||
UserName: getStringValue(item["user_name"]),
|
||||
UserEmail: getStringValue(item["user_email"]),
|
||||
Department: getStringValue(item["department"]),
|
||||
LetterCount: getInt64Value(item["letter_count"]),
|
||||
AvgResponseTime: getFloat64Value(item["avg_response_time"]),
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) mapInstitutionStats(data []map[string]interface{}) []contract.InstitutionStats {
|
||||
result := make([]contract.InstitutionStats, 0, len(data))
|
||||
for _, item := range data {
|
||||
if instID, err := uuid.Parse(getStringValue(item["institution_id"])); err == nil {
|
||||
stat := contract.InstitutionStats{
|
||||
InstitutionID: instID,
|
||||
InstitutionName: getStringValue(item["institution_name"]),
|
||||
InstitutionType: getStringValue(item["institution_type"]),
|
||||
IncomingCount: getInt64Value(item["incoming_count"]),
|
||||
OutgoingCount: getInt64Value(item["outgoing_count"]),
|
||||
TotalCount: getInt64Value(item["total_count"]),
|
||||
}
|
||||
|
||||
if lastActivity, ok := item["last_activity"].(time.Time); ok {
|
||||
stat.LastActivity = lastActivity
|
||||
}
|
||||
|
||||
result = append(result, stat)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) mapApprovalMetrics(data map[string]interface{}) contract.ApprovalMetrics {
|
||||
return contract.ApprovalMetrics{
|
||||
TotalSubmitted: getInt64Value(data["total_submitted"]),
|
||||
TotalApproved: getInt64Value(data["total_approved"]),
|
||||
TotalRejected: getInt64Value(data["total_rejected"]),
|
||||
TotalPending: getInt64Value(data["total_pending"]),
|
||||
ApprovalRate: getFloat64Value(data["approval_rate"]),
|
||||
RejectionRate: getFloat64Value(data["rejection_rate"]),
|
||||
AvgApprovalTime: getFloat64Value(data["avg_approval_time"]),
|
||||
AvgApprovalSteps: getFloat64Value(data["avg_approval_steps"]),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) mapResponseTimeStats(data map[string]interface{}) contract.ResponseTimeStats {
|
||||
return contract.ResponseTimeStats{
|
||||
MinResponseTime: getFloat64Value(data["min_response_time"]),
|
||||
MaxResponseTime: getFloat64Value(data["max_response_time"]),
|
||||
AvgResponseTime: getFloat64Value(data["avg_response_time"]),
|
||||
MedianResponseTime: getFloat64Value(data["median_response_time"]),
|
||||
P95ResponseTime: getFloat64Value(data["p95_response_time"]),
|
||||
P99ResponseTime: getFloat64Value(data["p99_response_time"]),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceImpl) mapDailyActivity(data []map[string]interface{}) []contract.DailyActivity {
|
||||
result := make([]contract.DailyActivity, 0, len(data))
|
||||
for _, item := range data {
|
||||
activity := contract.DailyActivity{
|
||||
Date: "", // Leave date empty as per requirement
|
||||
DayOfWeek: getStringValue(item["day_of_week"]),
|
||||
IncomingCount: getInt64Value(item["incoming_count"]),
|
||||
OutgoingCount: getInt64Value(item["outgoing_count"]),
|
||||
}
|
||||
result = append(result, activity)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Helper functions to safely extract values from interface{}
|
||||
|
||||
func getStringValue(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if str, ok := v.(string); ok {
|
||||
return str
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getInt64Value(v interface{}) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case int64:
|
||||
return val
|
||||
case float64:
|
||||
return int64(val)
|
||||
case int:
|
||||
return int64(val)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func getIntValue(v interface{}) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val
|
||||
case int64:
|
||||
return int(val)
|
||||
case float64:
|
||||
return int(val)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func getFloat64Value(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
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,8 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"eslogad-be/internal/appcontext"
|
||||
"eslogad-be/internal/contract"
|
||||
@ -39,9 +41,15 @@ type LetterOutgoingService interface {
|
||||
DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error
|
||||
|
||||
GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error)
|
||||
|
||||
|
||||
// GetLetterApprovals returns all approvals and their status for a letter
|
||||
GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error)
|
||||
|
||||
// GetApprovalDiscussions returns both approvals and discussions for an outgoing letter
|
||||
GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error)
|
||||
|
||||
// GetApprovalTimeline returns a chronological timeline of all events for a letter
|
||||
GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error)
|
||||
}
|
||||
|
||||
type LetterOutgoingServiceImpl struct {
|
||||
@ -106,16 +114,48 @@ func (s *LetterOutgoingServiceImpl) GetOutgoingLetterByID(ctx context.Context, i
|
||||
}
|
||||
|
||||
func (s *LetterOutgoingServiceImpl) ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error) {
|
||||
filter := repository.ListOutgoingLettersFilter{
|
||||
Status: req.Status,
|
||||
Query: req.Query,
|
||||
CreatedBy: req.CreatedBy,
|
||||
ReceiverInstitutionID: req.ReceiverInstitutionID,
|
||||
FromDate: req.FromDate,
|
||||
ToDate: req.ToDate,
|
||||
offset := (req.Page - 1) * req.Limit
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
letters, total, err := s.processor.ListOutgoingLetters(ctx, filter, req.Limit, req.Offset)
|
||||
filter := repository.ListOutgoingLettersFilter{
|
||||
CreatedBy: req.CreatedBy,
|
||||
DepartmentID: req.DepartmentID,
|
||||
ReceiverInstitutionID: req.ReceiverInstitutionID,
|
||||
PriorityID: req.PriorityID,
|
||||
}
|
||||
|
||||
if req.Status != "" {
|
||||
filter.Status = &req.Status
|
||||
}
|
||||
if req.Query != "" {
|
||||
filter.Query = &req.Query
|
||||
}
|
||||
if req.SortBy != "" {
|
||||
filter.SortBy = &req.SortBy
|
||||
}
|
||||
if req.SortOrder != "" {
|
||||
filter.SortOrder = &req.SortOrder
|
||||
}
|
||||
|
||||
if req.FromDate != "" {
|
||||
if date, err := time.Parse("2006-01-02", req.FromDate); err == nil {
|
||||
filter.FromDate = &date
|
||||
}
|
||||
}
|
||||
|
||||
if req.ToDate != "" {
|
||||
if date, err := time.Parse("2006-01-02", req.ToDate); err == nil {
|
||||
endOfDay := date.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
||||
filter.ToDate = &endOfDay
|
||||
}
|
||||
}
|
||||
|
||||
// Apply access control overrides based on user context
|
||||
ApplyLetterFilterOverrides(ctx, &filter)
|
||||
|
||||
letters, total, err := s.processor.ListOutgoingLetters(ctx, filter, req.Limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -523,67 +563,104 @@ func (s *LetterOutgoingServiceImpl) DeleteDiscussion(ctx context.Context, discus
|
||||
func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) {
|
||||
userID := getUserIDFromContext(ctx)
|
||||
|
||||
_, err := s.processor.GetOutgoingLetterByID(ctx, letterID)
|
||||
// Verify letter exists
|
||||
letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get all approvals for this letter
|
||||
approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var currentApproval *entities.LetterOutgoingApproval
|
||||
// Group approvals by step order to understand the workflow
|
||||
approvalsByStep := make(map[int][]entities.LetterOutgoingApproval)
|
||||
for _, approval := range approvals {
|
||||
approvalsByStep[approval.StepOrder] = append(approvalsByStep[approval.StepOrder], approval)
|
||||
}
|
||||
|
||||
// Find the current active step (lowest step order with pending approvals)
|
||||
var currentStepOrder int = -1
|
||||
var userApproval *entities.LetterOutgoingApproval
|
||||
var isApproverOnActiveStep bool
|
||||
var canApprove bool
|
||||
|
||||
for _, approval := range approvals {
|
||||
if approval.Status == entities.ApprovalStatusPending {
|
||||
currentApproval = &approval
|
||||
break
|
||||
// Find the minimum step order that has pending approvals
|
||||
for stepOrder, stepApprovals := range approvalsByStep {
|
||||
hasPending := false
|
||||
for _, approval := range stepApprovals {
|
||||
if approval.Status == entities.ApprovalStatusPending {
|
||||
hasPending = true
|
||||
// Check if this user is an approver for this pending approval
|
||||
if approval.ApproverID != nil && *approval.ApproverID == userID {
|
||||
if currentStepOrder == -1 || stepOrder < currentStepOrder {
|
||||
currentStepOrder = stepOrder
|
||||
userApproval = &approval
|
||||
isApproverOnActiveStep = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Track the lowest pending step
|
||||
if hasPending && (currentStepOrder == -1 || stepOrder < currentStepOrder) {
|
||||
currentStepOrder = stepOrder
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current user is the approver for the active step
|
||||
if currentApproval != nil && currentApproval.Step != nil {
|
||||
step := currentApproval.Step
|
||||
|
||||
// Check if user is the specific approver
|
||||
if step.ApproverUserID != nil && *step.ApproverUserID == userID {
|
||||
isApproverOnActiveStep = true
|
||||
canApprove = true
|
||||
}
|
||||
// Note: Role-based approval check would require additional implementation
|
||||
// For now, we only support user-specific approvers
|
||||
// User can approve if they have a pending approval on the current active step
|
||||
if isApproverOnActiveStep && userApproval != nil && userApproval.Status == entities.ApprovalStatusPending {
|
||||
canApprove = true
|
||||
}
|
||||
|
||||
// Build actions based on current status
|
||||
// Build actions based on eligibility
|
||||
var actions []contract.ApprovalAction
|
||||
if canApprove && currentApproval != nil {
|
||||
if canApprove && userApproval != nil {
|
||||
actions = []contract.ApprovalAction{
|
||||
{
|
||||
Type: "APPROVE",
|
||||
Href: fmt.Sprintf("/v1/letters/%s/approvals/%s/decision", letterID, currentApproval.ID),
|
||||
Href: fmt.Sprintf("/api/v1/letters/outgoing/%s/approve", letterID),
|
||||
Method: "POST",
|
||||
},
|
||||
{
|
||||
Type: "REJECT",
|
||||
Href: fmt.Sprintf("/v1/letters/%s/approvals/%s/decision", letterID, currentApproval.ID),
|
||||
Href: fmt.Sprintf("/api/v1/letters/outgoing/%s/reject", letterID),
|
||||
Method: "POST",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Determine decision status
|
||||
// Determine overall decision status
|
||||
decisionStatus := "PENDING"
|
||||
if currentApproval == nil {
|
||||
|
||||
// Check if all required approvals are completed
|
||||
allCompleted := true
|
||||
hasRejection := false
|
||||
for _, approval := range approvals {
|
||||
// Check required approvals only
|
||||
if approval.IsRequired {
|
||||
if approval.Status == entities.ApprovalStatusPending || approval.Status == entities.ApprovalStatusNotStarted {
|
||||
allCompleted = false
|
||||
}
|
||||
if approval.Status == entities.ApprovalStatusRejected {
|
||||
hasRejection = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasRejection {
|
||||
decisionStatus = "REJECTED"
|
||||
} else if allCompleted {
|
||||
decisionStatus = "COMPLETED"
|
||||
} else if letter.Status == entities.LetterOutgoingStatusPendingApproval {
|
||||
decisionStatus = "PENDING"
|
||||
}
|
||||
|
||||
// Determine notes visibility
|
||||
notesVisibility := "FULL"
|
||||
if !isApproverOnActiveStep {
|
||||
notesVisibility = "READONLY"
|
||||
notesVisibility := "READONLY"
|
||||
if canApprove {
|
||||
notesVisibility = "FULL"
|
||||
}
|
||||
|
||||
info := &contract.LetterApprovalInfoResponse{
|
||||
@ -597,6 +674,127 @@ func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, l
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (s *LetterOutgoingServiceImpl) GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error) {
|
||||
// Get letter details
|
||||
letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get all approvals for this letter
|
||||
approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sort approvals by step order and parallel group
|
||||
sort.Slice(approvals, func(i, j int) bool {
|
||||
if approvals[i].StepOrder != approvals[j].StepOrder {
|
||||
return approvals[i].StepOrder < approvals[j].StepOrder
|
||||
}
|
||||
return approvals[i].ParallelGroup < approvals[j].ParallelGroup
|
||||
})
|
||||
|
||||
// Transform to response format
|
||||
approvalResponses := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(approvals))
|
||||
totalSteps := 0
|
||||
currentStep := 0
|
||||
stepOrdersSeen := make(map[int]bool)
|
||||
|
||||
for _, approval := range approvals {
|
||||
// Count unique step orders for total steps
|
||||
if !stepOrdersSeen[approval.StepOrder] {
|
||||
stepOrdersSeen[approval.StepOrder] = true
|
||||
totalSteps++
|
||||
}
|
||||
|
||||
// Determine current step (lowest step with pending/not_started status)
|
||||
if approval.Status == entities.ApprovalStatusPending && (currentStep == 0 || approval.StepOrder < currentStep) {
|
||||
currentStep = approval.StepOrder
|
||||
}
|
||||
|
||||
approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{
|
||||
ID: approval.ID,
|
||||
LetterID: approval.LetterID,
|
||||
StepID: approval.StepID,
|
||||
StepOrder: approval.StepOrder,
|
||||
ParallelGroup: approval.ParallelGroup,
|
||||
IsRequired: approval.IsRequired,
|
||||
ApproverID: approval.ApproverID,
|
||||
Status: string(approval.Status),
|
||||
Remarks: approval.Remarks,
|
||||
ActedAt: approval.ActedAt,
|
||||
CreatedAt: approval.CreatedAt,
|
||||
}
|
||||
|
||||
// Add step details if available
|
||||
if approval.Step != nil {
|
||||
approvalResp.Step = &contract.ApprovalFlowStepResponse{
|
||||
ID: approval.Step.ID,
|
||||
StepOrder: approval.Step.StepOrder,
|
||||
ParallelGroup: approval.Step.ParallelGroup,
|
||||
Required: approval.Step.Required,
|
||||
CreatedAt: approval.Step.CreatedAt,
|
||||
UpdatedAt: approval.Step.UpdatedAt,
|
||||
}
|
||||
|
||||
// Add approver role if available
|
||||
if approval.Step.ApproverRole != nil {
|
||||
approvalResp.Step.ApproverRole = &contract.RoleResponse{
|
||||
ID: approval.Step.ApproverRole.ID,
|
||||
Name: approval.Step.ApproverRole.Name,
|
||||
Code: approval.Step.ApproverRole.Code,
|
||||
}
|
||||
}
|
||||
|
||||
// Add approver user if available
|
||||
if approval.Step.ApproverUser != nil {
|
||||
approvalResp.Step.ApproverUser = &contract.UserResponse{
|
||||
ID: approval.Step.ApproverUser.ID,
|
||||
Name: approval.Step.ApproverUser.Name,
|
||||
Email: approval.Step.ApproverUser.Email,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add approver details if available
|
||||
if approval.Approver != nil {
|
||||
approvalResp.Approver = &contract.UserResponse{
|
||||
ID: approval.Approver.ID,
|
||||
Name: approval.Approver.Name,
|
||||
Email: approval.Approver.Email,
|
||||
}
|
||||
}
|
||||
|
||||
approvalResponses = append(approvalResponses, approvalResp)
|
||||
}
|
||||
|
||||
// If no current step found but there are approvals, check if all are completed
|
||||
if currentStep == 0 && len(approvals) > 0 {
|
||||
allCompleted := true
|
||||
for _, approval := range approvals {
|
||||
if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved {
|
||||
allCompleted = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allCompleted {
|
||||
currentStep = totalSteps // All steps completed
|
||||
}
|
||||
}
|
||||
|
||||
response := &contract.GetLetterApprovalsResponse{
|
||||
LetterID: letter.ID,
|
||||
LetterNumber: letter.LetterNumber,
|
||||
LetterStatus: string(letter.Status),
|
||||
TotalSteps: totalSteps,
|
||||
CurrentStep: currentStep,
|
||||
Approvals: approvalResponses,
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func getUserIDFromContext(ctx context.Context) uuid.UUID {
|
||||
appCtx := appcontext.FromGinContext(ctx)
|
||||
if appCtx != nil {
|
||||
@ -628,14 +826,17 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
|
||||
approvals := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(letter.Approvals))
|
||||
for _, approval := range letter.Approvals {
|
||||
approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{
|
||||
ID: approval.ID,
|
||||
LetterID: approval.LetterID,
|
||||
StepID: approval.StepID,
|
||||
ApproverID: approval.ApproverID,
|
||||
Status: string(approval.Status),
|
||||
Remarks: approval.Remarks,
|
||||
ActedAt: approval.ActedAt,
|
||||
CreatedAt: approval.CreatedAt,
|
||||
ID: approval.ID,
|
||||
LetterID: approval.LetterID,
|
||||
StepID: approval.StepID,
|
||||
StepOrder: approval.StepOrder,
|
||||
ParallelGroup: approval.ParallelGroup,
|
||||
IsRequired: approval.IsRequired,
|
||||
ApproverID: approval.ApproverID,
|
||||
Status: string(approval.Status),
|
||||
Remarks: approval.Remarks,
|
||||
ActedAt: approval.ActedAt,
|
||||
CreatedAt: approval.CreatedAt,
|
||||
}
|
||||
|
||||
// Add step details if available
|
||||
@ -648,14 +849,14 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
|
||||
CreatedAt: approval.Step.CreatedAt,
|
||||
UpdatedAt: approval.Step.UpdatedAt,
|
||||
}
|
||||
|
||||
|
||||
if approval.Step.ApproverRoleID != nil {
|
||||
approvalResp.Step.ApproverRoleID = approval.Step.ApproverRoleID
|
||||
}
|
||||
if approval.Step.ApproverUserID != nil {
|
||||
approvalResp.Step.ApproverUserID = approval.Step.ApproverUserID
|
||||
}
|
||||
|
||||
|
||||
// Add role information if available
|
||||
if approval.Step.ApproverRole != nil {
|
||||
approvalResp.Step.ApproverRole = &contract.RoleResponse{
|
||||
@ -664,7 +865,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
|
||||
Code: approval.Step.ApproverRole.Code,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add user information if available
|
||||
if approval.Step.ApproverUser != nil {
|
||||
approvalResp.Step.ApproverUser = &contract.UserResponse{
|
||||
@ -682,7 +883,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
|
||||
Name: approval.Approver.Name,
|
||||
Email: approval.Approver.Email,
|
||||
}
|
||||
|
||||
|
||||
// Add profile if available
|
||||
if approval.Approver.Profile != nil {
|
||||
approvalResp.Approver.Profile = &contract.UserProfileResponse{
|
||||
@ -708,7 +909,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
|
||||
for _, discussion := range letter.Discussions {
|
||||
// Extract mentioned user IDs from mentions
|
||||
mentionedUserIDs := extractMentionedUserIDs(discussion.Mentions)
|
||||
|
||||
|
||||
discussionResp := contract.OutgoingLetterDiscussionResponse{
|
||||
ID: discussion.ID,
|
||||
LetterID: discussion.LetterID,
|
||||
@ -731,7 +932,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
|
||||
CreatedAt: discussion.User.CreatedAt,
|
||||
UpdatedAt: discussion.User.UpdatedAt,
|
||||
}
|
||||
|
||||
|
||||
// Add profile if available
|
||||
if discussion.User.Profile != nil {
|
||||
discussionResp.User.Profile = &contract.UserProfileResponse{
|
||||
@ -761,7 +962,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
|
||||
|
||||
if user.Profile != nil {
|
||||
mentionedUserResp.Profile = &contract.UserProfileResponse{
|
||||
UserID: user.Profile.UserID,
|
||||
@ -771,7 +972,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
|
||||
Locale: user.Profile.Locale,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
discussionResp.MentionedUsers = append(discussionResp.MentionedUsers, mentionedUserResp)
|
||||
}
|
||||
}
|
||||
@ -802,11 +1003,11 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
|
||||
// Helper function to extract user IDs from mentions
|
||||
func extractMentionedUserIDs(mentions map[string]interface{}) []uuid.UUID {
|
||||
var userIDs []uuid.UUID
|
||||
|
||||
|
||||
if mentions == nil {
|
||||
return userIDs
|
||||
}
|
||||
|
||||
|
||||
if userIDsInterface, ok := mentions["user_ids"]; ok {
|
||||
if userIDsList, ok := userIDsInterface.([]interface{}); ok {
|
||||
for _, id := range userIDsList {
|
||||
@ -818,7 +1019,7 @@ func extractMentionedUserIDs(mentions map[string]interface{}) []uuid.UUID {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return userIDs
|
||||
}
|
||||
|
||||
@ -916,17 +1117,15 @@ func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.Outgoi
|
||||
resp.Approvals = make([]contract.OutgoingLetterApprovalResponse, len(letter.Approvals))
|
||||
for i, approval := range letter.Approvals {
|
||||
approvalResp := contract.OutgoingLetterApprovalResponse{
|
||||
ID: approval.ID,
|
||||
ApproverID: approval.ApproverID,
|
||||
Status: string(approval.Status),
|
||||
Remarks: approval.Remarks,
|
||||
ActedAt: approval.ActedAt,
|
||||
CreatedAt: approval.CreatedAt,
|
||||
}
|
||||
|
||||
// Include step order if step is loaded
|
||||
if approval.Step != nil {
|
||||
approvalResp.StepOrder = approval.Step.StepOrder
|
||||
ID: approval.ID,
|
||||
StepOrder: approval.StepOrder,
|
||||
ParallelGroup: approval.ParallelGroup,
|
||||
IsRequired: approval.IsRequired,
|
||||
ApproverID: approval.ApproverID,
|
||||
Status: string(approval.Status),
|
||||
Remarks: approval.Remarks,
|
||||
ActedAt: approval.ActedAt,
|
||||
CreatedAt: approval.CreatedAt,
|
||||
}
|
||||
|
||||
resp.Approvals[i] = approvalResp
|
||||
@ -945,3 +1144,220 @@ func transformDiscussionToResponse(discussion *entities.LetterOutgoingDiscussion
|
||||
UpdatedAt: discussion.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// GetApprovalTimeline generates a chronological timeline of all events for a letter
|
||||
func (s *LetterOutgoingServiceImpl) GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error) {
|
||||
// Get letter details
|
||||
letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get approvals and discussions
|
||||
approvalDiscussions, err := s.GetApprovalDiscussions(ctx, letterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create timeline events
|
||||
timeline := make([]contract.TimelineEvent, 0)
|
||||
|
||||
// Add letter creation event
|
||||
timeline = append(timeline, contract.TimelineEvent{
|
||||
ID: letter.ID.String(),
|
||||
Type: "submission",
|
||||
Timestamp: letter.CreatedAt,
|
||||
Actor: nil, // Could add creator info here if needed
|
||||
Action: "created",
|
||||
Description: "Letter was created",
|
||||
Status: "created",
|
||||
})
|
||||
|
||||
// Add approval events
|
||||
for _, approval := range approvalDiscussions.Approvals {
|
||||
if approval.ActedAt != nil {
|
||||
eventType := "approval"
|
||||
action := "approved"
|
||||
status := "approved"
|
||||
|
||||
if approval.Status == "rejected" {
|
||||
eventType = "rejection"
|
||||
action = "rejected"
|
||||
status = "rejected"
|
||||
} else if approval.Status == "pending" {
|
||||
continue // Skip pending approvals as they haven't happened yet
|
||||
}
|
||||
|
||||
description := fmt.Sprintf("Step %d: %s by %s",
|
||||
approval.StepOrder,
|
||||
action,
|
||||
getApproverName(approval.Approver))
|
||||
|
||||
timeline = append(timeline, contract.TimelineEvent{
|
||||
ID: approval.ID.String(),
|
||||
Type: eventType,
|
||||
Timestamp: *approval.ActedAt,
|
||||
Actor: approval.Approver,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Status: status,
|
||||
StepOrder: approval.StepOrder,
|
||||
Message: getLetterStringValue(approval.Remarks),
|
||||
Data: approval,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add discussion events
|
||||
for _, discussion := range approvalDiscussions.Discussions {
|
||||
timeline = append(timeline, contract.TimelineEvent{
|
||||
ID: discussion.ID.String(),
|
||||
Type: "discussion",
|
||||
Timestamp: discussion.CreatedAt,
|
||||
Actor: discussion.User,
|
||||
Action: "commented",
|
||||
Description: fmt.Sprintf("%s added a comment", getUserName(discussion.User)),
|
||||
Message: discussion.Message,
|
||||
Data: discussion,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort timeline by timestamp
|
||||
sort.Slice(timeline, func(i, j int) bool {
|
||||
return timeline[i].Timestamp.Before(timeline[j].Timestamp)
|
||||
})
|
||||
|
||||
// Calculate summary statistics
|
||||
summary := s.calculateTimelineSummary(letter, approvalDiscussions.Approvals, timeline)
|
||||
|
||||
return &contract.ApprovalTimelineResponse{
|
||||
LetterID: letter.ID,
|
||||
LetterNumber: letter.LetterNumber,
|
||||
Subject: letter.Subject,
|
||||
Status: string(letter.Status),
|
||||
CreatedAt: letter.CreatedAt,
|
||||
Timeline: timeline,
|
||||
Summary: summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *LetterOutgoingServiceImpl) calculateTimelineSummary(
|
||||
letter *entities.LetterOutgoing,
|
||||
approvals []contract.EnhancedOutgoingLetterApprovalResponse,
|
||||
timeline []contract.TimelineEvent,
|
||||
) contract.TimelineSummary {
|
||||
totalSteps := 0
|
||||
completedSteps := 0
|
||||
pendingSteps := 0
|
||||
currentStep := 0
|
||||
|
||||
// Count unique step orders
|
||||
stepMap := make(map[int]string)
|
||||
for _, approval := range approvals {
|
||||
if _, exists := stepMap[approval.StepOrder]; !exists {
|
||||
stepMap[approval.StepOrder] = approval.Status
|
||||
totalSteps++
|
||||
}
|
||||
|
||||
switch approval.Status {
|
||||
case "approved":
|
||||
if stepMap[approval.StepOrder] == "approved" {
|
||||
completedSteps++
|
||||
currentStep = approval.StepOrder + 1
|
||||
}
|
||||
case "pending":
|
||||
pendingSteps++
|
||||
if currentStep == 0 {
|
||||
currentStep = approval.StepOrder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate duration
|
||||
totalDuration := ""
|
||||
averageStepTime := ""
|
||||
|
||||
if len(timeline) > 0 {
|
||||
lastEvent := timeline[len(timeline)-1]
|
||||
duration := lastEvent.Timestamp.Sub(letter.CreatedAt)
|
||||
totalDuration = formatDuration(duration)
|
||||
|
||||
if completedSteps > 0 {
|
||||
avgDuration := duration / time.Duration(completedSteps)
|
||||
averageStepTime = formatDuration(avgDuration)
|
||||
}
|
||||
}
|
||||
|
||||
status := "in_progress"
|
||||
if letter.Status == entities.LetterOutgoingStatusApproved {
|
||||
status = "completed"
|
||||
} else if letter.Status == "rejected" {
|
||||
status = "rejected"
|
||||
}
|
||||
|
||||
return contract.TimelineSummary{
|
||||
TotalSteps: totalSteps,
|
||||
CompletedSteps: completedSteps,
|
||||
PendingSteps: pendingSteps,
|
||||
CurrentStep: currentStep,
|
||||
TotalDuration: totalDuration,
|
||||
AverageStepTime: averageStepTime,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
func getApproverName(user *contract.UserResponse) string {
|
||||
if user == nil {
|
||||
return "Unknown"
|
||||
}
|
||||
if user.Name != "" {
|
||||
return user.Name
|
||||
}
|
||||
return user.Email
|
||||
}
|
||||
|
||||
func getUserName(user *contract.UserResponse) string {
|
||||
if user == nil {
|
||||
return "Unknown"
|
||||
}
|
||||
if user.Name != "" {
|
||||
return user.Name
|
||||
}
|
||||
return user.Email
|
||||
}
|
||||
|
||||
func getLetterStringValue(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
days := int(d.Hours() / 24)
|
||||
hours := int(d.Hours()) % 24
|
||||
minutes := int(d.Minutes()) % 60
|
||||
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
||||
} else if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
}
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
|
||||
func ApplyLetterFilterOverrides(ctx context.Context, filter *repository.ListOutgoingLettersFilter) {
|
||||
appCtx := appcontext.FromGinContext(ctx)
|
||||
if appCtx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
isSuperAdmin := false
|
||||
if appCtx.UserRole == "superadmin" || appCtx.UserRole == "admin" {
|
||||
isSuperAdmin = true
|
||||
}
|
||||
|
||||
if !isSuperAdmin && appCtx.UserID != uuid.Nil {
|
||||
filter.UserID = &appCtx.UserID
|
||||
}
|
||||
}
|
||||
|
||||
@ -498,7 +498,7 @@ func (s *OnlyOfficeServiceImpl) GetEditorConfig(ctx context.Context, req *contra
|
||||
Mode: req.Mode,
|
||||
User: &contract.OnlyOfficeUserConfig{
|
||||
ID: userCtx.UserID.String(),
|
||||
Name: fmt.Sprintf("User-%s", userCtx.UserID.String()[:8]),
|
||||
Name: userCtx.UserName,
|
||||
},
|
||||
Customization: &contract.OnlyOfficeCustomization{
|
||||
Autosave: true,
|
||||
|
||||
13
migrations/000025_add_not_started_approval_status.down.sql
Normal file
13
migrations/000025_add_not_started_approval_status.down.sql
Normal file
@ -0,0 +1,13 @@
|
||||
-- Revert to original status check constraint without 'not_started'
|
||||
-- First update any 'not_started' statuses to 'pending'
|
||||
UPDATE letter_outgoing_approvals
|
||||
SET status = 'pending'
|
||||
WHERE status = 'not_started';
|
||||
|
||||
-- Drop and recreate the constraint
|
||||
ALTER TABLE letter_outgoing_approvals
|
||||
DROP CONSTRAINT IF EXISTS letter_outgoing_approvals_status_check;
|
||||
|
||||
ALTER TABLE letter_outgoing_approvals
|
||||
ADD CONSTRAINT letter_outgoing_approvals_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'rejected'));
|
||||
10
migrations/000025_add_not_started_approval_status.up.sql
Normal file
10
migrations/000025_add_not_started_approval_status.up.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- Add 'not_started' status to letter_outgoing_approvals status check constraint
|
||||
ALTER TABLE letter_outgoing_approvals
|
||||
DROP CONSTRAINT IF EXISTS letter_outgoing_approvals_status_check;
|
||||
|
||||
ALTER TABLE letter_outgoing_approvals
|
||||
ADD CONSTRAINT letter_outgoing_approvals_status_check
|
||||
CHECK (status IN ('not_started', 'pending', 'approved', 'rejected'));
|
||||
|
||||
-- Update default value for new approvals (optional, keeping 'pending' as default)
|
||||
-- ALTER TABLE letter_outgoing_approvals ALTER COLUMN status SET DEFAULT 'not_started';
|
||||
9
migrations/000026_add_approval_step_details.down.sql
Normal file
9
migrations/000026_add_approval_step_details.down.sql
Normal file
@ -0,0 +1,9 @@
|
||||
-- Remove indexes
|
||||
DROP INDEX IF EXISTS idx_letter_outgoing_approvals_step_order;
|
||||
DROP INDEX IF EXISTS idx_letter_outgoing_approvals_parallel_group;
|
||||
|
||||
-- Remove columns
|
||||
ALTER TABLE letter_outgoing_approvals
|
||||
DROP COLUMN IF EXISTS step_order,
|
||||
DROP COLUMN IF EXISTS parallel_group,
|
||||
DROP COLUMN IF EXISTS is_required;
|
||||
17
migrations/000026_add_approval_step_details.up.sql
Normal file
17
migrations/000026_add_approval_step_details.up.sql
Normal file
@ -0,0 +1,17 @@
|
||||
-- Add step details to letter_outgoing_approvals table
|
||||
ALTER TABLE letter_outgoing_approvals
|
||||
ADD COLUMN IF NOT EXISTS step_order INT NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS parallel_group INT DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS is_required BOOLEAN DEFAULT true;
|
||||
|
||||
-- Add indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_step_order
|
||||
ON letter_outgoing_approvals(letter_id, step_order);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_parallel_group
|
||||
ON letter_outgoing_approvals(letter_id, parallel_group);
|
||||
|
||||
-- Add comments for documentation
|
||||
COMMENT ON COLUMN letter_outgoing_approvals.step_order IS 'The order in which this approval step should be processed';
|
||||
COMMENT ON COLUMN letter_outgoing_approvals.parallel_group IS 'Steps with the same parallel_group value can be processed simultaneously';
|
||||
COMMENT ON COLUMN letter_outgoing_approvals.is_required IS 'Whether this approval step is required for the letter to proceed';
|
||||
23
migrations/000027_create_analytics_summary_tables.down.sql
Normal file
23
migrations/000027_create_analytics_summary_tables.down.sql
Normal file
@ -0,0 +1,23 @@
|
||||
-- Drop triggers
|
||||
DROP TRIGGER IF EXISTS update_letter_summary_on_incoming ON letters_incoming;
|
||||
DROP TRIGGER IF EXISTS update_letter_summary_on_outgoing ON letters_outgoing;
|
||||
|
||||
-- Drop functions
|
||||
DROP FUNCTION IF EXISTS update_letter_summary();
|
||||
DROP FUNCTION IF EXISTS populate_letter_summary_history();
|
||||
|
||||
-- Drop indexes
|
||||
DROP INDEX IF EXISTS idx_letter_summary_date;
|
||||
DROP INDEX IF EXISTS idx_letter_summary_type_date;
|
||||
DROP INDEX IF EXISTS idx_dept_letter_summary_dept_date;
|
||||
DROP INDEX IF EXISTS idx_dept_letter_summary_date;
|
||||
DROP INDEX IF EXISTS idx_inst_letter_summary_inst_date;
|
||||
DROP INDEX IF EXISTS idx_inst_letter_summary_date;
|
||||
DROP INDEX IF EXISTS idx_approval_sla_summary_date;
|
||||
DROP INDEX IF EXISTS idx_approval_sla_summary_dept_date;
|
||||
|
||||
-- Drop tables
|
||||
DROP TABLE IF EXISTS approval_sla_summary;
|
||||
DROP TABLE IF EXISTS institution_letter_summary;
|
||||
DROP TABLE IF EXISTS department_letter_summary;
|
||||
DROP TABLE IF EXISTS letter_summary;
|
||||
389
migrations/000027_create_analytics_summary_tables.up.sql
Normal file
389
migrations/000027_create_analytics_summary_tables.up.sql
Normal file
@ -0,0 +1,389 @@
|
||||
-- Create letter_summary table for daily aggregated statistics
|
||||
CREATE TABLE IF NOT EXISTS letter_summary (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
summary_date DATE NOT NULL,
|
||||
letter_type VARCHAR(20) NOT NULL, -- 'incoming' or 'outgoing'
|
||||
total_count INTEGER DEFAULT 0,
|
||||
pending_count INTEGER DEFAULT 0,
|
||||
approved_count INTEGER DEFAULT 0,
|
||||
rejected_count INTEGER DEFAULT 0,
|
||||
archived_count INTEGER DEFAULT 0,
|
||||
sent_count INTEGER DEFAULT 0,
|
||||
avg_processing_hours DECIMAL(10,2),
|
||||
min_processing_hours DECIMAL(10,2),
|
||||
max_processing_hours DECIMAL(10,2),
|
||||
median_processing_hours DECIMAL(10,2),
|
||||
p95_processing_hours DECIMAL(10,2),
|
||||
p99_processing_hours DECIMAL(10,2),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(summary_date, letter_type)
|
||||
);
|
||||
|
||||
-- Create department_letter_summary table
|
||||
CREATE TABLE IF NOT EXISTS department_letter_summary (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
department_id UUID NOT NULL REFERENCES departments(id),
|
||||
summary_date DATE NOT NULL,
|
||||
incoming_count INTEGER DEFAULT 0,
|
||||
outgoing_count INTEGER DEFAULT 0,
|
||||
pending_incoming INTEGER DEFAULT 0,
|
||||
pending_outgoing INTEGER DEFAULT 0,
|
||||
approved_outgoing INTEGER DEFAULT 0,
|
||||
rejected_outgoing INTEGER DEFAULT 0,
|
||||
avg_response_hours DECIMAL(10,2),
|
||||
completion_rate DECIMAL(5,2),
|
||||
total_recipients INTEGER DEFAULT 0,
|
||||
unique_senders INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(department_id, summary_date)
|
||||
);
|
||||
|
||||
-- Create institution_letter_summary table
|
||||
CREATE TABLE IF NOT EXISTS institution_letter_summary (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
institution_id UUID NOT NULL REFERENCES institutions(id),
|
||||
summary_date DATE NOT NULL,
|
||||
incoming_sent INTEGER DEFAULT 0,
|
||||
outgoing_received INTEGER DEFAULT 0,
|
||||
total_correspondence INTEGER DEFAULT 0,
|
||||
avg_turnaround_hours DECIMAL(10,2),
|
||||
last_activity_at TIMESTAMP,
|
||||
priority_high_count INTEGER DEFAULT 0,
|
||||
priority_medium_count INTEGER DEFAULT 0,
|
||||
priority_low_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(institution_id, summary_date)
|
||||
);
|
||||
|
||||
-- Create approval_sla_summary table
|
||||
CREATE TABLE IF NOT EXISTS approval_sla_summary (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
summary_date DATE NOT NULL,
|
||||
department_id UUID REFERENCES departments(id),
|
||||
total_approvals INTEGER DEFAULT 0,
|
||||
approved_count INTEGER DEFAULT 0,
|
||||
rejected_count INTEGER DEFAULT 0,
|
||||
pending_count INTEGER DEFAULT 0,
|
||||
avg_approval_hours DECIMAL(10,2),
|
||||
min_approval_hours DECIMAL(10,2),
|
||||
max_approval_hours DECIMAL(10,2),
|
||||
median_approval_hours DECIMAL(10,2),
|
||||
within_sla_count INTEGER DEFAULT 0,
|
||||
exceeded_sla_count INTEGER DEFAULT 0,
|
||||
sla_compliance_rate DECIMAL(5,2),
|
||||
avg_approval_steps DECIMAL(5,2),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(summary_date, department_id)
|
||||
);
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX idx_letter_summary_date ON letter_summary(summary_date DESC);
|
||||
CREATE INDEX idx_letter_summary_type_date ON letter_summary(letter_type, summary_date DESC);
|
||||
|
||||
CREATE INDEX idx_dept_letter_summary_dept_date ON department_letter_summary(department_id, summary_date DESC);
|
||||
CREATE INDEX idx_dept_letter_summary_date ON department_letter_summary(summary_date DESC);
|
||||
|
||||
CREATE INDEX idx_inst_letter_summary_inst_date ON institution_letter_summary(institution_id, summary_date DESC);
|
||||
CREATE INDEX idx_inst_letter_summary_date ON institution_letter_summary(summary_date DESC);
|
||||
|
||||
CREATE INDEX idx_approval_sla_summary_date ON approval_sla_summary(summary_date DESC);
|
||||
CREATE INDEX idx_approval_sla_summary_dept_date ON approval_sla_summary(department_id, summary_date DESC);
|
||||
|
||||
-- Create function to update letter_summary
|
||||
CREATE OR REPLACE FUNCTION update_letter_summary()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_date DATE;
|
||||
v_type VARCHAR(20);
|
||||
BEGIN
|
||||
-- Determine date and type
|
||||
IF TG_TABLE_NAME = 'letters_incoming' THEN
|
||||
v_type := 'incoming';
|
||||
v_date := DATE(COALESCE(NEW.created_at, OLD.created_at));
|
||||
ELSE
|
||||
v_type := 'outgoing';
|
||||
v_date := DATE(COALESCE(NEW.created_at, OLD.created_at));
|
||||
END IF;
|
||||
|
||||
-- Update or insert summary
|
||||
INSERT INTO letter_summary (
|
||||
summary_date,
|
||||
letter_type,
|
||||
total_count,
|
||||
pending_count,
|
||||
approved_count,
|
||||
rejected_count,
|
||||
archived_count,
|
||||
sent_count
|
||||
)
|
||||
SELECT
|
||||
v_date,
|
||||
v_type,
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'pending' OR status = 'pending_approval') as pending_count,
|
||||
COUNT(*) FILTER (WHERE status = 'approved') as approved_count,
|
||||
COUNT(*) FILTER (WHERE status = 'rejected') as rejected_count,
|
||||
COUNT(*) FILTER (WHERE status = 'archived') as archived_count,
|
||||
COUNT(*) FILTER (WHERE status = 'sent') as sent_count
|
||||
FROM (
|
||||
SELECT status, created_at FROM letters_incoming WHERE DATE(created_at) = v_date AND v_type = 'incoming'
|
||||
UNION ALL
|
||||
SELECT status, created_at FROM letters_outgoing WHERE DATE(created_at) = v_date AND v_type = 'outgoing'
|
||||
) t
|
||||
ON CONFLICT (summary_date, letter_type)
|
||||
DO UPDATE SET
|
||||
total_count = EXCLUDED.total_count,
|
||||
pending_count = EXCLUDED.pending_count,
|
||||
approved_count = EXCLUDED.approved_count,
|
||||
rejected_count = EXCLUDED.rejected_count,
|
||||
archived_count = EXCLUDED.archived_count,
|
||||
sent_count = EXCLUDED.sent_count,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create triggers for automatic updates
|
||||
CREATE TRIGGER update_letter_summary_on_incoming
|
||||
AFTER INSERT OR UPDATE OR DELETE ON letters_incoming
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_letter_summary();
|
||||
|
||||
CREATE TRIGGER update_letter_summary_on_outgoing
|
||||
AFTER INSERT OR UPDATE OR DELETE ON letters_outgoing
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_letter_summary();
|
||||
|
||||
-- Function to populate historical data
|
||||
CREATE OR REPLACE FUNCTION populate_letter_summary_history()
|
||||
RETURNS void AS $$
|
||||
BEGIN
|
||||
-- Populate letter_summary with historical data
|
||||
INSERT INTO letter_summary (
|
||||
summary_date,
|
||||
letter_type,
|
||||
total_count,
|
||||
pending_count,
|
||||
approved_count,
|
||||
rejected_count,
|
||||
archived_count,
|
||||
sent_count,
|
||||
avg_processing_hours
|
||||
)
|
||||
SELECT
|
||||
DATE(created_at) as summary_date,
|
||||
'incoming' as letter_type,
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
|
||||
COUNT(*) FILTER (WHERE status = 'approved') as approved_count,
|
||||
COUNT(*) FILTER (WHERE status = 'rejected') as rejected_count,
|
||||
COUNT(*) FILTER (WHERE status = 'archived') as archived_count,
|
||||
0 as sent_count,
|
||||
AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/3600) as avg_processing_hours
|
||||
FROM letters_incoming
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY DATE(created_at)
|
||||
UNION ALL
|
||||
SELECT
|
||||
DATE(created_at) as summary_date,
|
||||
'outgoing' as letter_type,
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'pending_approval') as pending_count,
|
||||
COUNT(*) FILTER (WHERE status = 'approved') as approved_count,
|
||||
COUNT(*) FILTER (WHERE status = 'rejected') as rejected_count,
|
||||
COUNT(*) FILTER (WHERE status = 'archived') as archived_count,
|
||||
COUNT(*) FILTER (WHERE status = 'sent') as sent_count,
|
||||
AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/3600) as avg_processing_hours
|
||||
FROM letters_outgoing
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY DATE(created_at)
|
||||
ON CONFLICT (summary_date, letter_type) DO NOTHING;
|
||||
|
||||
-- Populate department_letter_summary
|
||||
INSERT INTO department_letter_summary (
|
||||
department_id,
|
||||
summary_date,
|
||||
incoming_count,
|
||||
outgoing_count,
|
||||
pending_incoming,
|
||||
pending_outgoing,
|
||||
approved_outgoing,
|
||||
rejected_outgoing,
|
||||
avg_response_hours,
|
||||
completion_rate
|
||||
)
|
||||
SELECT
|
||||
d.id as department_id,
|
||||
dates.summary_date,
|
||||
COALESCE(inc.count, 0) as incoming_count,
|
||||
COALESCE(outg.count, 0) as outgoing_count,
|
||||
COALESCE(inc.pending, 0) as pending_incoming,
|
||||
COALESCE(outg.pending, 0) as pending_outgoing,
|
||||
COALESCE(outg.approved, 0) as approved_outgoing,
|
||||
COALESCE(outg.rejected, 0) as rejected_outgoing,
|
||||
COALESCE(outg.avg_hours, 0) as avg_response_hours,
|
||||
CASE
|
||||
WHEN COALESCE(outg.count, 0) > 0
|
||||
THEN (COALESCE(outg.completed, 0)::DECIMAL / outg.count::DECIMAL) * 100
|
||||
ELSE 0
|
||||
END as completion_rate
|
||||
FROM departments d
|
||||
CROSS JOIN (
|
||||
SELECT DISTINCT DATE(created_at) as summary_date
|
||||
FROM letters_incoming
|
||||
UNION
|
||||
SELECT DISTINCT DATE(created_at)
|
||||
FROM letters_outgoing
|
||||
) dates
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
lir.recipient_department_id as dept_id,
|
||||
DATE(li.created_at) as date,
|
||||
COUNT(DISTINCT li.id) as count,
|
||||
COUNT(DISTINCT li.id) FILTER (WHERE li.status = 'pending') as pending
|
||||
FROM letters_incoming li
|
||||
JOIN letter_incoming_recipients lir ON lir.letter_id = li.id
|
||||
WHERE li.deleted_at IS NULL
|
||||
GROUP BY lir.recipient_department_id, DATE(li.created_at)
|
||||
) inc ON inc.dept_id = d.id AND inc.date = dates.summary_date
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
lor.department_id as dept_id,
|
||||
DATE(lo.created_at) as date,
|
||||
COUNT(DISTINCT lo.id) as count,
|
||||
COUNT(DISTINCT lo.id) FILTER (WHERE lo.status = 'pending_approval') as pending,
|
||||
COUNT(DISTINCT lo.id) FILTER (WHERE lo.status = 'approved') as approved,
|
||||
COUNT(DISTINCT lo.id) FILTER (WHERE lo.status = 'rejected') as rejected,
|
||||
COUNT(DISTINCT lo.id) FILTER (WHERE lo.status IN ('sent', 'archived')) as completed,
|
||||
AVG(EXTRACT(EPOCH FROM (lo.updated_at - lo.created_at))/3600) as avg_hours
|
||||
FROM letters_outgoing lo
|
||||
JOIN letter_outgoing_recipients lor ON lor.letter_id = lo.id
|
||||
WHERE lo.deleted_at IS NULL
|
||||
GROUP BY lor.department_id, DATE(lo.created_at)
|
||||
) outg ON outg.dept_id = d.id AND outg.date = dates.summary_date
|
||||
WHERE inc.count > 0 OR outg.count > 0
|
||||
ON CONFLICT (department_id, summary_date) DO NOTHING;
|
||||
|
||||
-- Populate institution_letter_summary
|
||||
INSERT INTO institution_letter_summary (
|
||||
institution_id,
|
||||
summary_date,
|
||||
incoming_sent,
|
||||
outgoing_received,
|
||||
total_correspondence,
|
||||
avg_turnaround_hours,
|
||||
last_activity_at,
|
||||
priority_high_count,
|
||||
priority_medium_count,
|
||||
priority_low_count
|
||||
)
|
||||
SELECT
|
||||
i.id as institution_id,
|
||||
dates.summary_date,
|
||||
COALESCE(inc.count, 0) as incoming_sent,
|
||||
COALESCE(outg.count, 0) as outgoing_received,
|
||||
COALESCE(inc.count, 0) + COALESCE(outg.count, 0) as total_correspondence,
|
||||
COALESCE((inc.avg_hours + outg.avg_hours) / 2, 0) as avg_turnaround_hours,
|
||||
GREATEST(inc.last_activity, outg.last_activity) as last_activity_at,
|
||||
COALESCE(inc.high_priority, 0) + COALESCE(outg.high_priority, 0) as priority_high_count,
|
||||
COALESCE(inc.medium_priority, 0) + COALESCE(outg.medium_priority, 0) as priority_medium_count,
|
||||
COALESCE(inc.low_priority, 0) + COALESCE(outg.low_priority, 0) as priority_low_count
|
||||
FROM institutions i
|
||||
CROSS JOIN (
|
||||
SELECT DISTINCT DATE(created_at) as summary_date
|
||||
FROM letters_incoming
|
||||
UNION
|
||||
SELECT DISTINCT DATE(created_at)
|
||||
FROM letters_outgoing
|
||||
) dates
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
sender_institution_id as inst_id,
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as count,
|
||||
AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/3600) as avg_hours,
|
||||
MAX(created_at) as last_activity,
|
||||
COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 1)) as high_priority,
|
||||
COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 2)) as medium_priority,
|
||||
COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 3)) as low_priority
|
||||
FROM letters_incoming
|
||||
WHERE deleted_at IS NULL AND sender_institution_id IS NOT NULL
|
||||
GROUP BY sender_institution_id, DATE(created_at)
|
||||
) inc ON inc.inst_id = i.id AND inc.date = dates.summary_date
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
receiver_institution_id as inst_id,
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as count,
|
||||
AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/3600) as avg_hours,
|
||||
MAX(created_at) as last_activity,
|
||||
COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 1)) as high_priority,
|
||||
COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 2)) as medium_priority,
|
||||
COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 3)) as low_priority
|
||||
FROM letters_outgoing
|
||||
WHERE deleted_at IS NULL AND receiver_institution_id IS NOT NULL
|
||||
GROUP BY receiver_institution_id, DATE(created_at)
|
||||
) outg ON outg.inst_id = i.id AND outg.date = dates.summary_date
|
||||
WHERE inc.count > 0 OR outg.count > 0
|
||||
ON CONFLICT (institution_id, summary_date) DO NOTHING;
|
||||
|
||||
-- Populate approval_sla_summary
|
||||
INSERT INTO approval_sla_summary (
|
||||
summary_date,
|
||||
department_id,
|
||||
total_approvals,
|
||||
approved_count,
|
||||
rejected_count,
|
||||
pending_count,
|
||||
avg_approval_hours,
|
||||
min_approval_hours,
|
||||
max_approval_hours,
|
||||
median_approval_hours,
|
||||
within_sla_count,
|
||||
exceeded_sla_count,
|
||||
sla_compliance_rate,
|
||||
avg_approval_steps
|
||||
)
|
||||
SELECT
|
||||
DATE(loa.created_at) as summary_date,
|
||||
d.id as department_id,
|
||||
COUNT(loa.id) as total_approvals,
|
||||
COUNT(loa.id) FILTER (WHERE loa.status = 'approved') as approved_count,
|
||||
COUNT(loa.id) FILTER (WHERE loa.status = 'rejected') as rejected_count,
|
||||
COUNT(loa.id) FILTER (WHERE loa.status = 'pending') as pending_count,
|
||||
AVG(EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600) FILTER (WHERE loa.acted_at IS NOT NULL) as avg_approval_hours,
|
||||
MIN(EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600) FILTER (WHERE loa.acted_at IS NOT NULL) as min_approval_hours,
|
||||
MAX(EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600) FILTER (WHERE loa.acted_at IS NOT NULL) as max_approval_hours,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600) FILTER (WHERE loa.acted_at IS NOT NULL) as median_approval_hours,
|
||||
COUNT(loa.id) FILTER (WHERE EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600 <= 24 AND loa.acted_at IS NOT NULL) as within_sla_count,
|
||||
COUNT(loa.id) FILTER (WHERE EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600 > 24 AND loa.acted_at IS NOT NULL) as exceeded_sla_count,
|
||||
CASE
|
||||
WHEN COUNT(loa.id) FILTER (WHERE loa.acted_at IS NOT NULL) > 0
|
||||
THEN (COUNT(loa.id) FILTER (WHERE EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600 <= 24 AND loa.acted_at IS NOT NULL)::DECIMAL /
|
||||
COUNT(loa.id) FILTER (WHERE loa.acted_at IS NOT NULL)::DECIMAL) * 100
|
||||
ELSE 0
|
||||
END as sla_compliance_rate,
|
||||
AVG(step_counts.step_count) as avg_approval_steps
|
||||
FROM letter_outgoing_approvals loa
|
||||
JOIN letters_outgoing lo ON lo.id = loa.letter_id
|
||||
JOIN letter_outgoing_recipients lor ON lor.letter_id = lo.id
|
||||
JOIN departments d ON d.id = lor.department_id
|
||||
LEFT JOIN (
|
||||
SELECT letter_id, COUNT(*) as step_count
|
||||
FROM letter_outgoing_approvals
|
||||
GROUP BY letter_id
|
||||
) step_counts ON step_counts.letter_id = loa.letter_id
|
||||
WHERE lo.deleted_at IS NULL
|
||||
GROUP BY DATE(loa.created_at), d.id
|
||||
ON CONFLICT (summary_date, department_id) DO NOTHING;
|
||||
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Execute the function to populate historical data
|
||||
SELECT populate_letter_summary_history();
|
||||
Loading…
x
Reference in New Issue
Block a user