Compare commits
4 Commits
6c19876a47
...
5fa9fc5070
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fa9fc5070 | |||
| 2921631ac3 | |||
| 07e8be0521 | |||
| 4b7b225f58 |
@ -19,11 +19,11 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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"`
|
||||||
|
|||||||
@ -216,3 +216,11 @@ type ExclusiveSummaryDailyTransaction struct {
|
|||||||
Amount float64
|
Amount float64
|
||||||
Source string
|
Source string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExclusiveSummaryBankBalance struct {
|
||||||
|
Bank string
|
||||||
|
AccountType string
|
||||||
|
OpeningBalance float64
|
||||||
|
ClosingBalance float64
|
||||||
|
Description *string
|
||||||
|
}
|
||||||
|
|||||||
@ -43,11 +43,11 @@ 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"`
|
||||||
|
|||||||
@ -24,11 +24,11 @@ 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"`
|
||||||
@ -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,11 +96,11 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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"`
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import (
|
|||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
"apskel-pos-be/internal/repository"
|
"apskel-pos-be/internal/repository"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AnalyticsProcessor interface {
|
type AnalyticsProcessor interface {
|
||||||
@ -656,14 +658,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 +666,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,6 +679,11 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bankBalance, err := p.buildExclusiveSummaryBankBalances(ctx, req.OrganizationID, req.OutletID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
buckets := buildExclusiveSummaryMonthlyBuckets(monthStart)
|
buckets := buildExclusiveSummaryMonthlyBuckets(monthStart)
|
||||||
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0, len(buckets))
|
periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0, len(buckets))
|
||||||
for _, bucket := range buckets {
|
for _, bucket := range buckets {
|
||||||
@ -702,7 +697,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,7 +704,7 @@ 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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -728,13 +722,38 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]models.ExclusiveSummaryBankBalance, error) {
|
||||||
|
balances, err := p.analyticsRepo.GetExclusiveSummaryBankBalances(ctx, organizationID, outletID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get exclusive summary bank balances: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]models.ExclusiveSummaryBankBalance, len(balances))
|
||||||
|
for i, balance := range balances {
|
||||||
|
openingBalance := balance.OpeningBalance
|
||||||
|
closingBalance := balance.ClosingBalance
|
||||||
|
notes := strings.TrimSpace(balance.AccountType)
|
||||||
|
if balance.Description != nil && strings.TrimSpace(*balance.Description) != "" {
|
||||||
|
notes = strings.TrimSpace(*balance.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
result[i] = models.ExclusiveSummaryBankBalance{
|
||||||
|
Bank: balance.Bank,
|
||||||
|
OpeningBalance: &openingBalance,
|
||||||
|
ClosingBalance: &closingBalance,
|
||||||
|
}
|
||||||
|
if notes != "" {
|
||||||
|
result[i].Notes = ¬es
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) {
|
||||||
result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
|
result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -836,16 +855,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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ type analyticsRepositoryStub struct {
|
|||||||
purchasingResult *entities.PurchasingAnalytics
|
purchasingResult *entities.PurchasingAnalytics
|
||||||
profitLossResult *entities.ProfitLossAnalytics
|
profitLossResult *entities.ProfitLossAnalytics
|
||||||
exclusiveResult *entities.ExclusiveSummaryAnalytics
|
exclusiveResult *entities.ExclusiveSummaryAnalytics
|
||||||
|
bankBalances []entities.ExclusiveSummaryBankBalance
|
||||||
profitLossGroup string
|
profitLossGroup string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,6 +53,10 @@ func (s analyticsRepositoryStub) GetExclusiveSummaryAnalytics(context.Context, u
|
|||||||
return s.exclusiveResult, nil
|
return s.exclusiveResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Context, uuid.UUID, *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) {
|
||||||
|
return s.bankBalances, nil
|
||||||
|
}
|
||||||
|
|
||||||
type expenseRepositoryStub struct{}
|
type expenseRepositoryStub struct{}
|
||||||
|
|
||||||
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
|
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }
|
||||||
@ -288,12 +293,13 @@ func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburs
|
|||||||
{CategoryCode: "hpp_nusantara", CategoryName: "Nusantara", Amount: 19010552},
|
{CategoryCode: "hpp_nusantara", CategoryName: "Nusantara", Amount: 19010552},
|
||||||
},
|
},
|
||||||
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{
|
||||||
{CategoryCode: "biaya_gaji", CategoryName: "Gaji", Amount: 51758333},
|
{CategoryCode: "biaya_gaji_staff", CategoryName: "Gaji Staff", Amount: 48203333},
|
||||||
|
{CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Amount: 3555000},
|
||||||
{CategoryCode: "biaya_lain", CategoryName: "Biaya Lain-lain", Amount: 1608605},
|
{CategoryCode: "biaya_lain", CategoryName: "Biaya Lain-lain", Amount: 1608605},
|
||||||
},
|
},
|
||||||
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
|
DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{
|
||||||
{Date: now, CategoryCode: "biaya_gaji", CategoryName: "Gaji", Description: "gaji kary", Amount: 48203333, Source: "expense"},
|
{Date: now, CategoryCode: "biaya_gaji_staff", CategoryName: "Gaji Staff", Description: "gaji kary", Amount: 48203333, Source: "purchase_order"},
|
||||||
{Date: now, CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Description: "gaji karyawan", Amount: 3555000, Source: "expense"},
|
{Date: now, CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Description: "DW", Amount: 3555000, Source: "purchase_order"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, expenseRepositoryStub{})
|
}, expenseRepositoryStub{})
|
||||||
@ -334,6 +340,10 @@ func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBan
|
|||||||
{CategoryCode: "ops", CategoryName: "OPS", Amount: 100},
|
{CategoryCode: "ops", CategoryName: "OPS", Amount: 100},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
bankBalances: []entities.ExclusiveSummaryBankBalance{
|
||||||
|
{Bank: "Rekening Bank", AccountType: "wallet", OpeningBalance: 1000, ClosingBalance: 2500},
|
||||||
|
{Bank: "Kas Utama", AccountType: "cash", OpeningBalance: 3000, ClosingBalance: 3500},
|
||||||
|
},
|
||||||
}, expenseRepositoryStub{})
|
}, expenseRepositoryStub{})
|
||||||
|
|
||||||
result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
|
result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{
|
||||||
@ -350,6 +360,14 @@ func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBan
|
|||||||
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, 2)
|
||||||
require.Equal(t, "BCA", result.BankBalance[0].Bank)
|
require.Equal(t, "Rekening Bank", result.BankBalance[0].Bank)
|
||||||
require.Equal(t, "BRI", result.BankBalance[1].Bank)
|
require.NotNil(t, result.BankBalance[0].OpeningBalance)
|
||||||
|
require.Equal(t, float64(1000), *result.BankBalance[0].OpeningBalance)
|
||||||
|
require.NotNil(t, result.BankBalance[0].ClosingBalance)
|
||||||
|
require.Equal(t, float64(2500), *result.BankBalance[0].ClosingBalance)
|
||||||
|
require.NotNil(t, result.BankBalance[0].Notes)
|
||||||
|
require.Equal(t, "wallet", *result.BankBalance[0].Notes)
|
||||||
|
require.Nil(t, result.BankBalance[0].IncomingMutation)
|
||||||
|
require.Nil(t, result.BankBalance[0].OutgoingMutation)
|
||||||
|
require.Equal(t, "Kas Utama", result.BankBalance[1].Bank)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,21 +67,41 @@ 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 {
|
||||||
|
case entities.PurchaseCategoryTypeRawMaterial:
|
||||||
|
if item.IngredientID == nil {
|
||||||
|
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
|
||||||
|
}
|
||||||
|
if item.Quantity == nil {
|
||||||
|
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
|
||||||
|
}
|
||||||
|
if item.UnitID == nil {
|
||||||
|
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ingredient 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)
|
_, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unit not found for item %d: %w", i, err)
|
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
|
// Calculate total amount
|
||||||
@ -204,39 +224,49 @@ 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 {
|
||||||
|
case entities.PurchaseCategoryTypeRawMaterial:
|
||||||
|
if ingredientID == nil {
|
||||||
|
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
|
||||||
|
}
|
||||||
|
if quantity == nil {
|
||||||
|
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
|
||||||
|
}
|
||||||
|
if unitID == nil {
|
||||||
|
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ingredient not found: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unit not found: %w", err)
|
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{
|
||||||
PurchaseOrderID: poEntity.ID,
|
PurchaseOrderID: poEntity.ID,
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,13 +754,15 @@ 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).
|
||||||
Where("po.status = ?", "received").
|
Where("po.status = ?", "received").
|
||||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
|
||||||
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
|
if outletID != nil {
|
||||||
|
query = query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
|
||||||
|
}
|
||||||
|
|
||||||
err := query.
|
err := query.
|
||||||
Group("pc.id, pc.code, pc.name, pc.sort_order").
|
Group("pc.id, pc.code, pc.name, pc.sort_order").
|
||||||
@ -795,28 +772,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.PurchaseCategoryTypeNonInventory).
|
||||||
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 +797,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.exclusiveSummaryPurchaseOrderItemsQuery(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 +811,30 @@ 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.exclusiveSummaryPurchaseOrderItemsQuery(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) exclusiveSummaryPurchaseOrderItemsQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) {
|
||||||
poOutletFilter := ""
|
|
||||||
expenseOutletFilter := ""
|
|
||||||
args := []interface{}{
|
args := []interface{}{
|
||||||
organizationID,
|
organizationID,
|
||||||
entities.PurchaseCategoryTypeRawMaterial,
|
entities.PurchaseCategoryTypeRawMaterial,
|
||||||
|
entities.PurchaseCategoryTypeNonInventory,
|
||||||
"received",
|
"received",
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
}
|
}
|
||||||
|
outletFilter := ""
|
||||||
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.PurchaseCategoryTypeNonInventory, *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 +848,41 @@ 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 pc.type IN (?, ?)
|
||||||
AND po.status = ?
|
AND po.status = ?
|
||||||
AND po.transaction_date >= ? AND po.transaction_date <= ?
|
AND po.transaction_date >= ? AND po.transaction_date <= ?
|
||||||
` + 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) {
|
||||||
|
var results []entities.ExclusiveSummaryBankBalance
|
||||||
|
|
||||||
|
query := r.db.WithContext(ctx).
|
||||||
|
Table("accounts").
|
||||||
|
Select(`
|
||||||
|
name as bank,
|
||||||
|
account_type,
|
||||||
|
opening_balance,
|
||||||
|
current_balance as closing_balance,
|
||||||
|
description
|
||||||
|
`).
|
||||||
|
Where("organization_id = ?", organizationID).
|
||||||
|
Where("is_active = ?", true).
|
||||||
|
Where("account_type IN ?", []entities.AccountType{entities.AccountTypeBank, entities.AccountTypeWallet, entities.AccountTypeCash})
|
||||||
|
|
||||||
|
if outletID != nil {
|
||||||
|
query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := query.
|
||||||
|
Order("CASE account_type WHEN 'bank' THEN 1 WHEN 'wallet' THEN 2 WHEN 'cash' THEN 3 ELSE 4 END, number ASC, name ASC").
|
||||||
|
Scan(&results).Error
|
||||||
|
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|||||||
@ -753,18 +753,18 @@ func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
date, err := time.Parse("2006-01-02", dateStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
date, err := time.ParseInLocation("2006-01-02", dateStr, location)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if endOfDay {
|
if endOfDay {
|
||||||
result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, location)
|
result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), location)
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -209,15 +209,50 @@ func TestExclusiveSummaryPeriodContractToModelParsesISODateRange(t *testing.T) {
|
|||||||
|
|
||||||
func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) {
|
func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) {
|
||||||
orgID := uuid.New()
|
orgID := uuid.New()
|
||||||
|
outletID := uuid.New().String()
|
||||||
|
|
||||||
result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{
|
result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{
|
||||||
OrganizationID: orgID,
|
OrganizationID: orgID,
|
||||||
|
OutletID: &outletID,
|
||||||
Month: "2026-05",
|
Month: "2026-05",
|
||||||
})
|
})
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, orgID, result.OrganizationID)
|
require.Equal(t, orgID, result.OrganizationID)
|
||||||
|
require.NotNil(t, result.OutletID)
|
||||||
|
require.Equal(t, outletID, result.OutletID.String())
|
||||||
|
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month)
|
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExclusiveSummaryPeriodModelToContractCopiesBreakdowns(t *testing.T) {
|
||||||
|
dateFrom := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC)
|
||||||
|
dateTo := time.Date(2026, 5, 31, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
|
||||||
|
|
||||||
|
result := ExclusiveSummaryPeriodModelToContract(&models.ExclusiveSummaryPeriodResponse{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
Period: models.ExclusiveSummaryPeriodRange{
|
||||||
|
DateFrom: dateFrom,
|
||||||
|
DateTo: dateTo,
|
||||||
|
},
|
||||||
|
Summary: models.ExclusiveSummaryPeriodSummary{
|
||||||
|
Sales: 1000,
|
||||||
|
HPP: 400,
|
||||||
|
TotalCost: 550,
|
||||||
|
NetProfit: 450,
|
||||||
|
SalaryStaff: 100,
|
||||||
|
},
|
||||||
|
HPPBreakdown: []models.ExclusiveSummaryCategoryBreakdown{
|
||||||
|
{CategoryCode: "hpp", CategoryName: "HPP", Amount: 400, Percentage: 100},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, dateFrom, result.Period.DateFrom)
|
||||||
|
require.Equal(t, dateTo, result.Period.DateTo)
|
||||||
|
require.Equal(t, float64(1000), result.Summary.Sales)
|
||||||
|
require.Len(t, result.HPPBreakdown, 1)
|
||||||
|
require.Equal(t, "hpp", result.HPPBreakdown[0].CategoryCode)
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -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();
|
|
||||||
@ -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;
|
|
||||||
@ -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();
|
|
||||||
Loading…
x
Reference in New Issue
Block a user