feature/exclusive-summary #16

Merged
aefril merged 4 commits from feature/exclusive-summary into main 2026-06-18 01:11:15 +00:00
17 changed files with 422 additions and 456 deletions

View File

@ -19,12 +19,12 @@ type CreatePurchaseOrderRequest struct {
} }
type CreatePurchaseOrderItemRequest struct { type CreatePurchaseOrderItemRequest struct {
IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
Description *string `json:"description,omitempty" validate:"omitempty"` Description *string `json:"description,omitempty" validate:"omitempty"`
Quantity float64 `json:"quantity" validate:"required,gt=0"` Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
UnitID uuid.UUID `json:"unit_id" validate:"required"` UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
Amount float64 `json:"amount" validate:"required,gte=0"` Amount float64 `json:"amount" validate:"required,gte=0"`
} }
type UpdatePurchaseOrderRequest struct { type UpdatePurchaseOrderRequest struct {
@ -40,12 +40,12 @@ type UpdatePurchaseOrderRequest struct {
} }
type UpdatePurchaseOrderItemRequest 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" validate:"required"` IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id" validate:"required"` PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"`
Description *string `json:"description,omitempty" validate:"omitempty"` Description *string `json:"description,omitempty" validate:"omitempty"`
Quantity *float64 `json:"quantity" validate:"required,gt=0"` Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
UnitID *uuid.UUID `json:"unit_id" validate:"required"` UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"` Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"`
} }
@ -70,11 +70,11 @@ type PurchaseOrderResponse struct {
type PurchaseOrderItemResponse struct { type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_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"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description"` Description *string `json:"description"`
Quantity float64 `json:"quantity"` Quantity *float64 `json:"quantity"`
UnitID uuid.UUID `json:"unit_id"` UnitID *uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`

View File

@ -216,3 +216,12 @@ type ExclusiveSummaryDailyTransaction struct {
Amount float64 Amount float64
Source string Source string
} }
type ExclusiveSummaryBankBalance struct {
Bank string
OpeningBalance *float64
IncomingMutation *float64
OutgoingMutation *float64
ClosingBalance *float64
Notes *string
}

View File

@ -41,16 +41,16 @@ func (PurchaseOrder) TableName() string {
} }
type PurchaseOrderItem struct { type PurchaseOrderItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` 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"` 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"` 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"` PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"`
Description *string `gorm:"type:text" json:"description" validate:"omitempty"` Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"` Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"`
UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"` 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"` Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"` PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`

View File

@ -22,16 +22,16 @@ type PurchaseOrder struct {
} }
type PurchaseOrderItem struct { type PurchaseOrderItem struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_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"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description"` Description *string `json:"description"`
Quantity float64 `json:"quantity"` Quantity *float64 `json:"quantity"`
UnitID uuid.UUID `json:"unit_id"` UnitID *uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
type PurchaseOrderAttachment struct { type PurchaseOrderAttachment struct {
@ -62,11 +62,11 @@ type PurchaseOrderResponse struct {
type PurchaseOrderItemResponse struct { type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_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"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description"` Description *string `json:"description"`
Quantity float64 `json:"quantity"` Quantity *float64 `json:"quantity"`
UnitID uuid.UUID `json:"unit_id"` UnitID *uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@ -96,12 +96,12 @@ type CreatePurchaseOrderRequest struct {
} }
type CreatePurchaseOrderItemRequest struct { type CreatePurchaseOrderItemRequest struct {
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Quantity float64 `json:"quantity"` Quantity *float64 `json:"quantity,omitempty"`
UnitID uuid.UUID `json:"unit_id"` UnitID *uuid.UUID `json:"unit_id,omitempty"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
} }
type UpdatePurchaseOrderRequest struct { type UpdatePurchaseOrderRequest struct {
@ -117,7 +117,7 @@ type UpdatePurchaseOrderRequest struct {
} }
type UpdatePurchaseOrderItemRequest 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"` IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"` PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`

View File

@ -656,14 +656,6 @@ func slugify(s string) string {
} }
func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { 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) { if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to") 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) { 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()) 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) monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond)
@ -689,9 +677,8 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
return nil, err return nil, err
} }
buckets := buildExclusiveSummaryMonthlyBuckets(monthStart) periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0)
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0, len(buckets)) for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) {
for _, bucket := range buckets {
period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
@ -702,7 +689,6 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
return nil, err return nil, err
} }
grossMargin := percentage(period.Summary.GrossProfit, period.Summary.Sales)
periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{ periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{
Label: bucket.Label, Label: bucket.Label,
DateFrom: bucket.DateFrom, DateFrom: bucket.DateFrom,
@ -710,10 +696,27 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
Sales: period.Summary.Sales, Sales: period.Summary.Sales,
HPP: period.Summary.HPP, HPP: period.Summary.HPP,
GrossProfit: period.Summary.GrossProfit, 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{ return &models.ExclusiveSummaryMonthlyResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
@ -727,11 +730,8 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
NetProfit: fullPeriod.Summary.NetProfit, NetProfit: fullPeriod.Summary.NetProfit,
NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales), NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales),
}, },
Periods: periods, Periods: periods,
BankBalance: []models.ExclusiveSummaryBankBalance{ BankBalance: bankBalance,
{Bank: "BCA"},
{Bank: "BRI"},
},
}, nil }, nil
} }
@ -836,16 +836,16 @@ func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDai
var salaryOther float64 var salaryOther float64
for _, transaction := range transactions { for _, transaction := range transactions {
if transaction.Source != "expense" || !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) { if !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
continue continue
} }
classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description) classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description)
switch { switch {
case strings.Contains(classification, "dw"):
salaryDW += transaction.Amount
case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"): case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"):
salaryStaff += transaction.Amount salaryStaff += transaction.Amount
case strings.Contains(classification, "dw"):
salaryDW += transaction.Amount
default: default:
salaryOther += transaction.Amount salaryOther += transaction.Amount
} }

View File

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

View File

@ -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) 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 { 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 return nil, err
} }
_, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) switch category.Type {
if err != nil { case entities.PurchaseCategoryTypeRawMaterial:
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) 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) _, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("unit not found for item %d: %w", i, err) 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) return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
} }
if itemReq.IngredientID == nil { ingredientID := itemReq.IngredientID
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 purchaseCategoryID := *itemReq.PurchaseCategoryID
unitID := *itemReq.UnitID unitID := itemReq.UnitID
quantity := *itemReq.Quantity quantity := itemReq.Quantity
amount := 0.0 amount := 0.0
if itemReq.Amount != nil { if itemReq.Amount != nil {
amount = *itemReq.Amount amount = *itemReq.Amount
} }
description := itemReq.Description 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 return nil, err
} }
_, err := p.ingredientRepo.GetByID(ctx, ingredientID, organizationID) switch category.Type {
if err != nil { case entities.PurchaseCategoryTypeRawMaterial:
return nil, fmt.Errorf("ingredient not found: %w", err) 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) _, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("unit not found: %w", err) 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{ 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) 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" // Check if status is changing to "received" and current status is not "received"
if status == "received" && po.Status != "received" { if status == "received" && po.Status != "received" {
// Get purchase order with items for inventory update // Get purchase order with items for inventory update
@ -387,19 +419,27 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
// Update inventory for each item // Update inventory for each item
for _, item := range poWithItems.Items { 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 // 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 { 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 // Convert quantity to ingredient's base unit if needed
quantityToAdd := item.Quantity quantityToAdd := *item.Quantity
if item.UnitID != ingredient.UnitID { if *item.UnitID != ingredient.UnitID {
// Convert from purchase unit to ingredient's base unit // 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 { 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 quantityToAdd = convertedQuantity
} }
@ -417,7 +457,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
err = p.inventoryMovementService.CreateIngredientMovement( err = p.inventoryMovementService.CreateIngredientMovement(
ctx, ctx,
item.IngredientID, *item.IngredientID,
organizationID, organizationID,
outletID, outletID,
userID, userID,
@ -430,7 +470,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
&item.ID, &item.ID,
) )
if err != nil { 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 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) category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
if err != nil { 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 { 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 { if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense {
return fmt.Errorf("purchase category for item %d must be raw_material", itemIndex) return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex)
} }
return nil return category, nil
} }

View File

@ -19,6 +19,7 @@ type AnalyticsRepository interface {
GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, 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) 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) 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 { type AnalyticsRepositoryImpl struct {
@ -153,11 +154,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Table("purchase_orders po"). Table("purchase_orders po").
Select(` Select(`
COALESCE(SUM(poi.amount), 0) as total_purchases, 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 total_purchase_orders,
COUNT(DISTINCT po.id) as raw_material_purchase_orders,
0 as expense_count,
COALESCE(SUM(poi.quantity), 0) as total_quantity, COALESCE(SUM(poi.quantity), 0) as total_quantity,
CASE CASE
WHEN COUNT(DISTINCT po.id) > 0 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 i.id) as total_ingredients,
COUNT(DISTINCT po.vendor_id) as total_vendors COUNT(DISTINCT po.vendor_id) as total_vendors
`). `).
Joins("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). Joins("LEFT 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("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Joins("JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). 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) Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID) summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
@ -199,22 +194,16 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Select(` Select(`
`+dateFormat+` as date, `+dateFormat+` as date,
COALESCE(SUM(poi.amount), 0) as purchases, 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 purchase_orders,
COUNT(DISTINCT po.id) as raw_material_purchase_orders,
0 as expense_count,
COALESCE(SUM(poi.quantity), 0) as quantity, COALESCE(SUM(poi.quantity), 0) as quantity,
COUNT(DISTINCT i.id) as ingredients, COUNT(DISTINCT i.id) as ingredients,
COUNT(DISTINCT po.vendor_id) as vendors COUNT(DISTINCT po.vendor_id) as vendors
`). `).
Joins("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). Joins("LEFT 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("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Joins("JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). 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). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat). Group(dateFormat).
Order(dateFormat) Order(dateFormat)
@ -240,12 +229,10 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
COUNT(DISTINCT po.id) as purchase_order_count COUNT(DISTINCT po.id) as purchase_order_count
`). `).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). 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). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("i.id, i.name"). Group("i.id, i.name").
Order("total_cost DESC") Order("total_cost DESC")
@ -267,13 +254,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
COALESCE(SUM(poi.quantity), 0) as quantity COALESCE(SUM(poi.quantity), 0) as quantity
`). `).
Joins("JOIN vendors v ON po.vendor_id = v.id"). Joins("JOIN vendors v ON po.vendor_id = v.id").
Joins("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). Joins("LEFT 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("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Joins("JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). 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). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("v.id, v.name"). Group("v.id, v.name").
Order("total_cost DESC") Order("total_cost DESC")
@ -296,15 +281,7 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm
if outletID == nil { if outletID == nil {
return query return query
} }
return query.Where(` return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
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) { 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(` Select(`
p.id as product_id, p.id as product_id,
p.name as product_name, p.name as product_name,
p.price as product_price,
c.id as category_id, c.id as category_id,
c.name as category_name, c.name as category_name,
c.order as category_order, 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") query = r.resolveOutletID(query, outletID, "o.outlet_id")
err := query. 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"). Order("revenue DESC").
Limit(limit). Limit(limit).
Scan(&results).Error Scan(&results).Error
@ -669,11 +645,11 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga
query := r.db.WithContext(ctx). query := r.db.WithContext(ctx).
Table("expense_items ei"). 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 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("e.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("e.status = ?", "approved"). Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
@ -682,8 +658,8 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga
} }
err := query. err := query.
Group("pc.id, pc.name, pc.sort_order"). Group("COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
Order("pc.sort_order ASC, pc.name ASC"). Order("COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
Scan(&results).Error Scan(&results).Error
return results, err return results, err
@ -694,11 +670,10 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context
query := r.db.WithContext(ctx). query := r.db.WithContext(ctx).
Table("expense_items ei"). 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 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("e.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("e.status = ?", "approved"). Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
@ -707,7 +682,7 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context
} }
err := query. err := query.
Group("COALESCE(NULLIF(ei.item, ''), ei.description, pc.name)"). Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)").
Order("amount DESC"). Order("amount DESC").
Scan(&results).Error Scan(&results).Error
@ -742,7 +717,7 @@ func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Conte
return nil, err return nil, err
} }
operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo) operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, dateFrom, dateTo)
if err != nil { if err != nil {
return nil, err 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_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
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"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
@ -795,28 +770,22 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co
return results, err 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 var results []entities.ExclusiveSummaryCategoryTotal
query := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("expense_items ei"). Table("purchase_order_items poi").
Select(` Select(`
pc.code as category_code, pc.code as category_code,
pc.name as category_name, 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_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON ei.purchase_category_id = pc.id"). Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("e.organization_id = ?", organizationID). Where("po.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense). Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("e.status = ?", "approved"). Where("po.status = ?", "received").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) Where("po.transaction_date >= ? AND po.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"). Group("pc.id, pc.code, pc.name, pc.sort_order").
Order("pc.sort_order ASC, pc.name ASC"). Order("pc.sort_order ASC, pc.name ASC").
Scan(&results).Error 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) { func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailySummary, error) {
var results []entities.ExclusiveSummaryDailySummary var results []entities.ExclusiveSummaryDailySummary
rawQuery, args := r.exclusiveSummaryTransactionUnionQuery(organizationID, outletID, dateFrom, dateTo) rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo)
err := r.db.WithContext(ctx).Raw(` err := r.db.WithContext(ctx).Raw(`
SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost
@ -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) { func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailyTransaction, error) {
var results []entities.ExclusiveSummaryDailyTransaction var results []entities.ExclusiveSummaryDailyTransaction
rawQuery, args := r.exclusiveSummaryTransactionUnionQuery(organizationID, outletID, dateFrom, dateTo) rawQuery, args := r.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo)
err := r.db.WithContext(ctx).Raw(` err := r.db.WithContext(ctx).Raw(`
SELECT date, category_code, category_name, description, amount, source SELECT date, category_code, category_name, description, amount, source
FROM (`+rawQuery+`) transactions 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 `, args...).Scan(&results).Error
return results, err return results, err
} }
func (r *AnalyticsRepositoryImpl) exclusiveSummaryTransactionUnionQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) { func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) {
poOutletFilter := "" outletFilter := ""
expenseOutletFilter := ""
args := []interface{}{ args := []interface{}{
organizationID, organizationID,
entities.PurchaseCategoryTypeRawMaterial,
"received", "received",
dateFrom, dateFrom,
dateTo, dateTo,
} }
if outletID != nil { if outletID != nil {
poOutletFilter = `AND EXISTS ( outletFilter = "AND (pc.type = ? OR i.outlet_id = ? OR u.outlet_id = ?)"
SELECT 1 args = append(args, entities.PurchaseCategoryTypeExpense, *outletID, *outletID)
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)
} }
query := ` query := `
@ -897,32 +845,57 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryTransactionUnionQuery(organiza
FROM purchase_order_items poi FROM purchase_order_items poi
JOIN purchase_orders po ON poi.purchase_order_id = po.id JOIN purchase_orders po ON poi.purchase_order_id = po.id
JOIN purchase_categories pc ON poi.purchase_category_id = pc.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 LEFT JOIN units u ON poi.unit_id = u.id
WHERE po.organization_id = ? WHERE po.organization_id = ?
AND pc.type = ?
AND po.status = ? AND po.status = ?
AND po.transaction_date >= ? AND po.transaction_date <= ? AND po.transaction_date >= ? AND po.transaction_date <= ?
` + poOutletFilter + ` ` + outletFilter + `
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 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
}

View File

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

View File

@ -183,7 +183,7 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) {
require.Equal(t, "total_omset", result.MainSummary[0].ID) require.Equal(t, "total_omset", result.MainSummary[0].ID)
} }
func TestExclusiveSummaryPeriodContractToModelParsesISODateRange(t *testing.T) { func TestExclusiveSummaryPeriodContractToModelParsesFlexibleDates(t *testing.T) {
orgID := uuid.New() orgID := uuid.New()
outletID := uuid.New().String() outletID := uuid.New().String()
@ -217,6 +217,7 @@ func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, orgID, result.OrganizationID) require.Equal(t, orgID, result.OrganizationID)
location, err := time.LoadLocation("Asia/Jakarta") location, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month) require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month)

View File

@ -12,15 +12,19 @@ import (
) )
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) { func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
ingredientID := uuid.New()
quantity := 1.0
unitID := uuid.New()
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{ result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
VendorID: uuid.New(), VendorID: uuid.New(),
PONumber: "PO-001", PONumber: "PO-001",
TransactionDate: "2026-05-29", TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{ Items: []contract.CreatePurchaseOrderItemRequest{
{ {
IngredientID: uuid.New(), IngredientID: &ingredientID,
Quantity: 1, Quantity: &quantity,
UnitID: uuid.New(), UnitID: &unitID,
Amount: 1000, Amount: 1000,
}, },
}, },

View File

@ -181,20 +181,20 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
} }
func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) { 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 { if item.PurchaseCategoryID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode 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 return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
} }
if item.UnitID == uuid.Nil { if item.UnitID != nil && *item.UnitID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
} }
if item.Amount < 0 { 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 return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
} }
if item.IngredientID == nil || *item.IngredientID == uuid.Nil { if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
} }
if item.UnitID == nil || *item.UnitID == uuid.Nil { if item.UnitID != nil && *item.UnitID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode 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 return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
} }

View File

@ -11,34 +11,26 @@ import (
) )
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest { func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
ingredientID := uuid.New()
quantity := 1.0
unitID := uuid.New()
return &contract.CreatePurchaseOrderRequest{ return &contract.CreatePurchaseOrderRequest{
VendorID: uuid.New(), VendorID: uuid.New(),
PONumber: "PO-001", PONumber: "PO-001",
TransactionDate: "2026-05-29", TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{ Items: []contract.CreatePurchaseOrderItemRequest{
{ {
IngredientID: uuid.New(), IngredientID: &ingredientID,
PurchaseCategoryID: uuid.New(), PurchaseCategoryID: uuid.New(),
Quantity: 1, Quantity: &quantity,
UnitID: uuid.New(), UnitID: &unitID,
Amount: 1000, 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) { func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) {
validator := NewPurchaseOrderValidator() validator := NewPurchaseOrderValidator()
@ -73,31 +65,3 @@ func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *test
require.Equal(t, constants.MalformedFieldErrorCode, code) require.Equal(t, constants.MalformedFieldErrorCode, code)
require.Contains(t, err.Error(), "due_date must be after transaction_date") 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
}

View File

@ -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;

View File

@ -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();

View File

@ -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;

View File

@ -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();