diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 786a90d..8e3c19d 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -324,3 +324,114 @@ type OperationalExpenseItem struct { Item string `json:"item"` Nominal float64 `json:"nominal"` } + +type ExclusiveSummaryPeriodRequest struct { + OrganizationID uuid.UUID + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"` +} + +type ExclusiveSummaryMonthlyRequest struct { + OrganizationID uuid.UUID + OutletID *string `form:"outlet_id,omitempty"` + Month string `form:"month" validate:"required"` +} + +type ExclusiveSummaryPeriodResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Period ExclusiveSummaryPeriodRange `json:"period"` + Summary ExclusiveSummaryPeriodSummary `json:"summary"` + Reimburse ExclusiveSummaryReimburse `json:"reimburse"` + HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"` + OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"` + DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"` + DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"` +} + +type ExclusiveSummaryPeriodRange struct { + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` +} + +type ExclusiveSummaryPeriodSummary struct { + Sales float64 `json:"sales"` + HPP float64 `json:"hpp"` + GrossProfit float64 `json:"gross_profit"` + SalaryTotal float64 `json:"salary_total"` + SalaryDW float64 `json:"salary_dw"` + SalaryStaff float64 `json:"salary_staff"` + SalaryOther float64 `json:"salary_other"` + OtherOperationalExpenses float64 `json:"other_operational_expenses"` + OperationalExpensesTotal float64 `json:"operational_expenses_total"` + TotalCost float64 `json:"total_cost"` + NetProfit float64 `json:"net_profit"` +} + +type ExclusiveSummaryReimburse struct { + TotalCost float64 `json:"total_cost"` + ExcludedSalaryStaff float64 `json:"excluded_salary_staff"` + TotalReimburse float64 `json:"total_reimburse"` +} + +type ExclusiveSummaryCategoryBreakdown struct { + CategoryCode string `json:"category_code"` + CategoryName string `json:"category_name"` + Amount float64 `json:"amount"` + Percentage float64 `json:"percentage"` +} + +type ExclusiveSummaryDailySummary struct { + Date time.Time `json:"date"` + TransactionCount int64 `json:"transaction_count"` + TotalCost float64 `json:"total_cost"` +} + +type ExclusiveSummaryDailyTransaction struct { + Date time.Time `json:"date"` + CategoryCode string `json:"category_code"` + CategoryName string `json:"category_name"` + Description string `json:"description"` + Amount float64 `json:"amount"` + Source string `json:"source"` +} + +type ExclusiveSummaryMonthlyResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Month string `json:"month"` + Summary ExclusiveSummaryMonthlySummary `json:"summary"` + Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"` + BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"` +} + +type ExclusiveSummaryMonthlySummary struct { + TotalSales float64 `json:"total_sales"` + HPP float64 `json:"hpp"` + GrossProfit float64 `json:"gross_profit"` + OperationalExpensesTotal float64 `json:"operational_expenses_total"` + TotalCost float64 `json:"total_cost"` + NetProfit float64 `json:"net_profit"` + NetProfitMargin float64 `json:"net_profit_margin"` +} + +type ExclusiveSummaryMonthlyPeriod struct { + Label string `json:"label"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + Sales float64 `json:"sales"` + HPP float64 `json:"hpp"` + GrossProfit float64 `json:"gross_profit"` + GrossMargin float64 `json:"gross_margin"` +} + +type ExclusiveSummaryBankBalance struct { + Bank string `json:"bank"` + OpeningBalance *float64 `json:"opening_balance"` + IncomingMutation *float64 `json:"incoming_mutation"` + OutgoingMutation *float64 `json:"outgoing_mutation"` + ClosingBalance *float64 `json:"closing_balance"` + Notes *string `json:"notes"` +} diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 66b37d0..4e4e175 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -186,3 +186,33 @@ type OperationalExpenseItem struct { Item string Amount float64 } + +type ExclusiveSummaryAnalytics struct { + SalesTotal float64 + SalesCount int64 + HPPBreakdown []ExclusiveSummaryCategoryTotal + OperationalExpenseBreakdown []ExclusiveSummaryCategoryTotal + DailySummary []ExclusiveSummaryDailySummary + DailyTransactions []ExclusiveSummaryDailyTransaction +} + +type ExclusiveSummaryCategoryTotal struct { + CategoryCode string + CategoryName string + Amount float64 +} + +type ExclusiveSummaryDailySummary struct { + Date time.Time + TransactionCount int64 + TotalCost float64 +} + +type ExclusiveSummaryDailyTransaction struct { + Date time.Time + CategoryCode string + CategoryName string + Description string + Amount float64 + Source string +} diff --git a/internal/handler/analytics_handler.go b/internal/handler/analytics_handler.go index a8945e0..a1db87d 100644 --- a/internal/handler/analytics_handler.go +++ b/internal/handler/analytics_handler.go @@ -210,3 +210,59 @@ func (h *AnalyticsHandler) GetProfitLossAnalytics(c *gin.Context) { contractResp := transformer.ProfitLossAnalyticsModelToContract(response) util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProfitLossAnalytics") } + +func (h *AnalyticsHandler) GetExclusiveSummaryPeriod(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.ExclusiveSummaryPeriodRequest + if err := c.ShouldBindQuery(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod") + return + } + + req.OrganizationID = contextInfo.OrganizationID + req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) + modelReq, err := transformer.ExclusiveSummaryPeriodContractToModel(&req) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod") + return + } + + response, err := h.analyticsService.GetExclusiveSummaryPeriod(ctx, modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod") + return + } + + contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryPeriod") +} + +func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.ExclusiveSummaryMonthlyRequest + if err := c.ShouldBindQuery(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly") + return + } + + req.OrganizationID = contextInfo.OrganizationID + req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) + modelReq, err := transformer.ExclusiveSummaryMonthlyContractToModel(&req) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly") + return + } + + response, err := h.analyticsService.GetExclusiveSummaryMonthly(ctx, modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly") + return + } + + contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly") +} diff --git a/internal/models/analytics.go b/internal/models/analytics.go index e72e3e0..170c048 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -334,3 +334,114 @@ type OperationalExpenseItem struct { Item string `json:"item"` Nominal float64 `json:"nominal"` } + +type ExclusiveSummaryPeriodRequest struct { + OrganizationID uuid.UUID `validate:"required"` + OutletID *uuid.UUID `validate:"omitempty"` + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` + ExcludeGajiStaffFromReimburse bool `validate:"omitempty"` +} + +type ExclusiveSummaryMonthlyRequest struct { + OrganizationID uuid.UUID `validate:"required"` + OutletID *uuid.UUID `validate:"omitempty"` + Month time.Time `validate:"required"` +} + +type ExclusiveSummaryPeriodResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Period ExclusiveSummaryPeriodRange `json:"period"` + Summary ExclusiveSummaryPeriodSummary `json:"summary"` + Reimburse ExclusiveSummaryReimburse `json:"reimburse"` + HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"` + OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"` + DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"` + DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"` +} + +type ExclusiveSummaryPeriodRange struct { + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` +} + +type ExclusiveSummaryPeriodSummary struct { + Sales float64 `json:"sales"` + HPP float64 `json:"hpp"` + GrossProfit float64 `json:"gross_profit"` + SalaryTotal float64 `json:"salary_total"` + SalaryDW float64 `json:"salary_dw"` + SalaryStaff float64 `json:"salary_staff"` + SalaryOther float64 `json:"salary_other"` + OtherOperationalExpenses float64 `json:"other_operational_expenses"` + OperationalExpensesTotal float64 `json:"operational_expenses_total"` + TotalCost float64 `json:"total_cost"` + NetProfit float64 `json:"net_profit"` +} + +type ExclusiveSummaryReimburse struct { + TotalCost float64 `json:"total_cost"` + ExcludedSalaryStaff float64 `json:"excluded_salary_staff"` + TotalReimburse float64 `json:"total_reimburse"` +} + +type ExclusiveSummaryCategoryBreakdown struct { + CategoryCode string `json:"category_code"` + CategoryName string `json:"category_name"` + Amount float64 `json:"amount"` + Percentage float64 `json:"percentage"` +} + +type ExclusiveSummaryDailySummary struct { + Date time.Time `json:"date"` + TransactionCount int64 `json:"transaction_count"` + TotalCost float64 `json:"total_cost"` +} + +type ExclusiveSummaryDailyTransaction struct { + Date time.Time `json:"date"` + CategoryCode string `json:"category_code"` + CategoryName string `json:"category_name"` + Description string `json:"description"` + Amount float64 `json:"amount"` + Source string `json:"source"` +} + +type ExclusiveSummaryMonthlyResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Month string `json:"month"` + Summary ExclusiveSummaryMonthlySummary `json:"summary"` + Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"` + BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"` +} + +type ExclusiveSummaryMonthlySummary struct { + TotalSales float64 `json:"total_sales"` + HPP float64 `json:"hpp"` + GrossProfit float64 `json:"gross_profit"` + OperationalExpensesTotal float64 `json:"operational_expenses_total"` + TotalCost float64 `json:"total_cost"` + NetProfit float64 `json:"net_profit"` + NetProfitMargin float64 `json:"net_profit_margin"` +} + +type ExclusiveSummaryMonthlyPeriod struct { + Label string `json:"label"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + Sales float64 `json:"sales"` + HPP float64 `json:"hpp"` + GrossProfit float64 `json:"gross_profit"` + GrossMargin float64 `json:"gross_margin"` +} + +type ExclusiveSummaryBankBalance struct { + Bank string `json:"bank"` + OpeningBalance *float64 `json:"opening_balance"` + IncomingMutation *float64 `json:"incoming_mutation"` + OutgoingMutation *float64 `json:"outgoing_mutation"` + ClosingBalance *float64 `json:"closing_balance"` + Notes *string `json:"notes"` +} diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 895f3b0..928d9eb 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) @@ -18,6 +19,8 @@ type AnalyticsProcessor interface { GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) 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) } type AnalyticsProcessorImpl struct { @@ -651,3 +654,266 @@ func slugify(s string) string { } return string(result) } + +func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + if req.DateFrom.IsZero() { + return nil, fmt.Errorf("date_from is required") + } + + if req.DateTo.IsZero() { + return nil, fmt.Errorf("date_to is required") + } + + if req.DateFrom.After(req.DateTo) { + return nil, fmt.Errorf("date_from cannot be after date_to") + } + + return p.buildExclusiveSummaryPeriod(ctx, req) +} + +func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) { + if req.Month.IsZero() { + return nil, fmt.Errorf("month is required") + } + + monthStart := time.Date(req.Month.Year(), req.Month.Month(), 1, 0, 0, 0, 0, req.Month.Location()) + monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond) + + fullPeriod, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + DateFrom: monthStart, + DateTo: monthEnd, + }) + if err != nil { + return nil, err + } + + buckets := buildExclusiveSummaryMonthlyBuckets(monthStart) + periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0, len(buckets)) + for _, bucket := range buckets { + period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + DateFrom: bucket.DateFrom, + DateTo: bucket.DateTo, + }) + if err != nil { + return nil, err + } + + grossMargin := percentage(period.Summary.GrossProfit, period.Summary.Sales) + periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{ + Label: bucket.Label, + DateFrom: bucket.DateFrom, + DateTo: bucket.DateTo, + Sales: period.Summary.Sales, + HPP: period.Summary.HPP, + GrossProfit: period.Summary.GrossProfit, + GrossMargin: grossMargin, + }) + } + + return &models.ExclusiveSummaryMonthlyResponse{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + Month: monthStart.Format("2006-01"), + Summary: models.ExclusiveSummaryMonthlySummary{ + TotalSales: fullPeriod.Summary.Sales, + HPP: fullPeriod.Summary.HPP, + GrossProfit: fullPeriod.Summary.GrossProfit, + OperationalExpensesTotal: fullPeriod.Summary.OperationalExpensesTotal, + TotalCost: fullPeriod.Summary.TotalCost, + NetProfit: fullPeriod.Summary.NetProfit, + NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales), + }, + Periods: periods, + BankBalance: []models.ExclusiveSummaryBankBalance{ + {Bank: "BCA"}, + {Bank: "BRI"}, + }, + }, nil +} + +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 { + return nil, fmt.Errorf("failed to get exclusive summary analytics: %w", err) + } + + hppBreakdown, hppTotal := exclusiveSummaryCategoryBreakdown(result.HPPBreakdown) + operationalBreakdown, operationalTotal := exclusiveSummaryCategoryBreakdown(result.OperationalExpenseBreakdown) + salaryDW, salaryStaff, salaryOther := exclusiveSummarySalaryBreakdown(result.DailyTransactions) + salaryTotal := salaryDW + salaryStaff + salaryOther + otherOperationalExpenses := operationalTotal - salaryTotal + if otherOperationalExpenses < 0 { + otherOperationalExpenses = 0 + } + + grossProfit := result.SalesTotal - hppTotal + totalCost := hppTotal + operationalTotal + netProfit := result.SalesTotal - totalCost + excludedSalaryStaff := 0.0 + if req.ExcludeGajiStaffFromReimburse { + excludedSalaryStaff = salaryStaff + } + + dailySummary := make([]models.ExclusiveSummaryDailySummary, len(result.DailySummary)) + for i, item := range result.DailySummary { + dailySummary[i] = models.ExclusiveSummaryDailySummary{ + Date: item.Date, + TransactionCount: item.TransactionCount, + TotalCost: item.TotalCost, + } + } + + dailyTransactions := make([]models.ExclusiveSummaryDailyTransaction, len(result.DailyTransactions)) + for i, item := range result.DailyTransactions { + dailyTransactions[i] = models.ExclusiveSummaryDailyTransaction{ + Date: item.Date, + CategoryCode: item.CategoryCode, + CategoryName: item.CategoryName, + Description: item.Description, + Amount: item.Amount, + Source: item.Source, + } + } + + return &models.ExclusiveSummaryPeriodResponse{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + Period: models.ExclusiveSummaryPeriodRange{ + DateFrom: req.DateFrom, + DateTo: req.DateTo, + }, + Summary: models.ExclusiveSummaryPeriodSummary{ + Sales: result.SalesTotal, + HPP: hppTotal, + GrossProfit: grossProfit, + SalaryTotal: salaryTotal, + SalaryDW: salaryDW, + SalaryStaff: salaryStaff, + SalaryOther: salaryOther, + OtherOperationalExpenses: otherOperationalExpenses, + OperationalExpensesTotal: operationalTotal, + TotalCost: totalCost, + NetProfit: netProfit, + }, + Reimburse: models.ExclusiveSummaryReimburse{ + TotalCost: totalCost, + ExcludedSalaryStaff: excludedSalaryStaff, + TotalReimburse: totalCost - excludedSalaryStaff, + }, + HPPBreakdown: hppBreakdown, + OperationalExpenseBreakdown: operationalBreakdown, + DailySummary: dailySummary, + DailyTransactions: dailyTransactions, + }, nil +} + +func exclusiveSummaryCategoryBreakdown(items []entities.ExclusiveSummaryCategoryTotal) ([]models.ExclusiveSummaryCategoryBreakdown, float64) { + var total float64 + for _, item := range items { + total += item.Amount + } + + breakdown := make([]models.ExclusiveSummaryCategoryBreakdown, len(items)) + for i, item := range items { + breakdown[i] = models.ExclusiveSummaryCategoryBreakdown{ + CategoryCode: item.CategoryCode, + CategoryName: item.CategoryName, + Amount: item.Amount, + Percentage: percentage(item.Amount, total), + } + } + + return breakdown, total +} + +func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDailyTransaction) (float64, float64, float64) { + var salaryDW float64 + var salaryStaff float64 + var salaryOther float64 + + for _, transaction := range transactions { + if transaction.Source != "expense" || !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) { + continue + } + + classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description) + switch { + case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"): + salaryStaff += transaction.Amount + case strings.Contains(classification, "dw"): + salaryDW += transaction.Amount + default: + salaryOther += transaction.Amount + } + } + + return salaryDW, salaryStaff, salaryOther +} + +func isExclusiveSummarySalary(parts ...string) bool { + text := strings.ToLower(strings.Join(parts, " ")) + return strings.Contains(text, "gaji") || strings.Contains(text, "salary") +} + +func percentage(numerator, denominator float64) float64 { + if denominator == 0 { + return 0 + } + return (numerator / denominator) * 100 +} + +type exclusiveSummaryMonthlyBucket struct { + Label string + DateFrom time.Time + DateTo time.Time +} + +func buildExclusiveSummaryMonthlyBuckets(monthStart time.Time) []exclusiveSummaryMonthlyBucket { + monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond) + buckets := make([]exclusiveSummaryMonthlyBucket, 0, 6) + currentStart := monthStart + + for !currentStart.After(monthEnd) { + currentEnd := currentStart + for currentEnd.Weekday() != time.Sunday && currentEnd.Day() < monthEnd.Day() { + currentEnd = currentEnd.AddDate(0, 0, 1) + } + + bucketEnd := time.Date(currentEnd.Year(), currentEnd.Month(), currentEnd.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), currentEnd.Location()) + if bucketEnd.After(monthEnd) { + bucketEnd = monthEnd + } + + buckets = append(buckets, exclusiveSummaryMonthlyBucket{ + Label: fmt.Sprintf("%d - %d %s", currentStart.Day(), bucketEnd.Day(), indonesianMonthName(currentStart.Month())), + DateFrom: currentStart, + DateTo: bucketEnd, + }) + + currentStart = time.Date(bucketEnd.Year(), bucketEnd.Month(), bucketEnd.Day(), 0, 0, 0, 0, bucketEnd.Location()).AddDate(0, 0, 1) + } + + return buckets +} + +func indonesianMonthName(month time.Month) string { + names := map[time.Month]string{ + time.January: "Januari", + time.February: "Februari", + time.March: "Maret", + time.April: "April", + time.May: "Mei", + time.June: "Juni", + time.July: "Juli", + time.August: "Agustus", + time.September: "September", + time.October: "Oktober", + time.November: "November", + time.December: "Desember", + } + return names[month] +} diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index b50c462..4cdd910 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -15,6 +15,7 @@ import ( type analyticsRepositoryStub struct { purchasingResult *entities.PurchasingAnalytics profitLossResult *entities.ProfitLossAnalytics + exclusiveResult *entities.ExclusiveSummaryAnalytics profitLossGroup string } @@ -47,6 +48,10 @@ 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) { + return s.exclusiveResult, nil +} + type expenseRepositoryStub struct{} func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil } @@ -273,3 +278,78 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal) require.True(t, result.MainSummary[6].IsBold) } + +func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburse(t *testing.T) { + now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ + exclusiveResult: &entities.ExclusiveSummaryAnalytics{ + SalesTotal: 35619000, + HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "hpp_nusantara", CategoryName: "Nusantara", Amount: 19010552}, + }, + OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "biaya_gaji", CategoryName: "Gaji", Amount: 51758333}, + {CategoryCode: "biaya_lain", CategoryName: "Biaya Lain-lain", Amount: 1608605}, + }, + DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{ + {Date: now, CategoryCode: "biaya_gaji", CategoryName: "Gaji", Description: "gaji kary", Amount: 48203333, Source: "expense"}, + {Date: now, CategoryCode: "biaya_gaji", CategoryName: "Gaji", Description: "DW", Amount: 3555000, Source: "expense"}, + }, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetExclusiveSummaryPeriod(context.Background(), &models.ExclusiveSummaryPeriodRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + ExcludeGajiStaffFromReimburse: true, + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, float64(35619000), result.Summary.Sales) + require.Equal(t, float64(19010552), result.Summary.HPP) + require.Equal(t, float64(16608448), result.Summary.GrossProfit) + require.Equal(t, float64(51758333), result.Summary.SalaryTotal) + require.Equal(t, float64(3555000), result.Summary.SalaryDW) + require.Equal(t, float64(48203333), result.Summary.SalaryStaff) + require.Equal(t, float64(53366938), result.Summary.OperationalExpensesTotal) + require.Equal(t, float64(72377490), result.Summary.TotalCost) + require.Equal(t, float64(-36758490), result.Summary.NetProfit) + require.Equal(t, float64(48203333), result.Reimburse.ExcludedSalaryStaff) + require.Equal(t, float64(24174157), result.Reimburse.TotalReimburse) +} + +func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBankTemplate(t *testing.T) { + location, err := time.LoadLocation("Asia/Jakarta") + require.NoError(t, err) + month := time.Date(2026, 5, 1, 0, 0, 0, 0, location) + processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ + exclusiveResult: &entities.ExclusiveSummaryAnalytics{ + SalesTotal: 1000, + HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "hpp", CategoryName: "HPP", Amount: 400}, + }, + OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "ops", CategoryName: "OPS", Amount: 100}, + }, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{ + OrganizationID: uuid.New(), + Month: month, + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "2026-05", result.Month) + require.Equal(t, float64(1000), result.Summary.TotalSales) + require.Equal(t, float64(500), result.Summary.NetProfit) + require.Len(t, result.Periods, 5) + require.Equal(t, "1 - 3 Mei", result.Periods[0].Label) + require.Equal(t, "25 - 31 Mei", result.Periods[4].Label) + require.Len(t, result.BankBalance, 2) + require.Equal(t, "BCA", result.BankBalance[0].Bank) + require.Equal(t, "BRI", result.BankBalance[1].Bank) +} diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 9877230..8f429d2 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -18,6 +18,7 @@ type AnalyticsRepository interface { GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) + GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) } type AnalyticsRepositoryImpl struct { @@ -703,3 +704,210 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context return results, err } + +func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) { + type salesResult struct { + SalesTotal float64 + SalesCount int64 + } + + var sales salesResult + salesQuery := r.db.WithContext(ctx). + Table("orders o"). + Select(` + COALESCE(SUM(o.total_amount), 0) as sales_total, + COUNT(o.id) as sales_count + `). + Where("o.organization_id = ?", organizationID). + Where("o.status = ?", entities.OrderStatusCompleted). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). + Where("o.is_void = false AND o.is_refund = false"). + Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) + salesQuery = r.resolveOutletID(salesQuery, outletID, "o.outlet_id") + if err := salesQuery.Scan(&sales).Error; err != nil { + return nil, err + } + + hppBreakdown, err := r.getExclusiveSummaryHPPBreakdown(ctx, organizationID, outletID, dateFrom, dateTo) + if err != nil { + return nil, err + } + + operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo) + if err != nil { + return nil, err + } + + dailySummary, err := r.getExclusiveSummaryDailySummary(ctx, organizationID, outletID, dateFrom, dateTo) + if err != nil { + return nil, err + } + + dailyTransactions, err := r.getExclusiveSummaryDailyTransactions(ctx, organizationID, outletID, dateFrom, dateTo) + if err != nil { + return nil, err + } + + return &entities.ExclusiveSummaryAnalytics{ + SalesTotal: sales.SalesTotal, + SalesCount: sales.SalesCount, + HPPBreakdown: hppBreakdown, + OperationalExpenseBreakdown: operationalExpenseBreakdown, + DailySummary: dailySummary, + DailyTransactions: dailyTransactions, + }, nil +} + +func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) { + var results []entities.ExclusiveSummaryCategoryTotal + + query := r.db.WithContext(ctx). + Table("purchase_order_items poi"). + Select(` + pc.code as category_code, + pc.name as category_name, + COALESCE(SUM(poi.amount), 0) as amount + `). + Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). + Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). + Joins("LEFT JOIN units u ON poi.unit_id = u.id"). + Where("po.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). + Where("po.status = ?", "received"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) + query = r.applyPurchaseOrderItemOutletFilter(query, outletID) + + err := query. + Group("pc.id, pc.code, pc.name, pc.sort_order"). + Order("pc.sort_order ASC, pc.name ASC"). + Scan(&results).Error + + return results, err +} + +func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) { + var results []entities.ExclusiveSummaryCategoryTotal + + query := r.db.WithContext(ctx). + Table("expense_items ei"). + Select(` + pc.code as category_code, + pc.name as category_name, + COALESCE(SUM(ei.amount), 0) as amount + `). + Joins("JOIN expenses e ON ei.expense_id = e.id"). + Joins("JOIN purchase_categories pc ON ei.purchase_category_id = pc.id"). + Where("e.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeExpense). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) + + if outletID != nil { + query = query.Where("e.outlet_id = ?", *outletID) + } + + err := query. + Group("pc.id, pc.code, pc.name, pc.sort_order"). + Order("pc.sort_order ASC, pc.name ASC"). + Scan(&results).Error + + return results, err +} + +func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailySummary, error) { + var results []entities.ExclusiveSummaryDailySummary + rawQuery, args := r.exclusiveSummaryTransactionUnionQuery(organizationID, outletID, dateFrom, dateTo) + + err := r.db.WithContext(ctx).Raw(` + SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost + FROM (`+rawQuery+`) transactions + GROUP BY date + ORDER BY date ASC + `, args...).Scan(&results).Error + + return results, err +} + +func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailyTransaction, error) { + var results []entities.ExclusiveSummaryDailyTransaction + rawQuery, args := r.exclusiveSummaryTransactionUnionQuery(organizationID, outletID, dateFrom, dateTo) + + err := r.db.WithContext(ctx).Raw(` + SELECT date, category_code, category_name, description, amount, source + FROM (`+rawQuery+`) transactions + ORDER BY date ASC, source ASC, category_name ASC, description ASC + `, args...).Scan(&results).Error + + return results, err +} + +func (r *AnalyticsRepositoryImpl) exclusiveSummaryTransactionUnionQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) { + poOutletFilter := "" + expenseOutletFilter := "" + args := []interface{}{ + organizationID, + entities.PurchaseCategoryTypeRawMaterial, + "received", + dateFrom, + dateTo, + } + + if outletID != nil { + poOutletFilter = "AND (i.outlet_id = ? OR u.outlet_id = ?)" + args = append(args, *outletID, *outletID) + } + + args = append(args, + organizationID, + entities.PurchaseCategoryTypeExpense, + "approved", + dateFrom, + dateTo, + ) + + if outletID != nil { + expenseOutletFilter = "AND e.outlet_id = ?" + args = append(args, *outletID) + } + + query := ` + SELECT + DATE(po.transaction_date) as date, + pc.code as category_code, + pc.name as category_name, + COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as description, + poi.amount as amount, + 'purchase_order' as source + FROM purchase_order_items poi + JOIN purchase_orders po ON poi.purchase_order_id = po.id + JOIN purchase_categories pc ON poi.purchase_category_id = pc.id + JOIN ingredients i ON poi.ingredient_id = i.id + LEFT JOIN units u ON poi.unit_id = u.id + WHERE po.organization_id = ? + AND pc.type = ? + AND po.status = ? + AND po.transaction_date >= ? AND po.transaction_date <= ? + ` + poOutletFilter + ` + + UNION ALL + + SELECT + DATE(e.transaction_date) as date, + pc.code as category_code, + pc.name as category_name, + COALESCE(NULLIF(ei.item, ''), NULLIF(ei.description, ''), pc.name) as description, + ei.amount as amount, + 'expense' as source + FROM expense_items ei + JOIN expenses e ON ei.expense_id = e.id + JOIN purchase_categories pc ON ei.purchase_category_id = pc.id + WHERE e.organization_id = ? + AND pc.type = ? + AND e.status = ? + AND e.transaction_date >= ? AND e.transaction_date <= ? + ` + expenseOutletFilter + ` + ` + + return query, args +} diff --git a/internal/router/router.go b/internal/router/router.go index 8db5e7b..35aec41 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -337,6 +337,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory) analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics) analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics) + analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod) + analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly) } tables := protected.Group("/tables") diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 5496ad2..b9dc137 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -18,6 +18,8 @@ type AnalyticsService interface { GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) 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) } type AnalyticsServiceImpl struct { @@ -320,3 +322,69 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr return nil } + +func (s *AnalyticsServiceImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + if err := s.validateExclusiveSummaryPeriodRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + response, err := s.analyticsProcessor.GetExclusiveSummaryPeriod(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get exclusive summary period: %w", err) + } + + return response, nil +} + +func (s *AnalyticsServiceImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) { + if err := s.validateExclusiveSummaryMonthlyRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + response, err := s.analyticsProcessor.GetExclusiveSummaryMonthly(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get exclusive summary monthly: %w", err) + } + + return response, nil +} + +func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) 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.DateFrom.IsZero() { + return fmt.Errorf("date_from is required") + } + + if req.DateTo.IsZero() { + return fmt.Errorf("date_to is required") + } + + if req.DateFrom.After(req.DateTo) { + return fmt.Errorf("date_from cannot be after date_to") + } + + return nil +} + +func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) 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.Month.IsZero() { + return fmt.Errorf("month is required") + } + + return nil +} diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index c43419d..49b42ce 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -41,6 +41,14 @@ func (analyticsProcessorStub) GetProfitLossAnalytics(context.Context, *models.Pr return nil, nil } +func (analyticsProcessorStub) GetExclusiveSummaryPeriod(context.Context, *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + return &models.ExclusiveSummaryPeriodResponse{}, nil +} + +func (analyticsProcessorStub) GetExclusiveSummaryMonthly(context.Context, *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) { + return &models.ExclusiveSummaryMonthlyResponse{}, nil +} + func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) { service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index fa3c42d..9cd5dcd 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -559,3 +559,229 @@ func profitLossSummaryRowModelToContract(row models.ProfitLossSummaryRow) contra SubItems: subItems, } } + +func ExclusiveSummaryPeriodContractToModel(req *contract.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodRequest, error) { + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + + dateFrom, dateTo, err := parseFlexibleDateRangeToJakartaTime(req.DateFrom, req.DateTo) + if err != nil { + return nil, fmt.Errorf("invalid date range: %w", err) + } + + if dateFrom == nil { + return nil, fmt.Errorf("date_from is required") + } + + if dateTo == nil { + return nil, fmt.Errorf("date_to is required") + } + + return &models.ExclusiveSummaryPeriodRequest{ + OrganizationID: req.OrganizationID, + OutletID: parseOutletID(req.OutletID), + DateFrom: *dateFrom, + DateTo: *dateTo, + ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse, + }, nil +} + +func ExclusiveSummaryMonthlyContractToModel(req *contract.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyRequest, error) { + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + + month, err := parseMonthToJakartaTime(req.Month) + if err != nil { + return nil, fmt.Errorf("invalid month: %w", err) + } + + return &models.ExclusiveSummaryMonthlyRequest{ + OrganizationID: req.OrganizationID, + OutletID: parseOutletID(req.OutletID), + Month: month, + }, nil +} + +func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse { + if resp == nil { + return nil + } + + hppBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.HPPBreakdown)) + for i, item := range resp.HPPBreakdown { + hppBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item) + } + + operationalBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.OperationalExpenseBreakdown)) + for i, item := range resp.OperationalExpenseBreakdown { + operationalBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item) + } + + dailySummary := make([]contract.ExclusiveSummaryDailySummary, len(resp.DailySummary)) + for i, item := range resp.DailySummary { + dailySummary[i] = contract.ExclusiveSummaryDailySummary{ + Date: item.Date, + TransactionCount: item.TransactionCount, + TotalCost: item.TotalCost, + } + } + + dailyTransactions := make([]contract.ExclusiveSummaryDailyTransaction, len(resp.DailyTransactions)) + for i, item := range resp.DailyTransactions { + dailyTransactions[i] = contract.ExclusiveSummaryDailyTransaction{ + Date: item.Date, + CategoryCode: item.CategoryCode, + CategoryName: item.CategoryName, + Description: item.Description, + Amount: item.Amount, + Source: item.Source, + } + } + + return &contract.ExclusiveSummaryPeriodResponse{ + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + Period: contract.ExclusiveSummaryPeriodRange{ + DateFrom: resp.Period.DateFrom, + DateTo: resp.Period.DateTo, + }, + Summary: contract.ExclusiveSummaryPeriodSummary{ + Sales: resp.Summary.Sales, + HPP: resp.Summary.HPP, + GrossProfit: resp.Summary.GrossProfit, + SalaryTotal: resp.Summary.SalaryTotal, + SalaryDW: resp.Summary.SalaryDW, + SalaryStaff: resp.Summary.SalaryStaff, + SalaryOther: resp.Summary.SalaryOther, + OtherOperationalExpenses: resp.Summary.OtherOperationalExpenses, + OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal, + TotalCost: resp.Summary.TotalCost, + NetProfit: resp.Summary.NetProfit, + }, + Reimburse: contract.ExclusiveSummaryReimburse{ + TotalCost: resp.Reimburse.TotalCost, + ExcludedSalaryStaff: resp.Reimburse.ExcludedSalaryStaff, + TotalReimburse: resp.Reimburse.TotalReimburse, + }, + HPPBreakdown: hppBreakdown, + OperationalExpenseBreakdown: operationalBreakdown, + DailySummary: dailySummary, + DailyTransactions: dailyTransactions, + } +} + +func ExclusiveSummaryMonthlyModelToContract(resp *models.ExclusiveSummaryMonthlyResponse) *contract.ExclusiveSummaryMonthlyResponse { + if resp == nil { + return nil + } + + periods := make([]contract.ExclusiveSummaryMonthlyPeriod, len(resp.Periods)) + for i, item := range resp.Periods { + periods[i] = contract.ExclusiveSummaryMonthlyPeriod{ + Label: item.Label, + DateFrom: item.DateFrom, + DateTo: item.DateTo, + Sales: item.Sales, + HPP: item.HPP, + GrossProfit: item.GrossProfit, + GrossMargin: item.GrossMargin, + } + } + + bankBalance := make([]contract.ExclusiveSummaryBankBalance, len(resp.BankBalance)) + for i, item := range resp.BankBalance { + bankBalance[i] = contract.ExclusiveSummaryBankBalance{ + Bank: item.Bank, + OpeningBalance: item.OpeningBalance, + IncomingMutation: item.IncomingMutation, + OutgoingMutation: item.OutgoingMutation, + ClosingBalance: item.ClosingBalance, + Notes: item.Notes, + } + } + + return &contract.ExclusiveSummaryMonthlyResponse{ + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + Month: resp.Month, + Summary: contract.ExclusiveSummaryMonthlySummary{ + TotalSales: resp.Summary.TotalSales, + HPP: resp.Summary.HPP, + GrossProfit: resp.Summary.GrossProfit, + OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal, + TotalCost: resp.Summary.TotalCost, + NetProfit: resp.Summary.NetProfit, + NetProfitMargin: resp.Summary.NetProfitMargin, + }, + Periods: periods, + BankBalance: bankBalance, + } +} + +func exclusiveSummaryCategoryBreakdownModelToContract(item models.ExclusiveSummaryCategoryBreakdown) contract.ExclusiveSummaryCategoryBreakdown { + return contract.ExclusiveSummaryCategoryBreakdown{ + CategoryCode: item.CategoryCode, + CategoryName: item.CategoryName, + Amount: item.Amount, + Percentage: item.Percentage, + } +} + +func parseFlexibleDateRangeToJakartaTime(dateFrom, dateTo string) (*time.Time, *time.Time, error) { + fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateFrom, dateTo) + if err == nil { + return fromTime, toTime, nil + } + + fromTime, err = parseISODateToJakartaTime(dateFrom, false) + if err != nil { + return nil, nil, err + } + + toTime, err = parseISODateToJakartaTime(dateTo, true) + if err != nil { + return nil, nil, err + } + + return fromTime, toTime, nil +} + +func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) { + if dateStr == "" { + return nil, nil + } + + date, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return nil, err + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, err + } + + if endOfDay { + result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, location) + return &result, nil + } + + result := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) + return &result, nil +} + +func parseMonthToJakartaTime(month string) (time.Time, error) { + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return time.Time{}, err + } + + parsed, err := time.ParseInLocation("2006-01", month, location) + if err != nil { + return time.Time{}, err + } + + return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, location), nil +} diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 4fca5cf..270945d 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -182,3 +182,42 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) { require.Len(t, result.MainSummary, 1) require.Equal(t, "total_omset", result.MainSummary[0].ID) } + +func TestExclusiveSummaryPeriodContractToModelParsesISODateRange(t *testing.T) { + orgID := uuid.New() + outletID := uuid.New().String() + + result, err := ExclusiveSummaryPeriodContractToModel(&contract.ExclusiveSummaryPeriodRequest{ + OrganizationID: orgID, + OutletID: &outletID, + DateFrom: "2026-05-26", + DateTo: "2026-05-31", + ExcludeGajiStaffFromReimburse: true, + }) + + require.NoError(t, err) + require.Equal(t, orgID, result.OrganizationID) + require.NotNil(t, result.OutletID) + require.Equal(t, outletID, result.OutletID.String()) + require.True(t, result.ExcludeGajiStaffFromReimburse) + + location, err := time.LoadLocation("Asia/Jakarta") + require.NoError(t, err) + require.Equal(t, time.Date(2026, 5, 26, 0, 0, 0, 0, location), result.DateFrom) + require.Equal(t, time.Date(2026, 5, 31, 23, 59, 59, int(time.Second-time.Nanosecond), location), result.DateTo) +} + +func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) { + orgID := uuid.New() + + result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{ + OrganizationID: orgID, + Month: "2026-05", + }) + + require.NoError(t, err) + require.Equal(t, orgID, result.OrganizationID) + location, err := time.LoadLocation("Asia/Jakarta") + require.NoError(t, err) + require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month) +}