Compare commits

...
Author SHA1 Message Date
Efril 7a7ac25dcf fix conflict 2026-06-11 16:47:03 +07:00
Efril d0a548f44e merger 2026-06-11 16:43:53 +07:00
ryan f4172fcea7 Fix purchase analytic by outlet 2026-06-11 16:27:50 +07:00
ryan d5216e7994 Fix purchase analytic zero for group by today 2026-06-11 15:57:19 +07:00
ryan 1718c5adab Fix expense to be nullable without raw material. 2026-06-10 14:25:23 +07:00
ryan d0c090a657 Update purchase analytics 2026-06-10 13:18:49 +07:00
24 changed files with 466 additions and 257 deletions
+8
View File
@@ -106,7 +106,11 @@ type PurchasingAnalyticsResponse struct {
type PurchasingSummary struct { type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"` TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"` TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"` TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"` TotalIngredients int64 `json:"total_ingredients"`
@@ -116,7 +120,11 @@ type PurchasingSummary struct {
type PurchasingAnalyticsData struct { type PurchasingAnalyticsData struct {
Date time.Time `json:"date"` Date time.Time `json:"date"`
Purchases float64 `json:"purchases"` Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"` PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"` Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"` Vendors int64 `json:"vendors"`
@@ -10,7 +10,7 @@ type CreatePurchaseCategoryRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"` ParentID *uuid.UUID `json:"parent_id,omitempty"`
Code *string `json:"code,omitempty"` Code *string `json:"code,omitempty"`
Name string `json:"name" validate:"required,min=1,max=255"` Name string `json:"name" validate:"required,min=1,max=255"`
Type string `json:"type" validate:"required,oneof=raw_material non_inventory"` Type string `json:"type" validate:"required,oneof=raw_material expense"`
SortOrder *int `json:"sort_order,omitempty"` SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
} }
@@ -19,14 +19,14 @@ type UpdatePurchaseCategoryRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"` ParentID *uuid.UUID `json:"parent_id,omitempty"`
Code *string `json:"code,omitempty"` Code *string `json:"code,omitempty"`
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material non_inventory"` Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
SortOrder *int `json:"sort_order,omitempty"` SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
} }
type ListPurchaseCategoriesRequest struct { type ListPurchaseCategoriesRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"` ParentID *uuid.UUID `json:"parent_id,omitempty"`
Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material non_inventory"` Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
Search string `json:"search,omitempty"` Search string `json:"search,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
Page int `json:"page" validate:"required,min=1"` Page int `json:"page" validate:"required,min=1"`
+6 -6
View File
@@ -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"`
} }
@@ -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"`
+8
View File
@@ -38,7 +38,11 @@ type PurchasingAnalytics struct {
type PurchasingSummary struct { type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"` TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"` TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"` TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"` TotalIngredients int64 `json:"total_ingredients"`
@@ -48,7 +52,11 @@ type PurchasingSummary struct {
type PurchasingAnalyticsData struct { type PurchasingAnalyticsData struct {
Date time.Time `json:"date"` Date time.Time `json:"date"`
Purchases float64 `json:"purchases"` Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"` PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"` Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"` Vendors int64 `json:"vendors"`
+1 -1
View File
@@ -11,7 +11,7 @@ type PurchaseCategoryType string
const ( const (
PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material" PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material"
PurchaseCategoryTypeNonInventory PurchaseCategoryType = "non_inventory" PurchaseCategoryTypeExpense PurchaseCategoryType = "expense"
) )
type PurchaseCategoryPreset struct { type PurchaseCategoryPreset struct {
+3 -3
View File
@@ -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"`
+8
View File
@@ -113,7 +113,11 @@ type PurchasingAnalyticsResponse struct {
// PurchasingSummary represents the summary of purchasing analytics // PurchasingSummary represents the summary of purchasing analytics
type PurchasingSummary struct { type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"` TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"` TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"` TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"` TotalIngredients int64 `json:"total_ingredients"`
@@ -124,7 +128,11 @@ type PurchasingSummary struct {
type PurchasingAnalyticsData struct { type PurchasingAnalyticsData struct {
Date time.Time `json:"date"` Date time.Time `json:"date"`
Purchases float64 `json:"purchases"` Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"` PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"` Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"` Vendors int64 `json:"vendors"`
+9 -9
View File
@@ -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"`
} }
@@ -187,7 +187,11 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
data[i] = models.PurchasingAnalyticsData{ data[i] = models.PurchasingAnalyticsData{
Date: item.Date, Date: item.Date,
Purchases: item.Purchases, Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders, PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity, Quantity: item.Quantity,
Ingredients: item.Ingredients, Ingredients: item.Ingredients,
Vendors: item.Vendors, Vendors: item.Vendors,
@@ -227,7 +231,11 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
Summary: models.PurchasingSummary{ Summary: models.PurchasingSummary{
TotalPurchases: result.Summary.TotalPurchases, TotalPurchases: result.Summary.TotalPurchases,
RawMaterialPurchases: result.Summary.RawMaterialPurchases,
ExpensePurchases: result.Summary.ExpensePurchases,
TotalPurchaseOrders: result.Summary.TotalPurchaseOrders, TotalPurchaseOrders: result.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders,
ExpenseCount: result.Summary.ExpenseCount,
TotalQuantity: result.Summary.TotalQuantity, TotalQuantity: result.Summary.TotalQuantity,
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
TotalIngredients: result.Summary.TotalIngredients, TotalIngredients: result.Summary.TotalIngredients,
+27 -2
View File
@@ -75,7 +75,23 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
purchasingResult: &entities.PurchasingAnalytics{ purchasingResult: &entities.PurchasingAnalytics{
OutletName: &outletName, OutletName: &outletName,
Summary: entities.PurchasingSummary{ Summary: entities.PurchasingSummary{
TotalPurchases: 125, TotalPurchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
TotalPurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
Data: []entities.PurchasingAnalyticsData{
{
Date: now,
Purchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
PurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
}, },
}, },
}, expenseRepositoryStub{}) }, expenseRepositoryStub{})
@@ -92,7 +108,16 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
require.Equal(t, &outletID, result.OutletID) require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, result.OutletName) require.NotNil(t, result.OutletName)
require.Equal(t, outletName, *result.OutletName) require.Equal(t, outletName, *result.OutletName)
require.Equal(t, float64(125), result.Summary.TotalPurchases) require.Equal(t, float64(300), result.Summary.TotalPurchases)
require.Equal(t, float64(125), result.Summary.RawMaterialPurchases)
require.Equal(t, float64(175), result.Summary.ExpensePurchases)
require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders)
require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders)
require.Equal(t, int64(2), result.Summary.ExpenseCount)
require.Len(t, result.Data, 1)
require.Equal(t, float64(300), result.Data[0].Purchases)
require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases)
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
} }
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
+5 -5
View File
@@ -61,7 +61,7 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err) return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err)
} }
if err := p.validateNonInventoryPurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil { if err := p.validateExpensePurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil {
return nil, err return nil, err
} }
@@ -169,7 +169,7 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err) return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err)
} }
if err := p.validateNonInventoryPurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil { if err := p.validateExpensePurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil {
return nil, err return nil, err
} }
@@ -334,7 +334,7 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod
}, nil }, nil
} }
func (p *ExpenseProcessorImpl) validateNonInventoryPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error { func (p *ExpenseProcessorImpl) validateExpensePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
if err != nil { if err != nil {
return fmt.Errorf("purchase category not found: %w", err) return fmt.Errorf("purchase category not found: %w", err)
@@ -344,8 +344,8 @@ func (p *ExpenseProcessorImpl) validateNonInventoryPurchaseCategory(ctx context.
return fmt.Errorf("purchase category is inactive") return fmt.Errorf("purchase category is inactive")
} }
if category.Type != entities.PurchaseCategoryTypeNonInventory { if category.Type != entities.PurchaseCategoryTypeExpense {
return fmt.Errorf("purchase category must be non_inventory") return fmt.Errorf("purchase category must be expense")
} }
return nil return nil
+6 -6
View File
@@ -102,7 +102,7 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui
func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
repo := &expenseRepositoryCaptureStub{} repo := &expenseRepositoryCaptureStub{}
purchaseCategoryID := uuid.New() purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
chartOfAccountID := uuid.New() chartOfAccountID := uuid.New()
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
@@ -133,7 +133,7 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
repo := &expenseRepositoryCaptureStub{} repo := &expenseRepositoryCaptureStub{}
purchaseCategoryID := uuid.New() purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
Receiver: "Cashier", Receiver: "Cashier",
@@ -160,7 +160,7 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
repo := &expenseRepositoryCaptureStub{} repo := &expenseRepositoryCaptureStub{}
purchaseCategoryID := uuid.New() purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
status := "approved" status := "approved"
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
@@ -209,7 +209,7 @@ func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T)
require.Error(t, err) require.Error(t, err)
require.Nil(t, resp) require.Nil(t, resp)
require.Contains(t, err.Error(), "non_inventory") require.Contains(t, err.Error(), "expense")
} }
func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) { func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) {
@@ -241,7 +241,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te
{ {
PurchaseCategoryID: purchaseCategoryID, PurchaseCategoryID: purchaseCategoryID,
PurchaseCategoryName: "Operational Supplies", PurchaseCategoryName: "Operational Supplies",
PurchaseCategoryType: "non_inventory", PurchaseCategoryType: "expense",
TotalAmount: 100000, TotalAmount: 100000,
ExpenseCount: 2, ExpenseCount: 2,
ItemCount: 2, ItemCount: 2,
@@ -266,7 +266,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te
}, },
}, },
} }
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{ resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{
OrganizationID: uuid.New(), OrganizationID: uuid.New(),
+101 -72
View File
@@ -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)
} }
// Validate ingredients, raw-material categories, and units exist // Validate categories and inventory fields per item type.
for i, item := range req.Items { for i, item := range req.Items {
_, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i)
if err != nil {
return nil, err
}
switch category.Type {
case entities.PurchaseCategoryTypeRawMaterial:
if item.IngredientID == nil {
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
}
if item.Quantity == nil {
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
}
if item.UnitID == nil {
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
}
_, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
if err != nil { 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)
} }
if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil { _, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID)
return nil, err
}
_, 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
@@ -197,64 +217,58 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
// Update items if provided // Update items if provided
if req.Items != nil { if req.Items != nil {
// Delete existing items
err = p.purchaseOrderRepo.DeleteItemsByPurchaseOrderID(ctx, poEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to delete existing items: %w", err)
}
// Create new items
totalAmount := 0.0 totalAmount := 0.0
items := make([]*entities.PurchaseOrderItem, len(req.Items))
for i, itemReq := range req.Items { for i, itemReq := range req.Items {
// Validate ingredients and units exist if itemReq.PurchaseCategoryID == nil {
if itemReq.IngredientID != nil { return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
_, err := p.ingredientRepo.GetByID(ctx, *itemReq.IngredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("ingredient not found: %w", err)
}
} }
if itemReq.UnitID != nil { ingredientID := itemReq.IngredientID
_, err := p.unitRepo.GetByID(ctx, *itemReq.UnitID, organizationID) purchaseCategoryID := *itemReq.PurchaseCategoryID
if err != nil { unitID := itemReq.UnitID
return nil, fmt.Errorf("unit not found: %w", err) quantity := itemReq.Quantity
} amount := 0.0
}
if itemReq.PurchaseCategoryID != nil {
if err := p.validateRawMaterialPurchaseCategory(ctx, *itemReq.PurchaseCategoryID, organizationID, i); err != nil {
return nil, err
}
}
// Use existing values if not provided
ingredientID := poEntity.Items[0].IngredientID // This is a simplified approach
purchaseCategoryID := poEntity.Items[0].PurchaseCategoryID
unitID := poEntity.Items[0].UnitID
quantity := poEntity.Items[0].Quantity
amount := poEntity.Items[0].Amount
description := poEntity.Items[0].Description
if itemReq.IngredientID != nil {
ingredientID = *itemReq.IngredientID
}
if itemReq.UnitID != nil {
unitID = *itemReq.UnitID
}
if itemReq.PurchaseCategoryID != nil {
purchaseCategoryID = *itemReq.PurchaseCategoryID
}
if itemReq.Quantity != nil {
quantity = *itemReq.Quantity
}
if itemReq.Amount != nil { if itemReq.Amount != nil {
amount = *itemReq.Amount amount = *itemReq.Amount
} }
if itemReq.Description != nil { description := itemReq.Description
description = itemReq.Description
category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i)
if err != nil {
return nil, err
} }
itemEntity := &entities.PurchaseOrderItem{ switch category.Type {
case entities.PurchaseCategoryTypeRawMaterial:
if ingredientID == nil {
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
}
if quantity == nil {
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
}
if unitID == nil {
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
}
_, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("ingredient not found: %w", err)
}
_, err = p.unitRepo.GetByID(ctx, *unitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found: %w", err)
}
case entities.PurchaseCategoryTypeExpense:
if ingredientID != nil || quantity != nil || unitID != nil {
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
}
default:
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
}
items[i] = &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID, PurchaseOrderID: poEntity.ID,
IngredientID: ingredientID, IngredientID: ingredientID,
PurchaseCategoryID: purchaseCategoryID, PurchaseCategoryID: purchaseCategoryID,
@@ -263,13 +277,20 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
UnitID: unitID, UnitID: unitID,
Amount: amount, Amount: amount,
} }
totalAmount += amount
}
// Delete and recreate only after all replacement items are valid.
err = p.purchaseOrderRepo.DeleteItemsByPurchaseOrderID(ctx, poEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to delete existing items: %w", err)
}
for _, itemEntity := range items {
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity) err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create purchase order item: %w", err) return nil, fmt.Errorf("failed to create purchase order item: %w", err)
} }
totalAmount += amount
} }
poEntity.TotalAmount = totalAmount poEntity.TotalAmount = totalAmount
@@ -398,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
} }
@@ -428,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,
@@ -441,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)
} }
} }
} }
@@ -461,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
} }
+67 -62
View File
@@ -124,7 +124,6 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz
} }
func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) {
var summary entities.PurchasingSummary
var outletName *string var outletName *string
if outletID != nil { if outletID != nil {
@@ -144,29 +143,32 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
outletName = &outlet.Name outletName = &outlet.Name
} }
} }
return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, outletName, dateFrom, dateTo, groupBy)
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) {
var summary entities.PurchasingSummary
summaryQuery := r.db.WithContext(ctx). summaryQuery := r.db.WithContext(ctx).
Table("inventory_movements im"). Table("purchase_orders po").
Select(` Select(`
COALESCE(SUM(im.total_cost), 0) as total_purchases, COALESCE(SUM(poi.amount), 0) as total_purchases,
COUNT(DISTINCT im.reference_id) as total_purchase_orders, COUNT(DISTINCT po.id) as total_purchase_orders,
COALESCE(SUM(im.quantity), 0) as total_quantity, COALESCE(SUM(poi.quantity), 0) as total_quantity,
CASE CASE
WHEN COUNT(DISTINCT im.reference_id) > 0 WHEN COUNT(DISTINCT po.id) > 0
THEN COALESCE(SUM(im.total_cost), 0) / COUNT(DISTINCT im.reference_id) THEN COALESCE(SUM(poi.amount), 0) / COUNT(DISTINCT po.id)
ELSE 0 ELSE 0
END as average_purchase_order_value, END as average_purchase_order_value,
COUNT(DISTINCT im.item_id) as total_ingredients, COUNT(DISTINCT i.id) as total_ingredients,
COUNT(DISTINCT po.vendor_id) as total_vendors COUNT(DISTINCT po.vendor_id) as total_vendors
`). `).
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id"). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Where("im.organization_id = ?", organizationID). Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("im.item_type = ?", "INGREDIENT"). Where("po.organization_id = ?", organizationID).
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). Where("po.status != ?", "cancelled").
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo) Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
summaryQuery = r.resolveOutletID(summaryQuery, outletID, "im.outlet_id")
if err := summaryQuery.Scan(&summary).Error; err != nil { if err := summaryQuery.Scan(&summary).Error; err != nil {
return nil, err return nil, err
@@ -175,36 +177,35 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
var dateFormat string var dateFormat string
switch groupBy { switch groupBy {
case "hour": case "hour":
dateFormat = "DATE_TRUNC('hour', im.created_at)" dateFormat = "DATE_TRUNC('hour', po.created_at)"
case "week": case "week":
dateFormat = "DATE_TRUNC('week', im.created_at)" dateFormat = "DATE_TRUNC('week', po.transaction_date::timestamp)"
case "month": case "month":
dateFormat = "DATE_TRUNC('month', im.created_at)" dateFormat = "DATE_TRUNC('month', po.transaction_date::timestamp)"
default: default:
dateFormat = "DATE_TRUNC('day', im.created_at)" dateFormat = "DATE_TRUNC('day', po.transaction_date::timestamp)"
} }
var data []entities.PurchasingAnalyticsData var data []entities.PurchasingAnalyticsData
dataQuery := r.db.WithContext(ctx). dataQuery := r.db.WithContext(ctx).
Table("inventory_movements im"). Table("purchase_orders po").
Select(` Select(`
`+dateFormat+` as date, `+dateFormat+` as date,
COALESCE(SUM(im.total_cost), 0) as purchases, COALESCE(SUM(poi.amount), 0) as purchases,
COUNT(DISTINCT im.reference_id) as purchase_orders, COUNT(DISTINCT po.id) as purchase_orders,
COALESCE(SUM(im.quantity), 0) as quantity, COALESCE(SUM(poi.quantity), 0) as quantity,
COUNT(DISTINCT im.item_id) as ingredients, COUNT(DISTINCT i.id) as ingredients,
COUNT(DISTINCT po.vendor_id) as vendors COUNT(DISTINCT po.vendor_id) as vendors
`). `).
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id"). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Where("im.organization_id = ?", organizationID). Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("im.item_type = ?", "INGREDIENT"). Where("po.organization_id = ?", organizationID).
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). Where("po.status != ?", "cancelled").
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat). Group(dateFormat).
Order(dateFormat) Order(dateFormat)
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID)
dataQuery = r.resolveOutletID(dataQuery, outletID, "im.outlet_id")
if err := dataQuery.Scan(&data).Error; err != nil { if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err return nil, err
@@ -212,29 +213,28 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
var ingredientData []entities.PurchasingIngredientData var ingredientData []entities.PurchasingIngredientData
ingredientQuery := r.db.WithContext(ctx). ingredientQuery := r.db.WithContext(ctx).
Table("inventory_movements im"). Table("purchase_order_items poi").
Select(` Select(`
i.id as ingredient_id, i.id as ingredient_id,
i.name as ingredient_name, i.name as ingredient_name,
COALESCE(SUM(im.quantity), 0) as quantity, COALESCE(SUM(poi.quantity), 0) as quantity,
COALESCE(SUM(im.total_cost), 0) as total_cost, COALESCE(SUM(poi.amount), 0) as total_cost,
CASE CASE
WHEN SUM(im.quantity) > 0 WHEN SUM(poi.quantity) > 0
THEN COALESCE(SUM(im.total_cost), 0) / SUM(im.quantity) THEN COALESCE(SUM(poi.amount), 0) / SUM(poi.quantity)
ELSE 0 ELSE 0
END as average_unit_cost, END as average_unit_cost,
COUNT(DISTINCT im.reference_id) as purchase_order_count COUNT(DISTINCT po.id) as purchase_order_count
`). `).
Joins("JOIN ingredients i ON im.item_id = i.id"). Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Where("im.organization_id = ?", organizationID). Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("im.item_type = ?", "INGREDIENT"). Where("po.organization_id = ?", organizationID).
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). Where("po.status != ?", "cancelled").
Where("im.created_at >= ? AND im.created_at <= ?", 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")
ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID)
ingredientQuery = r.resolveOutletID(ingredientQuery, outletID, "im.outlet_id")
if err := ingredientQuery.Scan(&ingredientData).Error; err != nil { if err := ingredientQuery.Scan(&ingredientData).Error; err != nil {
return nil, err return nil, err
@@ -242,26 +242,25 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
var vendorData []entities.PurchasingVendorData var vendorData []entities.PurchasingVendorData
vendorQuery := r.db.WithContext(ctx). vendorQuery := r.db.WithContext(ctx).
Table("inventory_movements im"). Table("purchase_orders po").
Select(` Select(`
v.id as vendor_id, v.id as vendor_id,
v.name as vendor_name, v.name as vendor_name,
COALESCE(SUM(im.total_cost), 0) as total_cost, COALESCE(SUM(poi.amount), 0) as total_cost,
COUNT(DISTINCT im.reference_id) as purchase_order_count, COUNT(DISTINCT po.id) as purchase_order_count,
COUNT(DISTINCT im.item_id) as ingredient_count, COUNT(DISTINCT i.id) as ingredient_count,
COALESCE(SUM(im.quantity), 0) as quantity COALESCE(SUM(poi.quantity), 0) as quantity
`). `).
Joins("JOIN purchase_orders po ON im.reference_id = po.id").
Joins("JOIN vendors v ON po.vendor_id = v.id"). Joins("JOIN vendors v ON po.vendor_id = v.id").
Where("im.organization_id = ?", organizationID). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Where("im.item_type = ?", "INGREDIENT"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). Where("po.organization_id = ?", organizationID).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). Where("po.status != ?", "cancelled").
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")
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
vendorQuery = r.resolveOutletID(vendorQuery, outletID, "im.outlet_id")
if err := vendorQuery.Scan(&vendorData).Error; err != nil { if err := vendorQuery.Scan(&vendorData).Error; err != nil {
return nil, err return nil, err
@@ -276,6 +275,13 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
}, nil }, nil
} }
func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB {
if outletID == nil {
return query
}
return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
}
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
var results []*entities.ProductAnalytics var results []*entities.ProductAnalytics
@@ -284,7 +290,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,
@@ -343,7 +348,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
+1 -1
View File
@@ -207,7 +207,7 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID
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 purchase_categories pc ON ei.purchase_category_id = pc.id").
Where("e.organization_id = ?", organizationID). Where("e.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeNonInventory). 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).
Group("pc.id, pc.name, pc.type"). Group("pc.id, pc.name, pc.type").
@@ -171,7 +171,11 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
data[i] = contract.PurchasingAnalyticsData{ data[i] = contract.PurchasingAnalyticsData{
Date: item.Date, Date: item.Date,
Purchases: item.Purchases, Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders, PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity, Quantity: item.Quantity,
Ingredients: item.Ingredients, Ingredients: item.Ingredients,
Vendors: item.Vendors, Vendors: item.Vendors,
@@ -211,7 +215,11 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
GroupBy: resp.GroupBy, GroupBy: resp.GroupBy,
Summary: contract.PurchasingSummary{ Summary: contract.PurchasingSummary{
TotalPurchases: resp.Summary.TotalPurchases, TotalPurchases: resp.Summary.TotalPurchases,
RawMaterialPurchases: resp.Summary.RawMaterialPurchases,
ExpensePurchases: resp.Summary.ExpensePurchases,
TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders, TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: resp.Summary.RawMaterialPurchaseOrders,
ExpenseCount: resp.Summary.ExpenseCount,
TotalQuantity: resp.Summary.TotalQuantity, TotalQuantity: resp.Summary.TotalQuantity,
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue, AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
TotalIngredients: resp.Summary.TotalIngredients, TotalIngredients: resp.Summary.TotalIngredients,
@@ -52,17 +52,47 @@ func TestPurchasingAnalyticsContractToModelIgnoresInvalidOutlet(t *testing.T) {
func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) { func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) {
outletID := uuid.New() outletID := uuid.New()
outletName := "Main Outlet" outletName := "Main Outlet"
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
OrganizationID: uuid.New(), OrganizationID: uuid.New(),
OutletID: &outletID, OutletID: &outletID,
OutletName: &outletName, OutletName: &outletName,
Summary: models.PurchasingSummary{
TotalPurchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
TotalPurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
Data: []models.PurchasingAnalyticsData{
{
Date: now,
Purchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
PurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
},
}) })
require.NotNil(t, result) require.NotNil(t, result)
require.Equal(t, &outletID, result.OutletID) require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, result.OutletName) require.NotNil(t, result.OutletName)
require.Equal(t, outletName, *result.OutletName) require.Equal(t, outletName, *result.OutletName)
require.Equal(t, float64(300), result.Summary.TotalPurchases)
require.Equal(t, float64(125), result.Summary.RawMaterialPurchases)
require.Equal(t, float64(175), result.Summary.ExpensePurchases)
require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders)
require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders)
require.Equal(t, int64(2), result.Summary.ExpenseCount)
require.Len(t, result.Data, 1)
require.Equal(t, float64(300), result.Data[0].Purchases)
require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases)
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
} }
func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
@@ -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,
}, },
}, },
@@ -34,7 +34,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateCreatePurchaseCategoryRequest(re
} }
if !isValidPurchaseCategoryType(req.Type) { if !isValidPurchaseCategoryType(req.Type) {
return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode
} }
if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 { if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 {
@@ -63,7 +63,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateUpdatePurchaseCategoryRequest(re
} }
if req.Type != nil && !isValidPurchaseCategoryType(*req.Type) { if req.Type != nil && !isValidPurchaseCategoryType(*req.Type) {
return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode
} }
if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 { if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 {
@@ -87,7 +87,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(re
} }
if req.Type != "" && !isValidPurchaseCategoryType(req.Type) { if req.Type != "" && !isValidPurchaseCategoryType(req.Type) {
return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode
} }
return nil, "" return nil, ""
@@ -95,7 +95,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(re
func isValidPurchaseCategoryType(categoryType string) bool { func isValidPurchaseCategoryType(categoryType string) bool {
switch categoryType { switch categoryType {
case "raw_material", "non_inventory": case "raw_material", "expense":
return true return true
default: default:
return false return false
+15 -7
View File
@@ -181,20 +181,20 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
} }
func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) { func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) {
if item.IngredientID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode
}
if item.PurchaseCategoryID == uuid.Nil { if item.PurchaseCategoryID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
} }
if item.Quantity <= 0 { if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
}
if item.Quantity != nil && *item.Quantity <= 0 {
return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
} }
if item.UnitID == uuid.Nil { if item.UnitID != nil && *item.UnitID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
} }
if item.Amount < 0 { if item.Amount < 0 {
@@ -209,6 +209,14 @@ 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 {
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
}
if item.UnitID != nil && *item.UnitID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
}
if item.Quantity != nil && *item.Quantity <= 0 { 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,16 +11,20 @@ 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,
}, },
}, },
@@ -3,7 +3,7 @@ CREATE TABLE purchase_category_presets (
parent_id UUID REFERENCES purchase_category_presets(id) ON DELETE SET NULL, parent_id UUID REFERENCES purchase_category_presets(id) ON DELETE SET NULL,
code VARCHAR(100) NOT NULL UNIQUE, code VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'non_inventory')), type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'expense')),
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true, is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
@@ -17,7 +17,7 @@ CREATE TABLE purchase_categories (
parent_id UUID REFERENCES purchase_categories(id) ON DELETE SET NULL, parent_id UUID REFERENCES purchase_categories(id) ON DELETE SET NULL,
code VARCHAR(100) NOT NULL, code VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'non_inventory')), type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'expense')),
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
is_system BOOLEAN NOT NULL DEFAULT false, is_system BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true, is_active BOOLEAN NOT NULL DEFAULT true,
@@ -38,8 +38,8 @@ CREATE INDEX idx_purchase_categories_is_active ON purchase_categories(is_active)
INSERT INTO purchase_category_presets (code, name, type, sort_order) INSERT INTO purchase_category_presets (code, name, type, sort_order)
VALUES VALUES
('hpp', 'HPP', 'raw_material', 1), ('bahan_baku', 'Bahan Baku', 'raw_material', 1),
('biaya_lain_lain', 'Biaya Lain-lain', 'non_inventory', 2) ('biaya_lain_lain', 'Biaya Lain-lain', 'expense', 2)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
INSERT INTO purchase_category_presets (parent_id, code, name, type, sort_order) INSERT INTO purchase_category_presets (parent_id, code, name, type, sort_order)
@@ -47,18 +47,14 @@ SELECT parent.id, child.code, child.name, child.type, child.sort_order
FROM purchase_category_presets parent FROM purchase_category_presets parent
JOIN ( JOIN (
VALUES VALUES
('hpp', 'hpp_bakso_mie_ayam', 'Bakso & Mie Ayam', 'raw_material', 1), ('biaya_lain_lain', 'biaya_atk_perlengkapan', 'ATK & Perlengkapan', 'expense', 1),
('hpp', 'hpp_nusantara', 'Nusantara', 'raw_material', 2), ('biaya_lain_lain', 'biaya_makan_karyawan', 'Makan Karyawan', 'expense', 2),
('hpp', 'hpp_ramen', 'Ramen', 'raw_material', 3), ('biaya_lain_lain', 'biaya_bensin_parkir', 'Bensin & Parkir', 'expense', 3),
('hpp', 'hpp_minuman_kopi', 'Minuman/Kopi', 'raw_material', 4), ('biaya_lain_lain', 'biaya_kebersihan_keamanan', 'Kebersihan & Keamanan', 'expense', 4),
('biaya_lain_lain', 'biaya_atk_perlengkapan', 'ATK & Perlengkapan', 'non_inventory', 1), ('biaya_lain_lain', 'biaya_gaji_dw', 'Gaji DW', 'expense', 5),
('biaya_lain_lain', 'biaya_makan_karyawan', 'Makan Karyawan', 'non_inventory', 2), ('biaya_lain_lain', 'biaya_gaji_staff', 'Gaji Staff', 'expense', 6),
('biaya_lain_lain', 'biaya_bensin_parkir', 'Bensin & Parkir', 'non_inventory', 3), ('biaya_lain_lain', 'biaya_internet_server', 'Internet & Server', 'expense', 7),
('biaya_lain_lain', 'biaya_kebersihan_keamanan', 'Kebersihan & Keamanan', 'non_inventory', 4), ('biaya_lain_lain', 'biaya_air_listrik', 'Air & Listrik', 'expense', 8),
('biaya_lain_lain', 'biaya_gaji_dw', 'Gaji DW', 'non_inventory', 5), ('biaya_lain_lain', 'biaya_promosi', 'Promosi', 'expense', 9)
('biaya_lain_lain', 'biaya_gaji_staff', 'Gaji Staff', 'non_inventory', 6),
('biaya_lain_lain', 'biaya_internet_server', 'Internet & Server', 'non_inventory', 7),
('biaya_lain_lain', 'biaya_air_listrik', 'Air & Listrik', 'non_inventory', 8),
('biaya_lain_lain', 'biaya_promosi', 'Promosi', 'non_inventory', 9)
) AS child(parent_code, code, name, type, sort_order) ON parent.code = child.parent_code ) AS child(parent_code, code, name, type, sort_order) ON parent.code = child.parent_code
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
@@ -0,0 +1,32 @@
ALTER TABLE purchase_category_presets DROP CONSTRAINT IF EXISTS purchase_category_presets_type_check;
ALTER TABLE purchase_categories DROP CONSTRAINT IF EXISTS purchase_categories_type_check;
UPDATE purchase_category_presets
SET type = 'non_inventory'
WHERE type = 'expense';
UPDATE purchase_categories
SET type = 'non_inventory'
WHERE type = 'expense';
UPDATE purchase_category_presets
SET code = 'hpp', name = 'HPP'
WHERE code = 'bahan_baku' AND type = 'raw_material';
UPDATE purchase_categories
SET code = 'hpp', name = 'HPP'
WHERE code = 'bahan_baku' AND type = 'raw_material';
UPDATE purchase_category_presets
SET is_active = true
WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material';
UPDATE purchase_categories
SET is_active = true
WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material';
ALTER TABLE purchase_category_presets
ADD CONSTRAINT purchase_category_presets_type_check CHECK (type IN ('raw_material', 'non_inventory'));
ALTER TABLE purchase_categories
ADD CONSTRAINT purchase_categories_type_check CHECK (type IN ('raw_material', 'non_inventory'));
@@ -0,0 +1,36 @@
ALTER TABLE purchase_category_presets DROP CONSTRAINT IF EXISTS purchase_category_presets_type_check;
ALTER TABLE purchase_categories DROP CONSTRAINT IF EXISTS purchase_categories_type_check;
UPDATE purchase_category_presets
SET type = 'expense'
WHERE type = 'non_inventory';
UPDATE purchase_categories
SET type = 'expense'
WHERE type = 'non_inventory';
UPDATE purchase_category_presets
SET code = 'bahan_baku', name = 'Bahan Baku'
WHERE code = 'hpp' AND type = 'raw_material';
UPDATE purchase_categories
SET code = 'bahan_baku', name = 'Bahan Baku'
WHERE code = 'hpp' AND type = 'raw_material';
UPDATE purchase_category_presets
SET is_active = false
WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material';
UPDATE purchase_categories
SET is_active = false
WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material';
ALTER TABLE purchase_category_presets
ADD CONSTRAINT purchase_category_presets_type_check CHECK (type IN ('raw_material', 'expense'));
ALTER TABLE purchase_categories
ADD CONSTRAINT purchase_categories_type_check CHECK (type IN ('raw_material', 'expense'));
ALTER TABLE purchase_order_items ALTER COLUMN ingredient_id DROP NOT NULL;
ALTER TABLE purchase_order_items ALTER COLUMN quantity DROP NOT NULL;
ALTER TABLE purchase_order_items ALTER COLUMN unit_id DROP NOT NULL;