Compare commits
No commits in common. "a8d62bc5e82626a9e322cff8ac83e54d758431ca" and "9e0ba0ce565d3a83b09f1bbe3c720792eb3a2e14" have entirely different histories.
a8d62bc5e8
...
9e0ba0ce56
@ -19,11 +19,11 @@ type CreatePurchaseOrderRequest struct {
|
||||
}
|
||||
|
||||
type CreatePurchaseOrderItemRequest struct {
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id" validate:"required"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
|
||||
Quantity float64 `json:"quantity" validate:"required,gt=0"`
|
||||
UnitID uuid.UUID `json:"unit_id" validate:"required"`
|
||||
Amount float64 `json:"amount" validate:"required,gte=0"`
|
||||
}
|
||||
|
||||
@ -40,12 +40,12 @@ type UpdatePurchaseOrderRequest struct {
|
||||
}
|
||||
|
||||
type UpdatePurchaseOrderItemRequest struct {
|
||||
ID *uuid.UUID `json:"id,omitempty"` // For existing items
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
|
||||
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"`
|
||||
ID *uuid.UUID `json:"id,omitempty"` // Ignored. Supplying items replaces all existing PO items.
|
||||
IngredientID *uuid.UUID `json:"ingredient_id" validate:"required"`
|
||||
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id" validate:"required"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
|
||||
Quantity *float64 `json:"quantity" validate:"required,gt=0"`
|
||||
UnitID *uuid.UUID `json:"unit_id" validate:"required"`
|
||||
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"`
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -216,12 +216,3 @@ type ExclusiveSummaryDailyTransaction struct {
|
||||
Amount float64
|
||||
Source string
|
||||
}
|
||||
|
||||
type ExclusiveSummaryBankBalance struct {
|
||||
Bank string
|
||||
OpeningBalance *float64
|
||||
IncomingMutation *float64
|
||||
OutgoingMutation *float64
|
||||
ClosingBalance *float64
|
||||
Notes *string
|
||||
}
|
||||
|
||||
@ -43,11 +43,11 @@ func (PurchaseOrder) TableName() string {
|
||||
type PurchaseOrderItem struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
|
||||
IngredientID *uuid.UUID `gorm:"type:uuid" json:"ingredient_id" validate:"omitempty"`
|
||||
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)" json:"quantity" validate:"omitempty,gt=0"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" 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"`
|
||||
|
||||
@ -24,11 +24,11 @@ type PurchaseOrder struct {
|
||||
type PurchaseOrderItem 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"`
|
||||
@ -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,11 +96,11 @@ type CreatePurchaseOrderRequest struct {
|
||||
}
|
||||
|
||||
type CreatePurchaseOrderItemRequest struct {
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Quantity *float64 `json:"quantity,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
@ -117,7 +117,7 @@ type UpdatePurchaseOrderRequest struct {
|
||||
}
|
||||
|
||||
type UpdatePurchaseOrderItemRequest struct {
|
||||
ID *uuid.UUID `json:"id,omitempty"` // For existing items
|
||||
ID *uuid.UUID `json:"id,omitempty"` // Ignored. Supplying items replaces all existing PO items.
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
|
||||
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
|
||||
@ -656,6 +656,14 @@ 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")
|
||||
}
|
||||
@ -664,6 +672,10 @@ 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)
|
||||
|
||||
@ -677,8 +689,9 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0)
|
||||
for _, bucket := range buildExclusiveSummaryMonthlyBuckets(monthStart) {
|
||||
buckets := buildExclusiveSummaryMonthlyBuckets(monthStart)
|
||||
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0, len(buckets))
|
||||
for _, bucket := range buckets {
|
||||
period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
@ -689,6 +702,7 @@ 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,
|
||||
@ -696,27 +710,10 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
|
||||
Sales: period.Summary.Sales,
|
||||
HPP: period.Summary.HPP,
|
||||
GrossProfit: period.Summary.GrossProfit,
|
||||
GrossMargin: percentage(period.Summary.GrossProfit, period.Summary.Sales),
|
||||
GrossMargin: grossMargin,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
@ -731,7 +728,10 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
|
||||
NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales),
|
||||
},
|
||||
Periods: periods,
|
||||
BankBalance: bankBalance,
|
||||
BankBalance: []models.ExclusiveSummaryBankBalance{
|
||||
{Bank: "BCA"},
|
||||
{Bank: "BRI"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -836,16 +836,16 @@ func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDai
|
||||
var salaryOther float64
|
||||
|
||||
for _, transaction := range transactions {
|
||||
if !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
|
||||
if transaction.Source != "expense" || !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) {
|
||||
continue
|
||||
}
|
||||
|
||||
classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description)
|
||||
switch {
|
||||
case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"):
|
||||
salaryStaff += transaction.Amount
|
||||
case strings.Contains(classification, "dw"):
|
||||
salaryDW += transaction.Amount
|
||||
case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"):
|
||||
salaryStaff += transaction.Amount
|
||||
default:
|
||||
salaryOther += transaction.Amount
|
||||
}
|
||||
|
||||
@ -15,10 +15,8 @@ import (
|
||||
type analyticsRepositoryStub struct {
|
||||
purchasingResult *entities.PurchasingAnalytics
|
||||
profitLossResult *entities.ProfitLossAnalytics
|
||||
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
|
||||
bankBalances []entities.ExclusiveSummaryBankBalance
|
||||
exclusiveResult *entities.ExclusiveSummaryAnalytics
|
||||
profitLossGroup string
|
||||
exclusiveSummaryCalls int
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
|
||||
@ -50,18 +48,8 @@ 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) {
|
||||
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
|
||||
func (s analyticsRepositoryStub) GetExclusiveSummaryAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ExclusiveSummaryAnalytics, error) {
|
||||
return s.exclusiveResult, nil
|
||||
}
|
||||
|
||||
type expenseRepositoryStub struct{}
|
||||
@ -88,7 +76,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{
|
||||
@ -141,7 +129,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,
|
||||
@ -213,7 +201,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,
|
||||
@ -291,28 +279,21 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *tes
|
||||
require.True(t, result.MainSummary[6].IsBold)
|
||||
}
|
||||
|
||||
func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse(t *testing.T) {
|
||||
func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburse(t *testing.T) {
|
||||
now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
|
||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||
exclusiveSummaryResults: []*entities.ExclusiveSummaryAnalytics{
|
||||
{
|
||||
SalesTotal: 1000,
|
||||
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
||||
exclusiveResult: &entities.ExclusiveSummaryAnalytics{
|
||||
SalesTotal: 35619000,
|
||||
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||
{CategoryCode: "RAW", CategoryName: "Raw", Amount: 400},
|
||||
{CategoryCode: "hpp_nusantara", CategoryName: "Nusantara", Amount: 19010552},
|
||||
},
|
||||
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||
{CategoryCode: "GAJI", CategoryName: "Gaji", Amount: 250},
|
||||
{CategoryCode: "OPS", CategoryName: "Operasional", Amount: 100},
|
||||
},
|
||||
DailySummary: []entities.ExclusiveSummaryDailySummary{
|
||||
{Date: now, TransactionCount: 3, TotalCost: 750},
|
||||
{CategoryCode: "biaya_gaji", CategoryName: "Gaji", Amount: 51758333},
|
||||
{CategoryCode: "biaya_lain", CategoryName: "Biaya Lain-lain", Amount: 1608605},
|
||||
},
|
||||
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"},
|
||||
},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
}, expenseRepositoryStub{})
|
||||
@ -326,45 +307,34 @@ func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesTotalsAndReimburse
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
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)
|
||||
require.Equal(t, float64(35619000), result.Summary.Sales)
|
||||
require.Equal(t, float64(19010552), result.Summary.HPP)
|
||||
require.Equal(t, float64(16608448), result.Summary.GrossProfit)
|
||||
require.Equal(t, float64(51758333), result.Summary.SalaryTotal)
|
||||
require.Equal(t, float64(3555000), result.Summary.SalaryDW)
|
||||
require.Equal(t, float64(48203333), result.Summary.SalaryStaff)
|
||||
require.Equal(t, float64(53366938), result.Summary.OperationalExpensesTotal)
|
||||
require.Equal(t, float64(72377490), result.Summary.TotalCost)
|
||||
require.Equal(t, float64(-36758490), result.Summary.NetProfit)
|
||||
require.Equal(t, float64(48203333), result.Reimburse.ExcludedSalaryStaff)
|
||||
require.Equal(t, float64(24174157), result.Reimburse.TotalReimburse)
|
||||
}
|
||||
|
||||
func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *testing.T) {
|
||||
func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBankTemplate(t *testing.T) {
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
require.NoError(t, err)
|
||||
month := time.Date(2026, 5, 1, 0, 0, 0, 0, location)
|
||||
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}}},
|
||||
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
||||
exclusiveResult: &entities.ExclusiveSummaryAnalytics{
|
||||
SalesTotal: 1000,
|
||||
HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||
{CategoryCode: "hpp", CategoryName: "HPP", Amount: 400},
|
||||
},
|
||||
bankBalances: []entities.ExclusiveSummaryBankBalance{
|
||||
{Bank: "Cash and Bank", OpeningBalance: &openingBalance, ClosingBalance: &closingBalance, Notes: ¬es},
|
||||
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||
{CategoryCode: "ops", CategoryName: "OPS", Amount: 100},
|
||||
},
|
||||
}
|
||||
processor := NewAnalyticsProcessorImpl(stub, expenseRepositoryStub{})
|
||||
},
|
||||
}, expenseRepositoryStub{})
|
||||
|
||||
result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
@ -375,21 +345,11 @@ func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsSummaryAndBuckets(t *
|
||||
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, 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)
|
||||
require.Len(t, result.BankBalance, 2)
|
||||
require.Equal(t, "BCA", result.BankBalance[0].Bank)
|
||||
require.Equal(t, "BRI", result.BankBalance[1].Bank)
|
||||
}
|
||||
|
||||
@ -67,41 +67,21 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber)
|
||||
}
|
||||
|
||||
// Validate categories and inventory fields per item type.
|
||||
// Purchase orders are raw-material only because they affect ingredient stock.
|
||||
for i, item := range req.Items {
|
||||
category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i)
|
||||
if err != nil {
|
||||
if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch category.Type {
|
||||
case entities.PurchaseCategoryTypeRawMaterial:
|
||||
if item.IngredientID == nil {
|
||||
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
|
||||
}
|
||||
if item.Quantity == nil {
|
||||
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
|
||||
}
|
||||
if item.UnitID == nil {
|
||||
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
|
||||
}
|
||||
|
||||
_, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
|
||||
_, 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)
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total amount
|
||||
@ -224,49 +204,39 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
||||
return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
|
||||
}
|
||||
|
||||
ingredientID := itemReq.IngredientID
|
||||
if itemReq.IngredientID == nil {
|
||||
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
|
||||
}
|
||||
if itemReq.Quantity == nil {
|
||||
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
|
||||
}
|
||||
if itemReq.UnitID == nil {
|
||||
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
|
||||
}
|
||||
|
||||
ingredientID := *itemReq.IngredientID
|
||||
purchaseCategoryID := *itemReq.PurchaseCategoryID
|
||||
unitID := itemReq.UnitID
|
||||
quantity := itemReq.Quantity
|
||||
unitID := *itemReq.UnitID
|
||||
quantity := *itemReq.Quantity
|
||||
amount := 0.0
|
||||
if itemReq.Amount != nil {
|
||||
amount = *itemReq.Amount
|
||||
}
|
||||
description := itemReq.Description
|
||||
|
||||
category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i)
|
||||
if err != nil {
|
||||
if err := p.validateRawMaterialPurchaseCategory(ctx, purchaseCategoryID, organizationID, i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch category.Type {
|
||||
case entities.PurchaseCategoryTypeRawMaterial:
|
||||
if ingredientID == nil {
|
||||
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
|
||||
}
|
||||
if quantity == nil {
|
||||
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
|
||||
}
|
||||
if unitID == nil {
|
||||
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
|
||||
}
|
||||
|
||||
_, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID)
|
||||
_, 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)
|
||||
_, err = p.unitRepo.GetByID(ctx, unitID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unit not found: %w", err)
|
||||
}
|
||||
case entities.PurchaseCategoryTypeExpense:
|
||||
if ingredientID != nil || quantity != nil || unitID != nil {
|
||||
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
|
||||
}
|
||||
|
||||
items[i] = &entities.PurchaseOrderItem{
|
||||
PurchaseOrderID: poEntity.ID,
|
||||
@ -407,8 +377,6 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
||||
return nil, fmt.Errorf("purchase order not found: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("status:", po.Status)
|
||||
|
||||
// Check if status is changing to "received" and current status is not "received"
|
||||
if status == "received" && po.Status != "received" {
|
||||
// Get purchase order with items for inventory update
|
||||
@ -419,27 +387,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
||||
|
||||
// Update inventory for each item
|
||||
for _, item := range poWithItems.Items {
|
||||
if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense {
|
||||
continue
|
||||
}
|
||||
|
||||
if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil {
|
||||
return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID)
|
||||
}
|
||||
|
||||
// Get ingredient to find its base unit
|
||||
ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
|
||||
ingredient, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err)
|
||||
return nil, fmt.Errorf("failed to get ingredient %s: %w", item.IngredientID, err)
|
||||
}
|
||||
|
||||
// Convert quantity to ingredient's base unit if needed
|
||||
quantityToAdd := *item.Quantity
|
||||
if *item.UnitID != ingredient.UnitID {
|
||||
quantityToAdd := item.Quantity
|
||||
if item.UnitID != ingredient.UnitID {
|
||||
// Convert from purchase unit to ingredient's base unit
|
||||
convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity)
|
||||
convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, item.IngredientID, item.UnitID, ingredient.UnitID, organizationID, item.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err)
|
||||
return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", item.IngredientID, item.UnitID, ingredient.UnitID, err)
|
||||
}
|
||||
quantityToAdd = convertedQuantity
|
||||
}
|
||||
@ -457,7 +417,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
||||
|
||||
err = p.inventoryMovementService.CreateIngredientMovement(
|
||||
ctx,
|
||||
*item.IngredientID,
|
||||
item.IngredientID,
|
||||
organizationID,
|
||||
outletID,
|
||||
userID,
|
||||
@ -470,7 +430,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
||||
&item.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err)
|
||||
return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -490,19 +450,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
||||
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
|
||||
}
|
||||
|
||||
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
|
||||
func (p *PurchaseOrderProcessorImpl) validateRawMaterialPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) error {
|
||||
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
|
||||
return fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
|
||||
}
|
||||
|
||||
if !category.IsActive {
|
||||
return nil, fmt.Errorf("purchase category for item %d is inactive", itemIndex)
|
||||
return fmt.Errorf("purchase category for item %d is inactive", itemIndex)
|
||||
}
|
||||
|
||||
if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense {
|
||||
return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex)
|
||||
if category.Type != entities.PurchaseCategoryTypeRawMaterial {
|
||||
return fmt.Errorf("purchase category for item %d must be raw_material", itemIndex)
|
||||
}
|
||||
|
||||
return category, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -19,7 +19,6 @@ 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 {
|
||||
@ -154,7 +153,11 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
||||
Table("purchase_orders po").
|
||||
Select(`
|
||||
COALESCE(SUM(poi.amount), 0) as total_purchases,
|
||||
COALESCE(SUM(poi.amount), 0) as raw_material_purchases,
|
||||
0 as expense_purchases,
|
||||
COUNT(DISTINCT po.id) as total_purchase_orders,
|
||||
COUNT(DISTINCT po.id) as raw_material_purchase_orders,
|
||||
0 as expense_count,
|
||||
COALESCE(SUM(poi.quantity), 0) as total_quantity,
|
||||
CASE
|
||||
WHEN COUNT(DISTINCT po.id) > 0
|
||||
@ -164,11 +167,13 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
||||
COUNT(DISTINCT i.id) as total_ingredients,
|
||||
COUNT(DISTINCT po.vendor_id) as total_vendors
|
||||
`).
|
||||
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
||||
Joins("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||
Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||
Joins("JOIN units u ON poi.unit_id = u.id").
|
||||
Where("po.organization_id = ?", organizationID).
|
||||
Where("po.status != ?", "cancelled").
|
||||
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||
Where("po.status = ?", "received").
|
||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
|
||||
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
|
||||
|
||||
@ -194,16 +199,22 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
||||
Select(`
|
||||
`+dateFormat+` as date,
|
||||
COALESCE(SUM(poi.amount), 0) as purchases,
|
||||
COALESCE(SUM(poi.amount), 0) as raw_material_purchases,
|
||||
0 as expense_purchases,
|
||||
COUNT(DISTINCT po.id) as purchase_orders,
|
||||
COUNT(DISTINCT po.id) as raw_material_purchase_orders,
|
||||
0 as expense_count,
|
||||
COALESCE(SUM(poi.quantity), 0) as quantity,
|
||||
COUNT(DISTINCT i.id) as ingredients,
|
||||
COUNT(DISTINCT po.vendor_id) as vendors
|
||||
`).
|
||||
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
||||
Joins("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||
Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||
Joins("JOIN units u ON poi.unit_id = u.id").
|
||||
Where("po.organization_id = ?", organizationID).
|
||||
Where("po.status != ?", "cancelled").
|
||||
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||
Where("po.status = ?", "received").
|
||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||
Group(dateFormat).
|
||||
Order(dateFormat)
|
||||
@ -229,10 +240,12 @@ 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("po.status != ?", "cancelled").
|
||||
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||
Where("po.status = ?", "received").
|
||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||
Group("i.id, i.name").
|
||||
Order("total_cost DESC")
|
||||
@ -254,11 +267,13 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
||||
COALESCE(SUM(poi.quantity), 0) as quantity
|
||||
`).
|
||||
Joins("JOIN vendors v ON po.vendor_id = v.id").
|
||||
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
|
||||
Joins("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
|
||||
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||
Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
|
||||
Joins("JOIN units u ON poi.unit_id = u.id").
|
||||
Where("po.organization_id = ?", organizationID).
|
||||
Where("po.status != ?", "cancelled").
|
||||
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||
Where("po.status = ?", "received").
|
||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||
Group("v.id, v.name").
|
||||
Order("total_cost DESC")
|
||||
@ -281,7 +296,15 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm
|
||||
if outletID == nil {
|
||||
return query
|
||||
}
|
||||
return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
|
||||
return query.Where(`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM inventory_movements im
|
||||
WHERE im.purchase_order_item_id = poi.id
|
||||
AND im.movement_type = ?
|
||||
AND im.outlet_id = ?
|
||||
)
|
||||
`, entities.InventoryMovementTypePurchase, *outletID)
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
|
||||
@ -292,6 +315,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
|
||||
Select(`
|
||||
p.id as product_id,
|
||||
p.name as product_name,
|
||||
p.price as product_price,
|
||||
c.id as category_id,
|
||||
c.name as category_name,
|
||||
c.order as category_order,
|
||||
@ -350,7 +374,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
|
||||
query = r.resolveOutletID(query, outletID, "o.outlet_id")
|
||||
|
||||
err := query.
|
||||
Group("p.id, p.name, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit").
|
||||
Group("p.id, p.name, p.price, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit").
|
||||
Order("revenue DESC").
|
||||
Limit(limit).
|
||||
Scan(&results).Error
|
||||
@ -645,11 +669,11 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Table("expense_items ei").
|
||||
Select(`COALESCE(parent_coa.name, coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`).
|
||||
Select(`pc.name as category_name, COALESCE(SUM(ei.amount), 0) as amount`).
|
||||
Joins("JOIN expenses e ON ei.expense_id = e.id").
|
||||
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
|
||||
Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id").
|
||||
Joins("JOIN purchase_categories pc ON ei.purchase_category_id = pc.id").
|
||||
Where("e.organization_id = ?", organizationID).
|
||||
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
|
||||
Where("e.status = ?", "approved").
|
||||
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
|
||||
|
||||
@ -658,8 +682,8 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga
|
||||
}
|
||||
|
||||
err := query.
|
||||
Group("COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
|
||||
Order("COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
|
||||
Group("pc.id, pc.name, pc.sort_order").
|
||||
Order("pc.sort_order ASC, pc.name ASC").
|
||||
Scan(&results).Error
|
||||
|
||||
return results, err
|
||||
@ -670,10 +694,11 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Table("expense_items ei").
|
||||
Select(`COALESCE(NULLIF(ei.item, ''), ei.description, coa.name) as item, COALESCE(SUM(ei.amount), 0) as amount`).
|
||||
Select(`COALESCE(NULLIF(ei.item, ''), ei.description, pc.name) as item, COALESCE(SUM(ei.amount), 0) as amount`).
|
||||
Joins("JOIN expenses e ON ei.expense_id = e.id").
|
||||
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
|
||||
Joins("JOIN purchase_categories pc ON ei.purchase_category_id = pc.id").
|
||||
Where("e.organization_id = ?", organizationID).
|
||||
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
|
||||
Where("e.status = ?", "approved").
|
||||
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
|
||||
|
||||
@ -682,7 +707,7 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context
|
||||
}
|
||||
|
||||
err := query.
|
||||
Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)").
|
||||
Group("COALESCE(NULLIF(ei.item, ''), ei.description, pc.name)").
|
||||
Order("amount DESC").
|
||||
Scan(&results).Error
|
||||
|
||||
@ -717,7 +742,7 @@ func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Conte
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, dateFrom, dateTo)
|
||||
operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -754,7 +779,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("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").
|
||||
Where("po.organization_id = ?", organizationID).
|
||||
Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial).
|
||||
@ -770,22 +795,28 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) {
|
||||
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) {
|
||||
var results []entities.ExclusiveSummaryCategoryTotal
|
||||
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("purchase_order_items poi").
|
||||
query := r.db.WithContext(ctx).
|
||||
Table("expense_items ei").
|
||||
Select(`
|
||||
pc.code as category_code,
|
||||
pc.name as category_name,
|
||||
COALESCE(SUM(poi.amount), 0) as amount
|
||||
COALESCE(SUM(ei.amount), 0) as amount
|
||||
`).
|
||||
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
|
||||
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
|
||||
Where("po.organization_id = ?", organizationID).
|
||||
Joins("JOIN expenses e ON ei.expense_id = e.id").
|
||||
Joins("JOIN purchase_categories pc ON ei.purchase_category_id = pc.id").
|
||||
Where("e.organization_id = ?", organizationID).
|
||||
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
|
||||
Where("po.status = ?", "received").
|
||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
|
||||
Where("e.status = ?", "approved").
|
||||
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
|
||||
|
||||
if outletID != nil {
|
||||
query = query.Where("e.outlet_id = ?", *outletID)
|
||||
}
|
||||
|
||||
err := query.
|
||||
Group("pc.id, pc.code, pc.name, pc.sort_order").
|
||||
Order("pc.sort_order ASC, pc.name ASC").
|
||||
Scan(&results).Error
|
||||
@ -795,7 +826,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.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo)
|
||||
rawQuery, args := r.exclusiveSummaryTransactionUnionQuery(organizationID, outletID, dateFrom, dateTo)
|
||||
|
||||
err := r.db.WithContext(ctx).Raw(`
|
||||
SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost
|
||||
@ -809,29 +840,50 @@ 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.exclusiveSummaryPurchaseOrderItemQuery(organizationID, outletID, dateFrom, dateTo)
|
||||
rawQuery, args := r.exclusiveSummaryTransactionUnionQuery(organizationID, outletID, dateFrom, dateTo)
|
||||
|
||||
err := r.db.WithContext(ctx).Raw(`
|
||||
SELECT date, category_code, category_name, description, amount, source
|
||||
FROM (`+rawQuery+`) transactions
|
||||
ORDER BY date ASC, category_name ASC, description ASC
|
||||
ORDER BY date ASC, source ASC, category_name ASC, description ASC
|
||||
`, args...).Scan(&results).Error
|
||||
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) {
|
||||
outletFilter := ""
|
||||
func (r *AnalyticsRepositoryImpl) exclusiveSummaryTransactionUnionQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) {
|
||||
poOutletFilter := ""
|
||||
expenseOutletFilter := ""
|
||||
args := []interface{}{
|
||||
organizationID,
|
||||
entities.PurchaseCategoryTypeRawMaterial,
|
||||
"received",
|
||||
dateFrom,
|
||||
dateTo,
|
||||
}
|
||||
|
||||
if outletID != nil {
|
||||
outletFilter = "AND (pc.type = ? OR i.outlet_id = ? OR u.outlet_id = ?)"
|
||||
args = append(args, entities.PurchaseCategoryTypeExpense, *outletID, *outletID)
|
||||
poOutletFilter = `AND EXISTS (
|
||||
SELECT 1
|
||||
FROM inventory_movements im
|
||||
WHERE im.purchase_order_item_id = poi.id
|
||||
AND im.movement_type = 'purchase'
|
||||
AND im.outlet_id = ?
|
||||
)`
|
||||
args = append(args, *outletID)
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
organizationID,
|
||||
entities.PurchaseCategoryTypeExpense,
|
||||
"approved",
|
||||
dateFrom,
|
||||
dateTo,
|
||||
)
|
||||
|
||||
if outletID != nil {
|
||||
expenseOutletFilter = "AND e.outlet_id = ?"
|
||||
args = append(args, *outletID)
|
||||
}
|
||||
|
||||
query := `
|
||||
@ -845,57 +897,32 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organiz
|
||||
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
|
||||
LEFT JOIN ingredients i ON poi.ingredient_id = i.id
|
||||
JOIN ingredients i ON poi.ingredient_id = i.id
|
||||
LEFT JOIN units u ON poi.unit_id = u.id
|
||||
WHERE po.organization_id = ?
|
||||
AND pc.type = ?
|
||||
AND po.status = ?
|
||||
AND po.transaction_date >= ? AND po.transaction_date <= ?
|
||||
` + outletFilter + `
|
||||
` + poOutletFilter + `
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
DATE(e.transaction_date) as date,
|
||||
pc.code as category_code,
|
||||
pc.name as category_name,
|
||||
COALESCE(NULLIF(ei.item, ''), NULLIF(ei.description, ''), pc.name) as description,
|
||||
ei.amount as amount,
|
||||
'expense' as source
|
||||
FROM expense_items ei
|
||||
JOIN expenses e ON ei.expense_id = e.id
|
||||
JOIN purchase_categories pc ON ei.purchase_category_id = pc.id
|
||||
WHERE e.organization_id = ?
|
||||
AND pc.type = ?
|
||||
AND e.status = ?
|
||||
AND e.transaction_date >= ? AND e.transaction_date <= ?
|
||||
` + expenseOutletFilter + `
|
||||
`
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -198,59 +198,3 @@ 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")
|
||||
}
|
||||
|
||||
@ -183,7 +183,7 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) {
|
||||
require.Equal(t, "total_omset", result.MainSummary[0].ID)
|
||||
}
|
||||
|
||||
func TestExclusiveSummaryPeriodContractToModelParsesFlexibleDates(t *testing.T) {
|
||||
func TestExclusiveSummaryPeriodContractToModelParsesISODateRange(t *testing.T) {
|
||||
orgID := uuid.New()
|
||||
outletID := uuid.New().String()
|
||||
|
||||
@ -217,7 +217,6 @@ 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)
|
||||
|
||||
@ -12,19 +12,15 @@ import (
|
||||
)
|
||||
|
||||
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
|
||||
ingredientID := uuid.New()
|
||||
quantity := 1.0
|
||||
unitID := uuid.New()
|
||||
|
||||
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
|
||||
VendorID: uuid.New(),
|
||||
PONumber: "PO-001",
|
||||
TransactionDate: "2026-05-29",
|
||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||
{
|
||||
IngredientID: &ingredientID,
|
||||
Quantity: &quantity,
|
||||
UnitID: &unitID,
|
||||
IngredientID: uuid.New(),
|
||||
Quantity: 1,
|
||||
UnitID: uuid.New(),
|
||||
Amount: 1000,
|
||||
},
|
||||
},
|
||||
|
||||
@ -181,20 +181,20 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
|
||||
}
|
||||
|
||||
func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) {
|
||||
if item.IngredientID == uuid.Nil {
|
||||
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if item.PurchaseCategoryID == uuid.Nil {
|
||||
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
|
||||
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if item.Quantity != nil && *item.Quantity <= 0 {
|
||||
if item.Quantity <= 0 {
|
||||
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if item.UnitID != nil && *item.UnitID == uuid.Nil {
|
||||
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||
if item.UnitID == uuid.Nil {
|
||||
return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if item.Amount < 0 {
|
||||
@ -209,15 +209,15 @@ func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contr
|
||||
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
|
||||
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||
if item.IngredientID == nil || *item.IngredientID == uuid.Nil {
|
||||
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if item.UnitID != nil && *item.UnitID == uuid.Nil {
|
||||
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||
if item.UnitID == nil || *item.UnitID == uuid.Nil {
|
||||
return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if item.Quantity != nil && *item.Quantity <= 0 {
|
||||
if item.Quantity == nil || *item.Quantity <= 0 {
|
||||
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
|
||||
@ -11,26 +11,34 @@ import (
|
||||
)
|
||||
|
||||
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
|
||||
ingredientID := uuid.New()
|
||||
quantity := 1.0
|
||||
unitID := uuid.New()
|
||||
|
||||
return &contract.CreatePurchaseOrderRequest{
|
||||
VendorID: uuid.New(),
|
||||
PONumber: "PO-001",
|
||||
TransactionDate: "2026-05-29",
|
||||
Items: []contract.CreatePurchaseOrderItemRequest{
|
||||
{
|
||||
IngredientID: &ingredientID,
|
||||
IngredientID: uuid.New(),
|
||||
PurchaseCategoryID: uuid.New(),
|
||||
Quantity: &quantity,
|
||||
UnitID: &unitID,
|
||||
Quantity: 1,
|
||||
UnitID: uuid.New(),
|
||||
Amount: 1000,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsMissingRawMaterialFields(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
req.Items[0].IngredientID = uuid.Nil
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MissingFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "ingredient_id is required")
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
|
||||
@ -65,3 +73,31 @@ func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *test
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "due_date must be after transaction_date")
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorUpdateItemsRequireFullReplacementFields(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := &contract.UpdatePurchaseOrderRequest{
|
||||
Items: []contract.UpdatePurchaseOrderItemRequest{
|
||||
{
|
||||
PurchaseCategoryID: ptrUUID(uuid.New()),
|
||||
Quantity: ptrFloat64(1),
|
||||
UnitID: ptrUUID(uuid.New()),
|
||||
Amount: ptrFloat64(1000),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err, code := validator.ValidateUpdatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MissingFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "ingredient_id is required")
|
||||
}
|
||||
|
||||
func ptrUUID(id uuid.UUID) *uuid.UUID {
|
||||
return &id
|
||||
}
|
||||
|
||||
func ptrFloat64(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
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;
|
||||
@ -0,0 +1,53 @@
|
||||
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();
|
||||
@ -0,0 +1,5 @@
|
||||
DROP TRIGGER IF EXISTS trigger_validate_expense_item_expense_category ON expense_items;
|
||||
DROP FUNCTION IF EXISTS validate_expense_item_expense_category();
|
||||
|
||||
ALTER TABLE expense_items
|
||||
ALTER COLUMN purchase_category_id DROP NOT NULL;
|
||||
@ -0,0 +1,55 @@
|
||||
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();
|
||||
Loading…
x
Reference in New Issue
Block a user