diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 15de705..04e2d61 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -339,6 +339,13 @@ type ExclusiveSummaryMonthlyRequest struct { Month string `form:"month" validate:"required"` } +type ExclusiveSummaryMTDRequest struct { + OrganizationID uuid.UUID + OutletID *string `form:"outlet_id,omitempty"` + DateTo string `form:"date_to" validate:"required"` + ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"` +} + type ExclusiveSummaryPeriodResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` diff --git a/internal/handler/analytics_handler.go b/internal/handler/analytics_handler.go index a1db87d..438f932 100644 --- a/internal/handler/analytics_handler.go +++ b/internal/handler/analytics_handler.go @@ -266,3 +266,31 @@ func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) { contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response) util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly") } + +func (h *AnalyticsHandler) GetExclusiveSummaryMTD(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.ExclusiveSummaryMTDRequest + if err := c.ShouldBindQuery(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD") + return + } + + req.OrganizationID = contextInfo.OrganizationID + req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) + modelReq, err := transformer.ExclusiveSummaryMTDContractToModel(&req) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD") + return + } + + response, err := h.analyticsService.GetExclusiveSummaryMTD(ctx, modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD") + return + } + + contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMTD") +} diff --git a/internal/models/analytics.go b/internal/models/analytics.go index e5d3a0f..fe51e64 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -349,6 +349,13 @@ type ExclusiveSummaryMonthlyRequest struct { Month time.Time `validate:"required"` } +type ExclusiveSummaryMTDRequest struct { + OrganizationID uuid.UUID `validate:"required"` + OutletID *uuid.UUID `validate:"omitempty"` + DateTo time.Time `validate:"required"` + ExcludeGajiStaffFromReimburse bool `validate:"omitempty"` +} + type ExclusiveSummaryPeriodResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 880555f..728f06a 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -21,6 +21,7 @@ type AnalyticsProcessor interface { GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) + GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) } type AnalyticsProcessorImpl struct { @@ -735,6 +736,18 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, }, nil } +func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + mtdStart := time.Date(req.DateTo.Year(), req.DateTo.Month(), 1, 0, 0, 0, 0, req.DateTo.Location()) + + return p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + DateFrom: mtdStart, + DateTo: req.DateTo, + ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse, + }) +} + func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) if err != nil { diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 8a4d9c0..52b5c9e 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -19,6 +19,8 @@ type analyticsRepositoryStub struct { bankBalances []entities.ExclusiveSummaryBankBalance profitLossGroup string exclusiveSummaryCalls int + exclusiveSummaryFrom []time.Time + exclusiveSummaryTo []time.Time } func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { @@ -50,7 +52,9 @@ func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uui return s.profitLossResult, nil } -func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ExclusiveSummaryAnalytics, error) { +func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) { + s.exclusiveSummaryFrom = append(s.exclusiveSummaryFrom, dateFrom) + s.exclusiveSummaryTo = append(s.exclusiveSummaryTo, dateTo) if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) { result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls] s.exclusiveSummaryCalls++ @@ -393,3 +397,52 @@ func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t * require.Equal(t, notes, *result.BankBalance[0].Notes) require.Equal(t, 6, stub.exclusiveSummaryCalls) } + +func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *testing.T) { + location, err := time.LoadLocation("Asia/Jakarta") + require.NoError(t, err) + dateTo := time.Date(2026, 6, 18, 23, 59, 59, int(time.Second-time.Nanosecond), location) + stub := &analyticsRepositoryStub{ + exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{ + { + SalesTotal: 1000, + HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "RAW", CategoryName: "Raw Material", Amount: 400}, + }, + OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "OPS", CategoryName: "Operational", Amount: 100}, + }, + DailySummary: []entities.ExclusiveSummaryDailySummary{ + {Date: dateTo, TransactionCount: 2, TotalCost: 500}, + }, + DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{ + {Date: dateTo, CategoryCode: "RAW", CategoryName: "Raw Material", Description: "beras", Amount: 400, Source: "purchase_order"}, + {Date: dateTo, CategoryCode: "OPS", CategoryName: "Operational", Description: "atk", Amount: 100, Source: "expense"}, + }, + }, + }, + } + processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{}) + + result, err := processor.GetExclusiveSummaryMTD(context.Background(), &models.ExclusiveSummaryMTDRequest{ + OrganizationID: uuid.New(), + DateTo: dateTo, + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, stub.exclusiveSummaryFrom, 1) + require.Equal(t, time.Date(2026, 6, 1, 0, 0, 0, 0, location), stub.exclusiveSummaryFrom[0]) + require.Equal(t, dateTo, stub.exclusiveSummaryTo[0]) + require.Equal(t, stub.exclusiveSummaryFrom[0], result.Period.DateFrom) + require.Equal(t, dateTo, result.Period.DateTo) + require.Equal(t, float64(1000), result.Summary.Sales) + require.Equal(t, float64(400), result.Summary.HPP) + require.Equal(t, float64(500), result.Summary.TotalCost) + require.Equal(t, float64(500), result.Summary.NetProfit) + require.Len(t, result.HPPBreakdown, 1) + require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage) + require.Len(t, result.OperationalExpenseBreakdown, 1) + require.Len(t, result.DailySummary, 1) + require.Len(t, result.DailyTransactions, 2) +} diff --git a/internal/router/router.go b/internal/router/router.go index 35aec41..415ef04 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -339,6 +339,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics) analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod) analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly) + analytics.GET("/exclusive-summary/mtd", r.analyticsHandler.GetExclusiveSummaryMTD) } tables := protected.Group("/tables") diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index b9dc137..740aaf0 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -20,6 +20,7 @@ type AnalyticsService interface { GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) + GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) } type AnalyticsServiceImpl struct { @@ -349,6 +350,19 @@ func (s *AnalyticsServiceImpl) GetExclusiveSummaryMonthly(ctx context.Context, r return response, nil } +func (s *AnalyticsServiceImpl) GetExclusiveSummaryMTD(ctx context.Context, req *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + if err := s.validateExclusiveSummaryMTDRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + response, err := s.analyticsProcessor.GetExclusiveSummaryMTD(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get exclusive summary mtd: %w", err) + } + + return response, nil +} + func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) error { if req == nil { return fmt.Errorf("request cannot be nil") @@ -373,6 +387,22 @@ func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models return nil } +func (s *AnalyticsServiceImpl) validateExclusiveSummaryMTDRequest(req *models.ExclusiveSummaryMTDRequest) error { + if req == nil { + return fmt.Errorf("request cannot be nil") + } + + if req.OrganizationID == uuid.Nil { + return fmt.Errorf("organization_id is required") + } + + if req.DateTo.IsZero() { + return fmt.Errorf("date_to is required") + } + + return nil +} + func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) error { if req == nil { return fmt.Errorf("request cannot be nil") diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index d1aab7d..fa4db25 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -49,6 +49,10 @@ func (analyticsProcessorStub) GetExclusiveSummaryMonthly(context.Context, *model return &models.ExclusiveSummaryMonthlyResponse{}, nil } +func (analyticsProcessorStub) GetExclusiveSummaryMTD(context.Context, *models.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + return &models.ExclusiveSummaryPeriodResponse{}, nil +} + func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) { service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) @@ -254,3 +258,44 @@ func TestAnalyticsServiceGetExclusiveSummaryMonthlyValidation(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "month is required") } + +func TestAnalyticsServiceGetExclusiveSummaryMTDValidation(t *testing.T) { + service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) + now := time.Date(2026, 6, 18, 23, 59, 59, 0, time.UTC) + + tests := []struct { + name string + req *models.ExclusiveSummaryMTDRequest + wantErr string + }{ + { + name: "nil request", + req: nil, + wantErr: "request cannot be nil", + }, + { + name: "missing organization", + req: &models.ExclusiveSummaryMTDRequest{ + DateTo: now, + }, + wantErr: "organization_id is required", + }, + { + name: "missing date_to", + req: &models.ExclusiveSummaryMTDRequest{ + OrganizationID: uuid.New(), + }, + wantErr: "date_to is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := service.GetExclusiveSummaryMTD(context.Background(), tt.req) + + require.Nil(t, resp) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 9cd5dcd..03deab4 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -604,6 +604,27 @@ func ExclusiveSummaryMonthlyContractToModel(req *contract.ExclusiveSummaryMonthl }, nil } +func ExclusiveSummaryMTDContractToModel(req *contract.ExclusiveSummaryMTDRequest) (*models.ExclusiveSummaryMTDRequest, error) { + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + + dateTo, err := parseFlexibleDateToJakartaTime(req.DateTo, true) + if err != nil { + return nil, fmt.Errorf("invalid date_to: %w", err) + } + if dateTo == nil { + return nil, fmt.Errorf("date_to is required") + } + + return &models.ExclusiveSummaryMTDRequest{ + OrganizationID: req.OrganizationID, + OutletID: parseOutletID(req.OutletID), + DateTo: *dateTo, + ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse, + }, nil +} + func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse { if resp == nil { return nil @@ -772,6 +793,22 @@ func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error return &result, nil } +func parseFlexibleDateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) { + if dateStr == "" { + return nil, nil + } + + fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateStr, dateStr) + if err == nil { + if endOfDay { + return toTime, nil + } + return fromTime, nil + } + + return parseISODateToJakartaTime(dateStr, endOfDay) +} + func parseMonthToJakartaTime(month string) (time.Time, error) { location, err := time.LoadLocation("Asia/Jakarta") if err != nil {