diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index a5a0b75..786a90d 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -107,10 +107,10 @@ type PurchasingAnalyticsResponse struct { type PurchasingSummary struct { TotalPurchases float64 `json:"total_purchases"` RawMaterialPurchases float64 `json:"raw_material_purchases"` - NonInventoryPurchases float64 `json:"non_inventory_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` TotalPurchaseOrders int64 `json:"total_purchase_orders"` RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` - NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + ExpenseCount int64 `json:"expense_count"` TotalQuantity float64 `json:"total_quantity"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` @@ -121,10 +121,10 @@ type PurchasingAnalyticsData struct { Date time.Time `json:"date"` Purchases float64 `json:"purchases"` RawMaterialPurchases float64 `json:"raw_material_purchases"` - NonInventoryPurchases float64 `json:"non_inventory_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` PurchaseOrders int64 `json:"purchase_orders"` RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` - NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + ExpenseCount int64 `json:"expense_count"` Quantity float64 `json:"quantity"` Ingredients int64 `json:"ingredients"` Vendors int64 `json:"vendors"` diff --git a/internal/contract/purchase_category_contract.go b/internal/contract/purchase_category_contract.go index 8aed0ef..817bb42 100644 --- a/internal/contract/purchase_category_contract.go +++ b/internal/contract/purchase_category_contract.go @@ -10,7 +10,7 @@ type CreatePurchaseCategoryRequest struct { ParentID *uuid.UUID `json:"parent_id,omitempty"` Code *string `json:"code,omitempty"` 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"` IsActive *bool `json:"is_active,omitempty"` } @@ -19,14 +19,14 @@ type UpdatePurchaseCategoryRequest struct { ParentID *uuid.UUID `json:"parent_id,omitempty"` Code *string `json:"code,omitempty"` 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"` IsActive *bool `json:"is_active,omitempty"` } type ListPurchaseCategoriesRequest struct { 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"` IsActive *bool `json:"is_active,omitempty"` Page int `json:"page" validate:"required,min=1"` diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 70e722c..907316a 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -19,12 +19,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"` - Description *string `json:"description,omitempty" validate:"omitempty"` - Quantity float64 `json:"quantity" validate:"required,gt=0"` - UnitID uuid.UUID `json:"unit_id" validate:"required"` - Amount float64 `json:"amount" validate:"required,gte=0"` + IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"` + Description *string `json:"description,omitempty" validate:"omitempty"` + Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"` + UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"` + Amount float64 `json:"amount" validate:"required,gte=0"` } type UpdatePurchaseOrderRequest struct { @@ -70,11 +70,11 @@ type PurchaseOrderResponse struct { type PurchaseOrderItemResponse struct { ID uuid.UUID `json:"id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID uuid.UUID `json:"ingredient_id"` + IngredientID *uuid.UUID `json:"ingredient_id"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` Description *string `json:"description"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` + Quantity *float64 `json:"quantity"` + UnitID *uuid.UUID `json:"unit_id"` Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index b9f74c8..66b37d0 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -39,10 +39,10 @@ type PurchasingAnalytics struct { type PurchasingSummary struct { TotalPurchases float64 `json:"total_purchases"` RawMaterialPurchases float64 `json:"raw_material_purchases"` - NonInventoryPurchases float64 `json:"non_inventory_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` TotalPurchaseOrders int64 `json:"total_purchase_orders"` RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` - NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + ExpenseCount int64 `json:"expense_count"` TotalQuantity float64 `json:"total_quantity"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` @@ -53,10 +53,10 @@ type PurchasingAnalyticsData struct { Date time.Time `json:"date"` Purchases float64 `json:"purchases"` RawMaterialPurchases float64 `json:"raw_material_purchases"` - NonInventoryPurchases float64 `json:"non_inventory_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` PurchaseOrders int64 `json:"purchase_orders"` RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` - NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + ExpenseCount int64 `json:"expense_count"` Quantity float64 `json:"quantity"` Ingredients int64 `json:"ingredients"` Vendors int64 `json:"vendors"` diff --git a/internal/entities/purchase_category.go b/internal/entities/purchase_category.go index 34c5667..31c1718 100644 --- a/internal/entities/purchase_category.go +++ b/internal/entities/purchase_category.go @@ -10,8 +10,8 @@ import ( type PurchaseCategoryType string const ( - PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material" - PurchaseCategoryTypeNonInventory PurchaseCategoryType = "non_inventory" + PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material" + PurchaseCategoryTypeExpense PurchaseCategoryType = "expense" ) type PurchaseCategoryPreset struct { diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index 3a455db..b98006a 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -41,16 +41,16 @@ func (PurchaseOrder) TableName() string { } type PurchaseOrderItem struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` - IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"` - PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"` - Description *string `gorm:"type:text" json:"description" validate:"omitempty"` - Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"` - UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"` - Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` + IngredientID *uuid.UUID `gorm:"type:uuid" json:"ingredient_id" validate:"omitempty"` + PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"` + Description *string `gorm:"type:text" json:"description" validate:"omitempty"` + Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"` + UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" validate:"omitempty"` + Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"` Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 0b75cb2..e72e3e0 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -114,10 +114,10 @@ type PurchasingAnalyticsResponse struct { type PurchasingSummary struct { TotalPurchases float64 `json:"total_purchases"` RawMaterialPurchases float64 `json:"raw_material_purchases"` - NonInventoryPurchases float64 `json:"non_inventory_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` TotalPurchaseOrders int64 `json:"total_purchase_orders"` RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` - NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + ExpenseCount int64 `json:"expense_count"` TotalQuantity float64 `json:"total_quantity"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` @@ -129,10 +129,10 @@ type PurchasingAnalyticsData struct { Date time.Time `json:"date"` Purchases float64 `json:"purchases"` RawMaterialPurchases float64 `json:"raw_material_purchases"` - NonInventoryPurchases float64 `json:"non_inventory_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` PurchaseOrders int64 `json:"purchase_orders"` RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"` - NonInventoryExpenseCount int64 `json:"non_inventory_expense_count"` + ExpenseCount int64 `json:"expense_count"` Quantity float64 `json:"quantity"` Ingredients int64 `json:"ingredients"` Vendors int64 `json:"vendors"` diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 1afa23f..562271e 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -22,16 +22,16 @@ type PurchaseOrder struct { } type PurchaseOrderItem struct { - ID uuid.UUID `json:"id"` - PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID uuid.UUID `json:"ingredient_id"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` - Description *string `json:"description"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + PurchaseOrderID uuid.UUID `json:"purchase_order_id"` + IngredientID *uuid.UUID `json:"ingredient_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description"` + Quantity *float64 `json:"quantity"` + UnitID *uuid.UUID `json:"unit_id"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PurchaseOrderAttachment struct { @@ -62,11 +62,11 @@ type PurchaseOrderResponse struct { type PurchaseOrderItemResponse struct { ID uuid.UUID `json:"id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID uuid.UUID `json:"ingredient_id"` + IngredientID *uuid.UUID `json:"ingredient_id"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` Description *string `json:"description"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` + Quantity *float64 `json:"quantity"` + UnitID *uuid.UUID `json:"unit_id"` Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -96,12 +96,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - IngredientID uuid.UUID `json:"ingredient_id"` - PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` - Description *string `json:"description,omitempty"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` - Amount float64 `json:"amount"` + IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description,omitempty"` + Quantity *float64 `json:"quantity,omitempty"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + Amount float64 `json:"amount"` } type UpdatePurchaseOrderRequest struct { diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 4eccc2c..895f3b0 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -188,10 +188,10 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req Date: item.Date, Purchases: item.Purchases, RawMaterialPurchases: item.RawMaterialPurchases, - NonInventoryPurchases: item.NonInventoryPurchases, + ExpensePurchases: item.ExpensePurchases, PurchaseOrders: item.PurchaseOrders, RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders, - NonInventoryExpenseCount: item.NonInventoryExpenseCount, + ExpenseCount: item.ExpenseCount, Quantity: item.Quantity, Ingredients: item.Ingredients, Vendors: item.Vendors, @@ -232,10 +232,10 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req Summary: models.PurchasingSummary{ TotalPurchases: result.Summary.TotalPurchases, RawMaterialPurchases: result.Summary.RawMaterialPurchases, - NonInventoryPurchases: result.Summary.NonInventoryPurchases, + ExpensePurchases: result.Summary.ExpensePurchases, TotalPurchaseOrders: result.Summary.TotalPurchaseOrders, RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders, - NonInventoryExpenseCount: result.Summary.NonInventoryExpenseCount, + ExpenseCount: result.Summary.ExpenseCount, TotalQuantity: result.Summary.TotalQuantity, AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, TotalIngredients: result.Summary.TotalIngredients, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index e088b2e..b50c462 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -77,20 +77,20 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) Summary: entities.PurchasingSummary{ TotalPurchases: 300, RawMaterialPurchases: 125, - NonInventoryPurchases: 175, + ExpensePurchases: 175, TotalPurchaseOrders: 3, RawMaterialPurchaseOrders: 1, - NonInventoryExpenseCount: 2, + ExpenseCount: 2, }, Data: []entities.PurchasingAnalyticsData{ { Date: now, Purchases: 300, RawMaterialPurchases: 125, - NonInventoryPurchases: 175, + ExpensePurchases: 175, PurchaseOrders: 3, RawMaterialPurchaseOrders: 1, - NonInventoryExpenseCount: 2, + ExpenseCount: 2, }, }, }, @@ -110,14 +110,14 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) 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.NonInventoryPurchases) + 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.NonInventoryExpenseCount) + 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].NonInventoryPurchases) + require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 0b8ab86..5419b1c 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -61,7 +61,7 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID if err != nil { 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 } @@ -169,7 +169,7 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati if err != nil { 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 } @@ -334,7 +334,7 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod }, 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) if err != nil { 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") } - if category.Type != entities.PurchaseCategoryTypeNonInventory { - return fmt.Errorf("purchase category must be non_inventory") + if category.Type != entities.PurchaseCategoryTypeExpense { + return fmt.Errorf("purchase category must be expense") } return nil diff --git a/internal/processor/expense_processor_test.go b/internal/processor/expense_processor_test.go index e0a4435..ba0fb3e 100644 --- a/internal/processor/expense_processor_test.go +++ b/internal/processor/expense_processor_test.go @@ -102,7 +102,7 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { repo := &expenseRepositoryCaptureStub{} purchaseCategoryID := uuid.New() - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) chartOfAccountID := uuid.New() resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -133,7 +133,7 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { repo := &expenseRepositoryCaptureStub{} 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{ Receiver: "Cashier", @@ -160,7 +160,7 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { repo := &expenseRepositoryCaptureStub{} purchaseCategoryID := uuid.New() - p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) status := "approved" resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -209,7 +209,7 @@ func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) require.Error(t, err) require.Nil(t, resp) - require.Contains(t, err.Error(), "non_inventory") + require.Contains(t, err.Error(), "expense") } func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) { @@ -241,7 +241,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te { PurchaseCategoryID: purchaseCategoryID, PurchaseCategoryName: "Operational Supplies", - PurchaseCategoryType: "non_inventory", + PurchaseCategoryType: "expense", TotalAmount: 100000, ExpenseCount: 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{ OrganizationID: uuid.New(), diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index b666d9b..7d87e90 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -67,20 +67,40 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber) } - // Validate ingredients, raw-material categories, and units exist + // Validate categories and inventory fields per item type. 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, fmt.Errorf("ingredient not found for item %d: %w", i, err) - } - - if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil { return nil, err } - _, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID) - if err != nil { - return nil, fmt.Errorf("unit not found for item %d: %w", i, err) + 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 { + return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) + } + + _, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID) + if err != nil { + return nil, fmt.Errorf("unit not found for item %d: %w", i, err) + } + case entities.PurchaseCategoryTypeExpense: + if item.IngredientID != nil || item.Quantity != nil || item.UnitID != nil { + return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i) + } + default: + return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type) } } @@ -197,64 +217,58 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id // Update items if provided 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 + items := make([]*entities.PurchaseOrderItem, len(req.Items)) for i, itemReq := range req.Items { - // Validate ingredients and units exist - if itemReq.IngredientID != nil { - _, err := p.ingredientRepo.GetByID(ctx, *itemReq.IngredientID, organizationID) - if err != nil { - return nil, fmt.Errorf("ingredient not found: %w", err) - } + if itemReq.PurchaseCategoryID == nil { + return nil, fmt.Errorf("purchase_category_id is required for item %d", i) } - if itemReq.UnitID != nil { - _, err := p.unitRepo.GetByID(ctx, *itemReq.UnitID, organizationID) - if err != nil { - return nil, fmt.Errorf("unit not found: %w", err) - } - } - - 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 - } + ingredientID := itemReq.IngredientID + purchaseCategoryID := *itemReq.PurchaseCategoryID + unitID := itemReq.UnitID + quantity := itemReq.Quantity + amount := 0.0 if itemReq.Amount != nil { 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, IngredientID: ingredientID, PurchaseCategoryID: purchaseCategoryID, @@ -263,13 +277,20 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id UnitID: unitID, 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) if err != nil { return nil, fmt.Errorf("failed to create purchase order item: %w", err) } - - totalAmount += amount } poEntity.TotalAmount = totalAmount @@ -398,19 +419,27 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte // Update inventory for each item for _, item := range poWithItems.Items { + if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense { + continue + } + + if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil { + return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID) + } + // Get ingredient to find its base unit - ingredient, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) + ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID) if err != nil { - return nil, fmt.Errorf("failed to get ingredient %s: %w", item.IngredientID, err) + return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err) } // Convert quantity to ingredient's base unit if needed - quantityToAdd := item.Quantity - if item.UnitID != ingredient.UnitID { + quantityToAdd := *item.Quantity + if *item.UnitID != ingredient.UnitID { // Convert from purchase unit to ingredient's base unit - convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, item.IngredientID, item.UnitID, ingredient.UnitID, organizationID, item.Quantity) + convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity) if err != nil { - return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", item.IngredientID, item.UnitID, ingredient.UnitID, err) + return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err) } quantityToAdd = convertedQuantity } @@ -428,7 +457,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte err = p.inventoryMovementService.CreateIngredientMovement( ctx, - item.IngredientID, + *item.IngredientID, organizationID, outletID, userID, @@ -441,7 +470,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte &item.ID, ) if err != nil { - return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err) + return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err) } } } @@ -461,19 +490,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return mappers.PurchaseOrderEntityToResponse(updatedPO), nil } -func (p *PurchaseOrderProcessorImpl) validateRawMaterialPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) error { +func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) { category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) if err != nil { - return fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err) + return nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err) } if !category.IsActive { - return fmt.Errorf("purchase category for item %d is inactive", itemIndex) + return nil, fmt.Errorf("purchase category for item %d is inactive", itemIndex) } - if category.Type != entities.PurchaseCategoryTypeRawMaterial { - return fmt.Errorf("purchase category for item %d must be raw_material", itemIndex) + if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense { + return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex) } - return nil + return category, nil } diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 4677b31..1a6852a 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -146,7 +146,7 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or } rawMaterialOutletFilter := "" - nonInventoryOutletFilter := "" + expenseOutletFilter := "" rawMaterialSummaryArgs := []interface{}{ organizationID, entities.InventoryMovementTypePurchase, @@ -155,20 +155,20 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or dateFrom, dateTo, } - nonInventorySummaryArgs := []interface{}{ + expenseSummaryArgs := []interface{}{ organizationID, - entities.PurchaseCategoryTypeNonInventory, + entities.PurchaseCategoryTypeExpense, "approved", dateFrom, dateTo, } if outletID != nil { rawMaterialOutletFilter = "AND im.outlet_id = ?" - nonInventoryOutletFilter = "AND e.outlet_id = ?" + expenseOutletFilter = "AND e.outlet_id = ?" rawMaterialSummaryArgs = append(rawMaterialSummaryArgs, *outletID) - nonInventorySummaryArgs = append(nonInventorySummaryArgs, *outletID) + expenseSummaryArgs = append(expenseSummaryArgs, *outletID) } - summaryArgs := append(rawMaterialSummaryArgs, nonInventorySummaryArgs...) + summaryArgs := append(rawMaterialSummaryArgs, expenseSummaryArgs...) summaryQuery := ` WITH raw_material AS ( @@ -187,10 +187,10 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or AND im.created_at >= ? AND im.created_at <= ? ` + rawMaterialOutletFilter + ` ), - non_inventory AS ( + expense AS ( SELECT - COALESCE(SUM(ei.amount), 0) as non_inventory_purchases, - COUNT(DISTINCT e.id) as non_inventory_expense_count + COALESCE(SUM(ei.amount), 0) as expense_purchases, + COUNT(DISTINCT e.id) as expense_count FROM expense_items ei JOIN expenses e ON ei.expense_id = e.id JOIN purchase_categories pc ON ei.purchase_category_id = pc.id @@ -198,25 +198,25 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or AND pc.type = ? AND e.status = ? AND e.transaction_date >= ? AND e.transaction_date <= ? - ` + nonInventoryOutletFilter + ` + ` + expenseOutletFilter + ` ) SELECT - rm.raw_material_purchases + ni.non_inventory_purchases as total_purchases, + rm.raw_material_purchases + ex.expense_purchases as total_purchases, rm.raw_material_purchases, - ni.non_inventory_purchases, - rm.raw_material_purchase_orders + ni.non_inventory_expense_count as total_purchase_orders, + ex.expense_purchases, + rm.raw_material_purchase_orders + ex.expense_count as total_purchase_orders, rm.raw_material_purchase_orders, - ni.non_inventory_expense_count, + ex.expense_count, rm.total_quantity, CASE - WHEN rm.raw_material_purchase_orders + ni.non_inventory_expense_count > 0 - THEN (rm.raw_material_purchases + ni.non_inventory_purchases) / (rm.raw_material_purchase_orders + ni.non_inventory_expense_count) + WHEN rm.raw_material_purchase_orders + ex.expense_count > 0 + THEN (rm.raw_material_purchases + ex.expense_purchases) / (rm.raw_material_purchase_orders + ex.expense_count) ELSE 0 END as average_purchase_order_value, rm.total_ingredients, rm.total_vendors FROM raw_material rm - CROSS JOIN non_inventory ni + CROSS JOIN expense ex ` if err := r.db.WithContext(ctx).Raw(summaryQuery, summaryArgs...).Scan(&summary).Error; err != nil { @@ -235,14 +235,14 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or dateFormat = "DATE_TRUNC('day', im.created_at)::timestamp" } - nonInventoryDateFormat := "DATE_TRUNC('day', e.transaction_date)::timestamp" + expenseDateFormat := "DATE_TRUNC('day', e.transaction_date)::timestamp" switch groupBy { case "hour": - nonInventoryDateFormat = "DATE_TRUNC('hour', e.transaction_date)::timestamp" + expenseDateFormat = "DATE_TRUNC('hour', e.transaction_date)::timestamp" case "week": - nonInventoryDateFormat = "DATE_TRUNC('week', e.transaction_date)::timestamp" + expenseDateFormat = "DATE_TRUNC('week', e.transaction_date)::timestamp" case "month": - nonInventoryDateFormat = "DATE_TRUNC('month', e.transaction_date)::timestamp" + expenseDateFormat = "DATE_TRUNC('month', e.transaction_date)::timestamp" } rawMaterialDataArgs := []interface{}{ @@ -253,18 +253,18 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or dateFrom, dateTo, } - nonInventoryDataArgs := []interface{}{ + expenseDataArgs := []interface{}{ organizationID, - entities.PurchaseCategoryTypeNonInventory, + entities.PurchaseCategoryTypeExpense, "approved", dateFrom, dateTo, } if outletID != nil { rawMaterialDataArgs = append(rawMaterialDataArgs, *outletID) - nonInventoryDataArgs = append(nonInventoryDataArgs, *outletID) + expenseDataArgs = append(expenseDataArgs, *outletID) } - dataArgs := append(rawMaterialDataArgs, nonInventoryDataArgs...) + dataArgs := append(rawMaterialDataArgs, expenseDataArgs...) var data []entities.PurchasingAnalyticsData dataQuery := ` @@ -286,11 +286,11 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or ` + rawMaterialOutletFilter + ` GROUP BY 1 ), - non_inventory AS ( + expense AS ( SELECT - ` + nonInventoryDateFormat + ` as date, - COALESCE(SUM(ei.amount), 0) as non_inventory_purchases, - COUNT(DISTINCT e.id) as non_inventory_expense_count + ` + expenseDateFormat + ` as date, + COALESCE(SUM(ei.amount), 0) as expense_purchases, + COUNT(DISTINCT e.id) as expense_count FROM expense_items ei JOIN expenses e ON ei.expense_id = e.id JOIN purchase_categories pc ON ei.purchase_category_id = pc.id @@ -298,22 +298,22 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or AND pc.type = ? AND e.status = ? AND e.transaction_date >= ? AND e.transaction_date <= ? - ` + nonInventoryOutletFilter + ` + ` + expenseOutletFilter + ` GROUP BY 1 ) SELECT - COALESCE(rm.date, ni.date) as date, - COALESCE(rm.raw_material_purchases, 0) + COALESCE(ni.non_inventory_purchases, 0) as purchases, + COALESCE(rm.date, ex.date) as date, + COALESCE(rm.raw_material_purchases, 0) + COALESCE(ex.expense_purchases, 0) as purchases, COALESCE(rm.raw_material_purchases, 0) as raw_material_purchases, - COALESCE(ni.non_inventory_purchases, 0) as non_inventory_purchases, - COALESCE(rm.raw_material_purchase_orders, 0) + COALESCE(ni.non_inventory_expense_count, 0) as purchase_orders, + COALESCE(ex.expense_purchases, 0) as expense_purchases, + COALESCE(rm.raw_material_purchase_orders, 0) + COALESCE(ex.expense_count, 0) as purchase_orders, COALESCE(rm.raw_material_purchase_orders, 0) as raw_material_purchase_orders, - COALESCE(ni.non_inventory_expense_count, 0) as non_inventory_expense_count, + COALESCE(ex.expense_count, 0) as expense_count, COALESCE(rm.quantity, 0) as quantity, COALESCE(rm.ingredients, 0) as ingredients, COALESCE(rm.vendors, 0) as vendors FROM raw_material rm - FULL OUTER JOIN non_inventory ni ON rm.date = ni.date + FULL OUTER JOIN expense ex ON rm.date = ex.date ORDER BY date ` diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index bf557af..8698db2 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -207,7 +207,7 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID Joins("JOIN expenses e ON ei.expense_id = e.id"). Joins("JOIN purchase_categories pc ON ei.purchase_category_id = pc.id"). Where("e.organization_id = ?", organizationID). - Where("pc.type = ?", entities.PurchaseCategoryTypeNonInventory). + Where("pc.type = ?", entities.PurchaseCategoryTypeExpense). Where("e.status = ?", "approved"). Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo). Group("pc.id, pc.name, pc.type"). diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 93f3967..fa3c42d 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -172,10 +172,10 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse Date: item.Date, Purchases: item.Purchases, RawMaterialPurchases: item.RawMaterialPurchases, - NonInventoryPurchases: item.NonInventoryPurchases, + ExpensePurchases: item.ExpensePurchases, PurchaseOrders: item.PurchaseOrders, RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders, - NonInventoryExpenseCount: item.NonInventoryExpenseCount, + ExpenseCount: item.ExpenseCount, Quantity: item.Quantity, Ingredients: item.Ingredients, Vendors: item.Vendors, @@ -216,10 +216,10 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse Summary: contract.PurchasingSummary{ TotalPurchases: resp.Summary.TotalPurchases, RawMaterialPurchases: resp.Summary.RawMaterialPurchases, - NonInventoryPurchases: resp.Summary.NonInventoryPurchases, + ExpensePurchases: resp.Summary.ExpensePurchases, TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders, RawMaterialPurchaseOrders: resp.Summary.RawMaterialPurchaseOrders, - NonInventoryExpenseCount: resp.Summary.NonInventoryExpenseCount, + ExpenseCount: resp.Summary.ExpenseCount, TotalQuantity: resp.Summary.TotalQuantity, AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue, TotalIngredients: resp.Summary.TotalIngredients, diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 45775ff..4fca5cf 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -61,20 +61,20 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) { Summary: models.PurchasingSummary{ TotalPurchases: 300, RawMaterialPurchases: 125, - NonInventoryPurchases: 175, + ExpensePurchases: 175, TotalPurchaseOrders: 3, RawMaterialPurchaseOrders: 1, - NonInventoryExpenseCount: 2, + ExpenseCount: 2, }, Data: []models.PurchasingAnalyticsData{ { Date: now, Purchases: 300, RawMaterialPurchases: 125, - NonInventoryPurchases: 175, + ExpensePurchases: 175, PurchaseOrders: 3, RawMaterialPurchaseOrders: 1, - NonInventoryExpenseCount: 2, + ExpenseCount: 2, }, }, }) @@ -85,14 +85,14 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) { 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.NonInventoryPurchases) + 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.NonInventoryExpenseCount) + 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].NonInventoryPurchases) + require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { diff --git a/internal/transformer/purchase_order_transformer_test.go b/internal/transformer/purchase_order_transformer_test.go index 24aea4c..4f481a6 100644 --- a/internal/transformer/purchase_order_transformer_test.go +++ b/internal/transformer/purchase_order_transformer_test.go @@ -12,15 +12,19 @@ import ( ) func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) { + ingredientID := uuid.New() + quantity := 1.0 + unitID := uuid.New() + result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{ VendorID: uuid.New(), PONumber: "PO-001", TransactionDate: "2026-05-29", Items: []contract.CreatePurchaseOrderItemRequest{ { - IngredientID: uuid.New(), - Quantity: 1, - UnitID: uuid.New(), + IngredientID: &ingredientID, + Quantity: &quantity, + UnitID: &unitID, Amount: 1000, }, }, diff --git a/internal/validator/purchase_category_validator.go b/internal/validator/purchase_category_validator.go index 10b1f5c..e69376f 100644 --- a/internal/validator/purchase_category_validator.go +++ b/internal/validator/purchase_category_validator.go @@ -34,7 +34,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateCreatePurchaseCategoryRequest(re } 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 { @@ -63,7 +63,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateUpdatePurchaseCategoryRequest(re } 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 { @@ -87,7 +87,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(re } 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, "" @@ -95,7 +95,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(re func isValidPurchaseCategoryType(categoryType string) bool { switch categoryType { - case "raw_material", "non_inventory": + case "raw_material", "expense": return true default: return false diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index b6c3f07..1b67023 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -181,20 +181,20 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont } func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) { - if item.IngredientID == uuid.Nil { - return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode - } - if item.PurchaseCategoryID == uuid.Nil { return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode } - if item.Quantity <= 0 { + if item.IngredientID != nil && *item.IngredientID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode + } + + if item.Quantity != nil && *item.Quantity <= 0 { return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } - if item.UnitID == uuid.Nil { - return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode + if item.UnitID != nil && *item.UnitID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode } if item.Amount < 0 { @@ -209,6 +209,14 @@ func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contr return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode } + if item.IngredientID != nil && *item.IngredientID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode + } + + if item.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 { return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index 1fde2f9..4c37b90 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -11,16 +11,20 @@ import ( ) func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest { + ingredientID := uuid.New() + quantity := 1.0 + unitID := uuid.New() + return &contract.CreatePurchaseOrderRequest{ VendorID: uuid.New(), PONumber: "PO-001", TransactionDate: "2026-05-29", Items: []contract.CreatePurchaseOrderItemRequest{ { - IngredientID: uuid.New(), + IngredientID: &ingredientID, PurchaseCategoryID: uuid.New(), - Quantity: 1, - UnitID: uuid.New(), + Quantity: &quantity, + UnitID: &unitID, Amount: 1000, }, }, diff --git a/migrations/000075_create_purchase_categories.up.sql b/migrations/000075_create_purchase_categories.up.sql index 2f78bc6..0b419a3 100644 --- a/migrations/000075_create_purchase_categories.up.sql +++ b/migrations/000075_create_purchase_categories.up.sql @@ -3,7 +3,7 @@ CREATE TABLE purchase_category_presets ( parent_id UUID REFERENCES purchase_category_presets(id) ON DELETE SET NULL, code VARCHAR(100) NOT NULL UNIQUE, 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, is_active BOOLEAN NOT NULL DEFAULT true, 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, code VARCHAR(100) 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, is_system BOOLEAN NOT NULL DEFAULT false, 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) VALUES - ('hpp', 'HPP', 'raw_material', 1), - ('biaya_lain_lain', 'Biaya Lain-lain', 'non_inventory', 2) + ('bahan_baku', 'Bahan Baku', 'raw_material', 1), + ('biaya_lain_lain', 'Biaya Lain-lain', 'expense', 2) ON CONFLICT (code) DO NOTHING; 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 JOIN ( VALUES - ('hpp', 'hpp_bakso_mie_ayam', 'Bakso & Mie Ayam', 'raw_material', 1), - ('hpp', 'hpp_nusantara', 'Nusantara', 'raw_material', 2), - ('hpp', 'hpp_ramen', 'Ramen', 'raw_material', 3), - ('hpp', 'hpp_minuman_kopi', 'Minuman/Kopi', 'raw_material', 4), - ('biaya_lain_lain', 'biaya_atk_perlengkapan', 'ATK & Perlengkapan', 'non_inventory', 1), - ('biaya_lain_lain', 'biaya_makan_karyawan', 'Makan Karyawan', 'non_inventory', 2), - ('biaya_lain_lain', 'biaya_bensin_parkir', 'Bensin & Parkir', 'non_inventory', 3), - ('biaya_lain_lain', 'biaya_kebersihan_keamanan', 'Kebersihan & Keamanan', 'non_inventory', 4), - ('biaya_lain_lain', 'biaya_gaji_dw', 'Gaji DW', 'non_inventory', 5), - ('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) + ('biaya_lain_lain', 'biaya_atk_perlengkapan', 'ATK & Perlengkapan', 'expense', 1), + ('biaya_lain_lain', 'biaya_makan_karyawan', 'Makan Karyawan', 'expense', 2), + ('biaya_lain_lain', 'biaya_bensin_parkir', 'Bensin & Parkir', 'expense', 3), + ('biaya_lain_lain', 'biaya_kebersihan_keamanan', 'Kebersihan & Keamanan', 'expense', 4), + ('biaya_lain_lain', 'biaya_gaji_dw', 'Gaji DW', 'expense', 5), + ('biaya_lain_lain', 'biaya_gaji_staff', 'Gaji Staff', 'expense', 6), + ('biaya_lain_lain', 'biaya_internet_server', 'Internet & Server', 'expense', 7), + ('biaya_lain_lain', 'biaya_air_listrik', 'Air & Listrik', 'expense', 8), + ('biaya_lain_lain', 'biaya_promosi', 'Promosi', 'expense', 9) ) AS child(parent_code, code, name, type, sort_order) ON parent.code = child.parent_code ON CONFLICT (code) DO NOTHING; diff --git a/migrations/000080_rename_non_inventory_purchase_categories.down.sql b/migrations/000080_rename_non_inventory_purchase_categories.down.sql new file mode 100644 index 0000000..e90c1bd --- /dev/null +++ b/migrations/000080_rename_non_inventory_purchase_categories.down.sql @@ -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')); diff --git a/migrations/000080_rename_non_inventory_purchase_categories.up.sql b/migrations/000080_rename_non_inventory_purchase_categories.up.sql new file mode 100644 index 0000000..923bd88 --- /dev/null +++ b/migrations/000080_rename_non_inventory_purchase_categories.up.sql @@ -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;