From 7c8c7fb7db0eecc3ba6ecfcb6434d31000c5f52b Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 2 Jun 2026 16:31:52 +0700 Subject: [PATCH 01/16] update expense coa --- internal/processor/analytics_processor.go | 122 +++++++++++------- .../processor/analytics_processor_test.go | 76 +++++++++++ 2 files changed, 150 insertions(+), 48 deletions(-) diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index c8e41f6..5950023 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -3,10 +3,8 @@ package processor import ( "context" "fmt" - "strings" "time" - "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) @@ -453,24 +451,45 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req } } - todayPromosi := getExpenseAmountByCategory(result.TodayExpenseByCategory, "promosi") - todayLainLain := getExpenseAmountByCategory(result.TodayExpenseByCategory, "lain") - todayTotalOps := todayPromosi + todayLainLain - todayGaji := getExpenseAmountByCategory(result.TodayExpenseByCategory, "gaji") + type categoryAmount struct { + Name string + TodayAmt float64 + MtdAmt float64 + } - mtdPromosi := getExpenseAmountByCategory(result.MtdExpenseByCategory, "promosi") - mtdLainLain := getExpenseAmountByCategory(result.MtdExpenseByCategory, "lain") - mtdTotalOps := mtdPromosi + mtdLainLain - mtdGaji := getExpenseAmountByCategory(result.MtdExpenseByCategory, "gaji") + categoryMap := make(map[string]*categoryAmount) + var categoryOrder []string + + for _, cat := range result.TodayExpenseByCategory { + name := cat.CategoryName + if _, exists := categoryMap[name]; !exists { + categoryMap[name] = &categoryAmount{Name: name} + categoryOrder = append(categoryOrder, name) + } + categoryMap[name].TodayAmt = cat.Amount + } + + for _, cat := range result.MtdExpenseByCategory { + name := cat.CategoryName + if _, exists := categoryMap[name]; !exists { + categoryMap[name] = &categoryAmount{Name: name} + categoryOrder = append(categoryOrder, name) + } + categoryMap[name].MtdAmt = cat.Amount + } + + var todayTotalOps float64 + var mtdTotalOps float64 + for _, cat := range categoryMap { + todayTotalOps += cat.TodayAmt + mtdTotalOps += cat.MtdAmt + } todayGrossProfit := result.TodayRevenue - result.TodayCost mtdGrossProfit := result.MtdRevenue - result.MtdCost - todayProfitBeforeGaji := todayGrossProfit - todayTotalOps - mtdProfitBeforeGaji := mtdGrossProfit - mtdTotalOps - - todayNetProfit := todayProfitBeforeGaji - todayGaji - mtdNetProfit := mtdProfitBeforeGaji - mtdGaji + todayNetProfit := todayGrossProfit - todayTotalOps + mtdNetProfit := mtdGrossProfit - mtdTotalOps todayPct := func(nominal float64) float64 { if result.TodayRevenue == 0 { @@ -485,6 +504,28 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req return (nominal / result.MtdRevenue) * 100 } + opsSubItems := make([]models.ProfitLossSummaryRow, 0, len(categoryOrder)+1) + for i, name := range categoryOrder { + cat := categoryMap[name] + opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{ + ID: fmt.Sprintf("by_%s", slugify(name)), + Label: fmt.Sprintf("%d. %s", i+1, cat.Name), + TodayNominal: cat.TodayAmt, + TodayPct: todayPct(cat.TodayAmt), + MtdNominal: cat.MtdAmt, + MtdPct: mtdPct(cat.MtdAmt), + }) + } + opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{ + ID: "total_biaya_ops", + Label: fmt.Sprintf("Total Biaya OPS (%d kategori)", len(categoryOrder)), + IsBold: true, + TodayNominal: todayTotalOps, + TodayPct: todayPct(todayTotalOps), + MtdNominal: mtdTotalOps, + MtdPct: mtdPct(mtdTotalOps), + }) + mainSummary := []models.ProfitLossSummaryRow{ { ID: "total_omset", Label: "TOTAL OMSET", @@ -505,36 +546,10 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req ID: "biaya_ops", Label: "BIAYA OPS", TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps), MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps), - SubItems: []models.ProfitLossSummaryRow{ - { - ID: "by_promosi", Label: "1. By Promosi", - TodayNominal: todayPromosi, TodayPct: todayPct(todayPromosi), - MtdNominal: mtdPromosi, MtdPct: mtdPct(mtdPromosi), - }, - { - ID: "by_lain_lain", Label: "2. By Lain lain", - TodayNominal: todayLainLain, TodayPct: todayPct(todayLainLain), - MtdNominal: mtdLainLain, MtdPct: mtdPct(mtdLainLain), - }, - { - ID: "total_biaya_ops", Label: "Total Biaya OPS (4.1+4.2)", IsBold: true, - TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps), - MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps), - }, - }, + SubItems: opsSubItems, }, { - ID: "laba_rugi_sblm_gaji", Label: "Laba/Rugi sblm Gaji (3-4)", - TodayNominal: todayProfitBeforeGaji, TodayPct: todayPct(todayProfitBeforeGaji), - MtdNominal: mtdProfitBeforeGaji, MtdPct: mtdPct(mtdProfitBeforeGaji), - }, - { - ID: "biaya_gaji", Label: "BIAYA GAJI", - TodayNominal: todayGaji, TodayPct: todayPct(todayGaji), - MtdNominal: mtdGaji, MtdPct: mtdPct(mtdGaji), - }, - { - ID: "laba_rugi", Label: "Laba/Rugi (5-6)", IsBold: true, + ID: "laba_rugi", Label: "Laba/Rugi Bersih (3-4)", IsBold: true, TodayNominal: todayNetProfit, TodayPct: todayPct(todayNetProfit), MtdNominal: mtdNetProfit, MtdPct: mtdPct(mtdNetProfit), }, @@ -577,11 +592,22 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req }, nil } -func getExpenseAmountByCategory(categories []entities.ExpenseCategoryTotal, keyword string) float64 { - for _, cat := range categories { - if strings.Contains(strings.ToLower(cat.CategoryName), keyword) { - return cat.Amount +func slugify(s string) string { + result := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z': + result = append(result, c) + case c >= 'A' && c <= 'Z': + result = append(result, c+32) + case c >= '0' && c <= '9': + result = append(result, c) + default: + if len(result) == 0 || result[len(result)-1] != '_' { + result = append(result, '_') + } } } - return 0 + return string(result) } diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 998831a..620c3db 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -165,3 +165,79 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t * require.NotEmpty(t, result.MainSummary) require.Equal(t, "total_omset", result.MainSummary[0].ID) } + +func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) { + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ + profitLossResult: &entities.ProfitLossAnalytics{ + Summary: entities.ProfitLossSummary{ + TotalRevenue: 10000, + TotalCost: 4000, + }, + TodayRevenue: 10000, + TodayCost: 4000, + MtdRevenue: 20000, + MtdCost: 8000, + TodayExpenseByCategory: []entities.ExpenseCategoryTotal{ + {CategoryName: "Gaji", Amount: 1500}, + {CategoryName: "Promosi", Amount: 300}, + {CategoryName: "Sewa", Amount: 500}, + }, + MtdExpenseByCategory: []entities.ExpenseCategoryTotal{ + {CategoryName: "Gaji", Amount: 3000}, + {CategoryName: "Promosi", Amount: 600}, + {CategoryName: "Sewa", Amount: 1000}, + }, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.NotNil(t, result) + + require.Len(t, result.MainSummary, 5) + + require.Equal(t, "total_omset", result.MainSummary[0].ID) + require.Equal(t, float64(10000), result.MainSummary[0].TodayNominal) + require.Equal(t, float64(20000), result.MainSummary[0].MtdNominal) + + require.Equal(t, "hpp", result.MainSummary[1].ID) + require.Equal(t, float64(4000), result.MainSummary[1].TodayNominal) + require.Equal(t, float64(8000), result.MainSummary[1].MtdNominal) + + require.Equal(t, "laba_kotor", result.MainSummary[2].ID) + require.Equal(t, float64(6000), result.MainSummary[2].TodayNominal) + require.Equal(t, float64(12000), result.MainSummary[2].MtdNominal) + + require.Equal(t, "biaya_ops", result.MainSummary[3].ID) + require.Equal(t, float64(2300), result.MainSummary[3].TodayNominal) + require.Equal(t, float64(4600), result.MainSummary[3].MtdNominal) + require.Len(t, result.MainSummary[3].SubItems, 4) // 3 categories + 1 total + + require.Equal(t, "by_gaji", result.MainSummary[3].SubItems[0].ID) + require.Equal(t, float64(1500), result.MainSummary[3].SubItems[0].TodayNominal) + require.Equal(t, float64(3000), result.MainSummary[3].SubItems[0].MtdNominal) + + require.Equal(t, "by_promosi", result.MainSummary[3].SubItems[1].ID) + require.Equal(t, float64(300), result.MainSummary[3].SubItems[1].TodayNominal) + require.Equal(t, float64(600), result.MainSummary[3].SubItems[1].MtdNominal) + + require.Equal(t, "by_sewa", result.MainSummary[3].SubItems[2].ID) + require.Equal(t, float64(500), result.MainSummary[3].SubItems[2].TodayNominal) + require.Equal(t, float64(1000), result.MainSummary[3].SubItems[2].MtdNominal) + + require.Equal(t, "total_biaya_ops", result.MainSummary[3].SubItems[3].ID) + require.True(t, result.MainSummary[3].SubItems[3].IsBold) + require.Equal(t, float64(2300), result.MainSummary[3].SubItems[3].TodayNominal) + require.Equal(t, float64(4600), result.MainSummary[3].SubItems[3].MtdNominal) + + require.Equal(t, "laba_rugi", result.MainSummary[4].ID) + require.Equal(t, float64(3700), result.MainSummary[4].TodayNominal) + require.Equal(t, float64(7400), result.MainSummary[4].MtdNominal) + require.True(t, result.MainSummary[4].IsBold) +} From b90a3cde4a86a83efd8fecf1f74394f462c47b73 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 3 Jun 2026 13:00:50 +0700 Subject: [PATCH 02/16] Fix coa summary zero --- internal/processor/analytics_processor.go | 43 ++++++++++++++--- .../processor/analytics_processor_test.go | 46 ++++++++++--------- .../processor/product_recipe_processor.go | 4 +- internal/processor/split_bill_processor.go | 2 - internal/repository/analytics_repository.go | 6 +-- .../ingredient_unit_converter_repository.go | 1 - 6 files changed, 67 insertions(+), 35 deletions(-) diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 5950023..013acdf 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -3,6 +3,7 @@ package processor import ( "context" "fmt" + "strings" "time" "apskel-pos-be/internal/models" @@ -480,7 +481,14 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req var todayTotalOps float64 var mtdTotalOps float64 + var todayGaji float64 + var mtdGaji float64 for _, cat := range categoryMap { + if isSalaryExpenseCategory(cat.Name) { + todayGaji += cat.TodayAmt + mtdGaji += cat.MtdAmt + continue + } todayTotalOps += cat.TodayAmt mtdTotalOps += cat.MtdAmt } @@ -488,8 +496,11 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req todayGrossProfit := result.TodayRevenue - result.TodayCost mtdGrossProfit := result.MtdRevenue - result.MtdCost - todayNetProfit := todayGrossProfit - todayTotalOps - mtdNetProfit := mtdGrossProfit - mtdTotalOps + todayProfitBeforeGaji := todayGrossProfit - todayTotalOps + mtdProfitBeforeGaji := mtdGrossProfit - mtdTotalOps + + todayNetProfit := todayProfitBeforeGaji - todayGaji + mtdNetProfit := mtdProfitBeforeGaji - mtdGaji todayPct := func(nominal float64) float64 { if result.TodayRevenue == 0 { @@ -505,11 +516,16 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req } opsSubItems := make([]models.ProfitLossSummaryRow, 0, len(categoryOrder)+1) - for i, name := range categoryOrder { + opsCategoryCount := 0 + for _, name := range categoryOrder { cat := categoryMap[name] + if isSalaryExpenseCategory(cat.Name) { + continue + } + opsCategoryCount++ opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{ ID: fmt.Sprintf("by_%s", slugify(name)), - Label: fmt.Sprintf("%d. %s", i+1, cat.Name), + Label: fmt.Sprintf("%d. %s", opsCategoryCount, cat.Name), TodayNominal: cat.TodayAmt, TodayPct: todayPct(cat.TodayAmt), MtdNominal: cat.MtdAmt, @@ -518,7 +534,7 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req } opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{ ID: "total_biaya_ops", - Label: fmt.Sprintf("Total Biaya OPS (%d kategori)", len(categoryOrder)), + Label: fmt.Sprintf("Total Biaya OPS (%d kategori)", opsCategoryCount), IsBold: true, TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps), @@ -549,7 +565,17 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req SubItems: opsSubItems, }, { - ID: "laba_rugi", Label: "Laba/Rugi Bersih (3-4)", IsBold: true, + ID: "laba_rugi_sblm_gaji", Label: "Laba/Rugi sblm Gaji (3-4)", + TodayNominal: todayProfitBeforeGaji, TodayPct: todayPct(todayProfitBeforeGaji), + MtdNominal: mtdProfitBeforeGaji, MtdPct: mtdPct(mtdProfitBeforeGaji), + }, + { + ID: "biaya_gaji", Label: "BIAYA GAJI", + TodayNominal: todayGaji, TodayPct: todayPct(todayGaji), + MtdNominal: mtdGaji, MtdPct: mtdPct(mtdGaji), + }, + { + ID: "laba_rugi", Label: "Laba/Rugi (5-6)", IsBold: true, TodayNominal: todayNetProfit, TodayPct: todayPct(todayNetProfit), MtdNominal: mtdNetProfit, MtdPct: mtdPct(mtdNetProfit), }, @@ -592,6 +618,11 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req }, nil } +func isSalaryExpenseCategory(name string) bool { + name = strings.ToLower(name) + return strings.Contains(name, "gaji") || strings.Contains(name, "salary") +} + func slugify(s string) string { result := make([]byte, 0, len(s)) for i := 0; i < len(s); i++ { diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 620c3db..78fe121 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -200,7 +200,7 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes require.NoError(t, err) require.NotNil(t, result) - require.Len(t, result.MainSummary, 5) + require.Len(t, result.MainSummary, 7) require.Equal(t, "total_omset", result.MainSummary[0].ID) require.Equal(t, float64(10000), result.MainSummary[0].TodayNominal) @@ -215,29 +215,33 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes require.Equal(t, float64(12000), result.MainSummary[2].MtdNominal) require.Equal(t, "biaya_ops", result.MainSummary[3].ID) - require.Equal(t, float64(2300), result.MainSummary[3].TodayNominal) - require.Equal(t, float64(4600), result.MainSummary[3].MtdNominal) - require.Len(t, result.MainSummary[3].SubItems, 4) // 3 categories + 1 total + require.Equal(t, float64(800), result.MainSummary[3].TodayNominal) + require.Equal(t, float64(1600), result.MainSummary[3].MtdNominal) + require.Len(t, result.MainSummary[3].SubItems, 3) // 2 operational categories + 1 total - require.Equal(t, "by_gaji", result.MainSummary[3].SubItems[0].ID) - require.Equal(t, float64(1500), result.MainSummary[3].SubItems[0].TodayNominal) - require.Equal(t, float64(3000), result.MainSummary[3].SubItems[0].MtdNominal) + require.Equal(t, "by_promosi", result.MainSummary[3].SubItems[0].ID) + require.Equal(t, float64(300), result.MainSummary[3].SubItems[0].TodayNominal) + require.Equal(t, float64(600), result.MainSummary[3].SubItems[0].MtdNominal) - require.Equal(t, "by_promosi", result.MainSummary[3].SubItems[1].ID) - require.Equal(t, float64(300), result.MainSummary[3].SubItems[1].TodayNominal) - require.Equal(t, float64(600), result.MainSummary[3].SubItems[1].MtdNominal) + require.Equal(t, "by_sewa", result.MainSummary[3].SubItems[1].ID) + require.Equal(t, float64(500), result.MainSummary[3].SubItems[1].TodayNominal) + require.Equal(t, float64(1000), result.MainSummary[3].SubItems[1].MtdNominal) - require.Equal(t, "by_sewa", result.MainSummary[3].SubItems[2].ID) - require.Equal(t, float64(500), result.MainSummary[3].SubItems[2].TodayNominal) - require.Equal(t, float64(1000), result.MainSummary[3].SubItems[2].MtdNominal) + require.Equal(t, "total_biaya_ops", result.MainSummary[3].SubItems[2].ID) + require.True(t, result.MainSummary[3].SubItems[2].IsBold) + require.Equal(t, float64(800), result.MainSummary[3].SubItems[2].TodayNominal) + require.Equal(t, float64(1600), result.MainSummary[3].SubItems[2].MtdNominal) - require.Equal(t, "total_biaya_ops", result.MainSummary[3].SubItems[3].ID) - require.True(t, result.MainSummary[3].SubItems[3].IsBold) - require.Equal(t, float64(2300), result.MainSummary[3].SubItems[3].TodayNominal) - require.Equal(t, float64(4600), result.MainSummary[3].SubItems[3].MtdNominal) + require.Equal(t, "laba_rugi_sblm_gaji", result.MainSummary[4].ID) + require.Equal(t, float64(5200), result.MainSummary[4].TodayNominal) + require.Equal(t, float64(10400), result.MainSummary[4].MtdNominal) - require.Equal(t, "laba_rugi", result.MainSummary[4].ID) - require.Equal(t, float64(3700), result.MainSummary[4].TodayNominal) - require.Equal(t, float64(7400), result.MainSummary[4].MtdNominal) - require.True(t, result.MainSummary[4].IsBold) + require.Equal(t, "biaya_gaji", result.MainSummary[5].ID) + require.Equal(t, float64(1500), result.MainSummary[5].TodayNominal) + require.Equal(t, float64(3000), result.MainSummary[5].MtdNominal) + + require.Equal(t, "laba_rugi", result.MainSummary[6].ID) + require.Equal(t, float64(3700), result.MainSummary[6].TodayNominal) + require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal) + require.True(t, result.MainSummary[6].IsBold) } diff --git a/internal/processor/product_recipe_processor.go b/internal/processor/product_recipe_processor.go index 831257e..5f63fa1 100644 --- a/internal/processor/product_recipe_processor.go +++ b/internal/processor/product_recipe_processor.go @@ -235,7 +235,7 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe CreatedAt: entity.Ingredient.CreatedAt, UpdatedAt: entity.Ingredient.UpdatedAt, } - + // Add unit if available if entity.Ingredient.Unit != nil { symbol := "" @@ -253,4 +253,4 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe } return response -} \ No newline at end of file +} diff --git a/internal/processor/split_bill_processor.go b/internal/processor/split_bill_processor.go index da7759c..5559125 100644 --- a/internal/processor/split_bill_processor.go +++ b/internal/processor/split_bill_processor.go @@ -22,8 +22,6 @@ const ( MetadataKeyLastSplitQuantities = "last_split_quantities" ) - - type SplitBillValidation struct { OrderItems map[uuid.UUID]*entities.OrderItem PaidQuantities map[uuid.UUID]int diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index b250dc9..5f90e0f 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -637,7 +637,7 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga query := r.db.WithContext(ctx). Table("expense_items ei"). - Select(`COALESCE(parent_coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`). + Select(`COALESCE(parent_coa.name, coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`). Joins("JOIN expenses e ON ei.expense_id = e.id"). Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id"). Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id"). @@ -650,8 +650,8 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga } err := query. - Group("parent_coa.name"). - Order("parent_coa.name"). + Group("COALESCE(parent_coa.name, coa.name, 'Lain-lain')"). + Order("COALESCE(parent_coa.name, coa.name, 'Lain-lain')"). Scan(&results).Error return results, err diff --git a/internal/repository/ingredient_unit_converter_repository.go b/internal/repository/ingredient_unit_converter_repository.go index 88ae55a..a8d723e 100644 --- a/internal/repository/ingredient_unit_converter_repository.go +++ b/internal/repository/ingredient_unit_converter_repository.go @@ -173,4 +173,3 @@ func (r *IngredientUnitConverterRepositoryImpl) ConvertQuantity(ctx context.Cont // If no converter found, return error return 0, fmt.Errorf("no conversion found between units %s and %s for ingredient %s", fromUnitID, toUnitID, ingredientID) } - From 094e8b2a476da26322e496cd0ee8ac23f75ce3f9 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 3 Jun 2026 14:56:27 +0700 Subject: [PATCH 03/16] Add expense analytics --- internal/contract/expense_contract.go | 52 +++++++ internal/entities/expense.go | 40 ++++++ internal/handler/expense_handler.go | 28 ++++ internal/models/expense.go | 53 +++++++ .../processor/analytics_processor_test.go | 3 + internal/processor/expense_processor.go | 68 +++++++++ internal/processor/expense_processor_test.go | 70 ++++++++++ internal/processor/expense_repository.go | 2 + internal/repository/expense_repository.go | 132 ++++++++++++++++++ internal/router/router.go | 1 + internal/service/expense_service.go | 22 +++ internal/transformer/expense_transformer.go | 79 +++++++++++ 12 files changed, 550 insertions(+) diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index a8ff07e..b769d2e 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -91,3 +91,55 @@ type ListExpenseResponse struct { Limit int `json:"limit"` TotalPages int `json:"total_pages"` } + +type ExpenseAnalyticsRequest struct { + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` +} + +type ExpenseAnalyticsResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + GroupBy string `json:"group_by"` + Summary ExpenseAnalyticsSummary `json:"summary"` + Data []ExpenseAnalyticsData `json:"data"` + CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"` + ItemData []ExpenseAnalyticsItemData `json:"item_data"` +} + +type ExpenseAnalyticsSummary struct { + TotalExpenses float64 `json:"total_expenses"` + TotalExpenseCount int64 `json:"total_expense_count"` + TotalTax float64 `json:"total_tax"` + AverageExpenseValue float64 `json:"average_expense_value"` + TotalCategories int64 `json:"total_categories"` + TotalItems int64 `json:"total_items"` +} + +type ExpenseAnalyticsData struct { + Date time.Time `json:"date"` + Expenses float64 `json:"expenses"` + ExpenseCount int64 `json:"expense_count"` + Tax float64 `json:"tax"` + Items int64 `json:"items"` + Categories int64 `json:"categories"` +} + +type ExpenseAnalyticsCategoryData struct { + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + ChartOfAccountName string `json:"chart_of_account_name"` + TotalAmount float64 `json:"total_amount"` + ExpenseCount int64 `json:"expense_count"` + ItemCount int64 `json:"item_count"` +} + +type ExpenseAnalyticsItemData struct { + Item string `json:"item"` + TotalAmount float64 `json:"total_amount"` + ExpenseCount int64 `json:"expense_count"` + ItemCount int64 `json:"item_count"` +} diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 93d9789..137157f 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -28,6 +28,46 @@ type Expense struct { Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"` } +type ExpenseAnalytics struct { + Summary ExpenseAnalyticsSummary + Data []ExpenseAnalyticsData + CategoryData []ExpenseAnalyticsCategoryData + ItemData []ExpenseAnalyticsItemData +} + +type ExpenseAnalyticsSummary struct { + TotalExpenses float64 + TotalExpenseCount int64 + TotalTax float64 + AverageExpenseValue float64 + TotalCategories int64 + TotalItems int64 +} + +type ExpenseAnalyticsData struct { + Date time.Time + Expenses float64 + ExpenseCount int64 + Tax float64 + Items int64 + Categories int64 +} + +type ExpenseAnalyticsCategoryData struct { + ChartOfAccountID uuid.UUID + ChartOfAccountName string + TotalAmount float64 + ExpenseCount int64 + ItemCount int64 +} + +type ExpenseAnalyticsItemData struct { + Item string + TotalAmount float64 + ExpenseCount int64 + ItemCount int64 +} + func (e *Expense) BeforeCreate(tx *gorm.DB) error { if e.ID == uuid.Nil { e.ID = uuid.New() diff --git a/internal/handler/expense_handler.go b/internal/handler/expense_handler.go index c09cc79..36ad959 100644 --- a/internal/handler/expense_handler.go +++ b/internal/handler/expense_handler.go @@ -199,3 +199,31 @@ func (h *ExpenseHandler) ListExpenses(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::ListExpenses") } + +func (h *ExpenseHandler) GetExpenseAnalytics(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.ExpenseAnalyticsRequest + if err := c.ShouldBindQuery(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpenseAnalytics -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpenseAnalytics") + return + } + + if contextInfo.OutletID != uuid.Nil { + outletID := contextInfo.OutletID.String() + req.OutletID = &outletID + } else if outletID := c.Query("outlet_id"); outletID != "" { + req.OutletID = &outletID + } + + expenseResponse := h.expenseService.GetExpenseAnalytics(ctx, contextInfo, &req) + if expenseResponse.HasErrors() { + errorResp := expenseResponse.GetErrors()[0] + logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpenseAnalytics -> Failed to get expense analytics from service") + } + + util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpenseAnalytics") +} diff --git a/internal/models/expense.go b/internal/models/expense.go index 52a34e5..57c08d0 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -118,3 +118,56 @@ type ListExpenseResponse struct { Limit int `json:"limit"` TotalPages int `json:"total_pages"` } + +type ExpenseAnalyticsRequest struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + GroupBy string `json:"group_by"` +} + +type ExpenseAnalyticsResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + GroupBy string `json:"group_by"` + Summary ExpenseAnalyticsSummary `json:"summary"` + Data []ExpenseAnalyticsData `json:"data"` + CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"` + ItemData []ExpenseAnalyticsItemData `json:"item_data"` +} + +type ExpenseAnalyticsSummary struct { + TotalExpenses float64 `json:"total_expenses"` + TotalExpenseCount int64 `json:"total_expense_count"` + TotalTax float64 `json:"total_tax"` + AverageExpenseValue float64 `json:"average_expense_value"` + TotalCategories int64 `json:"total_categories"` + TotalItems int64 `json:"total_items"` +} + +type ExpenseAnalyticsData struct { + Date time.Time `json:"date"` + Expenses float64 `json:"expenses"` + ExpenseCount int64 `json:"expense_count"` + Tax float64 `json:"tax"` + Items int64 `json:"items"` + Categories int64 `json:"categories"` +} + +type ExpenseAnalyticsCategoryData struct { + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + ChartOfAccountName string `json:"chart_of_account_name"` + TotalAmount float64 `json:"total_amount"` + ExpenseCount int64 `json:"expense_count"` + ItemCount int64 `json:"item_count"` +} + +type ExpenseAnalyticsItemData struct { + Item string `json:"item"` + TotalAmount float64 `json:"total_amount"` + ExpenseCount int64 `json:"expense_count"` + ItemCount int64 `json:"item_count"` +} diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 78fe121..0c46b4d 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -61,6 +61,9 @@ func (expenseRepositoryStub) Delete(context.Context, uuid.UUID) error { func (expenseRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) { return nil, 0, nil } +func (expenseRepositoryStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) { + return nil, nil +} func (expenseRepositoryStub) CreateItem(context.Context, *entities.ExpenseItem) error { return nil } func (expenseRepositoryStub) DeleteItemsByExpenseID(context.Context, uuid.UUID) error { return nil } diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 4e0557b..2141ebe 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -19,6 +19,7 @@ type ExpenseProcessor interface { DeleteExpense(ctx context.Context, id, organizationID uuid.UUID) error GetExpenseByID(ctx context.Context, id, organizationID uuid.UUID) (*models.ExpenseResponse, error) ListExpenses(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.ExpenseResponse, int, error) + GetExpenseAnalytics(ctx context.Context, req *models.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsResponse, error) } type ExpenseProcessorImpl struct { @@ -221,3 +222,70 @@ func (p *ExpenseProcessorImpl) ListExpenses(ctx context.Context, organizationID return expenseResponses, totalPages, nil } + +func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *models.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsResponse, error) { + if req.DateFrom.After(req.DateTo) { + return nil, fmt.Errorf("date_from cannot be after date_to") + } + + if req.GroupBy == "" { + req.GroupBy = "day" + } + + result, err := p.expenseRepo.GetAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) + if err != nil { + return nil, fmt.Errorf("failed to get expense analytics: %w", err) + } + + data := make([]models.ExpenseAnalyticsData, len(result.Data)) + for i, item := range result.Data { + data[i] = models.ExpenseAnalyticsData{ + Date: item.Date, + Expenses: item.Expenses, + ExpenseCount: item.ExpenseCount, + Tax: item.Tax, + Items: item.Items, + Categories: item.Categories, + } + } + + categoryData := make([]models.ExpenseAnalyticsCategoryData, len(result.CategoryData)) + for i, item := range result.CategoryData { + categoryData[i] = models.ExpenseAnalyticsCategoryData{ + ChartOfAccountID: item.ChartOfAccountID, + ChartOfAccountName: item.ChartOfAccountName, + TotalAmount: item.TotalAmount, + ExpenseCount: item.ExpenseCount, + ItemCount: item.ItemCount, + } + } + + itemData := make([]models.ExpenseAnalyticsItemData, len(result.ItemData)) + for i, item := range result.ItemData { + itemData[i] = models.ExpenseAnalyticsItemData{ + Item: item.Item, + TotalAmount: item.TotalAmount, + ExpenseCount: item.ExpenseCount, + ItemCount: item.ItemCount, + } + } + + return &models.ExpenseAnalyticsResponse{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + DateFrom: req.DateFrom, + DateTo: req.DateTo, + GroupBy: req.GroupBy, + Summary: models.ExpenseAnalyticsSummary{ + TotalExpenses: result.Summary.TotalExpenses, + TotalExpenseCount: result.Summary.TotalExpenseCount, + TotalTax: result.Summary.TotalTax, + AverageExpenseValue: result.Summary.AverageExpenseValue, + TotalCategories: result.Summary.TotalCategories, + TotalItems: result.Summary.TotalItems, + }, + Data: data, + CategoryData: categoryData, + ItemData: itemData, + }, nil +} diff --git a/internal/processor/expense_processor_test.go b/internal/processor/expense_processor_test.go index 7afb0e7..b42fed7 100644 --- a/internal/processor/expense_processor_test.go +++ b/internal/processor/expense_processor_test.go @@ -3,6 +3,7 @@ package processor import ( "context" "testing" + "time" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" @@ -14,6 +15,7 @@ import ( type expenseRepositoryCaptureStub struct { createdExpense *entities.Expense createdItems []*entities.ExpenseItem + analytics *entities.ExpenseAnalytics } func (s *expenseRepositoryCaptureStub) Create(_ context.Context, expense *entities.Expense) error { @@ -44,6 +46,9 @@ func (*expenseRepositoryCaptureStub) Delete(context.Context, uuid.UUID) error func (*expenseRepositoryCaptureStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) { return nil, 0, nil } +func (s *expenseRepositoryCaptureStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) { + return s.analytics, nil +} func (s *expenseRepositoryCaptureStub) CreateItem(_ context.Context, item *entities.ExpenseItem) error { if item.ID == uuid.Nil { item.ID = uuid.New() @@ -134,3 +139,68 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { require.Equal(t, "approved", repo.createdExpense.Status) require.Equal(t, "approved", resp.Status) } + +func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) { + coaID := uuid.New() + outletID := uuid.New() + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + repo := &expenseRepositoryCaptureStub{ + analytics: &entities.ExpenseAnalytics{ + Summary: entities.ExpenseAnalyticsSummary{ + TotalExpenses: 100000, + TotalExpenseCount: 2, + TotalTax: 10000, + AverageExpenseValue: 50000, + TotalCategories: 1, + TotalItems: 2, + }, + Data: []entities.ExpenseAnalyticsData{ + { + Date: now, + Expenses: 100000, + ExpenseCount: 2, + Tax: 10000, + Items: 2, + Categories: 1, + }, + }, + CategoryData: []entities.ExpenseAnalyticsCategoryData{ + { + ChartOfAccountID: coaID, + ChartOfAccountName: "Operational", + TotalAmount: 100000, + ExpenseCount: 2, + ItemCount: 2, + }, + }, + ItemData: []entities.ExpenseAnalyticsItemData{ + { + Item: "Cleaning supplies", + TotalAmount: 100000, + ExpenseCount: 2, + ItemCount: 2, + }, + }, + }, + } + p := NewExpenseProcessorImpl(repo) + + resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{ + OrganizationID: uuid.New(), + OutletID: &outletID, + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, "day", resp.GroupBy) + require.Equal(t, &outletID, resp.OutletID) + require.Equal(t, float64(100000), resp.Summary.TotalExpenses) + require.Len(t, resp.Data, 1) + require.Equal(t, int64(2), resp.Data[0].ExpenseCount) + require.Len(t, resp.CategoryData, 1) + require.Equal(t, coaID, resp.CategoryData[0].ChartOfAccountID) + require.Len(t, resp.ItemData, 1) + require.Equal(t, "Cleaning supplies", resp.ItemData[0].Item) +} diff --git a/internal/processor/expense_repository.go b/internal/processor/expense_repository.go index baefbc2..2b96c0b 100644 --- a/internal/processor/expense_repository.go +++ b/internal/processor/expense_repository.go @@ -3,6 +3,7 @@ package processor import ( "apskel-pos-be/internal/entities" "context" + "time" "github.com/google/uuid" ) @@ -14,6 +15,7 @@ type ExpenseRepository interface { Update(ctx context.Context, expense *entities.Expense) error Delete(ctx context.Context, id uuid.UUID) error List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.Expense, int64, error) + GetAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ExpenseAnalytics, error) CreateItem(ctx context.Context, item *entities.ExpenseItem) error DeleteItemsByExpenseID(ctx context.Context, expenseID uuid.UUID) error } diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index 335d0eb..4877243 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -114,6 +114,138 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU return expenses, total, err } +func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ExpenseAnalytics, error) { + var summary entities.ExpenseAnalyticsSummary + + summaryQuery := r.db.WithContext(ctx). + Table("expenses e"). + Select(` + COALESCE(SUM(e.total), 0) as total_expenses, + COUNT(e.id) as total_expense_count, + COALESCE(SUM(e.tax), 0) as total_tax, + COALESCE(AVG(e.total), 0) as average_expense_value + `). + Where("e.organization_id = ?", organizationID). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) + if outletID != nil { + summaryQuery = summaryQuery.Where("e.outlet_id = ?", *outletID) + } + if err := summaryQuery.Scan(&summary).Error; err != nil { + return nil, err + } + + countsQuery := r.db.WithContext(ctx). + Table("expense_items ei"). + Select(` + COUNT(ei.id) as total_items, + COUNT(DISTINCT ei.chart_of_account_id) as total_categories + `). + Joins("JOIN expenses e ON ei.expense_id = e.id"). + Where("e.organization_id = ?", organizationID). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) + if outletID != nil { + countsQuery = countsQuery.Where("e.outlet_id = ?", *outletID) + } + if err := countsQuery.Scan(&summary).Error; err != nil { + return nil, err + } + + dateFormat := "DATE_TRUNC('day', e.transaction_date)" + switch groupBy { + case "hour": + dateFormat = "DATE_TRUNC('hour', e.transaction_date)" + case "week": + dateFormat = "DATE_TRUNC('week', e.transaction_date)" + case "month": + dateFormat = "DATE_TRUNC('month', e.transaction_date)" + } + + var data []entities.ExpenseAnalyticsData + dataQuery := r.db.WithContext(ctx). + Table("expenses e"). + Select(` + `+dateFormat+` as date, + COALESCE(SUM(e.total), 0) as expenses, + COUNT(e.id) as expense_count, + COALESCE(SUM(e.tax), 0) as tax, + COALESCE(SUM(item_counts.items), 0) as items, + COALESCE(SUM(item_counts.categories), 0) as categories + `). + Joins(`LEFT JOIN ( + SELECT expense_id, COUNT(id) as items, COUNT(DISTINCT chart_of_account_id) as categories + FROM expense_items + GROUP BY expense_id + ) item_counts ON item_counts.expense_id = e.id`). + Where("e.organization_id = ?", organizationID). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo). + Group(dateFormat). + Order(dateFormat) + if outletID != nil { + dataQuery = dataQuery.Where("e.outlet_id = ?", *outletID) + } + if err := dataQuery.Scan(&data).Error; err != nil { + return nil, err + } + + var categoryData []entities.ExpenseAnalyticsCategoryData + categoryQuery := r.db.WithContext(ctx). + Table("expense_items ei"). + Select(` + COALESCE(parent_coa.id, coa.id) as chart_of_account_id, + COALESCE(parent_coa.name, coa.name, 'Lain-lain') as chart_of_account_name, + COALESCE(SUM(ei.amount), 0) as total_amount, + COUNT(DISTINCT e.id) as expense_count, + COUNT(ei.id) as item_count + `). + Joins("JOIN expenses e ON ei.expense_id = e.id"). + Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id"). + Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id"). + Where("e.organization_id = ?", organizationID). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo). + Group("COALESCE(parent_coa.id, coa.id), COALESCE(parent_coa.name, coa.name, 'Lain-lain')"). + Order("total_amount DESC") + if outletID != nil { + categoryQuery = categoryQuery.Where("e.outlet_id = ?", *outletID) + } + if err := categoryQuery.Scan(&categoryData).Error; err != nil { + return nil, err + } + + var itemData []entities.ExpenseAnalyticsItemData + itemQuery := r.db.WithContext(ctx). + Table("expense_items ei"). + Select(` + COALESCE(NULLIF(ei.item, ''), ei.description, coa.name) as item, + COALESCE(SUM(ei.amount), 0) as total_amount, + COUNT(DISTINCT e.id) as expense_count, + COUNT(ei.id) as item_count + `). + Joins("JOIN expenses e ON ei.expense_id = e.id"). + Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id"). + Where("e.organization_id = ?", organizationID). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo). + Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)"). + Order("total_amount DESC") + if outletID != nil { + itemQuery = itemQuery.Where("e.outlet_id = ?", *outletID) + } + if err := itemQuery.Scan(&itemData).Error; err != nil { + return nil, err + } + + return &entities.ExpenseAnalytics{ + Summary: summary, + Data: data, + CategoryData: categoryData, + ItemData: itemData, + }, nil +} + func (r *ExpenseRepositoryImpl) CreateItem(ctx context.Context, item *entities.ExpenseItem) error { return r.db.WithContext(ctx).Create(item).Error } diff --git a/internal/router/router.go b/internal/router/router.go index 4febd1b..f9fdee7 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -451,6 +451,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { { expenses.POST("", r.expenseHandler.CreateExpense) expenses.GET("", r.expenseHandler.ListExpenses) + expenses.GET("/analytics", r.expenseHandler.GetExpenseAnalytics) expenses.GET("/:id", r.expenseHandler.GetExpense) expenses.PUT("/:id", r.expenseHandler.UpdateExpense) expenses.DELETE("/:id", r.expenseHandler.DeleteExpense) diff --git a/internal/service/expense_service.go b/internal/service/expense_service.go index bb8a417..9e937b4 100644 --- a/internal/service/expense_service.go +++ b/internal/service/expense_service.go @@ -19,6 +19,7 @@ type ExpenseService interface { DeleteExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response GetExpenseByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response ListExpenses(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListExpenseRequest) *contract.Response + GetExpenseAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ExpenseAnalyticsRequest) *contract.Response } type ExpenseServiceImpl struct { @@ -126,3 +127,24 @@ func (s *ExpenseServiceImpl) ListExpenses(ctx context.Context, apctx *appcontext return contract.BuildSuccessResponse(response) } + +func (s *ExpenseServiceImpl) GetExpenseAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ExpenseAnalyticsRequest) *contract.Response { + modelReq, err := transformer.ExpenseAnalyticsRequestToModel(req) + if err != nil { + errorResp := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + modelReq.OrganizationID = apctx.OrganizationID + if apctx.OutletID != uuid.Nil { + modelReq.OutletID = &apctx.OutletID + } + + response, err := s.expenseProcessor.GetExpenseAnalytics(ctx, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.ExpenseAnalyticsModelToContract(response)) +} diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index 5b3c47c..6f1fbf6 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -3,6 +3,7 @@ package transformer import ( "apskel-pos-be/internal/contract" "apskel-pos-be/internal/models" + "apskel-pos-be/internal/util" ) func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.CreateExpenseRequest { @@ -134,3 +135,81 @@ func ExpenseModelResponsesToResponses(expenses []*models.ExpenseResponse) []cont } return responses } + +func ExpenseAnalyticsRequestToModel(req *contract.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsRequest, error) { + dateFrom, dateTo, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo) + if err != nil { + return nil, err + } + + modelReq := &models.ExpenseAnalyticsRequest{ + OutletID: parseOutletID(req.OutletID), + GroupBy: req.GroupBy, + } + if dateFrom != nil { + modelReq.DateFrom = *dateFrom + } + if dateTo != nil { + modelReq.DateTo = *dateTo + } + + return modelReq, nil +} + +func ExpenseAnalyticsModelToContract(resp *models.ExpenseAnalyticsResponse) *contract.ExpenseAnalyticsResponse { + if resp == nil { + return nil + } + + data := make([]contract.ExpenseAnalyticsData, len(resp.Data)) + for i, item := range resp.Data { + data[i] = contract.ExpenseAnalyticsData{ + Date: item.Date, + Expenses: item.Expenses, + ExpenseCount: item.ExpenseCount, + Tax: item.Tax, + Items: item.Items, + Categories: item.Categories, + } + } + + categoryData := make([]contract.ExpenseAnalyticsCategoryData, len(resp.CategoryData)) + for i, item := range resp.CategoryData { + categoryData[i] = contract.ExpenseAnalyticsCategoryData{ + ChartOfAccountID: item.ChartOfAccountID, + ChartOfAccountName: item.ChartOfAccountName, + TotalAmount: item.TotalAmount, + ExpenseCount: item.ExpenseCount, + ItemCount: item.ItemCount, + } + } + + itemData := make([]contract.ExpenseAnalyticsItemData, len(resp.ItemData)) + for i, item := range resp.ItemData { + itemData[i] = contract.ExpenseAnalyticsItemData{ + Item: item.Item, + TotalAmount: item.TotalAmount, + ExpenseCount: item.ExpenseCount, + ItemCount: item.ItemCount, + } + } + + return &contract.ExpenseAnalyticsResponse{ + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + DateFrom: resp.DateFrom, + DateTo: resp.DateTo, + GroupBy: resp.GroupBy, + Summary: contract.ExpenseAnalyticsSummary{ + TotalExpenses: resp.Summary.TotalExpenses, + TotalExpenseCount: resp.Summary.TotalExpenseCount, + TotalTax: resp.Summary.TotalTax, + AverageExpenseValue: resp.Summary.AverageExpenseValue, + TotalCategories: resp.Summary.TotalCategories, + TotalItems: resp.Summary.TotalItems, + }, + Data: data, + CategoryData: categoryData, + ItemData: itemData, + } +} From 69d8c8ce5ecfe00041694165c79b4924d3b2aa70 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 8 Jun 2026 12:29:59 +0700 Subject: [PATCH 04/16] Add category table --- internal/app/app.go | 11 + internal/constants/error.go | 1 + .../contract/purchase_category_contract.go | 57 +++++ internal/entities/purchase_category.go | 71 ++++++ internal/handler/purchase_category_handler.go | 160 +++++++++++++ internal/mappers/purchase_category_mapper.go | 53 +++++ internal/models/purchase_category.go | 51 +++++ .../processor/purchase_category_processor.go | 210 ++++++++++++++++++ .../purchase_category_repository.go | 91 ++++++++ internal/router/router.go | 14 +- internal/service/purchase_category_service.go | 107 +++++++++ .../purchase_category_transformer.go | 72 ++++++ .../validator/purchase_category_validator.go | 103 +++++++++ ...000075_create_purchase_categories.down.sql | 2 + .../000075_create_purchase_categories.up.sql | 64 ++++++ ...fault_purchase_categories_trigger.down.sql | 2 + ...default_purchase_categories_trigger.up.sql | 49 ++++ ...kfill_default_purchase_categories.down.sql | 3 + ...ackfill_default_purchase_categories.up.sql | 39 ++++ 19 files changed, 1159 insertions(+), 1 deletion(-) create mode 100644 internal/contract/purchase_category_contract.go create mode 100644 internal/entities/purchase_category.go create mode 100644 internal/handler/purchase_category_handler.go create mode 100644 internal/mappers/purchase_category_mapper.go create mode 100644 internal/models/purchase_category.go create mode 100644 internal/processor/purchase_category_processor.go create mode 100644 internal/repository/purchase_category_repository.go create mode 100644 internal/service/purchase_category_service.go create mode 100644 internal/transformer/purchase_category_transformer.go create mode 100644 internal/validator/purchase_category_validator.go create mode 100644 migrations/000075_create_purchase_categories.down.sql create mode 100644 migrations/000075_create_purchase_categories.up.sql create mode 100644 migrations/000076_add_default_purchase_categories_trigger.down.sql create mode 100644 migrations/000076_add_default_purchase_categories_trigger.up.sql create mode 100644 migrations/000077_backfill_default_purchase_categories.down.sql create mode 100644 migrations/000077_backfill_default_purchase_categories.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 82f4442..9e36acd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -107,6 +107,8 @@ func (a *App) Initialize(cfg *config.Config) error { validators.vendorValidator, services.purchaseOrderService, validators.purchaseOrderValidator, + services.purchaseCategoryService, + validators.purchaseCategoryValidator, services.unitConverterService, validators.unitConverterValidator, services.chartOfAccountTypeService, @@ -214,6 +216,7 @@ type repositories struct { productRecipeRepo *repository.ProductRecipeRepository vendorRepo *repository.VendorRepositoryImpl purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl + purchaseCategoryRepo *repository.PurchaseCategoryRepositoryImpl unitConverterRepo *repository.IngredientUnitConverterRepositoryImpl chartOfAccountTypeRepo *repository.ChartOfAccountTypeRepositoryImpl chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl @@ -267,6 +270,7 @@ func (a *App) initRepositories() *repositories { productRecipeRepo: repository.NewProductRecipeRepository(a.db), vendorRepo: repository.NewVendorRepositoryImpl(a.db), purchaseOrderRepo: repository.NewPurchaseOrderRepositoryImpl(a.db), + purchaseCategoryRepo: repository.NewPurchaseCategoryRepositoryImpl(a.db), unitConverterRepo: repository.NewIngredientUnitConverterRepositoryImpl(a.db).(*repository.IngredientUnitConverterRepositoryImpl), chartOfAccountTypeRepo: repository.NewChartOfAccountTypeRepositoryImpl(a.db), chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db), @@ -315,6 +319,7 @@ type processors struct { productRecipeProcessor *processor.ProductRecipeProcessorImpl vendorProcessor *processor.VendorProcessorImpl purchaseOrderProcessor *processor.PurchaseOrderProcessorImpl + purchaseCategoryProcessor *processor.PurchaseCategoryProcessorImpl unitConverterProcessor *processor.IngredientUnitConverterProcessorImpl chartOfAccountTypeProcessor *processor.ChartOfAccountTypeProcessorImpl chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl @@ -366,6 +371,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo), purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), + purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo), unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo), chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo), chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo), @@ -414,6 +420,7 @@ type services struct { productRecipeService *service.ProductRecipeServiceImpl vendorService *service.VendorServiceImpl purchaseOrderService *service.PurchaseOrderServiceImpl + purchaseCategoryService service.PurchaseCategoryService unitConverterService *service.IngredientUnitConverterServiceImpl chartOfAccountTypeService service.ChartOfAccountTypeService chartOfAccountService service.ChartOfAccountService @@ -453,6 +460,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con productRecipeService := service.NewProductRecipeService(processors.productRecipeProcessor) vendorService := service.NewVendorService(processors.vendorProcessor) purchaseOrderService := service.NewPurchaseOrderService(processors.purchaseOrderProcessor) + purchaseCategoryService := service.NewPurchaseCategoryService(processors.purchaseCategoryProcessor) unitConverterService := service.NewIngredientUnitConverterService(processors.unitConverterProcessor) chartOfAccountTypeService := service.NewChartOfAccountTypeService(processors.chartOfAccountTypeProcessor) chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor) @@ -492,6 +500,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con productRecipeService: productRecipeService, vendorService: vendorService, purchaseOrderService: purchaseOrderService, + purchaseCategoryService: purchaseCategoryService, unitConverterService: unitConverterService, chartOfAccountTypeService: chartOfAccountTypeService, chartOfAccountService: chartOfAccountService, @@ -537,6 +546,7 @@ type validators struct { tableValidator *validator.TableValidator vendorValidator *validator.VendorValidatorImpl purchaseOrderValidator *validator.PurchaseOrderValidatorImpl + purchaseCategoryValidator *validator.PurchaseCategoryValidatorImpl unitConverterValidator *validator.IngredientUnitConverterValidatorImpl chartOfAccountTypeValidator *validator.ChartOfAccountTypeValidatorImpl chartOfAccountValidator *validator.ChartOfAccountValidatorImpl @@ -568,6 +578,7 @@ func (a *App) initValidators() *validators { tableValidator: validator.NewTableValidator(), vendorValidator: validator.NewVendorValidator(), purchaseOrderValidator: validator.NewPurchaseOrderValidator(), + purchaseCategoryValidator: validator.NewPurchaseCategoryValidator(), unitConverterValidator: validator.NewIngredientUnitConverterValidator().(*validator.IngredientUnitConverterValidatorImpl), chartOfAccountTypeValidator: validator.NewChartOfAccountTypeValidator().(*validator.ChartOfAccountTypeValidatorImpl), chartOfAccountValidator: validator.NewChartOfAccountValidator().(*validator.ChartOfAccountValidatorImpl), diff --git a/internal/constants/error.go b/internal/constants/error.go index 8937a58..af56665 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -40,6 +40,7 @@ const ( OutletServiceEntity = "outlet_service" VendorServiceEntity = "vendor_service" PurchaseOrderServiceEntity = "purchase_order_service" + PurchaseCategoryServiceEntity = "purchase_category_service" IngredientUnitConverterServiceEntity = "ingredient_unit_converter_service" IngredientCompositionServiceEntity = "ingredient_composition_service" TableEntity = "table" diff --git a/internal/contract/purchase_category_contract.go b/internal/contract/purchase_category_contract.go new file mode 100644 index 0000000..8aed0ef --- /dev/null +++ b/internal/contract/purchase_category_contract.go @@ -0,0 +1,57 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +type CreatePurchaseCategoryRequest struct { + ParentID *uuid.UUID `json:"parent_id,omitempty"` + Code *string `json:"code,omitempty"` + Name string `json:"name" validate:"required,min=1,max=255"` + Type string `json:"type" validate:"required,oneof=raw_material non_inventory"` + SortOrder *int `json:"sort_order,omitempty"` + IsActive *bool `json:"is_active,omitempty"` +} + +type UpdatePurchaseCategoryRequest struct { + ParentID *uuid.UUID `json:"parent_id,omitempty"` + Code *string `json:"code,omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` + Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material non_inventory"` + SortOrder *int `json:"sort_order,omitempty"` + IsActive *bool `json:"is_active,omitempty"` +} + +type ListPurchaseCategoriesRequest struct { + ParentID *uuid.UUID `json:"parent_id,omitempty"` + Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material non_inventory"` + Search string `json:"search,omitempty"` + IsActive *bool `json:"is_active,omitempty"` + Page int `json:"page" validate:"required,min=1"` + Limit int `json:"limit" validate:"required,min=1,max=100"` +} + +type PurchaseCategoryResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + PresetID *uuid.UUID `json:"preset_id"` + ParentID *uuid.UUID `json:"parent_id"` + Code string `json:"code"` + Name string `json:"name"` + Type string `json:"type"` + SortOrder int `json:"sort_order"` + IsSystem bool `json:"is_system"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListPurchaseCategoriesResponse struct { + PurchaseCategories []PurchaseCategoryResponse `json:"purchase_categories"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} diff --git a/internal/entities/purchase_category.go b/internal/entities/purchase_category.go new file mode 100644 index 0000000..34c5667 --- /dev/null +++ b/internal/entities/purchase_category.go @@ -0,0 +1,71 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type PurchaseCategoryType string + +const ( + PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material" + PurchaseCategoryTypeNonInventory PurchaseCategoryType = "non_inventory" +) + +type PurchaseCategoryPreset struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"` + Code string `gorm:"not null;unique;size:100" json:"code"` + Name string `gorm:"not null;size:255" json:"name"` + Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + IsActive bool `gorm:"not null;default:true" json:"is_active"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + Parent *PurchaseCategoryPreset `gorm:"foreignKey:ParentID" json:"parent,omitempty"` +} + +func (p *PurchaseCategoryPreset) BeforeCreate(tx *gorm.DB) error { + if p.ID == uuid.Nil { + p.ID = uuid.New() + } + return nil +} + +func (PurchaseCategoryPreset) TableName() string { + return "purchase_category_presets" +} + +type PurchaseCategory struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` + PresetID *uuid.UUID `gorm:"type:uuid;index" json:"preset_id"` + ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"` + Code string `gorm:"not null;size:100" json:"code"` + Name string `gorm:"not null;size:255" json:"name"` + Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + IsSystem bool `gorm:"not null;default:false" json:"is_system"` + IsActive bool `gorm:"not null;default:true" json:"is_active"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` + Preset *PurchaseCategoryPreset `gorm:"foreignKey:PresetID" json:"preset,omitempty"` + Parent *PurchaseCategory `gorm:"foreignKey:ParentID" json:"parent,omitempty"` + Children []PurchaseCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"` +} + +func (c *PurchaseCategory) BeforeCreate(tx *gorm.DB) error { + if c.ID == uuid.Nil { + c.ID = uuid.New() + } + return nil +} + +func (PurchaseCategory) TableName() string { + return "purchase_categories" +} diff --git a/internal/handler/purchase_category_handler.go b/internal/handler/purchase_category_handler.go new file mode 100644 index 0000000..1555004 --- /dev/null +++ b/internal/handler/purchase_category_handler.go @@ -0,0 +1,160 @@ +package handler + +import ( + "strconv" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" + "apskel-pos-be/internal/validator" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type PurchaseCategoryHandler struct { + purchaseCategoryService service.PurchaseCategoryService + purchaseCategoryValidator validator.PurchaseCategoryValidator +} + +func NewPurchaseCategoryHandler(purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator) *PurchaseCategoryHandler { + return &PurchaseCategoryHandler{ + purchaseCategoryService: purchaseCategoryService, + purchaseCategoryValidator: purchaseCategoryValidator, + } +} + +func (h *PurchaseCategoryHandler) CreatePurchaseCategory(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.CreatePurchaseCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::CreatePurchaseCategory -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory") + return + } + + if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateCreatePurchaseCategoryRequest(&req); validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory") + return + } + + response := h.purchaseCategoryService.CreatePurchaseCategory(ctx, contextInfo, &req) + util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::CreatePurchaseCategory") +} + +func (h *PurchaseCategoryHandler) UpdatePurchaseCategory(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + categoryID, err := uuid.Parse(c.Param("id")) + if err != nil { + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory") + return + } + + var req contract.UpdatePurchaseCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::UpdatePurchaseCategory -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory") + return + } + + if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateUpdatePurchaseCategoryRequest(&req); validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory") + return + } + + response := h.purchaseCategoryService.UpdatePurchaseCategory(ctx, contextInfo, categoryID, &req) + util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::UpdatePurchaseCategory") +} + +func (h *PurchaseCategoryHandler) DeletePurchaseCategory(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + categoryID, err := uuid.Parse(c.Param("id")) + if err != nil { + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::DeletePurchaseCategory") + return + } + + response := h.purchaseCategoryService.DeletePurchaseCategory(ctx, contextInfo, categoryID) + util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::DeletePurchaseCategory") +} + +func (h *PurchaseCategoryHandler) GetPurchaseCategory(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + categoryID, err := uuid.Parse(c.Param("id")) + if err != nil { + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::GetPurchaseCategory") + return + } + + response := h.purchaseCategoryService.GetPurchaseCategoryByID(ctx, contextInfo, categoryID) + util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::GetPurchaseCategory") +} + +func (h *PurchaseCategoryHandler) ListPurchaseCategories(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + req := &contract.ListPurchaseCategoriesRequest{ + Page: 1, + Limit: 100, + } + + if pageStr := c.Query("page"); pageStr != "" { + if page, err := strconv.Atoi(pageStr); err == nil { + req.Page = page + } + } + + if limitStr := c.Query("limit"); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil { + req.Limit = limit + } + } + + if parentIDStr := c.Query("parent_id"); parentIDStr != "" { + if parentID, err := uuid.Parse(parentIDStr); err == nil { + req.ParentID = &parentID + } + } + + if categoryType := c.Query("type"); categoryType != "" { + req.Type = categoryType + } + + if search := c.Query("search"); search != "" { + req.Search = search + } + + if isActiveStr := c.Query("is_active"); isActiveStr != "" { + if isActive, err := strconv.ParseBool(isActiveStr); err == nil { + req.IsActive = &isActive + } + } + + if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateListPurchaseCategoriesRequest(req); validationError != nil { + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::ListPurchaseCategories") + return + } + + response := h.purchaseCategoryService.ListPurchaseCategories(ctx, contextInfo, req) + util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::ListPurchaseCategories") +} diff --git a/internal/mappers/purchase_category_mapper.go b/internal/mappers/purchase_category_mapper.go new file mode 100644 index 0000000..a781331 --- /dev/null +++ b/internal/mappers/purchase_category_mapper.go @@ -0,0 +1,53 @@ +package mappers + +import ( + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +func CreatePurchaseCategoryRequestToEntity(req *models.CreatePurchaseCategoryRequest) *entities.PurchaseCategory { + if req == nil { + return nil + } + + return &entities.PurchaseCategory{ + OrganizationID: req.OrganizationID, + ParentID: req.ParentID, + Name: req.Name, + Type: entities.PurchaseCategoryType(req.Type), + SortOrder: req.SortOrder, + IsActive: req.IsActive, + } +} + +func PurchaseCategoryEntityToResponse(entity *entities.PurchaseCategory) *models.PurchaseCategoryResponse { + if entity == nil { + return nil + } + + return &models.PurchaseCategoryResponse{ + ID: entity.ID, + OrganizationID: entity.OrganizationID, + PresetID: entity.PresetID, + ParentID: entity.ParentID, + Code: entity.Code, + Name: entity.Name, + Type: string(entity.Type), + SortOrder: entity.SortOrder, + IsSystem: entity.IsSystem, + IsActive: entity.IsActive, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } +} + +func PurchaseCategoryEntitiesToResponses(categoryEntities []*entities.PurchaseCategory) []models.PurchaseCategoryResponse { + responses := make([]models.PurchaseCategoryResponse, len(categoryEntities)) + for i, entity := range categoryEntities { + response := PurchaseCategoryEntityToResponse(entity) + if response != nil { + responses[i] = *response + } + } + return responses +} diff --git a/internal/models/purchase_category.go b/internal/models/purchase_category.go new file mode 100644 index 0000000..bb2f26e --- /dev/null +++ b/internal/models/purchase_category.go @@ -0,0 +1,51 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type PurchaseCategoryResponse struct { + ID uuid.UUID + OrganizationID uuid.UUID + PresetID *uuid.UUID + ParentID *uuid.UUID + Code string + Name string + Type string + SortOrder int + IsSystem bool + IsActive bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type CreatePurchaseCategoryRequest struct { + OrganizationID uuid.UUID + ParentID *uuid.UUID + Code *string + Name string + Type string + SortOrder int + IsActive bool +} + +type UpdatePurchaseCategoryRequest struct { + ParentID *uuid.UUID + Code *string + Name *string + Type *string + SortOrder *int + IsActive *bool +} + +type ListPurchaseCategoriesRequest struct { + OrganizationID uuid.UUID + ParentID *uuid.UUID + Type string + Search string + IsActive *bool + Page int + Limit int +} diff --git a/internal/processor/purchase_category_processor.go b/internal/processor/purchase_category_processor.go new file mode 100644 index 0000000..feda869 --- /dev/null +++ b/internal/processor/purchase_category_processor.go @@ -0,0 +1,210 @@ +package processor + +import ( + "context" + "fmt" + "strings" + "unicode" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +type PurchaseCategoryProcessor interface { + CreatePurchaseCategory(ctx context.Context, req *models.CreatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) + UpdatePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) + DeletePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID) error + GetPurchaseCategoryByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseCategoryResponse, error) + ListPurchaseCategories(ctx context.Context, req *models.ListPurchaseCategoriesRequest) ([]models.PurchaseCategoryResponse, int, error) +} + +type PurchaseCategoryRepository interface { + Create(ctx context.Context, category *entities.PurchaseCategory) error + GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.PurchaseCategory, error) + Update(ctx context.Context, category *entities.PurchaseCategory) error + SoftDelete(ctx context.Context, id, organizationID uuid.UUID) error + List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.PurchaseCategory, int64, error) + ExistsByCode(ctx context.Context, organizationID uuid.UUID, code string, excludeID *uuid.UUID) (bool, error) +} + +type PurchaseCategoryProcessorImpl struct { + purchaseCategoryRepo PurchaseCategoryRepository +} + +func NewPurchaseCategoryProcessorImpl(purchaseCategoryRepo PurchaseCategoryRepository) *PurchaseCategoryProcessorImpl { + return &PurchaseCategoryProcessorImpl{purchaseCategoryRepo: purchaseCategoryRepo} +} + +func (p *PurchaseCategoryProcessorImpl) CreatePurchaseCategory(ctx context.Context, req *models.CreatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) { + code := "" + if req.Code != nil && strings.TrimSpace(*req.Code) != "" { + code = normalizePurchaseCategoryCode(*req.Code) + } else { + code = normalizePurchaseCategoryCode(req.Name) + } + + if code == "" { + return nil, fmt.Errorf("purchase category code cannot be empty") + } + + exists, err := p.purchaseCategoryRepo.ExistsByCode(ctx, req.OrganizationID, code, nil) + if err != nil { + return nil, fmt.Errorf("failed to check purchase category code uniqueness: %w", err) + } + if exists { + return nil, fmt.Errorf("purchase category with code '%s' already exists", code) + } + + if req.ParentID != nil { + parent, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, *req.ParentID, req.OrganizationID) + if err != nil { + return nil, fmt.Errorf("parent purchase category not found: %w", err) + } + if string(parent.Type) != req.Type { + return nil, fmt.Errorf("parent purchase category type must match child type") + } + } + + category := mappers.CreatePurchaseCategoryRequestToEntity(req) + category.Code = code + + if err := p.purchaseCategoryRepo.Create(ctx, category); err != nil { + return nil, fmt.Errorf("failed to create purchase category: %w", err) + } + + return mappers.PurchaseCategoryEntityToResponse(category), nil +} + +func (p *PurchaseCategoryProcessorImpl) UpdatePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) { + category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("purchase category not found: %w", err) + } + + newType := string(category.Type) + if req.Type != nil { + newType = *req.Type + } + + if req.ParentID != nil { + if *req.ParentID == id { + return nil, fmt.Errorf("purchase category cannot be its own parent") + } + + parent, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, *req.ParentID, organizationID) + if err != nil { + return nil, fmt.Errorf("parent purchase category not found: %w", err) + } + if string(parent.Type) != newType { + return nil, fmt.Errorf("parent purchase category type must match child type") + } + category.ParentID = req.ParentID + } + + if req.Code != nil { + code := normalizePurchaseCategoryCode(*req.Code) + if code == "" { + return nil, fmt.Errorf("purchase category code cannot be empty") + } + + if code != category.Code { + exists, err := p.purchaseCategoryRepo.ExistsByCode(ctx, organizationID, code, &id) + if err != nil { + return nil, fmt.Errorf("failed to check purchase category code uniqueness: %w", err) + } + if exists { + return nil, fmt.Errorf("purchase category with code '%s' already exists", code) + } + category.Code = code + } + } + + if req.Name != nil { + category.Name = strings.TrimSpace(*req.Name) + } + if req.Type != nil { + category.Type = entities.PurchaseCategoryType(*req.Type) + } + if req.SortOrder != nil { + category.SortOrder = *req.SortOrder + } + if req.IsActive != nil { + category.IsActive = *req.IsActive + } + + if err := p.purchaseCategoryRepo.Update(ctx, category); err != nil { + return nil, fmt.Errorf("failed to update purchase category: %w", err) + } + + return mappers.PurchaseCategoryEntityToResponse(category), nil +} + +func (p *PurchaseCategoryProcessorImpl) DeletePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID) error { + _, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return fmt.Errorf("purchase category not found: %w", err) + } + + if err := p.purchaseCategoryRepo.SoftDelete(ctx, id, organizationID); err != nil { + return fmt.Errorf("failed to delete purchase category: %w", err) + } + + return nil +} + +func (p *PurchaseCategoryProcessorImpl) GetPurchaseCategoryByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseCategoryResponse, error) { + category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID) + if err != nil { + return nil, fmt.Errorf("purchase category not found: %w", err) + } + + return mappers.PurchaseCategoryEntityToResponse(category), nil +} + +func (p *PurchaseCategoryProcessorImpl) ListPurchaseCategories(ctx context.Context, req *models.ListPurchaseCategoriesRequest) ([]models.PurchaseCategoryResponse, int, error) { + filters := make(map[string]interface{}) + if req.ParentID != nil { + filters["parent_id"] = *req.ParentID + } + if req.Type != "" { + filters["type"] = req.Type + } + if req.Search != "" { + filters["search"] = req.Search + } + if req.IsActive != nil { + filters["is_active"] = *req.IsActive + } + + offset := (req.Page - 1) * req.Limit + categories, total, err := p.purchaseCategoryRepo.List(ctx, req.OrganizationID, filters, req.Limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to list purchase categories: %w", err) + } + + return mappers.PurchaseCategoryEntitiesToResponses(categories), int(total), nil +} + +func normalizePurchaseCategoryCode(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + var builder strings.Builder + lastUnderscore := false + + for _, r := range value { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + builder.WriteRune(r) + lastUnderscore = false + continue + } + + if !lastUnderscore { + builder.WriteRune('_') + lastUnderscore = true + } + } + + return strings.Trim(builder.String(), "_") +} diff --git a/internal/repository/purchase_category_repository.go b/internal/repository/purchase_category_repository.go new file mode 100644 index 0000000..331a423 --- /dev/null +++ b/internal/repository/purchase_category_repository.go @@ -0,0 +1,91 @@ +package repository + +import ( + "context" + + "apskel-pos-be/internal/entities" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type PurchaseCategoryRepositoryImpl struct { + db *gorm.DB +} + +func NewPurchaseCategoryRepositoryImpl(db *gorm.DB) *PurchaseCategoryRepositoryImpl { + return &PurchaseCategoryRepositoryImpl{db: db} +} + +func (r *PurchaseCategoryRepositoryImpl) Create(ctx context.Context, category *entities.PurchaseCategory) error { + return r.db.WithContext(ctx).Create(category).Error +} + +func (r *PurchaseCategoryRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.PurchaseCategory, error) { + var category entities.PurchaseCategory + err := r.db.WithContext(ctx). + First(&category, "id = ? AND organization_id = ?", id, organizationID).Error + if err != nil { + return nil, err + } + return &category, nil +} + +func (r *PurchaseCategoryRepositoryImpl) Update(ctx context.Context, category *entities.PurchaseCategory) error { + return r.db.WithContext(ctx).Save(category).Error +} + +func (r *PurchaseCategoryRepositoryImpl) SoftDelete(ctx context.Context, id, organizationID uuid.UUID) error { + return r.db.WithContext(ctx). + Model(&entities.PurchaseCategory{}). + Where("id = ? AND organization_id = ?", id, organizationID). + Update("is_active", false).Error +} + +func (r *PurchaseCategoryRepositoryImpl) List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.PurchaseCategory, int64, error) { + var categories []*entities.PurchaseCategory + var total int64 + + query := r.db.WithContext(ctx). + Model(&entities.PurchaseCategory{}). + Where("organization_id = ?", organizationID) + + for key, value := range filters { + switch key { + case "search": + searchValue := "%" + value.(string) + "%" + query = query.Where("name ILIKE ? OR code ILIKE ?", searchValue, searchValue) + case "parent_id": + query = query.Where("parent_id = ?", value) + case "type": + query = query.Where("type = ?", value) + case "is_active": + query = query.Where("is_active = ?", value) + } + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + err := query. + Order("parent_id NULLS FIRST, sort_order ASC, name ASC"). + Limit(limit). + Offset(offset). + Find(&categories).Error + return categories, total, err +} + +func (r *PurchaseCategoryRepositoryImpl) ExistsByCode(ctx context.Context, organizationID uuid.UUID, code string, excludeID *uuid.UUID) (bool, error) { + query := r.db.WithContext(ctx). + Model(&entities.PurchaseCategory{}). + Where("organization_id = ? AND code = ?", organizationID, code) + + if excludeID != nil { + query = query.Where("id != ?", *excludeID) + } + + var count int64 + err := query.Count(&count).Error + return count > 0, err +} diff --git a/internal/router/router.go b/internal/router/router.go index f9fdee7..d3efbcd 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -35,6 +35,7 @@ type Router struct { productRecipeHandler *handler.ProductRecipeHandler vendorHandler *handler.VendorHandler purchaseOrderHandler *handler.PurchaseOrderHandler + purchaseCategoryHandler *handler.PurchaseCategoryHandler unitConverterHandler *handler.IngredientUnitConverterHandler chartOfAccountTypeHandler *handler.ChartOfAccountTypeHandler chartOfAccountHandler *handler.ChartOfAccountHandler @@ -55,7 +56,7 @@ type Router struct { customerAuthMiddleware *middleware.CustomerAuthMiddleware } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl) *Router { return &Router{ config: cfg, @@ -80,6 +81,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer productRecipeHandler: handler.NewProductRecipeHandler(productRecipeService), vendorHandler: handler.NewVendorHandler(vendorService, vendorValidator), purchaseOrderHandler: handler.NewPurchaseOrderHandler(purchaseOrderService, purchaseOrderValidator), + purchaseCategoryHandler: handler.NewPurchaseCategoryHandler(purchaseCategoryService, purchaseCategoryValidator), unitConverterHandler: handler.NewIngredientUnitConverterHandler(unitConverterService, unitConverterValidator), chartOfAccountTypeHandler: handler.NewChartOfAccountTypeHandler(chartOfAccountTypeService, chartOfAccountTypeValidator), chartOfAccountHandler: handler.NewChartOfAccountHandler(chartOfAccountService, chartOfAccountValidator), @@ -384,6 +386,16 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { purchaseOrders.DELETE("/:id", r.purchaseOrderHandler.DeletePurchaseOrder) } + purchaseCategories := protected.Group("/purchase-categories") + purchaseCategories.Use(r.authMiddleware.RequireAdminOrManager()) + { + purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory) + purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories) + purchaseCategories.GET("/:id", r.purchaseCategoryHandler.GetPurchaseCategory) + purchaseCategories.PUT("/:id", r.purchaseCategoryHandler.UpdatePurchaseCategory) + purchaseCategories.DELETE("/:id", r.purchaseCategoryHandler.DeletePurchaseCategory) + } + unitConverters := protected.Group("/unit-converters") unitConverters.Use(r.authMiddleware.RequireAdminOrManager()) { diff --git a/internal/service/purchase_category_service.go b/internal/service/purchase_category_service.go new file mode 100644 index 0000000..eacc77c --- /dev/null +++ b/internal/service/purchase_category_service.go @@ -0,0 +1,107 @@ +package service + +import ( + "context" + + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/transformer" + + "github.com/google/uuid" +) + +type PurchaseCategoryService interface { + CreatePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreatePurchaseCategoryRequest) *contract.Response + UpdatePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdatePurchaseCategoryRequest) *contract.Response + DeletePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + GetPurchaseCategoryByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + ListPurchaseCategories(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListPurchaseCategoriesRequest) *contract.Response +} + +type PurchaseCategoryServiceImpl struct { + purchaseCategoryProcessor processor.PurchaseCategoryProcessor +} + +func NewPurchaseCategoryService(purchaseCategoryProcessor processor.PurchaseCategoryProcessor) *PurchaseCategoryServiceImpl { + return &PurchaseCategoryServiceImpl{purchaseCategoryProcessor: purchaseCategoryProcessor} +} + +func (s *PurchaseCategoryServiceImpl) CreatePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreatePurchaseCategoryRequest) *contract.Response { + modelReq := transformer.CreatePurchaseCategoryRequestToModel(apctx, req) + + category, err := s.purchaseCategoryProcessor.CreatePurchaseCategory(ctx, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.PurchaseCategoryModelResponseToResponse(category)) +} + +func (s *PurchaseCategoryServiceImpl) UpdatePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdatePurchaseCategoryRequest) *contract.Response { + modelReq := transformer.UpdatePurchaseCategoryRequestToModel(req) + + category, err := s.purchaseCategoryProcessor.UpdatePurchaseCategory(ctx, id, apctx.OrganizationID, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.PurchaseCategoryModelResponseToResponse(category)) +} + +func (s *PurchaseCategoryServiceImpl) DeletePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + err := s.purchaseCategoryProcessor.DeletePurchaseCategory(ctx, id, apctx.OrganizationID) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(map[string]interface{}{ + "message": "Purchase category deleted successfully", + }) +} + +func (s *PurchaseCategoryServiceImpl) GetPurchaseCategoryByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + category, err := s.purchaseCategoryProcessor.GetPurchaseCategoryByID(ctx, id, apctx.OrganizationID) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.PurchaseCategoryModelResponseToResponse(category)) +} + +func (s *PurchaseCategoryServiceImpl) ListPurchaseCategories(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListPurchaseCategoriesRequest) *contract.Response { + modelReq := &models.ListPurchaseCategoriesRequest{ + OrganizationID: apctx.OrganizationID, + ParentID: req.ParentID, + Type: req.Type, + Search: req.Search, + IsActive: req.IsActive, + Page: req.Page, + Limit: req.Limit, + } + + categories, totalCount, err := s.purchaseCategoryProcessor.ListPurchaseCategories(ctx, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + totalPages := totalCount / req.Limit + if totalCount%req.Limit > 0 { + totalPages++ + } + + return contract.BuildSuccessResponse(&contract.ListPurchaseCategoriesResponse{ + PurchaseCategories: transformer.PurchaseCategoryModelResponsesToResponses(categories), + TotalCount: totalCount, + Page: req.Page, + Limit: req.Limit, + TotalPages: totalPages, + }) +} diff --git a/internal/transformer/purchase_category_transformer.go b/internal/transformer/purchase_category_transformer.go new file mode 100644 index 0000000..24f3d97 --- /dev/null +++ b/internal/transformer/purchase_category_transformer.go @@ -0,0 +1,72 @@ +package transformer + +import ( + "apskel-pos-be/internal/appcontext" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" +) + +func CreatePurchaseCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreatePurchaseCategoryRequest) *models.CreatePurchaseCategoryRequest { + sortOrder := 0 + if req.SortOrder != nil { + sortOrder = *req.SortOrder + } + + isActive := true + if req.IsActive != nil { + isActive = *req.IsActive + } + + return &models.CreatePurchaseCategoryRequest{ + OrganizationID: apctx.OrganizationID, + ParentID: req.ParentID, + Code: req.Code, + Name: req.Name, + Type: req.Type, + SortOrder: sortOrder, + IsActive: isActive, + } +} + +func UpdatePurchaseCategoryRequestToModel(req *contract.UpdatePurchaseCategoryRequest) *models.UpdatePurchaseCategoryRequest { + return &models.UpdatePurchaseCategoryRequest{ + ParentID: req.ParentID, + Code: req.Code, + Name: req.Name, + Type: req.Type, + SortOrder: req.SortOrder, + IsActive: req.IsActive, + } +} + +func PurchaseCategoryModelResponseToResponse(category *models.PurchaseCategoryResponse) *contract.PurchaseCategoryResponse { + if category == nil { + return nil + } + + return &contract.PurchaseCategoryResponse{ + ID: category.ID, + OrganizationID: category.OrganizationID, + PresetID: category.PresetID, + ParentID: category.ParentID, + Code: category.Code, + Name: category.Name, + Type: category.Type, + SortOrder: category.SortOrder, + IsSystem: category.IsSystem, + IsActive: category.IsActive, + CreatedAt: category.CreatedAt, + UpdatedAt: category.UpdatedAt, + } +} + +func PurchaseCategoryModelResponsesToResponses(categories []models.PurchaseCategoryResponse) []contract.PurchaseCategoryResponse { + responses := make([]contract.PurchaseCategoryResponse, len(categories)) + for i, category := range categories { + response := PurchaseCategoryModelResponseToResponse(&category) + if response != nil { + responses[i] = *response + } + } + return responses +} diff --git a/internal/validator/purchase_category_validator.go b/internal/validator/purchase_category_validator.go new file mode 100644 index 0000000..10b1f5c --- /dev/null +++ b/internal/validator/purchase_category_validator.go @@ -0,0 +1,103 @@ +package validator + +import ( + "errors" + "strings" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" +) + +type PurchaseCategoryValidator interface { + ValidateCreatePurchaseCategoryRequest(req *contract.CreatePurchaseCategoryRequest) (error, string) + ValidateUpdatePurchaseCategoryRequest(req *contract.UpdatePurchaseCategoryRequest) (error, string) + ValidateListPurchaseCategoriesRequest(req *contract.ListPurchaseCategoriesRequest) (error, string) +} + +type PurchaseCategoryValidatorImpl struct{} + +func NewPurchaseCategoryValidator() *PurchaseCategoryValidatorImpl { + return &PurchaseCategoryValidatorImpl{} +} + +func (v *PurchaseCategoryValidatorImpl) ValidateCreatePurchaseCategoryRequest(req *contract.CreatePurchaseCategoryRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.Name) == "" { + return errors.New("name is required"), constants.MissingFieldErrorCode + } + + if len(req.Name) > 255 { + return errors.New("name cannot exceed 255 characters"), constants.MalformedFieldErrorCode + } + + if !isValidPurchaseCategoryType(req.Type) { + return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode + } + + if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 { + return errors.New("code cannot exceed 100 characters"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *PurchaseCategoryValidatorImpl) ValidateUpdatePurchaseCategoryRequest(req *contract.UpdatePurchaseCategoryRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if req.ParentID == nil && req.Code == nil && req.Name == nil && req.Type == nil && req.SortOrder == nil && req.IsActive == nil { + return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode + } + + if req.Name != nil { + if strings.TrimSpace(*req.Name) == "" { + return errors.New("name cannot be empty"), constants.MalformedFieldErrorCode + } + if len(*req.Name) > 255 { + return errors.New("name cannot exceed 255 characters"), constants.MalformedFieldErrorCode + } + } + + if req.Type != nil && !isValidPurchaseCategoryType(*req.Type) { + return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode + } + + if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 { + return errors.New("code cannot exceed 100 characters"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(req *contract.ListPurchaseCategoriesRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.MissingFieldErrorCode + } + + if req.Page < 1 { + return errors.New("page must be at least 1"), constants.MalformedFieldErrorCode + } + + if req.Limit < 1 || req.Limit > 100 { + return errors.New("limit must be between 1 and 100"), constants.MalformedFieldErrorCode + } + + if req.Type != "" && !isValidPurchaseCategoryType(req.Type) { + return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func isValidPurchaseCategoryType(categoryType string) bool { + switch categoryType { + case "raw_material", "non_inventory": + return true + default: + return false + } +} diff --git a/migrations/000075_create_purchase_categories.down.sql b/migrations/000075_create_purchase_categories.down.sql new file mode 100644 index 0000000..350087f --- /dev/null +++ b/migrations/000075_create_purchase_categories.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS purchase_categories; +DROP TABLE IF EXISTS purchase_category_presets; diff --git a/migrations/000075_create_purchase_categories.up.sql b/migrations/000075_create_purchase_categories.up.sql new file mode 100644 index 0000000..2f78bc6 --- /dev/null +++ b/migrations/000075_create_purchase_categories.up.sql @@ -0,0 +1,64 @@ +CREATE TABLE purchase_category_presets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + parent_id UUID REFERENCES purchase_category_presets(id) ON DELETE SET NULL, + code VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'non_inventory')), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE purchase_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + preset_id UUID REFERENCES purchase_category_presets(id) ON DELETE SET NULL, + parent_id UUID REFERENCES purchase_categories(id) ON DELETE SET NULL, + code VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'non_inventory')), + sort_order INTEGER NOT NULL DEFAULT 0, + is_system BOOLEAN NOT NULL DEFAULT false, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE (organization_id, code) +); + +CREATE INDEX idx_purchase_category_presets_parent_id ON purchase_category_presets(parent_id); +CREATE INDEX idx_purchase_category_presets_type ON purchase_category_presets(type); +CREATE INDEX idx_purchase_category_presets_is_active ON purchase_category_presets(is_active); + +CREATE INDEX idx_purchase_categories_organization_id ON purchase_categories(organization_id); +CREATE INDEX idx_purchase_categories_preset_id ON purchase_categories(preset_id); +CREATE INDEX idx_purchase_categories_parent_id ON purchase_categories(parent_id); +CREATE INDEX idx_purchase_categories_type ON purchase_categories(type); +CREATE INDEX idx_purchase_categories_is_active ON purchase_categories(is_active); + +INSERT INTO purchase_category_presets (code, name, type, sort_order) +VALUES + ('hpp', 'HPP', 'raw_material', 1), + ('biaya_lain_lain', 'Biaya Lain-lain', 'non_inventory', 2) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO purchase_category_presets (parent_id, code, name, type, sort_order) +SELECT parent.id, child.code, child.name, child.type, child.sort_order +FROM purchase_category_presets parent +JOIN ( + VALUES + ('hpp', 'hpp_bakso_mie_ayam', 'Bakso & Mie Ayam', 'raw_material', 1), + ('hpp', 'hpp_nusantara', 'Nusantara', 'raw_material', 2), + ('hpp', 'hpp_ramen', 'Ramen', 'raw_material', 3), + ('hpp', 'hpp_minuman_kopi', 'Minuman/Kopi', 'raw_material', 4), + ('biaya_lain_lain', 'biaya_atk_perlengkapan', 'ATK & Perlengkapan', 'non_inventory', 1), + ('biaya_lain_lain', 'biaya_makan_karyawan', 'Makan Karyawan', 'non_inventory', 2), + ('biaya_lain_lain', 'biaya_bensin_parkir', 'Bensin & Parkir', 'non_inventory', 3), + ('biaya_lain_lain', 'biaya_kebersihan_keamanan', 'Kebersihan & Keamanan', 'non_inventory', 4), + ('biaya_lain_lain', 'biaya_gaji_dw', 'Gaji DW', 'non_inventory', 5), + ('biaya_lain_lain', 'biaya_gaji_staff', 'Gaji Staff', 'non_inventory', 6), + ('biaya_lain_lain', 'biaya_internet_server', 'Internet & Server', 'non_inventory', 7), + ('biaya_lain_lain', 'biaya_air_listrik', 'Air & Listrik', 'non_inventory', 8), + ('biaya_lain_lain', 'biaya_promosi', 'Promosi', 'non_inventory', 9) +) AS child(parent_code, code, name, type, sort_order) ON parent.code = child.parent_code +ON CONFLICT (code) DO NOTHING; diff --git a/migrations/000076_add_default_purchase_categories_trigger.down.sql b/migrations/000076_add_default_purchase_categories_trigger.down.sql new file mode 100644 index 0000000..ccfc82e --- /dev/null +++ b/migrations/000076_add_default_purchase_categories_trigger.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS trigger_create_default_purchase_categories ON organizations; +DROP FUNCTION IF EXISTS create_default_purchase_categories(); diff --git a/migrations/000076_add_default_purchase_categories_trigger.up.sql b/migrations/000076_add_default_purchase_categories_trigger.up.sql new file mode 100644 index 0000000..d5ff930 --- /dev/null +++ b/migrations/000076_add_default_purchase_categories_trigger.up.sql @@ -0,0 +1,49 @@ +CREATE OR REPLACE FUNCTION create_default_purchase_categories() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO purchase_categories (organization_id, preset_id, parent_id, code, name, type, sort_order, is_system, is_active, created_at, updated_at) + SELECT + NEW.id, + preset.id, + NULL, + preset.code, + preset.name, + preset.type, + preset.sort_order, + true, + preset.is_active, + NOW(), + NOW() + FROM purchase_category_presets preset + WHERE preset.parent_id IS NULL + AND preset.is_active = true + ON CONFLICT (organization_id, code) DO NOTHING; + + INSERT INTO purchase_categories (organization_id, preset_id, parent_id, code, name, type, sort_order, is_system, is_active, created_at, updated_at) + SELECT + NEW.id, + child_preset.id, + parent_category.id, + child_preset.code, + child_preset.name, + child_preset.type, + child_preset.sort_order, + true, + child_preset.is_active, + NOW(), + NOW() + FROM purchase_category_presets child_preset + JOIN purchase_category_presets parent_preset ON child_preset.parent_id = parent_preset.id + JOIN purchase_categories parent_category ON parent_category.organization_id = NEW.id + AND parent_category.code = parent_preset.code + WHERE child_preset.is_active = true + ON CONFLICT (organization_id, code) DO NOTHING; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_create_default_purchase_categories + AFTER INSERT ON organizations + FOR EACH ROW + EXECUTE FUNCTION create_default_purchase_categories(); diff --git a/migrations/000077_backfill_default_purchase_categories.down.sql b/migrations/000077_backfill_default_purchase_categories.down.sql new file mode 100644 index 0000000..baf5349 --- /dev/null +++ b/migrations/000077_backfill_default_purchase_categories.down.sql @@ -0,0 +1,3 @@ +DELETE FROM purchase_categories +WHERE is_system = true + AND preset_id IS NOT NULL; diff --git a/migrations/000077_backfill_default_purchase_categories.up.sql b/migrations/000077_backfill_default_purchase_categories.up.sql new file mode 100644 index 0000000..9901256 --- /dev/null +++ b/migrations/000077_backfill_default_purchase_categories.up.sql @@ -0,0 +1,39 @@ +INSERT INTO purchase_categories (organization_id, preset_id, parent_id, code, name, type, sort_order, is_system, is_active, created_at, updated_at) +SELECT + org.id, + preset.id, + NULL, + preset.code, + preset.name, + preset.type, + preset.sort_order, + true, + preset.is_active, + NOW(), + NOW() +FROM organizations org +CROSS JOIN purchase_category_presets preset +WHERE preset.parent_id IS NULL + AND preset.is_active = true +ON CONFLICT (organization_id, code) DO NOTHING; + +INSERT INTO purchase_categories (organization_id, preset_id, parent_id, code, name, type, sort_order, is_system, is_active, created_at, updated_at) +SELECT + org.id, + child_preset.id, + parent_category.id, + child_preset.code, + child_preset.name, + child_preset.type, + child_preset.sort_order, + true, + child_preset.is_active, + NOW(), + NOW() +FROM organizations org +JOIN purchase_category_presets child_preset ON child_preset.parent_id IS NOT NULL +JOIN purchase_category_presets parent_preset ON child_preset.parent_id = parent_preset.id +JOIN purchase_categories parent_category ON parent_category.organization_id = org.id + AND parent_category.code = parent_preset.code +WHERE child_preset.is_active = true +ON CONFLICT (organization_id, code) DO NOTHING; From 29aeb58fc09ba563e37e66876d3d3e37ceee65f7 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 8 Jun 2026 12:30:39 +0700 Subject: [PATCH 05/16] Fix formatting --- internal/contract/account_request.go | 42 ++++++++-------- .../ingredient_unit_converter_contract.go | 1 - internal/contract/inventory_contract.go | 12 ++--- internal/contract/product_recipe_contract.go | 50 +++++++++---------- .../entities/ingredient_unit_converter.go | 1 - .../entities/order_ingredient_transaction.go | 14 +++--- internal/entities/product_ingredient.go | 18 +++---- internal/entities/product_recipe.go | 2 +- .../handler/chart_of_account_type_handler.go | 2 +- .../ingredient_unit_converter_handler.go | 1 - internal/handler/product_recipe_handler.go | 2 +- internal/mappers/product_ingredient_mapper.go | 44 ++++++++-------- internal/models/account.go | 12 ++--- internal/models/customer.go | 22 ++++---- internal/models/ingredient_unit_converter.go | 1 - .../models/order_ingredient_transaction.go | 18 +++---- internal/models/product_ingredient.go | 36 ++++++------- internal/models/product_recipe.go | 2 +- internal/service/account_service.go | 12 ++--- .../service/chart_of_account_type_service.go | 4 +- .../ingredient_unit_converter_service.go | 1 - internal/service/product_recipe_service.go | 2 +- .../ingredient_unit_converter_transformer.go | 1 - internal/util/waste_util.go | 4 +- .../ingredient_unit_converter_validator.go | 1 - 25 files changed, 149 insertions(+), 156 deletions(-) diff --git a/internal/contract/account_request.go b/internal/contract/account_request.go index b77dcdb..e794d6b 100644 --- a/internal/contract/account_request.go +++ b/internal/contract/account_request.go @@ -5,12 +5,12 @@ import ( ) type CreateAccountRequest struct { - ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"` - Name string `json:"name" validate:"required,min=1,max=255"` - Number string `json:"number" validate:"required,min=1,max=50"` - AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"` - OpeningBalance float64 `json:"opening_balance"` - Description *string `json:"description"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"` + Name string `json:"name" validate:"required,min=1,max=255"` + Number string `json:"number" validate:"required,min=1,max=50"` + AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"` + OpeningBalance float64 `json:"opening_balance"` + Description *string `json:"description"` } type UpdateAccountRequest struct { @@ -24,21 +24,21 @@ type UpdateAccountRequest struct { } type AccountResponse struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id"` - ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` - Name string `json:"name"` - Number string `json:"number"` - AccountType string `json:"account_type"` - OpeningBalance float64 `json:"opening_balance"` - CurrentBalance float64 `json:"current_balance"` - Description *string `json:"description"` - IsActive bool `json:"is_active"` - IsSystem bool `json:"is_system"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - ChartOfAccount *ChartOfAccountResponse `json:"chart_of_account,omitempty"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + Name string `json:"name"` + Number string `json:"number"` + AccountType string `json:"account_type"` + OpeningBalance float64 `json:"opening_balance"` + CurrentBalance float64 `json:"current_balance"` + Description *string `json:"description"` + IsActive bool `json:"is_active"` + IsSystem bool `json:"is_system"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ChartOfAccount *ChartOfAccountResponse `json:"chart_of_account,omitempty"` } type ListAccountsRequest struct { diff --git a/internal/contract/ingredient_unit_converter_contract.go b/internal/contract/ingredient_unit_converter_contract.go index de37f8a..9741814 100644 --- a/internal/contract/ingredient_unit_converter_contract.go +++ b/internal/contract/ingredient_unit_converter_contract.go @@ -81,4 +81,3 @@ type IngredientUnitsResponse struct { BaseUnitName string `json:"base_unit_name"` Units []*UnitResponse `json:"units"` } - diff --git a/internal/contract/inventory_contract.go b/internal/contract/inventory_contract.go index 0b1f642..1dbc76d 100644 --- a/internal/contract/inventory_contract.go +++ b/internal/contract/inventory_contract.go @@ -26,9 +26,9 @@ type AdjustInventoryRequest struct { } type RestockInventoryRequest struct { - OutletID uuid.UUID `json:"outlet_id" validate:"required"` + OutletID uuid.UUID `json:"outlet_id" validate:"required"` Items []RestockItem `json:"items" validate:"required,min=1,dive"` - Reason string `json:"reason" validate:"required,min=1,max=255"` + Reason string `json:"reason" validate:"required,min=1,max=255"` } type RestockItem struct { @@ -82,10 +82,10 @@ type InventoryAdjustmentResponse struct { } type RestockInventoryResponse struct { - OutletID uuid.UUID `json:"outlet_id"` - Items []RestockItemResult `json:"items"` - Reason string `json:"reason"` - RestockedAt time.Time `json:"restocked_at"` + OutletID uuid.UUID `json:"outlet_id"` + Items []RestockItemResult `json:"items"` + Reason string `json:"reason"` + RestockedAt time.Time `json:"restocked_at"` } type RestockItemResult struct { diff --git a/internal/contract/product_recipe_contract.go b/internal/contract/product_recipe_contract.go index 9df640c..cc73de2 100644 --- a/internal/contract/product_recipe_contract.go +++ b/internal/contract/product_recipe_contract.go @@ -34,34 +34,34 @@ type BulkCreateProductRecipeRequest struct { // Response structures type ProductRecipeResponse struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id"` - ProductID uuid.UUID `json:"product_id"` - VariantID *uuid.UUID `json:"variant_id"` - IngredientID uuid.UUID `json:"ingredient_id"` - Quantity float64 `json:"quantity"` - WastePercentage float64 `json:"waste_percentage"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Product *ProductResponse `json:"product,omitempty"` - ProductVariant *ProductVariantResponse `json:"product_variant,omitempty"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + ProductID uuid.UUID `json:"product_id"` + VariantID *uuid.UUID `json:"variant_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + Quantity float64 `json:"quantity"` + WastePercentage float64 `json:"waste_percentage"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Product *ProductResponse `json:"product,omitempty"` + ProductVariant *ProductVariantResponse `json:"product_variant,omitempty"` Ingredient *ProductRecipeIngredientResponse `json:"ingredient,omitempty"` } type ProductRecipeIngredientResponse struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id"` - Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` - Cost float64 `json:"cost"` - Stock float64 `json:"stock"` - IsSemiFinished bool `json:"is_semi_finished"` - IsActive bool `json:"is_active"` - Metadata map[string]interface{} `json:"metadata"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + Name string `json:"name"` + UnitID uuid.UUID `json:"unit_id"` + Cost float64 `json:"cost"` + Stock float64 `json:"stock"` + IsSemiFinished bool `json:"is_semi_finished"` + IsActive bool `json:"is_active"` + Metadata map[string]interface{} `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Unit *ProductRecipeUnitResponse `json:"unit,omitempty"` } @@ -71,4 +71,4 @@ type ProductRecipeUnitResponse struct { Symbol string `json:"symbol"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` -} \ No newline at end of file +} diff --git a/internal/entities/ingredient_unit_converter.go b/internal/entities/ingredient_unit_converter.go index d2a3465..ad9650e 100644 --- a/internal/entities/ingredient_unit_converter.go +++ b/internal/entities/ingredient_unit_converter.go @@ -39,4 +39,3 @@ func (iuc *IngredientUnitConverter) BeforeCreate() error { } return nil } - diff --git a/internal/entities/order_ingredient_transaction.go b/internal/entities/order_ingredient_transaction.go index 38b9917..5a50ccf 100644 --- a/internal/entities/order_ingredient_transaction.go +++ b/internal/entities/order_ingredient_transaction.go @@ -26,14 +26,14 @@ type OrderIngredientTransaction struct { UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` // Relations - Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` - Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` - Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` - OrderItem *OrderItem `gorm:"foreignKey:OrderItemID" json:"order_item,omitempty"` - Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` + Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` + Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` + Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` + OrderItem *OrderItem `gorm:"foreignKey:OrderItemID" json:"order_item,omitempty"` + Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"` - Ingredient Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` - CreatedByUser User `gorm:"foreignKey:CreatedBy" json:"created_by_user,omitempty"` + Ingredient Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` + CreatedByUser User `gorm:"foreignKey:CreatedBy" json:"created_by_user,omitempty"` } func (oit *OrderIngredientTransaction) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/entities/product_ingredient.go b/internal/entities/product_ingredient.go index 5d9bb46..20d869c 100644 --- a/internal/entities/product_ingredient.go +++ b/internal/entities/product_ingredient.go @@ -7,15 +7,15 @@ import ( ) type ProductIngredient struct { - ID uuid.UUID `json:"id" db:"id"` - OrganizationID uuid.UUID `json:"organization_id" db:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id" db:"outlet_id"` - ProductID uuid.UUID `json:"product_id" db:"product_id"` - IngredientID uuid.UUID `json:"ingredient_id" db:"ingredient_id"` - Quantity float64 `json:"quantity" db:"quantity"` - WastePercentage float64 `json:"waste_percentage" db:"waste_percentage"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID uuid.UUID `json:"id" db:"id"` + OrganizationID uuid.UUID `json:"organization_id" db:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id" db:"outlet_id"` + ProductID uuid.UUID `json:"product_id" db:"product_id"` + IngredientID uuid.UUID `json:"ingredient_id" db:"ingredient_id"` + Quantity float64 `json:"quantity" db:"quantity"` + WastePercentage float64 `json:"waste_percentage" db:"waste_percentage"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` // Relations Product *Product `json:"product,omitempty"` diff --git a/internal/entities/product_recipe.go b/internal/entities/product_recipe.go index d178f2b..7292fe4 100644 --- a/internal/entities/product_recipe.go +++ b/internal/entities/product_recipe.go @@ -34,4 +34,4 @@ func (pr *ProductRecipe) BeforeCreate(tx *gorm.DB) error { func (ProductRecipe) TableName() string { return "product_recipes" -} \ No newline at end of file +} diff --git a/internal/handler/chart_of_account_type_handler.go b/internal/handler/chart_of_account_type_handler.go index a9cc3ff..43300cb 100644 --- a/internal/handler/chart_of_account_type_handler.go +++ b/internal/handler/chart_of_account_type_handler.go @@ -99,7 +99,7 @@ func (h *ChartOfAccountTypeHandler) DeleteChartOfAccountType(c *gin.Context) { func (h *ChartOfAccountTypeHandler) ListChartOfAccountTypes(c *gin.Context) { // Parse query parameters filters := make(map[string]interface{}) - + if isActive := c.Query("is_active"); isActive != "" { if isActiveBool, err := strconv.ParseBool(isActive); err == nil { filters["is_active"] = isActiveBool diff --git a/internal/handler/ingredient_unit_converter_handler.go b/internal/handler/ingredient_unit_converter_handler.go index e78346b..4dd642e 100644 --- a/internal/handler/ingredient_unit_converter_handler.go +++ b/internal/handler/ingredient_unit_converter_handler.go @@ -275,4 +275,3 @@ func (h *IngredientUnitConverterHandler) GetUnitsByIngredientID(c *gin.Context) util.HandleResponse(c.Writer, c.Request, unitsResponse, "IngredientUnitConverterHandler::GetUnitsByIngredientID") } - diff --git a/internal/handler/product_recipe_handler.go b/internal/handler/product_recipe_handler.go index 15066f1..8078203 100644 --- a/internal/handler/product_recipe_handler.go +++ b/internal/handler/product_recipe_handler.go @@ -219,4 +219,4 @@ func (h *ProductRecipeHandler) BulkCreate(c *gin.Context) { } c.JSON(http.StatusCreated, contract.BuildSuccessResponse(recipes)) -} \ No newline at end of file +} diff --git a/internal/mappers/product_ingredient_mapper.go b/internal/mappers/product_ingredient_mapper.go index b7c99cb..f5dc1bc 100644 --- a/internal/mappers/product_ingredient_mapper.go +++ b/internal/mappers/product_ingredient_mapper.go @@ -11,17 +11,17 @@ func MapProductIngredientEntityToModel(entity *entities.ProductIngredient) *mode } return &models.ProductIngredient{ - ID: entity.ID, - OrganizationID: entity.OrganizationID, - OutletID: entity.OutletID, - ProductID: entity.ProductID, - IngredientID: entity.IngredientID, - Quantity: entity.Quantity, - WastePercentage: entity.WastePercentage, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, - Product: ProductEntityToModel(entity.Product), - Ingredient: MapIngredientEntityToModel(entity.Ingredient), + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + ProductID: entity.ProductID, + IngredientID: entity.IngredientID, + Quantity: entity.Quantity, + WastePercentage: entity.WastePercentage, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + Product: ProductEntityToModel(entity.Product), + Ingredient: MapIngredientEntityToModel(entity.Ingredient), } } @@ -31,17 +31,17 @@ func MapProductIngredientModelToEntity(model *models.ProductIngredient) *entitie } return &entities.ProductIngredient{ - ID: model.ID, - OrganizationID: model.OrganizationID, - OutletID: model.OutletID, - ProductID: model.ProductID, - IngredientID: model.IngredientID, - Quantity: model.Quantity, - WastePercentage: model.WastePercentage, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, - Product: ProductModelToEntity(model.Product), - Ingredient: MapIngredientModelToEntity(model.Ingredient), + ID: model.ID, + OrganizationID: model.OrganizationID, + OutletID: model.OutletID, + ProductID: model.ProductID, + IngredientID: model.IngredientID, + Quantity: model.Quantity, + WastePercentage: model.WastePercentage, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + Product: ProductModelToEntity(model.Product), + Ingredient: MapIngredientModelToEntity(model.Ingredient), } } diff --git a/internal/models/account.go b/internal/models/account.go index 8c943d6..07259bd 100644 --- a/internal/models/account.go +++ b/internal/models/account.go @@ -25,12 +25,12 @@ type AccountResponse struct { } type CreateAccountRequest struct { - ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"` - Name string `json:"name" validate:"required,min=1,max=255"` - Number string `json:"number" validate:"required,min=1,max=50"` - AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"` - OpeningBalance float64 `json:"opening_balance"` - Description *string `json:"description"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"` + Name string `json:"name" validate:"required,min=1,max=255"` + Number string `json:"number" validate:"required,min=1,max=50"` + AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"` + OpeningBalance float64 `json:"opening_balance"` + Description *string `json:"description"` } type UpdateAccountRequest struct { diff --git a/internal/models/customer.go b/internal/models/customer.go index b873198..2b9e388 100644 --- a/internal/models/customer.go +++ b/internal/models/customer.go @@ -23,17 +23,17 @@ type UpdateCustomerRequest struct { } type CustomerResponse struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - Name string `json:"name"` - Email *string `json:"email,omitempty"` - Phone *string `json:"phone,omitempty"` - Address *string `json:"address,omitempty"` - IsDefault bool `json:"is_default"` - IsActive bool `json:"is_active"` - Metadata entities.Metadata `json:"metadata"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + Name string `json:"name"` + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + Address *string `json:"address,omitempty"` + IsDefault bool `json:"is_default"` + IsActive bool `json:"is_active"` + Metadata entities.Metadata `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ListCustomersQuery represents query parameters for listing customers diff --git a/internal/models/ingredient_unit_converter.go b/internal/models/ingredient_unit_converter.go index 3413a7c..af14c0c 100644 --- a/internal/models/ingredient_unit_converter.go +++ b/internal/models/ingredient_unit_converter.go @@ -101,4 +101,3 @@ type IngredientUnitsResponse struct { BaseUnitName string `json:"base_unit_name"` Units []*UnitResponse `json:"units"` } - diff --git a/internal/models/order_ingredient_transaction.go b/internal/models/order_ingredient_transaction.go index b8706fa..7c9f1ab 100644 --- a/internal/models/order_ingredient_transaction.go +++ b/internal/models/order_ingredient_transaction.go @@ -52,8 +52,8 @@ type UpdateOrderIngredientTransactionRequest struct { GrossQty *float64 `json:"gross_qty,omitempty" validate:"omitempty,gt=0"` NetQty *float64 `json:"net_qty,omitempty" validate:"omitempty,gt=0"` WasteQty *float64 `json:"waste_qty,omitempty" validate:"min=0"` - Unit *string `json:"unit,omitempty" validate:"omitempty,max=50"` - TransactionDate *time.Time `json:"transaction_date,omitempty"` + Unit *string `json:"unit,omitempty" validate:"omitempty,max=50"` + TransactionDate *time.Time `json:"transaction_date,omitempty"` } type OrderIngredientTransactionResponse struct { @@ -98,11 +98,11 @@ type ListOrderIngredientTransactionsRequest struct { } type OrderIngredientTransactionSummary struct { - IngredientID uuid.UUID `json:"ingredient_id"` - IngredientName string `json:"ingredient_name"` - TotalGrossQty float64 `json:"total_gross_qty"` - TotalNetQty float64 `json:"total_net_qty"` - TotalWasteQty float64 `json:"total_waste_qty"` - WastePercentage float64 `json:"waste_percentage"` - Unit string `json:"unit"` + IngredientID uuid.UUID `json:"ingredient_id"` + IngredientName string `json:"ingredient_name"` + TotalGrossQty float64 `json:"total_gross_qty"` + TotalNetQty float64 `json:"total_net_qty"` + TotalWasteQty float64 `json:"total_waste_qty"` + WastePercentage float64 `json:"waste_percentage"` + Unit string `json:"unit"` } diff --git a/internal/models/product_ingredient.go b/internal/models/product_ingredient.go index 3c3d9de..6466f1e 100644 --- a/internal/models/product_ingredient.go +++ b/internal/models/product_ingredient.go @@ -7,15 +7,15 @@ import ( ) type ProductIngredient struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id"` - ProductID uuid.UUID `json:"product_id"` - IngredientID uuid.UUID `json:"ingredient_id"` - Quantity float64 `json:"quantity"` - WastePercentage float64 `json:"waste_percentage"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + ProductID uuid.UUID `json:"product_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + Quantity float64 `json:"quantity"` + WastePercentage float64 `json:"waste_percentage"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // Relations Product *Product `json:"product,omitempty"` @@ -37,15 +37,15 @@ type UpdateProductIngredientRequest struct { } type ProductIngredientResponse struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id"` - ProductID uuid.UUID `json:"product_id"` - IngredientID uuid.UUID `json:"ingredient_id"` - Quantity float64 `json:"quantity"` - WastePercentage float64 `json:"waste_percentage"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + ProductID uuid.UUID `json:"product_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + Quantity float64 `json:"quantity"` + WastePercentage float64 `json:"waste_percentage"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // Relations Product *Product `json:"product,omitempty"` diff --git a/internal/models/product_recipe.go b/internal/models/product_recipe.go index 5df109c..a9832ea 100644 --- a/internal/models/product_recipe.go +++ b/internal/models/product_recipe.go @@ -56,4 +56,4 @@ type ProductRecipeResponse struct { Product *Product `json:"product,omitempty"` ProductVariant *ProductVariant `json:"product_variant,omitempty"` Ingredient *Ingredient `json:"ingredient,omitempty"` -} \ No newline at end of file +} diff --git a/internal/service/account_service.go b/internal/service/account_service.go index 83a5c9f..43c4f00 100644 --- a/internal/service/account_service.go +++ b/internal/service/account_service.go @@ -60,12 +60,12 @@ func (s *AccountServiceImpl) ListAccounts(ctx context.Context, req *contract.Lis if err != nil { return nil, 0, err } - + contractResp := make([]contract.AccountResponse, len(modelResp)) for i, resp := range modelResp { contractResp[i] = *mappers.ModelToContractAccountResponse(&resp) } - + return contractResp, total, nil } @@ -74,12 +74,12 @@ func (s *AccountServiceImpl) GetAccountsByOrganization(ctx context.Context, orga if err != nil { return nil, err } - + contractResp := make([]contract.AccountResponse, len(modelResp)) for i, resp := range modelResp { contractResp[i] = *mappers.ModelToContractAccountResponse(&resp) } - + return contractResp, nil } @@ -88,12 +88,12 @@ func (s *AccountServiceImpl) GetAccountsByChartOfAccount(ctx context.Context, ch if err != nil { return nil, err } - + contractResp := make([]contract.AccountResponse, len(modelResp)) for i, resp := range modelResp { contractResp[i] = *mappers.ModelToContractAccountResponse(&resp) } - + return contractResp, nil } diff --git a/internal/service/chart_of_account_type_service.go b/internal/service/chart_of_account_type_service.go index a4085d9..9f3c66b 100644 --- a/internal/service/chart_of_account_type_service.go +++ b/internal/service/chart_of_account_type_service.go @@ -59,11 +59,11 @@ func (s *ChartOfAccountTypeServiceImpl) ListChartOfAccountTypes(ctx context.Cont if err != nil { return nil, 0, err } - + contractResp := make([]contract.ChartOfAccountTypeResponse, len(modelResp)) for i, resp := range modelResp { contractResp[i] = *mappers.ModelToContractChartOfAccountTypeResponse(&resp) } - + return contractResp, total, nil } diff --git a/internal/service/ingredient_unit_converter_service.go b/internal/service/ingredient_unit_converter_service.go index 7d7d0a4..3432056 100644 --- a/internal/service/ingredient_unit_converter_service.go +++ b/internal/service/ingredient_unit_converter_service.go @@ -160,4 +160,3 @@ func (s *IngredientUnitConverterServiceImpl) GetUnitsByIngredientID(ctx context. contractResponse := transformer.IngredientUnitsModelResponseToResponse(unitsResponse) return contract.BuildSuccessResponse(contractResponse) } - diff --git a/internal/service/product_recipe_service.go b/internal/service/product_recipe_service.go index bfaf76d..fe0c5b6 100644 --- a/internal/service/product_recipe_service.go +++ b/internal/service/product_recipe_service.go @@ -111,4 +111,4 @@ func (s *ProductRecipeServiceImpl) BulkCreate(ctx context.Context, organizationI } return s.processor.BulkCreate(ctx, req.Recipes, organizationID) -} \ No newline at end of file +} diff --git a/internal/transformer/ingredient_unit_converter_transformer.go b/internal/transformer/ingredient_unit_converter_transformer.go index e3512b0..2d64c49 100644 --- a/internal/transformer/ingredient_unit_converter_transformer.go +++ b/internal/transformer/ingredient_unit_converter_transformer.go @@ -175,4 +175,3 @@ func IngredientUnitsModelResponseToResponse(model *models.IngredientUnitsRespons return response } - diff --git a/internal/util/waste_util.go b/internal/util/waste_util.go index 696fafc..a627365 100644 --- a/internal/util/waste_util.go +++ b/internal/util/waste_util.go @@ -20,11 +20,11 @@ func CalculateWasteQuantities(productIngredients []*entities.ProductIngredient, for _, pi := range productIngredients { // Calculate net quantity (actual quantity needed for the product) netQty := pi.Quantity * quantity - + // Calculate gross quantity (including waste) wasteMultiplier := 1 + (pi.WastePercentage / 100) grossQty := netQty * wasteMultiplier - + // Calculate waste quantity wasteQty := grossQty - netQty diff --git a/internal/validator/ingredient_unit_converter_validator.go b/internal/validator/ingredient_unit_converter_validator.go index f2a0499..a5ae6e9 100644 --- a/internal/validator/ingredient_unit_converter_validator.go +++ b/internal/validator/ingredient_unit_converter_validator.go @@ -119,4 +119,3 @@ func (v *IngredientUnitConverterValidatorImpl) ValidateConvertUnitRequest(req *c return nil, "" } - From e09feff36d69616c0ec3d8a7637c7e3b90ece74c Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 9 Jun 2026 15:59:34 +0700 Subject: [PATCH 06/16] Update purchase for product category (inventory type) --- internal/app/app.go | 2 +- internal/contract/purchase_order_contract.go | 48 +++++++------ internal/entities/inventory_movement.go | 56 ++++++++-------- internal/entities/purchase_order.go | 26 +++---- internal/mappers/purchase_order_mapper.go | 61 +++++++++-------- internal/models/purchase_order.go | 67 ++++++++++--------- internal/processor/order_processor.go | 2 +- .../processor/purchase_order_processor.go | 65 ++++++++++++++---- .../repository/purchase_order_repository.go | 6 ++ .../service/inventory_movement_service.go | 37 +++++----- .../transformer/purchase_order_transformer.go | 47 +++++++------ .../validator/purchase_order_validator.go | 29 +++++--- .../purchase_order_validator_test.go | 9 +-- ..._category_to_purchase_order_items.down.sql | 5 ++ ...se_category_to_purchase_order_items.up.sql | 11 +++ 15 files changed, 285 insertions(+), 186 deletions(-) create mode 100644 migrations/000078_add_purchase_category_to_purchase_order_items.down.sql create mode 100644 migrations/000078_add_purchase_category_to_purchase_order_items.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 3b6e833..fbb32dd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -372,7 +372,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo), productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo), - purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), + purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo), unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo), chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo), diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index e6f1c92..70e722c 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -19,11 +19,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` - Description *string `json:"description,omitempty" validate:"omitempty"` - Quantity float64 `json:"quantity" validate:"required,gt=0"` - UnitID uuid.UUID `json:"unit_id" validate:"required"` - Amount float64 `json:"amount" validate:"required,gte=0"` + IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"` + Description *string `json:"description,omitempty" validate:"omitempty"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` + UnitID uuid.UUID `json:"unit_id" validate:"required"` + Amount float64 `json:"amount" validate:"required,gte=0"` } type UpdatePurchaseOrderRequest struct { @@ -39,12 +40,13 @@ type UpdatePurchaseOrderRequest struct { } type UpdatePurchaseOrderItemRequest struct { - ID *uuid.UUID `json:"id,omitempty"` // For existing items - IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"` - Description *string `json:"description,omitempty" validate:"omitempty"` - Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"` - UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"` - Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"` + ID *uuid.UUID `json:"id,omitempty"` // For existing items + IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"` + PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"` + Description *string `json:"description,omitempty" validate:"omitempty"` + Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"` + UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"` + Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"` } type PurchaseOrderResponse struct { @@ -66,17 +68,19 @@ type PurchaseOrderResponse struct { } type PurchaseOrderItemResponse struct { - ID uuid.UUID `json:"id"` - PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID uuid.UUID `json:"ingredient_id"` - Description *string `json:"description"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Ingredient *IngredientResponse `json:"ingredient,omitempty"` - Unit *UnitResponse `json:"unit,omitempty"` + ID uuid.UUID `json:"id"` + PurchaseOrderID uuid.UUID `json:"purchase_order_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Ingredient *IngredientResponse `json:"ingredient,omitempty"` + PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"` + Unit *UnitResponse `json:"unit,omitempty"` } type PurchaseOrderAttachmentResponse struct { diff --git a/internal/entities/inventory_movement.go b/internal/entities/inventory_movement.go index cc4faa4..7baef02 100644 --- a/internal/entities/inventory_movement.go +++ b/internal/entities/inventory_movement.go @@ -36,34 +36,36 @@ const ( ) type InventoryMovement struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` - OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"` - ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"` - ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT" - MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"` - Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"` - PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"` - NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"` - UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"` - TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"` - ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"` - ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"` - OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"` - PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"` - UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"` - Reason *string `gorm:"size:255" json:"reason"` - Notes *string `gorm:"type:text" json:"notes"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` + OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"` + ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"` + ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT" + MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"` + Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"` + PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"` + NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"` + UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"` + TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"` + ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"` + ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"` + PurchaseOrderItemID *uuid.UUID `gorm:"type:uuid;index" json:"purchase_order_item_id"` + OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"` + PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"` + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"` + Reason *string `gorm:"size:255" json:"reason"` + Notes *string `gorm:"type:text" json:"notes"` + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` - Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` - Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"` - Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"` - Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` - Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"` - User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` + Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` + Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"` + Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"` + PurchaseOrderItem *PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderItemID" json:"purchase_order_item,omitempty"` + Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` + Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` } func (im *InventoryMovement) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index 8fe74c6..3a455db 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -41,19 +41,21 @@ func (PurchaseOrder) TableName() string { } type PurchaseOrderItem struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` - IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"` - Description *string `gorm:"type:text" json:"description" validate:"omitempty"` - Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"` - UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"` - Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` + IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"` + PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"` + Description *string `gorm:"type:text" json:"description" validate:"omitempty"` + Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"` + UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"` + Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"` - Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` - Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"` + PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"` + Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` + PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"` + Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"` } func (poi *PurchaseOrderItem) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/mappers/purchase_order_mapper.go b/internal/mappers/purchase_order_mapper.go index 8b3cf9e..b08327f 100644 --- a/internal/mappers/purchase_order_mapper.go +++ b/internal/mappers/purchase_order_mapper.go @@ -91,15 +91,16 @@ func PurchaseOrderItemEntityToModel(entity *entities.PurchaseOrderItem) *models. } return &models.PurchaseOrderItem{ - ID: entity.ID, - PurchaseOrderID: entity.PurchaseOrderID, - IngredientID: entity.IngredientID, - Description: entity.Description, - Quantity: entity.Quantity, - UnitID: entity.UnitID, - Amount: entity.Amount, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, + ID: entity.ID, + PurchaseOrderID: entity.PurchaseOrderID, + IngredientID: entity.IngredientID, + PurchaseCategoryID: entity.PurchaseCategoryID, + Description: entity.Description, + Quantity: entity.Quantity, + UnitID: entity.UnitID, + Amount: entity.Amount, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, } } @@ -109,15 +110,16 @@ func PurchaseOrderItemModelToEntity(model *models.PurchaseOrderItem) *entities.P } return &entities.PurchaseOrderItem{ - ID: model.ID, - PurchaseOrderID: model.PurchaseOrderID, - IngredientID: model.IngredientID, - Description: model.Description, - Quantity: model.Quantity, - UnitID: model.UnitID, - Amount: model.Amount, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, + ID: model.ID, + PurchaseOrderID: model.PurchaseOrderID, + IngredientID: model.IngredientID, + PurchaseCategoryID: model.PurchaseCategoryID, + Description: model.Description, + Quantity: model.Quantity, + UnitID: model.UnitID, + Amount: model.Amount, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, } } @@ -127,15 +129,16 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode } response := &models.PurchaseOrderItemResponse{ - ID: entity.ID, - PurchaseOrderID: entity.PurchaseOrderID, - IngredientID: entity.IngredientID, - Description: entity.Description, - Quantity: entity.Quantity, - UnitID: entity.UnitID, - Amount: entity.Amount, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, + ID: entity.ID, + PurchaseOrderID: entity.PurchaseOrderID, + IngredientID: entity.IngredientID, + PurchaseCategoryID: entity.PurchaseCategoryID, + Description: entity.Description, + Quantity: entity.Quantity, + UnitID: entity.UnitID, + Amount: entity.Amount, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, } // Map ingredient if present @@ -146,6 +149,10 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode } } + if entity.PurchaseCategory != nil { + response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory) + } + // Map unit if present if entity.Unit != nil { response.Unit = &models.UnitResponse{ diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 95b598b..1afa23f 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -22,15 +22,16 @@ type PurchaseOrder struct { } type PurchaseOrderItem struct { - ID uuid.UUID `json:"id"` - PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID uuid.UUID `json:"ingredient_id"` - Description *string `json:"description"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + PurchaseOrderID uuid.UUID `json:"purchase_order_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PurchaseOrderAttachment struct { @@ -59,17 +60,19 @@ type PurchaseOrderResponse struct { } type PurchaseOrderItemResponse struct { - ID uuid.UUID `json:"id"` - PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID uuid.UUID `json:"ingredient_id"` - Description *string `json:"description"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Ingredient *IngredientResponse `json:"ingredient,omitempty"` - Unit *UnitResponse `json:"unit,omitempty"` + ID uuid.UUID `json:"id"` + PurchaseOrderID uuid.UUID `json:"purchase_order_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Ingredient *IngredientResponse `json:"ingredient,omitempty"` + PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"` + Unit *UnitResponse `json:"unit,omitempty"` } type PurchaseOrderAttachmentResponse struct { @@ -93,11 +96,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - IngredientID uuid.UUID `json:"ingredient_id"` - Description *string `json:"description,omitempty"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` - Amount float64 `json:"amount"` + IngredientID uuid.UUID `json:"ingredient_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description,omitempty"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` + Amount float64 `json:"amount"` } type UpdatePurchaseOrderRequest struct { @@ -113,12 +117,13 @@ type UpdatePurchaseOrderRequest struct { } type UpdatePurchaseOrderItemRequest struct { - ID *uuid.UUID `json:"id,omitempty"` // For existing items - IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` - Description *string `json:"description,omitempty"` - Quantity *float64 `json:"quantity,omitempty"` - UnitID *uuid.UUID `json:"unit_id,omitempty"` - Amount *float64 `json:"amount,omitempty"` + ID *uuid.UUID `json:"id,omitempty"` // For existing items + IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` + PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"` + Description *string `json:"description,omitempty"` + Quantity *float64 `json:"quantity,omitempty"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + Amount *float64 `json:"amount,omitempty"` } type ListPurchaseOrdersRequest struct { diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 3d22958..6065005 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -86,7 +86,7 @@ type CustomerRepository interface { } type InventoryMovementService interface { - CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error + CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error } diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index fd5863e..b666d9b 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -25,6 +25,7 @@ type PurchaseOrderProcessorImpl struct { purchaseOrderRepo PurchaseOrderRepository vendorRepo VendorRepository ingredientRepo IngredientRepository + purchaseCategoryRepo PurchaseCategoryRepository unitRepo UnitRepository fileRepo FileRepository inventoryMovementService InventoryMovementService @@ -35,6 +36,7 @@ func NewPurchaseOrderProcessorImpl( purchaseOrderRepo PurchaseOrderRepository, vendorRepo VendorRepository, ingredientRepo IngredientRepository, + purchaseCategoryRepo PurchaseCategoryRepository, unitRepo UnitRepository, fileRepo FileRepository, inventoryMovementService InventoryMovementService, @@ -44,6 +46,7 @@ func NewPurchaseOrderProcessorImpl( purchaseOrderRepo: purchaseOrderRepo, vendorRepo: vendorRepo, ingredientRepo: ingredientRepo, + purchaseCategoryRepo: purchaseCategoryRepo, unitRepo: unitRepo, fileRepo: fileRepo, inventoryMovementService: inventoryMovementService, @@ -64,13 +67,17 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber) } - // Validate ingredients and units exist + // Validate ingredients, raw-material categories, and units exist for i, item := range req.Items { _, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) if err != nil { return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) } + if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil { + return nil, err + } + _, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID) if err != nil { return nil, fmt.Errorf("unit not found for item %d: %w", i, err) @@ -109,12 +116,13 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or // Create purchase order items for _, itemReq := range req.Items { itemEntity := &entities.PurchaseOrderItem{ - PurchaseOrderID: poEntity.ID, - IngredientID: itemReq.IngredientID, - Description: itemReq.Description, - Quantity: itemReq.Quantity, - UnitID: itemReq.UnitID, - Amount: itemReq.Amount, + PurchaseOrderID: poEntity.ID, + IngredientID: itemReq.IngredientID, + PurchaseCategoryID: itemReq.PurchaseCategoryID, + Description: itemReq.Description, + Quantity: itemReq.Quantity, + UnitID: itemReq.UnitID, + Amount: itemReq.Amount, } err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity) @@ -197,7 +205,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id // Create new items totalAmount := 0.0 - for _, itemReq := range req.Items { + for i, itemReq := range req.Items { // Validate ingredients and units exist if itemReq.IngredientID != nil { _, err := p.ingredientRepo.GetByID(ctx, *itemReq.IngredientID, organizationID) @@ -213,8 +221,15 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id } } + if itemReq.PurchaseCategoryID != nil { + if err := p.validateRawMaterialPurchaseCategory(ctx, *itemReq.PurchaseCategoryID, organizationID, i); err != nil { + return nil, err + } + } + // Use existing values if not provided ingredientID := poEntity.Items[0].IngredientID // This is a simplified approach + purchaseCategoryID := poEntity.Items[0].PurchaseCategoryID unitID := poEntity.Items[0].UnitID quantity := poEntity.Items[0].Quantity amount := poEntity.Items[0].Amount @@ -226,6 +241,9 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id if itemReq.UnitID != nil { unitID = *itemReq.UnitID } + if itemReq.PurchaseCategoryID != nil { + purchaseCategoryID = *itemReq.PurchaseCategoryID + } if itemReq.Quantity != nil { quantity = *itemReq.Quantity } @@ -237,12 +255,13 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id } itemEntity := &entities.PurchaseOrderItem{ - PurchaseOrderID: poEntity.ID, - IngredientID: ingredientID, - Description: description, - Quantity: quantity, - UnitID: unitID, - Amount: amount, + PurchaseOrderID: poEntity.ID, + IngredientID: ingredientID, + PurchaseCategoryID: purchaseCategoryID, + Description: description, + Quantity: quantity, + UnitID: unitID, + Amount: amount, } err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity) @@ -419,6 +438,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte reason, &referenceType, referenceID, + &item.ID, ) if err != nil { return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err) @@ -440,3 +460,20 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return mappers.PurchaseOrderEntityToResponse(updatedPO), nil } + +func (p *PurchaseOrderProcessorImpl) validateRawMaterialPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) error { + category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) + if err != nil { + return fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err) + } + + if !category.IsActive { + return fmt.Errorf("purchase category for item %d is inactive", itemIndex) + } + + if category.Type != entities.PurchaseCategoryTypeRawMaterial { + return fmt.Errorf("purchase category for item %d must be raw_material", itemIndex) + } + + return nil +} diff --git a/internal/repository/purchase_order_repository.go b/internal/repository/purchase_order_repository.go index 0d51a8e..8383eec 100644 --- a/internal/repository/purchase_order_repository.go +++ b/internal/repository/purchase_order_repository.go @@ -31,6 +31,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) err := r.db.WithContext(ctx). Preload("Vendor"). Preload("Items.Ingredient"). + Preload("Items.PurchaseCategory"). Preload("Items.Unit"). Preload("Attachments.File"). First(&po, "id = ?", id).Error @@ -45,6 +46,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte err := r.db.WithContext(ctx). Preload("Vendor"). Preload("Items.Ingredient"). + Preload("Items.PurchaseCategory"). Preload("Items.Unit"). Preload("Attachments.File"). Where("id = ? AND organization_id = ?", id, organizationID). @@ -105,6 +107,7 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u err := query. Preload("Vendor"). Preload("Items.Ingredient"). + Preload("Items.PurchaseCategory"). Preload("Items.Unit"). Preload("Attachments.File"). Order("created_at DESC"). @@ -168,6 +171,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByStatus(ctx context.Context, organizat Where("organization_id = ? AND status = ?", organizationID, status). Preload("Vendor"). Preload("Items.Ingredient"). + Preload("Items.PurchaseCategory"). Preload("Items.Unit"). Find(&pos).Error return pos, err @@ -179,6 +183,7 @@ func (r *PurchaseOrderRepositoryImpl) GetOverdue(ctx context.Context, organizati Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}). Preload("Vendor"). Preload("Items.Ingredient"). + Preload("Items.PurchaseCategory"). Preload("Items.Unit"). Find(&pos).Error return pos, err @@ -219,6 +224,7 @@ func (r *PurchaseOrderRepositoryImpl) GetItemsByPurchaseOrderID(ctx context.Cont var items []*entities.PurchaseOrderItem err := r.db.WithContext(ctx). Preload("Ingredient"). + Preload("PurchaseCategory"). Preload("Unit"). Where("purchase_order_id = ?", purchaseOrderID). Find(&items).Error diff --git a/internal/service/inventory_movement_service.go b/internal/service/inventory_movement_service.go index 9300a5a..4cda860 100644 --- a/internal/service/inventory_movement_service.go +++ b/internal/service/inventory_movement_service.go @@ -10,7 +10,7 @@ import ( ) type InventoryMovementService interface { - CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error + CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error } @@ -26,7 +26,7 @@ func NewInventoryMovementService(inventoryMovementRepo repository.InventoryMovem } } -func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error { +func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error { ingredient, err := s.ingredientRepo.GetByID(ctx, ingredientID, organizationID) if err != nil { return err @@ -36,22 +36,23 @@ func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Cont newQuantity := previousQuantity + quantity movement := &entities.InventoryMovement{ - ID: uuid.New(), - OrganizationID: organizationID, - OutletID: outletID, - ItemID: ingredientID, - ItemType: "INGREDIENT", - MovementType: movementType, - Quantity: quantity, - PreviousQuantity: previousQuantity, - NewQuantity: newQuantity, - UnitCost: unitCost, - TotalCost: unitCost * quantity, - ReferenceType: referenceType, - ReferenceID: referenceID, - UserID: userID, - Reason: &reason, - CreatedAt: time.Now(), + ID: uuid.New(), + OrganizationID: organizationID, + OutletID: outletID, + ItemID: ingredientID, + ItemType: "INGREDIENT", + MovementType: movementType, + Quantity: quantity, + PreviousQuantity: previousQuantity, + NewQuantity: newQuantity, + UnitCost: unitCost, + TotalCost: unitCost * quantity, + ReferenceType: referenceType, + ReferenceID: referenceID, + PurchaseOrderItemID: purchaseOrderItemID, + UserID: userID, + Reason: &reason, + CreatedAt: time.Now(), } err = s.inventoryMovementRepo.Create(ctx, movement) diff --git a/internal/transformer/purchase_order_transformer.go b/internal/transformer/purchase_order_transformer.go index 9860f8f..b61e2ef 100644 --- a/internal/transformer/purchase_order_transformer.go +++ b/internal/transformer/purchase_order_transformer.go @@ -11,11 +11,12 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest) items := make([]models.CreatePurchaseOrderItemRequest, len(req.Items)) for i, item := range req.Items { items[i] = models.CreatePurchaseOrderItemRequest{ - IngredientID: item.IngredientID, - Description: item.Description, - Quantity: item.Quantity, - UnitID: item.UnitID, - Amount: item.Amount, + IngredientID: item.IngredientID, + PurchaseCategoryID: item.PurchaseCategoryID, + Description: item.Description, + Quantity: item.Quantity, + UnitID: item.UnitID, + Amount: item.Amount, } } @@ -54,12 +55,13 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest) items = make([]models.UpdatePurchaseOrderItemRequest, len(req.Items)) for i, item := range req.Items { items[i] = models.UpdatePurchaseOrderItemRequest{ - ID: item.ID, - IngredientID: item.IngredientID, - Description: item.Description, - Quantity: item.Quantity, - UnitID: item.UnitID, - Amount: item.Amount, + ID: item.ID, + IngredientID: item.IngredientID, + PurchaseCategoryID: item.PurchaseCategoryID, + Description: item.Description, + Quantity: item.Quantity, + UnitID: item.UnitID, + Amount: item.Amount, } } } @@ -154,15 +156,16 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con response.Items = make([]contract.PurchaseOrderItemResponse, len(po.Items)) for i, item := range po.Items { response.Items[i] = contract.PurchaseOrderItemResponse{ - ID: item.ID, - PurchaseOrderID: item.PurchaseOrderID, - IngredientID: item.IngredientID, - Description: item.Description, - Quantity: item.Quantity, - UnitID: item.UnitID, - Amount: item.Amount, - CreatedAt: item.CreatedAt, - UpdatedAt: item.UpdatedAt, + ID: item.ID, + PurchaseOrderID: item.PurchaseOrderID, + IngredientID: item.IngredientID, + PurchaseCategoryID: item.PurchaseCategoryID, + Description: item.Description, + Quantity: item.Quantity, + UnitID: item.UnitID, + Amount: item.Amount, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, } // Map ingredient if present @@ -173,6 +176,10 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con } } + if item.PurchaseCategory != nil { + response.Items[i].PurchaseCategory = PurchaseCategoryModelResponseToResponse(item.PurchaseCategory) + } + // Map unit if present if item.Unit != nil { response.Items[i].Unit = &contract.UnitResponse{ diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index 824ea1b..b6c3f07 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -2,11 +2,14 @@ package validator import ( "errors" + "strconv" "strings" "time" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" + + "github.com/google/uuid" ) type PurchaseOrderValidator interface { @@ -26,7 +29,7 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con return errors.New("request body is required"), constants.MissingFieldErrorCode } - if req.VendorID.String() == "" { + if req.VendorID == uuid.Nil { return errors.New("vendor_id is required"), constants.MissingFieldErrorCode } @@ -178,32 +181,40 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont } func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) { - if item.IngredientID.String() == "" { - return errors.New("items[" + string(rune(index)) + "].ingredient_id is required"), constants.MissingFieldErrorCode + if item.IngredientID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode + } + + if item.PurchaseCategoryID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode } if item.Quantity <= 0 { - return errors.New("items[" + string(rune(index)) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode + return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } - if item.UnitID.String() == "" { - return errors.New("items[" + string(rune(index)) + "].unit_id is required"), constants.MissingFieldErrorCode + if item.UnitID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode } if item.Amount < 0 { - return errors.New("items[" + string(rune(index)) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode + return errors.New("items[" + strconv.Itoa(index) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode } return nil, "" } func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contract.UpdatePurchaseOrderItemRequest, index int) (error, string) { + if item.PurchaseCategoryID == nil || *item.PurchaseCategoryID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode + } + if item.Quantity != nil && *item.Quantity <= 0 { - return errors.New("items[" + string(rune(index)) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode + return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } if item.Amount != nil && *item.Amount < 0 { - return errors.New("items[" + string(rune(index)) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode + return errors.New("items[" + strconv.Itoa(index) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode } return nil, "" diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index 3cd821c..1fde2f9 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -17,10 +17,11 @@ func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest { TransactionDate: "2026-05-29", Items: []contract.CreatePurchaseOrderItemRequest{ { - IngredientID: uuid.New(), - Quantity: 1, - UnitID: uuid.New(), - Amount: 1000, + IngredientID: uuid.New(), + PurchaseCategoryID: uuid.New(), + Quantity: 1, + UnitID: uuid.New(), + Amount: 1000, }, }, } diff --git a/migrations/000078_add_purchase_category_to_purchase_order_items.down.sql b/migrations/000078_add_purchase_category_to_purchase_order_items.down.sql new file mode 100644 index 0000000..1cf91b9 --- /dev/null +++ b/migrations/000078_add_purchase_category_to_purchase_order_items.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_inventory_movements_purchase_order_item_id; +ALTER TABLE inventory_movements DROP COLUMN IF EXISTS purchase_order_item_id; + +DROP INDEX IF EXISTS idx_purchase_order_items_purchase_category_id; +ALTER TABLE purchase_order_items DROP COLUMN IF EXISTS purchase_category_id; diff --git a/migrations/000078_add_purchase_category_to_purchase_order_items.up.sql b/migrations/000078_add_purchase_category_to_purchase_order_items.up.sql new file mode 100644 index 0000000..0d2af9b --- /dev/null +++ b/migrations/000078_add_purchase_category_to_purchase_order_items.up.sql @@ -0,0 +1,11 @@ +ALTER TABLE purchase_order_items +ADD COLUMN IF NOT EXISTS purchase_category_id UUID REFERENCES purchase_categories(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS idx_purchase_order_items_purchase_category_id +ON purchase_order_items(purchase_category_id); + +ALTER TABLE inventory_movements +ADD COLUMN IF NOT EXISTS purchase_order_item_id UUID REFERENCES purchase_order_items(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_inventory_movements_purchase_order_item_id +ON inventory_movements(purchase_order_item_id); From c3db9195310161ec53d384ca49dc371ee1a644d1 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 9 Jun 2026 17:02:49 +0700 Subject: [PATCH 07/16] Update expense for product category (non-inventory type) --- internal/app/app.go | 2 +- internal/contract/expense_contract.go | 68 ++++++---- internal/entities/expense.go | 18 ++- internal/entities/expense_item.go | 22 +-- internal/mappers/expense_mapper.go | 23 ++-- internal/models/expense.go | 85 +++++++----- internal/processor/expense_processor.go | 127 +++++++++++++----- internal/processor/expense_processor_test.go | 113 ++++++++++++++-- internal/repository/expense_repository.go | 46 +++++-- internal/transformer/expense_transformer.go | 59 +++++--- internal/validator/expense_validator.go | 15 +++ internal/validator/expense_validator_test.go | 29 ++-- ...urchase_category_to_expense_items.down.sql | 2 + ..._purchase_category_to_expense_items.up.sql | 5 + 14 files changed, 444 insertions(+), 170 deletions(-) create mode 100644 migrations/000079_add_purchase_category_to_expense_items.down.sql create mode 100644 migrations/000079_add_purchase_category_to_expense_items.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index fbb32dd..6a0f4d1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -396,7 +396,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo), notificationProcessor: buildNotificationProcessor(cfg, repos), productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo), - expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo), + expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo), } } diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index b769d2e..348cbd3 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -19,10 +19,11 @@ type CreateExpenseRequest struct { } type CreateExpenseItemRequest struct { - ChartOfAccountID string `json:"chart_of_account_id" validate:"required"` - Item string `json:"item" validate:"required"` - Description *string `json:"description,omitempty"` - Amount float64 `json:"amount" validate:"required"` + ChartOfAccountID string `json:"chart_of_account_id" validate:"required"` + PurchaseCategoryID string `json:"purchase_category_id" validate:"required"` + Item string `json:"item" validate:"required"` + Description *string `json:"description,omitempty"` + Amount float64 `json:"amount" validate:"required"` } type UpdateExpenseRequest struct { @@ -39,10 +40,11 @@ type UpdateExpenseRequest struct { } type UpdateExpenseItemRequest struct { - ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` - Item *string `json:"item,omitempty"` - Description *string `json:"description,omitempty"` - Amount *float64 `json:"amount,omitempty"` + ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` + PurchaseCategoryID *string `json:"purchase_category_id,omitempty"` + Item *string `json:"item,omitempty"` + Description *string `json:"description,omitempty"` + Amount *float64 `json:"amount,omitempty"` } type ExpenseResponse struct { @@ -63,15 +65,19 @@ type ExpenseResponse struct { } type ExpenseItemResponse struct { - ID uuid.UUID `json:"id"` - ExpenseID uuid.UUID `json:"expense_id"` - ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` - ChartOfAccountName string `json:"chart_of_account_name,omitempty"` - Item string `json:"item"` - Description *string `json:"description"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + ExpenseID uuid.UUID `json:"expense_id"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + ChartOfAccountName string `json:"chart_of_account_name,omitempty"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + PurchaseCategoryName string `json:"purchase_category_name,omitempty"` + PurchaseCategoryType string `json:"purchase_category_type,omitempty"` + PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"` + Item string `json:"item"` + Description *string `json:"description"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ListExpenseRequest struct { @@ -100,15 +106,16 @@ type ExpenseAnalyticsRequest struct { } type ExpenseAnalyticsResponse struct { - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - DateFrom time.Time `json:"date_from"` - DateTo time.Time `json:"date_to"` - GroupBy string `json:"group_by"` - Summary ExpenseAnalyticsSummary `json:"summary"` - Data []ExpenseAnalyticsData `json:"data"` - CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"` - ItemData []ExpenseAnalyticsItemData `json:"item_data"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + GroupBy string `json:"group_by"` + Summary ExpenseAnalyticsSummary `json:"summary"` + Data []ExpenseAnalyticsData `json:"data"` + CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"` + ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"` + ItemData []ExpenseAnalyticsItemData `json:"item_data"` } type ExpenseAnalyticsSummary struct { @@ -130,6 +137,15 @@ type ExpenseAnalyticsData struct { } type ExpenseAnalyticsCategoryData struct { + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + PurchaseCategoryName string `json:"purchase_category_name"` + PurchaseCategoryType string `json:"purchase_category_type"` + TotalAmount float64 `json:"total_amount"` + ExpenseCount int64 `json:"expense_count"` + ItemCount int64 `json:"item_count"` +} + +type ExpenseAnalyticsChartOfAccountData struct { ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountName string `json:"chart_of_account_name"` TotalAmount float64 `json:"total_amount"` diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 137157f..9f391fe 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -29,10 +29,11 @@ type Expense struct { } type ExpenseAnalytics struct { - Summary ExpenseAnalyticsSummary - Data []ExpenseAnalyticsData - CategoryData []ExpenseAnalyticsCategoryData - ItemData []ExpenseAnalyticsItemData + Summary ExpenseAnalyticsSummary + Data []ExpenseAnalyticsData + CategoryData []ExpenseAnalyticsCategoryData + ChartOfAccountData []ExpenseAnalyticsChartOfAccountData + ItemData []ExpenseAnalyticsItemData } type ExpenseAnalyticsSummary struct { @@ -54,6 +55,15 @@ type ExpenseAnalyticsData struct { } type ExpenseAnalyticsCategoryData struct { + PurchaseCategoryID uuid.UUID + PurchaseCategoryName string + PurchaseCategoryType string + TotalAmount float64 + ExpenseCount int64 + ItemCount int64 +} + +type ExpenseAnalyticsChartOfAccountData struct { ChartOfAccountID uuid.UUID ChartOfAccountName string TotalAmount float64 diff --git a/internal/entities/expense_item.go b/internal/entities/expense_item.go index f5bd7fc..f6ebbed 100644 --- a/internal/entities/expense_item.go +++ b/internal/entities/expense_item.go @@ -9,17 +9,19 @@ import ( ) type ExpenseItem struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"` - ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"` - Item string `gorm:"not null;size:255" json:"item"` - Description *string `gorm:"type:text" json:"description"` - Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"` + ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"` + PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id"` + Item string `gorm:"not null;size:255" json:"item"` + Description *string `gorm:"type:text" json:"description"` + Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"` - ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"` + Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"` + ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"` + PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"` } func (e *ExpenseItem) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go index 34015d4..c3b1e43 100644 --- a/internal/mappers/expense_mapper.go +++ b/internal/mappers/expense_mapper.go @@ -95,20 +95,27 @@ func ExpenseItemEntityToResponse(entity *entities.ExpenseItem) *models.ExpenseIt } response := &models.ExpenseItemResponse{ - ID: entity.ID, - ExpenseID: entity.ExpenseID, - ChartOfAccountID: entity.ChartOfAccountID, - Item: entity.Item, - Description: entity.Description, - Amount: entity.Amount, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, + ID: entity.ID, + ExpenseID: entity.ExpenseID, + ChartOfAccountID: entity.ChartOfAccountID, + PurchaseCategoryID: entity.PurchaseCategoryID, + Item: entity.Item, + Description: entity.Description, + Amount: entity.Amount, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, } if entity.ChartOfAccount != nil { response.ChartOfAccountName = entity.ChartOfAccount.Name } + if entity.PurchaseCategory != nil { + response.PurchaseCategoryName = entity.PurchaseCategory.Name + response.PurchaseCategoryType = string(entity.PurchaseCategory.Type) + response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory) + } + return response } diff --git a/internal/models/expense.go b/internal/models/expense.go index 57c08d0..859ed69 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -23,14 +23,15 @@ type Expense struct { } type ExpenseItem struct { - ID uuid.UUID `json:"id"` - ExpenseID uuid.UUID `json:"expense_id"` - ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` - Item string `json:"item"` - Description *string `json:"description"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + ExpenseID uuid.UUID `json:"expense_id"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Item string `json:"item"` + Description *string `json:"description"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ExpenseResponse struct { @@ -51,15 +52,19 @@ type ExpenseResponse struct { } type ExpenseItemResponse struct { - ID uuid.UUID `json:"id"` - ExpenseID uuid.UUID `json:"expense_id"` - ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` - ChartOfAccountName string `json:"chart_of_account_name,omitempty"` - Item string `json:"item"` - Description *string `json:"description"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + ExpenseID uuid.UUID `json:"expense_id"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + ChartOfAccountName string `json:"chart_of_account_name,omitempty"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + PurchaseCategoryName string `json:"purchase_category_name,omitempty"` + PurchaseCategoryType string `json:"purchase_category_type,omitempty"` + PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"` + Item string `json:"item"` + Description *string `json:"description"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateExpenseRequest struct { @@ -75,10 +80,11 @@ type CreateExpenseRequest struct { } type CreateExpenseItemRequest struct { - ChartOfAccountID string `json:"chart_of_account_id"` - Item string `json:"item"` - Description *string `json:"description,omitempty"` - Amount float64 `json:"amount"` + ChartOfAccountID string `json:"chart_of_account_id"` + PurchaseCategoryID string `json:"purchase_category_id"` + Item string `json:"item"` + Description *string `json:"description,omitempty"` + Amount float64 `json:"amount"` } type UpdateExpenseRequest struct { @@ -95,10 +101,11 @@ type UpdateExpenseRequest struct { } type UpdateExpenseItemRequest struct { - ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` - Item *string `json:"item,omitempty"` - Description *string `json:"description,omitempty"` - Amount *float64 `json:"amount,omitempty"` + ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` + PurchaseCategoryID *string `json:"purchase_category_id,omitempty"` + Item *string `json:"item,omitempty"` + Description *string `json:"description,omitempty"` + Amount *float64 `json:"amount,omitempty"` } type ListExpenseRequest struct { @@ -128,15 +135,16 @@ type ExpenseAnalyticsRequest struct { } type ExpenseAnalyticsResponse struct { - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - DateFrom time.Time `json:"date_from"` - DateTo time.Time `json:"date_to"` - GroupBy string `json:"group_by"` - Summary ExpenseAnalyticsSummary `json:"summary"` - Data []ExpenseAnalyticsData `json:"data"` - CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"` - ItemData []ExpenseAnalyticsItemData `json:"item_data"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + GroupBy string `json:"group_by"` + Summary ExpenseAnalyticsSummary `json:"summary"` + Data []ExpenseAnalyticsData `json:"data"` + CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"` + ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"` + ItemData []ExpenseAnalyticsItemData `json:"item_data"` } type ExpenseAnalyticsSummary struct { @@ -158,6 +166,15 @@ type ExpenseAnalyticsData struct { } type ExpenseAnalyticsCategoryData struct { + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + PurchaseCategoryName string `json:"purchase_category_name"` + PurchaseCategoryType string `json:"purchase_category_type"` + TotalAmount float64 `json:"total_amount"` + ExpenseCount int64 `json:"expense_count"` + ItemCount int64 `json:"item_count"` +} + +type ExpenseAnalyticsChartOfAccountData struct { ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountName string `json:"chart_of_account_name"` TotalAmount float64 `json:"total_amount"` diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 2141ebe..0b8ab86 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -23,12 +23,14 @@ type ExpenseProcessor interface { } type ExpenseProcessorImpl struct { - expenseRepo ExpenseRepository + expenseRepo ExpenseRepository + purchaseCategoryRepo PurchaseCategoryRepository } -func NewExpenseProcessorImpl(expenseRepo ExpenseRepository) *ExpenseProcessorImpl { +func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl { return &ExpenseProcessorImpl{ - expenseRepo: expenseRepo, + expenseRepo: expenseRepo, + purchaseCategoryRepo: purchaseCategoryRepo, } } @@ -48,6 +50,30 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID status = *req.Status } + items := make([]entities.ExpenseItem, len(req.Items)) + for i, itemReq := range req.Items { + chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID) + if err != nil { + return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err) + } + + purchaseCategoryID, err := uuid.Parse(itemReq.PurchaseCategoryID) + if err != nil { + return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err) + } + if err := p.validateNonInventoryPurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil { + return nil, err + } + + items[i] = entities.ExpenseItem{ + ChartOfAccountID: chartOfAccountID, + PurchaseCategoryID: purchaseCategoryID, + Item: itemReq.Item, + Description: itemReq.Description, + Amount: itemReq.Amount, + } + } + expenseEntity := &entities.Expense{ OrganizationID: organizationID, OutletID: outletID, @@ -65,21 +91,10 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID return nil, fmt.Errorf("failed to create expense: %w", err) } - for _, itemReq := range req.Items { - chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID) - if err != nil { - return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err) - } + for i := range items { + items[i].ExpenseID = expenseEntity.ID - itemEntity := &entities.ExpenseItem{ - ExpenseID: expenseEntity.ID, - ChartOfAccountID: chartOfAccountID, - Item: itemReq.Item, - Description: itemReq.Description, - Amount: itemReq.Amount, - } - - err = p.expenseRepo.CreateItem(ctx, itemEntity) + err = p.expenseRepo.CreateItem(ctx, &items[i]) if err != nil { return nil, fmt.Errorf("failed to create expense item: %w", err) } @@ -135,13 +150,10 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati expenseEntity.Reserved1 = req.Reserved1 } + var items []entities.ExpenseItem if req.Items != nil { - err = p.expenseRepo.DeleteItemsByExpenseID(ctx, expenseEntity.ID) - if err != nil { - return nil, fmt.Errorf("failed to delete existing items: %w", err) - } - - for _, itemReq := range req.Items { + items = make([]entities.ExpenseItem, len(req.Items)) + for i, itemReq := range req.Items { chartOfAccountID := uuid.Nil if itemReq.ChartOfAccountID != nil { chartOfAccountID, err = uuid.Parse(*itemReq.ChartOfAccountID) @@ -150,6 +162,17 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati } } + if itemReq.PurchaseCategoryID == nil { + return nil, fmt.Errorf("purchase_category_id is required for item") + } + purchaseCategoryID, err := uuid.Parse(*itemReq.PurchaseCategoryID) + if err != nil { + return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err) + } + if err := p.validateNonInventoryPurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil { + return nil, err + } + amount := 0.0 if itemReq.Amount != nil { amount = *itemReq.Amount @@ -159,15 +182,23 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati item = *itemReq.Item } - itemEntity := &entities.ExpenseItem{ - ExpenseID: expenseEntity.ID, - ChartOfAccountID: chartOfAccountID, - Item: item, - Description: itemReq.Description, - Amount: amount, + items[i] = entities.ExpenseItem{ + ExpenseID: expenseEntity.ID, + ChartOfAccountID: chartOfAccountID, + PurchaseCategoryID: purchaseCategoryID, + Item: item, + Description: itemReq.Description, + Amount: amount, } + } - err = p.expenseRepo.CreateItem(ctx, itemEntity) + err = p.expenseRepo.DeleteItemsByExpenseID(ctx, expenseEntity.ID) + if err != nil { + return nil, fmt.Errorf("failed to delete existing items: %w", err) + } + + for i := range items { + err = p.expenseRepo.CreateItem(ctx, &items[i]) if err != nil { return nil, fmt.Errorf("failed to create expense item: %w", err) } @@ -252,6 +283,18 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod categoryData := make([]models.ExpenseAnalyticsCategoryData, len(result.CategoryData)) for i, item := range result.CategoryData { categoryData[i] = models.ExpenseAnalyticsCategoryData{ + PurchaseCategoryID: item.PurchaseCategoryID, + PurchaseCategoryName: item.PurchaseCategoryName, + PurchaseCategoryType: item.PurchaseCategoryType, + TotalAmount: item.TotalAmount, + ExpenseCount: item.ExpenseCount, + ItemCount: item.ItemCount, + } + } + + chartOfAccountData := make([]models.ExpenseAnalyticsChartOfAccountData, len(result.ChartOfAccountData)) + for i, item := range result.ChartOfAccountData { + chartOfAccountData[i] = models.ExpenseAnalyticsChartOfAccountData{ ChartOfAccountID: item.ChartOfAccountID, ChartOfAccountName: item.ChartOfAccountName, TotalAmount: item.TotalAmount, @@ -284,8 +327,26 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod TotalCategories: result.Summary.TotalCategories, TotalItems: result.Summary.TotalItems, }, - Data: data, - CategoryData: categoryData, - ItemData: itemData, + Data: data, + CategoryData: categoryData, + ChartOfAccountData: chartOfAccountData, + ItemData: itemData, }, nil } + +func (p *ExpenseProcessorImpl) validateNonInventoryPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error { + category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) + if err != nil { + return fmt.Errorf("purchase category not found: %w", err) + } + + if !category.IsActive { + return fmt.Errorf("purchase category is inactive") + } + + if category.Type != entities.PurchaseCategoryTypeNonInventory { + return fmt.Errorf("purchase category must be non_inventory") + } + + return nil +} diff --git a/internal/processor/expense_processor_test.go b/internal/processor/expense_processor_test.go index b42fed7..e0a4435 100644 --- a/internal/processor/expense_processor_test.go +++ b/internal/processor/expense_processor_test.go @@ -18,6 +18,45 @@ type expenseRepositoryCaptureStub struct { analytics *entities.ExpenseAnalytics } +type expensePurchaseCategoryRepositoryStub struct { + category *entities.PurchaseCategory +} + +func (*expensePurchaseCategoryRepositoryStub) Create(context.Context, *entities.PurchaseCategory) error { + return nil +} + +func (s *expensePurchaseCategoryRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.PurchaseCategory, error) { + return s.category, nil +} + +func (*expensePurchaseCategoryRepositoryStub) Update(context.Context, *entities.PurchaseCategory) error { + return nil +} + +func (*expensePurchaseCategoryRepositoryStub) SoftDelete(context.Context, uuid.UUID, uuid.UUID) error { + return nil +} + +func (*expensePurchaseCategoryRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.PurchaseCategory, int64, error) { + return nil, 0, nil +} + +func (*expensePurchaseCategoryRepositoryStub) ExistsByCode(context.Context, uuid.UUID, string, *uuid.UUID) (bool, error) { + return false, nil +} + +func newExpensePurchaseCategoryRepo(categoryID uuid.UUID, categoryType entities.PurchaseCategoryType) *expensePurchaseCategoryRepositoryStub { + return &expensePurchaseCategoryRepositoryStub{ + category: &entities.PurchaseCategory{ + ID: categoryID, + Name: "Operational", + Type: categoryType, + IsActive: true, + }, + } +} + func (s *expenseRepositoryCaptureStub) Create(_ context.Context, expense *entities.Expense) error { if expense.ID == uuid.Nil { expense.ID = uuid.New() @@ -62,7 +101,8 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { repo := &expenseRepositoryCaptureStub{} - p := NewExpenseProcessorImpl(repo) + purchaseCategoryID := uuid.New() + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) chartOfAccountID := uuid.New() resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -73,9 +113,10 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { Total: 10000, Items: []models.CreateExpenseItemRequest{ { - ChartOfAccountID: chartOfAccountID.String(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: chartOfAccountID.String(), + PurchaseCategoryID: purchaseCategoryID.String(), + Item: "Cleaning supplies", + Amount: 10000, }, }, }) @@ -84,13 +125,15 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { require.NotNil(t, resp) require.Len(t, repo.createdItems, 1) require.Equal(t, "Cleaning supplies", repo.createdItems[0].Item) + require.Equal(t, purchaseCategoryID, repo.createdItems[0].PurchaseCategoryID) require.Len(t, resp.Items, 1) require.Equal(t, "Cleaning supplies", resp.Items[0].Item) } func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { repo := &expenseRepositoryCaptureStub{} - p := NewExpenseProcessorImpl(repo) + purchaseCategoryID := uuid.New() + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ Receiver: "Cashier", @@ -100,9 +143,10 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { Total: 10000, Items: []models.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: purchaseCategoryID.String(), + Item: "Cleaning supplies", + Amount: 10000, }, }, }) @@ -115,7 +159,8 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { repo := &expenseRepositoryCaptureStub{} - p := NewExpenseProcessorImpl(repo) + purchaseCategoryID := uuid.New() + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) status := "approved" resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -127,9 +172,10 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { Total: 10000, Items: []models.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: purchaseCategoryID.String(), + Item: "Cleaning supplies", + Amount: 10000, }, }, }) @@ -140,8 +186,35 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { require.Equal(t, "approved", resp.Status) } +func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) { + repo := &expenseRepositoryCaptureStub{} + purchaseCategoryID := uuid.New() + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial)) + + resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ + Receiver: "Cashier", + TransactionDate: "2026-05-29", + CodeNumber: "EXP-001", + OutletID: uuid.NewString(), + Total: 10000, + Items: []models.CreateExpenseItemRequest{ + { + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: purchaseCategoryID.String(), + Item: "Cleaning supplies", + Amount: 10000, + }, + }, + }) + + require.Error(t, err) + require.Nil(t, resp) + require.Contains(t, err.Error(), "non_inventory") +} + func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) { coaID := uuid.New() + purchaseCategoryID := uuid.New() outletID := uuid.New() now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) repo := &expenseRepositoryCaptureStub{ @@ -165,6 +238,16 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te }, }, CategoryData: []entities.ExpenseAnalyticsCategoryData{ + { + PurchaseCategoryID: purchaseCategoryID, + PurchaseCategoryName: "Operational Supplies", + PurchaseCategoryType: "non_inventory", + TotalAmount: 100000, + ExpenseCount: 2, + ItemCount: 2, + }, + }, + ChartOfAccountData: []entities.ExpenseAnalyticsChartOfAccountData{ { ChartOfAccountID: coaID, ChartOfAccountName: "Operational", @@ -183,7 +266,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te }, }, } - p := NewExpenseProcessorImpl(repo) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{ OrganizationID: uuid.New(), @@ -200,7 +283,9 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te require.Len(t, resp.Data, 1) require.Equal(t, int64(2), resp.Data[0].ExpenseCount) require.Len(t, resp.CategoryData, 1) - require.Equal(t, coaID, resp.CategoryData[0].ChartOfAccountID) + require.Equal(t, purchaseCategoryID, resp.CategoryData[0].PurchaseCategoryID) + require.Len(t, resp.ChartOfAccountData, 1) + require.Equal(t, coaID, resp.ChartOfAccountData[0].ChartOfAccountID) require.Len(t, resp.ItemData, 1) require.Equal(t, "Cleaning supplies", resp.ItemData[0].Item) } diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index 4877243..bf557af 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -30,6 +30,7 @@ func (r *ExpenseRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*ent var expense entities.Expense err := r.db.WithContext(ctx). Preload("Items.ChartOfAccount"). + Preload("Items.PurchaseCategory"). First(&expense, "id = ?", id).Error if err != nil { return nil, err @@ -41,6 +42,7 @@ func (r *ExpenseRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id var expense entities.Expense err := r.db.WithContext(ctx). Preload("Items.ChartOfAccount"). + Preload("Items.PurchaseCategory"). Where("id = ? AND organization_id = ?", id, organizationID). First(&expense).Error if err != nil { @@ -107,6 +109,7 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU err := query. Preload("Items.ChartOfAccount"). + Preload("Items.PurchaseCategory"). Order("created_at DESC"). Limit(limit). Offset(offset). @@ -139,7 +142,7 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID Table("expense_items ei"). Select(` COUNT(ei.id) as total_items, - COUNT(DISTINCT ei.chart_of_account_id) as total_categories + COUNT(DISTINCT ei.purchase_category_id) as total_categories `). Joins("JOIN expenses e ON ei.expense_id = e.id"). Where("e.organization_id = ?", organizationID). @@ -174,7 +177,7 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID COALESCE(SUM(item_counts.categories), 0) as categories `). Joins(`LEFT JOIN ( - SELECT expense_id, COUNT(id) as items, COUNT(DISTINCT chart_of_account_id) as categories + SELECT expense_id, COUNT(id) as items, COUNT(DISTINCT purchase_category_id) as categories FROM expense_items GROUP BY expense_id ) item_counts ON item_counts.expense_id = e.id`). @@ -192,6 +195,32 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID var categoryData []entities.ExpenseAnalyticsCategoryData categoryQuery := r.db.WithContext(ctx). + Table("expense_items ei"). + Select(` + pc.id as purchase_category_id, + pc.name as purchase_category_name, + pc.type as purchase_category_type, + COALESCE(SUM(ei.amount), 0) as total_amount, + COUNT(DISTINCT e.id) as expense_count, + COUNT(ei.id) as item_count + `). + 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.PurchaseCategoryTypeNonInventory). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo). + Group("pc.id, pc.name, pc.type"). + Order("total_amount DESC") + if outletID != nil { + categoryQuery = categoryQuery.Where("e.outlet_id = ?", *outletID) + } + if err := categoryQuery.Scan(&categoryData).Error; err != nil { + return nil, err + } + + var chartOfAccountData []entities.ExpenseAnalyticsChartOfAccountData + chartOfAccountQuery := r.db.WithContext(ctx). Table("expense_items ei"). Select(` COALESCE(parent_coa.id, coa.id) as chart_of_account_id, @@ -209,9 +238,9 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID Group("COALESCE(parent_coa.id, coa.id), COALESCE(parent_coa.name, coa.name, 'Lain-lain')"). Order("total_amount DESC") if outletID != nil { - categoryQuery = categoryQuery.Where("e.outlet_id = ?", *outletID) + chartOfAccountQuery = chartOfAccountQuery.Where("e.outlet_id = ?", *outletID) } - if err := categoryQuery.Scan(&categoryData).Error; err != nil { + if err := chartOfAccountQuery.Scan(&chartOfAccountData).Error; err != nil { return nil, err } @@ -239,10 +268,11 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID } return &entities.ExpenseAnalytics{ - Summary: summary, - Data: data, - CategoryData: categoryData, - ItemData: itemData, + Summary: summary, + Data: data, + CategoryData: categoryData, + ChartOfAccountData: chartOfAccountData, + ItemData: itemData, }, nil } diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index 6f1fbf6..bc8606c 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -27,10 +27,11 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre func CreateExpenseItemRequestToModel(req *contract.CreateExpenseItemRequest) models.CreateExpenseItemRequest { return models.CreateExpenseItemRequest{ - ChartOfAccountID: req.ChartOfAccountID, - Item: req.Item, - Description: req.Description, - Amount: req.Amount, + ChartOfAccountID: req.ChartOfAccountID, + PurchaseCategoryID: req.PurchaseCategoryID, + Item: req.Item, + Description: req.Description, + Amount: req.Amount, } } @@ -60,10 +61,11 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd func UpdateExpenseItemRequestToModel(req *contract.UpdateExpenseItemRequest) models.UpdateExpenseItemRequest { return models.UpdateExpenseItemRequest{ - ChartOfAccountID: req.ChartOfAccountID, - Item: req.Item, - Description: req.Description, - Amount: req.Amount, + ChartOfAccountID: req.ChartOfAccountID, + PurchaseCategoryID: req.PurchaseCategoryID, + Item: req.Item, + Description: req.Description, + Amount: req.Amount, } } @@ -109,15 +111,19 @@ func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.E func ExpenseItemModelResponseToResponse(item *models.ExpenseItemResponse) contract.ExpenseItemResponse { return contract.ExpenseItemResponse{ - ID: item.ID, - ExpenseID: item.ExpenseID, - ChartOfAccountID: item.ChartOfAccountID, - ChartOfAccountName: item.ChartOfAccountName, - Item: item.Item, - Description: item.Description, - Amount: item.Amount, - CreatedAt: item.CreatedAt, - UpdatedAt: item.UpdatedAt, + ID: item.ID, + ExpenseID: item.ExpenseID, + ChartOfAccountID: item.ChartOfAccountID, + ChartOfAccountName: item.ChartOfAccountName, + PurchaseCategoryID: item.PurchaseCategoryID, + PurchaseCategoryName: item.PurchaseCategoryName, + PurchaseCategoryType: item.PurchaseCategoryType, + PurchaseCategory: PurchaseCategoryModelResponseToResponse(item.PurchaseCategory), + Item: item.Item, + Description: item.Description, + Amount: item.Amount, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, } } @@ -176,6 +182,18 @@ func ExpenseAnalyticsModelToContract(resp *models.ExpenseAnalyticsResponse) *con categoryData := make([]contract.ExpenseAnalyticsCategoryData, len(resp.CategoryData)) for i, item := range resp.CategoryData { categoryData[i] = contract.ExpenseAnalyticsCategoryData{ + PurchaseCategoryID: item.PurchaseCategoryID, + PurchaseCategoryName: item.PurchaseCategoryName, + PurchaseCategoryType: item.PurchaseCategoryType, + TotalAmount: item.TotalAmount, + ExpenseCount: item.ExpenseCount, + ItemCount: item.ItemCount, + } + } + + chartOfAccountData := make([]contract.ExpenseAnalyticsChartOfAccountData, len(resp.ChartOfAccountData)) + for i, item := range resp.ChartOfAccountData { + chartOfAccountData[i] = contract.ExpenseAnalyticsChartOfAccountData{ ChartOfAccountID: item.ChartOfAccountID, ChartOfAccountName: item.ChartOfAccountName, TotalAmount: item.TotalAmount, @@ -208,8 +226,9 @@ func ExpenseAnalyticsModelToContract(resp *models.ExpenseAnalyticsResponse) *con TotalCategories: resp.Summary.TotalCategories, TotalItems: resp.Summary.TotalItems, }, - Data: data, - CategoryData: categoryData, - ItemData: itemData, + Data: data, + CategoryData: categoryData, + ChartOfAccountData: chartOfAccountData, + ItemData: itemData, } } diff --git a/internal/validator/expense_validator.go b/internal/validator/expense_validator.go index c9306eb..f5be379 100644 --- a/internal/validator/expense_validator.go +++ b/internal/validator/expense_validator.go @@ -68,12 +68,18 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create if strings.TrimSpace(item.ChartOfAccountID) == "" { return fmt.Errorf("item %d: chart_of_account_id is required", i), constants.MissingFieldErrorCode } + if strings.TrimSpace(item.PurchaseCategoryID) == "" { + return fmt.Errorf("item %d: purchase_category_id is required", i), constants.MissingFieldErrorCode + } if strings.TrimSpace(item.Item) == "" { return fmt.Errorf("item %d: item is required", i), constants.MissingFieldErrorCode } if _, err := uuid.Parse(item.ChartOfAccountID); err != nil { return fmt.Errorf("item %d: chart_of_account_id must be a valid UUID", i), constants.MalformedFieldErrorCode } + if _, err := uuid.Parse(item.PurchaseCategoryID); err != nil { + return fmt.Errorf("item %d: purchase_category_id must be a valid UUID", i), constants.MalformedFieldErrorCode + } if item.Amount <= 0 { return fmt.Errorf("item %d: amount must be greater than 0", i), constants.MalformedFieldErrorCode } @@ -126,6 +132,15 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update return fmt.Errorf("item %d: chart_of_account_id must be a valid UUID", i), constants.MalformedFieldErrorCode } } + if item.PurchaseCategoryID == nil { + return fmt.Errorf("item %d: purchase_category_id is required", i), constants.MissingFieldErrorCode + } + if strings.TrimSpace(*item.PurchaseCategoryID) == "" { + return fmt.Errorf("item %d: purchase_category_id cannot be empty", i), constants.MalformedFieldErrorCode + } + if _, err := uuid.Parse(*item.PurchaseCategoryID); err != nil { + return fmt.Errorf("item %d: purchase_category_id must be a valid UUID", i), constants.MalformedFieldErrorCode + } if item.Item != nil && strings.TrimSpace(*item.Item) == "" { return fmt.Errorf("item %d: item cannot be empty", i), constants.MalformedFieldErrorCode } diff --git a/internal/validator/expense_validator_test.go b/internal/validator/expense_validator_test.go index d9ae15d..8729b8f 100644 --- a/internal/validator/expense_validator_test.go +++ b/internal/validator/expense_validator_test.go @@ -21,8 +21,9 @@ func TestExpenseValidatorCreateRequiresItemName(t *testing.T) { Total: 10000, Items: []contract.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: uuid.NewString(), + Amount: 10000, }, }, } @@ -45,9 +46,10 @@ func TestExpenseValidatorCreateDoesNotRequireHeaderExpenseName(t *testing.T) { Total: 10000, Items: []contract.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, }, }, } @@ -71,9 +73,10 @@ func TestExpenseValidatorCreateAllowsValidOptionalStatus(t *testing.T) { Total: 10000, Items: []contract.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, }, }, } @@ -97,9 +100,10 @@ func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) { Total: 10000, Items: []contract.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, }, }, } @@ -114,10 +118,11 @@ func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) { func TestExpenseValidatorUpdateRejectsEmptyItemNameWhenProvided(t *testing.T) { v := NewExpenseValidator() empty := " " + purchaseCategoryID := uuid.NewString() req := &contract.UpdateExpenseRequest{ Items: []contract.UpdateExpenseItemRequest{ - {Item: &empty}, + {PurchaseCategoryID: &purchaseCategoryID, Item: &empty}, }, } diff --git a/migrations/000079_add_purchase_category_to_expense_items.down.sql b/migrations/000079_add_purchase_category_to_expense_items.down.sql new file mode 100644 index 0000000..06940fa --- /dev/null +++ b/migrations/000079_add_purchase_category_to_expense_items.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_expense_items_purchase_category_id; +ALTER TABLE expense_items DROP COLUMN IF EXISTS purchase_category_id; diff --git a/migrations/000079_add_purchase_category_to_expense_items.up.sql b/migrations/000079_add_purchase_category_to_expense_items.up.sql new file mode 100644 index 0000000..d1e1abd --- /dev/null +++ b/migrations/000079_add_purchase_category_to_expense_items.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE expense_items +ADD COLUMN IF NOT EXISTS purchase_category_id UUID REFERENCES purchase_categories(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS idx_expense_items_purchase_category_id +ON expense_items(purchase_category_id); From d0c090a657a63889a7030d1261045d849b97d95e Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 10 Jun 2026 13:18:49 +0700 Subject: [PATCH 08/16] Update purchase analytics --- internal/contract/analytics_contract.go | 20 +- internal/entities/analytics.go | 20 +- internal/models/analytics.go | 20 +- internal/processor/analytics_processor.go | 20 +- .../processor/analytics_processor_test.go | 29 ++- internal/repository/analytics_repository.go | 201 ++++++++++++++---- internal/transformer/analytics_transformer.go | 20 +- .../transformer/analytics_transformer_test.go | 30 +++ 8 files changed, 283 insertions(+), 77 deletions(-) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index f7d019a..a5a0b75 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -106,7 +106,11 @@ type PurchasingAnalyticsResponse struct { type PurchasingSummary struct { TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + NonInventoryPurchases float64 `json:"non_inventory_purchases"` TotalPurchaseOrders int64 `json:"total_purchase_orders"` + RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` + NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` TotalQuantity float64 `json:"total_quantity"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` @@ -114,12 +118,16 @@ type PurchasingSummary struct { } type PurchasingAnalyticsData struct { - Date time.Time `json:"date"` - Purchases float64 `json:"purchases"` - PurchaseOrders int64 `json:"purchase_orders"` - Quantity float64 `json:"quantity"` - Ingredients int64 `json:"ingredients"` - Vendors int64 `json:"vendors"` + Date time.Time `json:"date"` + Purchases float64 `json:"purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + NonInventoryPurchases float64 `json:"non_inventory_purchases"` + PurchaseOrders int64 `json:"purchase_orders"` + RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` + NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + Quantity float64 `json:"quantity"` + Ingredients int64 `json:"ingredients"` + Vendors int64 `json:"vendors"` } type PurchasingIngredientData struct { diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 0fa8bda..b9f74c8 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -38,7 +38,11 @@ type PurchasingAnalytics struct { type PurchasingSummary struct { TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + NonInventoryPurchases float64 `json:"non_inventory_purchases"` TotalPurchaseOrders int64 `json:"total_purchase_orders"` + RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` + NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` TotalQuantity float64 `json:"total_quantity"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` @@ -46,12 +50,16 @@ type PurchasingSummary struct { } type PurchasingAnalyticsData struct { - Date time.Time `json:"date"` - Purchases float64 `json:"purchases"` - PurchaseOrders int64 `json:"purchase_orders"` - Quantity float64 `json:"quantity"` - Ingredients int64 `json:"ingredients"` - Vendors int64 `json:"vendors"` + Date time.Time `json:"date"` + Purchases float64 `json:"purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + NonInventoryPurchases float64 `json:"non_inventory_purchases"` + PurchaseOrders int64 `json:"purchase_orders"` + RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` + NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + Quantity float64 `json:"quantity"` + Ingredients int64 `json:"ingredients"` + Vendors int64 `json:"vendors"` } type PurchasingIngredientData struct { diff --git a/internal/models/analytics.go b/internal/models/analytics.go index ec8858d..0b75cb2 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -113,7 +113,11 @@ type PurchasingAnalyticsResponse struct { // PurchasingSummary represents the summary of purchasing analytics type PurchasingSummary struct { TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + NonInventoryPurchases float64 `json:"non_inventory_purchases"` TotalPurchaseOrders int64 `json:"total_purchase_orders"` + RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` + NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` TotalQuantity float64 `json:"total_quantity"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` @@ -122,12 +126,16 @@ type PurchasingSummary struct { // PurchasingAnalyticsData represents purchasing analytics by time period type PurchasingAnalyticsData struct { - Date time.Time `json:"date"` - Purchases float64 `json:"purchases"` - PurchaseOrders int64 `json:"purchase_orders"` - Quantity float64 `json:"quantity"` - Ingredients int64 `json:"ingredients"` - Vendors int64 `json:"vendors"` + Date time.Time `json:"date"` + Purchases float64 `json:"purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + NonInventoryPurchases float64 `json:"non_inventory_purchases"` + PurchaseOrders int64 `json:"purchase_orders"` + RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` + NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + Quantity float64 `json:"quantity"` + Ingredients int64 `json:"ingredients"` + Vendors int64 `json:"vendors"` } // PurchasingIngredientData represents purchasing analytics for an ingredient diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 4743144..4eccc2c 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -185,12 +185,16 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req data := make([]models.PurchasingAnalyticsData, len(result.Data)) for i, item := range result.Data { data[i] = models.PurchasingAnalyticsData{ - Date: item.Date, - Purchases: item.Purchases, - PurchaseOrders: item.PurchaseOrders, - Quantity: item.Quantity, - Ingredients: item.Ingredients, - Vendors: item.Vendors, + Date: item.Date, + Purchases: item.Purchases, + RawMaterialPurchases: item.RawMaterialPurchases, + NonInventoryPurchases: item.NonInventoryPurchases, + PurchaseOrders: item.PurchaseOrders, + RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders, + NonInventoryExpenseCount: item.NonInventoryExpenseCount, + Quantity: item.Quantity, + Ingredients: item.Ingredients, + Vendors: item.Vendors, } } @@ -227,7 +231,11 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req GroupBy: req.GroupBy, Summary: models.PurchasingSummary{ TotalPurchases: result.Summary.TotalPurchases, + RawMaterialPurchases: result.Summary.RawMaterialPurchases, + NonInventoryPurchases: result.Summary.NonInventoryPurchases, TotalPurchaseOrders: result.Summary.TotalPurchaseOrders, + RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders, + NonInventoryExpenseCount: result.Summary.NonInventoryExpenseCount, TotalQuantity: result.Summary.TotalQuantity, AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, TotalIngredients: result.Summary.TotalIngredients, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 0c46b4d..e088b2e 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -75,7 +75,23 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) purchasingResult: &entities.PurchasingAnalytics{ OutletName: &outletName, Summary: entities.PurchasingSummary{ - TotalPurchases: 125, + TotalPurchases: 300, + RawMaterialPurchases: 125, + NonInventoryPurchases: 175, + TotalPurchaseOrders: 3, + RawMaterialPurchaseOrders: 1, + NonInventoryExpenseCount: 2, + }, + Data: []entities.PurchasingAnalyticsData{ + { + Date: now, + Purchases: 300, + RawMaterialPurchases: 125, + NonInventoryPurchases: 175, + PurchaseOrders: 3, + RawMaterialPurchaseOrders: 1, + NonInventoryExpenseCount: 2, + }, }, }, }, expenseRepositoryStub{}) @@ -92,7 +108,16 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) require.Equal(t, &outletID, result.OutletID) require.NotNil(t, result.OutletName) require.Equal(t, outletName, *result.OutletName) - require.Equal(t, float64(125), result.Summary.TotalPurchases) + require.Equal(t, float64(300), result.Summary.TotalPurchases) + require.Equal(t, float64(125), result.Summary.RawMaterialPurchases) + require.Equal(t, float64(175), result.Summary.NonInventoryPurchases) + require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders) + require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders) + require.Equal(t, int64(2), result.Summary.NonInventoryExpenseCount) + require.Len(t, result.Data, 1) + require.Equal(t, float64(300), result.Data[0].Purchases) + require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases) + require.Equal(t, float64(175), result.Data[0].NonInventoryPurchases) } func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index dd73f91..4677b31 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -145,68 +145,179 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or } } - summaryQuery := r.db.WithContext(ctx). - Table("inventory_movements im"). - Select(` - COALESCE(SUM(im.total_cost), 0) as total_purchases, - COUNT(DISTINCT im.reference_id) as total_purchase_orders, - COALESCE(SUM(im.quantity), 0) as total_quantity, + rawMaterialOutletFilter := "" + nonInventoryOutletFilter := "" + rawMaterialSummaryArgs := []interface{}{ + organizationID, + entities.InventoryMovementTypePurchase, + "INGREDIENT", + entities.InventoryMovementReferenceTypePurchaseOrder, + dateFrom, + dateTo, + } + nonInventorySummaryArgs := []interface{}{ + organizationID, + entities.PurchaseCategoryTypeNonInventory, + "approved", + dateFrom, + dateTo, + } + if outletID != nil { + rawMaterialOutletFilter = "AND im.outlet_id = ?" + nonInventoryOutletFilter = "AND e.outlet_id = ?" + rawMaterialSummaryArgs = append(rawMaterialSummaryArgs, *outletID) + nonInventorySummaryArgs = append(nonInventorySummaryArgs, *outletID) + } + summaryArgs := append(rawMaterialSummaryArgs, nonInventorySummaryArgs...) + + summaryQuery := ` + WITH raw_material AS ( + SELECT + COALESCE(SUM(im.total_cost), 0) as raw_material_purchases, + COUNT(DISTINCT im.reference_id) as raw_material_purchase_orders, + COALESCE(SUM(im.quantity), 0) as total_quantity, + COUNT(DISTINCT im.item_id) as total_ingredients, + COUNT(DISTINCT po.vendor_id) as total_vendors + FROM inventory_movements im + LEFT JOIN purchase_orders po ON im.reference_id = po.id + WHERE im.organization_id = ? + AND im.movement_type = ? + AND im.item_type = ? + AND im.reference_type = ? + AND im.created_at >= ? AND im.created_at <= ? + ` + rawMaterialOutletFilter + ` + ), + non_inventory AS ( + SELECT + COALESCE(SUM(ei.amount), 0) as non_inventory_purchases, + COUNT(DISTINCT e.id) as non_inventory_expense_count + 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 <= ? + ` + nonInventoryOutletFilter + ` + ) + SELECT + rm.raw_material_purchases + ni.non_inventory_purchases as total_purchases, + rm.raw_material_purchases, + ni.non_inventory_purchases, + rm.raw_material_purchase_orders + ni.non_inventory_expense_count as total_purchase_orders, + rm.raw_material_purchase_orders, + ni.non_inventory_expense_count, + rm.total_quantity, CASE - WHEN COUNT(DISTINCT im.reference_id) > 0 - THEN COALESCE(SUM(im.total_cost), 0) / COUNT(DISTINCT im.reference_id) + WHEN rm.raw_material_purchase_orders + ni.non_inventory_expense_count > 0 + THEN (rm.raw_material_purchases + ni.non_inventory_purchases) / (rm.raw_material_purchase_orders + ni.non_inventory_expense_count) ELSE 0 END as average_purchase_order_value, - COUNT(DISTINCT im.item_id) as total_ingredients, - COUNT(DISTINCT po.vendor_id) as total_vendors - `). - Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id"). - Where("im.organization_id = ?", organizationID). - Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). - Where("im.item_type = ?", "INGREDIENT"). - Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). - Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo) + rm.total_ingredients, + rm.total_vendors + FROM raw_material rm + CROSS JOIN non_inventory ni + ` - summaryQuery = r.resolveOutletID(summaryQuery, outletID, "im.outlet_id") - - if err := summaryQuery.Scan(&summary).Error; err != nil { + if err := r.db.WithContext(ctx).Raw(summaryQuery, summaryArgs...).Scan(&summary).Error; err != nil { return nil, err } var dateFormat string switch groupBy { case "hour": - dateFormat = "DATE_TRUNC('hour', im.created_at)" + dateFormat = "DATE_TRUNC('hour', im.created_at)::timestamp" case "week": - dateFormat = "DATE_TRUNC('week', im.created_at)" + dateFormat = "DATE_TRUNC('week', im.created_at)::timestamp" case "month": - dateFormat = "DATE_TRUNC('month', im.created_at)" + dateFormat = "DATE_TRUNC('month', im.created_at)::timestamp" default: - dateFormat = "DATE_TRUNC('day', im.created_at)" + dateFormat = "DATE_TRUNC('day', im.created_at)::timestamp" } + nonInventoryDateFormat := "DATE_TRUNC('day', e.transaction_date)::timestamp" + switch groupBy { + case "hour": + nonInventoryDateFormat = "DATE_TRUNC('hour', e.transaction_date)::timestamp" + case "week": + nonInventoryDateFormat = "DATE_TRUNC('week', e.transaction_date)::timestamp" + case "month": + nonInventoryDateFormat = "DATE_TRUNC('month', e.transaction_date)::timestamp" + } + + rawMaterialDataArgs := []interface{}{ + organizationID, + entities.InventoryMovementTypePurchase, + "INGREDIENT", + entities.InventoryMovementReferenceTypePurchaseOrder, + dateFrom, + dateTo, + } + nonInventoryDataArgs := []interface{}{ + organizationID, + entities.PurchaseCategoryTypeNonInventory, + "approved", + dateFrom, + dateTo, + } + if outletID != nil { + rawMaterialDataArgs = append(rawMaterialDataArgs, *outletID) + nonInventoryDataArgs = append(nonInventoryDataArgs, *outletID) + } + dataArgs := append(rawMaterialDataArgs, nonInventoryDataArgs...) + var data []entities.PurchasingAnalyticsData - dataQuery := r.db.WithContext(ctx). - Table("inventory_movements im"). - Select(` - `+dateFormat+` as date, - COALESCE(SUM(im.total_cost), 0) as purchases, - COUNT(DISTINCT im.reference_id) as purchase_orders, - COALESCE(SUM(im.quantity), 0) as quantity, - COUNT(DISTINCT im.item_id) as ingredients, - COUNT(DISTINCT po.vendor_id) as vendors - `). - Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id"). - Where("im.organization_id = ?", organizationID). - Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). - Where("im.item_type = ?", "INGREDIENT"). - Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). - Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). - Group(dateFormat). - Order(dateFormat) + dataQuery := ` + WITH raw_material AS ( + SELECT + ` + dateFormat + ` as date, + COALESCE(SUM(im.total_cost), 0) as raw_material_purchases, + COUNT(DISTINCT im.reference_id) as raw_material_purchase_orders, + COALESCE(SUM(im.quantity), 0) as quantity, + COUNT(DISTINCT im.item_id) as ingredients, + COUNT(DISTINCT po.vendor_id) as vendors + FROM inventory_movements im + LEFT JOIN purchase_orders po ON im.reference_id = po.id + WHERE im.organization_id = ? + AND im.movement_type = ? + AND im.item_type = ? + AND im.reference_type = ? + AND im.created_at >= ? AND im.created_at <= ? + ` + rawMaterialOutletFilter + ` + GROUP BY 1 + ), + non_inventory AS ( + SELECT + ` + nonInventoryDateFormat + ` as date, + COALESCE(SUM(ei.amount), 0) as non_inventory_purchases, + COUNT(DISTINCT e.id) as non_inventory_expense_count + 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 <= ? + ` + nonInventoryOutletFilter + ` + GROUP BY 1 + ) + SELECT + COALESCE(rm.date, ni.date) as date, + COALESCE(rm.raw_material_purchases, 0) + COALESCE(ni.non_inventory_purchases, 0) as purchases, + COALESCE(rm.raw_material_purchases, 0) as raw_material_purchases, + COALESCE(ni.non_inventory_purchases, 0) as non_inventory_purchases, + COALESCE(rm.raw_material_purchase_orders, 0) + COALESCE(ni.non_inventory_expense_count, 0) as purchase_orders, + COALESCE(rm.raw_material_purchase_orders, 0) as raw_material_purchase_orders, + COALESCE(ni.non_inventory_expense_count, 0) as non_inventory_expense_count, + COALESCE(rm.quantity, 0) as quantity, + COALESCE(rm.ingredients, 0) as ingredients, + COALESCE(rm.vendors, 0) as vendors + FROM raw_material rm + FULL OUTER JOIN non_inventory ni ON rm.date = ni.date + ORDER BY date + ` - dataQuery = r.resolveOutletID(dataQuery, outletID, "im.outlet_id") - - if err := dataQuery.Scan(&data).Error; err != nil { + if err := r.db.WithContext(ctx).Raw(dataQuery, dataArgs...).Scan(&data).Error; err != nil { return nil, err } diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index f157598..93f3967 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -169,12 +169,16 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse data := make([]contract.PurchasingAnalyticsData, len(resp.Data)) for i, item := range resp.Data { data[i] = contract.PurchasingAnalyticsData{ - Date: item.Date, - Purchases: item.Purchases, - PurchaseOrders: item.PurchaseOrders, - Quantity: item.Quantity, - Ingredients: item.Ingredients, - Vendors: item.Vendors, + Date: item.Date, + Purchases: item.Purchases, + RawMaterialPurchases: item.RawMaterialPurchases, + NonInventoryPurchases: item.NonInventoryPurchases, + PurchaseOrders: item.PurchaseOrders, + RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders, + NonInventoryExpenseCount: item.NonInventoryExpenseCount, + Quantity: item.Quantity, + Ingredients: item.Ingredients, + Vendors: item.Vendors, } } @@ -211,7 +215,11 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse GroupBy: resp.GroupBy, Summary: contract.PurchasingSummary{ TotalPurchases: resp.Summary.TotalPurchases, + RawMaterialPurchases: resp.Summary.RawMaterialPurchases, + NonInventoryPurchases: resp.Summary.NonInventoryPurchases, TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders, + RawMaterialPurchaseOrders: resp.Summary.RawMaterialPurchaseOrders, + NonInventoryExpenseCount: resp.Summary.NonInventoryExpenseCount, TotalQuantity: resp.Summary.TotalQuantity, AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue, TotalIngredients: resp.Summary.TotalIngredients, diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 2a7bc6c..45775ff 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -52,17 +52,47 @@ func TestPurchasingAnalyticsContractToModelIgnoresInvalidOutlet(t *testing.T) { func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) { outletID := uuid.New() outletName := "Main Outlet" + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ OrganizationID: uuid.New(), OutletID: &outletID, OutletName: &outletName, + Summary: models.PurchasingSummary{ + TotalPurchases: 300, + RawMaterialPurchases: 125, + NonInventoryPurchases: 175, + TotalPurchaseOrders: 3, + RawMaterialPurchaseOrders: 1, + NonInventoryExpenseCount: 2, + }, + Data: []models.PurchasingAnalyticsData{ + { + Date: now, + Purchases: 300, + RawMaterialPurchases: 125, + NonInventoryPurchases: 175, + PurchaseOrders: 3, + RawMaterialPurchaseOrders: 1, + NonInventoryExpenseCount: 2, + }, + }, }) require.NotNil(t, result) require.Equal(t, &outletID, result.OutletID) require.NotNil(t, result.OutletName) require.Equal(t, outletName, *result.OutletName) + require.Equal(t, float64(300), result.Summary.TotalPurchases) + require.Equal(t, float64(125), result.Summary.RawMaterialPurchases) + require.Equal(t, float64(175), result.Summary.NonInventoryPurchases) + require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders) + require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders) + require.Equal(t, int64(2), result.Summary.NonInventoryExpenseCount) + require.Len(t, result.Data, 1) + require.Equal(t, float64(300), result.Data[0].Purchases) + require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases) + require.Equal(t, float64(175), result.Data[0].NonInventoryPurchases) } func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { From 1718c5adabd237c379fbc9d27ecd4d9b3bb7bc24 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 10 Jun 2026 14:25:23 +0700 Subject: [PATCH 09/16] Fix expense to be nullable without raw material. --- internal/contract/analytics_contract.go | 8 +- .../contract/purchase_category_contract.go | 6 +- internal/contract/purchase_order_contract.go | 18 +- internal/entities/analytics.go | 8 +- internal/entities/purchase_category.go | 4 +- internal/entities/purchase_order.go | 20 +- internal/models/analytics.go | 8 +- internal/models/purchase_order.go | 38 ++-- internal/processor/analytics_processor.go | 8 +- .../processor/analytics_processor_test.go | 14 +- internal/processor/expense_processor.go | 10 +- internal/processor/expense_processor_test.go | 12 +- .../processor/purchase_order_processor.go | 177 ++++++++++-------- internal/repository/analytics_repository.go | 72 +++---- internal/repository/expense_repository.go | 2 +- internal/transformer/analytics_transformer.go | 8 +- .../transformer/analytics_transformer_test.go | 14 +- .../purchase_order_transformer_test.go | 10 +- .../validator/purchase_category_validator.go | 8 +- .../validator/purchase_order_validator.go | 22 ++- .../purchase_order_validator_test.go | 10 +- .../000075_create_purchase_categories.up.sql | 30 ++- ...non_inventory_purchase_categories.down.sql | 32 ++++ ...e_non_inventory_purchase_categories.up.sql | 36 ++++ 24 files changed, 342 insertions(+), 233 deletions(-) create mode 100644 migrations/000080_rename_non_inventory_purchase_categories.down.sql create mode 100644 migrations/000080_rename_non_inventory_purchase_categories.up.sql diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index a5a0b75..786a90d 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -107,10 +107,10 @@ type PurchasingAnalyticsResponse struct { type PurchasingSummary struct { TotalPurchases float64 `json:"total_purchases"` RawMaterialPurchases float64 `json:"raw_material_purchases"` - NonInventoryPurchases float64 `json:"non_inventory_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` TotalPurchaseOrders int64 `json:"total_purchase_orders"` RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` - NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + ExpenseCount int64 `json:"expense_count"` TotalQuantity float64 `json:"total_quantity"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` @@ -121,10 +121,10 @@ type PurchasingAnalyticsData struct { Date time.Time `json:"date"` Purchases float64 `json:"purchases"` RawMaterialPurchases float64 `json:"raw_material_purchases"` - NonInventoryPurchases float64 `json:"non_inventory_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` PurchaseOrders int64 `json:"purchase_orders"` RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` - NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + ExpenseCount int64 `json:"expense_count"` Quantity float64 `json:"quantity"` Ingredients int64 `json:"ingredients"` Vendors int64 `json:"vendors"` diff --git a/internal/contract/purchase_category_contract.go b/internal/contract/purchase_category_contract.go index 8aed0ef..817bb42 100644 --- a/internal/contract/purchase_category_contract.go +++ b/internal/contract/purchase_category_contract.go @@ -10,7 +10,7 @@ type CreatePurchaseCategoryRequest struct { ParentID *uuid.UUID `json:"parent_id,omitempty"` Code *string `json:"code,omitempty"` Name string `json:"name" validate:"required,min=1,max=255"` - Type string `json:"type" validate:"required,oneof=raw_material non_inventory"` + Type string `json:"type" validate:"required,oneof=raw_material expense"` SortOrder *int `json:"sort_order,omitempty"` IsActive *bool `json:"is_active,omitempty"` } @@ -19,14 +19,14 @@ type UpdatePurchaseCategoryRequest struct { ParentID *uuid.UUID `json:"parent_id,omitempty"` Code *string `json:"code,omitempty"` Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` - Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material non_inventory"` + Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"` SortOrder *int `json:"sort_order,omitempty"` IsActive *bool `json:"is_active,omitempty"` } type ListPurchaseCategoriesRequest struct { ParentID *uuid.UUID `json:"parent_id,omitempty"` - Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material non_inventory"` + Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"` Search string `json:"search,omitempty"` IsActive *bool `json:"is_active,omitempty"` Page int `json:"page" validate:"required,min=1"` diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 70e722c..907316a 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -19,12 +19,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"` - Description *string `json:"description,omitempty" validate:"omitempty"` - Quantity float64 `json:"quantity" validate:"required,gt=0"` - UnitID uuid.UUID `json:"unit_id" validate:"required"` - Amount float64 `json:"amount" validate:"required,gte=0"` + IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"` + Description *string `json:"description,omitempty" validate:"omitempty"` + Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"` + UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"` + Amount float64 `json:"amount" validate:"required,gte=0"` } type UpdatePurchaseOrderRequest struct { @@ -70,11 +70,11 @@ type PurchaseOrderResponse struct { type PurchaseOrderItemResponse struct { ID uuid.UUID `json:"id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID uuid.UUID `json:"ingredient_id"` + IngredientID *uuid.UUID `json:"ingredient_id"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` Description *string `json:"description"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` + Quantity *float64 `json:"quantity"` + UnitID *uuid.UUID `json:"unit_id"` Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index b9f74c8..66b37d0 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -39,10 +39,10 @@ type PurchasingAnalytics struct { type PurchasingSummary struct { TotalPurchases float64 `json:"total_purchases"` RawMaterialPurchases float64 `json:"raw_material_purchases"` - NonInventoryPurchases float64 `json:"non_inventory_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` TotalPurchaseOrders int64 `json:"total_purchase_orders"` RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` - NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + ExpenseCount int64 `json:"expense_count"` TotalQuantity float64 `json:"total_quantity"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` @@ -53,10 +53,10 @@ type PurchasingAnalyticsData struct { Date time.Time `json:"date"` Purchases float64 `json:"purchases"` RawMaterialPurchases float64 `json:"raw_material_purchases"` - NonInventoryPurchases float64 `json:"non_inventory_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` PurchaseOrders int64 `json:"purchase_orders"` RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` - NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + ExpenseCount int64 `json:"expense_count"` Quantity float64 `json:"quantity"` Ingredients int64 `json:"ingredients"` Vendors int64 `json:"vendors"` diff --git a/internal/entities/purchase_category.go b/internal/entities/purchase_category.go index 34c5667..31c1718 100644 --- a/internal/entities/purchase_category.go +++ b/internal/entities/purchase_category.go @@ -10,8 +10,8 @@ import ( type PurchaseCategoryType string const ( - PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material" - PurchaseCategoryTypeNonInventory PurchaseCategoryType = "non_inventory" + PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material" + PurchaseCategoryTypeExpense PurchaseCategoryType = "expense" ) type PurchaseCategoryPreset struct { diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index 3a455db..b98006a 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -41,16 +41,16 @@ func (PurchaseOrder) TableName() string { } type PurchaseOrderItem struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` - IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"` - PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"` - Description *string `gorm:"type:text" json:"description" validate:"omitempty"` - Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"` - UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"` - Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` + IngredientID *uuid.UUID `gorm:"type:uuid" json:"ingredient_id" validate:"omitempty"` + PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"` + Description *string `gorm:"type:text" json:"description" validate:"omitempty"` + Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"` + UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" validate:"omitempty"` + Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"` Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 0b75cb2..e72e3e0 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -114,10 +114,10 @@ type PurchasingAnalyticsResponse struct { type PurchasingSummary struct { TotalPurchases float64 `json:"total_purchases"` RawMaterialPurchases float64 `json:"raw_material_purchases"` - NonInventoryPurchases float64 `json:"non_inventory_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` TotalPurchaseOrders int64 `json:"total_purchase_orders"` RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` - NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + ExpenseCount int64 `json:"expense_count"` TotalQuantity float64 `json:"total_quantity"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` @@ -129,10 +129,10 @@ type PurchasingAnalyticsData struct { Date time.Time `json:"date"` Purchases float64 `json:"purchases"` RawMaterialPurchases float64 `json:"raw_material_purchases"` - NonInventoryPurchases float64 `json:"non_inventory_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` PurchaseOrders int64 `json:"purchase_orders"` RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` - NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + ExpenseCount int64 `json:"expense_count"` Quantity float64 `json:"quantity"` Ingredients int64 `json:"ingredients"` Vendors int64 `json:"vendors"` diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 1afa23f..562271e 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -22,16 +22,16 @@ type PurchaseOrder struct { } type PurchaseOrderItem struct { - ID uuid.UUID `json:"id"` - PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID uuid.UUID `json:"ingredient_id"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` - Description *string `json:"description"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + PurchaseOrderID uuid.UUID `json:"purchase_order_id"` + IngredientID *uuid.UUID `json:"ingredient_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description"` + Quantity *float64 `json:"quantity"` + UnitID *uuid.UUID `json:"unit_id"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PurchaseOrderAttachment struct { @@ -62,11 +62,11 @@ type PurchaseOrderResponse struct { type PurchaseOrderItemResponse struct { ID uuid.UUID `json:"id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID uuid.UUID `json:"ingredient_id"` + IngredientID *uuid.UUID `json:"ingredient_id"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` Description *string `json:"description"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` + Quantity *float64 `json:"quantity"` + UnitID *uuid.UUID `json:"unit_id"` Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -96,12 +96,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - IngredientID uuid.UUID `json:"ingredient_id"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` - Description *string `json:"description,omitempty"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` - Amount float64 `json:"amount"` + IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description,omitempty"` + Quantity *float64 `json:"quantity,omitempty"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + Amount float64 `json:"amount"` } type UpdatePurchaseOrderRequest struct { diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 4eccc2c..895f3b0 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -188,10 +188,10 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req Date: item.Date, Purchases: item.Purchases, RawMaterialPurchases: item.RawMaterialPurchases, - NonInventoryPurchases: item.NonInventoryPurchases, + ExpensePurchases: item.ExpensePurchases, PurchaseOrders: item.PurchaseOrders, RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders, - NonInventoryExpenseCount: item.NonInventoryExpenseCount, + ExpenseCount: item.ExpenseCount, Quantity: item.Quantity, Ingredients: item.Ingredients, Vendors: item.Vendors, @@ -232,10 +232,10 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req Summary: models.PurchasingSummary{ TotalPurchases: result.Summary.TotalPurchases, RawMaterialPurchases: result.Summary.RawMaterialPurchases, - NonInventoryPurchases: result.Summary.NonInventoryPurchases, + ExpensePurchases: result.Summary.ExpensePurchases, TotalPurchaseOrders: result.Summary.TotalPurchaseOrders, RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders, - NonInventoryExpenseCount: result.Summary.NonInventoryExpenseCount, + ExpenseCount: result.Summary.ExpenseCount, TotalQuantity: result.Summary.TotalQuantity, AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, TotalIngredients: result.Summary.TotalIngredients, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index e088b2e..b50c462 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -77,20 +77,20 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) Summary: entities.PurchasingSummary{ TotalPurchases: 300, RawMaterialPurchases: 125, - NonInventoryPurchases: 175, + ExpensePurchases: 175, TotalPurchaseOrders: 3, RawMaterialPurchaseOrders: 1, - NonInventoryExpenseCount: 2, + ExpenseCount: 2, }, Data: []entities.PurchasingAnalyticsData{ { Date: now, Purchases: 300, RawMaterialPurchases: 125, - NonInventoryPurchases: 175, + ExpensePurchases: 175, PurchaseOrders: 3, RawMaterialPurchaseOrders: 1, - NonInventoryExpenseCount: 2, + ExpenseCount: 2, }, }, }, @@ -110,14 +110,14 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) require.Equal(t, outletName, *result.OutletName) require.Equal(t, float64(300), result.Summary.TotalPurchases) require.Equal(t, float64(125), result.Summary.RawMaterialPurchases) - require.Equal(t, float64(175), result.Summary.NonInventoryPurchases) + require.Equal(t, float64(175), result.Summary.ExpensePurchases) require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders) require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders) - require.Equal(t, int64(2), result.Summary.NonInventoryExpenseCount) + require.Equal(t, int64(2), result.Summary.ExpenseCount) require.Len(t, result.Data, 1) require.Equal(t, float64(300), result.Data[0].Purchases) require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases) - require.Equal(t, float64(175), result.Data[0].NonInventoryPurchases) + require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 0b8ab86..5419b1c 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -61,7 +61,7 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID if err != nil { return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err) } - if err := p.validateNonInventoryPurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil { + if err := p.validateExpensePurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil { return nil, err } @@ -169,7 +169,7 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati if err != nil { return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err) } - if err := p.validateNonInventoryPurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil { + if err := p.validateExpensePurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil { return nil, err } @@ -334,7 +334,7 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod }, nil } -func (p *ExpenseProcessorImpl) validateNonInventoryPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error { +func (p *ExpenseProcessorImpl) validateExpensePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error { category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) if err != nil { return fmt.Errorf("purchase category not found: %w", err) @@ -344,8 +344,8 @@ func (p *ExpenseProcessorImpl) validateNonInventoryPurchaseCategory(ctx context. return fmt.Errorf("purchase category is inactive") } - if category.Type != entities.PurchaseCategoryTypeNonInventory { - return fmt.Errorf("purchase category must be non_inventory") + if category.Type != entities.PurchaseCategoryTypeExpense { + return fmt.Errorf("purchase category must be expense") } return nil diff --git a/internal/processor/expense_processor_test.go b/internal/processor/expense_processor_test.go index e0a4435..ba0fb3e 100644 --- a/internal/processor/expense_processor_test.go +++ b/internal/processor/expense_processor_test.go @@ -102,7 +102,7 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { repo := &expenseRepositoryCaptureStub{} purchaseCategoryID := uuid.New() - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) chartOfAccountID := uuid.New() resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -133,7 +133,7 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { repo := &expenseRepositoryCaptureStub{} purchaseCategoryID := uuid.New() - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ Receiver: "Cashier", @@ -160,7 +160,7 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { repo := &expenseRepositoryCaptureStub{} purchaseCategoryID := uuid.New() - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) status := "approved" resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -209,7 +209,7 @@ func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) require.Error(t, err) require.Nil(t, resp) - require.Contains(t, err.Error(), "non_inventory") + require.Contains(t, err.Error(), "expense") } func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) { @@ -241,7 +241,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te { PurchaseCategoryID: purchaseCategoryID, PurchaseCategoryName: "Operational Supplies", - PurchaseCategoryType: "non_inventory", + PurchaseCategoryType: "expense", TotalAmount: 100000, ExpenseCount: 2, ItemCount: 2, @@ -266,7 +266,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te }, }, } - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{ OrganizationID: uuid.New(), diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index b666d9b..7d87e90 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -67,20 +67,40 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber) } - // Validate ingredients, raw-material categories, and units exist + // Validate categories and inventory fields per item type. for i, item := range req.Items { - _, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) + category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i) if err != nil { - return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) - } - - if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil { return nil, err } - _, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID) - if err != nil { - return nil, fmt.Errorf("unit not found for item %d: %w", i, err) + switch category.Type { + case entities.PurchaseCategoryTypeRawMaterial: + if item.IngredientID == nil { + return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i) + } + if item.Quantity == nil { + return nil, fmt.Errorf("quantity is required for raw_material item %d", i) + } + if item.UnitID == nil { + return nil, fmt.Errorf("unit_id is required for raw_material item %d", i) + } + + _, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID) + if err != nil { + return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) + } + + _, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID) + if err != nil { + return nil, fmt.Errorf("unit not found for item %d: %w", i, err) + } + case entities.PurchaseCategoryTypeExpense: + if item.IngredientID != nil || item.Quantity != nil || item.UnitID != nil { + return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i) + } + default: + return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type) } } @@ -197,64 +217,58 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id // Update items if provided if req.Items != nil { - // Delete existing items - err = p.purchaseOrderRepo.DeleteItemsByPurchaseOrderID(ctx, poEntity.ID) - if err != nil { - return nil, fmt.Errorf("failed to delete existing items: %w", err) - } - - // Create new items totalAmount := 0.0 + items := make([]*entities.PurchaseOrderItem, len(req.Items)) for i, itemReq := range req.Items { - // Validate ingredients and units exist - if itemReq.IngredientID != nil { - _, err := p.ingredientRepo.GetByID(ctx, *itemReq.IngredientID, organizationID) - if err != nil { - return nil, fmt.Errorf("ingredient not found: %w", err) - } + if itemReq.PurchaseCategoryID == nil { + return nil, fmt.Errorf("purchase_category_id is required for item %d", i) } - if itemReq.UnitID != nil { - _, err := p.unitRepo.GetByID(ctx, *itemReq.UnitID, organizationID) - if err != nil { - return nil, fmt.Errorf("unit not found: %w", err) - } - } - - if itemReq.PurchaseCategoryID != nil { - if err := p.validateRawMaterialPurchaseCategory(ctx, *itemReq.PurchaseCategoryID, organizationID, i); err != nil { - return nil, err - } - } - - // Use existing values if not provided - ingredientID := poEntity.Items[0].IngredientID // This is a simplified approach - purchaseCategoryID := poEntity.Items[0].PurchaseCategoryID - unitID := poEntity.Items[0].UnitID - quantity := poEntity.Items[0].Quantity - amount := poEntity.Items[0].Amount - description := poEntity.Items[0].Description - - if itemReq.IngredientID != nil { - ingredientID = *itemReq.IngredientID - } - if itemReq.UnitID != nil { - unitID = *itemReq.UnitID - } - if itemReq.PurchaseCategoryID != nil { - purchaseCategoryID = *itemReq.PurchaseCategoryID - } - if itemReq.Quantity != nil { - quantity = *itemReq.Quantity - } + ingredientID := itemReq.IngredientID + purchaseCategoryID := *itemReq.PurchaseCategoryID + unitID := itemReq.UnitID + quantity := itemReq.Quantity + amount := 0.0 if itemReq.Amount != nil { amount = *itemReq.Amount } - if itemReq.Description != nil { - description = itemReq.Description + description := itemReq.Description + + category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i) + if err != nil { + return nil, err } - itemEntity := &entities.PurchaseOrderItem{ + switch category.Type { + case entities.PurchaseCategoryTypeRawMaterial: + if ingredientID == nil { + return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i) + } + if quantity == nil { + return nil, fmt.Errorf("quantity is required for raw_material item %d", i) + } + if unitID == nil { + return nil, fmt.Errorf("unit_id is required for raw_material item %d", i) + } + + _, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID) + if err != nil { + return nil, fmt.Errorf("ingredient not found: %w", err) + } + + _, err = p.unitRepo.GetByID(ctx, *unitID, organizationID) + if err != nil { + return nil, fmt.Errorf("unit not found: %w", err) + } + case entities.PurchaseCategoryTypeExpense: + if ingredientID != nil || quantity != nil || unitID != nil { + return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i) + } + default: + return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type) + } + + items[i] = &entities.PurchaseOrderItem{ PurchaseOrderID: poEntity.ID, IngredientID: ingredientID, PurchaseCategoryID: purchaseCategoryID, @@ -263,13 +277,20 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id UnitID: unitID, Amount: amount, } + totalAmount += amount + } + // Delete and recreate only after all replacement items are valid. + err = p.purchaseOrderRepo.DeleteItemsByPurchaseOrderID(ctx, poEntity.ID) + if err != nil { + return nil, fmt.Errorf("failed to delete existing items: %w", err) + } + + for _, itemEntity := range items { err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity) if err != nil { return nil, fmt.Errorf("failed to create purchase order item: %w", err) } - - totalAmount += amount } poEntity.TotalAmount = totalAmount @@ -398,19 +419,27 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte // Update inventory for each item for _, item := range poWithItems.Items { + if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense { + continue + } + + if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil { + return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID) + } + // Get ingredient to find its base unit - ingredient, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) + ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID) if err != nil { - return nil, fmt.Errorf("failed to get ingredient %s: %w", item.IngredientID, err) + return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err) } // Convert quantity to ingredient's base unit if needed - quantityToAdd := item.Quantity - if item.UnitID != ingredient.UnitID { + quantityToAdd := *item.Quantity + if *item.UnitID != ingredient.UnitID { // Convert from purchase unit to ingredient's base unit - convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, item.IngredientID, item.UnitID, ingredient.UnitID, organizationID, item.Quantity) + convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity) if err != nil { - return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", item.IngredientID, item.UnitID, ingredient.UnitID, err) + return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err) } quantityToAdd = convertedQuantity } @@ -428,7 +457,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte err = p.inventoryMovementService.CreateIngredientMovement( ctx, - item.IngredientID, + *item.IngredientID, organizationID, outletID, userID, @@ -441,7 +470,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte &item.ID, ) if err != nil { - return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err) + return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err) } } } @@ -461,19 +490,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return mappers.PurchaseOrderEntityToResponse(updatedPO), nil } -func (p *PurchaseOrderProcessorImpl) validateRawMaterialPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) error { +func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) { category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) if err != nil { - return fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err) + return nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err) } if !category.IsActive { - return fmt.Errorf("purchase category for item %d is inactive", itemIndex) + return nil, fmt.Errorf("purchase category for item %d is inactive", itemIndex) } - if category.Type != entities.PurchaseCategoryTypeRawMaterial { - return fmt.Errorf("purchase category for item %d must be raw_material", itemIndex) + if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense { + return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex) } - return nil + return category, nil } diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 4677b31..1a6852a 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -146,7 +146,7 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or } rawMaterialOutletFilter := "" - nonInventoryOutletFilter := "" + expenseOutletFilter := "" rawMaterialSummaryArgs := []interface{}{ organizationID, entities.InventoryMovementTypePurchase, @@ -155,20 +155,20 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or dateFrom, dateTo, } - nonInventorySummaryArgs := []interface{}{ + expenseSummaryArgs := []interface{}{ organizationID, - entities.PurchaseCategoryTypeNonInventory, + entities.PurchaseCategoryTypeExpense, "approved", dateFrom, dateTo, } if outletID != nil { rawMaterialOutletFilter = "AND im.outlet_id = ?" - nonInventoryOutletFilter = "AND e.outlet_id = ?" + expenseOutletFilter = "AND e.outlet_id = ?" rawMaterialSummaryArgs = append(rawMaterialSummaryArgs, *outletID) - nonInventorySummaryArgs = append(nonInventorySummaryArgs, *outletID) + expenseSummaryArgs = append(expenseSummaryArgs, *outletID) } - summaryArgs := append(rawMaterialSummaryArgs, nonInventorySummaryArgs...) + summaryArgs := append(rawMaterialSummaryArgs, expenseSummaryArgs...) summaryQuery := ` WITH raw_material AS ( @@ -187,10 +187,10 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or AND im.created_at >= ? AND im.created_at <= ? ` + rawMaterialOutletFilter + ` ), - non_inventory AS ( + expense AS ( SELECT - COALESCE(SUM(ei.amount), 0) as non_inventory_purchases, - COUNT(DISTINCT e.id) as non_inventory_expense_count + COALESCE(SUM(ei.amount), 0) as expense_purchases, + COUNT(DISTINCT e.id) as expense_count FROM expense_items ei JOIN expenses e ON ei.expense_id = e.id JOIN purchase_categories pc ON ei.purchase_category_id = pc.id @@ -198,25 +198,25 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or AND pc.type = ? AND e.status = ? AND e.transaction_date >= ? AND e.transaction_date <= ? - ` + nonInventoryOutletFilter + ` + ` + expenseOutletFilter + ` ) SELECT - rm.raw_material_purchases + ni.non_inventory_purchases as total_purchases, + rm.raw_material_purchases + ex.expense_purchases as total_purchases, rm.raw_material_purchases, - ni.non_inventory_purchases, - rm.raw_material_purchase_orders + ni.non_inventory_expense_count as total_purchase_orders, + ex.expense_purchases, + rm.raw_material_purchase_orders + ex.expense_count as total_purchase_orders, rm.raw_material_purchase_orders, - ni.non_inventory_expense_count, + ex.expense_count, rm.total_quantity, CASE - WHEN rm.raw_material_purchase_orders + ni.non_inventory_expense_count > 0 - THEN (rm.raw_material_purchases + ni.non_inventory_purchases) / (rm.raw_material_purchase_orders + ni.non_inventory_expense_count) + WHEN rm.raw_material_purchase_orders + ex.expense_count > 0 + THEN (rm.raw_material_purchases + ex.expense_purchases) / (rm.raw_material_purchase_orders + ex.expense_count) ELSE 0 END as average_purchase_order_value, rm.total_ingredients, rm.total_vendors FROM raw_material rm - CROSS JOIN non_inventory ni + CROSS JOIN expense ex ` if err := r.db.WithContext(ctx).Raw(summaryQuery, summaryArgs...).Scan(&summary).Error; err != nil { @@ -235,14 +235,14 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or dateFormat = "DATE_TRUNC('day', im.created_at)::timestamp" } - nonInventoryDateFormat := "DATE_TRUNC('day', e.transaction_date)::timestamp" + expenseDateFormat := "DATE_TRUNC('day', e.transaction_date)::timestamp" switch groupBy { case "hour": - nonInventoryDateFormat = "DATE_TRUNC('hour', e.transaction_date)::timestamp" + expenseDateFormat = "DATE_TRUNC('hour', e.transaction_date)::timestamp" case "week": - nonInventoryDateFormat = "DATE_TRUNC('week', e.transaction_date)::timestamp" + expenseDateFormat = "DATE_TRUNC('week', e.transaction_date)::timestamp" case "month": - nonInventoryDateFormat = "DATE_TRUNC('month', e.transaction_date)::timestamp" + expenseDateFormat = "DATE_TRUNC('month', e.transaction_date)::timestamp" } rawMaterialDataArgs := []interface{}{ @@ -253,18 +253,18 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or dateFrom, dateTo, } - nonInventoryDataArgs := []interface{}{ + expenseDataArgs := []interface{}{ organizationID, - entities.PurchaseCategoryTypeNonInventory, + entities.PurchaseCategoryTypeExpense, "approved", dateFrom, dateTo, } if outletID != nil { rawMaterialDataArgs = append(rawMaterialDataArgs, *outletID) - nonInventoryDataArgs = append(nonInventoryDataArgs, *outletID) + expenseDataArgs = append(expenseDataArgs, *outletID) } - dataArgs := append(rawMaterialDataArgs, nonInventoryDataArgs...) + dataArgs := append(rawMaterialDataArgs, expenseDataArgs...) var data []entities.PurchasingAnalyticsData dataQuery := ` @@ -286,11 +286,11 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or ` + rawMaterialOutletFilter + ` GROUP BY 1 ), - non_inventory AS ( + expense AS ( SELECT - ` + nonInventoryDateFormat + ` as date, - COALESCE(SUM(ei.amount), 0) as non_inventory_purchases, - COUNT(DISTINCT e.id) as non_inventory_expense_count + ` + expenseDateFormat + ` as date, + COALESCE(SUM(ei.amount), 0) as expense_purchases, + COUNT(DISTINCT e.id) as expense_count FROM expense_items ei JOIN expenses e ON ei.expense_id = e.id JOIN purchase_categories pc ON ei.purchase_category_id = pc.id @@ -298,22 +298,22 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or AND pc.type = ? AND e.status = ? AND e.transaction_date >= ? AND e.transaction_date <= ? - ` + nonInventoryOutletFilter + ` + ` + expenseOutletFilter + ` GROUP BY 1 ) SELECT - COALESCE(rm.date, ni.date) as date, - COALESCE(rm.raw_material_purchases, 0) + COALESCE(ni.non_inventory_purchases, 0) as purchases, + COALESCE(rm.date, ex.date) as date, + COALESCE(rm.raw_material_purchases, 0) + COALESCE(ex.expense_purchases, 0) as purchases, COALESCE(rm.raw_material_purchases, 0) as raw_material_purchases, - COALESCE(ni.non_inventory_purchases, 0) as non_inventory_purchases, - COALESCE(rm.raw_material_purchase_orders, 0) + COALESCE(ni.non_inventory_expense_count, 0) as purchase_orders, + COALESCE(ex.expense_purchases, 0) as expense_purchases, + COALESCE(rm.raw_material_purchase_orders, 0) + COALESCE(ex.expense_count, 0) as purchase_orders, COALESCE(rm.raw_material_purchase_orders, 0) as raw_material_purchase_orders, - COALESCE(ni.non_inventory_expense_count, 0) as non_inventory_expense_count, + COALESCE(ex.expense_count, 0) as expense_count, COALESCE(rm.quantity, 0) as quantity, COALESCE(rm.ingredients, 0) as ingredients, COALESCE(rm.vendors, 0) as vendors FROM raw_material rm - FULL OUTER JOIN non_inventory ni ON rm.date = ni.date + FULL OUTER JOIN expense ex ON rm.date = ex.date ORDER BY date ` diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index bf557af..8698db2 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -207,7 +207,7 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID 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.PurchaseCategoryTypeNonInventory). + Where("pc.type = ?", entities.PurchaseCategoryTypeExpense). Where("e.status = ?", "approved"). Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo). Group("pc.id, pc.name, pc.type"). diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 93f3967..fa3c42d 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -172,10 +172,10 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse Date: item.Date, Purchases: item.Purchases, RawMaterialPurchases: item.RawMaterialPurchases, - NonInventoryPurchases: item.NonInventoryPurchases, + ExpensePurchases: item.ExpensePurchases, PurchaseOrders: item.PurchaseOrders, RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders, - NonInventoryExpenseCount: item.NonInventoryExpenseCount, + ExpenseCount: item.ExpenseCount, Quantity: item.Quantity, Ingredients: item.Ingredients, Vendors: item.Vendors, @@ -216,10 +216,10 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse Summary: contract.PurchasingSummary{ TotalPurchases: resp.Summary.TotalPurchases, RawMaterialPurchases: resp.Summary.RawMaterialPurchases, - NonInventoryPurchases: resp.Summary.NonInventoryPurchases, + ExpensePurchases: resp.Summary.ExpensePurchases, TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders, RawMaterialPurchaseOrders: resp.Summary.RawMaterialPurchaseOrders, - NonInventoryExpenseCount: resp.Summary.NonInventoryExpenseCount, + ExpenseCount: resp.Summary.ExpenseCount, TotalQuantity: resp.Summary.TotalQuantity, AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue, TotalIngredients: resp.Summary.TotalIngredients, diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 45775ff..4fca5cf 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -61,20 +61,20 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) { Summary: models.PurchasingSummary{ TotalPurchases: 300, RawMaterialPurchases: 125, - NonInventoryPurchases: 175, + ExpensePurchases: 175, TotalPurchaseOrders: 3, RawMaterialPurchaseOrders: 1, - NonInventoryExpenseCount: 2, + ExpenseCount: 2, }, Data: []models.PurchasingAnalyticsData{ { Date: now, Purchases: 300, RawMaterialPurchases: 125, - NonInventoryPurchases: 175, + ExpensePurchases: 175, PurchaseOrders: 3, RawMaterialPurchaseOrders: 1, - NonInventoryExpenseCount: 2, + ExpenseCount: 2, }, }, }) @@ -85,14 +85,14 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) { require.Equal(t, outletName, *result.OutletName) require.Equal(t, float64(300), result.Summary.TotalPurchases) require.Equal(t, float64(125), result.Summary.RawMaterialPurchases) - require.Equal(t, float64(175), result.Summary.NonInventoryPurchases) + require.Equal(t, float64(175), result.Summary.ExpensePurchases) require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders) require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders) - require.Equal(t, int64(2), result.Summary.NonInventoryExpenseCount) + require.Equal(t, int64(2), result.Summary.ExpenseCount) require.Len(t, result.Data, 1) require.Equal(t, float64(300), result.Data[0].Purchases) require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases) - require.Equal(t, float64(175), result.Data[0].NonInventoryPurchases) + require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { diff --git a/internal/transformer/purchase_order_transformer_test.go b/internal/transformer/purchase_order_transformer_test.go index 24aea4c..4f481a6 100644 --- a/internal/transformer/purchase_order_transformer_test.go +++ b/internal/transformer/purchase_order_transformer_test.go @@ -12,15 +12,19 @@ import ( ) func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) { + ingredientID := uuid.New() + quantity := 1.0 + unitID := uuid.New() + result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{ VendorID: uuid.New(), PONumber: "PO-001", TransactionDate: "2026-05-29", Items: []contract.CreatePurchaseOrderItemRequest{ { - IngredientID: uuid.New(), - Quantity: 1, - UnitID: uuid.New(), + IngredientID: &ingredientID, + Quantity: &quantity, + UnitID: &unitID, Amount: 1000, }, }, diff --git a/internal/validator/purchase_category_validator.go b/internal/validator/purchase_category_validator.go index 10b1f5c..e69376f 100644 --- a/internal/validator/purchase_category_validator.go +++ b/internal/validator/purchase_category_validator.go @@ -34,7 +34,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateCreatePurchaseCategoryRequest(re } if !isValidPurchaseCategoryType(req.Type) { - return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode + return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode } if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 { @@ -63,7 +63,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateUpdatePurchaseCategoryRequest(re } if req.Type != nil && !isValidPurchaseCategoryType(*req.Type) { - return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode + return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode } if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 { @@ -87,7 +87,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(re } if req.Type != "" && !isValidPurchaseCategoryType(req.Type) { - return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode + return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode } return nil, "" @@ -95,7 +95,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(re func isValidPurchaseCategoryType(categoryType string) bool { switch categoryType { - case "raw_material", "non_inventory": + case "raw_material", "expense": return true default: return false diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index b6c3f07..1b67023 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -181,20 +181,20 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont } func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) { - if item.IngredientID == uuid.Nil { - return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode - } - if item.PurchaseCategoryID == uuid.Nil { return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode } - if item.Quantity <= 0 { + if item.IngredientID != nil && *item.IngredientID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode + } + + if item.Quantity != nil && *item.Quantity <= 0 { return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } - if item.UnitID == uuid.Nil { - return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode + if item.UnitID != nil && *item.UnitID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode } if item.Amount < 0 { @@ -209,6 +209,14 @@ func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contr return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode } + if item.IngredientID != nil && *item.IngredientID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode + } + + if item.UnitID != nil && *item.UnitID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode + } + if item.Quantity != nil && *item.Quantity <= 0 { return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index 1fde2f9..4c37b90 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -11,16 +11,20 @@ import ( ) func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest { + ingredientID := uuid.New() + quantity := 1.0 + unitID := uuid.New() + return &contract.CreatePurchaseOrderRequest{ VendorID: uuid.New(), PONumber: "PO-001", TransactionDate: "2026-05-29", Items: []contract.CreatePurchaseOrderItemRequest{ { - IngredientID: uuid.New(), + IngredientID: &ingredientID, PurchaseCategoryID: uuid.New(), - Quantity: 1, - UnitID: uuid.New(), + Quantity: &quantity, + UnitID: &unitID, Amount: 1000, }, }, diff --git a/migrations/000075_create_purchase_categories.up.sql b/migrations/000075_create_purchase_categories.up.sql index 2f78bc6..0b419a3 100644 --- a/migrations/000075_create_purchase_categories.up.sql +++ b/migrations/000075_create_purchase_categories.up.sql @@ -3,7 +3,7 @@ CREATE TABLE purchase_category_presets ( parent_id UUID REFERENCES purchase_category_presets(id) ON DELETE SET NULL, code VARCHAR(100) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, - type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'non_inventory')), + type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'expense')), sort_order INTEGER NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), @@ -17,7 +17,7 @@ CREATE TABLE purchase_categories ( parent_id UUID REFERENCES purchase_categories(id) ON DELETE SET NULL, code VARCHAR(100) NOT NULL, name VARCHAR(255) NOT NULL, - type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'non_inventory')), + type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'expense')), sort_order INTEGER NOT NULL DEFAULT 0, is_system BOOLEAN NOT NULL DEFAULT false, is_active BOOLEAN NOT NULL DEFAULT true, @@ -38,8 +38,8 @@ CREATE INDEX idx_purchase_categories_is_active ON purchase_categories(is_active) INSERT INTO purchase_category_presets (code, name, type, sort_order) VALUES - ('hpp', 'HPP', 'raw_material', 1), - ('biaya_lain_lain', 'Biaya Lain-lain', 'non_inventory', 2) + ('bahan_baku', 'Bahan Baku', 'raw_material', 1), + ('biaya_lain_lain', 'Biaya Lain-lain', 'expense', 2) ON CONFLICT (code) DO NOTHING; INSERT INTO purchase_category_presets (parent_id, code, name, type, sort_order) @@ -47,18 +47,14 @@ SELECT parent.id, child.code, child.name, child.type, child.sort_order FROM purchase_category_presets parent JOIN ( VALUES - ('hpp', 'hpp_bakso_mie_ayam', 'Bakso & Mie Ayam', 'raw_material', 1), - ('hpp', 'hpp_nusantara', 'Nusantara', 'raw_material', 2), - ('hpp', 'hpp_ramen', 'Ramen', 'raw_material', 3), - ('hpp', 'hpp_minuman_kopi', 'Minuman/Kopi', 'raw_material', 4), - ('biaya_lain_lain', 'biaya_atk_perlengkapan', 'ATK & Perlengkapan', 'non_inventory', 1), - ('biaya_lain_lain', 'biaya_makan_karyawan', 'Makan Karyawan', 'non_inventory', 2), - ('biaya_lain_lain', 'biaya_bensin_parkir', 'Bensin & Parkir', 'non_inventory', 3), - ('biaya_lain_lain', 'biaya_kebersihan_keamanan', 'Kebersihan & Keamanan', 'non_inventory', 4), - ('biaya_lain_lain', 'biaya_gaji_dw', 'Gaji DW', 'non_inventory', 5), - ('biaya_lain_lain', 'biaya_gaji_staff', 'Gaji Staff', 'non_inventory', 6), - ('biaya_lain_lain', 'biaya_internet_server', 'Internet & Server', 'non_inventory', 7), - ('biaya_lain_lain', 'biaya_air_listrik', 'Air & Listrik', 'non_inventory', 8), - ('biaya_lain_lain', 'biaya_promosi', 'Promosi', 'non_inventory', 9) + ('biaya_lain_lain', 'biaya_atk_perlengkapan', 'ATK & Perlengkapan', 'expense', 1), + ('biaya_lain_lain', 'biaya_makan_karyawan', 'Makan Karyawan', 'expense', 2), + ('biaya_lain_lain', 'biaya_bensin_parkir', 'Bensin & Parkir', 'expense', 3), + ('biaya_lain_lain', 'biaya_kebersihan_keamanan', 'Kebersihan & Keamanan', 'expense', 4), + ('biaya_lain_lain', 'biaya_gaji_dw', 'Gaji DW', 'expense', 5), + ('biaya_lain_lain', 'biaya_gaji_staff', 'Gaji Staff', 'expense', 6), + ('biaya_lain_lain', 'biaya_internet_server', 'Internet & Server', 'expense', 7), + ('biaya_lain_lain', 'biaya_air_listrik', 'Air & Listrik', 'expense', 8), + ('biaya_lain_lain', 'biaya_promosi', 'Promosi', 'expense', 9) ) AS child(parent_code, code, name, type, sort_order) ON parent.code = child.parent_code ON CONFLICT (code) DO NOTHING; diff --git a/migrations/000080_rename_non_inventory_purchase_categories.down.sql b/migrations/000080_rename_non_inventory_purchase_categories.down.sql new file mode 100644 index 0000000..e90c1bd --- /dev/null +++ b/migrations/000080_rename_non_inventory_purchase_categories.down.sql @@ -0,0 +1,32 @@ +ALTER TABLE purchase_category_presets DROP CONSTRAINT IF EXISTS purchase_category_presets_type_check; +ALTER TABLE purchase_categories DROP CONSTRAINT IF EXISTS purchase_categories_type_check; + +UPDATE purchase_category_presets +SET type = 'non_inventory' +WHERE type = 'expense'; + +UPDATE purchase_categories +SET type = 'non_inventory' +WHERE type = 'expense'; + +UPDATE purchase_category_presets +SET code = 'hpp', name = 'HPP' +WHERE code = 'bahan_baku' AND type = 'raw_material'; + +UPDATE purchase_categories +SET code = 'hpp', name = 'HPP' +WHERE code = 'bahan_baku' AND type = 'raw_material'; + +UPDATE purchase_category_presets +SET is_active = true +WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material'; + +UPDATE purchase_categories +SET is_active = true +WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material'; + +ALTER TABLE purchase_category_presets +ADD CONSTRAINT purchase_category_presets_type_check CHECK (type IN ('raw_material', 'non_inventory')); + +ALTER TABLE purchase_categories +ADD CONSTRAINT purchase_categories_type_check CHECK (type IN ('raw_material', 'non_inventory')); diff --git a/migrations/000080_rename_non_inventory_purchase_categories.up.sql b/migrations/000080_rename_non_inventory_purchase_categories.up.sql new file mode 100644 index 0000000..923bd88 --- /dev/null +++ b/migrations/000080_rename_non_inventory_purchase_categories.up.sql @@ -0,0 +1,36 @@ +ALTER TABLE purchase_category_presets DROP CONSTRAINT IF EXISTS purchase_category_presets_type_check; +ALTER TABLE purchase_categories DROP CONSTRAINT IF EXISTS purchase_categories_type_check; + +UPDATE purchase_category_presets +SET type = 'expense' +WHERE type = 'non_inventory'; + +UPDATE purchase_categories +SET type = 'expense' +WHERE type = 'non_inventory'; + +UPDATE purchase_category_presets +SET code = 'bahan_baku', name = 'Bahan Baku' +WHERE code = 'hpp' AND type = 'raw_material'; + +UPDATE purchase_categories +SET code = 'bahan_baku', name = 'Bahan Baku' +WHERE code = 'hpp' AND type = 'raw_material'; + +UPDATE purchase_category_presets +SET is_active = false +WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material'; + +UPDATE purchase_categories +SET is_active = false +WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material'; + +ALTER TABLE purchase_category_presets +ADD CONSTRAINT purchase_category_presets_type_check CHECK (type IN ('raw_material', 'expense')); + +ALTER TABLE purchase_categories +ADD CONSTRAINT purchase_categories_type_check CHECK (type IN ('raw_material', 'expense')); + +ALTER TABLE purchase_order_items ALTER COLUMN ingredient_id DROP NOT NULL; +ALTER TABLE purchase_order_items ALTER COLUMN quantity DROP NOT NULL; +ALTER TABLE purchase_order_items ALTER COLUMN unit_id DROP NOT NULL; From d5216e7994d81a375b3e4b6d225d9a784e6d667b Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 11 Jun 2026 15:57:19 +0700 Subject: [PATCH 10/16] Fix purchase analytic zero for group by today --- internal/repository/analytics_repository.go | 120 ++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 5f90e0f..c03ee0f 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -144,6 +144,9 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or outletName = &outlet.Name } } + if outletID == nil { + return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, dateFrom, dateTo, groupBy) + } summaryQuery := r.db.WithContext(ctx). Table("inventory_movements im"). @@ -276,6 +279,123 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or }, nil } +func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { + var summary entities.PurchasingSummary + summaryQuery := r.db.WithContext(ctx). + Table("purchase_orders po"). + Select(` + COALESCE(SUM(poi.amount), 0) as total_purchases, + COUNT(DISTINCT po.id) as total_purchase_orders, + COALESCE(SUM(poi.quantity), 0) as total_quantity, + CASE + WHEN COUNT(DISTINCT po.id) > 0 + THEN COALESCE(SUM(poi.amount), 0) / COUNT(DISTINCT po.id) + ELSE 0 + END as average_purchase_order_value, + COUNT(DISTINCT poi.ingredient_id) as total_ingredients, + COUNT(DISTINCT po.vendor_id) as total_vendors + `). + Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) + + if err := summaryQuery.Scan(&summary).Error; err != nil { + return nil, err + } + + var dateFormat string + switch groupBy { + case "hour": + dateFormat = "DATE_TRUNC('hour', po.created_at)" + case "week": + dateFormat = "DATE_TRUNC('week', po.transaction_date::timestamp)" + case "month": + dateFormat = "DATE_TRUNC('month', po.transaction_date::timestamp)" + default: + dateFormat = "DATE_TRUNC('day', po.transaction_date::timestamp)" + } + + var data []entities.PurchasingAnalyticsData + dataQuery := r.db.WithContext(ctx). + Table("purchase_orders po"). + Select(` + `+dateFormat+` as date, + COALESCE(SUM(poi.amount), 0) as purchases, + COUNT(DISTINCT po.id) as purchase_orders, + COALESCE(SUM(poi.quantity), 0) as quantity, + COUNT(DISTINCT poi.ingredient_id) as ingredients, + COUNT(DISTINCT po.vendor_id) as vendors + `). + Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Group(dateFormat). + Order(dateFormat) + + if err := dataQuery.Scan(&data).Error; err != nil { + return nil, err + } + + var ingredientData []entities.PurchasingIngredientData + ingredientQuery := r.db.WithContext(ctx). + Table("purchase_order_items poi"). + Select(` + i.id as ingredient_id, + i.name as ingredient_name, + COALESCE(SUM(poi.quantity), 0) as quantity, + COALESCE(SUM(poi.amount), 0) as total_cost, + CASE + WHEN SUM(poi.quantity) > 0 + THEN COALESCE(SUM(poi.amount), 0) / SUM(poi.quantity) + ELSE 0 + END as average_unit_cost, + COUNT(DISTINCT po.id) as purchase_order_count + `). + Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). + Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Group("i.id, i.name"). + Order("total_cost DESC") + + if err := ingredientQuery.Scan(&ingredientData).Error; err != nil { + return nil, err + } + + var vendorData []entities.PurchasingVendorData + vendorQuery := r.db.WithContext(ctx). + Table("purchase_orders po"). + Select(` + v.id as vendor_id, + v.name as vendor_name, + COALESCE(SUM(poi.amount), 0) as total_cost, + COUNT(DISTINCT po.id) as purchase_order_count, + COUNT(DISTINCT poi.ingredient_id) as ingredient_count, + COALESCE(SUM(poi.quantity), 0) as quantity + `). + Joins("JOIN vendors v ON po.vendor_id = v.id"). + Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Group("v.id, v.name"). + Order("total_cost DESC") + + if err := vendorQuery.Scan(&vendorData).Error; err != nil { + return nil, err + } + + return &entities.PurchasingAnalytics{ + Summary: summary, + Data: data, + IngredientData: ingredientData, + VendorData: vendorData, + }, nil +} + func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { var results []*entities.ProductAnalytics From f4172fcea71003b577365e2c6c02ea8ec42bb72b Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 11 Jun 2026 16:27:50 +0700 Subject: [PATCH 11/16] Fix purchase analytic by outlet --- internal/repository/analytics_repository.go | 162 +++----------------- 1 file changed, 24 insertions(+), 138 deletions(-) diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index c03ee0f..9667788 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -124,7 +124,6 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz } func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { - var summary entities.PurchasingSummary var outletName *string if outletID != nil { @@ -144,142 +143,10 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or outletName = &outlet.Name } } - if outletID == nil { - return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, dateFrom, dateTo, groupBy) - } - - summaryQuery := r.db.WithContext(ctx). - Table("inventory_movements im"). - Select(` - COALESCE(SUM(im.total_cost), 0) as total_purchases, - COUNT(DISTINCT im.reference_id) as total_purchase_orders, - COALESCE(SUM(im.quantity), 0) as total_quantity, - CASE - WHEN COUNT(DISTINCT im.reference_id) > 0 - THEN COALESCE(SUM(im.total_cost), 0) / COUNT(DISTINCT im.reference_id) - ELSE 0 - END as average_purchase_order_value, - COUNT(DISTINCT im.item_id) as total_ingredients, - COUNT(DISTINCT po.vendor_id) as total_vendors - `). - Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id"). - Where("im.organization_id = ?", organizationID). - Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). - Where("im.item_type = ?", "INGREDIENT"). - Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). - Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo) - - summaryQuery = r.resolveOutletID(summaryQuery, outletID, "im.outlet_id") - - if err := summaryQuery.Scan(&summary).Error; err != nil { - return nil, err - } - - var dateFormat string - switch groupBy { - case "hour": - dateFormat = "DATE_TRUNC('hour', im.created_at)" - case "week": - dateFormat = "DATE_TRUNC('week', im.created_at)" - case "month": - dateFormat = "DATE_TRUNC('month', im.created_at)" - default: - dateFormat = "DATE_TRUNC('day', im.created_at)" - } - - var data []entities.PurchasingAnalyticsData - dataQuery := r.db.WithContext(ctx). - Table("inventory_movements im"). - Select(` - `+dateFormat+` as date, - COALESCE(SUM(im.total_cost), 0) as purchases, - COUNT(DISTINCT im.reference_id) as purchase_orders, - COALESCE(SUM(im.quantity), 0) as quantity, - COUNT(DISTINCT im.item_id) as ingredients, - COUNT(DISTINCT po.vendor_id) as vendors - `). - Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id"). - Where("im.organization_id = ?", organizationID). - Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). - Where("im.item_type = ?", "INGREDIENT"). - Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). - Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). - Group(dateFormat). - Order(dateFormat) - - dataQuery = r.resolveOutletID(dataQuery, outletID, "im.outlet_id") - - if err := dataQuery.Scan(&data).Error; err != nil { - return nil, err - } - - var ingredientData []entities.PurchasingIngredientData - ingredientQuery := r.db.WithContext(ctx). - Table("inventory_movements im"). - Select(` - i.id as ingredient_id, - i.name as ingredient_name, - COALESCE(SUM(im.quantity), 0) as quantity, - COALESCE(SUM(im.total_cost), 0) as total_cost, - CASE - WHEN SUM(im.quantity) > 0 - THEN COALESCE(SUM(im.total_cost), 0) / SUM(im.quantity) - ELSE 0 - END as average_unit_cost, - COUNT(DISTINCT im.reference_id) as purchase_order_count - `). - Joins("JOIN ingredients i ON im.item_id = i.id"). - Where("im.organization_id = ?", organizationID). - Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). - Where("im.item_type = ?", "INGREDIENT"). - Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). - Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). - Group("i.id, i.name"). - Order("total_cost DESC") - - ingredientQuery = r.resolveOutletID(ingredientQuery, outletID, "im.outlet_id") - - if err := ingredientQuery.Scan(&ingredientData).Error; err != nil { - return nil, err - } - - var vendorData []entities.PurchasingVendorData - vendorQuery := r.db.WithContext(ctx). - Table("inventory_movements im"). - Select(` - v.id as vendor_id, - v.name as vendor_name, - COALESCE(SUM(im.total_cost), 0) as total_cost, - COUNT(DISTINCT im.reference_id) as purchase_order_count, - COUNT(DISTINCT im.item_id) as ingredient_count, - COALESCE(SUM(im.quantity), 0) as quantity - `). - Joins("JOIN purchase_orders po ON im.reference_id = po.id"). - Joins("JOIN vendors v ON po.vendor_id = v.id"). - Where("im.organization_id = ?", organizationID). - Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). - Where("im.item_type = ?", "INGREDIENT"). - Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). - Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). - Group("v.id, v.name"). - Order("total_cost DESC") - - vendorQuery = r.resolveOutletID(vendorQuery, outletID, "im.outlet_id") - - if err := vendorQuery.Scan(&vendorData).Error; err != nil { - return nil, err - } - - return &entities.PurchasingAnalytics{ - OutletName: outletName, - Summary: summary, - Data: data, - IngredientData: ingredientData, - VendorData: vendorData, - }, nil + return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, outletName, dateFrom, dateTo, groupBy) } -func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { +func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { var summary entities.PurchasingSummary summaryQuery := r.db.WithContext(ctx). Table("purchase_orders po"). @@ -292,13 +159,16 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex THEN COALESCE(SUM(poi.amount), 0) / COUNT(DISTINCT po.id) ELSE 0 END as average_purchase_order_value, - COUNT(DISTINCT poi.ingredient_id) as total_ingredients, + COUNT(DISTINCT i.id) as total_ingredients, COUNT(DISTINCT po.vendor_id) as total_vendors `). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT 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("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) + summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID) if err := summaryQuery.Scan(&summary).Error; err != nil { return nil, err @@ -324,15 +194,18 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex COALESCE(SUM(poi.amount), 0) as purchases, COUNT(DISTINCT po.id) as purchase_orders, COALESCE(SUM(poi.quantity), 0) as quantity, - COUNT(DISTINCT poi.ingredient_id) as ingredients, + COUNT(DISTINCT i.id) as ingredients, COUNT(DISTINCT po.vendor_id) as vendors `). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT 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("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group(dateFormat). Order(dateFormat) + dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID) if err := dataQuery.Scan(&data).Error; err != nil { return nil, err @@ -355,11 +228,13 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex `). Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.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("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("i.id, i.name"). Order("total_cost DESC") + ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID) if err := ingredientQuery.Scan(&ingredientData).Error; err != nil { return nil, err @@ -373,22 +248,26 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex v.name as vendor_name, COALESCE(SUM(poi.amount), 0) as total_cost, COUNT(DISTINCT po.id) as purchase_order_count, - COUNT(DISTINCT poi.ingredient_id) as ingredient_count, + COUNT(DISTINCT i.id) as ingredient_count, COALESCE(SUM(poi.quantity), 0) as quantity `). Joins("JOIN vendors v ON po.vendor_id = v.id"). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT 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("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("v.id, v.name"). Order("total_cost DESC") + vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID) if err := vendorQuery.Scan(&vendorData).Error; err != nil { return nil, err } return &entities.PurchasingAnalytics{ + OutletName: outletName, Summary: summary, Data: data, IngredientData: ingredientData, @@ -396,6 +275,13 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex }, nil } +func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB { + if outletID == nil { + return query + } + return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID) +} + func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { var results []*entities.ProductAnalytics From 7a7ac25dcf9401fee47e001ddc3dd29a1ff97afd Mon Sep 17 00:00:00 2001 From: Efril Date: Thu, 11 Jun 2026 16:47:03 +0700 Subject: [PATCH 12/16] fix conflict --- internal/repository/analytics_repository.go | 135 ++++++++++---------- 1 file changed, 70 insertions(+), 65 deletions(-) diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 62a9a90..9667788 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -124,7 +124,6 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz } func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { - var summary entities.PurchasingSummary var outletName *string if outletID != nil { @@ -144,29 +143,32 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or outletName = &outlet.Name } } + return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, outletName, dateFrom, dateTo, groupBy) +} +func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { + var summary entities.PurchasingSummary summaryQuery := r.db.WithContext(ctx). - Table("inventory_movements im"). + Table("purchase_orders po"). Select(` - COALESCE(SUM(im.total_cost), 0) as total_purchases, - COUNT(DISTINCT im.reference_id) as total_purchase_orders, - COALESCE(SUM(im.quantity), 0) as total_quantity, + COALESCE(SUM(poi.amount), 0) as total_purchases, + COUNT(DISTINCT po.id) as total_purchase_orders, + COALESCE(SUM(poi.quantity), 0) as total_quantity, CASE - WHEN COUNT(DISTINCT im.reference_id) > 0 - THEN COALESCE(SUM(im.total_cost), 0) / COUNT(DISTINCT im.reference_id) + WHEN COUNT(DISTINCT po.id) > 0 + THEN COALESCE(SUM(poi.amount), 0) / COUNT(DISTINCT po.id) ELSE 0 END as average_purchase_order_value, - COUNT(DISTINCT im.item_id) as total_ingredients, + COUNT(DISTINCT i.id) as total_ingredients, COUNT(DISTINCT po.vendor_id) as total_vendors `). - Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id"). - Where("im.organization_id = ?", organizationID). - Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). - Where("im.item_type = ?", "INGREDIENT"). - Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). - Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo) - - summaryQuery = r.resolveOutletID(summaryQuery, outletID, "im.outlet_id") + Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT 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("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) + summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID) if err := summaryQuery.Scan(&summary).Error; err != nil { return nil, err @@ -175,36 +177,35 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or var dateFormat string switch groupBy { case "hour": - dateFormat = "DATE_TRUNC('hour', im.created_at)" + dateFormat = "DATE_TRUNC('hour', po.created_at)" case "week": - dateFormat = "DATE_TRUNC('week', im.created_at)" + dateFormat = "DATE_TRUNC('week', po.transaction_date::timestamp)" case "month": - dateFormat = "DATE_TRUNC('month', im.created_at)" + dateFormat = "DATE_TRUNC('month', po.transaction_date::timestamp)" default: - dateFormat = "DATE_TRUNC('day', im.created_at)" + dateFormat = "DATE_TRUNC('day', po.transaction_date::timestamp)" } var data []entities.PurchasingAnalyticsData dataQuery := r.db.WithContext(ctx). - Table("inventory_movements im"). + Table("purchase_orders po"). Select(` `+dateFormat+` as date, - COALESCE(SUM(im.total_cost), 0) as purchases, - COUNT(DISTINCT im.reference_id) as purchase_orders, - COALESCE(SUM(im.quantity), 0) as quantity, - COUNT(DISTINCT im.item_id) as ingredients, + COALESCE(SUM(poi.amount), 0) as purchases, + COUNT(DISTINCT po.id) as purchase_orders, + COALESCE(SUM(poi.quantity), 0) as quantity, + COUNT(DISTINCT i.id) as ingredients, COUNT(DISTINCT po.vendor_id) as vendors `). - Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id"). - Where("im.organization_id = ?", organizationID). - Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). - Where("im.item_type = ?", "INGREDIENT"). - Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). - Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). + Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT 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("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group(dateFormat). Order(dateFormat) - - dataQuery = r.resolveOutletID(dataQuery, outletID, "im.outlet_id") + dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID) if err := dataQuery.Scan(&data).Error; err != nil { return nil, err @@ -212,29 +213,28 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or var ingredientData []entities.PurchasingIngredientData ingredientQuery := r.db.WithContext(ctx). - Table("inventory_movements im"). + Table("purchase_order_items poi"). Select(` i.id as ingredient_id, i.name as ingredient_name, - COALESCE(SUM(im.quantity), 0) as quantity, - COALESCE(SUM(im.total_cost), 0) as total_cost, + COALESCE(SUM(poi.quantity), 0) as quantity, + COALESCE(SUM(poi.amount), 0) as total_cost, CASE - WHEN SUM(im.quantity) > 0 - THEN COALESCE(SUM(im.total_cost), 0) / SUM(im.quantity) + WHEN SUM(poi.quantity) > 0 + THEN COALESCE(SUM(poi.amount), 0) / SUM(poi.quantity) ELSE 0 END as average_unit_cost, - COUNT(DISTINCT im.reference_id) as purchase_order_count + COUNT(DISTINCT po.id) as purchase_order_count `). - Joins("JOIN ingredients i ON im.item_id = i.id"). - Where("im.organization_id = ?", organizationID). - Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). - Where("im.item_type = ?", "INGREDIENT"). - Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). - Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). + Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.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("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("i.id, i.name"). Order("total_cost DESC") - - ingredientQuery = r.resolveOutletID(ingredientQuery, outletID, "im.outlet_id") + ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID) if err := ingredientQuery.Scan(&ingredientData).Error; err != nil { return nil, err @@ -242,26 +242,25 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or var vendorData []entities.PurchasingVendorData vendorQuery := r.db.WithContext(ctx). - Table("inventory_movements im"). + Table("purchase_orders po"). Select(` v.id as vendor_id, v.name as vendor_name, - COALESCE(SUM(im.total_cost), 0) as total_cost, - COUNT(DISTINCT im.reference_id) as purchase_order_count, - COUNT(DISTINCT im.item_id) as ingredient_count, - COALESCE(SUM(im.quantity), 0) as quantity + COALESCE(SUM(poi.amount), 0) as total_cost, + COUNT(DISTINCT po.id) as purchase_order_count, + COUNT(DISTINCT i.id) as ingredient_count, + COALESCE(SUM(poi.quantity), 0) as quantity `). - Joins("JOIN purchase_orders po ON im.reference_id = po.id"). Joins("JOIN vendors v ON po.vendor_id = v.id"). - Where("im.organization_id = ?", organizationID). - Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). - Where("im.item_type = ?", "INGREDIENT"). - Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). - Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). + Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT 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("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("v.id, v.name"). Order("total_cost DESC") - - vendorQuery = r.resolveOutletID(vendorQuery, outletID, "im.outlet_id") + vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID) if err := vendorQuery.Scan(&vendorData).Error; err != nil { return nil, err @@ -276,6 +275,13 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or }, nil } +func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB { + if outletID == nil { + return query + } + return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID) +} + func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { var results []*entities.ProductAnalytics @@ -284,7 +290,6 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ Select(` p.id as product_id, p.name as product_name, - p.price as product_price, c.id as category_id, c.name as category_name, c.order as category_order, @@ -343,7 +348,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ query = r.resolveOutletID(query, outletID, "o.outlet_id") err := query. - Group("p.id, p.name, p.price, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit"). + Group("p.id, p.name, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit"). Order("revenue DESC"). Limit(limit). Scan(&results).Error @@ -638,7 +643,7 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga query := r.db.WithContext(ctx). Table("expense_items ei"). - Select(`COALESCE(parent_coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`). + Select(`COALESCE(parent_coa.name, coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`). Joins("JOIN expenses e ON ei.expense_id = e.id"). Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id"). Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id"). @@ -651,8 +656,8 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga } err := query. - Group("parent_coa.name"). - Order("parent_coa.name"). + Group("COALESCE(parent_coa.name, coa.name, 'Lain-lain')"). + Order("COALESCE(parent_coa.name, coa.name, 'Lain-lain')"). Scan(&results).Error return results, err From 657a201fc0367a9e8b38de99fe90c5e6ba2d6059 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 15 Jun 2026 13:52:08 +0700 Subject: [PATCH 13/16] Revert purchase order --- internal/contract/purchase_order_contract.go | 18 +-- internal/entities/purchase_order.go | 20 +-- internal/models/purchase_order.go | 38 +++--- .../processor/purchase_order_processor.go | 128 ++++++------------ internal/repository/analytics_repository.go | 34 +++-- .../purchase_order_transformer_test.go | 10 +- .../validator/purchase_order_validator.go | 24 ++-- .../purchase_order_validator_test.go | 22 ++- ...raw_material_purchase_order_items.down.sql | 7 + ...e_raw_material_purchase_order_items.up.sql | 41 ++++++ 10 files changed, 185 insertions(+), 157 deletions(-) create mode 100644 migrations/000081_enforce_raw_material_purchase_order_items.down.sql create mode 100644 migrations/000081_enforce_raw_material_purchase_order_items.up.sql diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 907316a..70e722c 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -19,12 +19,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"` - Description *string `json:"description,omitempty" validate:"omitempty"` - Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"` - UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"` - Amount float64 `json:"amount" validate:"required,gte=0"` + IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"` + Description *string `json:"description,omitempty" validate:"omitempty"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` + UnitID uuid.UUID `json:"unit_id" validate:"required"` + Amount float64 `json:"amount" validate:"required,gte=0"` } type UpdatePurchaseOrderRequest struct { @@ -70,11 +70,11 @@ type PurchaseOrderResponse struct { type PurchaseOrderItemResponse struct { ID uuid.UUID `json:"id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID *uuid.UUID `json:"ingredient_id"` + IngredientID uuid.UUID `json:"ingredient_id"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` Description *string `json:"description"` - Quantity *float64 `json:"quantity"` - UnitID *uuid.UUID `json:"unit_id"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index b98006a..3a455db 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -41,16 +41,16 @@ func (PurchaseOrder) TableName() string { } type PurchaseOrderItem struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` - IngredientID *uuid.UUID `gorm:"type:uuid" json:"ingredient_id" validate:"omitempty"` - PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"` - Description *string `gorm:"type:text" json:"description" validate:"omitempty"` - Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"` - UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" validate:"omitempty"` - Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` + IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"` + PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"` + Description *string `gorm:"type:text" json:"description" validate:"omitempty"` + Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"` + UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"` + Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"` Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 562271e..1afa23f 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -22,16 +22,16 @@ type PurchaseOrder struct { } type PurchaseOrderItem struct { - ID uuid.UUID `json:"id"` - PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID *uuid.UUID `json:"ingredient_id"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` - Description *string `json:"description"` - Quantity *float64 `json:"quantity"` - UnitID *uuid.UUID `json:"unit_id"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + PurchaseOrderID uuid.UUID `json:"purchase_order_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PurchaseOrderAttachment struct { @@ -62,11 +62,11 @@ type PurchaseOrderResponse struct { type PurchaseOrderItemResponse struct { ID uuid.UUID `json:"id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID *uuid.UUID `json:"ingredient_id"` + IngredientID uuid.UUID `json:"ingredient_id"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` Description *string `json:"description"` - Quantity *float64 `json:"quantity"` - UnitID *uuid.UUID `json:"unit_id"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -96,12 +96,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` - Description *string `json:"description,omitempty"` - Quantity *float64 `json:"quantity,omitempty"` - UnitID *uuid.UUID `json:"unit_id,omitempty"` - Amount float64 `json:"amount"` + IngredientID uuid.UUID `json:"ingredient_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description,omitempty"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` + Amount float64 `json:"amount"` } type UpdatePurchaseOrderRequest struct { diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index 7d87e90..ca0819e 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -67,40 +67,20 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber) } - // Validate categories and inventory fields per item type. + // Purchase orders are raw-material only because they affect ingredient stock. for i, item := range req.Items { - category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i) - if err != nil { + if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil { return nil, err } - switch category.Type { - case entities.PurchaseCategoryTypeRawMaterial: - if item.IngredientID == nil { - return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i) - } - if item.Quantity == nil { - return nil, fmt.Errorf("quantity is required for raw_material item %d", i) - } - if item.UnitID == nil { - return nil, fmt.Errorf("unit_id is required for raw_material item %d", i) - } + _, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) + if err != nil { + return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) + } - _, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID) - if err != nil { - return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) - } - - _, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID) - if err != nil { - return nil, fmt.Errorf("unit not found for item %d: %w", i, err) - } - case entities.PurchaseCategoryTypeExpense: - if item.IngredientID != nil || item.Quantity != nil || item.UnitID != nil { - return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i) - } - default: - return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type) + _, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID) + if err != nil { + return nil, fmt.Errorf("unit not found for item %d: %w", i, err) } } @@ -224,48 +204,38 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id return nil, fmt.Errorf("purchase_category_id is required for item %d", i) } - ingredientID := itemReq.IngredientID + if itemReq.IngredientID == nil { + return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i) + } + if itemReq.Quantity == nil { + return nil, fmt.Errorf("quantity is required for raw_material item %d", i) + } + if itemReq.UnitID == nil { + return nil, fmt.Errorf("unit_id is required for raw_material item %d", i) + } + + ingredientID := *itemReq.IngredientID purchaseCategoryID := *itemReq.PurchaseCategoryID - unitID := itemReq.UnitID - quantity := itemReq.Quantity + unitID := *itemReq.UnitID + quantity := *itemReq.Quantity amount := 0.0 if itemReq.Amount != nil { amount = *itemReq.Amount } description := itemReq.Description - category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i) - if err != nil { + if err := p.validateRawMaterialPurchaseCategory(ctx, purchaseCategoryID, organizationID, i); err != nil { return nil, err } - switch category.Type { - case entities.PurchaseCategoryTypeRawMaterial: - if ingredientID == nil { - return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i) - } - if quantity == nil { - return nil, fmt.Errorf("quantity is required for raw_material item %d", i) - } - if unitID == nil { - return nil, fmt.Errorf("unit_id is required for raw_material item %d", i) - } + _, err := p.ingredientRepo.GetByID(ctx, ingredientID, organizationID) + if err != nil { + return nil, fmt.Errorf("ingredient not found: %w", err) + } - _, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID) - if err != nil { - return nil, fmt.Errorf("ingredient not found: %w", err) - } - - _, err = p.unitRepo.GetByID(ctx, *unitID, organizationID) - if err != nil { - return nil, fmt.Errorf("unit not found: %w", err) - } - case entities.PurchaseCategoryTypeExpense: - if ingredientID != nil || quantity != nil || unitID != nil { - return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i) - } - default: - return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type) + _, err = p.unitRepo.GetByID(ctx, unitID, organizationID) + if err != nil { + return nil, fmt.Errorf("unit not found: %w", err) } items[i] = &entities.PurchaseOrderItem{ @@ -407,8 +377,6 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return nil, fmt.Errorf("purchase order not found: %w", err) } - fmt.Println("status:", po.Status) - // Check if status is changing to "received" and current status is not "received" if status == "received" && po.Status != "received" { // Get purchase order with items for inventory update @@ -419,27 +387,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte // Update inventory for each item for _, item := range poWithItems.Items { - if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense { - continue - } - - if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil { - return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID) - } - // Get ingredient to find its base unit - ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID) + ingredient, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) if err != nil { - return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err) + return nil, fmt.Errorf("failed to get ingredient %s: %w", item.IngredientID, err) } // Convert quantity to ingredient's base unit if needed - quantityToAdd := *item.Quantity - if *item.UnitID != ingredient.UnitID { + quantityToAdd := item.Quantity + if item.UnitID != ingredient.UnitID { // Convert from purchase unit to ingredient's base unit - convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity) + convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, item.IngredientID, item.UnitID, ingredient.UnitID, organizationID, item.Quantity) if err != nil { - return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err) + return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", item.IngredientID, item.UnitID, ingredient.UnitID, err) } quantityToAdd = convertedQuantity } @@ -457,7 +417,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte err = p.inventoryMovementService.CreateIngredientMovement( ctx, - *item.IngredientID, + item.IngredientID, organizationID, outletID, userID, @@ -470,7 +430,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte &item.ID, ) if err != nil { - return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err) + return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err) } } } @@ -490,19 +450,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return mappers.PurchaseOrderEntityToResponse(updatedPO), nil } -func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) { +func (p *PurchaseOrderProcessorImpl) validateRawMaterialPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) error { category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) if err != nil { - return nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err) + return fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err) } if !category.IsActive { - return nil, fmt.Errorf("purchase category for item %d is inactive", itemIndex) + return fmt.Errorf("purchase category for item %d is inactive", itemIndex) } - if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense { - return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex) + if category.Type != entities.PurchaseCategoryTypeRawMaterial { + return fmt.Errorf("purchase category for item %d must be raw_material", itemIndex) } - return category, nil + return nil } diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 9667788..0ace0cb 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -152,7 +152,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Table("purchase_orders po"). Select(` COALESCE(SUM(poi.amount), 0) as total_purchases, + COALESCE(SUM(poi.amount), 0) as raw_material_purchases, + 0 as expense_purchases, COUNT(DISTINCT po.id) as total_purchase_orders, + COUNT(DISTINCT po.id) as raw_material_purchase_orders, + 0 as expense_count, COALESCE(SUM(poi.quantity), 0) as total_quantity, CASE WHEN COUNT(DISTINCT po.id) > 0 @@ -162,10 +166,12 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex COUNT(DISTINCT i.id) as total_ingredients, COUNT(DISTINCT po.vendor_id) as total_vendors `). - Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). - Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id"). - Joins("LEFT JOIN units u ON poi.unit_id = u.id"). + Joins("JOIN purchase_order_items poi 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("JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID) @@ -192,15 +198,21 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Select(` `+dateFormat+` as date, COALESCE(SUM(poi.amount), 0) as purchases, + COALESCE(SUM(poi.amount), 0) as raw_material_purchases, + 0 as expense_purchases, COUNT(DISTINCT po.id) as purchase_orders, + COUNT(DISTINCT po.id) as raw_material_purchase_orders, + 0 as expense_count, COALESCE(SUM(poi.quantity), 0) as quantity, COUNT(DISTINCT i.id) as ingredients, COUNT(DISTINCT po.vendor_id) as vendors `). - Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). - Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id"). - Joins("LEFT JOIN units u ON poi.unit_id = u.id"). + Joins("JOIN purchase_order_items poi 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("JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group(dateFormat). @@ -227,9 +239,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex COUNT(DISTINCT po.id) as purchase_order_count `). 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 != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("i.id, i.name"). @@ -252,10 +266,12 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex COALESCE(SUM(poi.quantity), 0) as quantity `). Joins("JOIN vendors v ON po.vendor_id = v.id"). - Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). - Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id"). - Joins("LEFT JOIN units u ON poi.unit_id = u.id"). + Joins("JOIN purchase_order_items poi 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("JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("v.id, v.name"). diff --git a/internal/transformer/purchase_order_transformer_test.go b/internal/transformer/purchase_order_transformer_test.go index 4f481a6..24aea4c 100644 --- a/internal/transformer/purchase_order_transformer_test.go +++ b/internal/transformer/purchase_order_transformer_test.go @@ -12,19 +12,15 @@ import ( ) func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) { - ingredientID := uuid.New() - quantity := 1.0 - unitID := uuid.New() - result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{ VendorID: uuid.New(), PONumber: "PO-001", TransactionDate: "2026-05-29", Items: []contract.CreatePurchaseOrderItemRequest{ { - IngredientID: &ingredientID, - Quantity: &quantity, - UnitID: &unitID, + IngredientID: uuid.New(), + Quantity: 1, + UnitID: uuid.New(), Amount: 1000, }, }, diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index 1b67023..a62a7c7 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -181,20 +181,20 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont } func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) { + if item.IngredientID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode + } + if item.PurchaseCategoryID == uuid.Nil { return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode } - if item.IngredientID != nil && *item.IngredientID == uuid.Nil { - return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode - } - - if item.Quantity != nil && *item.Quantity <= 0 { + if item.Quantity <= 0 { return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } - if item.UnitID != nil && *item.UnitID == uuid.Nil { - return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode + if item.UnitID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode } if item.Amount < 0 { @@ -209,15 +209,15 @@ func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contr return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode } - if item.IngredientID != nil && *item.IngredientID == uuid.Nil { - return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode + if item.IngredientID == nil || *item.IngredientID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode } - if item.UnitID != nil && *item.UnitID == uuid.Nil { - return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode + if item.UnitID == nil || *item.UnitID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode } - if item.Quantity != nil && *item.Quantity <= 0 { + if item.Quantity == nil || *item.Quantity <= 0 { return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index 4c37b90..c07f9a7 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -11,26 +11,34 @@ import ( ) func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest { - ingredientID := uuid.New() - quantity := 1.0 - unitID := uuid.New() - return &contract.CreatePurchaseOrderRequest{ VendorID: uuid.New(), PONumber: "PO-001", TransactionDate: "2026-05-29", Items: []contract.CreatePurchaseOrderItemRequest{ { - IngredientID: &ingredientID, + IngredientID: uuid.New(), PurchaseCategoryID: uuid.New(), - Quantity: &quantity, - UnitID: &unitID, + Quantity: 1, + UnitID: uuid.New(), Amount: 1000, }, }, } } +func TestPurchaseOrderValidatorCreateRejectsMissingRawMaterialFields(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + req.Items[0].IngredientID = uuid.Nil + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MissingFieldErrorCode, code) + require.Contains(t, err.Error(), "ingredient_id is required") +} + func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) { validator := NewPurchaseOrderValidator() diff --git a/migrations/000081_enforce_raw_material_purchase_order_items.down.sql b/migrations/000081_enforce_raw_material_purchase_order_items.down.sql new file mode 100644 index 0000000..e413239 --- /dev/null +++ b/migrations/000081_enforce_raw_material_purchase_order_items.down.sql @@ -0,0 +1,7 @@ +DROP TRIGGER IF EXISTS trigger_validate_purchase_order_item_raw_material ON purchase_order_items; +DROP FUNCTION IF EXISTS validate_purchase_order_item_raw_material(); + +ALTER TABLE purchase_order_items + ALTER COLUMN ingredient_id DROP NOT NULL, + ALTER COLUMN quantity DROP NOT NULL, + ALTER COLUMN unit_id DROP NOT NULL; diff --git a/migrations/000081_enforce_raw_material_purchase_order_items.up.sql b/migrations/000081_enforce_raw_material_purchase_order_items.up.sql new file mode 100644 index 0000000..b3fcae7 --- /dev/null +++ b/migrations/000081_enforce_raw_material_purchase_order_items.up.sql @@ -0,0 +1,41 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM purchase_order_items poi + JOIN purchase_categories pc ON pc.id = poi.purchase_category_id + WHERE pc.type <> 'raw_material' + OR poi.ingredient_id IS NULL + OR poi.quantity IS NULL + OR poi.unit_id IS NULL + ) THEN + RAISE EXCEPTION 'purchase_order_items contains non-raw-material or incomplete raw-material rows. Move expense rows to expenses and fill ingredient_id, quantity, and unit_id before running this migration.'; + END IF; +END $$; + +ALTER TABLE purchase_order_items + ALTER COLUMN ingredient_id SET NOT NULL, + ALTER COLUMN quantity SET NOT NULL, + ALTER COLUMN unit_id SET NOT NULL; + +CREATE OR REPLACE FUNCTION validate_purchase_order_item_raw_material() +RETURNS TRIGGER AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM purchase_categories pc + WHERE pc.id = NEW.purchase_category_id + AND pc.type = 'raw_material' + ) THEN + RAISE EXCEPTION 'purchase_order_items.purchase_category_id must reference a raw_material purchase category'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_validate_purchase_order_item_raw_material ON purchase_order_items; +CREATE TRIGGER trigger_validate_purchase_order_item_raw_material + BEFORE INSERT OR UPDATE OF purchase_category_id ON purchase_order_items + FOR EACH ROW + EXECUTE FUNCTION validate_purchase_order_item_raw_material(); From 8c4d9c69d057adc0e1319604ed589a3f4ce505ab Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 15 Jun 2026 14:17:48 +0700 Subject: [PATCH 14/16] Update analytic to support new categories change --- internal/repository/analytics_repository.go | 17 +++++---- ..._expense_items_expense_categories.down.sql | 5 +++ ...ce_expense_items_expense_categories.up.sql | 38 +++++++++++++++++++ 3 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 migrations/000082_enforce_expense_items_expense_categories.down.sql create mode 100644 migrations/000082_enforce_expense_items_expense_categories.up.sql diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 0ace0cb..9877230 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -659,11 +659,11 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga query := r.db.WithContext(ctx). Table("expense_items ei"). - Select(`COALESCE(parent_coa.name, coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`). + Select(`pc.name as category_name, COALESCE(SUM(ei.amount), 0) as amount`). Joins("JOIN expenses e ON ei.expense_id = e.id"). - Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id"). - Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.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) @@ -672,8 +672,8 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga } err := query. - Group("COALESCE(parent_coa.name, coa.name, 'Lain-lain')"). - Order("COALESCE(parent_coa.name, coa.name, 'Lain-lain')"). + Group("pc.id, pc.name, pc.sort_order"). + Order("pc.sort_order ASC, pc.name ASC"). Scan(&results).Error return results, err @@ -684,10 +684,11 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context query := r.db.WithContext(ctx). Table("expense_items ei"). - Select(`COALESCE(NULLIF(ei.item, ''), ei.description, coa.name) as item, COALESCE(SUM(ei.amount), 0) as amount`). + Select(`COALESCE(NULLIF(ei.item, ''), ei.description, pc.name) as item, COALESCE(SUM(ei.amount), 0) as amount`). Joins("JOIN expenses e ON ei.expense_id = e.id"). - Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.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) @@ -696,7 +697,7 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context } err := query. - Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)"). + Group("COALESCE(NULLIF(ei.item, ''), ei.description, pc.name)"). Order("amount DESC"). Scan(&results).Error diff --git a/migrations/000082_enforce_expense_items_expense_categories.down.sql b/migrations/000082_enforce_expense_items_expense_categories.down.sql new file mode 100644 index 0000000..2d79281 --- /dev/null +++ b/migrations/000082_enforce_expense_items_expense_categories.down.sql @@ -0,0 +1,5 @@ +DROP TRIGGER IF EXISTS trigger_validate_expense_item_expense_category ON expense_items; +DROP FUNCTION IF EXISTS validate_expense_item_expense_category(); + +ALTER TABLE expense_items + ALTER COLUMN purchase_category_id DROP NOT NULL; diff --git a/migrations/000082_enforce_expense_items_expense_categories.up.sql b/migrations/000082_enforce_expense_items_expense_categories.up.sql new file mode 100644 index 0000000..73f59ae --- /dev/null +++ b/migrations/000082_enforce_expense_items_expense_categories.up.sql @@ -0,0 +1,38 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM expense_items ei + LEFT JOIN purchase_categories pc ON pc.id = ei.purchase_category_id + WHERE ei.purchase_category_id IS NULL + OR pc.id IS NULL + OR pc.type <> 'expense' + ) THEN + RAISE EXCEPTION 'expense_items contains missing or non-expense purchase categories. Assign valid expense categories before running this migration.'; + END IF; +END $$; + +ALTER TABLE expense_items + ALTER COLUMN purchase_category_id SET NOT NULL; + +CREATE OR REPLACE FUNCTION validate_expense_item_expense_category() +RETURNS TRIGGER AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM purchase_categories pc + WHERE pc.id = NEW.purchase_category_id + AND pc.type = 'expense' + ) THEN + RAISE EXCEPTION 'expense_items.purchase_category_id must reference an expense purchase category'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_validate_expense_item_expense_category ON expense_items; +CREATE TRIGGER trigger_validate_expense_item_expense_category + BEFORE INSERT OR UPDATE OF purchase_category_id ON expense_items + FOR EACH ROW + EXECUTE FUNCTION validate_expense_item_expense_category(); From b2db56f85562589334d1d78b7441a8f011a0cf2f Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 15 Jun 2026 17:01:47 +0700 Subject: [PATCH 15/16] Add excusive-summary endpoint --- internal/contract/analytics_contract.go | 111 ++++++++ internal/entities/analytics.go | 30 ++ internal/handler/analytics_handler.go | 56 ++++ internal/models/analytics.go | 111 ++++++++ internal/processor/analytics_processor.go | 266 ++++++++++++++++++ .../processor/analytics_processor_test.go | 80 ++++++ internal/repository/analytics_repository.go | 208 ++++++++++++++ internal/router/router.go | 2 + internal/service/analytics_service.go | 68 +++++ internal/service/analytics_service_test.go | 8 + internal/transformer/analytics_transformer.go | 226 +++++++++++++++ .../transformer/analytics_transformer_test.go | 39 +++ 12 files changed, 1205 insertions(+) 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) +} From 6c19876a4798a75bbfb00aad3328dcee869278d7 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 15 Jun 2026 17:44:25 +0700 Subject: [PATCH 16/16] Fix issue --- internal/contract/purchase_order_contract.go | 10 +++--- internal/models/purchase_order.go | 2 +- internal/processor/analytics_processor.go | 4 +-- .../processor/analytics_processor_test.go | 2 +- internal/repository/analytics_repository.go | 31 ++++++++++++++----- .../purchase_order_validator_test.go | 28 +++++++++++++++++ ...raw_material_purchase_order_items.down.sql | 1 + ...e_raw_material_purchase_order_items.up.sql | 16 ++++++++-- ...ce_expense_items_expense_categories.up.sql | 17 ++++++++++ 9 files changed, 92 insertions(+), 19 deletions(-) diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 70e722c..5ab9024 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -40,12 +40,12 @@ type UpdatePurchaseOrderRequest struct { } type UpdatePurchaseOrderItemRequest struct { - ID *uuid.UUID `json:"id,omitempty"` // For existing items - IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"` - PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"` + ID *uuid.UUID `json:"id,omitempty"` // Ignored. Supplying items replaces all existing PO items. + IngredientID *uuid.UUID `json:"ingredient_id" validate:"required"` + PurchaseCategoryID *uuid.UUID `json:"purchase_category_id" validate:"required"` Description *string `json:"description,omitempty" validate:"omitempty"` - Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"` - UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"` + Quantity *float64 `json:"quantity" validate:"required,gt=0"` + UnitID *uuid.UUID `json:"unit_id" validate:"required"` Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"` } diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 1afa23f..452536c 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -117,7 +117,7 @@ type UpdatePurchaseOrderRequest struct { } type UpdatePurchaseOrderItemRequest struct { - ID *uuid.UUID `json:"id,omitempty"` // For existing items + ID *uuid.UUID `json:"id,omitempty"` // Ignored. Supplying items replaces all existing PO items. IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"` Description *string `json:"description,omitempty"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 928d9eb..e4894e3 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -842,10 +842,10 @@ func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDai 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 + case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"): + salaryStaff += transaction.Amount default: salaryOther += transaction.Amount } diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 4cdd910..11373de 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -293,7 +293,7 @@ func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburs }, 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"}, + {Date: now, CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Description: "gaji karyawan", Amount: 3555000, Source: "expense"}, }, }, }, expenseRepositoryStub{}) diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 8f429d2..6b2be98 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -173,7 +173,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Joins("JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). - Where("po.status != ?", "cancelled"). + Where("po.status = ?", "received"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID) @@ -214,7 +214,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Joins("JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). - Where("po.status != ?", "cancelled"). + Where("po.status = ?", "received"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group(dateFormat). Order(dateFormat) @@ -245,7 +245,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). - Where("po.status != ?", "cancelled"). + Where("po.status = ?", "received"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("i.id, i.name"). Order("total_cost DESC") @@ -273,7 +273,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Joins("JOIN units u ON poi.unit_id = u.id"). Where("po.organization_id = ?", organizationID). Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). - Where("po.status != ?", "cancelled"). + Where("po.status = ?", "received"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("v.id, v.name"). Order("total_cost DESC") @@ -296,7 +296,15 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm if outletID == nil { return query } - return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID) + return query.Where(` + EXISTS ( + SELECT 1 + FROM inventory_movements im + WHERE im.purchase_order_item_id = poi.id + AND im.movement_type = ? + AND im.outlet_id = ? + ) + `, entities.InventoryMovementTypePurchase, *outletID) } func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { @@ -307,6 +315,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ Select(` p.id as product_id, p.name as product_name, + p.price as product_price, c.id as category_id, c.name as category_name, c.order as category_order, @@ -365,7 +374,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ query = r.resolveOutletID(query, outletID, "o.outlet_id") err := query. - Group("p.id, p.name, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit"). + Group("p.id, p.name, p.price, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit"). Order("revenue DESC"). Limit(limit). Scan(&results).Error @@ -854,8 +863,14 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryTransactionUnionQuery(organiza } if outletID != nil { - poOutletFilter = "AND (i.outlet_id = ? OR u.outlet_id = ?)" - args = append(args, *outletID, *outletID) + poOutletFilter = `AND EXISTS ( + SELECT 1 + FROM inventory_movements im + WHERE im.purchase_order_item_id = poi.id + AND im.movement_type = 'purchase' + AND im.outlet_id = ? + )` + args = append(args, *outletID) } args = append(args, diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index c07f9a7..34c39f2 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -73,3 +73,31 @@ func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *test require.Equal(t, constants.MalformedFieldErrorCode, code) require.Contains(t, err.Error(), "due_date must be after transaction_date") } + +func TestPurchaseOrderValidatorUpdateItemsRequireFullReplacementFields(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := &contract.UpdatePurchaseOrderRequest{ + Items: []contract.UpdatePurchaseOrderItemRequest{ + { + PurchaseCategoryID: ptrUUID(uuid.New()), + Quantity: ptrFloat64(1), + UnitID: ptrUUID(uuid.New()), + Amount: ptrFloat64(1000), + }, + }, + } + + err, code := validator.ValidateUpdatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MissingFieldErrorCode, code) + require.Contains(t, err.Error(), "ingredient_id is required") +} + +func ptrUUID(id uuid.UUID) *uuid.UUID { + return &id +} + +func ptrFloat64(value float64) *float64 { + return &value +} diff --git a/migrations/000081_enforce_raw_material_purchase_order_items.down.sql b/migrations/000081_enforce_raw_material_purchase_order_items.down.sql index e413239..802b92d 100644 --- a/migrations/000081_enforce_raw_material_purchase_order_items.down.sql +++ b/migrations/000081_enforce_raw_material_purchase_order_items.down.sql @@ -2,6 +2,7 @@ DROP TRIGGER IF EXISTS trigger_validate_purchase_order_item_raw_material ON purc DROP FUNCTION IF EXISTS validate_purchase_order_item_raw_material(); ALTER TABLE purchase_order_items + ALTER COLUMN purchase_category_id DROP NOT NULL, ALTER COLUMN ingredient_id DROP NOT NULL, ALTER COLUMN quantity DROP NOT NULL, ALTER COLUMN unit_id DROP NOT NULL; diff --git a/migrations/000081_enforce_raw_material_purchase_order_items.up.sql b/migrations/000081_enforce_raw_material_purchase_order_items.up.sql index b3fcae7..d394149 100644 --- a/migrations/000081_enforce_raw_material_purchase_order_items.up.sql +++ b/migrations/000081_enforce_raw_material_purchase_order_items.up.sql @@ -1,10 +1,21 @@ +UPDATE purchase_order_items poi +SET purchase_category_id = pc.id +FROM purchase_orders po +JOIN purchase_categories pc ON pc.organization_id = po.organization_id + AND pc.code = 'bahan_baku' + AND pc.type = 'raw_material' +WHERE poi.purchase_order_id = po.id + AND poi.purchase_category_id IS NULL; + DO $$ BEGIN IF EXISTS ( SELECT 1 FROM purchase_order_items poi - JOIN purchase_categories pc ON pc.id = poi.purchase_category_id - WHERE pc.type <> 'raw_material' + LEFT JOIN purchase_categories pc ON pc.id = poi.purchase_category_id + WHERE poi.purchase_category_id IS NULL + OR pc.id IS NULL + OR pc.type <> 'raw_material' OR poi.ingredient_id IS NULL OR poi.quantity IS NULL OR poi.unit_id IS NULL @@ -14,6 +25,7 @@ BEGIN END $$; ALTER TABLE purchase_order_items + ALTER COLUMN purchase_category_id SET NOT NULL, ALTER COLUMN ingredient_id SET NOT NULL, ALTER COLUMN quantity SET NOT NULL, ALTER COLUMN unit_id SET NOT NULL; diff --git a/migrations/000082_enforce_expense_items_expense_categories.up.sql b/migrations/000082_enforce_expense_items_expense_categories.up.sql index 73f59ae..e218504 100644 --- a/migrations/000082_enforce_expense_items_expense_categories.up.sql +++ b/migrations/000082_enforce_expense_items_expense_categories.up.sql @@ -1,3 +1,20 @@ +UPDATE expense_items ei +SET purchase_category_id = pc.id +FROM expenses e +JOIN purchase_categories pc ON pc.organization_id = e.organization_id + AND pc.code = 'biaya_lain_lain' + AND pc.type = 'expense' +WHERE ei.expense_id = e.id + AND ( + ei.purchase_category_id IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM purchase_categories current_pc + WHERE current_pc.id = ei.purchase_category_id + AND current_pc.type = 'expense' + ) + ); + DO $$ BEGIN IF EXISTS (