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)
|
adminApprovalFlowHandler := handler.NewAdminApprovalFlowHandler(services.approvalFlowService)
|
||||||
dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService)
|
dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService)
|
||||||
onlyOfficeHandler := handler.NewOnlyOfficeHandler(services.onlyOfficeService)
|
onlyOfficeHandler := handler.NewOnlyOfficeHandler(services.onlyOfficeService)
|
||||||
|
analyticsHandler := handler.NewAnalyticsHandler(services.analyticsService)
|
||||||
|
|
||||||
a.router = router.NewRouter(
|
a.router = router.NewRouter(
|
||||||
cfg,
|
cfg,
|
||||||
@ -65,6 +66,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
|||||||
adminApprovalFlowHandler,
|
adminApprovalFlowHandler,
|
||||||
dispositionRouteHandler,
|
dispositionRouteHandler,
|
||||||
onlyOfficeHandler,
|
onlyOfficeHandler,
|
||||||
|
analyticsHandler,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -141,6 +143,7 @@ type repositories struct {
|
|||||||
letterOutgoingActivityLogRepo *repository.LetterOutgoingActivityLogRepository
|
letterOutgoingActivityLogRepo *repository.LetterOutgoingActivityLogRepository
|
||||||
approvalFlowRepo *repository.ApprovalFlowRepository
|
approvalFlowRepo *repository.ApprovalFlowRepository
|
||||||
letterOutgoingApprovalRepo *repository.LetterOutgoingApprovalRepository
|
letterOutgoingApprovalRepo *repository.LetterOutgoingApprovalRepository
|
||||||
|
analyticsRepo *repository.AnalyticsRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initRepositories() *repositories {
|
func (a *App) initRepositories() *repositories {
|
||||||
@ -174,6 +177,7 @@ func (a *App) initRepositories() *repositories {
|
|||||||
letterOutgoingActivityLogRepo: repository.NewLetterOutgoingActivityLogRepository(a.db),
|
letterOutgoingActivityLogRepo: repository.NewLetterOutgoingActivityLogRepository(a.db),
|
||||||
approvalFlowRepo: repository.NewApprovalFlowRepository(a.db),
|
approvalFlowRepo: repository.NewApprovalFlowRepository(a.db),
|
||||||
letterOutgoingApprovalRepo: repository.NewLetterOutgoingApprovalRepository(a.db),
|
letterOutgoingApprovalRepo: repository.NewLetterOutgoingApprovalRepository(a.db),
|
||||||
|
analyticsRepo: repository.NewAnalyticsRepository(a.db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,6 +259,7 @@ type services struct {
|
|||||||
approvalFlowService *service.ApprovalFlowServiceImpl
|
approvalFlowService *service.ApprovalFlowServiceImpl
|
||||||
dispositionRouteService *service.DispositionRouteServiceImpl
|
dispositionRouteService *service.DispositionRouteServiceImpl
|
||||||
onlyOfficeService *service.OnlyOfficeServiceImpl
|
onlyOfficeService *service.OnlyOfficeServiceImpl
|
||||||
|
analyticsService *service.AnalyticsServiceImpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
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
|
// Create OnlyOffice service with file storage
|
||||||
onlyOfficeSvc := service.NewOnlyOfficeService(processors.onlyOfficeProcessor, &cfg.OnlyOffice, a.db, s3Client)
|
onlyOfficeSvc := service.NewOnlyOfficeService(processors.onlyOfficeProcessor, &cfg.OnlyOffice, a.db, s3Client)
|
||||||
|
|
||||||
|
// Create Analytics service
|
||||||
|
analyticsSvc := service.NewAnalyticsService(repos.analyticsRepo)
|
||||||
|
|
||||||
return &services{
|
return &services{
|
||||||
userService: userSvc,
|
userService: userSvc,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
@ -300,6 +308,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
|||||||
approvalFlowService: approvalFlowSvc,
|
approvalFlowService: approvalFlowSvc,
|
||||||
dispositionRouteService: dispRouteSvc,
|
dispositionRouteService: dispRouteSvc,
|
||||||
onlyOfficeService: onlyOfficeSvc,
|
onlyOfficeService: onlyOfficeSvc,
|
||||||
|
analyticsService: analyticsSvc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ const (
|
|||||||
DeviceOSKey = key("deviceOS")
|
DeviceOSKey = key("deviceOS")
|
||||||
UserLocaleKey = key("userLocale")
|
UserLocaleKey = key("userLocale")
|
||||||
UserRoleKey = key("userRole")
|
UserRoleKey = key("userRole")
|
||||||
|
UserNameKey = key("UserName")
|
||||||
)
|
)
|
||||||
|
|
||||||
func LogFields(ctx interface{}) map[string]interface{} {
|
func LogFields(ctx interface{}) map[string]interface{} {
|
||||||
|
|||||||
@ -27,6 +27,7 @@ type ContextInfo struct {
|
|||||||
DeviceOS string
|
DeviceOS string
|
||||||
UserLocale string
|
UserLocale string
|
||||||
UserRole string
|
UserRole string
|
||||||
|
UserName string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ctxKeyType struct{}
|
type ctxKeyType struct{}
|
||||||
@ -68,6 +69,7 @@ func FromGinContext(ctx context.Context) *ContextInfo {
|
|||||||
DeviceOS: value(ctx, DeviceOSKey),
|
DeviceOS: value(ctx, DeviceOSKey),
|
||||||
UserLocale: value(ctx, UserLocaleKey),
|
UserLocale: value(ctx, UserLocaleKey),
|
||||||
UserRole: value(ctx, UserRoleKey),
|
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 {
|
type OutgoingLetterApprovalResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
StepOrder int `json:"step_order"`
|
StepOrder int `json:"step_order"`
|
||||||
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
|
ParallelGroup int `json:"parallel_group"`
|
||||||
Status string `json:"status"`
|
IsRequired bool `json:"is_required"`
|
||||||
Remarks *string `json:"remarks,omitempty"`
|
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
|
||||||
ActedAt *time.Time `json:"acted_at,omitempty"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Remarks *string `json:"remarks,omitempty"`
|
||||||
|
ActedAt *time.Time `json:"acted_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OutgoingLetterResponse struct {
|
type OutgoingLetterResponse struct {
|
||||||
@ -97,14 +99,18 @@ type UpdateOutgoingLetterRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListOutgoingLettersRequest struct {
|
type ListOutgoingLettersRequest struct {
|
||||||
Limit int `json:"limit"`
|
Page int `form:"page" json:"page"`
|
||||||
Offset int `json:"offset"`
|
Limit int `form:"limit" json:"limit"`
|
||||||
Status *string `json:"status,omitempty"`
|
Status string `form:"status" json:"status,omitempty"`
|
||||||
Query *string `json:"query,omitempty"`
|
Query string `form:"q" json:"query,omitempty"`
|
||||||
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
|
CreatedBy *uuid.UUID `form:"created_by" json:"created_by,omitempty"`
|
||||||
ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"`
|
DepartmentID *uuid.UUID `form:"department_id" json:"department_id,omitempty"`
|
||||||
FromDate *time.Time `json:"from_date,omitempty"`
|
ReceiverInstitutionID *uuid.UUID `form:"receiver_institution_id" json:"receiver_institution_id,omitempty"`
|
||||||
ToDate *time.Time `json:"to_date,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 {
|
type ListOutgoingLettersResponse struct {
|
||||||
@ -242,32 +248,45 @@ type OutgoingLetterApprovalDiscussionsResponse struct {
|
|||||||
|
|
||||||
// EnhancedOutgoingLetterApprovalResponse includes approval details with related data
|
// EnhancedOutgoingLetterApprovalResponse includes approval details with related data
|
||||||
type EnhancedOutgoingLetterApprovalResponse struct {
|
type EnhancedOutgoingLetterApprovalResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
LetterID uuid.UUID `json:"letter_id"`
|
LetterID uuid.UUID `json:"letter_id"`
|
||||||
StepID uuid.UUID `json:"step_id"`
|
StepID uuid.UUID `json:"step_id"`
|
||||||
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
|
StepOrder int `json:"step_order"`
|
||||||
Status string `json:"status"`
|
ParallelGroup int `json:"parallel_group"`
|
||||||
Remarks *string `json:"remarks,omitempty"`
|
IsRequired bool `json:"is_required"`
|
||||||
ActedAt *time.Time `json:"acted_at,omitempty"`
|
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Status string `json:"status"`
|
||||||
Step *ApprovalFlowStepResponse `json:"step,omitempty"`
|
Remarks *string `json:"remarks,omitempty"`
|
||||||
Approver *UserResponse `json:"approver,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
|
// OutgoingLetterDiscussionResponse represents a discussion on an outgoing letter
|
||||||
type OutgoingLetterDiscussionResponse struct {
|
type OutgoingLetterDiscussionResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
LetterID uuid.UUID `json:"letter_id"`
|
LetterID uuid.UUID `json:"letter_id"`
|
||||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||||
UserID uuid.UUID `json:"user_id"`
|
UserID uuid.UUID `json:"user_id"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Mentions map[string]interface{} `json:"mentions,omitempty"`
|
Mentions map[string]interface{} `json:"mentions,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
EditedAt *time.Time `json:"edited_at,omitempty"`
|
EditedAt *time.Time `json:"edited_at,omitempty"`
|
||||||
User *UserResponse `json:"user,omitempty"`
|
User *UserResponse `json:"user,omitempty"`
|
||||||
MentionedUsers []UserResponse `json:"mentioned_users,omitempty"`
|
MentionedUsers []UserResponse `json:"mentioned_users,omitempty"`
|
||||||
Attachments []OutgoingLetterDiscussionAttachmentResponse `json:"attachments,omitempty"`
|
Attachments []OutgoingLetterDiscussionAttachmentResponse `json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutgoingLetterDiscussionAttachmentResponse represents an attachment in a discussion
|
// OutgoingLetterDiscussionAttachmentResponse represents an attachment in a discussion
|
||||||
@ -280,3 +299,39 @@ type OutgoingLetterDiscussionAttachmentResponse struct {
|
|||||||
UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"`
|
UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"`
|
||||||
UploadedAt time.Time `json:"uploaded_at"`
|
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
|
type ApprovalStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ApprovalStatusPending ApprovalStatus = "pending"
|
ApprovalStatusNotStarted ApprovalStatus = "not_started"
|
||||||
ApprovalStatusApproved ApprovalStatus = "approved"
|
ApprovalStatusPending ApprovalStatus = "pending"
|
||||||
ApprovalStatusRejected ApprovalStatus = "rejected"
|
ApprovalStatusApproved ApprovalStatus = "approved"
|
||||||
|
ApprovalStatusRejected ApprovalStatus = "rejected"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LetterOutgoingApproval struct {
|
type LetterOutgoingApproval struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
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"`
|
LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"`
|
||||||
StepID uuid.UUID `gorm:"type:uuid;not null" json:"step_id"`
|
StepID uuid.UUID `gorm:"type:uuid;not null" json:"step_id"`
|
||||||
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
|
StepOrder int `gorm:"not null" json:"step_order"`
|
||||||
Status ApprovalStatus `gorm:"not null;default:'pending'" json:"status"`
|
ParallelGroup int `gorm:"default:1" json:"parallel_group"`
|
||||||
Remarks *string `json:"remarks,omitempty"`
|
IsRequired bool `gorm:"default:true" json:"is_required"`
|
||||||
ActedAt *time.Time `json:"acted_at,omitempty"`
|
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
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
|
// Relations
|
||||||
Letter *LetterOutgoing `gorm:"foreignKey:LetterID" json:"letter,omitempty"`
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"eslogad-be/internal/appcontext"
|
"eslogad-be/internal/appcontext"
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"eslogad-be/internal/contract"
|
"eslogad-be/internal/contract"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@ -38,7 +36,9 @@ type LetterOutgoingService interface {
|
|||||||
DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error
|
DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error
|
||||||
|
|
||||||
GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, 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)
|
GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error)
|
||||||
|
GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type LetterOutgoingHandler struct {
|
type LetterOutgoingHandler struct {
|
||||||
@ -84,47 +84,22 @@ func (h *LetterOutgoingHandler) GetOutgoingLetter(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *LetterOutgoingHandler) ListOutgoingLetters(c *gin.Context) {
|
func (h *LetterOutgoingHandler) ListOutgoingLetters(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
var req contract.ListOutgoingLettersRequest
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
|
||||||
offset := (page - 1) * limit
|
|
||||||
|
|
||||||
status := c.Query("status")
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
query := c.Query("q")
|
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid query parameters", Code: http.StatusBadRequest})
|
||||||
createdByStr := c.Query("created_by")
|
return
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &contract.ListOutgoingLettersRequest{
|
if req.Page <= 0 {
|
||||||
Limit: limit,
|
req.Page = 1
|
||||||
Offset: offset,
|
|
||||||
Status: statusPtr,
|
|
||||||
Query: queryPtr,
|
|
||||||
CreatedBy: createdByPtr,
|
|
||||||
ReceiverInstitutionID: receiverInstitutionPtr,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
|
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
|
||||||
return
|
return
|
||||||
@ -203,7 +178,7 @@ func (h *LetterOutgoingHandler) ApproveOutgoingLetter(c *gin.Context) {
|
|||||||
return
|
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) {
|
func (h *LetterOutgoingHandler) RejectOutgoingLetter(c *gin.Context) {
|
||||||
@ -224,7 +199,7 @@ func (h *LetterOutgoingHandler) RejectOutgoingLetter(c *gin.Context) {
|
|||||||
return
|
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) {
|
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))
|
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
|
// GetApprovalDiscussions returns both approvals and discussions for an outgoing letter
|
||||||
func (h *LetterOutgoingHandler) GetApprovalDiscussions(c *gin.Context) {
|
func (h *LetterOutgoingHandler) GetApprovalDiscussions(c *gin.Context) {
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
@ -462,3 +458,24 @@ func (h *LetterOutgoingHandler) GetApprovalDiscussions(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
|
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.UserIDKey, userResponse.ID.String())
|
||||||
|
setKeyInContext(c, appcontext.UserNameKey, userResponse.Name)
|
||||||
if len(userResponse.DepartmentResponse) > 0 {
|
if len(userResponse.DepartmentResponse) > 0 {
|
||||||
departmentID := userResponse.DepartmentResponse[0].ID.String()
|
departmentID := userResponse.DepartmentResponse[0].ID.String()
|
||||||
setKeyInContext(c, appcontext.DepartmentIDKey, departmentID)
|
setKeyInContext(c, appcontext.DepartmentIDKey, departmentID)
|
||||||
|
|||||||
@ -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
|
var recipients []entities.LetterOutgoingRecipient
|
||||||
for i, step := range flow.Steps {
|
for i, step := range flow.Steps {
|
||||||
// Only process steps with the minimum step order (first step)
|
// 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
|
// Bulk create all recipients if any
|
||||||
if len(recipients) == 0 {
|
if len(recipients) > 0 {
|
||||||
return nil
|
return p.recipientRepo.CreateBulk(ctx, recipients)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk create all recipients
|
return nil
|
||||||
return p.recipientRepo.CreateBulk(ctx, recipients)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// createRecipientFromApprovalStep creates a recipient from an approval flow step
|
// 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 {
|
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))
|
approvals := make([]entities.LetterOutgoingApproval, len(flow.Steps))
|
||||||
for i, step := range flow.Steps {
|
for i, step := range flow.Steps {
|
||||||
approvals[i] = entities.LetterOutgoingApproval{
|
approvals[i] = entities.LetterOutgoingApproval{
|
||||||
LetterID: letterID,
|
LetterID: letterID,
|
||||||
StepID: step.ID,
|
StepID: step.ID,
|
||||||
Status: entities.ApprovalStatusPending,
|
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
|
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 {
|
if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusPendingApproval); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -376,7 +458,78 @@ func (p *LetterOutgoingProcessorImpl) ProcessApproval(ctx context.Context, lette
|
|||||||
return err
|
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 {
|
if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusApproved); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@ -72,12 +72,17 @@ func (r *LetterOutgoingRepository) GetWithRelations(ctx context.Context, id uuid
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListOutgoingLettersFilter struct {
|
type ListOutgoingLettersFilter struct {
|
||||||
Status *string
|
Status *string
|
||||||
Query *string
|
Query *string
|
||||||
CreatedBy *uuid.UUID
|
CreatedBy *uuid.UUID
|
||||||
|
DepartmentID *uuid.UUID
|
||||||
|
UserID *uuid.UUID
|
||||||
ReceiverInstitutionID *uuid.UUID
|
ReceiverInstitutionID *uuid.UUID
|
||||||
FromDate *time.Time
|
FromDate *time.Time
|
||||||
ToDate *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) {
|
func (r *LetterOutgoingRepository) List(ctx context.Context, filter ListOutgoingLettersFilter, limit, offset int) ([]entities.LetterOutgoing, int64, error) {
|
||||||
@ -94,9 +99,18 @@ func (r *LetterOutgoingRepository) List(ctx context.Context, filter ListOutgoing
|
|||||||
if filter.CreatedBy != nil {
|
if filter.CreatedBy != nil {
|
||||||
query = query.Where("created_by = ?", *filter.CreatedBy)
|
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 {
|
if filter.ReceiverInstitutionID != nil {
|
||||||
query = query.Where("receiver_institution_id = ?", *filter.ReceiverInstitutionID)
|
query = query.Where("receiver_institution_id = ?", *filter.ReceiverInstitutionID)
|
||||||
}
|
}
|
||||||
|
if filter.PriorityID != nil {
|
||||||
|
query = query.Where("priority_id = ?", *filter.PriorityID)
|
||||||
|
}
|
||||||
if filter.FromDate != nil {
|
if filter.FromDate != nil {
|
||||||
query = query.Where("issue_date >= ?", *filter.FromDate)
|
query = query.Where("issue_date >= ?", *filter.FromDate)
|
||||||
}
|
}
|
||||||
@ -109,16 +123,44 @@ func (r *LetterOutgoingRepository) List(ctx context.Context, filter ListOutgoing
|
|||||||
return nil, 0, err
|
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
|
var list []entities.LetterOutgoing
|
||||||
if err := query.
|
if err := query.
|
||||||
Preload("Priority").
|
Preload("Priority").
|
||||||
Preload("ReceiverInstitution").
|
Preload("ReceiverInstitution").
|
||||||
Preload("Creator").
|
Preload("Creator").
|
||||||
|
Preload("Creator.Profile").
|
||||||
|
Preload("Creator.Departments").
|
||||||
Preload("Recipients").
|
Preload("Recipients").
|
||||||
|
Preload("Recipients.User").
|
||||||
|
Preload("Recipients.Department").
|
||||||
Preload("Attachments").
|
Preload("Attachments").
|
||||||
Preload("Approvals.Step").
|
Preload("Approvals.Step").
|
||||||
Preload("Approvals.Approver").
|
Preload("Approvals.Approver").
|
||||||
Order("created_at DESC").
|
Order(orderBy).
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
Offset(offset).
|
Offset(offset).
|
||||||
Find(&list).Error; err != nil {
|
Find(&list).Error; err != nil {
|
||||||
|
|||||||
@ -91,6 +91,7 @@ type LetterOutgoingHandler interface {
|
|||||||
SendOutgoingLetter(c *gin.Context)
|
SendOutgoingLetter(c *gin.Context)
|
||||||
ArchiveOutgoingLetter(c *gin.Context)
|
ArchiveOutgoingLetter(c *gin.Context)
|
||||||
GetLetterApprovalInfo(c *gin.Context)
|
GetLetterApprovalInfo(c *gin.Context)
|
||||||
|
GetLetterApprovals(c *gin.Context)
|
||||||
|
|
||||||
AddRecipients(c *gin.Context)
|
AddRecipients(c *gin.Context)
|
||||||
UpdateRecipient(c *gin.Context)
|
UpdateRecipient(c *gin.Context)
|
||||||
@ -104,6 +105,7 @@ type LetterOutgoingHandler interface {
|
|||||||
DeleteDiscussion(c *gin.Context)
|
DeleteDiscussion(c *gin.Context)
|
||||||
|
|
||||||
GetApprovalDiscussions(c *gin.Context)
|
GetApprovalDiscussions(c *gin.Context)
|
||||||
|
GetApprovalTimeline(c *gin.Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminApprovalFlowHandler interface {
|
type AdminApprovalFlowHandler interface {
|
||||||
@ -134,3 +136,13 @@ type OnlyOfficeHandler interface {
|
|||||||
UnlockDocument(c *gin.Context)
|
UnlockDocument(c *gin.Context)
|
||||||
GetDocumentSession(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
|
adminApprovalFlowHandler AdminApprovalFlowHandler
|
||||||
dispRouteHandler DispositionRouteHandler
|
dispRouteHandler DispositionRouteHandler
|
||||||
onlyOfficeHandler OnlyOfficeHandler
|
onlyOfficeHandler OnlyOfficeHandler
|
||||||
|
analyticsHandler AnalyticsHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(
|
func NewRouter(
|
||||||
@ -37,6 +38,7 @@ func NewRouter(
|
|||||||
adminApprovalFlowHandler AdminApprovalFlowHandler,
|
adminApprovalFlowHandler AdminApprovalFlowHandler,
|
||||||
dispRouteHandler DispositionRouteHandler,
|
dispRouteHandler DispositionRouteHandler,
|
||||||
onlyOfficeHandler OnlyOfficeHandler,
|
onlyOfficeHandler OnlyOfficeHandler,
|
||||||
|
analyticsHandler AnalyticsHandler,
|
||||||
) *Router {
|
) *Router {
|
||||||
return &Router{
|
return &Router{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
@ -52,6 +54,7 @@ func NewRouter(
|
|||||||
adminApprovalFlowHandler: adminApprovalFlowHandler,
|
adminApprovalFlowHandler: adminApprovalFlowHandler,
|
||||||
dispRouteHandler: dispRouteHandler,
|
dispRouteHandler: dispRouteHandler,
|
||||||
onlyOfficeHandler: onlyOfficeHandler,
|
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/reject", r.letterOutgoingHandler.RejectOutgoingLetter)
|
||||||
lettersch.POST("/outgoing/:id/send", r.letterOutgoingHandler.SendOutgoingLetter)
|
lettersch.POST("/outgoing/:id/send", r.letterOutgoingHandler.SendOutgoingLetter)
|
||||||
lettersch.POST("/outgoing/:id/archive", r.letterOutgoingHandler.ArchiveOutgoingLetter)
|
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.POST("/outgoing/:id/recipients", r.letterOutgoingHandler.AddRecipients)
|
||||||
lettersch.PUT("/outgoing/:id/recipients/:recipient_id", r.letterOutgoingHandler.UpdateRecipient)
|
lettersch.PUT("/outgoing/:id/recipients/:recipient_id", r.letterOutgoingHandler.UpdateRecipient)
|
||||||
@ -183,8 +187,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
lettersch.PUT("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.UpdateDiscussion)
|
lettersch.PUT("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.UpdateDiscussion)
|
||||||
lettersch.DELETE("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.DeleteDiscussion)
|
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/approval-discussions", r.letterOutgoingHandler.GetApprovalDiscussions)
|
||||||
|
lettersch.GET("/outgoing/:id/timeline", r.letterOutgoingHandler.GetApprovalTimeline)
|
||||||
|
|
||||||
lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions)
|
lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions)
|
||||||
lettersch.GET("/dispositions/:letter_id", r.letterHandler.GetEnhancedDispositionsByLetter)
|
lettersch.GET("/dispositions/:letter_id", r.letterHandler.GetEnhancedDispositionsByLetter)
|
||||||
@ -237,5 +241,18 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
onlyofficeAuth.GET("/session/:key", r.onlyOfficeHandler.GetDocumentSession)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"eslogad-be/internal/appcontext"
|
"eslogad-be/internal/appcontext"
|
||||||
"eslogad-be/internal/contract"
|
"eslogad-be/internal/contract"
|
||||||
@ -40,8 +42,14 @@ type LetterOutgoingService interface {
|
|||||||
|
|
||||||
GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, 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 returns both approvals and discussions for an outgoing letter
|
||||||
GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error)
|
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 {
|
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) {
|
func (s *LetterOutgoingServiceImpl) ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error) {
|
||||||
filter := repository.ListOutgoingLettersFilter{
|
offset := (req.Page - 1) * req.Limit
|
||||||
Status: req.Status,
|
if offset < 0 {
|
||||||
Query: req.Query,
|
offset = 0
|
||||||
CreatedBy: req.CreatedBy,
|
|
||||||
ReceiverInstitutionID: req.ReceiverInstitutionID,
|
|
||||||
FromDate: req.FromDate,
|
|
||||||
ToDate: req.ToDate,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) {
|
||||||
userID := getUserIDFromContext(ctx)
|
userID := getUserIDFromContext(ctx)
|
||||||
|
|
||||||
_, err := s.processor.GetOutgoingLetterByID(ctx, letterID)
|
// Verify letter exists
|
||||||
|
letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all approvals for this letter
|
||||||
approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID)
|
approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 isApproverOnActiveStep bool
|
||||||
var canApprove bool
|
var canApprove bool
|
||||||
|
|
||||||
for _, approval := range approvals {
|
// Find the minimum step order that has pending approvals
|
||||||
if approval.Status == entities.ApprovalStatusPending {
|
for stepOrder, stepApprovals := range approvalsByStep {
|
||||||
currentApproval = &approval
|
hasPending := false
|
||||||
break
|
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
|
// User can approve if they have a pending approval on the current active step
|
||||||
if currentApproval != nil && currentApproval.Step != nil {
|
if isApproverOnActiveStep && userApproval != nil && userApproval.Status == entities.ApprovalStatusPending {
|
||||||
step := currentApproval.Step
|
canApprove = true
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build actions based on current status
|
// Build actions based on eligibility
|
||||||
var actions []contract.ApprovalAction
|
var actions []contract.ApprovalAction
|
||||||
if canApprove && currentApproval != nil {
|
if canApprove && userApproval != nil {
|
||||||
actions = []contract.ApprovalAction{
|
actions = []contract.ApprovalAction{
|
||||||
{
|
{
|
||||||
Type: "APPROVE",
|
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",
|
Method: "POST",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Type: "REJECT",
|
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",
|
Method: "POST",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine decision status
|
// Determine overall decision status
|
||||||
decisionStatus := "PENDING"
|
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"
|
decisionStatus = "COMPLETED"
|
||||||
|
} else if letter.Status == entities.LetterOutgoingStatusPendingApproval {
|
||||||
|
decisionStatus = "PENDING"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine notes visibility
|
// Determine notes visibility
|
||||||
notesVisibility := "FULL"
|
notesVisibility := "READONLY"
|
||||||
if !isApproverOnActiveStep {
|
if canApprove {
|
||||||
notesVisibility = "READONLY"
|
notesVisibility = "FULL"
|
||||||
}
|
}
|
||||||
|
|
||||||
info := &contract.LetterApprovalInfoResponse{
|
info := &contract.LetterApprovalInfoResponse{
|
||||||
@ -597,6 +674,127 @@ func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, l
|
|||||||
return info, nil
|
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 {
|
func getUserIDFromContext(ctx context.Context) uuid.UUID {
|
||||||
appCtx := appcontext.FromGinContext(ctx)
|
appCtx := appcontext.FromGinContext(ctx)
|
||||||
if appCtx != nil {
|
if appCtx != nil {
|
||||||
@ -628,14 +826,17 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
|
|||||||
approvals := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(letter.Approvals))
|
approvals := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(letter.Approvals))
|
||||||
for _, approval := range letter.Approvals {
|
for _, approval := range letter.Approvals {
|
||||||
approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{
|
approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{
|
||||||
ID: approval.ID,
|
ID: approval.ID,
|
||||||
LetterID: approval.LetterID,
|
LetterID: approval.LetterID,
|
||||||
StepID: approval.StepID,
|
StepID: approval.StepID,
|
||||||
ApproverID: approval.ApproverID,
|
StepOrder: approval.StepOrder,
|
||||||
Status: string(approval.Status),
|
ParallelGroup: approval.ParallelGroup,
|
||||||
Remarks: approval.Remarks,
|
IsRequired: approval.IsRequired,
|
||||||
ActedAt: approval.ActedAt,
|
ApproverID: approval.ApproverID,
|
||||||
CreatedAt: approval.CreatedAt,
|
Status: string(approval.Status),
|
||||||
|
Remarks: approval.Remarks,
|
||||||
|
ActedAt: approval.ActedAt,
|
||||||
|
CreatedAt: approval.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add step details if available
|
// Add step details if available
|
||||||
@ -916,17 +1117,15 @@ func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.Outgoi
|
|||||||
resp.Approvals = make([]contract.OutgoingLetterApprovalResponse, len(letter.Approvals))
|
resp.Approvals = make([]contract.OutgoingLetterApprovalResponse, len(letter.Approvals))
|
||||||
for i, approval := range letter.Approvals {
|
for i, approval := range letter.Approvals {
|
||||||
approvalResp := contract.OutgoingLetterApprovalResponse{
|
approvalResp := contract.OutgoingLetterApprovalResponse{
|
||||||
ID: approval.ID,
|
ID: approval.ID,
|
||||||
ApproverID: approval.ApproverID,
|
StepOrder: approval.StepOrder,
|
||||||
Status: string(approval.Status),
|
ParallelGroup: approval.ParallelGroup,
|
||||||
Remarks: approval.Remarks,
|
IsRequired: approval.IsRequired,
|
||||||
ActedAt: approval.ActedAt,
|
ApproverID: approval.ApproverID,
|
||||||
CreatedAt: approval.CreatedAt,
|
Status: string(approval.Status),
|
||||||
}
|
Remarks: approval.Remarks,
|
||||||
|
ActedAt: approval.ActedAt,
|
||||||
// Include step order if step is loaded
|
CreatedAt: approval.CreatedAt,
|
||||||
if approval.Step != nil {
|
|
||||||
approvalResp.StepOrder = approval.Step.StepOrder
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.Approvals[i] = approvalResp
|
resp.Approvals[i] = approvalResp
|
||||||
@ -945,3 +1144,220 @@ func transformDiscussionToResponse(discussion *entities.LetterOutgoingDiscussion
|
|||||||
UpdatedAt: discussion.UpdatedAt,
|
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,
|
Mode: req.Mode,
|
||||||
User: &contract.OnlyOfficeUserConfig{
|
User: &contract.OnlyOfficeUserConfig{
|
||||||
ID: userCtx.UserID.String(),
|
ID: userCtx.UserID.String(),
|
||||||
Name: fmt.Sprintf("User-%s", userCtx.UserID.String()[:8]),
|
Name: userCtx.UserName,
|
||||||
},
|
},
|
||||||
Customization: &contract.OnlyOfficeCustomization{
|
Customization: &contract.OnlyOfficeCustomization{
|
||||||
Autosave: true,
|
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