Compare commits

..

No commits in common. "d0a548f44e4157d97b1763f9ba9f9ec495c3b7ba" and "f4172fcea71003b577365e2c6c02ea8ec42bb72b" have entirely different histories.

94 changed files with 693 additions and 3248 deletions

View File

@ -83,12 +83,6 @@ migration-up:
migration-down:
@migrate -database $(DB_URL) -path ./migrations down 1
# Force migration to specific version
.SILENT: migration-force
migration-force:
@migrate -database $(DB_URL) -path ./migrations force $(version)
.SILENT: seeder-create
seeder-create:
@migrate create -ext sql -dir ./seeders -seq $(name)

View File

@ -48,7 +48,6 @@ func (a *App) Initialize(cfg *config.Config) error {
// Initialize omset milestone scheduler
a.omsetScheduler = service.NewOmsetMilestoneScheduler(
repos.organizationRepo,
repos.outletRepo,
repos.userRepo,
processors.notificationProcessor,
)
@ -108,8 +107,6 @@ func (a *App) Initialize(cfg *config.Config) error {
validators.vendorValidator,
services.purchaseOrderService,
validators.purchaseOrderValidator,
services.purchaseCategoryService,
validators.purchaseCategoryValidator,
services.unitConverterService,
validators.unitConverterValidator,
services.chartOfAccountTypeService,
@ -140,16 +137,15 @@ func (a *App) Initialize(cfg *config.Config) error {
selfOrderHandler,
services.expenseService,
validators.expenseValidator,
a.redisClient,
)
return nil
}
func (a *App) Start(port string) error {
// Start the omset milestone scheduler (checks every 5 minutes for daily omset milestones)
// Start the omset milestone scheduler (checks every hour)
if a.omsetScheduler != nil {
a.omsetScheduler.Start(5 * time.Minute)
a.omsetScheduler.Start(1 * time.Hour)
}
engine := a.router.Init()
@ -218,7 +214,6 @@ type repositories struct {
productRecipeRepo *repository.ProductRecipeRepository
vendorRepo *repository.VendorRepositoryImpl
purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl
purchaseCategoryRepo *repository.PurchaseCategoryRepositoryImpl
unitConverterRepo *repository.IngredientUnitConverterRepositoryImpl
chartOfAccountTypeRepo *repository.ChartOfAccountTypeRepositoryImpl
chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl
@ -272,7 +267,6 @@ func (a *App) initRepositories() *repositories {
productRecipeRepo: repository.NewProductRecipeRepository(a.db),
vendorRepo: repository.NewVendorRepositoryImpl(a.db),
purchaseOrderRepo: repository.NewPurchaseOrderRepositoryImpl(a.db),
purchaseCategoryRepo: repository.NewPurchaseCategoryRepositoryImpl(a.db),
unitConverterRepo: repository.NewIngredientUnitConverterRepositoryImpl(a.db).(*repository.IngredientUnitConverterRepositoryImpl),
chartOfAccountTypeRepo: repository.NewChartOfAccountTypeRepositoryImpl(a.db),
chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db),
@ -321,7 +315,6 @@ type processors struct {
productRecipeProcessor *processor.ProductRecipeProcessorImpl
vendorProcessor *processor.VendorProcessorImpl
purchaseOrderProcessor *processor.PurchaseOrderProcessorImpl
purchaseCategoryProcessor *processor.PurchaseCategoryProcessorImpl
unitConverterProcessor *processor.IngredientUnitConverterProcessorImpl
chartOfAccountTypeProcessor *processor.ChartOfAccountTypeProcessorImpl
chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl
@ -372,8 +365,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo),
@ -396,7 +388,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
notificationProcessor: buildNotificationProcessor(cfg, repos),
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo),
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo),
}
}
@ -422,7 +414,6 @@ type services struct {
productRecipeService *service.ProductRecipeServiceImpl
vendorService *service.VendorServiceImpl
purchaseOrderService *service.PurchaseOrderServiceImpl
purchaseCategoryService service.PurchaseCategoryService
unitConverterService *service.IngredientUnitConverterServiceImpl
chartOfAccountTypeService service.ChartOfAccountTypeService
chartOfAccountService service.ChartOfAccountService
@ -462,7 +453,6 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productRecipeService := service.NewProductRecipeService(processors.productRecipeProcessor)
vendorService := service.NewVendorService(processors.vendorProcessor)
purchaseOrderService := service.NewPurchaseOrderService(processors.purchaseOrderProcessor)
purchaseCategoryService := service.NewPurchaseCategoryService(processors.purchaseCategoryProcessor)
unitConverterService := service.NewIngredientUnitConverterService(processors.unitConverterProcessor)
chartOfAccountTypeService := service.NewChartOfAccountTypeService(processors.chartOfAccountTypeProcessor)
chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor)
@ -502,7 +492,6 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productRecipeService: productRecipeService,
vendorService: vendorService,
purchaseOrderService: purchaseOrderService,
purchaseCategoryService: purchaseCategoryService,
unitConverterService: unitConverterService,
chartOfAccountTypeService: chartOfAccountTypeService,
chartOfAccountService: chartOfAccountService,
@ -548,7 +537,6 @@ type validators struct {
tableValidator *validator.TableValidator
vendorValidator *validator.VendorValidatorImpl
purchaseOrderValidator *validator.PurchaseOrderValidatorImpl
purchaseCategoryValidator *validator.PurchaseCategoryValidatorImpl
unitConverterValidator *validator.IngredientUnitConverterValidatorImpl
chartOfAccountTypeValidator *validator.ChartOfAccountTypeValidatorImpl
chartOfAccountValidator *validator.ChartOfAccountValidatorImpl
@ -580,7 +568,6 @@ func (a *App) initValidators() *validators {
tableValidator: validator.NewTableValidator(),
vendorValidator: validator.NewVendorValidator(),
purchaseOrderValidator: validator.NewPurchaseOrderValidator(),
purchaseCategoryValidator: validator.NewPurchaseCategoryValidator(),
unitConverterValidator: validator.NewIngredientUnitConverterValidator().(*validator.IngredientUnitConverterValidatorImpl),
chartOfAccountTypeValidator: validator.NewChartOfAccountTypeValidator().(*validator.ChartOfAccountTypeValidatorImpl),
chartOfAccountValidator: validator.NewChartOfAccountValidator().(*validator.ChartOfAccountValidatorImpl),

View File

@ -40,7 +40,6 @@ const (
OutletServiceEntity = "outlet_service"
VendorServiceEntity = "vendor_service"
PurchaseOrderServiceEntity = "purchase_order_service"
PurchaseCategoryServiceEntity = "purchase_category_service"
IngredientUnitConverterServiceEntity = "ingredient_unit_converter_service"
IngredientCompositionServiceEntity = "ingredient_composition_service"
TableEntity = "table"

View File

@ -5,12 +5,12 @@ import (
)
type CreateAccountRequest struct {
ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"`
Name string `json:"name" validate:"required,min=1,max=255"`
Number string `json:"number" validate:"required,min=1,max=50"`
AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"`
OpeningBalance float64 `json:"opening_balance"`
Description *string `json:"description"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"`
Name string `json:"name" validate:"required,min=1,max=255"`
Number string `json:"number" validate:"required,min=1,max=50"`
AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"`
OpeningBalance float64 `json:"opening_balance"`
Description *string `json:"description"`
}
type UpdateAccountRequest struct {
@ -24,21 +24,21 @@ type UpdateAccountRequest struct {
}
type AccountResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
Name string `json:"name"`
Number string `json:"number"`
AccountType string `json:"account_type"`
OpeningBalance float64 `json:"opening_balance"`
CurrentBalance float64 `json:"current_balance"`
Description *string `json:"description"`
IsActive bool `json:"is_active"`
IsSystem bool `json:"is_system"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ChartOfAccount *ChartOfAccountResponse `json:"chart_of_account,omitempty"`
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
Name string `json:"name"`
Number string `json:"number"`
AccountType string `json:"account_type"`
OpeningBalance float64 `json:"opening_balance"`
CurrentBalance float64 `json:"current_balance"`
Description *string `json:"description"`
IsActive bool `json:"is_active"`
IsSystem bool `json:"is_system"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ChartOfAccount *ChartOfAccountResponse `json:"chart_of_account,omitempty"`
}
type ListAccountsRequest struct {

View File

@ -106,11 +106,7 @@ type PurchasingAnalyticsResponse struct {
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
@ -118,16 +114,12 @@ type PurchasingSummary struct {
}
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct {
@ -170,7 +162,6 @@ type ProductAnalyticsData struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`

View File

@ -19,11 +19,10 @@ type CreateExpenseRequest struct {
}
type CreateExpenseItemRequest struct {
ChartOfAccountID string `json:"chart_of_account_id" validate:"required"`
PurchaseCategoryID string `json:"purchase_category_id" validate:"required"`
Item string `json:"item" validate:"required"`
Description *string `json:"description,omitempty"`
Amount float64 `json:"amount" validate:"required"`
ChartOfAccountID string `json:"chart_of_account_id" validate:"required"`
Item string `json:"item" validate:"required"`
Description *string `json:"description,omitempty"`
Amount float64 `json:"amount" validate:"required"`
}
type UpdateExpenseRequest struct {
@ -40,11 +39,10 @@ type UpdateExpenseRequest struct {
}
type UpdateExpenseItemRequest struct {
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
Item *string `json:"item,omitempty"`
Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
Item *string `json:"item,omitempty"`
Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
}
type ExpenseResponse struct {
@ -65,19 +63,15 @@ type ExpenseResponse struct {
}
type ExpenseItemResponse struct {
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListExpenseRequest struct {
@ -97,65 +91,3 @@ type ListExpenseResponse struct {
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
type ExpenseAnalyticsRequest struct {
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
}
type ExpenseAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary ExpenseAnalyticsSummary `json:"summary"`
Data []ExpenseAnalyticsData `json:"data"`
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
}
type ExpenseAnalyticsSummary struct {
TotalExpenses float64 `json:"total_expenses"`
TotalExpenseCount int64 `json:"total_expense_count"`
TotalTax float64 `json:"total_tax"`
AverageExpenseValue float64 `json:"average_expense_value"`
TotalCategories int64 `json:"total_categories"`
TotalItems int64 `json:"total_items"`
}
type ExpenseAnalyticsData struct {
Date time.Time `json:"date"`
Expenses float64 `json:"expenses"`
ExpenseCount int64 `json:"expense_count"`
Tax float64 `json:"tax"`
Items int64 `json:"items"`
Categories int64 `json:"categories"`
}
type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name"`
PurchaseCategoryType string `json:"purchase_category_type"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsItemData struct {
Item string `json:"item"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}

View File

@ -81,3 +81,4 @@ type IngredientUnitsResponse struct {
BaseUnitName string `json:"base_unit_name"`
Units []*UnitResponse `json:"units"`
}

View File

@ -26,9 +26,9 @@ type AdjustInventoryRequest struct {
}
type RestockInventoryRequest struct {
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
Items []RestockItem `json:"items" validate:"required,min=1,dive"`
Reason string `json:"reason" validate:"required,min=1,max=255"`
Reason string `json:"reason" validate:"required,min=1,max=255"`
}
type RestockItem struct {
@ -82,10 +82,10 @@ type InventoryAdjustmentResponse struct {
}
type RestockInventoryResponse struct {
OutletID uuid.UUID `json:"outlet_id"`
Items []RestockItemResult `json:"items"`
Reason string `json:"reason"`
RestockedAt time.Time `json:"restocked_at"`
OutletID uuid.UUID `json:"outlet_id"`
Items []RestockItemResult `json:"items"`
Reason string `json:"reason"`
RestockedAt time.Time `json:"restocked_at"`
}
type RestockItemResult struct {

View File

@ -34,34 +34,34 @@ type BulkCreateProductRecipeRequest struct {
// Response structures
type ProductRecipeResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ProductID uuid.UUID `json:"product_id"`
VariantID *uuid.UUID `json:"variant_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
Quantity float64 `json:"quantity"`
WastePercentage float64 `json:"waste_percentage"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Product *ProductResponse `json:"product,omitempty"`
ProductVariant *ProductVariantResponse `json:"product_variant,omitempty"`
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ProductID uuid.UUID `json:"product_id"`
VariantID *uuid.UUID `json:"variant_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
Quantity float64 `json:"quantity"`
WastePercentage float64 `json:"waste_percentage"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Product *ProductResponse `json:"product,omitempty"`
ProductVariant *ProductVariantResponse `json:"product_variant,omitempty"`
Ingredient *ProductRecipeIngredientResponse `json:"ingredient,omitempty"`
}
type ProductRecipeIngredientResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
Name string `json:"name"`
UnitID uuid.UUID `json:"unit_id"`
Cost float64 `json:"cost"`
Stock float64 `json:"stock"`
IsSemiFinished bool `json:"is_semi_finished"`
IsActive bool `json:"is_active"`
Metadata map[string]interface{} `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
Name string `json:"name"`
UnitID uuid.UUID `json:"unit_id"`
Cost float64 `json:"cost"`
Stock float64 `json:"stock"`
IsSemiFinished bool `json:"is_semi_finished"`
IsActive bool `json:"is_active"`
Metadata map[string]interface{} `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Unit *ProductRecipeUnitResponse `json:"unit,omitempty"`
}

View File

@ -1,57 +0,0 @@
package contract
import (
"time"
"github.com/google/uuid"
)
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 expense"`
SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
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 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 expense"`
Search string `json:"search,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Page int `json:"page" validate:"required,min=1"`
Limit int `json:"limit" validate:"required,min=1,max=100"`
}
type PurchaseCategoryResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
PresetID *uuid.UUID `json:"preset_id"`
ParentID *uuid.UUID `json:"parent_id"`
Code string `json:"code"`
Name string `json:"name"`
Type string `json:"type"`
SortOrder int `json:"sort_order"`
IsSystem bool `json:"is_system"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListPurchaseCategoriesResponse struct {
PurchaseCategories []PurchaseCategoryResponse `json:"purchase_categories"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}

View File

@ -19,12 +19,11 @@ type CreatePurchaseOrderRequest struct {
}
type CreatePurchaseOrderItemRequest struct {
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"`
IngredientID uuid.UUID `json:"ingredient_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"`
}
type UpdatePurchaseOrderRequest struct {
@ -40,13 +39,12 @@ type UpdatePurchaseOrderRequest struct {
}
type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"`
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,omitempty" validate:"omitempty,gte=0"`
ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
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,omitempty" validate:"omitempty,gte=0"`
}
type PurchaseOrderResponse struct {
@ -68,19 +66,17 @@ type PurchaseOrderResponse struct {
}
type PurchaseOrderItemResponse 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"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_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"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
}
type PurchaseOrderAttachmentResponse struct {

View File

@ -38,11 +38,7 @@ type PurchasingAnalytics struct {
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
@ -50,16 +46,12 @@ type PurchasingSummary struct {
}
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct {
@ -84,7 +76,6 @@ type ProductAnalytics struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`

View File

@ -28,56 +28,6 @@ type Expense struct {
Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"`
}
type ExpenseAnalytics struct {
Summary ExpenseAnalyticsSummary
Data []ExpenseAnalyticsData
CategoryData []ExpenseAnalyticsCategoryData
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData
ItemData []ExpenseAnalyticsItemData
}
type ExpenseAnalyticsSummary struct {
TotalExpenses float64
TotalExpenseCount int64
TotalTax float64
AverageExpenseValue float64
TotalCategories int64
TotalItems int64
}
type ExpenseAnalyticsData struct {
Date time.Time
Expenses float64
ExpenseCount int64
Tax float64
Items int64
Categories int64
}
type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID
PurchaseCategoryName string
PurchaseCategoryType string
TotalAmount float64
ExpenseCount int64
ItemCount int64
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID
ChartOfAccountName string
TotalAmount float64
ExpenseCount int64
ItemCount int64
}
type ExpenseAnalyticsItemData struct {
Item string
TotalAmount float64
ExpenseCount int64
ItemCount int64
}
func (e *Expense) BeforeCreate(tx *gorm.DB) error {
if e.ID == uuid.Nil {
e.ID = uuid.New()

View File

@ -9,19 +9,17 @@ import (
)
type ExpenseItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"`
ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"`
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id"`
Item string `gorm:"not null;size:255" json:"item"`
Description *string `gorm:"type:text" json:"description"`
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
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"`
ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"`
ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"`
Item string `gorm:"not null;size:255" json:"item"`
Description *string `gorm:"type:text" json:"description"`
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"`
ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"`
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"`
ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"`
}
func (e *ExpenseItem) BeforeCreate(tx *gorm.DB) error {

View File

@ -39,3 +39,4 @@ func (iuc *IngredientUnitConverter) BeforeCreate() error {
}
return nil
}

View File

@ -36,36 +36,34 @@ const (
)
type InventoryMovement struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"`
ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"`
ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT"
MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"`
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"`
PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"`
NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"`
UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"`
TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"`
ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"`
ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"`
PurchaseOrderItemID *uuid.UUID `gorm:"type:uuid;index" json:"purchase_order_item_id"`
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"`
Reason *string `gorm:"size:255" json:"reason"`
Notes *string `gorm:"type:text" json:"notes"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"`
ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"`
ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT"
MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"`
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"`
PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"`
NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"`
UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"`
TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"`
ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"`
ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"`
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"`
Reason *string `gorm:"size:255" json:"reason"`
Notes *string `gorm:"type:text" json:"notes"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"`
PurchaseOrderItem *PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderItemID" json:"purchase_order_item,omitempty"`
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"`
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
func (im *InventoryMovement) BeforeCreate(tx *gorm.DB) error {

View File

@ -26,14 +26,14 @@ type OrderIngredientTransaction struct {
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
// Relations
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
OrderItem *OrderItem `gorm:"foreignKey:OrderItemID" json:"order_item,omitempty"`
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
OrderItem *OrderItem `gorm:"foreignKey:OrderItemID" json:"order_item,omitempty"`
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"`
Ingredient Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
CreatedByUser User `gorm:"foreignKey:CreatedBy" json:"created_by_user,omitempty"`
Ingredient Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
CreatedByUser User `gorm:"foreignKey:CreatedBy" json:"created_by_user,omitempty"`
}
func (oit *OrderIngredientTransaction) BeforeCreate(tx *gorm.DB) error {

View File

@ -7,15 +7,15 @@ import (
)
type ProductIngredient struct {
ID uuid.UUID `json:"id" db:"id"`
OrganizationID uuid.UUID `json:"organization_id" db:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id" db:"outlet_id"`
ProductID uuid.UUID `json:"product_id" db:"product_id"`
IngredientID uuid.UUID `json:"ingredient_id" db:"ingredient_id"`
Quantity float64 `json:"quantity" db:"quantity"`
WastePercentage float64 `json:"waste_percentage" db:"waste_percentage"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ID uuid.UUID `json:"id" db:"id"`
OrganizationID uuid.UUID `json:"organization_id" db:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id" db:"outlet_id"`
ProductID uuid.UUID `json:"product_id" db:"product_id"`
IngredientID uuid.UUID `json:"ingredient_id" db:"ingredient_id"`
Quantity float64 `json:"quantity" db:"quantity"`
WastePercentage float64 `json:"waste_percentage" db:"waste_percentage"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// Relations
Product *Product `json:"product,omitempty"`

View File

@ -1,71 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type PurchaseCategoryType string
const (
PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material"
PurchaseCategoryTypeExpense PurchaseCategoryType = "expense"
)
type PurchaseCategoryPreset struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
Code string `gorm:"not null;unique;size:100" json:"code"`
Name string `gorm:"not null;size:255" json:"name"`
Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
IsActive bool `gorm:"not null;default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Parent *PurchaseCategoryPreset `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
}
func (p *PurchaseCategoryPreset) BeforeCreate(tx *gorm.DB) error {
if p.ID == uuid.Nil {
p.ID = uuid.New()
}
return nil
}
func (PurchaseCategoryPreset) TableName() string {
return "purchase_category_presets"
}
type PurchaseCategory struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
PresetID *uuid.UUID `gorm:"type:uuid;index" json:"preset_id"`
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
Code string `gorm:"not null;size:100" json:"code"`
Name string `gorm:"not null;size:255" json:"name"`
Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
IsSystem bool `gorm:"not null;default:false" json:"is_system"`
IsActive bool `gorm:"not null;default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Preset *PurchaseCategoryPreset `gorm:"foreignKey:PresetID" json:"preset,omitempty"`
Parent *PurchaseCategory `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
Children []PurchaseCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"`
}
func (c *PurchaseCategory) BeforeCreate(tx *gorm.DB) error {
if c.ID == uuid.Nil {
c.ID = uuid.New()
}
return nil
}
func (PurchaseCategory) TableName() string {
return "purchase_categories"
}

View File

@ -41,21 +41,19 @@ func (PurchaseOrder) TableName() string {
}
type PurchaseOrderItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
IngredientID *uuid.UUID `gorm:"type:uuid" json:"ingredient_id" validate:"omitempty"`
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"`
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"`
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"`
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
}
func (poi *PurchaseOrderItem) BeforeCreate(tx *gorm.DB) error {

View File

@ -1,8 +1,6 @@
package handler
import (
"apskel-pos-be/internal/logger"
"fmt"
"net/http"
"time"
)
@ -49,7 +47,7 @@ func (m *CommonMiddleware) Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
logger.FromContext(r.Context()).Error("Recovery", fmt.Sprintf("panic recovered: %v", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()

View File

@ -199,31 +199,3 @@ func (h *ExpenseHandler) ListExpenses(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::ListExpenses")
}
func (h *ExpenseHandler) GetExpenseAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.ExpenseAnalyticsRequest
if err := c.ShouldBindQuery(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpenseAnalytics -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpenseAnalytics")
return
}
if contextInfo.OutletID != uuid.Nil {
outletID := contextInfo.OutletID.String()
req.OutletID = &outletID
} else if outletID := c.Query("outlet_id"); outletID != "" {
req.OutletID = &outletID
}
expenseResponse := h.expenseService.GetExpenseAnalytics(ctx, contextInfo, &req)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpenseAnalytics -> Failed to get expense analytics from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpenseAnalytics")
}

View File

@ -275,3 +275,4 @@ func (h *IngredientUnitConverterHandler) GetUnitsByIngredientID(c *gin.Context)
util.HandleResponse(c.Writer, c.Request, unitsResponse, "IngredientUnitConverterHandler::GetUnitsByIngredientID")
}

View File

@ -1,160 +0,0 @@
package handler
import (
"strconv"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type PurchaseCategoryHandler struct {
purchaseCategoryService service.PurchaseCategoryService
purchaseCategoryValidator validator.PurchaseCategoryValidator
}
func NewPurchaseCategoryHandler(purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator) *PurchaseCategoryHandler {
return &PurchaseCategoryHandler{
purchaseCategoryService: purchaseCategoryService,
purchaseCategoryValidator: purchaseCategoryValidator,
}
}
func (h *PurchaseCategoryHandler) CreatePurchaseCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.CreatePurchaseCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::CreatePurchaseCategory -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory")
return
}
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateCreatePurchaseCategoryRequest(&req); validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory")
return
}
response := h.purchaseCategoryService.CreatePurchaseCategory(ctx, contextInfo, &req)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::CreatePurchaseCategory")
}
func (h *PurchaseCategoryHandler) UpdatePurchaseCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
categoryID, err := uuid.Parse(c.Param("id"))
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
return
}
var req contract.UpdatePurchaseCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::UpdatePurchaseCategory -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
return
}
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateUpdatePurchaseCategoryRequest(&req); validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
return
}
response := h.purchaseCategoryService.UpdatePurchaseCategory(ctx, contextInfo, categoryID, &req)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::UpdatePurchaseCategory")
}
func (h *PurchaseCategoryHandler) DeletePurchaseCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
categoryID, err := uuid.Parse(c.Param("id"))
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::DeletePurchaseCategory")
return
}
response := h.purchaseCategoryService.DeletePurchaseCategory(ctx, contextInfo, categoryID)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::DeletePurchaseCategory")
}
func (h *PurchaseCategoryHandler) GetPurchaseCategory(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
categoryID, err := uuid.Parse(c.Param("id"))
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::GetPurchaseCategory")
return
}
response := h.purchaseCategoryService.GetPurchaseCategoryByID(ctx, contextInfo, categoryID)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::GetPurchaseCategory")
}
func (h *PurchaseCategoryHandler) ListPurchaseCategories(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := &contract.ListPurchaseCategoriesRequest{
Page: 1,
Limit: 100,
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
if parentID, err := uuid.Parse(parentIDStr); err == nil {
req.ParentID = &parentID
}
}
if categoryType := c.Query("type"); categoryType != "" {
req.Type = categoryType
}
if search := c.Query("search"); search != "" {
req.Search = search
}
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
req.IsActive = &isActive
}
}
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateListPurchaseCategoriesRequest(req); validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::ListPurchaseCategories")
return
}
response := h.purchaseCategoryService.ListPurchaseCategories(ctx, contextInfo, req)
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::ListPurchaseCategories")
}

View File

@ -95,27 +95,20 @@ func ExpenseItemEntityToResponse(entity *entities.ExpenseItem) *models.ExpenseIt
}
response := &models.ExpenseItemResponse{
ID: entity.ID,
ExpenseID: entity.ExpenseID,
ChartOfAccountID: entity.ChartOfAccountID,
PurchaseCategoryID: entity.PurchaseCategoryID,
Item: entity.Item,
Description: entity.Description,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
ID: entity.ID,
ExpenseID: entity.ExpenseID,
ChartOfAccountID: entity.ChartOfAccountID,
Item: entity.Item,
Description: entity.Description,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
if entity.ChartOfAccount != nil {
response.ChartOfAccountName = entity.ChartOfAccount.Name
}
if entity.PurchaseCategory != nil {
response.PurchaseCategoryName = entity.PurchaseCategory.Name
response.PurchaseCategoryType = string(entity.PurchaseCategory.Type)
response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory)
}
return response
}

View File

@ -11,17 +11,17 @@ func MapProductIngredientEntityToModel(entity *entities.ProductIngredient) *mode
}
return &models.ProductIngredient{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
ProductID: entity.ProductID,
IngredientID: entity.IngredientID,
Quantity: entity.Quantity,
WastePercentage: entity.WastePercentage,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
Product: ProductEntityToModel(entity.Product),
Ingredient: MapIngredientEntityToModel(entity.Ingredient),
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
ProductID: entity.ProductID,
IngredientID: entity.IngredientID,
Quantity: entity.Quantity,
WastePercentage: entity.WastePercentage,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
Product: ProductEntityToModel(entity.Product),
Ingredient: MapIngredientEntityToModel(entity.Ingredient),
}
}
@ -31,17 +31,17 @@ func MapProductIngredientModelToEntity(model *models.ProductIngredient) *entitie
}
return &entities.ProductIngredient{
ID: model.ID,
OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
ProductID: model.ProductID,
IngredientID: model.IngredientID,
Quantity: model.Quantity,
WastePercentage: model.WastePercentage,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
Product: ProductModelToEntity(model.Product),
Ingredient: MapIngredientModelToEntity(model.Ingredient),
ID: model.ID,
OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
ProductID: model.ProductID,
IngredientID: model.IngredientID,
Quantity: model.Quantity,
WastePercentage: model.WastePercentage,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
Product: ProductModelToEntity(model.Product),
Ingredient: MapIngredientModelToEntity(model.Ingredient),
}
}

View File

@ -1,53 +0,0 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func CreatePurchaseCategoryRequestToEntity(req *models.CreatePurchaseCategoryRequest) *entities.PurchaseCategory {
if req == nil {
return nil
}
return &entities.PurchaseCategory{
OrganizationID: req.OrganizationID,
ParentID: req.ParentID,
Name: req.Name,
Type: entities.PurchaseCategoryType(req.Type),
SortOrder: req.SortOrder,
IsActive: req.IsActive,
}
}
func PurchaseCategoryEntityToResponse(entity *entities.PurchaseCategory) *models.PurchaseCategoryResponse {
if entity == nil {
return nil
}
return &models.PurchaseCategoryResponse{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
PresetID: entity.PresetID,
ParentID: entity.ParentID,
Code: entity.Code,
Name: entity.Name,
Type: string(entity.Type),
SortOrder: entity.SortOrder,
IsSystem: entity.IsSystem,
IsActive: entity.IsActive,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func PurchaseCategoryEntitiesToResponses(categoryEntities []*entities.PurchaseCategory) []models.PurchaseCategoryResponse {
responses := make([]models.PurchaseCategoryResponse, len(categoryEntities))
for i, entity := range categoryEntities {
response := PurchaseCategoryEntityToResponse(entity)
if response != nil {
responses[i] = *response
}
}
return responses
}

View File

@ -91,16 +91,15 @@ func PurchaseOrderItemEntityToModel(entity *entities.PurchaseOrderItem) *models.
}
return &models.PurchaseOrderItem{
ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID,
PurchaseCategoryID: entity.PurchaseCategoryID,
Description: entity.Description,
Quantity: entity.Quantity,
UnitID: entity.UnitID,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID,
Description: entity.Description,
Quantity: entity.Quantity,
UnitID: entity.UnitID,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
@ -110,16 +109,15 @@ func PurchaseOrderItemModelToEntity(model *models.PurchaseOrderItem) *entities.P
}
return &entities.PurchaseOrderItem{
ID: model.ID,
PurchaseOrderID: model.PurchaseOrderID,
IngredientID: model.IngredientID,
PurchaseCategoryID: model.PurchaseCategoryID,
Description: model.Description,
Quantity: model.Quantity,
UnitID: model.UnitID,
Amount: model.Amount,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
ID: model.ID,
PurchaseOrderID: model.PurchaseOrderID,
IngredientID: model.IngredientID,
Description: model.Description,
Quantity: model.Quantity,
UnitID: model.UnitID,
Amount: model.Amount,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
@ -129,16 +127,15 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
}
response := &models.PurchaseOrderItemResponse{
ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID,
PurchaseCategoryID: entity.PurchaseCategoryID,
Description: entity.Description,
Quantity: entity.Quantity,
UnitID: entity.UnitID,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID,
Description: entity.Description,
Quantity: entity.Quantity,
UnitID: entity.UnitID,
Amount: entity.Amount,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
// Map ingredient if present
@ -149,10 +146,6 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
}
}
if entity.PurchaseCategory != nil {
response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory)
}
// Map unit if present
if entity.Unit != nil {
response.Unit = &models.UnitResponse{

View File

@ -1,137 +0,0 @@
package middleware
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
IdempotencyKeyHeader = "X-Idempotency-Key"
idempotencyTTL = 24 * time.Hour
idempotencyPrefix = "idempotency:"
)
type cachedResponse struct {
StatusCode int `json:"status_code"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
// IdempotencyMiddleware returns a Gin middleware that ensures idempotent processing
// for mutating operations. Client must send X-Idempotency-Key header.
func IdempotencyMiddleware(redisClient *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
key := c.GetHeader(IdempotencyKeyHeader)
if key == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"success": false,
"errors": []gin.H{
{
"code": "missing_idempotency_key",
"entity": "IdempotencyMiddleware",
"cause": "X-Idempotency-Key header is required",
},
},
})
return
}
redisKey := fmt.Sprintf("%s%s", idempotencyPrefix, key)
ctx := context.Background()
fmt.Printf("[DEBUG] IdempotencyMiddleware: key=%s redisKey=%s\n", key, redisKey)
// Check if key already exists (request was already processed)
cached, err := redisClient.Get(ctx, redisKey).Result()
if err == nil {
// Key exists — return cached response
fmt.Printf("[DEBUG] IdempotencyMiddleware: cache HIT for key=%s\n", key)
var resp cachedResponse
if err := json.Unmarshal([]byte(cached), &resp); err == nil {
for k, v := range resp.Headers {
c.Writer.Header().Set(k, v)
}
c.Writer.Header().Set("X-Idempotent-Replay", "true")
c.Data(resp.StatusCode, "application/json", []byte(resp.Body))
c.Abort()
return
}
} else {
fmt.Printf("[DEBUG] IdempotencyMiddleware: cache MISS for key=%s err=%v\n", key, err)
}
// Mark key as in-progress to prevent concurrent duplicates
set, err := redisClient.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result()
if err != nil {
// Redis error — proceed without idempotency (fail open)
c.Next()
return
}
if !set {
// Another request with the same key is being processed
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
"success": false,
"errors": []gin.H{
{
"code": "request_in_progress",
"entity": "IdempotencyMiddleware",
"cause": "A request with this idempotency key is already being processed",
},
},
})
return
}
// Capture response using a custom writer
writer := &responseCapture{
ResponseWriter: c.Writer,
body: &bytes.Buffer{},
}
c.Writer = writer
c.Next()
// After handler completes, cache the response only if successful (2xx)
statusCode := writer.Status()
if statusCode >= 200 && statusCode < 300 {
resp := cachedResponse{
StatusCode: statusCode,
Headers: map[string]string{
"Content-Type": writer.Header().Get("Content-Type"),
},
Body: writer.body.String(),
}
respJSON, err := json.Marshal(resp)
if err == nil {
redisClient.Set(ctx, redisKey, string(respJSON), idempotencyTTL)
}
} else {
// Remove the in-progress key so the client can retry with the same key
redisClient.Del(ctx, redisKey)
}
}
}
// responseCapture wraps gin.ResponseWriter to capture the response body
type responseCapture struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w *responseCapture) Write(b []byte) (int, error) {
w.body.Write(b)
return w.ResponseWriter.Write(b)
}
func (w *responseCapture) WriteString(s string) (int, error) {
w.body.WriteString(s)
return w.ResponseWriter.WriteString(s)
}

View File

@ -25,12 +25,12 @@ type AccountResponse struct {
}
type CreateAccountRequest struct {
ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"`
Name string `json:"name" validate:"required,min=1,max=255"`
Number string `json:"number" validate:"required,min=1,max=50"`
AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"`
OpeningBalance float64 `json:"opening_balance"`
Description *string `json:"description"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"`
Name string `json:"name" validate:"required,min=1,max=255"`
Number string `json:"number" validate:"required,min=1,max=50"`
AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"`
OpeningBalance float64 `json:"opening_balance"`
Description *string `json:"description"`
}
type UpdateAccountRequest struct {

View File

@ -113,11 +113,7 @@ type PurchasingAnalyticsResponse struct {
// PurchasingSummary represents the summary of purchasing analytics
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
@ -126,16 +122,12 @@ type PurchasingSummary struct {
// PurchasingAnalyticsData represents purchasing analytics by time period
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
// PurchasingIngredientData represents purchasing analytics for an ingredient
@ -180,7 +172,6 @@ type ProductAnalyticsData struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`

View File

@ -23,17 +23,17 @@ type UpdateCustomerRequest struct {
}
type CustomerResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Name string `json:"name"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Address *string `json:"address,omitempty"`
IsDefault bool `json:"is_default"`
IsActive bool `json:"is_active"`
Metadata entities.Metadata `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Name string `json:"name"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Address *string `json:"address,omitempty"`
IsDefault bool `json:"is_default"`
IsActive bool `json:"is_active"`
Metadata entities.Metadata `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListCustomersQuery represents query parameters for listing customers

View File

@ -23,15 +23,14 @@ type Expense struct {
}
type ExpenseItem struct {
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ExpenseResponse struct {
@ -52,19 +51,15 @@ type ExpenseResponse struct {
}
type ExpenseItemResponse struct {
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
Item string `json:"item"`
Description *string `json:"description"`
Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateExpenseRequest struct {
@ -80,11 +75,10 @@ type CreateExpenseRequest struct {
}
type CreateExpenseItemRequest struct {
ChartOfAccountID string `json:"chart_of_account_id"`
PurchaseCategoryID string `json:"purchase_category_id"`
Item string `json:"item"`
Description *string `json:"description,omitempty"`
Amount float64 `json:"amount"`
ChartOfAccountID string `json:"chart_of_account_id"`
Item string `json:"item"`
Description *string `json:"description,omitempty"`
Amount float64 `json:"amount"`
}
type UpdateExpenseRequest struct {
@ -101,11 +95,10 @@ type UpdateExpenseRequest struct {
}
type UpdateExpenseItemRequest struct {
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
Item *string `json:"item,omitempty"`
Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
Item *string `json:"item,omitempty"`
Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
}
type ListExpenseRequest struct {
@ -125,66 +118,3 @@ type ListExpenseResponse struct {
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
type ExpenseAnalyticsRequest struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
}
type ExpenseAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary ExpenseAnalyticsSummary `json:"summary"`
Data []ExpenseAnalyticsData `json:"data"`
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
}
type ExpenseAnalyticsSummary struct {
TotalExpenses float64 `json:"total_expenses"`
TotalExpenseCount int64 `json:"total_expense_count"`
TotalTax float64 `json:"total_tax"`
AverageExpenseValue float64 `json:"average_expense_value"`
TotalCategories int64 `json:"total_categories"`
TotalItems int64 `json:"total_items"`
}
type ExpenseAnalyticsData struct {
Date time.Time `json:"date"`
Expenses float64 `json:"expenses"`
ExpenseCount int64 `json:"expense_count"`
Tax float64 `json:"tax"`
Items int64 `json:"items"`
Categories int64 `json:"categories"`
}
type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
PurchaseCategoryName string `json:"purchase_category_name"`
PurchaseCategoryType string `json:"purchase_category_type"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}
type ExpenseAnalyticsItemData struct {
Item string `json:"item"`
TotalAmount float64 `json:"total_amount"`
ExpenseCount int64 `json:"expense_count"`
ItemCount int64 `json:"item_count"`
}

View File

@ -101,3 +101,4 @@ type IngredientUnitsResponse struct {
BaseUnitName string `json:"base_unit_name"`
Units []*UnitResponse `json:"units"`
}

View File

@ -52,8 +52,8 @@ type UpdateOrderIngredientTransactionRequest struct {
GrossQty *float64 `json:"gross_qty,omitempty" validate:"omitempty,gt=0"`
NetQty *float64 `json:"net_qty,omitempty" validate:"omitempty,gt=0"`
WasteQty *float64 `json:"waste_qty,omitempty" validate:"min=0"`
Unit *string `json:"unit,omitempty" validate:"omitempty,max=50"`
TransactionDate *time.Time `json:"transaction_date,omitempty"`
Unit *string `json:"unit,omitempty" validate:"omitempty,max=50"`
TransactionDate *time.Time `json:"transaction_date,omitempty"`
}
type OrderIngredientTransactionResponse struct {
@ -98,11 +98,11 @@ type ListOrderIngredientTransactionsRequest struct {
}
type OrderIngredientTransactionSummary struct {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
TotalGrossQty float64 `json:"total_gross_qty"`
TotalNetQty float64 `json:"total_net_qty"`
TotalWasteQty float64 `json:"total_waste_qty"`
WastePercentage float64 `json:"waste_percentage"`
Unit string `json:"unit"`
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
TotalGrossQty float64 `json:"total_gross_qty"`
TotalNetQty float64 `json:"total_net_qty"`
TotalWasteQty float64 `json:"total_waste_qty"`
WastePercentage float64 `json:"waste_percentage"`
Unit string `json:"unit"`
}

View File

@ -7,15 +7,15 @@ import (
)
type ProductIngredient struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ProductID uuid.UUID `json:"product_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
Quantity float64 `json:"quantity"`
WastePercentage float64 `json:"waste_percentage"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ProductID uuid.UUID `json:"product_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
Quantity float64 `json:"quantity"`
WastePercentage float64 `json:"waste_percentage"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Relations
Product *Product `json:"product,omitempty"`
@ -37,15 +37,15 @@ type UpdateProductIngredientRequest struct {
}
type ProductIngredientResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ProductID uuid.UUID `json:"product_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
Quantity float64 `json:"quantity"`
WastePercentage float64 `json:"waste_percentage"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
ProductID uuid.UUID `json:"product_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
Quantity float64 `json:"quantity"`
WastePercentage float64 `json:"waste_percentage"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Relations
Product *Product `json:"product,omitempty"`

View File

@ -1,51 +0,0 @@
package models
import (
"time"
"github.com/google/uuid"
)
type PurchaseCategoryResponse struct {
ID uuid.UUID
OrganizationID uuid.UUID
PresetID *uuid.UUID
ParentID *uuid.UUID
Code string
Name string
Type string
SortOrder int
IsSystem bool
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
}
type CreatePurchaseCategoryRequest struct {
OrganizationID uuid.UUID
ParentID *uuid.UUID
Code *string
Name string
Type string
SortOrder int
IsActive bool
}
type UpdatePurchaseCategoryRequest struct {
ParentID *uuid.UUID
Code *string
Name *string
Type *string
SortOrder *int
IsActive *bool
}
type ListPurchaseCategoriesRequest struct {
OrganizationID uuid.UUID
ParentID *uuid.UUID
Type string
Search string
IsActive *bool
Page int
Limit int
}

View File

@ -22,16 +22,15 @@ 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"`
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 {
@ -60,19 +59,17 @@ type PurchaseOrderResponse struct {
}
type PurchaseOrderItemResponse 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"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_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"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
}
type PurchaseOrderAttachmentResponse struct {
@ -96,12 +93,11 @@ type CreatePurchaseOrderRequest struct {
}
type CreatePurchaseOrderItemRequest struct {
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"`
IngredientID uuid.UUID `json:"ingredient_id"`
Description *string `json:"description,omitempty"`
Quantity float64 `json:"quantity"`
UnitID uuid.UUID `json:"unit_id"`
Amount float64 `json:"amount"`
}
type UpdatePurchaseOrderRequest struct {
@ -117,13 +113,12 @@ type UpdatePurchaseOrderRequest struct {
}
type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"`
Description *string `json:"description,omitempty"`
Quantity *float64 `json:"quantity,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
Amount *float64 `json:"amount,omitempty"`
ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
Description *string `json:"description,omitempty"`
Quantity *float64 `json:"quantity,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
Amount *float64 `json:"amount,omitempty"`
}
type ListPurchaseOrdersRequest struct {

View File

@ -185,16 +185,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
data := make([]models.PurchasingAnalyticsData, len(result.Data))
for i, item := range result.Data {
data[i] = models.PurchasingAnalyticsData{
Date: item.Date,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
Date: item.Date,
Purchases: item.Purchases,
PurchaseOrders: item.PurchaseOrders,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
@ -231,11 +227,7 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
GroupBy: req.GroupBy,
Summary: models.PurchasingSummary{
TotalPurchases: result.Summary.TotalPurchases,
RawMaterialPurchases: result.Summary.RawMaterialPurchases,
ExpensePurchases: result.Summary.ExpensePurchases,
TotalPurchaseOrders: result.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders,
ExpenseCount: result.Summary.ExpenseCount,
TotalQuantity: result.Summary.TotalQuantity,
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
TotalIngredients: result.Summary.TotalIngredients,
@ -271,7 +263,6 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
ProductID: data.ProductID,
ProductName: data.ProductName,
ProductSku: data.ProductSku,
ProductPrice: data.ProductPrice,
CategoryID: data.CategoryID,
CategoryName: data.CategoryName,
CategoryOrder: data.CategoryOrder,

View File

@ -61,9 +61,6 @@ func (expenseRepositoryStub) Delete(context.Context, uuid.UUID) error {
func (expenseRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) {
return nil, 0, nil
}
func (expenseRepositoryStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) {
return nil, nil
}
func (expenseRepositoryStub) CreateItem(context.Context, *entities.ExpenseItem) error { return nil }
func (expenseRepositoryStub) DeleteItemsByExpenseID(context.Context, uuid.UUID) error { return nil }
@ -75,23 +72,7 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
purchasingResult: &entities.PurchasingAnalytics{
OutletName: &outletName,
Summary: entities.PurchasingSummary{
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,
},
TotalPurchases: 125,
},
},
}, expenseRepositoryStub{})
@ -108,16 +89,7 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, 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)
require.Equal(t, float64(125), result.Summary.TotalPurchases)
}
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {

View File

@ -19,18 +19,15 @@ type ExpenseProcessor interface {
DeleteExpense(ctx context.Context, id, organizationID uuid.UUID) error
GetExpenseByID(ctx context.Context, id, organizationID uuid.UUID) (*models.ExpenseResponse, error)
ListExpenses(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.ExpenseResponse, int, error)
GetExpenseAnalytics(ctx context.Context, req *models.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsResponse, error)
}
type ExpenseProcessorImpl struct {
expenseRepo ExpenseRepository
purchaseCategoryRepo PurchaseCategoryRepository
expenseRepo ExpenseRepository
}
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl {
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository) *ExpenseProcessorImpl {
return &ExpenseProcessorImpl{
expenseRepo: expenseRepo,
purchaseCategoryRepo: purchaseCategoryRepo,
expenseRepo: expenseRepo,
}
}
@ -50,30 +47,6 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
status = *req.Status
}
items := make([]entities.ExpenseItem, len(req.Items))
for i, itemReq := range req.Items {
chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID)
if err != nil {
return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err)
}
purchaseCategoryID, err := uuid.Parse(itemReq.PurchaseCategoryID)
if err != nil {
return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err)
}
if err := p.validateExpensePurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil {
return nil, err
}
items[i] = entities.ExpenseItem{
ChartOfAccountID: chartOfAccountID,
PurchaseCategoryID: purchaseCategoryID,
Item: itemReq.Item,
Description: itemReq.Description,
Amount: itemReq.Amount,
}
}
expenseEntity := &entities.Expense{
OrganizationID: organizationID,
OutletID: outletID,
@ -91,10 +64,21 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
return nil, fmt.Errorf("failed to create expense: %w", err)
}
for i := range items {
items[i].ExpenseID = expenseEntity.ID
for _, itemReq := range req.Items {
chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID)
if err != nil {
return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err)
}
err = p.expenseRepo.CreateItem(ctx, &items[i])
itemEntity := &entities.ExpenseItem{
ExpenseID: expenseEntity.ID,
ChartOfAccountID: chartOfAccountID,
Item: itemReq.Item,
Description: itemReq.Description,
Amount: itemReq.Amount,
}
err = p.expenseRepo.CreateItem(ctx, itemEntity)
if err != nil {
return nil, fmt.Errorf("failed to create expense item: %w", err)
}
@ -150,10 +134,13 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati
expenseEntity.Reserved1 = req.Reserved1
}
var items []entities.ExpenseItem
if req.Items != nil {
items = make([]entities.ExpenseItem, len(req.Items))
for i, itemReq := range req.Items {
err = p.expenseRepo.DeleteItemsByExpenseID(ctx, expenseEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to delete existing items: %w", err)
}
for _, itemReq := range req.Items {
chartOfAccountID := uuid.Nil
if itemReq.ChartOfAccountID != nil {
chartOfAccountID, err = uuid.Parse(*itemReq.ChartOfAccountID)
@ -162,17 +149,6 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati
}
}
if itemReq.PurchaseCategoryID == nil {
return nil, fmt.Errorf("purchase_category_id is required for item")
}
purchaseCategoryID, err := uuid.Parse(*itemReq.PurchaseCategoryID)
if err != nil {
return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err)
}
if err := p.validateExpensePurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil {
return nil, err
}
amount := 0.0
if itemReq.Amount != nil {
amount = *itemReq.Amount
@ -182,23 +158,15 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati
item = *itemReq.Item
}
items[i] = entities.ExpenseItem{
ExpenseID: expenseEntity.ID,
ChartOfAccountID: chartOfAccountID,
PurchaseCategoryID: purchaseCategoryID,
Item: item,
Description: itemReq.Description,
Amount: amount,
itemEntity := &entities.ExpenseItem{
ExpenseID: expenseEntity.ID,
ChartOfAccountID: chartOfAccountID,
Item: item,
Description: itemReq.Description,
Amount: amount,
}
}
err = p.expenseRepo.DeleteItemsByExpenseID(ctx, expenseEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to delete existing items: %w", err)
}
for i := range items {
err = p.expenseRepo.CreateItem(ctx, &items[i])
err = p.expenseRepo.CreateItem(ctx, itemEntity)
if err != nil {
return nil, fmt.Errorf("failed to create expense item: %w", err)
}
@ -253,100 +221,3 @@ func (p *ExpenseProcessorImpl) ListExpenses(ctx context.Context, organizationID
return expenseResponses, totalPages, nil
}
func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *models.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
if req.GroupBy == "" {
req.GroupBy = "day"
}
result, err := p.expenseRepo.GetAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
if err != nil {
return nil, fmt.Errorf("failed to get expense analytics: %w", err)
}
data := make([]models.ExpenseAnalyticsData, len(result.Data))
for i, item := range result.Data {
data[i] = models.ExpenseAnalyticsData{
Date: item.Date,
Expenses: item.Expenses,
ExpenseCount: item.ExpenseCount,
Tax: item.Tax,
Items: item.Items,
Categories: item.Categories,
}
}
categoryData := make([]models.ExpenseAnalyticsCategoryData, len(result.CategoryData))
for i, item := range result.CategoryData {
categoryData[i] = models.ExpenseAnalyticsCategoryData{
PurchaseCategoryID: item.PurchaseCategoryID,
PurchaseCategoryName: item.PurchaseCategoryName,
PurchaseCategoryType: item.PurchaseCategoryType,
TotalAmount: item.TotalAmount,
ExpenseCount: item.ExpenseCount,
ItemCount: item.ItemCount,
}
}
chartOfAccountData := make([]models.ExpenseAnalyticsChartOfAccountData, len(result.ChartOfAccountData))
for i, item := range result.ChartOfAccountData {
chartOfAccountData[i] = models.ExpenseAnalyticsChartOfAccountData{
ChartOfAccountID: item.ChartOfAccountID,
ChartOfAccountName: item.ChartOfAccountName,
TotalAmount: item.TotalAmount,
ExpenseCount: item.ExpenseCount,
ItemCount: item.ItemCount,
}
}
itemData := make([]models.ExpenseAnalyticsItemData, len(result.ItemData))
for i, item := range result.ItemData {
itemData[i] = models.ExpenseAnalyticsItemData{
Item: item.Item,
TotalAmount: item.TotalAmount,
ExpenseCount: item.ExpenseCount,
ItemCount: item.ItemCount,
}
}
return &models.ExpenseAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
Summary: models.ExpenseAnalyticsSummary{
TotalExpenses: result.Summary.TotalExpenses,
TotalExpenseCount: result.Summary.TotalExpenseCount,
TotalTax: result.Summary.TotalTax,
AverageExpenseValue: result.Summary.AverageExpenseValue,
TotalCategories: result.Summary.TotalCategories,
TotalItems: result.Summary.TotalItems,
},
Data: data,
CategoryData: categoryData,
ChartOfAccountData: chartOfAccountData,
ItemData: itemData,
}, nil
}
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)
}
if !category.IsActive {
return fmt.Errorf("purchase category is inactive")
}
if category.Type != entities.PurchaseCategoryTypeExpense {
return fmt.Errorf("purchase category must be expense")
}
return nil
}

View File

@ -3,7 +3,6 @@ package processor
import (
"context"
"testing"
"time"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
@ -15,46 +14,6 @@ import (
type expenseRepositoryCaptureStub struct {
createdExpense *entities.Expense
createdItems []*entities.ExpenseItem
analytics *entities.ExpenseAnalytics
}
type expensePurchaseCategoryRepositoryStub struct {
category *entities.PurchaseCategory
}
func (*expensePurchaseCategoryRepositoryStub) Create(context.Context, *entities.PurchaseCategory) error {
return nil
}
func (s *expensePurchaseCategoryRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.PurchaseCategory, error) {
return s.category, nil
}
func (*expensePurchaseCategoryRepositoryStub) Update(context.Context, *entities.PurchaseCategory) error {
return nil
}
func (*expensePurchaseCategoryRepositoryStub) SoftDelete(context.Context, uuid.UUID, uuid.UUID) error {
return nil
}
func (*expensePurchaseCategoryRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.PurchaseCategory, int64, error) {
return nil, 0, nil
}
func (*expensePurchaseCategoryRepositoryStub) ExistsByCode(context.Context, uuid.UUID, string, *uuid.UUID) (bool, error) {
return false, nil
}
func newExpensePurchaseCategoryRepo(categoryID uuid.UUID, categoryType entities.PurchaseCategoryType) *expensePurchaseCategoryRepositoryStub {
return &expensePurchaseCategoryRepositoryStub{
category: &entities.PurchaseCategory{
ID: categoryID,
Name: "Operational",
Type: categoryType,
IsActive: true,
},
}
}
func (s *expenseRepositoryCaptureStub) Create(_ context.Context, expense *entities.Expense) error {
@ -85,9 +44,6 @@ func (*expenseRepositoryCaptureStub) Delete(context.Context, uuid.UUID) error
func (*expenseRepositoryCaptureStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) {
return nil, 0, nil
}
func (s *expenseRepositoryCaptureStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) {
return s.analytics, nil
}
func (s *expenseRepositoryCaptureStub) CreateItem(_ context.Context, item *entities.ExpenseItem) error {
if item.ID == uuid.Nil {
item.ID = uuid.New()
@ -101,8 +57,7 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui
func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
repo := &expenseRepositoryCaptureStub{}
purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
p := NewExpenseProcessorImpl(repo)
chartOfAccountID := uuid.New()
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
@ -113,10 +68,9 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
Total: 10000,
Items: []models.CreateExpenseItemRequest{
{
ChartOfAccountID: chartOfAccountID.String(),
PurchaseCategoryID: purchaseCategoryID.String(),
Item: "Cleaning supplies",
Amount: 10000,
ChartOfAccountID: chartOfAccountID.String(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
})
@ -125,15 +79,13 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
require.NotNil(t, resp)
require.Len(t, repo.createdItems, 1)
require.Equal(t, "Cleaning supplies", repo.createdItems[0].Item)
require.Equal(t, purchaseCategoryID, repo.createdItems[0].PurchaseCategoryID)
require.Len(t, resp.Items, 1)
require.Equal(t, "Cleaning supplies", resp.Items[0].Item)
}
func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
repo := &expenseRepositoryCaptureStub{}
purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
p := NewExpenseProcessorImpl(repo)
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
Receiver: "Cashier",
@ -143,10 +95,9 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
Total: 10000,
Items: []models.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: purchaseCategoryID.String(),
Item: "Cleaning supplies",
Amount: 10000,
ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
})
@ -159,8 +110,7 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
repo := &expenseRepositoryCaptureStub{}
purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
p := NewExpenseProcessorImpl(repo)
status := "approved"
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
@ -172,10 +122,9 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
Total: 10000,
Items: []models.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: purchaseCategoryID.String(),
Item: "Cleaning supplies",
Amount: 10000,
ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
})
@ -185,107 +134,3 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
require.Equal(t, "approved", repo.createdExpense.Status)
require.Equal(t, "approved", resp.Status)
}
func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) {
repo := &expenseRepositoryCaptureStub{}
purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial))
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
Receiver: "Cashier",
TransactionDate: "2026-05-29",
CodeNumber: "EXP-001",
OutletID: uuid.NewString(),
Total: 10000,
Items: []models.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: purchaseCategoryID.String(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
})
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "expense")
}
func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) {
coaID := uuid.New()
purchaseCategoryID := uuid.New()
outletID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
repo := &expenseRepositoryCaptureStub{
analytics: &entities.ExpenseAnalytics{
Summary: entities.ExpenseAnalyticsSummary{
TotalExpenses: 100000,
TotalExpenseCount: 2,
TotalTax: 10000,
AverageExpenseValue: 50000,
TotalCategories: 1,
TotalItems: 2,
},
Data: []entities.ExpenseAnalyticsData{
{
Date: now,
Expenses: 100000,
ExpenseCount: 2,
Tax: 10000,
Items: 2,
Categories: 1,
},
},
CategoryData: []entities.ExpenseAnalyticsCategoryData{
{
PurchaseCategoryID: purchaseCategoryID,
PurchaseCategoryName: "Operational Supplies",
PurchaseCategoryType: "expense",
TotalAmount: 100000,
ExpenseCount: 2,
ItemCount: 2,
},
},
ChartOfAccountData: []entities.ExpenseAnalyticsChartOfAccountData{
{
ChartOfAccountID: coaID,
ChartOfAccountName: "Operational",
TotalAmount: 100000,
ExpenseCount: 2,
ItemCount: 2,
},
},
ItemData: []entities.ExpenseAnalyticsItemData{
{
Item: "Cleaning supplies",
TotalAmount: 100000,
ExpenseCount: 2,
ItemCount: 2,
},
},
},
}
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{
OrganizationID: uuid.New(),
OutletID: &outletID,
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, "day", resp.GroupBy)
require.Equal(t, &outletID, resp.OutletID)
require.Equal(t, float64(100000), resp.Summary.TotalExpenses)
require.Len(t, resp.Data, 1)
require.Equal(t, int64(2), resp.Data[0].ExpenseCount)
require.Len(t, resp.CategoryData, 1)
require.Equal(t, purchaseCategoryID, resp.CategoryData[0].PurchaseCategoryID)
require.Len(t, resp.ChartOfAccountData, 1)
require.Equal(t, coaID, resp.ChartOfAccountData[0].ChartOfAccountID)
require.Len(t, resp.ItemData, 1)
require.Equal(t, "Cleaning supplies", resp.ItemData[0].Item)
}

View File

@ -3,7 +3,6 @@ package processor
import (
"apskel-pos-be/internal/entities"
"context"
"time"
"github.com/google/uuid"
)
@ -15,7 +14,6 @@ type ExpenseRepository interface {
Update(ctx context.Context, expense *entities.Expense) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.Expense, int64, error)
GetAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ExpenseAnalytics, error)
CreateItem(ctx context.Context, item *entities.ExpenseItem) error
DeleteItemsByExpenseID(ctx context.Context, expenseID uuid.UUID) error
}

View File

@ -86,7 +86,7 @@ type CustomerRepository interface {
}
type InventoryMovementService interface {
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
}
@ -338,7 +338,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
ProductID: itemReq.ProductID,
ProductVariantID: itemReq.ProductVariantID,
Quantity: itemReq.Quantity,
UnitPrice: unitPrice,
UnitPrice: unitPrice, // Use price from database
TotalPrice: itemTotalPrice,
UnitCost: unitCost,
TotalCost: itemTotalCost,
@ -594,10 +594,6 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return fmt.Errorf("order item does not belong to this order")
}
if orderItem.Status == entities.OrderItemStatusCancelled {
return fmt.Errorf("order item %s is already cancelled", orderItemID)
}
if itemVoid.Quantity > orderItem.Quantity {
return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID)
}
@ -618,15 +614,9 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return fmt.Errorf("outlet not found: %w", err)
}
// Reload order to get latest state
order, err = p.orderRepo.GetByID(ctx, req.OrderID)
if err != nil {
return fmt.Errorf("failed to reload order: %w", err)
}
order.Subtotal -= totalVoidedAmount
order.TotalCost -= totalVoidedCost
order.TaxAmount = order.Subtotal * outlet.TaxRate
order.TaxAmount = order.Subtotal * outlet.TaxRate // Recalculate tax using outlet's tax rate
order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount
if err := p.orderRepo.Update(ctx, order); err != nil {

View File

@ -1,210 +0,0 @@
package processor
import (
"context"
"fmt"
"strings"
"unicode"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
)
type PurchaseCategoryProcessor interface {
CreatePurchaseCategory(ctx context.Context, req *models.CreatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error)
UpdatePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error)
DeletePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID) error
GetPurchaseCategoryByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseCategoryResponse, error)
ListPurchaseCategories(ctx context.Context, req *models.ListPurchaseCategoriesRequest) ([]models.PurchaseCategoryResponse, int, error)
}
type PurchaseCategoryRepository interface {
Create(ctx context.Context, category *entities.PurchaseCategory) error
GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.PurchaseCategory, error)
Update(ctx context.Context, category *entities.PurchaseCategory) error
SoftDelete(ctx context.Context, id, organizationID uuid.UUID) error
List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.PurchaseCategory, int64, error)
ExistsByCode(ctx context.Context, organizationID uuid.UUID, code string, excludeID *uuid.UUID) (bool, error)
}
type PurchaseCategoryProcessorImpl struct {
purchaseCategoryRepo PurchaseCategoryRepository
}
func NewPurchaseCategoryProcessorImpl(purchaseCategoryRepo PurchaseCategoryRepository) *PurchaseCategoryProcessorImpl {
return &PurchaseCategoryProcessorImpl{purchaseCategoryRepo: purchaseCategoryRepo}
}
func (p *PurchaseCategoryProcessorImpl) CreatePurchaseCategory(ctx context.Context, req *models.CreatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) {
code := ""
if req.Code != nil && strings.TrimSpace(*req.Code) != "" {
code = normalizePurchaseCategoryCode(*req.Code)
} else {
code = normalizePurchaseCategoryCode(req.Name)
}
if code == "" {
return nil, fmt.Errorf("purchase category code cannot be empty")
}
exists, err := p.purchaseCategoryRepo.ExistsByCode(ctx, req.OrganizationID, code, nil)
if err != nil {
return nil, fmt.Errorf("failed to check purchase category code uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("purchase category with code '%s' already exists", code)
}
if req.ParentID != nil {
parent, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, *req.ParentID, req.OrganizationID)
if err != nil {
return nil, fmt.Errorf("parent purchase category not found: %w", err)
}
if string(parent.Type) != req.Type {
return nil, fmt.Errorf("parent purchase category type must match child type")
}
}
category := mappers.CreatePurchaseCategoryRequestToEntity(req)
category.Code = code
if err := p.purchaseCategoryRepo.Create(ctx, category); err != nil {
return nil, fmt.Errorf("failed to create purchase category: %w", err)
}
return mappers.PurchaseCategoryEntityToResponse(category), nil
}
func (p *PurchaseCategoryProcessorImpl) UpdatePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseCategoryRequest) (*models.PurchaseCategoryResponse, error) {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("purchase category not found: %w", err)
}
newType := string(category.Type)
if req.Type != nil {
newType = *req.Type
}
if req.ParentID != nil {
if *req.ParentID == id {
return nil, fmt.Errorf("purchase category cannot be its own parent")
}
parent, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, *req.ParentID, organizationID)
if err != nil {
return nil, fmt.Errorf("parent purchase category not found: %w", err)
}
if string(parent.Type) != newType {
return nil, fmt.Errorf("parent purchase category type must match child type")
}
category.ParentID = req.ParentID
}
if req.Code != nil {
code := normalizePurchaseCategoryCode(*req.Code)
if code == "" {
return nil, fmt.Errorf("purchase category code cannot be empty")
}
if code != category.Code {
exists, err := p.purchaseCategoryRepo.ExistsByCode(ctx, organizationID, code, &id)
if err != nil {
return nil, fmt.Errorf("failed to check purchase category code uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("purchase category with code '%s' already exists", code)
}
category.Code = code
}
}
if req.Name != nil {
category.Name = strings.TrimSpace(*req.Name)
}
if req.Type != nil {
category.Type = entities.PurchaseCategoryType(*req.Type)
}
if req.SortOrder != nil {
category.SortOrder = *req.SortOrder
}
if req.IsActive != nil {
category.IsActive = *req.IsActive
}
if err := p.purchaseCategoryRepo.Update(ctx, category); err != nil {
return nil, fmt.Errorf("failed to update purchase category: %w", err)
}
return mappers.PurchaseCategoryEntityToResponse(category), nil
}
func (p *PurchaseCategoryProcessorImpl) DeletePurchaseCategory(ctx context.Context, id, organizationID uuid.UUID) error {
_, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return fmt.Errorf("purchase category not found: %w", err)
}
if err := p.purchaseCategoryRepo.SoftDelete(ctx, id, organizationID); err != nil {
return fmt.Errorf("failed to delete purchase category: %w", err)
}
return nil
}
func (p *PurchaseCategoryProcessorImpl) GetPurchaseCategoryByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseCategoryResponse, error) {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("purchase category not found: %w", err)
}
return mappers.PurchaseCategoryEntityToResponse(category), nil
}
func (p *PurchaseCategoryProcessorImpl) ListPurchaseCategories(ctx context.Context, req *models.ListPurchaseCategoriesRequest) ([]models.PurchaseCategoryResponse, int, error) {
filters := make(map[string]interface{})
if req.ParentID != nil {
filters["parent_id"] = *req.ParentID
}
if req.Type != "" {
filters["type"] = req.Type
}
if req.Search != "" {
filters["search"] = req.Search
}
if req.IsActive != nil {
filters["is_active"] = *req.IsActive
}
offset := (req.Page - 1) * req.Limit
categories, total, err := p.purchaseCategoryRepo.List(ctx, req.OrganizationID, filters, req.Limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to list purchase categories: %w", err)
}
return mappers.PurchaseCategoryEntitiesToResponses(categories), int(total), nil
}
func normalizePurchaseCategoryCode(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
var builder strings.Builder
lastUnderscore := false
for _, r := range value {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
builder.WriteRune(r)
lastUnderscore = false
continue
}
if !lastUnderscore {
builder.WriteRune('_')
lastUnderscore = true
}
}
return strings.Trim(builder.String(), "_")
}

View File

@ -25,7 +25,6 @@ type PurchaseOrderProcessorImpl struct {
purchaseOrderRepo PurchaseOrderRepository
vendorRepo VendorRepository
ingredientRepo IngredientRepository
purchaseCategoryRepo PurchaseCategoryRepository
unitRepo UnitRepository
fileRepo FileRepository
inventoryMovementService InventoryMovementService
@ -36,7 +35,6 @@ func NewPurchaseOrderProcessorImpl(
purchaseOrderRepo PurchaseOrderRepository,
vendorRepo VendorRepository,
ingredientRepo IngredientRepository,
purchaseCategoryRepo PurchaseCategoryRepository,
unitRepo UnitRepository,
fileRepo FileRepository,
inventoryMovementService InventoryMovementService,
@ -46,7 +44,6 @@ func NewPurchaseOrderProcessorImpl(
purchaseOrderRepo: purchaseOrderRepo,
vendorRepo: vendorRepo,
ingredientRepo: ingredientRepo,
purchaseCategoryRepo: purchaseCategoryRepo,
unitRepo: unitRepo,
fileRepo: fileRepo,
inventoryMovementService: inventoryMovementService,
@ -67,40 +64,16 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber)
}
// Validate categories and inventory fields per item type.
// Validate ingredients and units exist
for i, item := range req.Items {
category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i)
_, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
if err != nil {
return nil, err
return nil, fmt.Errorf("ingredient 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)
_, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found for item %d: %w", i, err)
}
}
@ -136,13 +109,12 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
// Create purchase order items
for _, itemReq := range req.Items {
itemEntity := &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID,
IngredientID: itemReq.IngredientID,
PurchaseCategoryID: itemReq.PurchaseCategoryID,
Description: itemReq.Description,
Quantity: itemReq.Quantity,
UnitID: itemReq.UnitID,
Amount: itemReq.Amount,
PurchaseOrderID: poEntity.ID,
IngredientID: itemReq.IngredientID,
Description: itemReq.Description,
Quantity: itemReq.Quantity,
UnitID: itemReq.UnitID,
Amount: itemReq.Amount,
}
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
@ -217,80 +189,68 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
// Update items if provided
if req.Items != nil {
totalAmount := 0.0
items := make([]*entities.PurchaseOrderItem, len(req.Items))
for i, itemReq := range req.Items {
if itemReq.PurchaseCategoryID == nil {
return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
}
ingredientID := itemReq.IngredientID
purchaseCategoryID := *itemReq.PurchaseCategoryID
unitID := itemReq.UnitID
quantity := itemReq.Quantity
amount := 0.0
if itemReq.Amount != nil {
amount = *itemReq.Amount
}
description := itemReq.Description
category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i)
if err != nil {
return nil, err
}
switch category.Type {
case entities.PurchaseCategoryTypeRawMaterial:
if ingredientID == nil {
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
}
if quantity == nil {
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
}
if unitID == nil {
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
}
_, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID)
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,
Description: description,
Quantity: quantity,
UnitID: unitID,
Amount: amount,
}
totalAmount += amount
}
// Delete and recreate only after all replacement items are valid.
// Delete existing items
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 {
// Create new items
totalAmount := 0.0
for _, 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.UnitID != nil {
_, err := p.unitRepo.GetByID(ctx, *itemReq.UnitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found: %w", err)
}
}
// Use existing values if not provided
ingredientID := poEntity.Items[0].IngredientID // This is a simplified approach
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.Quantity != nil {
quantity = *itemReq.Quantity
}
if itemReq.Amount != nil {
amount = *itemReq.Amount
}
if itemReq.Description != nil {
description = itemReq.Description
}
itemEntity := &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID,
IngredientID: ingredientID,
Description: description,
Quantity: quantity,
UnitID: unitID,
Amount: amount,
}
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
@ -419,27 +379,19 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
// Update inventory for each item
for _, item := range poWithItems.Items {
if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense {
continue
}
if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil {
return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID)
}
// Get ingredient to find its base unit
ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
ingredient, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err)
return nil, fmt.Errorf("failed to get ingredient %s: %w", item.IngredientID, err)
}
// Convert quantity to ingredient's base unit if needed
quantityToAdd := *item.Quantity
if *item.UnitID != ingredient.UnitID {
quantityToAdd := item.Quantity
if item.UnitID != ingredient.UnitID {
// Convert from purchase unit to ingredient's base unit
convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity)
convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, item.IngredientID, item.UnitID, ingredient.UnitID, organizationID, item.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err)
return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", item.IngredientID, item.UnitID, ingredient.UnitID, err)
}
quantityToAdd = convertedQuantity
}
@ -457,7 +409,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
err = p.inventoryMovementService.CreateIngredientMovement(
ctx,
*item.IngredientID,
item.IngredientID,
organizationID,
outletID,
userID,
@ -467,10 +419,9 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
reason,
&referenceType,
referenceID,
&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)
}
}
}
@ -489,20 +440,3 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
}
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
if err != nil {
return nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
}
if !category.IsActive {
return nil, fmt.Errorf("purchase category for item %d is inactive", itemIndex)
}
if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense {
return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex)
}
return category, nil
}

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) {
var summary entities.PurchasingSummary
var outletName *string
if outletID != nil {
@ -144,29 +143,32 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
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).
Table("inventory_movements im").
Table("purchase_orders po").
Select(`
COALESCE(SUM(im.total_cost), 0) as total_purchases,
COUNT(DISTINCT im.reference_id) as total_purchase_orders,
COALESCE(SUM(im.quantity), 0) as total_quantity,
COALESCE(SUM(poi.amount), 0) as total_purchases,
COUNT(DISTINCT po.id) as total_purchase_orders,
COALESCE(SUM(poi.quantity), 0) as total_quantity,
CASE
WHEN COUNT(DISTINCT im.reference_id) > 0
THEN COALESCE(SUM(im.total_cost), 0) / COUNT(DISTINCT im.reference_id)
WHEN COUNT(DISTINCT po.id) > 0
THEN COALESCE(SUM(poi.amount), 0) / COUNT(DISTINCT po.id)
ELSE 0
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
`).
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo)
summaryQuery = r.resolveOutletID(summaryQuery, outletID, "im.outlet_id")
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
Where("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
if err := summaryQuery.Scan(&summary).Error; err != nil {
return nil, err
@ -175,36 +177,35 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
var dateFormat string
switch groupBy {
case "hour":
dateFormat = "DATE_TRUNC('hour', im.created_at)"
dateFormat = "DATE_TRUNC('hour', po.created_at)"
case "week":
dateFormat = "DATE_TRUNC('week', im.created_at)"
dateFormat = "DATE_TRUNC('week', po.transaction_date::timestamp)"
case "month":
dateFormat = "DATE_TRUNC('month', im.created_at)"
dateFormat = "DATE_TRUNC('month', po.transaction_date::timestamp)"
default:
dateFormat = "DATE_TRUNC('day', im.created_at)"
dateFormat = "DATE_TRUNC('day', po.transaction_date::timestamp)"
}
var data []entities.PurchasingAnalyticsData
dataQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Table("purchase_orders po").
Select(`
`+dateFormat+` as date,
COALESCE(SUM(im.total_cost), 0) as purchases,
COUNT(DISTINCT im.reference_id) as purchase_orders,
COALESCE(SUM(im.quantity), 0) as quantity,
COUNT(DISTINCT im.item_id) as ingredients,
COALESCE(SUM(poi.amount), 0) as purchases,
COUNT(DISTINCT po.id) as purchase_orders,
COALESCE(SUM(poi.quantity), 0) as quantity,
COUNT(DISTINCT i.id) as ingredients,
COUNT(DISTINCT po.vendor_id) as vendors
`).
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
Where("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat).
Order(dateFormat)
dataQuery = r.resolveOutletID(dataQuery, outletID, "im.outlet_id")
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID)
if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err
@ -212,29 +213,28 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
var ingredientData []entities.PurchasingIngredientData
ingredientQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Table("purchase_order_items poi").
Select(`
i.id as ingredient_id,
i.name as ingredient_name,
COALESCE(SUM(im.quantity), 0) as quantity,
COALESCE(SUM(im.total_cost), 0) as total_cost,
COALESCE(SUM(poi.quantity), 0) as quantity,
COALESCE(SUM(poi.amount), 0) as total_cost,
CASE
WHEN SUM(im.quantity) > 0
THEN COALESCE(SUM(im.total_cost), 0) / SUM(im.quantity)
WHEN SUM(poi.quantity) > 0
THEN COALESCE(SUM(poi.amount), 0) / SUM(poi.quantity)
ELSE 0
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").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
Where("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("i.id, i.name").
Order("total_cost DESC")
ingredientQuery = r.resolveOutletID(ingredientQuery, outletID, "im.outlet_id")
ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID)
if err := ingredientQuery.Scan(&ingredientData).Error; err != nil {
return nil, err
@ -242,26 +242,25 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
var vendorData []entities.PurchasingVendorData
vendorQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Table("purchase_orders po").
Select(`
v.id as vendor_id,
v.name as vendor_name,
COALESCE(SUM(im.total_cost), 0) as total_cost,
COUNT(DISTINCT im.reference_id) as purchase_order_count,
COUNT(DISTINCT im.item_id) as ingredient_count,
COALESCE(SUM(im.quantity), 0) as quantity
COALESCE(SUM(poi.amount), 0) as total_cost,
COUNT(DISTINCT po.id) as purchase_order_count,
COUNT(DISTINCT i.id) as ingredient_count,
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").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
Where("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("v.id, v.name").
Order("total_cost DESC")
vendorQuery = r.resolveOutletID(vendorQuery, outletID, "im.outlet_id")
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
if err := vendorQuery.Scan(&vendorData).Error; err != nil {
return nil, err
@ -276,6 +275,13 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
}, 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) {
var results []*entities.ProductAnalytics
@ -284,7 +290,6 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
Select(`
p.id as product_id,
p.name as product_name,
p.price as product_price,
c.id as category_id,
c.name as category_name,
c.order as category_order,
@ -343,7 +348,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
query = r.resolveOutletID(query, outletID, "o.outlet_id")
err := query.
Group("p.id, p.name, p.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").
Limit(limit).
Scan(&results).Error
@ -638,7 +643,7 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga
query := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`COALESCE(parent_coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`).
Select(`COALESCE(parent_coa.name, coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`).
Joins("JOIN expenses e ON ei.expense_id = e.id").
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id").
@ -651,8 +656,8 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga
}
err := query.
Group("parent_coa.name").
Order("parent_coa.name").
Group("COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
Order("COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
Scan(&results).Error
return results, err

View File

@ -30,7 +30,6 @@ func (r *ExpenseRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*ent
var expense entities.Expense
err := r.db.WithContext(ctx).
Preload("Items.ChartOfAccount").
Preload("Items.PurchaseCategory").
First(&expense, "id = ?", id).Error
if err != nil {
return nil, err
@ -42,7 +41,6 @@ func (r *ExpenseRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id
var expense entities.Expense
err := r.db.WithContext(ctx).
Preload("Items.ChartOfAccount").
Preload("Items.PurchaseCategory").
Where("id = ? AND organization_id = ?", id, organizationID).
First(&expense).Error
if err != nil {
@ -109,7 +107,6 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU
err := query.
Preload("Items.ChartOfAccount").
Preload("Items.PurchaseCategory").
Order("created_at DESC").
Limit(limit).
Offset(offset).
@ -117,165 +114,6 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU
return expenses, total, err
}
func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ExpenseAnalytics, error) {
var summary entities.ExpenseAnalyticsSummary
summaryQuery := r.db.WithContext(ctx).
Table("expenses e").
Select(`
COALESCE(SUM(e.total), 0) as total_expenses,
COUNT(e.id) as total_expense_count,
COALESCE(SUM(e.tax), 0) as total_tax,
COALESCE(AVG(e.total), 0) as average_expense_value
`).
Where("e.organization_id = ?", organizationID).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
if outletID != nil {
summaryQuery = summaryQuery.Where("e.outlet_id = ?", *outletID)
}
if err := summaryQuery.Scan(&summary).Error; err != nil {
return nil, err
}
countsQuery := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`
COUNT(ei.id) as total_items,
COUNT(DISTINCT ei.purchase_category_id) as total_categories
`).
Joins("JOIN expenses e ON ei.expense_id = e.id").
Where("e.organization_id = ?", organizationID).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo)
if outletID != nil {
countsQuery = countsQuery.Where("e.outlet_id = ?", *outletID)
}
if err := countsQuery.Scan(&summary).Error; err != nil {
return nil, err
}
dateFormat := "DATE_TRUNC('day', e.transaction_date)"
switch groupBy {
case "hour":
dateFormat = "DATE_TRUNC('hour', e.transaction_date)"
case "week":
dateFormat = "DATE_TRUNC('week', e.transaction_date)"
case "month":
dateFormat = "DATE_TRUNC('month', e.transaction_date)"
}
var data []entities.ExpenseAnalyticsData
dataQuery := r.db.WithContext(ctx).
Table("expenses e").
Select(`
`+dateFormat+` as date,
COALESCE(SUM(e.total), 0) as expenses,
COUNT(e.id) as expense_count,
COALESCE(SUM(e.tax), 0) as tax,
COALESCE(SUM(item_counts.items), 0) as items,
COALESCE(SUM(item_counts.categories), 0) as categories
`).
Joins(`LEFT JOIN (
SELECT expense_id, COUNT(id) as items, COUNT(DISTINCT purchase_category_id) as categories
FROM expense_items
GROUP BY expense_id
) item_counts ON item_counts.expense_id = e.id`).
Where("e.organization_id = ?", organizationID).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat).
Order(dateFormat)
if outletID != nil {
dataQuery = dataQuery.Where("e.outlet_id = ?", *outletID)
}
if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err
}
var categoryData []entities.ExpenseAnalyticsCategoryData
categoryQuery := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`
pc.id as purchase_category_id,
pc.name as purchase_category_name,
pc.type as purchase_category_type,
COALESCE(SUM(ei.amount), 0) as total_amount,
COUNT(DISTINCT e.id) as expense_count,
COUNT(ei.id) as item_count
`).
Joins("JOIN expenses e ON ei.expense_id = e.id").
Joins("JOIN purchase_categories pc ON ei.purchase_category_id = pc.id").
Where("e.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo).
Group("pc.id, pc.name, pc.type").
Order("total_amount DESC")
if outletID != nil {
categoryQuery = categoryQuery.Where("e.outlet_id = ?", *outletID)
}
if err := categoryQuery.Scan(&categoryData).Error; err != nil {
return nil, err
}
var chartOfAccountData []entities.ExpenseAnalyticsChartOfAccountData
chartOfAccountQuery := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`
COALESCE(parent_coa.id, coa.id) as chart_of_account_id,
COALESCE(parent_coa.name, coa.name, 'Lain-lain') as chart_of_account_name,
COALESCE(SUM(ei.amount), 0) as total_amount,
COUNT(DISTINCT e.id) as expense_count,
COUNT(ei.id) as item_count
`).
Joins("JOIN expenses e ON ei.expense_id = e.id").
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id").
Where("e.organization_id = ?", organizationID).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo).
Group("COALESCE(parent_coa.id, coa.id), COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
Order("total_amount DESC")
if outletID != nil {
chartOfAccountQuery = chartOfAccountQuery.Where("e.outlet_id = ?", *outletID)
}
if err := chartOfAccountQuery.Scan(&chartOfAccountData).Error; err != nil {
return nil, err
}
var itemData []entities.ExpenseAnalyticsItemData
itemQuery := r.db.WithContext(ctx).
Table("expense_items ei").
Select(`
COALESCE(NULLIF(ei.item, ''), ei.description, coa.name) as item,
COALESCE(SUM(ei.amount), 0) as total_amount,
COUNT(DISTINCT e.id) as expense_count,
COUNT(ei.id) as item_count
`).
Joins("JOIN expenses e ON ei.expense_id = e.id").
Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id").
Where("e.organization_id = ?", organizationID).
Where("e.status = ?", "approved").
Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo).
Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)").
Order("total_amount DESC")
if outletID != nil {
itemQuery = itemQuery.Where("e.outlet_id = ?", *outletID)
}
if err := itemQuery.Scan(&itemData).Error; err != nil {
return nil, err
}
return &entities.ExpenseAnalytics{
Summary: summary,
Data: data,
CategoryData: categoryData,
ChartOfAccountData: chartOfAccountData,
ItemData: itemData,
}, nil
}
func (r *ExpenseRepositoryImpl) CreateItem(ctx context.Context, item *entities.ExpenseItem) error {
return r.db.WithContext(ctx).Create(item).Error
}

View File

@ -2,8 +2,6 @@ package repository
import (
"context"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/entities"
@ -112,29 +110,3 @@ func (r *OrganizationRepositoryImpl) GetTotalOmset(ctx context.Context, organiza
Scan(&total).Error
return total, err
}
// GetTodayOmset returns the total revenue from completed orders for an organization on the current calendar day.
func (r *OrganizationRepositoryImpl) GetTodayOmset(ctx context.Context, organizationID uuid.UUID) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("orders").
Where(
"organization_id = ? AND payment_status = ? AND is_void = ? AND is_refund = ? AND created_at >= ? AND created_at < ?",
organizationID, "completed", false, false,
todayStart(), tomorrowStart(),
).
Select("COALESCE(SUM(total_amount), 0)").
Scan(&total).Error
return total, err
}
// todayStart returns midnight of the current local day.
func todayStart() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
}
// tomorrowStart returns midnight of the next local day.
func tomorrowStart() time.Time {
return todayStart().AddDate(0, 0, 1)
}

View File

@ -3,7 +3,6 @@ package repository
import (
"apskel-pos-be/internal/entities"
"context"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
@ -104,22 +103,3 @@ func (r *OutletRepositoryImpl) Count(ctx context.Context, filters map[string]int
err := query.Count(&count).Error
return count, err
}
// GetTodayOmset returns the total revenue from completed orders for an outlet on the current calendar day.
func (r *OutletRepositoryImpl) GetTodayOmset(ctx context.Context, outletID uuid.UUID) (float64, error) {
var total float64
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
tomorrowStart := todayStart.AddDate(0, 0, 1)
err := r.db.WithContext(ctx).
Table("orders").
Where(
"outlet_id = ? AND payment_status = ? AND is_void = ? AND is_refund = ? AND created_at >= ? AND created_at < ?",
outletID, "completed", false, false,
todayStart, tomorrowStart,
).
Select("COALESCE(SUM(total_amount), 0)").
Scan(&total).Error
return total, err
}

View File

@ -1,91 +0,0 @@
package repository
import (
"context"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type PurchaseCategoryRepositoryImpl struct {
db *gorm.DB
}
func NewPurchaseCategoryRepositoryImpl(db *gorm.DB) *PurchaseCategoryRepositoryImpl {
return &PurchaseCategoryRepositoryImpl{db: db}
}
func (r *PurchaseCategoryRepositoryImpl) Create(ctx context.Context, category *entities.PurchaseCategory) error {
return r.db.WithContext(ctx).Create(category).Error
}
func (r *PurchaseCategoryRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.PurchaseCategory, error) {
var category entities.PurchaseCategory
err := r.db.WithContext(ctx).
First(&category, "id = ? AND organization_id = ?", id, organizationID).Error
if err != nil {
return nil, err
}
return &category, nil
}
func (r *PurchaseCategoryRepositoryImpl) Update(ctx context.Context, category *entities.PurchaseCategory) error {
return r.db.WithContext(ctx).Save(category).Error
}
func (r *PurchaseCategoryRepositoryImpl) SoftDelete(ctx context.Context, id, organizationID uuid.UUID) error {
return r.db.WithContext(ctx).
Model(&entities.PurchaseCategory{}).
Where("id = ? AND organization_id = ?", id, organizationID).
Update("is_active", false).Error
}
func (r *PurchaseCategoryRepositoryImpl) List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.PurchaseCategory, int64, error) {
var categories []*entities.PurchaseCategory
var total int64
query := r.db.WithContext(ctx).
Model(&entities.PurchaseCategory{}).
Where("organization_id = ?", organizationID)
for key, value := range filters {
switch key {
case "search":
searchValue := "%" + value.(string) + "%"
query = query.Where("name ILIKE ? OR code ILIKE ?", searchValue, searchValue)
case "parent_id":
query = query.Where("parent_id = ?", value)
case "type":
query = query.Where("type = ?", value)
case "is_active":
query = query.Where("is_active = ?", value)
}
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.
Order("parent_id NULLS FIRST, sort_order ASC, name ASC").
Limit(limit).
Offset(offset).
Find(&categories).Error
return categories, total, err
}
func (r *PurchaseCategoryRepositoryImpl) ExistsByCode(ctx context.Context, organizationID uuid.UUID, code string, excludeID *uuid.UUID) (bool, error) {
query := r.db.WithContext(ctx).
Model(&entities.PurchaseCategory{}).
Where("organization_id = ? AND code = ?", organizationID, code)
if excludeID != nil {
query = query.Where("id != ?", *excludeID)
}
var count int64
err := query.Count(&count).Error
return count > 0, err
}

View File

@ -31,7 +31,6 @@ func (r *PurchaseOrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID)
err := r.db.WithContext(ctx).
Preload("Vendor").
Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit").
Preload("Attachments.File").
First(&po, "id = ?", id).Error
@ -46,7 +45,6 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
err := r.db.WithContext(ctx).
Preload("Vendor").
Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit").
Preload("Attachments.File").
Where("id = ? AND organization_id = ?", id, organizationID).
@ -107,7 +105,6 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
err := query.
Preload("Vendor").
Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit").
Preload("Attachments.File").
Order("created_at DESC").
@ -171,7 +168,6 @@ func (r *PurchaseOrderRepositoryImpl) GetByStatus(ctx context.Context, organizat
Where("organization_id = ? AND status = ?", organizationID, status).
Preload("Vendor").
Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit").
Find(&pos).Error
return pos, err
@ -183,7 +179,6 @@ func (r *PurchaseOrderRepositoryImpl) GetOverdue(ctx context.Context, organizati
Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}).
Preload("Vendor").
Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit").
Find(&pos).Error
return pos, err
@ -224,7 +219,6 @@ func (r *PurchaseOrderRepositoryImpl) GetItemsByPurchaseOrderID(ctx context.Cont
var items []*entities.PurchaseOrderItem
err := r.db.WithContext(ctx).
Preload("Ingredient").
Preload("PurchaseCategory").
Preload("Unit").
Where("purchase_order_id = ?", purchaseOrderID).
Find(&items).Error

View File

@ -2,7 +2,6 @@ package repository
import (
"context"
"database/sql"
"gorm.io/gorm"
)
@ -38,15 +37,3 @@ func (m *TxManager) WithTransaction(ctx context.Context, fn func(ctx context.Con
return fn(ctxTx)
})
}
// WithTransactionOptions runs fn inside a DB transaction with custom TxOptions (e.g. isolation level).
func (m *TxManager) WithTransactionOptions(ctx context.Context, opts *sql.TxOptions, fn func(ctx context.Context) error) error {
if m == nil || m.db == nil {
return fn(ctx)
}
return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
ctxTx := context.WithValue(ctx, txKey, tx)
return fn(ctxTx)
}, opts)
}

View File

@ -9,7 +9,6 @@ import (
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
type Router struct {
@ -36,7 +35,6 @@ type Router struct {
productRecipeHandler *handler.ProductRecipeHandler
vendorHandler *handler.VendorHandler
purchaseOrderHandler *handler.PurchaseOrderHandler
purchaseCategoryHandler *handler.PurchaseCategoryHandler
unitConverterHandler *handler.IngredientUnitConverterHandler
chartOfAccountTypeHandler *handler.ChartOfAccountTypeHandler
chartOfAccountHandler *handler.ChartOfAccountHandler
@ -55,10 +53,9 @@ type Router struct {
expenseHandler *handler.ExpenseHandler
authMiddleware *middleware.AuthMiddleware
customerAuthMiddleware *middleware.CustomerAuthMiddleware
redisClient *redis.Client
}
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, redisClient *redis.Client) *Router {
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl) *Router {
return &Router{
config: cfg,
@ -83,7 +80,6 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
productRecipeHandler: handler.NewProductRecipeHandler(productRecipeService),
vendorHandler: handler.NewVendorHandler(vendorService, vendorValidator),
purchaseOrderHandler: handler.NewPurchaseOrderHandler(purchaseOrderService, purchaseOrderValidator),
purchaseCategoryHandler: handler.NewPurchaseCategoryHandler(purchaseCategoryService, purchaseCategoryValidator),
unitConverterHandler: handler.NewIngredientUnitConverterHandler(unitConverterService, unitConverterValidator),
chartOfAccountTypeHandler: handler.NewChartOfAccountTypeHandler(chartOfAccountTypeService, chartOfAccountTypeValidator),
chartOfAccountHandler: handler.NewChartOfAccountHandler(chartOfAccountService, chartOfAccountValidator),
@ -103,7 +99,6 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
selfOrderHandler: selfOrderHandler,
productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator),
expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator),
redisClient: redisClient,
}
}
@ -279,19 +274,19 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
orders.GET("", r.orderHandler.ListOrders)
orders.GET("/:id", r.orderHandler.GetOrderByID)
orders.POST("", r.orderHandler.CreateOrder)
orders.POST("/:id/add-items", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.AddToOrder)
orders.POST("/:id/add-items", r.orderHandler.AddToOrder)
orders.PUT("/:id", r.orderHandler.UpdateOrder)
orders.PUT("/:id/customer", r.orderHandler.SetOrderCustomer)
orders.POST("/void", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.VoidOrder)
orders.POST("/:id/refund", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.RefundOrder)
orders.POST("/void", r.orderHandler.VoidOrder)
orders.POST("/:id/refund", r.orderHandler.RefundOrder)
orders.POST("/split-bill", r.orderHandler.SplitBill)
}
payments := protected.Group("/payments")
payments.Use(r.authMiddleware.RequireAdminOrManager())
{
payments.POST("", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.CreatePayment)
payments.POST("/:id/refund", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.RefundPayment)
payments.POST("", r.orderHandler.CreatePayment)
payments.POST("/:id/refund", r.orderHandler.RefundPayment)
}
paymentMethods := protected.Group("/payment-methods")
@ -389,16 +384,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
purchaseOrders.DELETE("/:id", r.purchaseOrderHandler.DeletePurchaseOrder)
}
purchaseCategories := protected.Group("/purchase-categories")
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManager())
{
purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory)
purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories)
purchaseCategories.GET("/:id", r.purchaseCategoryHandler.GetPurchaseCategory)
purchaseCategories.PUT("/:id", r.purchaseCategoryHandler.UpdatePurchaseCategory)
purchaseCategories.DELETE("/:id", r.purchaseCategoryHandler.DeletePurchaseCategory)
}
unitConverters := protected.Group("/unit-converters")
unitConverters.Use(r.authMiddleware.RequireAdminOrManager())
{
@ -466,7 +451,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
{
expenses.POST("", r.expenseHandler.CreateExpense)
expenses.GET("", r.expenseHandler.ListExpenses)
expenses.GET("/analytics", r.expenseHandler.GetExpenseAnalytics)
expenses.GET("/:id", r.expenseHandler.GetExpense)
expenses.PUT("/:id", r.expenseHandler.UpdateExpense)
expenses.DELETE("/:id", r.expenseHandler.DeleteExpense)

View File

@ -19,7 +19,6 @@ type ExpenseService interface {
DeleteExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
GetExpenseByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
ListExpenses(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListExpenseRequest) *contract.Response
GetExpenseAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ExpenseAnalyticsRequest) *contract.Response
}
type ExpenseServiceImpl struct {
@ -127,24 +126,3 @@ func (s *ExpenseServiceImpl) ListExpenses(ctx context.Context, apctx *appcontext
return contract.BuildSuccessResponse(response)
}
func (s *ExpenseServiceImpl) GetExpenseAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ExpenseAnalyticsRequest) *contract.Response {
modelReq, err := transformer.ExpenseAnalyticsRequestToModel(req)
if err != nil {
errorResp := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.ExpenseServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
modelReq.OrganizationID = apctx.OrganizationID
if apctx.OutletID != uuid.Nil {
modelReq.OutletID = &apctx.OutletID
}
response, err := s.expenseProcessor.GetExpenseAnalytics(ctx, modelReq)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
return contract.BuildSuccessResponse(transformer.ExpenseAnalyticsModelToContract(response))
}

View File

@ -160,3 +160,4 @@ func (s *IngredientUnitConverterServiceImpl) GetUnitsByIngredientID(ctx context.
contractResponse := transformer.IngredientUnitsModelResponseToResponse(unitsResponse)
return contract.BuildSuccessResponse(contractResponse)
}

View File

@ -10,7 +10,7 @@ import (
)
type InventoryMovementService interface {
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
}
@ -26,7 +26,7 @@ func NewInventoryMovementService(inventoryMovementRepo repository.InventoryMovem
}
}
func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error {
func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error {
ingredient, err := s.ingredientRepo.GetByID(ctx, ingredientID, organizationID)
if err != nil {
return err
@ -36,23 +36,22 @@ func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Cont
newQuantity := previousQuantity + quantity
movement := &entities.InventoryMovement{
ID: uuid.New(),
OrganizationID: organizationID,
OutletID: outletID,
ItemID: ingredientID,
ItemType: "INGREDIENT",
MovementType: movementType,
Quantity: quantity,
PreviousQuantity: previousQuantity,
NewQuantity: newQuantity,
UnitCost: unitCost,
TotalCost: unitCost * quantity,
ReferenceType: referenceType,
ReferenceID: referenceID,
PurchaseOrderItemID: purchaseOrderItemID,
UserID: userID,
Reason: &reason,
CreatedAt: time.Now(),
ID: uuid.New(),
OrganizationID: organizationID,
OutletID: outletID,
ItemID: ingredientID,
ItemType: "INGREDIENT",
MovementType: movementType,
Quantity: quantity,
PreviousQuantity: previousQuantity,
NewQuantity: newQuantity,
UnitCost: unitCost,
TotalCost: unitCost * quantity,
ReferenceType: referenceType,
ReferenceID: referenceID,
UserID: userID,
Reason: &reason,
CreatedAt: time.Now(),
}
err = s.inventoryMovementRepo.Create(ctx, movement)

View File

@ -4,11 +4,11 @@ import (
"context"
"fmt"
"log"
"math"
"sync"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
@ -17,38 +17,32 @@ import (
)
const (
defaultCheckInterval = 5 * time.Minute
defaultCheckInterval = 1 * time.Hour
OmsetMillionRupiah = 1_000_000.0
)
// OmsetMilestoneScheduler periodically checks each outlet's omset for the
// current calendar day and sends a notification every time it crosses a new
// multiple of OmsetMillionRupiah (1 jt, 2 jt, 3 jt, …).
// OmsetMilestoneScheduler periodically checks each organization's total omset
// and sends a notification to owner/admin users when a milestone is reached.
//
// The notified state is keyed by "outletID:YYYY-MM-DD:N" so each multiple is
// only notified once per day. State resets naturally on the next day (new key).
// NOTE: state is in-memory; a server restart within the same day may re-send
// notifications for already-crossed milestones.
// NOTE: Milestone tracking is in-memory; notifications may re-trigger after a restart.
// For persistent tracking, persist the notified state in the database.
type OmsetMilestoneScheduler struct {
orgRepo *repository.OrganizationRepositoryImpl
outletRepo *repository.OutletRepositoryImpl
userRepo *repository.UserRepositoryImpl
notificationProc processor.NotificationProcessor
mu sync.Mutex
notified map[string]bool // "outletID:YYYY-MM-DD:N" -> already notified
notified map[string]bool // "orgID:milestone" -> already notified
stopCh chan struct{}
}
func NewOmsetMilestoneScheduler(
orgRepo *repository.OrganizationRepositoryImpl,
outletRepo *repository.OutletRepositoryImpl,
userRepo *repository.UserRepositoryImpl,
notificationProc processor.NotificationProcessor,
) *OmsetMilestoneScheduler {
return &OmsetMilestoneScheduler{
orgRepo: orgRepo,
outletRepo: outletRepo,
userRepo: userRepo,
notificationProc: notificationProc,
notified: make(map[string]bool),
@ -63,8 +57,8 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
}
go func() {
// Perform an initial check immediately on startup.
s.checkAllOutlets()
// Perform an initial check immediately.
s.checkAllOrganizations()
ticker := time.NewTicker(interval)
defer ticker.Stop()
@ -72,7 +66,7 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
for {
select {
case <-ticker.C:
s.checkAllOutlets()
s.checkAllOrganizations()
case <-s.stopCh:
log.Println("Omset milestone scheduler stopped")
return
@ -80,7 +74,7 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
}
}()
log.Printf("Omset milestone scheduler started (interval: %s)", interval)
log.Println("Omset milestone scheduler started")
}
// Stop signals the scheduler to stop.
@ -88,7 +82,7 @@ func (s *OmsetMilestoneScheduler) Stop() {
close(s.stopCh)
}
func (s *OmsetMilestoneScheduler) checkAllOutlets() {
func (s *OmsetMilestoneScheduler) checkAllOrganizations() {
ctx := context.Background()
orgs, _, err := s.orgRepo.List(ctx, nil, 1000, 0)
@ -98,38 +92,25 @@ func (s *OmsetMilestoneScheduler) checkAllOutlets() {
}
for _, org := range orgs {
outlets, err := s.outletRepo.GetByOrganizationID(ctx, org.ID)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to list outlets for org %s: %v", org.ID, err)
continue
}
for _, outlet := range outlets {
if !outlet.IsActive {
continue
}
s.checkOutlet(ctx, org.ID, outlet.ID, outlet.Name)
}
s.checkOrganization(ctx, org)
}
}
func (s *OmsetMilestoneScheduler) checkOutlet(ctx context.Context, organizationID, outletID uuid.UUID, outletName string) {
todayOmset, err := s.outletRepo.GetTodayOmset(ctx, outletID)
func (s *OmsetMilestoneScheduler) checkOrganization(ctx context.Context, org *entities.Organization) {
totalOmset, err := s.orgRepo.GetTotalOmset(ctx, org.ID)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to get today's omset for outlet %s: %v", outletID, err)
log.Printf("OmsetMilestoneScheduler: failed to get total omset for org %s: %v", org.ID, err)
return
}
if todayOmset < OmsetMillionRupiah {
return
}
milestones := []float64{OmsetMillionRupiah}
// How many full multiples of 1 juta have been crossed today?
crossedMultiple := int(math.Floor(todayOmset / OmsetMillionRupiah))
today := time.Now().Format("2006-01-02")
for _, milestone := range milestones {
if totalOmset < milestone {
continue
}
for n := 1; n <= crossedMultiple; n++ {
key := fmt.Sprintf("%s:%s:%d", outletID.String(), today, n)
key := fmt.Sprintf("%s:%.0f", org.ID.String(), milestone)
s.mu.Lock()
if s.notified[key] {
@ -139,31 +120,23 @@ func (s *OmsetMilestoneScheduler) checkOutlet(ctx context.Context, organizationI
s.notified[key] = true
s.mu.Unlock()
milestone := float64(n) * OmsetMillionRupiah
s.sendMilestoneNotification(ctx, organizationID, outletID, outletName, todayOmset, milestone, n)
s.sendMilestoneNotification(ctx, org, totalOmset, milestone)
}
}
func (s *OmsetMilestoneScheduler) sendMilestoneNotification(
ctx context.Context,
organizationID, outletID uuid.UUID,
outletName string,
todayOmset, milestone float64,
multiple int,
) {
// Fetch all users in the org, then filter to owner and manager only.
// These roles are not assigned to a specific outlet, so we query by org.
users, err := s.userRepo.GetByOrganizationID(ctx, organizationID)
func (s *OmsetMilestoneScheduler) sendMilestoneNotification(ctx context.Context, org *entities.Organization, totalOmset float64, milestone float64) {
users, err := s.userRepo.GetByOrganizationID(ctx, org.ID)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", organizationID, err)
log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", org.ID, err)
return
}
// Notify owner and admin users.
var receiverIDs []uuid.UUID
for _, u := range users {
role := string(u.Role)
if role == string(constants.RoleOwner) || role == string(constants.RoleManager) {
receiverIDs = append(receiverIDs, u.ID)
for _, user := range users {
roleStr := string(user.Role)
if roleStr == string(constants.RoleOwner) || roleStr == string(constants.RoleAdmin) {
receiverIDs = append(receiverIDs, user.ID)
}
}
@ -171,34 +144,28 @@ func (s *OmsetMilestoneScheduler) sendMilestoneNotification(
return
}
title := fmt.Sprintf("🎉 Omset %s Hari Ini Mencapai Rp %.0f!", outletName, milestone)
body := fmt.Sprintf(
"Selamat! Omset outlet %s hari ini sudah menembus Rp %.0f (total hari ini: Rp %.0f). Terus semangat!",
outletName, milestone, todayOmset,
)
orgID := org.ID
title := "🎉 Selamat! Omset Telah Mencapai 1 Juta Rupiah"
body := fmt.Sprintf("Organisasi %s telah mencapai omset Rp %.0f. Terus tingkatkan prestasinya!", org.Name, totalOmset)
notifReq := &models.SendNotificationRequest{
Title: title,
Body: body,
Type: "milestone",
Category: "omset_milestone",
NotifiableType: "outlet",
NotifiableID: &outletID,
NotifiableType: "organization",
NotifiableID: &orgID,
ReceiverIDs: receiverIDs,
Data: map[string]interface{}{
"organization_id": organizationID.String(),
"outlet_id": outletID.String(),
"outlet_name": outletName,
"today_omset": todayOmset,
"organization_id": org.ID.String(),
"total_omset": totalOmset,
"milestone": milestone,
"multiple": multiple,
},
}
if _, err := s.notificationProc.Send(ctx, notifReq); err != nil {
log.Printf("OmsetMilestoneScheduler: failed to send notification for outlet %s: %v", outletID, err)
log.Printf("OmsetMilestoneScheduler: failed to send notification for org %s: %v", org.ID, err)
} else {
log.Printf("OmsetMilestoneScheduler: sent milestone x%d (Rp %.0f) for outlet %s (today omset: %.0f)",
multiple, milestone, outletName, todayOmset)
log.Printf("OmsetMilestoneScheduler: sent milestone notification to org %s (omset: %.0f)", org.ID, totalOmset)
}
}

View File

@ -3,7 +3,6 @@ package service
import (
"apskel-pos-be/internal/appcontext"
"context"
"database/sql"
"fmt"
"time"
@ -229,9 +228,7 @@ func (s *OrderServiceImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, re
var response *models.AddToOrderResponse
var ingredientTransactions []*contract.CreateOrderIngredientTransactionRequest
err := s.txManager.WithTransactionOptions(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
}, func(txCtx context.Context) error {
err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
addResp, err := s.orderProcessor.AddToOrder(txCtx, orderID, req)
if err != nil {
return fmt.Errorf("failed to add items to order: %w", err)
@ -308,16 +305,8 @@ func (s *OrderServiceImpl) VoidOrder(ctx context.Context, req *models.VoidOrderR
return fmt.Errorf("invalid user ID")
}
err := s.txManager.WithTransactionOptions(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
}, func(txCtx context.Context) error {
if err := s.orderProcessor.VoidOrder(txCtx, req, voidedBy); err != nil {
return fmt.Errorf("failed to void order: %w", err)
}
return nil
})
if err != nil {
return err
if err := s.orderProcessor.VoidOrder(ctx, req, voidedBy); err != nil {
return fmt.Errorf("failed to void order: %w", err)
}
if err := s.handleTableReleaseOnVoid(ctx, req.OrderID); err != nil {
@ -572,14 +561,9 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen
return fmt.Errorf("payment item amount must be greater than zero for item %d", i+1)
}
fmt.Printf("[DEBUG] CreatePayment order_id=%s item[%d] order_item_id=%s amount=%.10f\n",
req.OrderID, i, item.OrderItemID, item.Amount)
totalItemAmount += item.Amount
}
fmt.Printf("[DEBUG] CreatePayment order_id=%s total_amount=%.10f sum_items=%.10f diff=%.10f\n",
req.OrderID, req.Amount, totalItemAmount, req.Amount-totalItemAmount)
if totalItemAmount != req.Amount {
return fmt.Errorf("sum of payment item amounts must equal total payment amount")
}

View File

@ -1,107 +0,0 @@
package service
import (
"context"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/transformer"
"github.com/google/uuid"
)
type PurchaseCategoryService interface {
CreatePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreatePurchaseCategoryRequest) *contract.Response
UpdatePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdatePurchaseCategoryRequest) *contract.Response
DeletePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
GetPurchaseCategoryByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
ListPurchaseCategories(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListPurchaseCategoriesRequest) *contract.Response
}
type PurchaseCategoryServiceImpl struct {
purchaseCategoryProcessor processor.PurchaseCategoryProcessor
}
func NewPurchaseCategoryService(purchaseCategoryProcessor processor.PurchaseCategoryProcessor) *PurchaseCategoryServiceImpl {
return &PurchaseCategoryServiceImpl{purchaseCategoryProcessor: purchaseCategoryProcessor}
}
func (s *PurchaseCategoryServiceImpl) CreatePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreatePurchaseCategoryRequest) *contract.Response {
modelReq := transformer.CreatePurchaseCategoryRequestToModel(apctx, req)
category, err := s.purchaseCategoryProcessor.CreatePurchaseCategory(ctx, modelReq)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
return contract.BuildSuccessResponse(transformer.PurchaseCategoryModelResponseToResponse(category))
}
func (s *PurchaseCategoryServiceImpl) UpdatePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdatePurchaseCategoryRequest) *contract.Response {
modelReq := transformer.UpdatePurchaseCategoryRequestToModel(req)
category, err := s.purchaseCategoryProcessor.UpdatePurchaseCategory(ctx, id, apctx.OrganizationID, modelReq)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
return contract.BuildSuccessResponse(transformer.PurchaseCategoryModelResponseToResponse(category))
}
func (s *PurchaseCategoryServiceImpl) DeletePurchaseCategory(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response {
err := s.purchaseCategoryProcessor.DeletePurchaseCategory(ctx, id, apctx.OrganizationID)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
return contract.BuildSuccessResponse(map[string]interface{}{
"message": "Purchase category deleted successfully",
})
}
func (s *PurchaseCategoryServiceImpl) GetPurchaseCategoryByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response {
category, err := s.purchaseCategoryProcessor.GetPurchaseCategoryByID(ctx, id, apctx.OrganizationID)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
return contract.BuildSuccessResponse(transformer.PurchaseCategoryModelResponseToResponse(category))
}
func (s *PurchaseCategoryServiceImpl) ListPurchaseCategories(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListPurchaseCategoriesRequest) *contract.Response {
modelReq := &models.ListPurchaseCategoriesRequest{
OrganizationID: apctx.OrganizationID,
ParentID: req.ParentID,
Type: req.Type,
Search: req.Search,
IsActive: req.IsActive,
Page: req.Page,
Limit: req.Limit,
}
categories, totalCount, err := s.purchaseCategoryProcessor.ListPurchaseCategories(ctx, modelReq)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseCategoryServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
totalPages := totalCount / req.Limit
if totalCount%req.Limit > 0 {
totalPages++
}
return contract.BuildSuccessResponse(&contract.ListPurchaseCategoriesResponse{
PurchaseCategories: transformer.PurchaseCategoryModelResponsesToResponses(categories),
TotalCount: totalCount,
Page: req.Page,
Limit: req.Limit,
TotalPages: totalPages,
})
}

View File

@ -169,16 +169,12 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
data := make([]contract.PurchasingAnalyticsData, len(resp.Data))
for i, item := range resp.Data {
data[i] = contract.PurchasingAnalyticsData{
Date: item.Date,
Purchases: item.Purchases,
RawMaterialPurchases: item.RawMaterialPurchases,
ExpensePurchases: item.ExpensePurchases,
PurchaseOrders: item.PurchaseOrders,
RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
Date: item.Date,
Purchases: item.Purchases,
PurchaseOrders: item.PurchaseOrders,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
@ -215,11 +211,7 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
GroupBy: resp.GroupBy,
Summary: contract.PurchasingSummary{
TotalPurchases: resp.Summary.TotalPurchases,
RawMaterialPurchases: resp.Summary.RawMaterialPurchases,
ExpensePurchases: resp.Summary.ExpensePurchases,
TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: resp.Summary.RawMaterialPurchaseOrders,
ExpenseCount: resp.Summary.ExpenseCount,
TotalQuantity: resp.Summary.TotalQuantity,
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
TotalIngredients: resp.Summary.TotalIngredients,
@ -265,7 +257,6 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
ProductID: item.ProductID,
ProductName: item.ProductName,
ProductSku: item.ProductSku,
ProductPrice: item.ProductPrice,
CategoryID: item.CategoryID,
CategoryName: item.CategoryName,
CategoryOrder: item.CategoryOrder,
@ -376,7 +367,6 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
topProducts = append(topProducts, contract.ProductAnalyticsData{
ProductID: item.ProductID,
ProductName: item.ProductName,
ProductPrice: item.ProductPrice,
CategoryID: item.CategoryID,
CategoryName: item.CategoryName,
QuantitySold: item.QuantitySold,

View File

@ -52,47 +52,17 @@ func TestPurchasingAnalyticsContractToModelIgnoresInvalidOutlet(t *testing.T) {
func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) {
outletID := uuid.New()
outletName := "Main Outlet"
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
OrganizationID: uuid.New(),
OutletID: &outletID,
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.Equal(t, &outletID, result.OutletID)
require.NotNil(t, 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) {

View File

@ -3,7 +3,6 @@ package transformer
import (
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/util"
)
func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.CreateExpenseRequest {
@ -27,11 +26,10 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre
func CreateExpenseItemRequestToModel(req *contract.CreateExpenseItemRequest) models.CreateExpenseItemRequest {
return models.CreateExpenseItemRequest{
ChartOfAccountID: req.ChartOfAccountID,
PurchaseCategoryID: req.PurchaseCategoryID,
Item: req.Item,
Description: req.Description,
Amount: req.Amount,
ChartOfAccountID: req.ChartOfAccountID,
Item: req.Item,
Description: req.Description,
Amount: req.Amount,
}
}
@ -61,11 +59,10 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd
func UpdateExpenseItemRequestToModel(req *contract.UpdateExpenseItemRequest) models.UpdateExpenseItemRequest {
return models.UpdateExpenseItemRequest{
ChartOfAccountID: req.ChartOfAccountID,
PurchaseCategoryID: req.PurchaseCategoryID,
Item: req.Item,
Description: req.Description,
Amount: req.Amount,
ChartOfAccountID: req.ChartOfAccountID,
Item: req.Item,
Description: req.Description,
Amount: req.Amount,
}
}
@ -111,19 +108,15 @@ func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.E
func ExpenseItemModelResponseToResponse(item *models.ExpenseItemResponse) contract.ExpenseItemResponse {
return contract.ExpenseItemResponse{
ID: item.ID,
ExpenseID: item.ExpenseID,
ChartOfAccountID: item.ChartOfAccountID,
ChartOfAccountName: item.ChartOfAccountName,
PurchaseCategoryID: item.PurchaseCategoryID,
PurchaseCategoryName: item.PurchaseCategoryName,
PurchaseCategoryType: item.PurchaseCategoryType,
PurchaseCategory: PurchaseCategoryModelResponseToResponse(item.PurchaseCategory),
Item: item.Item,
Description: item.Description,
Amount: item.Amount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
ID: item.ID,
ExpenseID: item.ExpenseID,
ChartOfAccountID: item.ChartOfAccountID,
ChartOfAccountName: item.ChartOfAccountName,
Item: item.Item,
Description: item.Description,
Amount: item.Amount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
@ -141,94 +134,3 @@ func ExpenseModelResponsesToResponses(expenses []*models.ExpenseResponse) []cont
}
return responses
}
func ExpenseAnalyticsRequestToModel(req *contract.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsRequest, error) {
dateFrom, dateTo, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo)
if err != nil {
return nil, err
}
modelReq := &models.ExpenseAnalyticsRequest{
OutletID: parseOutletID(req.OutletID),
GroupBy: req.GroupBy,
}
if dateFrom != nil {
modelReq.DateFrom = *dateFrom
}
if dateTo != nil {
modelReq.DateTo = *dateTo
}
return modelReq, nil
}
func ExpenseAnalyticsModelToContract(resp *models.ExpenseAnalyticsResponse) *contract.ExpenseAnalyticsResponse {
if resp == nil {
return nil
}
data := make([]contract.ExpenseAnalyticsData, len(resp.Data))
for i, item := range resp.Data {
data[i] = contract.ExpenseAnalyticsData{
Date: item.Date,
Expenses: item.Expenses,
ExpenseCount: item.ExpenseCount,
Tax: item.Tax,
Items: item.Items,
Categories: item.Categories,
}
}
categoryData := make([]contract.ExpenseAnalyticsCategoryData, len(resp.CategoryData))
for i, item := range resp.CategoryData {
categoryData[i] = contract.ExpenseAnalyticsCategoryData{
PurchaseCategoryID: item.PurchaseCategoryID,
PurchaseCategoryName: item.PurchaseCategoryName,
PurchaseCategoryType: item.PurchaseCategoryType,
TotalAmount: item.TotalAmount,
ExpenseCount: item.ExpenseCount,
ItemCount: item.ItemCount,
}
}
chartOfAccountData := make([]contract.ExpenseAnalyticsChartOfAccountData, len(resp.ChartOfAccountData))
for i, item := range resp.ChartOfAccountData {
chartOfAccountData[i] = contract.ExpenseAnalyticsChartOfAccountData{
ChartOfAccountID: item.ChartOfAccountID,
ChartOfAccountName: item.ChartOfAccountName,
TotalAmount: item.TotalAmount,
ExpenseCount: item.ExpenseCount,
ItemCount: item.ItemCount,
}
}
itemData := make([]contract.ExpenseAnalyticsItemData, len(resp.ItemData))
for i, item := range resp.ItemData {
itemData[i] = contract.ExpenseAnalyticsItemData{
Item: item.Item,
TotalAmount: item.TotalAmount,
ExpenseCount: item.ExpenseCount,
ItemCount: item.ItemCount,
}
}
return &contract.ExpenseAnalyticsResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
GroupBy: resp.GroupBy,
Summary: contract.ExpenseAnalyticsSummary{
TotalExpenses: resp.Summary.TotalExpenses,
TotalExpenseCount: resp.Summary.TotalExpenseCount,
TotalTax: resp.Summary.TotalTax,
AverageExpenseValue: resp.Summary.AverageExpenseValue,
TotalCategories: resp.Summary.TotalCategories,
TotalItems: resp.Summary.TotalItems,
},
Data: data,
CategoryData: categoryData,
ChartOfAccountData: chartOfAccountData,
ItemData: itemData,
}
}

View File

@ -175,3 +175,4 @@ func IngredientUnitsModelResponseToResponse(model *models.IngredientUnitsRespons
return response
}

View File

@ -1,72 +0,0 @@
package transformer
import (
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
)
func CreatePurchaseCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreatePurchaseCategoryRequest) *models.CreatePurchaseCategoryRequest {
sortOrder := 0
if req.SortOrder != nil {
sortOrder = *req.SortOrder
}
isActive := true
if req.IsActive != nil {
isActive = *req.IsActive
}
return &models.CreatePurchaseCategoryRequest{
OrganizationID: apctx.OrganizationID,
ParentID: req.ParentID,
Code: req.Code,
Name: req.Name,
Type: req.Type,
SortOrder: sortOrder,
IsActive: isActive,
}
}
func UpdatePurchaseCategoryRequestToModel(req *contract.UpdatePurchaseCategoryRequest) *models.UpdatePurchaseCategoryRequest {
return &models.UpdatePurchaseCategoryRequest{
ParentID: req.ParentID,
Code: req.Code,
Name: req.Name,
Type: req.Type,
SortOrder: req.SortOrder,
IsActive: req.IsActive,
}
}
func PurchaseCategoryModelResponseToResponse(category *models.PurchaseCategoryResponse) *contract.PurchaseCategoryResponse {
if category == nil {
return nil
}
return &contract.PurchaseCategoryResponse{
ID: category.ID,
OrganizationID: category.OrganizationID,
PresetID: category.PresetID,
ParentID: category.ParentID,
Code: category.Code,
Name: category.Name,
Type: category.Type,
SortOrder: category.SortOrder,
IsSystem: category.IsSystem,
IsActive: category.IsActive,
CreatedAt: category.CreatedAt,
UpdatedAt: category.UpdatedAt,
}
}
func PurchaseCategoryModelResponsesToResponses(categories []models.PurchaseCategoryResponse) []contract.PurchaseCategoryResponse {
responses := make([]contract.PurchaseCategoryResponse, len(categories))
for i, category := range categories {
response := PurchaseCategoryModelResponseToResponse(&category)
if response != nil {
responses[i] = *response
}
}
return responses
}

View File

@ -11,12 +11,11 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest)
items := make([]models.CreatePurchaseOrderItemRequest, len(req.Items))
for i, item := range req.Items {
items[i] = models.CreatePurchaseOrderItemRequest{
IngredientID: item.IngredientID,
PurchaseCategoryID: item.PurchaseCategoryID,
Description: item.Description,
Quantity: item.Quantity,
UnitID: item.UnitID,
Amount: item.Amount,
IngredientID: item.IngredientID,
Description: item.Description,
Quantity: item.Quantity,
UnitID: item.UnitID,
Amount: item.Amount,
}
}
@ -55,13 +54,12 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
items = make([]models.UpdatePurchaseOrderItemRequest, len(req.Items))
for i, item := range req.Items {
items[i] = models.UpdatePurchaseOrderItemRequest{
ID: item.ID,
IngredientID: item.IngredientID,
PurchaseCategoryID: item.PurchaseCategoryID,
Description: item.Description,
Quantity: item.Quantity,
UnitID: item.UnitID,
Amount: item.Amount,
ID: item.ID,
IngredientID: item.IngredientID,
Description: item.Description,
Quantity: item.Quantity,
UnitID: item.UnitID,
Amount: item.Amount,
}
}
}
@ -156,16 +154,15 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
response.Items = make([]contract.PurchaseOrderItemResponse, len(po.Items))
for i, item := range po.Items {
response.Items[i] = contract.PurchaseOrderItemResponse{
ID: item.ID,
PurchaseOrderID: item.PurchaseOrderID,
IngredientID: item.IngredientID,
PurchaseCategoryID: item.PurchaseCategoryID,
Description: item.Description,
Quantity: item.Quantity,
UnitID: item.UnitID,
Amount: item.Amount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
ID: item.ID,
PurchaseOrderID: item.PurchaseOrderID,
IngredientID: item.IngredientID,
Description: item.Description,
Quantity: item.Quantity,
UnitID: item.UnitID,
Amount: item.Amount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
// Map ingredient if present
@ -176,10 +173,6 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
}
}
if item.PurchaseCategory != nil {
response.Items[i].PurchaseCategory = PurchaseCategoryModelResponseToResponse(item.PurchaseCategory)
}
// Map unit if present
if item.Unit != nil {
response.Items[i].Unit = &contract.UnitResponse{

View File

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

View File

@ -16,12 +16,6 @@ func HandleResponse(w http.ResponseWriter, r *http.Request, response *contract.R
} else {
responseError := response.GetErrors()[0]
statusCode = MapErrorCodeToHttpStatus(responseError.GetCode())
logger.FromContext(r.Context()).WithFields(map[string]interface{}{
"error_code": responseError.GetCode(),
"error_entity": responseError.GetEntity(),
"error_cause": responseError.GetCause(),
"status_code": statusCode,
}).Error(methodName)
}
WriteResponse(w, r, *response, statusCode, methodName)
}

View File

@ -68,18 +68,12 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create
if strings.TrimSpace(item.ChartOfAccountID) == "" {
return fmt.Errorf("item %d: chart_of_account_id is required", i), constants.MissingFieldErrorCode
}
if strings.TrimSpace(item.PurchaseCategoryID) == "" {
return fmt.Errorf("item %d: purchase_category_id is required", i), constants.MissingFieldErrorCode
}
if strings.TrimSpace(item.Item) == "" {
return fmt.Errorf("item %d: item is required", i), constants.MissingFieldErrorCode
}
if _, err := uuid.Parse(item.ChartOfAccountID); err != nil {
return fmt.Errorf("item %d: chart_of_account_id must be a valid UUID", i), constants.MalformedFieldErrorCode
}
if _, err := uuid.Parse(item.PurchaseCategoryID); err != nil {
return fmt.Errorf("item %d: purchase_category_id must be a valid UUID", i), constants.MalformedFieldErrorCode
}
if item.Amount <= 0 {
return fmt.Errorf("item %d: amount must be greater than 0", i), constants.MalformedFieldErrorCode
}
@ -132,15 +126,6 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update
return fmt.Errorf("item %d: chart_of_account_id must be a valid UUID", i), constants.MalformedFieldErrorCode
}
}
if item.PurchaseCategoryID == nil {
return fmt.Errorf("item %d: purchase_category_id is required", i), constants.MissingFieldErrorCode
}
if strings.TrimSpace(*item.PurchaseCategoryID) == "" {
return fmt.Errorf("item %d: purchase_category_id cannot be empty", i), constants.MalformedFieldErrorCode
}
if _, err := uuid.Parse(*item.PurchaseCategoryID); err != nil {
return fmt.Errorf("item %d: purchase_category_id must be a valid UUID", i), constants.MalformedFieldErrorCode
}
if item.Item != nil && strings.TrimSpace(*item.Item) == "" {
return fmt.Errorf("item %d: item cannot be empty", i), constants.MalformedFieldErrorCode
}

View File

@ -21,9 +21,8 @@ func TestExpenseValidatorCreateRequiresItemName(t *testing.T) {
Total: 10000,
Items: []contract.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: uuid.NewString(),
Amount: 10000,
ChartOfAccountID: uuid.NewString(),
Amount: 10000,
},
},
}
@ -46,10 +45,9 @@ func TestExpenseValidatorCreateDoesNotRequireHeaderExpenseName(t *testing.T) {
Total: 10000,
Items: []contract.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
}
@ -73,10 +71,9 @@ func TestExpenseValidatorCreateAllowsValidOptionalStatus(t *testing.T) {
Total: 10000,
Items: []contract.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
}
@ -100,10 +97,9 @@ func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) {
Total: 10000,
Items: []contract.CreateExpenseItemRequest{
{
ChartOfAccountID: uuid.NewString(),
PurchaseCategoryID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies",
Amount: 10000,
},
},
}
@ -118,11 +114,10 @@ func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) {
func TestExpenseValidatorUpdateRejectsEmptyItemNameWhenProvided(t *testing.T) {
v := NewExpenseValidator()
empty := " "
purchaseCategoryID := uuid.NewString()
req := &contract.UpdateExpenseRequest{
Items: []contract.UpdateExpenseItemRequest{
{PurchaseCategoryID: &purchaseCategoryID, Item: &empty},
{Item: &empty},
},
}

View File

@ -119,3 +119,4 @@ func (v *IngredientUnitConverterValidatorImpl) ValidateConvertUnitRequest(req *c
return nil, ""
}

View File

@ -1,103 +0,0 @@
package validator
import (
"errors"
"strings"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
)
type PurchaseCategoryValidator interface {
ValidateCreatePurchaseCategoryRequest(req *contract.CreatePurchaseCategoryRequest) (error, string)
ValidateUpdatePurchaseCategoryRequest(req *contract.UpdatePurchaseCategoryRequest) (error, string)
ValidateListPurchaseCategoriesRequest(req *contract.ListPurchaseCategoriesRequest) (error, string)
}
type PurchaseCategoryValidatorImpl struct{}
func NewPurchaseCategoryValidator() *PurchaseCategoryValidatorImpl {
return &PurchaseCategoryValidatorImpl{}
}
func (v *PurchaseCategoryValidatorImpl) ValidateCreatePurchaseCategoryRequest(req *contract.CreatePurchaseCategoryRequest) (error, string) {
if req == nil {
return errors.New("request body is required"), constants.MissingFieldErrorCode
}
if strings.TrimSpace(req.Name) == "" {
return errors.New("name is required"), constants.MissingFieldErrorCode
}
if len(req.Name) > 255 {
return errors.New("name cannot exceed 255 characters"), constants.MalformedFieldErrorCode
}
if !isValidPurchaseCategoryType(req.Type) {
return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode
}
if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 {
return errors.New("code cannot exceed 100 characters"), constants.MalformedFieldErrorCode
}
return nil, ""
}
func (v *PurchaseCategoryValidatorImpl) ValidateUpdatePurchaseCategoryRequest(req *contract.UpdatePurchaseCategoryRequest) (error, string) {
if req == nil {
return errors.New("request body is required"), constants.MissingFieldErrorCode
}
if req.ParentID == nil && req.Code == nil && req.Name == nil && req.Type == nil && req.SortOrder == nil && req.IsActive == nil {
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
}
if req.Name != nil {
if strings.TrimSpace(*req.Name) == "" {
return errors.New("name cannot be empty"), constants.MalformedFieldErrorCode
}
if len(*req.Name) > 255 {
return errors.New("name cannot exceed 255 characters"), constants.MalformedFieldErrorCode
}
}
if req.Type != nil && !isValidPurchaseCategoryType(*req.Type) {
return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode
}
if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 {
return errors.New("code cannot exceed 100 characters"), constants.MalformedFieldErrorCode
}
return nil, ""
}
func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(req *contract.ListPurchaseCategoriesRequest) (error, string) {
if req == nil {
return errors.New("request is required"), constants.MissingFieldErrorCode
}
if req.Page < 1 {
return errors.New("page must be at least 1"), constants.MalformedFieldErrorCode
}
if req.Limit < 1 || req.Limit > 100 {
return errors.New("limit must be between 1 and 100"), constants.MalformedFieldErrorCode
}
if req.Type != "" && !isValidPurchaseCategoryType(req.Type) {
return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode
}
return nil, ""
}
func isValidPurchaseCategoryType(categoryType string) bool {
switch categoryType {
case "raw_material", "expense":
return true
default:
return false
}
}

View File

@ -2,14 +2,11 @@ package validator
import (
"errors"
"strconv"
"strings"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"github.com/google/uuid"
)
type PurchaseOrderValidator interface {
@ -29,7 +26,7 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
return errors.New("request body is required"), constants.MissingFieldErrorCode
}
if req.VendorID == uuid.Nil {
if req.VendorID.String() == "" {
return errors.New("vendor_id is required"), constants.MissingFieldErrorCode
}
@ -181,48 +178,32 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
}
func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) {
if item.PurchaseCategoryID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
if item.IngredientID.String() == "" {
return errors.New("items[" + string(rune(index)) + "].ingredient_id is required"), constants.MissingFieldErrorCode
}
if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
if item.Quantity <= 0 {
return errors.New("items[" + string(rune(index)) + "].quantity must be greater than 0"), 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 != nil && *item.UnitID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
if item.UnitID.String() == "" {
return errors.New("items[" + string(rune(index)) + "].unit_id is required"), constants.MissingFieldErrorCode
}
if item.Amount < 0 {
return errors.New("items[" + strconv.Itoa(index) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode
return errors.New("items[" + string(rune(index)) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode
}
return nil, ""
}
func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contract.UpdatePurchaseOrderItemRequest, index int) (error, string) {
if item.PurchaseCategoryID == nil || *item.PurchaseCategoryID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
}
if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
}
if item.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
return errors.New("items[" + string(rune(index)) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
}
if item.Amount != nil && *item.Amount < 0 {
return errors.New("items[" + strconv.Itoa(index) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode
return errors.New("items[" + string(rune(index)) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode
}
return nil, ""

View File

@ -11,21 +11,16 @@ import (
)
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
ingredientID := uuid.New()
quantity := 1.0
unitID := uuid.New()
return &contract.CreatePurchaseOrderRequest{
VendorID: uuid.New(),
PONumber: "PO-001",
TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{
{
IngredientID: &ingredientID,
PurchaseCategoryID: uuid.New(),
Quantity: &quantity,
UnitID: &unitID,
Amount: 1000,
IngredientID: uuid.New(),
Quantity: 1,
UnitID: uuid.New(),
Amount: 1000,
},
},
}

View File

@ -1,2 +0,0 @@
DROP TABLE IF EXISTS purchase_categories;
DROP TABLE IF EXISTS purchase_category_presets;

View File

@ -1,60 +0,0 @@
CREATE TABLE purchase_category_presets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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', 'expense')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE purchase_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
preset_id UUID REFERENCES purchase_category_presets(id) ON DELETE SET NULL,
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', 'expense')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_system BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE (organization_id, code)
);
CREATE INDEX idx_purchase_category_presets_parent_id ON purchase_category_presets(parent_id);
CREATE INDEX idx_purchase_category_presets_type ON purchase_category_presets(type);
CREATE INDEX idx_purchase_category_presets_is_active ON purchase_category_presets(is_active);
CREATE INDEX idx_purchase_categories_organization_id ON purchase_categories(organization_id);
CREATE INDEX idx_purchase_categories_preset_id ON purchase_categories(preset_id);
CREATE INDEX idx_purchase_categories_parent_id ON purchase_categories(parent_id);
CREATE INDEX idx_purchase_categories_type ON purchase_categories(type);
CREATE INDEX idx_purchase_categories_is_active ON purchase_categories(is_active);
INSERT INTO purchase_category_presets (code, name, type, sort_order)
VALUES
('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)
SELECT parent.id, child.code, child.name, child.type, child.sort_order
FROM purchase_category_presets parent
JOIN (
VALUES
('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;

View File

@ -1,2 +0,0 @@
DROP TRIGGER IF EXISTS trigger_create_default_purchase_categories ON organizations;
DROP FUNCTION IF EXISTS create_default_purchase_categories();

View File

@ -1,49 +0,0 @@
CREATE OR REPLACE FUNCTION create_default_purchase_categories()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO purchase_categories (organization_id, preset_id, parent_id, code, name, type, sort_order, is_system, is_active, created_at, updated_at)
SELECT
NEW.id,
preset.id,
NULL,
preset.code,
preset.name,
preset.type,
preset.sort_order,
true,
preset.is_active,
NOW(),
NOW()
FROM purchase_category_presets preset
WHERE preset.parent_id IS NULL
AND preset.is_active = true
ON CONFLICT (organization_id, code) DO NOTHING;
INSERT INTO purchase_categories (organization_id, preset_id, parent_id, code, name, type, sort_order, is_system, is_active, created_at, updated_at)
SELECT
NEW.id,
child_preset.id,
parent_category.id,
child_preset.code,
child_preset.name,
child_preset.type,
child_preset.sort_order,
true,
child_preset.is_active,
NOW(),
NOW()
FROM purchase_category_presets child_preset
JOIN purchase_category_presets parent_preset ON child_preset.parent_id = parent_preset.id
JOIN purchase_categories parent_category ON parent_category.organization_id = NEW.id
AND parent_category.code = parent_preset.code
WHERE child_preset.is_active = true
ON CONFLICT (organization_id, code) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_create_default_purchase_categories
AFTER INSERT ON organizations
FOR EACH ROW
EXECUTE FUNCTION create_default_purchase_categories();

View File

@ -1,3 +0,0 @@
DELETE FROM purchase_categories
WHERE is_system = true
AND preset_id IS NOT NULL;

View File

@ -1,39 +0,0 @@
INSERT INTO purchase_categories (organization_id, preset_id, parent_id, code, name, type, sort_order, is_system, is_active, created_at, updated_at)
SELECT
org.id,
preset.id,
NULL,
preset.code,
preset.name,
preset.type,
preset.sort_order,
true,
preset.is_active,
NOW(),
NOW()
FROM organizations org
CROSS JOIN purchase_category_presets preset
WHERE preset.parent_id IS NULL
AND preset.is_active = true
ON CONFLICT (organization_id, code) DO NOTHING;
INSERT INTO purchase_categories (organization_id, preset_id, parent_id, code, name, type, sort_order, is_system, is_active, created_at, updated_at)
SELECT
org.id,
child_preset.id,
parent_category.id,
child_preset.code,
child_preset.name,
child_preset.type,
child_preset.sort_order,
true,
child_preset.is_active,
NOW(),
NOW()
FROM organizations org
JOIN purchase_category_presets child_preset ON child_preset.parent_id IS NOT NULL
JOIN purchase_category_presets parent_preset ON child_preset.parent_id = parent_preset.id
JOIN purchase_categories parent_category ON parent_category.organization_id = org.id
AND parent_category.code = parent_preset.code
WHERE child_preset.is_active = true
ON CONFLICT (organization_id, code) DO NOTHING;

View File

@ -1,5 +0,0 @@
DROP INDEX IF EXISTS idx_inventory_movements_purchase_order_item_id;
ALTER TABLE inventory_movements DROP COLUMN IF EXISTS purchase_order_item_id;
DROP INDEX IF EXISTS idx_purchase_order_items_purchase_category_id;
ALTER TABLE purchase_order_items DROP COLUMN IF EXISTS purchase_category_id;

View File

@ -1,11 +0,0 @@
ALTER TABLE purchase_order_items
ADD COLUMN IF NOT EXISTS purchase_category_id UUID REFERENCES purchase_categories(id) ON DELETE RESTRICT;
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_purchase_category_id
ON purchase_order_items(purchase_category_id);
ALTER TABLE inventory_movements
ADD COLUMN IF NOT EXISTS purchase_order_item_id UUID REFERENCES purchase_order_items(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_inventory_movements_purchase_order_item_id
ON inventory_movements(purchase_order_item_id);

View File

@ -1,2 +0,0 @@
DROP INDEX IF EXISTS idx_expense_items_purchase_category_id;
ALTER TABLE expense_items DROP COLUMN IF EXISTS purchase_category_id;

View File

@ -1,5 +0,0 @@
ALTER TABLE expense_items
ADD COLUMN IF NOT EXISTS purchase_category_id UUID REFERENCES purchase_categories(id) ON DELETE RESTRICT;
CREATE INDEX IF NOT EXISTS idx_expense_items_purchase_category_id
ON expense_items(purchase_category_id);

View File

@ -1,32 +0,0 @@
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'));

View File

@ -1,36 +0,0 @@
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;