Compare commits

..

3 Commits

Author SHA1 Message Date
8816e4addc Merge branch 'feature/expense-revisi' into feature/exclusive-summary 2026-06-17 18:36:46 +07:00
0db838e2c4 Fix bank 2026-06-17 18:26:40 +07:00
4b6cbb69c1 Add exclusive-summary 2026-06-17 18:17:08 +07:00
7 changed files with 205 additions and 166 deletions

View File

@ -218,9 +218,10 @@ type ExclusiveSummaryDailyTransaction struct {
} }
type ExclusiveSummaryBankBalance struct { type ExclusiveSummaryBankBalance struct {
Bank string Bank string
AccountType string OpeningBalance *float64
OpeningBalance float64 IncomingMutation *float64
ClosingBalance float64 OutgoingMutation *float64
Description *string ClosingBalance *float64
Notes *string
} }

View File

@ -9,8 +9,6 @@ import (
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository" "apskel-pos-be/internal/repository"
"github.com/google/uuid"
) )
type AnalyticsProcessor interface { type AnalyticsProcessor interface {
@ -679,14 +677,8 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
return nil, err return nil, err
} }
bankBalance, err := p.buildExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID) periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0)
if err != nil { for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) {
return nil, err
}
buckets := buildExclusiveSummaryMonthlyBuckets(monthStart)
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0, len(buckets))
for _, bucket := range buckets {
period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
@ -708,6 +700,23 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
}) })
} }
bankBalances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err)
}
bankBalance := make([]models.ExclusiveSummaryBankBalance, len(bankBalances))
for i, item := range bankBalances {
bankBalance[i] = models.ExclusiveSummaryBankBalance{
Bank: item.Bank,
OpeningBalance: item.OpeningBalance,
IncomingMutation: item.IncomingMutation,
OutgoingMutation: item.OutgoingMutation,
ClosingBalance: item.ClosingBalance,
Notes: item.Notes,
}
}
return &models.ExclusiveSummaryMonthlyResponse{ return &models.ExclusiveSummaryMonthlyResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
@ -726,34 +735,6 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
}, nil }, nil
} }
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]models.ExclusiveSummaryBankBalance, error) {
balances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, organizationID, outletID)
if err != nil {
return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err)
}
result := make([]models.ExclusiveSummaryBankBalance, len(balances))
for i, balance := range balances {
openingBalance := balance.OpeningBalance
closingBalance := balance.ClosingBalance
notes := strings.TrimSpace(balance.AccountType)
if balance.Description != nil && strings.TrimSpace(*balance.Description) != "" {
notes = strings.TrimSpace(*balance.Description)
}
result[i] = models.ExclusiveSummaryBankBalance{
Bank: balance.Bank,
OpeningBalance: &openingBalance,
ClosingBalance: &closingBalance,
}
if notes != "" {
result[i].Notes = &notes
}
}
return result, nil
}
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { 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) result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
if err != nil { if err != nil {

View File

@ -13,11 +13,12 @@ import (
) )
type analyticsRepositoryStub struct { type analyticsRepositoryStub struct {
purchasingResult *entities.PurchasingAnalytics purchasingResult *entities.PurchasingAnalytics
profitLossResult *entities.ProfitLossAnalytics profitLossResult *entities.ProfitLossAnalytics
exclusiveResult *entities.ExclusiveSummaryAnalytics exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
bankBalances []entities.ExclusiveSummaryBankBalance bankBalances []entities.ExclusiveSummaryBankBalance
profitLossGroup string profitLossGroup string
exclusiveSummaryCalls int
} }
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
@ -49,11 +50,17 @@ func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uui
return s.profitLossResult, nil 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, time.Time, time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
return s.exclusiveResult, nil if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) {
result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls]
s.exclusiveSummaryCalls++
return result, nil
}
s.exclusiveSummaryCalls++
return &entities.ExclusiveSummaryAnalytics{}, nil
} }
func (s analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Context, uuid.UUID, *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) { func (s *analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Context, uuid.UUID, *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
return s.bankBalances, nil return s.bankBalances, nil
} }
@ -81,7 +88,7 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
outletID := uuid.New() outletID := uuid.New()
outletName := "Main Outlet" outletName := "Main Outlet"
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
purchasingResult: &entities.PurchasingAnalytics{ purchasingResult: &entities.PurchasingAnalytics{
OutletName: &outletName, OutletName: &outletName,
Summary: entities.PurchasingSummary{ Summary: entities.PurchasingSummary{
@ -134,7 +141,7 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *
productID := uuid.New() productID := uuid.New()
categoryID := uuid.New() categoryID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
profitLossResult: &entities.ProfitLossAnalytics{ profitLossResult: &entities.ProfitLossAnalytics{
Summary: entities.ProfitLossSummary{ Summary: entities.ProfitLossSummary{
TotalRevenue: 1000, TotalRevenue: 1000,
@ -206,7 +213,7 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *
func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) { func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) {
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
profitLossResult: &entities.ProfitLossAnalytics{ profitLossResult: &entities.ProfitLossAnalytics{
Summary: entities.ProfitLossSummary{ Summary: entities.ProfitLossSummary{
TotalRevenue: 10000, TotalRevenue: 10000,
@ -284,22 +291,28 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes
require.True(t, result.MainSummary[6].IsBold) require.True(t, result.MainSummary[6].IsBold)
} }
func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburse(t *testing.T) { func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse(t *testing.T) {
now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
exclusiveResult: &entities.ExclusiveSummaryAnalytics{ exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
SalesTotal: 35619000, {
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ SalesTotal: 1000,
{CategoryCode: "hpp_nusantara", CategoryName: "Nusantara", Amount: 19010552}, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
}, {CategoryCode: "RAW", CategoryName: "Raw", Amount: 400},
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ },
{CategoryCode: "biaya_gaji_staff", CategoryName: "Gaji Staff", Amount: 48203333}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
{CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Amount: 3555000}, {CategoryCode: "GAJI", CategoryName: "Gaji", Amount: 250},
{CategoryCode: "biaya_lain", CategoryName: "Biaya Lain-lain", Amount: 1608605}, {CategoryCode: "OPS", CategoryName: "Operasional", Amount: 100},
}, },
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{ DailySummary: []entities.ExclusiveSummaryDailySummary{
{Date: now, CategoryCode: "biaya_gaji_staff", CategoryName: "Gaji Staff", Description: "gaji kary", Amount: 48203333, Source: "purchase_order"}, {Date: now, TransactionCount: 3, TotalCost: 750},
{Date: now, CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Description: "DW", Amount: 3555000, Source: "purchase_order"}, },
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
{Date: now, CategoryCode: "RAW", CategoryName: "Raw", Description: "beras", Amount: 400, Source: "purchase_order"},
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "gaji karyawan", Amount: 200, Source: "purchase_order"},
{Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "DW", Amount: 50, Source: "purchase_order"},
{Date: now, CategoryCode: "OPS", CategoryName: "Operasional", Description: "atk", Amount: 100, Source: "purchase_order"},
},
}, },
}, },
}, expenseRepositoryStub{}) }, expenseRepositoryStub{})
@ -313,38 +326,45 @@ func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburs
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, result) require.NotNil(t, result)
require.Equal(t, float64(35619000), result.Summary.Sales) require.Equal(t, float64(1000), result.Summary.Sales)
require.Equal(t, float64(19010552), result.Summary.HPP) require.Equal(t, float64(400), result.Summary.HPP)
require.Equal(t, float64(16608448), result.Summary.GrossProfit) require.Equal(t, float64(600), result.Summary.GrossProfit)
require.Equal(t, float64(51758333), result.Summary.SalaryTotal) require.Equal(t, float64(350), result.Summary.OperationalExpensesTotal)
require.Equal(t, float64(3555000), result.Summary.SalaryDW) require.Equal(t, float64(750), result.Summary.TotalCost)
require.Equal(t, float64(48203333), result.Summary.SalaryStaff) require.Equal(t, float64(250), result.Summary.NetProfit)
require.Equal(t, float64(53366938), result.Summary.OperationalExpensesTotal) require.Equal(t, float64(250), result.Summary.SalaryTotal)
require.Equal(t, float64(72377490), result.Summary.TotalCost) require.Equal(t, float64(50), result.Summary.SalaryDW)
require.Equal(t, float64(-36758490), result.Summary.NetProfit) require.Equal(t, float64(200), result.Summary.SalaryStaff)
require.Equal(t, float64(48203333), result.Reimburse.ExcludedSalaryStaff) require.Equal(t, float64(100), result.Summary.OtherOperationalExpenses)
require.Equal(t, float64(24174157), result.Reimburse.TotalReimburse) require.Equal(t, float64(200), result.Reimburse.ExcludedSalaryStaff)
require.Equal(t, float64(550), result.Reimburse.TotalReimburse)
require.Len(t, result.HPPBreakdown, 1)
require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage)
require.Len(t, result.DailySummary, 1)
require.Len(t, result.DailyTransactions, 4)
} }
func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBankTemplate(t *testing.T) { func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *testing.T) {
location, err := time.LoadLocation("Asia/Jakarta") location, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err) require.NoError(t, err)
month := time.Date(2026, 5, 1, 0, 0, 0, 0, location) month := time.Date(2026, 5, 1, 0, 0, 0, 0, location)
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ openingBalance := 5000000.0
exclusiveResult: &entities.ExclusiveSummaryAnalytics{ closingBalance := 5000000.0
SalesTotal: 1000, notes := "Main cash account for daily transactions"
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ stub := &analyticsRepositoryStub{
{CategoryCode: "hpp", CategoryName: "HPP", Amount: 400}, exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
}, {SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 400}}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 100}}},
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ {SalesTotal: 100, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 40}}},
{CategoryCode: "ops", CategoryName: "OPS", Amount: 100}, {SalesTotal: 200, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 80}}},
}, {SalesTotal: 300, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 120}}},
{SalesTotal: 400, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 160}}},
{SalesTotal: 500, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 200}}},
}, },
bankBalances: []entities.ExclusiveSummaryBankBalance{ bankBalances: []entities.ExclusiveSummaryBankBalance{
{Bank: "Rekening Bank", AccountType: "wallet", OpeningBalance: 1000, ClosingBalance: 2500}, {Bank: "Cash and Bank", OpeningBalance: &openingBalance, ClosingBalance: &closingBalance, Notes: &notes},
{Bank: "Kas Utama", AccountType: "cash", OpeningBalance: 3000, ClosingBalance: 3500},
}, },
}, expenseRepositoryStub{}) }
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{ result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
OrganizationID: uuid.New(), OrganizationID: uuid.New(),
@ -355,19 +375,21 @@ func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBan
require.NotNil(t, result) require.NotNil(t, result)
require.Equal(t, "2026-05", result.Month) require.Equal(t, "2026-05", result.Month)
require.Equal(t, float64(1000), result.Summary.TotalSales) require.Equal(t, float64(1000), result.Summary.TotalSales)
require.Equal(t, float64(400), result.Summary.HPP)
require.Equal(t, float64(500), result.Summary.NetProfit) require.Equal(t, float64(500), result.Summary.NetProfit)
require.InDelta(t, float64(50), result.Summary.NetProfitMargin, 0.0001)
require.Len(t, result.Periods, 5) require.Len(t, result.Periods, 5)
require.Equal(t, "1 - 3 Mei", result.Periods[0].Label) require.Equal(t, "1 - 3 Mei", result.Periods[0].Label)
require.Equal(t, "25 - 31 Mei", result.Periods[4].Label) require.Equal(t, "25 - 31 Mei", result.Periods[4].Label)
require.Len(t, result.BankBalance, 2) require.Len(t, result.BankBalance, 1)
require.Equal(t, "Rekening Bank", result.BankBalance[0].Bank) require.Equal(t, "Cash and Bank", result.BankBalance[0].Bank)
require.NotNil(t, result.BankBalance[0].OpeningBalance) require.NotNil(t, result.BankBalance[0].OpeningBalance)
require.Equal(t, float64(1000), *result.BankBalance[0].OpeningBalance) require.Equal(t, openingBalance, *result.BankBalance[0].OpeningBalance)
require.NotNil(t, result.BankBalance[0].ClosingBalance) require.NotNil(t, result.BankBalance[0].ClosingBalance)
require.Equal(t, float64(2500), *result.BankBalance[0].ClosingBalance) require.Equal(t, closingBalance, *result.BankBalance[0].ClosingBalance)
require.NotNil(t, result.BankBalance[0].Notes)
require.Equal(t, "wallet", *result.BankBalance[0].Notes)
require.Nil(t, result.BankBalance[0].IncomingMutation) require.Nil(t, result.BankBalance[0].IncomingMutation)
require.Nil(t, result.BankBalance[0].OutgoingMutation) require.Nil(t, result.BankBalance[0].OutgoingMutation)
require.Equal(t, "Kas Utama", result.BankBalance[1].Bank) require.NotNil(t, result.BankBalance[0].Notes)
require.Equal(t, notes, *result.BankBalance[0].Notes)
require.Equal(t, 6, stub.exclusiveSummaryCalls)
} }

View File

@ -760,9 +760,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
Where("po.status = ?", "received"). Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
if outletID != nil { query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
query = query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
}
err := query. err := query.
Group("pc.id, pc.code, pc.name, pc.sort_order"). Group("pc.id, pc.code, pc.name, pc.sort_order").
@ -785,7 +783,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). 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 purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("po.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeNonInventory). Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("po.status = ?", "received"). Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("pc.id, pc.code, pc.name, pc.sort_order"). Group("pc.id, pc.code, pc.name, pc.sort_order").
@ -797,7 +795,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailySummary, error) { func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailySummary, error) {
var results []entities.ExclusiveSummaryDailySummary var results []entities.ExclusiveSummaryDailySummary
rawQuery, args := r.exclusiveSummaryPurchaseOrderItemsQuery(organizationID, outletID, dateFrom, dateTo) rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo)
err := r.db.WithContext(ctx).Raw(` err := r.db.WithContext(ctx).Raw(`
SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost
@ -811,7 +809,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Co
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailyTransaction, error) { func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailyTransaction, error) {
var results []entities.ExclusiveSummaryDailyTransaction var results []entities.ExclusiveSummaryDailyTransaction
rawQuery, args := r.exclusiveSummaryPurchaseOrderItemsQuery(organizationID, outletID, dateFrom, dateTo) rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo)
err := r.db.WithContext(ctx).Raw(` err := r.db.WithContext(ctx).Raw(`
SELECT date, category_code, category_name, description, amount, source SELECT date, category_code, category_name, description, amount, source
@ -822,19 +820,18 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx conte
return results, err return results, err
} }
func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemsQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) { func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) {
outletFilter := ""
args := []interface{}{ args := []interface{}{
organizationID, organizationID,
entities.PurchaseCategoryTypeRawMaterial,
entities.PurchaseCategoryTypeNonInventory,
"received", "received",
dateFrom, dateFrom,
dateTo, dateTo,
} }
outletFilter := ""
if outletID != nil { if outletID != nil {
outletFilter = "AND (pc.type = ? OR i.outlet_id = ? OR u.outlet_id = ?)" outletFilter = "AND (pc.type = ? OR i.outlet_id = ? OR u.outlet_id = ?)"
args = append(args, entities.PurchaseCategoryTypeNonInventory, *outletID, *outletID) args = append(args, entities.PurchaseCategoryTypeExpense, *outletID, *outletID)
} }
query := ` query := `
@ -851,7 +848,6 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemsQuery(organi
LEFT JOIN ingredients i ON poi.ingredient_id = i.id LEFT JOIN ingredients i ON poi.ingredient_id = i.id
LEFT JOIN units u ON poi.unit_id = u.id LEFT JOIN units u ON poi.unit_id = u.id
WHERE po.organization_id = ? WHERE po.organization_id = ?
AND pc.type IN (?, ?)
AND po.status = ? AND po.status = ?
AND po.transaction_date >= ? AND po.transaction_date <= ? AND po.transaction_date >= ? AND po.transaction_date <= ?
` + outletFilter + ` ` + outletFilter + `
@ -861,28 +857,45 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemsQuery(organi
} }
func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) { func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
var results []entities.ExclusiveSummaryBankBalance type accountBalance struct {
Name string
OpeningBalance float64
CurrentBalance float64
Description *string
}
var accounts []accountBalance
query := r.db.WithContext(ctx). query := r.db.WithContext(ctx).
Table("accounts"). Table("accounts").
Select(` Select("name, opening_balance, current_balance, description").
name as bank,
account_type,
opening_balance,
current_balance as closing_balance,
description
`).
Where("organization_id = ?", organizationID). Where("organization_id = ?", organizationID).
Where("is_active = ?", true). Where("account_type IN ?", []entities.AccountType{entities.AccountTypeCash, entities.AccountTypeWallet, entities.AccountTypeBank}).
Where("account_type IN ?", []entities.AccountType{entities.AccountTypeBank, entities.AccountTypeWallet, entities.AccountTypeCash}) Where("is_active = ?", true)
if outletID != nil { if outletID != nil {
query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID) query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID)
} else {
query = query.Where("outlet_id IS NULL")
} }
err := query. err := query.
Order("CASE account_type WHEN 'bank' THEN 1 WHEN 'wallet' THEN 2 WHEN 'cash' THEN 3 ELSE 4 END, number ASC, name ASC"). Order("number ASC, name ASC").
Scan(&results).Error Scan(&accounts).Error
if err != nil {
return nil, err
}
return results, err balances := make([]entities.ExclusiveSummaryBankBalance, len(accounts))
for i, account := range accounts {
openingBalance := account.OpeningBalance
closingBalance := account.CurrentBalance
balances[i] = entities.ExclusiveSummaryBankBalance{
Bank: account.Name,
OpeningBalance: &openingBalance,
ClosingBalance: &closingBalance,
Notes: account.Description,
}
}
return balances, nil
} }

View File

@ -198,3 +198,59 @@ func TestAnalyticsServiceGetProfitLossAnalyticsAllowsEmptyGroupBy(t *testing.T)
require.NoError(t, err) require.NoError(t, err)
require.Nil(t, resp) require.Nil(t, resp)
} }
func TestAnalyticsServiceGetExclusiveSummaryPeriodValidation(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
req *models.ExclusiveSummaryPeriodRequest
wantErr string
}{
{
name: "nil request",
req: nil,
wantErr: "request cannot be nil",
},
{
name: "missing organization",
req: &models.ExclusiveSummaryPeriodRequest{
DateFrom: now,
DateTo: now,
},
wantErr: "organization_id is required",
},
{
name: "reversed dates",
req: &models.ExclusiveSummaryPeriodRequest{
OrganizationID: uuid.New(),
DateFrom: now.AddDate(0, 0, 1),
DateTo: now,
},
wantErr: "date_from cannot be after date_to",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := service.GetExclusiveSummaryPeriod(context.Background(), tt.req)
require.Nil(t, resp)
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestAnalyticsServiceGetExclusiveSummaryMonthlyValidation(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
resp, err := service.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
OrganizationID: uuid.New(),
})
require.Nil(t, resp)
require.Error(t, err)
require.Contains(t, err.Error(), "month is required")
}

View File

@ -753,18 +753,18 @@ func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error
return nil, nil return nil, nil
} }
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return nil, err
}
location, err := time.LoadLocation("Asia/Jakarta") location, err := time.LoadLocation("Asia/Jakarta")
if err != nil { if err != nil {
return nil, err return nil, err
} }
date, err := time.ParseInLocation("2006-01-02", dateStr, location)
if err != nil {
return nil, err
}
if endOfDay { if endOfDay {
result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), location) result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, location)
return &result, nil return &result, nil
} }

View File

@ -183,7 +183,7 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) {
require.Equal(t, "total_omset", result.MainSummary[0].ID) require.Equal(t, "total_omset", result.MainSummary[0].ID)
} }
func TestExclusiveSummaryPeriodContractToModelParsesISODateRange(t *testing.T) { func TestExclusiveSummaryPeriodContractToModelParsesFlexibleDates(t *testing.T) {
orgID := uuid.New() orgID := uuid.New()
outletID := uuid.New().String() outletID := uuid.New().String()
@ -209,50 +209,16 @@ func TestExclusiveSummaryPeriodContractToModelParsesISODateRange(t *testing.T) {
func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) { func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) {
orgID := uuid.New() orgID := uuid.New()
outletID := uuid.New().String()
result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{ result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{
OrganizationID: orgID, OrganizationID: orgID,
OutletID: &outletID,
Month: "2026-05", Month: "2026-05",
}) })
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, orgID, result.OrganizationID) require.Equal(t, orgID, result.OrganizationID)
require.NotNil(t, result.OutletID)
require.Equal(t, outletID, result.OutletID.String())
location, err := time.LoadLocation("Asia/Jakarta") location, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month) require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month)
} }
func TestExclusiveSummaryPeriodModelToContractCopiesBreakdowns(t *testing.T) {
dateFrom := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
dateTo := time.Date(2026, 5, 31, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
result := ExclusiveSummaryPeriodModelToContract(&models.ExclusiveSummaryPeriodResponse{
OrganizationID: uuid.New(),
Period: models.ExclusiveSummaryPeriodRange{
DateFrom: dateFrom,
DateTo: dateTo,
},
Summary: models.ExclusiveSummaryPeriodSummary{
Sales: 1000,
HPP: 400,
TotalCost: 550,
NetProfit: 450,
SalaryStaff: 100,
},
HPPBreakdown: []models.ExclusiveSummaryCategoryBreakdown{
{CategoryCode: "hpp", CategoryName: "HPP", Amount: 400, Percentage: 100},
},
})
require.NotNil(t, result)
require.Equal(t, dateFrom, result.Period.DateFrom)
require.Equal(t, dateTo, result.Period.DateTo)
require.Equal(t, float64(1000), result.Summary.Sales)
require.Len(t, result.HPPBreakdown, 1)
require.Equal(t, "hpp", result.HPPBreakdown[0].CategoryCode)
}