diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 5ab9024..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 { @@ -40,12 +40,12 @@ type UpdatePurchaseOrderRequest struct { } type UpdatePurchaseOrderItemRequest struct { - 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"` + 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" validate:"required,gt=0"` - UnitID *uuid.UUID `json:"unit_id" validate:"required"` + 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"` } @@ -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 4e4e175..a388fdf 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -216,3 +216,12 @@ type ExclusiveSummaryDailyTransaction struct { Amount float64 Source string } + +type ExclusiveSummaryBankBalance struct { + Bank string + OpeningBalance *float64 + IncomingMutation *float64 + OutgoingMutation *float64 + ClosingBalance *float64 + Notes *string +} 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/purchase_order.go b/internal/models/purchase_order.go index 452536c..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 { @@ -117,7 +117,7 @@ type UpdatePurchaseOrderRequest struct { } type UpdatePurchaseOrderItemRequest struct { - ID *uuid.UUID `json:"id,omitempty"` // Ignored. Supplying items replaces all existing PO items. + 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"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index e4894e3..880555f 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -656,14 +656,6 @@ func slugify(s string) string { } 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") } @@ -672,10 +664,6 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, } 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) @@ -689,9 +677,8 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, return nil, err } - buckets := buildExclusiveSummaryMonthlyBuckets(monthStart) - periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0, len(buckets)) - for _, bucket := range buckets { + periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0) + for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) { period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, @@ -702,7 +689,6 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, return nil, err } - grossMargin := percentage(period.Summary.GrossProfit, period.Summary.Sales) periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{ Label: bucket.Label, DateFrom: bucket.DateFrom, @@ -710,10 +696,27 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, Sales: period.Summary.Sales, HPP: period.Summary.HPP, GrossProfit: period.Summary.GrossProfit, - GrossMargin: grossMargin, + GrossMargin: percentage(period.Summary.GrossProfit, period.Summary.Sales), }) } + bankBalances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID) + if err != nil { + return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err) + } + + bankBalance := make([]models.ExclusiveSummaryBankBalance, len(bankBalances)) + for i, item := range bankBalances { + bankBalance[i] = models.ExclusiveSummaryBankBalance{ + Bank: item.Bank, + OpeningBalance: item.OpeningBalance, + IncomingMutation: item.IncomingMutation, + OutgoingMutation: item.OutgoingMutation, + ClosingBalance: item.ClosingBalance, + Notes: item.Notes, + } + } + return &models.ExclusiveSummaryMonthlyResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, @@ -727,11 +730,8 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, NetProfit: fullPeriod.Summary.NetProfit, NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales), }, - Periods: periods, - BankBalance: []models.ExclusiveSummaryBankBalance{ - {Bank: "BCA"}, - {Bank: "BRI"}, - }, + Periods: periods, + BankBalance: bankBalance, }, nil } @@ -836,16 +836,16 @@ func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDai var salaryOther float64 for _, transaction := range transactions { - if transaction.Source != "expense" || !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) { + if !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) { continue } classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description) switch { - case strings.Contains(classification, "dw"): - salaryDW += transaction.Amount 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 } diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 11373de..8a4d9c0 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -13,10 +13,12 @@ import ( ) type analyticsRepositoryStub struct { - purchasingResult *entities.PurchasingAnalytics - profitLossResult *entities.ProfitLossAnalytics - exclusiveResult *entities.ExclusiveSummaryAnalytics - profitLossGroup string + purchasingResult *entities.PurchasingAnalytics + profitLossResult *entities.ProfitLossAnalytics + exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics + bankBalances []entities.ExclusiveSummaryBankBalance + profitLossGroup string + exclusiveSummaryCalls int } func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) { @@ -48,8 +50,18 @@ 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 +func (s *analyticsRepositoryStub) GetExclusiveSummaryAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ExclusiveSummaryAnalytics, error) { + if s.exclusiveSummaryCalls < len(s.exclusiveSummaryResults) { + result := s.exclusiveSummaryResults[s.exclusiveSummaryCalls] + s.exclusiveSummaryCalls++ + return result, nil + } + s.exclusiveSummaryCalls++ + return &entities.ExclusiveSummaryAnalytics{}, nil +} + +func (s *analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Context, uuid.UUID, *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) { + return s.bankBalances, nil } type expenseRepositoryStub struct{} @@ -76,7 +88,7 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) outletID := uuid.New() outletName := "Main Outlet" now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) - processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ + processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ purchasingResult: &entities.PurchasingAnalytics{ OutletName: &outletName, Summary: entities.PurchasingSummary{ @@ -129,7 +141,7 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t * productID := uuid.New() categoryID := uuid.New() now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) - processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ + processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ profitLossResult: &entities.ProfitLossAnalytics{ Summary: entities.ProfitLossSummary{ TotalRevenue: 1000, @@ -201,7 +213,7 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t * func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) { now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) - processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ + processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ profitLossResult: &entities.ProfitLossAnalytics{ Summary: entities.ProfitLossSummary{ TotalRevenue: 10000, @@ -279,21 +291,28 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes require.True(t, result.MainSummary[6].IsBold) } -func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburse(t *testing.T) { +func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse(t *testing.T) { now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC) - 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_dw", CategoryName: "Gaji DW", Description: "gaji karyawan", Amount: 3555000, Source: "expense"}, + processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ + exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{ + { + SalesTotal: 1000, + HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "RAW", CategoryName: "Raw", Amount: 400}, + }, + OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "GAJI", CategoryName: "Gaji", Amount: 250}, + {CategoryCode: "OPS", CategoryName: "Operasional", Amount: 100}, + }, + DailySummary: []entities.ExclusiveSummaryDailySummary{ + {Date: now, TransactionCount: 3, TotalCost: 750}, + }, + DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{ + {Date: now, CategoryCode: "RAW", CategoryName: "Raw", Description: "beras", Amount: 400, Source: "purchase_order"}, + {Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "gaji karyawan", Amount: 200, Source: "purchase_order"}, + {Date: now, CategoryCode: "GAJI", CategoryName: "Gaji", Description: "DW", Amount: 50, Source: "purchase_order"}, + {Date: now, CategoryCode: "OPS", CategoryName: "Operasional", Description: "atk", Amount: 100, Source: "purchase_order"}, + }, }, }, }, expenseRepositoryStub{}) @@ -307,34 +326,45 @@ func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburs 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) + require.Equal(t, float64(1000), result.Summary.Sales) + require.Equal(t, float64(400), result.Summary.HPP) + require.Equal(t, float64(600), result.Summary.GrossProfit) + require.Equal(t, float64(350), result.Summary.OperationalExpensesTotal) + require.Equal(t, float64(750), result.Summary.TotalCost) + require.Equal(t, float64(250), result.Summary.NetProfit) + require.Equal(t, float64(250), result.Summary.SalaryTotal) + require.Equal(t, float64(50), result.Summary.SalaryDW) + require.Equal(t, float64(200), result.Summary.SalaryStaff) + require.Equal(t, float64(100), result.Summary.OtherOperationalExpenses) + require.Equal(t, float64(200), result.Reimburse.ExcludedSalaryStaff) + require.Equal(t, float64(550), result.Reimburse.TotalReimburse) + require.Len(t, result.HPPBreakdown, 1) + require.Equal(t, float64(100), result.HPPBreakdown[0].Percentage) + require.Len(t, result.DailySummary, 1) + require.Len(t, result.DailyTransactions, 4) } -func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBankTemplate(t *testing.T) { +func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *testing.T) { location, err := time.LoadLocation("Asia/Jakarta") 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}, - }, + openingBalance := 5000000.0 + closingBalance := 5000000.0 + notes := "Main cash account for daily transactions" + stub := &analyticsRepositoryStub{ + exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{ + {SalesTotal: 1000, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 400}}, OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 100}}}, + {SalesTotal: 100, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 40}}}, + {SalesTotal: 200, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 80}}}, + {SalesTotal: 300, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 120}}}, + {SalesTotal: 400, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 160}}}, + {SalesTotal: 500, HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{{Amount: 200}}}, }, - }, expenseRepositoryStub{}) + bankBalances: []entities.ExclusiveSummaryBankBalance{ + {Bank: "Cash and Bank", OpeningBalance: &openingBalance, ClosingBalance: &closingBalance, Notes: ¬es}, + }, + } + processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{}) result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{ OrganizationID: uuid.New(), @@ -345,11 +375,21 @@ func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBan require.NotNil(t, result) require.Equal(t, "2026-05", result.Month) require.Equal(t, float64(1000), result.Summary.TotalSales) + require.Equal(t, float64(400), result.Summary.HPP) require.Equal(t, float64(500), result.Summary.NetProfit) + require.InDelta(t, float64(50), result.Summary.NetProfitMargin, 0.0001) 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) + require.Len(t, result.BankBalance, 1) + require.Equal(t, "Cash and Bank", result.BankBalance[0].Bank) + require.NotNil(t, result.BankBalance[0].OpeningBalance) + require.Equal(t, openingBalance, *result.BankBalance[0].OpeningBalance) + require.NotNil(t, result.BankBalance[0].ClosingBalance) + require.Equal(t, closingBalance, *result.BankBalance[0].ClosingBalance) + require.Nil(t, result.BankBalance[0].IncomingMutation) + require.Nil(t, result.BankBalance[0].OutgoingMutation) + require.NotNil(t, result.BankBalance[0].Notes) + require.Equal(t, notes, *result.BankBalance[0].Notes) + require.Equal(t, 6, stub.exclusiveSummaryCalls) } diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index ca0819e..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) } - // Purchase orders are raw-material only because they affect ingredient stock. + // Validate categories and inventory fields per item type. for i, item := range req.Items { - if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil { + category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i) + if err != nil { return nil, 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) - } + 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.unitRepo.GetByID(ctx, item.UnitID, organizationID) - if err != nil { - return nil, fmt.Errorf("unit 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) } } @@ -204,38 +224,48 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id return nil, fmt.Errorf("purchase_category_id is required for item %d", i) } - 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 + 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 - if err := p.validateRawMaterialPurchaseCategory(ctx, purchaseCategoryID, organizationID, i); err != nil { + category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i) + if err != nil { return nil, err } - _, err := p.ingredientRepo.GetByID(ctx, ingredientID, organizationID) - if err != nil { - return nil, fmt.Errorf("ingredient not found: %w", 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.unitRepo.GetByID(ctx, unitID, organizationID) - if err != nil { - return nil, fmt.Errorf("unit 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) } items[i] = &entities.PurchaseOrderItem{ @@ -377,6 +407,8 @@ 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 @@ -387,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 } @@ -417,7 +457,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte err = p.inventoryMovementService.CreateIngredientMovement( ctx, - item.IngredientID, + *item.IngredientID, organizationID, outletID, userID, @@ -430,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) } } } @@ -450,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 6b2be98..a402d4f 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -19,6 +19,7 @@ type AnalyticsRepository interface { 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) + GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) } type AnalyticsRepositoryImpl struct { @@ -153,11 +154,7 @@ 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 @@ -167,13 +164,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex COUNT(DISTINCT i.id) as total_ingredients, COUNT(DISTINCT po.vendor_id) as total_vendors `). - 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"). + 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("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). - Where("po.status = ?", "received"). + Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID) @@ -199,22 +194,16 @@ 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("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"). + 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("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). - Where("po.status = ?", "received"). + Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group(dateFormat). Order(dateFormat) @@ -240,12 +229,10 @@ 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 = ?", "received"). + Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("i.id, i.name"). Order("total_cost DESC") @@ -267,13 +254,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex COALESCE(SUM(poi.quantity), 0) as quantity `). Joins("JOIN vendors v ON po.vendor_id = v.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"). + 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("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). - Where("po.status = ?", "received"). + Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("v.id, v.name"). Order("total_cost DESC") @@ -296,15 +281,7 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm if outletID == nil { return query } - 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) + 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) { @@ -315,7 +292,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, @@ -374,7 +350,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 @@ -669,11 +645,11 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga query := r.db.WithContext(ctx). Table("expense_items ei"). - Select(`pc.name 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 purchase_categories pc ON ei.purchase_category_id = pc.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("pc.type = ?", entities.PurchaseCategoryTypeExpense). Where("e.status = ?", "approved"). Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) @@ -682,8 +658,8 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga } err := query. - Group("pc.id, pc.name, pc.sort_order"). - Order("pc.sort_order ASC, pc.name ASC"). + Group("COALESCE(parent_coa.name, coa.name, 'Lain-lain')"). + Order("COALESCE(parent_coa.name, coa.name, 'Lain-lain')"). Scan(&results).Error return results, err @@ -694,11 +670,10 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context query := r.db.WithContext(ctx). Table("expense_items ei"). - Select(`COALESCE(NULLIF(ei.item, ''), ei.description, pc.name) as item, COALESCE(SUM(ei.amount), 0) as amount`). + Select(`COALESCE(NULLIF(ei.item, ''), ei.description, coa.name) as item, 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"). + Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.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) @@ -707,7 +682,7 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context } err := query. - Group("COALESCE(NULLIF(ei.item, ''), ei.description, pc.name)"). + Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)"). Order("amount DESC"). Scan(&results).Error @@ -742,7 +717,7 @@ func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Conte return nil, err } - operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo) + operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, dateFrom, dateTo) if err != nil { return nil, err } @@ -779,7 +754,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co `). 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 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). @@ -795,28 +770,22 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co return results, err } -func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) { +func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) { var results []entities.ExclusiveSummaryCategoryTotal - query := r.db.WithContext(ctx). - Table("expense_items ei"). + err := r.db.WithContext(ctx). + Table("purchase_order_items poi"). Select(` pc.code as category_code, pc.name as category_name, - COALESCE(SUM(ei.amount), 0) as amount + COALESCE(SUM(poi.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). + Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). + Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Where("po.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. + Where("po.status = ?", "received"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("pc.id, pc.code, pc.name, pc.sort_order"). Order("pc.sort_order ASC, pc.name ASC"). Scan(&results).Error @@ -826,7 +795,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailySummary, error) { var results []entities.ExclusiveSummaryDailySummary - rawQuery, args := r.exclusiveSummaryTransactionUnionQuery(organizationID, outletID, dateFrom, dateTo) + rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo) err := r.db.WithContext(ctx).Raw(` SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost @@ -840,50 +809,29 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Co func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailyTransaction, error) { var results []entities.ExclusiveSummaryDailyTransaction - rawQuery, args := r.exclusiveSummaryTransactionUnionQuery(organizationID, outletID, dateFrom, dateTo) + rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(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 + ORDER BY date 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 := "" +func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) { + outletFilter := "" args := []interface{}{ organizationID, - entities.PurchaseCategoryTypeRawMaterial, "received", dateFrom, dateTo, } if outletID != nil { - 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, - organizationID, - entities.PurchaseCategoryTypeExpense, - "approved", - dateFrom, - dateTo, - ) - - if outletID != nil { - expenseOutletFilter = "AND e.outlet_id = ?" - args = append(args, *outletID) + outletFilter = "AND (pc.type = ? OR i.outlet_id = ? OR u.outlet_id = ?)" + args = append(args, entities.PurchaseCategoryTypeExpense, *outletID, *outletID) } query := ` @@ -897,32 +845,57 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryTransactionUnionQuery(organiza 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 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 + ` + ` + outletFilter + ` ` return query, args } + +func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) { + type accountBalance struct { + Name string + OpeningBalance float64 + CurrentBalance float64 + Description *string + } + + var accounts []accountBalance + query := r.db.WithContext(ctx). + Table("accounts"). + Select("name, opening_balance, current_balance, description"). + Where("organization_id = ?", organizationID). + Where("account_type IN ?", []entities.AccountType{entities.AccountTypeCash, entities.AccountTypeWallet, entities.AccountTypeBank}). + Where("is_active = ?", true) + + if outletID != nil { + query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID) + } else { + query = query.Where("outlet_id IS NULL") + } + + err := query. + Order("number ASC, name ASC"). + Scan(&accounts).Error + if err != nil { + return nil, err + } + + balances := make([]entities.ExclusiveSummaryBankBalance, len(accounts)) + for i, account := range accounts { + openingBalance := account.OpeningBalance + closingBalance := account.CurrentBalance + balances[i] = entities.ExclusiveSummaryBankBalance{ + Bank: account.Name, + OpeningBalance: &openingBalance, + ClosingBalance: &closingBalance, + Notes: account.Description, + } + } + + return balances, nil +} diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index 49b42ce..d1aab7d 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -198,3 +198,59 @@ func TestAnalyticsServiceGetProfitLossAnalyticsAllowsEmptyGroupBy(t *testing.T) require.NoError(t, err) require.Nil(t, resp) } + +func TestAnalyticsServiceGetExclusiveSummaryPeriodValidation(t *testing.T) { + service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + req *models.ExclusiveSummaryPeriodRequest + wantErr string + }{ + { + name: "nil request", + req: nil, + wantErr: "request cannot be nil", + }, + { + name: "missing organization", + req: &models.ExclusiveSummaryPeriodRequest{ + DateFrom: now, + DateTo: now, + }, + wantErr: "organization_id is required", + }, + { + name: "reversed dates", + req: &models.ExclusiveSummaryPeriodRequest{ + OrganizationID: uuid.New(), + DateFrom: now.AddDate(0, 0, 1), + DateTo: now, + }, + wantErr: "date_from cannot be after date_to", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := service.GetExclusiveSummaryPeriod(context.Background(), tt.req) + + require.Nil(t, resp) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestAnalyticsServiceGetExclusiveSummaryMonthlyValidation(t *testing.T) { + service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) + + resp, err := service.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{ + OrganizationID: uuid.New(), + }) + + require.Nil(t, resp) + require.Error(t, err) + require.Contains(t, err.Error(), "month is required") +} diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 270945d..4d1327e 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -183,7 +183,7 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) { require.Equal(t, "total_omset", result.MainSummary[0].ID) } -func TestExclusiveSummaryPeriodContractToModelParsesISODateRange(t *testing.T) { +func TestExclusiveSummaryPeriodContractToModelParsesFlexibleDates(t *testing.T) { orgID := uuid.New() outletID := uuid.New().String() @@ -217,6 +217,7 @@ func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) { 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) 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_order_validator.go b/internal/validator/purchase_order_validator.go index a62a7c7..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,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 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 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.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 34c39f2..4c37b90 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -11,34 +11,26 @@ 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, }, }, } } -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() @@ -73,31 +65,3 @@ 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 deleted file mode 100644 index 802b92d..0000000 --- a/migrations/000081_enforce_raw_material_purchase_order_items.down.sql +++ /dev/null @@ -1,8 +0,0 @@ -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 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 deleted file mode 100644 index d394149..0000000 --- a/migrations/000081_enforce_raw_material_purchase_order_items.up.sql +++ /dev/null @@ -1,53 +0,0 @@ -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 - 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 - ) 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 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; - -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(); diff --git a/migrations/000082_enforce_expense_items_expense_categories.down.sql b/migrations/000082_enforce_expense_items_expense_categories.down.sql deleted file mode 100644 index 2d79281..0000000 --- a/migrations/000082_enforce_expense_items_expense_categories.down.sql +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index e218504..0000000 --- a/migrations/000082_enforce_expense_items_expense_categories.up.sql +++ /dev/null @@ -1,55 +0,0 @@ -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 ( - 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();