diff --git a/internal/app/app.go b/internal/app/app.go index c8352a8..6a0f4d1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -108,6 +108,8 @@ func (a *App) Initialize(cfg *config.Config) error { validators.vendorValidator, services.purchaseOrderService, validators.purchaseOrderValidator, + services.purchaseCategoryService, + validators.purchaseCategoryValidator, services.unitConverterService, validators.unitConverterValidator, services.chartOfAccountTypeService, @@ -216,6 +218,7 @@ type repositories struct { productRecipeRepo *repository.ProductRecipeRepository vendorRepo *repository.VendorRepositoryImpl purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl + purchaseCategoryRepo *repository.PurchaseCategoryRepositoryImpl unitConverterRepo *repository.IngredientUnitConverterRepositoryImpl chartOfAccountTypeRepo *repository.ChartOfAccountTypeRepositoryImpl chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl @@ -269,6 +272,7 @@ 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), @@ -317,6 +321,7 @@ type processors struct { productRecipeProcessor *processor.ProductRecipeProcessorImpl vendorProcessor *processor.VendorProcessorImpl purchaseOrderProcessor *processor.PurchaseOrderProcessorImpl + purchaseCategoryProcessor *processor.PurchaseCategoryProcessorImpl unitConverterProcessor *processor.IngredientUnitConverterProcessorImpl chartOfAccountTypeProcessor *processor.ChartOfAccountTypeProcessorImpl chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl @@ -367,7 +372,8 @@ 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.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), + purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo), + purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo), unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo), chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo), chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo), @@ -390,7 +396,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), + expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo), } } @@ -416,6 +422,7 @@ type services struct { productRecipeService *service.ProductRecipeServiceImpl vendorService *service.VendorServiceImpl purchaseOrderService *service.PurchaseOrderServiceImpl + purchaseCategoryService service.PurchaseCategoryService unitConverterService *service.IngredientUnitConverterServiceImpl chartOfAccountTypeService service.ChartOfAccountTypeService chartOfAccountService service.ChartOfAccountService @@ -455,6 +462,7 @@ 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) @@ -494,6 +502,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con productRecipeService: productRecipeService, vendorService: vendorService, purchaseOrderService: purchaseOrderService, + purchaseCategoryService: purchaseCategoryService, unitConverterService: unitConverterService, chartOfAccountTypeService: chartOfAccountTypeService, chartOfAccountService: chartOfAccountService, @@ -539,6 +548,7 @@ type validators struct { tableValidator *validator.TableValidator vendorValidator *validator.VendorValidatorImpl purchaseOrderValidator *validator.PurchaseOrderValidatorImpl + purchaseCategoryValidator *validator.PurchaseCategoryValidatorImpl unitConverterValidator *validator.IngredientUnitConverterValidatorImpl chartOfAccountTypeValidator *validator.ChartOfAccountTypeValidatorImpl chartOfAccountValidator *validator.ChartOfAccountValidatorImpl @@ -570,6 +580,7 @@ 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), diff --git a/internal/constants/error.go b/internal/constants/error.go index 8937a58..af56665 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -40,6 +40,7 @@ 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" diff --git a/internal/contract/account_request.go b/internal/contract/account_request.go index b77dcdb..e794d6b 100644 --- a/internal/contract/account_request.go +++ b/internal/contract/account_request.go @@ -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 { diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index f7d019a..8e3c19d 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -106,7 +106,11 @@ 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"` @@ -114,12 +118,16 @@ type PurchasingSummary struct { } type PurchasingAnalyticsData struct { - 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"` + 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"` } type PurchasingIngredientData struct { @@ -316,3 +324,114 @@ type OperationalExpenseItem struct { Item string `json:"item"` Nominal float64 `json:"nominal"` } + +type ExclusiveSummaryPeriodRequest struct { + OrganizationID uuid.UUID + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"` +} + +type ExclusiveSummaryMonthlyRequest struct { + OrganizationID uuid.UUID + OutletID *string `form:"outlet_id,omitempty"` + Month string `form:"month" validate:"required"` +} + +type ExclusiveSummaryPeriodResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Period ExclusiveSummaryPeriodRange `json:"period"` + Summary ExclusiveSummaryPeriodSummary `json:"summary"` + Reimburse ExclusiveSummaryReimburse `json:"reimburse"` + HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"` + OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"` + DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"` + DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"` +} + +type ExclusiveSummaryPeriodRange struct { + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` +} + +type ExclusiveSummaryPeriodSummary struct { + Sales float64 `json:"sales"` + HPP float64 `json:"hpp"` + GrossProfit float64 `json:"gross_profit"` + SalaryTotal float64 `json:"salary_total"` + SalaryDW float64 `json:"salary_dw"` + SalaryStaff float64 `json:"salary_staff"` + SalaryOther float64 `json:"salary_other"` + OtherOperationalExpenses float64 `json:"other_operational_expenses"` + OperationalExpensesTotal float64 `json:"operational_expenses_total"` + TotalCost float64 `json:"total_cost"` + NetProfit float64 `json:"net_profit"` +} + +type ExclusiveSummaryReimburse struct { + TotalCost float64 `json:"total_cost"` + ExcludedSalaryStaff float64 `json:"excluded_salary_staff"` + TotalReimburse float64 `json:"total_reimburse"` +} + +type ExclusiveSummaryCategoryBreakdown struct { + CategoryCode string `json:"category_code"` + CategoryName string `json:"category_name"` + Amount float64 `json:"amount"` + Percentage float64 `json:"percentage"` +} + +type ExclusiveSummaryDailySummary struct { + Date time.Time `json:"date"` + TransactionCount int64 `json:"transaction_count"` + TotalCost float64 `json:"total_cost"` +} + +type ExclusiveSummaryDailyTransaction struct { + Date time.Time `json:"date"` + CategoryCode string `json:"category_code"` + CategoryName string `json:"category_name"` + Description string `json:"description"` + Amount float64 `json:"amount"` + Source string `json:"source"` +} + +type ExclusiveSummaryMonthlyResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Month string `json:"month"` + Summary ExclusiveSummaryMonthlySummary `json:"summary"` + Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"` + BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"` +} + +type ExclusiveSummaryMonthlySummary struct { + TotalSales float64 `json:"total_sales"` + HPP float64 `json:"hpp"` + GrossProfit float64 `json:"gross_profit"` + OperationalExpensesTotal float64 `json:"operational_expenses_total"` + TotalCost float64 `json:"total_cost"` + NetProfit float64 `json:"net_profit"` + NetProfitMargin float64 `json:"net_profit_margin"` +} + +type ExclusiveSummaryMonthlyPeriod struct { + Label string `json:"label"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + Sales float64 `json:"sales"` + HPP float64 `json:"hpp"` + GrossProfit float64 `json:"gross_profit"` + GrossMargin float64 `json:"gross_margin"` +} + +type ExclusiveSummaryBankBalance struct { + Bank string `json:"bank"` + OpeningBalance *float64 `json:"opening_balance"` + IncomingMutation *float64 `json:"incoming_mutation"` + OutgoingMutation *float64 `json:"outgoing_mutation"` + ClosingBalance *float64 `json:"closing_balance"` + Notes *string `json:"notes"` +} diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index a8ff07e..348cbd3 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -19,10 +19,11 @@ type CreateExpenseRequest struct { } type CreateExpenseItemRequest struct { - 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"` + 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"` } type UpdateExpenseRequest struct { @@ -39,10 +40,11 @@ type UpdateExpenseRequest struct { } type UpdateExpenseItemRequest struct { - ChartOfAccountID *string `json:"chart_of_account_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"` + PurchaseCategoryID *string `json:"purchase_category_id,omitempty"` + Item *string `json:"item,omitempty"` + Description *string `json:"description,omitempty"` + Amount *float64 `json:"amount,omitempty"` } type ExpenseResponse struct { @@ -63,15 +65,19 @@ 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"` - 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"` + 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"` } type ListExpenseRequest struct { @@ -91,3 +97,65 @@ 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"` +} diff --git a/internal/contract/ingredient_unit_converter_contract.go b/internal/contract/ingredient_unit_converter_contract.go index de37f8a..9741814 100644 --- a/internal/contract/ingredient_unit_converter_contract.go +++ b/internal/contract/ingredient_unit_converter_contract.go @@ -81,4 +81,3 @@ type IngredientUnitsResponse struct { BaseUnitName string `json:"base_unit_name"` Units []*UnitResponse `json:"units"` } - diff --git a/internal/contract/inventory_contract.go b/internal/contract/inventory_contract.go index 0b1f642..1dbc76d 100644 --- a/internal/contract/inventory_contract.go +++ b/internal/contract/inventory_contract.go @@ -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 { diff --git a/internal/contract/product_recipe_contract.go b/internal/contract/product_recipe_contract.go index 9df640c..cc73de2 100644 --- a/internal/contract/product_recipe_contract.go +++ b/internal/contract/product_recipe_contract.go @@ -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"` } @@ -71,4 +71,4 @@ type ProductRecipeUnitResponse struct { Symbol string `json:"symbol"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` -} \ No newline at end of file +} diff --git a/internal/contract/purchase_category_contract.go b/internal/contract/purchase_category_contract.go new file mode 100644 index 0000000..817bb42 --- /dev/null +++ b/internal/contract/purchase_category_contract.go @@ -0,0 +1,57 @@ +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"` +} diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index e6f1c92..5ab9024 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -19,11 +19,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - 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"` + IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"` + Description *string `json:"description,omitempty" validate:"omitempty"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` + UnitID uuid.UUID `json:"unit_id" validate:"required"` + Amount float64 `json:"amount" validate:"required,gte=0"` } type UpdatePurchaseOrderRequest struct { @@ -39,12 +40,13 @@ type UpdatePurchaseOrderRequest struct { } type UpdatePurchaseOrderItemRequest struct { - 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"` + ID *uuid.UUID `json:"id,omitempty"` // Ignored. Supplying items replaces all existing PO items. + IngredientID *uuid.UUID `json:"ingredient_id" validate:"required"` + PurchaseCategoryID *uuid.UUID `json:"purchase_category_id" validate:"required"` + Description *string `json:"description,omitempty" validate:"omitempty"` + Quantity *float64 `json:"quantity" validate:"required,gt=0"` + UnitID *uuid.UUID `json:"unit_id" validate:"required"` + Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"` } type PurchaseOrderResponse struct { @@ -66,17 +68,19 @@ type PurchaseOrderResponse struct { } type PurchaseOrderItemResponse struct { - 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"` + 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"` } type PurchaseOrderAttachmentResponse struct { diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 0fa8bda..4e4e175 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -38,7 +38,11 @@ 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"` @@ -46,12 +50,16 @@ type PurchasingSummary struct { } type PurchasingAnalyticsData struct { - 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"` + 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"` } type PurchasingIngredientData struct { @@ -178,3 +186,33 @@ type OperationalExpenseItem struct { Item string Amount float64 } + +type ExclusiveSummaryAnalytics struct { + SalesTotal float64 + SalesCount int64 + HPPBreakdown []ExclusiveSummaryCategoryTotal + OperationalExpenseBreakdown []ExclusiveSummaryCategoryTotal + DailySummary []ExclusiveSummaryDailySummary + DailyTransactions []ExclusiveSummaryDailyTransaction +} + +type ExclusiveSummaryCategoryTotal struct { + CategoryCode string + CategoryName string + Amount float64 +} + +type ExclusiveSummaryDailySummary struct { + Date time.Time + TransactionCount int64 + TotalCost float64 +} + +type ExclusiveSummaryDailyTransaction struct { + Date time.Time + CategoryCode string + CategoryName string + Description string + Amount float64 + Source string +} diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 93d9789..9f391fe 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -28,6 +28,56 @@ 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() diff --git a/internal/entities/expense_item.go b/internal/entities/expense_item.go index f5bd7fc..f6ebbed 100644 --- a/internal/entities/expense_item.go +++ b/internal/entities/expense_item.go @@ -9,17 +9,19 @@ 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"` - 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"` + 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"` - Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"` - ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"` + 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"` } func (e *ExpenseItem) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/entities/ingredient_unit_converter.go b/internal/entities/ingredient_unit_converter.go index d2a3465..ad9650e 100644 --- a/internal/entities/ingredient_unit_converter.go +++ b/internal/entities/ingredient_unit_converter.go @@ -39,4 +39,3 @@ func (iuc *IngredientUnitConverter) BeforeCreate() error { } return nil } - diff --git a/internal/entities/inventory_movement.go b/internal/entities/inventory_movement.go index cc4faa4..7baef02 100644 --- a/internal/entities/inventory_movement.go +++ b/internal/entities/inventory_movement.go @@ -36,34 +36,36 @@ 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"` - 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"` + 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"` - 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"` + 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"` } func (im *InventoryMovement) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/entities/order_ingredient_transaction.go b/internal/entities/order_ingredient_transaction.go index 38b9917..5a50ccf 100644 --- a/internal/entities/order_ingredient_transaction.go +++ b/internal/entities/order_ingredient_transaction.go @@ -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 { diff --git a/internal/entities/product_ingredient.go b/internal/entities/product_ingredient.go index 5d9bb46..20d869c 100644 --- a/internal/entities/product_ingredient.go +++ b/internal/entities/product_ingredient.go @@ -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"` diff --git a/internal/entities/product_recipe.go b/internal/entities/product_recipe.go index d178f2b..7292fe4 100644 --- a/internal/entities/product_recipe.go +++ b/internal/entities/product_recipe.go @@ -34,4 +34,4 @@ func (pr *ProductRecipe) BeforeCreate(tx *gorm.DB) error { func (ProductRecipe) TableName() string { return "product_recipes" -} \ No newline at end of file +} diff --git a/internal/entities/purchase_category.go b/internal/entities/purchase_category.go new file mode 100644 index 0000000..31c1718 --- /dev/null +++ b/internal/entities/purchase_category.go @@ -0,0 +1,71 @@ +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" +} diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index 8fe74c6..3a455db 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -41,19 +41,21 @@ func (PurchaseOrder) TableName() string { } type PurchaseOrderItem struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` - IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"` - Description *string `gorm:"type:text" json:"description" validate:"omitempty"` - Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"` - UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"` - Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` + IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"` + PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"` + Description *string `gorm:"type:text" json:"description" validate:"omitempty"` + Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"` + UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"` + Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - 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"` + 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"` } func (poi *PurchaseOrderItem) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/handler/analytics_handler.go b/internal/handler/analytics_handler.go index a8945e0..a1db87d 100644 --- a/internal/handler/analytics_handler.go +++ b/internal/handler/analytics_handler.go @@ -210,3 +210,59 @@ func (h *AnalyticsHandler) GetProfitLossAnalytics(c *gin.Context) { contractResp := transformer.ProfitLossAnalyticsModelToContract(response) util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProfitLossAnalytics") } + +func (h *AnalyticsHandler) GetExclusiveSummaryPeriod(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.ExclusiveSummaryPeriodRequest + if err := c.ShouldBindQuery(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod") + return + } + + req.OrganizationID = contextInfo.OrganizationID + req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) + modelReq, err := transformer.ExclusiveSummaryPeriodContractToModel(&req) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod") + return + } + + response, err := h.analyticsService.GetExclusiveSummaryPeriod(ctx, modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod") + return + } + + contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryPeriod") +} + +func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.ExclusiveSummaryMonthlyRequest + if err := c.ShouldBindQuery(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly") + return + } + + req.OrganizationID = contextInfo.OrganizationID + req.OutletID = h.resolveOutletID(c, contextInfo.OutletID) + modelReq, err := transformer.ExclusiveSummaryMonthlyContractToModel(&req) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly") + return + } + + response, err := h.analyticsService.GetExclusiveSummaryMonthly(ctx, modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly") + return + } + + contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly") +} diff --git a/internal/handler/chart_of_account_type_handler.go b/internal/handler/chart_of_account_type_handler.go index a9cc3ff..43300cb 100644 --- a/internal/handler/chart_of_account_type_handler.go +++ b/internal/handler/chart_of_account_type_handler.go @@ -99,7 +99,7 @@ func (h *ChartOfAccountTypeHandler) DeleteChartOfAccountType(c *gin.Context) { func (h *ChartOfAccountTypeHandler) ListChartOfAccountTypes(c *gin.Context) { // Parse query parameters filters := make(map[string]interface{}) - + if isActive := c.Query("is_active"); isActive != "" { if isActiveBool, err := strconv.ParseBool(isActive); err == nil { filters["is_active"] = isActiveBool diff --git a/internal/handler/expense_handler.go b/internal/handler/expense_handler.go index c09cc79..36ad959 100644 --- a/internal/handler/expense_handler.go +++ b/internal/handler/expense_handler.go @@ -199,3 +199,31 @@ 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") +} diff --git a/internal/handler/ingredient_unit_converter_handler.go b/internal/handler/ingredient_unit_converter_handler.go index e78346b..4dd642e 100644 --- a/internal/handler/ingredient_unit_converter_handler.go +++ b/internal/handler/ingredient_unit_converter_handler.go @@ -275,4 +275,3 @@ func (h *IngredientUnitConverterHandler) GetUnitsByIngredientID(c *gin.Context) util.HandleResponse(c.Writer, c.Request, unitsResponse, "IngredientUnitConverterHandler::GetUnitsByIngredientID") } - diff --git a/internal/handler/product_recipe_handler.go b/internal/handler/product_recipe_handler.go index 15066f1..8078203 100644 --- a/internal/handler/product_recipe_handler.go +++ b/internal/handler/product_recipe_handler.go @@ -219,4 +219,4 @@ func (h *ProductRecipeHandler) BulkCreate(c *gin.Context) { } c.JSON(http.StatusCreated, contract.BuildSuccessResponse(recipes)) -} \ No newline at end of file +} diff --git a/internal/handler/purchase_category_handler.go b/internal/handler/purchase_category_handler.go new file mode 100644 index 0000000..1555004 --- /dev/null +++ b/internal/handler/purchase_category_handler.go @@ -0,0 +1,160 @@ +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") +} diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go index 34015d4..c3b1e43 100644 --- a/internal/mappers/expense_mapper.go +++ b/internal/mappers/expense_mapper.go @@ -95,20 +95,27 @@ func ExpenseItemEntityToResponse(entity *entities.ExpenseItem) *models.ExpenseIt } response := &models.ExpenseItemResponse{ - ID: entity.ID, - ExpenseID: entity.ExpenseID, - ChartOfAccountID: entity.ChartOfAccountID, - Item: entity.Item, - Description: entity.Description, - Amount: entity.Amount, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, + 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, } 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 } diff --git a/internal/mappers/product_ingredient_mapper.go b/internal/mappers/product_ingredient_mapper.go index b7c99cb..f5dc1bc 100644 --- a/internal/mappers/product_ingredient_mapper.go +++ b/internal/mappers/product_ingredient_mapper.go @@ -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), } } diff --git a/internal/mappers/purchase_category_mapper.go b/internal/mappers/purchase_category_mapper.go new file mode 100644 index 0000000..a781331 --- /dev/null +++ b/internal/mappers/purchase_category_mapper.go @@ -0,0 +1,53 @@ +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 +} diff --git a/internal/mappers/purchase_order_mapper.go b/internal/mappers/purchase_order_mapper.go index 8b3cf9e..b08327f 100644 --- a/internal/mappers/purchase_order_mapper.go +++ b/internal/mappers/purchase_order_mapper.go @@ -91,15 +91,16 @@ func PurchaseOrderItemEntityToModel(entity *entities.PurchaseOrderItem) *models. } return &models.PurchaseOrderItem{ - 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, + 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, } } @@ -109,15 +110,16 @@ func PurchaseOrderItemModelToEntity(model *models.PurchaseOrderItem) *entities.P } return &entities.PurchaseOrderItem{ - 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, + 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, } } @@ -127,15 +129,16 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode } response := &models.PurchaseOrderItemResponse{ - 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, + 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, } // Map ingredient if present @@ -146,6 +149,10 @@ 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{ diff --git a/internal/models/account.go b/internal/models/account.go index 8c943d6..07259bd 100644 --- a/internal/models/account.go +++ b/internal/models/account.go @@ -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 { diff --git a/internal/models/analytics.go b/internal/models/analytics.go index ec8858d..170c048 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -113,7 +113,11 @@ 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"` @@ -122,12 +126,16 @@ type PurchasingSummary struct { // PurchasingAnalyticsData represents purchasing analytics by time period type PurchasingAnalyticsData struct { - 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"` + 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"` } // PurchasingIngredientData represents purchasing analytics for an ingredient @@ -326,3 +334,114 @@ type OperationalExpenseItem struct { Item string `json:"item"` Nominal float64 `json:"nominal"` } + +type ExclusiveSummaryPeriodRequest struct { + OrganizationID uuid.UUID `validate:"required"` + OutletID *uuid.UUID `validate:"omitempty"` + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` + ExcludeGajiStaffFromReimburse bool `validate:"omitempty"` +} + +type ExclusiveSummaryMonthlyRequest struct { + OrganizationID uuid.UUID `validate:"required"` + OutletID *uuid.UUID `validate:"omitempty"` + Month time.Time `validate:"required"` +} + +type ExclusiveSummaryPeriodResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Period ExclusiveSummaryPeriodRange `json:"period"` + Summary ExclusiveSummaryPeriodSummary `json:"summary"` + Reimburse ExclusiveSummaryReimburse `json:"reimburse"` + HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"` + OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"` + DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"` + DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"` +} + +type ExclusiveSummaryPeriodRange struct { + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` +} + +type ExclusiveSummaryPeriodSummary struct { + Sales float64 `json:"sales"` + HPP float64 `json:"hpp"` + GrossProfit float64 `json:"gross_profit"` + SalaryTotal float64 `json:"salary_total"` + SalaryDW float64 `json:"salary_dw"` + SalaryStaff float64 `json:"salary_staff"` + SalaryOther float64 `json:"salary_other"` + OtherOperationalExpenses float64 `json:"other_operational_expenses"` + OperationalExpensesTotal float64 `json:"operational_expenses_total"` + TotalCost float64 `json:"total_cost"` + NetProfit float64 `json:"net_profit"` +} + +type ExclusiveSummaryReimburse struct { + TotalCost float64 `json:"total_cost"` + ExcludedSalaryStaff float64 `json:"excluded_salary_staff"` + TotalReimburse float64 `json:"total_reimburse"` +} + +type ExclusiveSummaryCategoryBreakdown struct { + CategoryCode string `json:"category_code"` + CategoryName string `json:"category_name"` + Amount float64 `json:"amount"` + Percentage float64 `json:"percentage"` +} + +type ExclusiveSummaryDailySummary struct { + Date time.Time `json:"date"` + TransactionCount int64 `json:"transaction_count"` + TotalCost float64 `json:"total_cost"` +} + +type ExclusiveSummaryDailyTransaction struct { + Date time.Time `json:"date"` + CategoryCode string `json:"category_code"` + CategoryName string `json:"category_name"` + Description string `json:"description"` + Amount float64 `json:"amount"` + Source string `json:"source"` +} + +type ExclusiveSummaryMonthlyResponse struct { + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Month string `json:"month"` + Summary ExclusiveSummaryMonthlySummary `json:"summary"` + Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"` + BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"` +} + +type ExclusiveSummaryMonthlySummary struct { + TotalSales float64 `json:"total_sales"` + HPP float64 `json:"hpp"` + GrossProfit float64 `json:"gross_profit"` + OperationalExpensesTotal float64 `json:"operational_expenses_total"` + TotalCost float64 `json:"total_cost"` + NetProfit float64 `json:"net_profit"` + NetProfitMargin float64 `json:"net_profit_margin"` +} + +type ExclusiveSummaryMonthlyPeriod struct { + Label string `json:"label"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + Sales float64 `json:"sales"` + HPP float64 `json:"hpp"` + GrossProfit float64 `json:"gross_profit"` + GrossMargin float64 `json:"gross_margin"` +} + +type ExclusiveSummaryBankBalance struct { + Bank string `json:"bank"` + OpeningBalance *float64 `json:"opening_balance"` + IncomingMutation *float64 `json:"incoming_mutation"` + OutgoingMutation *float64 `json:"outgoing_mutation"` + ClosingBalance *float64 `json:"closing_balance"` + Notes *string `json:"notes"` +} diff --git a/internal/models/customer.go b/internal/models/customer.go index b873198..2b9e388 100644 --- a/internal/models/customer.go +++ b/internal/models/customer.go @@ -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 diff --git a/internal/models/expense.go b/internal/models/expense.go index 52a34e5..859ed69 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -23,14 +23,15 @@ 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"` - 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"` + 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"` } type ExpenseResponse struct { @@ -51,15 +52,19 @@ 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"` - 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"` + 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"` } type CreateExpenseRequest struct { @@ -75,10 +80,11 @@ type CreateExpenseRequest struct { } type CreateExpenseItemRequest struct { - ChartOfAccountID string `json:"chart_of_account_id"` - Item string `json:"item"` - Description *string `json:"description,omitempty"` - Amount float64 `json:"amount"` + 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"` } type UpdateExpenseRequest struct { @@ -95,10 +101,11 @@ type UpdateExpenseRequest struct { } type UpdateExpenseItemRequest struct { - ChartOfAccountID *string `json:"chart_of_account_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"` + PurchaseCategoryID *string `json:"purchase_category_id,omitempty"` + Item *string `json:"item,omitempty"` + Description *string `json:"description,omitempty"` + Amount *float64 `json:"amount,omitempty"` } type ListExpenseRequest struct { @@ -118,3 +125,66 @@ 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"` +} diff --git a/internal/models/ingredient_unit_converter.go b/internal/models/ingredient_unit_converter.go index 3413a7c..af14c0c 100644 --- a/internal/models/ingredient_unit_converter.go +++ b/internal/models/ingredient_unit_converter.go @@ -101,4 +101,3 @@ type IngredientUnitsResponse struct { BaseUnitName string `json:"base_unit_name"` Units []*UnitResponse `json:"units"` } - diff --git a/internal/models/order_ingredient_transaction.go b/internal/models/order_ingredient_transaction.go index b8706fa..7c9f1ab 100644 --- a/internal/models/order_ingredient_transaction.go +++ b/internal/models/order_ingredient_transaction.go @@ -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"` } diff --git a/internal/models/product_ingredient.go b/internal/models/product_ingredient.go index 3c3d9de..6466f1e 100644 --- a/internal/models/product_ingredient.go +++ b/internal/models/product_ingredient.go @@ -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"` diff --git a/internal/models/product_recipe.go b/internal/models/product_recipe.go index 5df109c..a9832ea 100644 --- a/internal/models/product_recipe.go +++ b/internal/models/product_recipe.go @@ -56,4 +56,4 @@ type ProductRecipeResponse struct { Product *Product `json:"product,omitempty"` ProductVariant *ProductVariant `json:"product_variant,omitempty"` Ingredient *Ingredient `json:"ingredient,omitempty"` -} \ No newline at end of file +} diff --git a/internal/models/purchase_category.go b/internal/models/purchase_category.go new file mode 100644 index 0000000..bb2f26e --- /dev/null +++ b/internal/models/purchase_category.go @@ -0,0 +1,51 @@ +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 +} diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 95b598b..452536c 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -22,15 +22,16 @@ type PurchaseOrder struct { } type PurchaseOrderItem struct { - ID uuid.UUID `json:"id"` - PurchaseOrderID uuid.UUID `json:"purchase_order_id"` - IngredientID uuid.UUID `json:"ingredient_id"` - Description *string `json:"description"` - Quantity float64 `json:"quantity"` - UnitID uuid.UUID `json:"unit_id"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + PurchaseOrderID uuid.UUID `json:"purchase_order_id"` + IngredientID uuid.UUID `json:"ingredient_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PurchaseOrderAttachment struct { @@ -59,17 +60,19 @@ type PurchaseOrderResponse struct { } type PurchaseOrderItemResponse struct { - 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"` + 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"` } type PurchaseOrderAttachmentResponse struct { @@ -93,11 +96,12 @@ type CreatePurchaseOrderRequest struct { } type CreatePurchaseOrderItemRequest struct { - 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"` + IngredientID uuid.UUID `json:"ingredient_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Description *string `json:"description,omitempty"` + Quantity float64 `json:"quantity"` + UnitID uuid.UUID `json:"unit_id"` + Amount float64 `json:"amount"` } type UpdatePurchaseOrderRequest struct { @@ -113,12 +117,13 @@ type UpdatePurchaseOrderRequest struct { } type UpdatePurchaseOrderItemRequest struct { - 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"` + ID *uuid.UUID `json:"id,omitempty"` // Ignored. Supplying items replaces all existing PO items. + IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` + PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"` + Description *string `json:"description,omitempty"` + Quantity *float64 `json:"quantity,omitempty"` + UnitID *uuid.UUID `json:"unit_id,omitempty"` + Amount *float64 `json:"amount,omitempty"` } type ListPurchaseOrdersRequest struct { diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index d08a523..e4894e3 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -19,6 +19,8 @@ type AnalyticsProcessor interface { GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) + GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) + GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) } type AnalyticsProcessorImpl struct { @@ -186,12 +188,16 @@ 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, - PurchaseOrders: item.PurchaseOrders, - Quantity: item.Quantity, - Ingredients: item.Ingredients, - Vendors: item.Vendors, + 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, } } @@ -228,7 +234,11 @@ 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, @@ -454,15 +464,46 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req } } - todayPromosi := getExpenseAmountByCategory(result.TodayExpenseByCategory, "promosi") - todayLainLain := getExpenseAmountByCategory(result.TodayExpenseByCategory, "lain") - todayTotalOps := todayPromosi + todayLainLain - todayGaji := getExpenseAmountByCategory(result.TodayExpenseByCategory, "gaji") + type categoryAmount struct { + Name string + TodayAmt float64 + MtdAmt float64 + } - mtdPromosi := getExpenseAmountByCategory(result.MtdExpenseByCategory, "promosi") - mtdLainLain := getExpenseAmountByCategory(result.MtdExpenseByCategory, "lain") - mtdTotalOps := mtdPromosi + mtdLainLain - mtdGaji := getExpenseAmountByCategory(result.MtdExpenseByCategory, "gaji") + categoryMap := make(map[string]*categoryAmount) + var categoryOrder []string + + for _, cat := range result.TodayExpenseByCategory { + name := cat.CategoryName + if _, exists := categoryMap[name]; !exists { + categoryMap[name] = &categoryAmount{Name: name} + categoryOrder = append(categoryOrder, name) + } + categoryMap[name].TodayAmt = cat.Amount + } + + for _, cat := range result.MtdExpenseByCategory { + name := cat.CategoryName + if _, exists := categoryMap[name]; !exists { + categoryMap[name] = &categoryAmount{Name: name} + categoryOrder = append(categoryOrder, name) + } + categoryMap[name].MtdAmt = cat.Amount + } + + var todayTotalOps float64 + var mtdTotalOps float64 + var todayGaji float64 + var mtdGaji float64 + for _, cat := range categoryMap { + if isSalaryExpenseCategory(cat.Name) { + todayGaji += cat.TodayAmt + mtdGaji += cat.MtdAmt + continue + } + todayTotalOps += cat.TodayAmt + mtdTotalOps += cat.MtdAmt + } todayGrossProfit := result.TodayRevenue - result.TodayCost mtdGrossProfit := result.MtdRevenue - result.MtdCost @@ -486,6 +527,33 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req return (nominal / result.MtdRevenue) * 100 } + opsSubItems := make([]models.ProfitLossSummaryRow, 0, len(categoryOrder)+1) + opsCategoryCount := 0 + for _, name := range categoryOrder { + cat := categoryMap[name] + if isSalaryExpenseCategory(cat.Name) { + continue + } + opsCategoryCount++ + opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{ + ID: fmt.Sprintf("by_%s", slugify(name)), + Label: fmt.Sprintf("%d. %s", opsCategoryCount, cat.Name), + TodayNominal: cat.TodayAmt, + TodayPct: todayPct(cat.TodayAmt), + MtdNominal: cat.MtdAmt, + MtdPct: mtdPct(cat.MtdAmt), + }) + } + opsSubItems = append(opsSubItems, models.ProfitLossSummaryRow{ + ID: "total_biaya_ops", + Label: fmt.Sprintf("Total Biaya OPS (%d kategori)", opsCategoryCount), + IsBold: true, + TodayNominal: todayTotalOps, + TodayPct: todayPct(todayTotalOps), + MtdNominal: mtdTotalOps, + MtdPct: mtdPct(mtdTotalOps), + }) + mainSummary := []models.ProfitLossSummaryRow{ { ID: "total_omset", Label: "TOTAL OMSET", @@ -506,23 +574,7 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req ID: "biaya_ops", Label: "BIAYA OPS", TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps), MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps), - SubItems: []models.ProfitLossSummaryRow{ - { - ID: "by_promosi", Label: "1. By Promosi", - TodayNominal: todayPromosi, TodayPct: todayPct(todayPromosi), - MtdNominal: mtdPromosi, MtdPct: mtdPct(mtdPromosi), - }, - { - ID: "by_lain_lain", Label: "2. By Lain lain", - TodayNominal: todayLainLain, TodayPct: todayPct(todayLainLain), - MtdNominal: mtdLainLain, MtdPct: mtdPct(mtdLainLain), - }, - { - ID: "total_biaya_ops", Label: "Total Biaya OPS (4.1+4.2)", IsBold: true, - TodayNominal: todayTotalOps, TodayPct: todayPct(todayTotalOps), - MtdNominal: mtdTotalOps, MtdPct: mtdPct(mtdTotalOps), - }, - }, + SubItems: opsSubItems, }, { ID: "laba_rugi_sblm_gaji", Label: "Laba/Rugi sblm Gaji (3-4)", @@ -578,11 +630,290 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req }, nil } -func getExpenseAmountByCategory(categories []entities.ExpenseCategoryTotal, keyword string) float64 { - for _, cat := range categories { - if strings.Contains(strings.ToLower(cat.CategoryName), keyword) { - return cat.Amount +func isSalaryExpenseCategory(name string) bool { + name = strings.ToLower(name) + return strings.Contains(name, "gaji") || strings.Contains(name, "salary") +} + +func slugify(s string) string { + result := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z': + result = append(result, c) + case c >= 'A' && c <= 'Z': + result = append(result, c+32) + case c >= '0' && c <= '9': + result = append(result, c) + default: + if len(result) == 0 || result[len(result)-1] != '_' { + result = append(result, '_') + } } } - return 0 + return string(result) +} + +func (p *AnalyticsProcessorImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + if req.DateFrom.IsZero() { + return nil, fmt.Errorf("date_from is required") + } + + if req.DateTo.IsZero() { + return nil, fmt.Errorf("date_to is required") + } + + if req.DateFrom.After(req.DateTo) { + return nil, fmt.Errorf("date_from cannot be after date_to") + } + + return p.buildExclusiveSummaryPeriod(ctx, req) +} + +func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) { + if req.Month.IsZero() { + return nil, fmt.Errorf("month is required") + } + + monthStart := time.Date(req.Month.Year(), req.Month.Month(), 1, 0, 0, 0, 0, req.Month.Location()) + monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond) + + fullPeriod, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + DateFrom: monthStart, + DateTo: monthEnd, + }) + if err != nil { + return nil, err + } + + buckets := buildExclusiveSummaryMonthlyBuckets(monthStart) + periods := make([]models.ExclusiveSummaryMonthlyPeriod, 0, len(buckets)) + for _, bucket := range buckets { + period, err := p.buildExclusiveSummaryPeriod(ctx, &models.ExclusiveSummaryPeriodRequest{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + DateFrom: bucket.DateFrom, + DateTo: bucket.DateTo, + }) + if err != nil { + return nil, err + } + + grossMargin := percentage(period.Summary.GrossProfit, period.Summary.Sales) + periods = append(periods, models.ExclusiveSummaryMonthlyPeriod{ + Label: bucket.Label, + DateFrom: bucket.DateFrom, + DateTo: bucket.DateTo, + Sales: period.Summary.Sales, + HPP: period.Summary.HPP, + GrossProfit: period.Summary.GrossProfit, + GrossMargin: grossMargin, + }) + } + + return &models.ExclusiveSummaryMonthlyResponse{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + Month: monthStart.Format("2006-01"), + Summary: models.ExclusiveSummaryMonthlySummary{ + TotalSales: fullPeriod.Summary.Sales, + HPP: fullPeriod.Summary.HPP, + GrossProfit: fullPeriod.Summary.GrossProfit, + OperationalExpensesTotal: fullPeriod.Summary.OperationalExpensesTotal, + TotalCost: fullPeriod.Summary.TotalCost, + NetProfit: fullPeriod.Summary.NetProfit, + NetProfitMargin: percentage(fullPeriod.Summary.NetProfit, fullPeriod.Summary.Sales), + }, + Periods: periods, + BankBalance: []models.ExclusiveSummaryBankBalance{ + {Bank: "BCA"}, + {Bank: "BRI"}, + }, + }, nil +} + +func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + result, err := p.analyticsRepo.GetExclusiveSummaryAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) + if err != nil { + return nil, fmt.Errorf("failed to get exclusive summary analytics: %w", err) + } + + hppBreakdown, hppTotal := exclusiveSummaryCategoryBreakdown(result.HPPBreakdown) + operationalBreakdown, operationalTotal := exclusiveSummaryCategoryBreakdown(result.OperationalExpenseBreakdown) + salaryDW, salaryStaff, salaryOther := exclusiveSummarySalaryBreakdown(result.DailyTransactions) + salaryTotal := salaryDW + salaryStaff + salaryOther + otherOperationalExpenses := operationalTotal - salaryTotal + if otherOperationalExpenses < 0 { + otherOperationalExpenses = 0 + } + + grossProfit := result.SalesTotal - hppTotal + totalCost := hppTotal + operationalTotal + netProfit := result.SalesTotal - totalCost + excludedSalaryStaff := 0.0 + if req.ExcludeGajiStaffFromReimburse { + excludedSalaryStaff = salaryStaff + } + + dailySummary := make([]models.ExclusiveSummaryDailySummary, len(result.DailySummary)) + for i, item := range result.DailySummary { + dailySummary[i] = models.ExclusiveSummaryDailySummary{ + Date: item.Date, + TransactionCount: item.TransactionCount, + TotalCost: item.TotalCost, + } + } + + dailyTransactions := make([]models.ExclusiveSummaryDailyTransaction, len(result.DailyTransactions)) + for i, item := range result.DailyTransactions { + dailyTransactions[i] = models.ExclusiveSummaryDailyTransaction{ + Date: item.Date, + CategoryCode: item.CategoryCode, + CategoryName: item.CategoryName, + Description: item.Description, + Amount: item.Amount, + Source: item.Source, + } + } + + return &models.ExclusiveSummaryPeriodResponse{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + Period: models.ExclusiveSummaryPeriodRange{ + DateFrom: req.DateFrom, + DateTo: req.DateTo, + }, + Summary: models.ExclusiveSummaryPeriodSummary{ + Sales: result.SalesTotal, + HPP: hppTotal, + GrossProfit: grossProfit, + SalaryTotal: salaryTotal, + SalaryDW: salaryDW, + SalaryStaff: salaryStaff, + SalaryOther: salaryOther, + OtherOperationalExpenses: otherOperationalExpenses, + OperationalExpensesTotal: operationalTotal, + TotalCost: totalCost, + NetProfit: netProfit, + }, + Reimburse: models.ExclusiveSummaryReimburse{ + TotalCost: totalCost, + ExcludedSalaryStaff: excludedSalaryStaff, + TotalReimburse: totalCost - excludedSalaryStaff, + }, + HPPBreakdown: hppBreakdown, + OperationalExpenseBreakdown: operationalBreakdown, + DailySummary: dailySummary, + DailyTransactions: dailyTransactions, + }, nil +} + +func exclusiveSummaryCategoryBreakdown(items []entities.ExclusiveSummaryCategoryTotal) ([]models.ExclusiveSummaryCategoryBreakdown, float64) { + var total float64 + for _, item := range items { + total += item.Amount + } + + breakdown := make([]models.ExclusiveSummaryCategoryBreakdown, len(items)) + for i, item := range items { + breakdown[i] = models.ExclusiveSummaryCategoryBreakdown{ + CategoryCode: item.CategoryCode, + CategoryName: item.CategoryName, + Amount: item.Amount, + Percentage: percentage(item.Amount, total), + } + } + + return breakdown, total +} + +func exclusiveSummarySalaryBreakdown(transactions []entities.ExclusiveSummaryDailyTransaction) (float64, float64, float64) { + var salaryDW float64 + var salaryStaff float64 + var salaryOther float64 + + for _, transaction := range transactions { + if transaction.Source != "expense" || !isExclusiveSummarySalary(transaction.CategoryCode, transaction.CategoryName, transaction.Description) { + continue + } + + classification := strings.ToLower(transaction.CategoryCode + " " + transaction.CategoryName + " " + transaction.Description) + switch { + case strings.Contains(classification, "dw"): + salaryDW += transaction.Amount + case strings.Contains(classification, "staff") || strings.Contains(classification, "kary") || strings.Contains(classification, "karyawan"): + salaryStaff += transaction.Amount + default: + salaryOther += transaction.Amount + } + } + + return salaryDW, salaryStaff, salaryOther +} + +func isExclusiveSummarySalary(parts ...string) bool { + text := strings.ToLower(strings.Join(parts, " ")) + return strings.Contains(text, "gaji") || strings.Contains(text, "salary") +} + +func percentage(numerator, denominator float64) float64 { + if denominator == 0 { + return 0 + } + return (numerator / denominator) * 100 +} + +type exclusiveSummaryMonthlyBucket struct { + Label string + DateFrom time.Time + DateTo time.Time +} + +func buildExclusiveSummaryMonthlyBuckets(monthStart time.Time) []exclusiveSummaryMonthlyBucket { + monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond) + buckets := make([]exclusiveSummaryMonthlyBucket, 0, 6) + currentStart := monthStart + + for !currentStart.After(monthEnd) { + currentEnd := currentStart + for currentEnd.Weekday() != time.Sunday && currentEnd.Day() < monthEnd.Day() { + currentEnd = currentEnd.AddDate(0, 0, 1) + } + + bucketEnd := time.Date(currentEnd.Year(), currentEnd.Month(), currentEnd.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), currentEnd.Location()) + if bucketEnd.After(monthEnd) { + bucketEnd = monthEnd + } + + buckets = append(buckets, exclusiveSummaryMonthlyBucket{ + Label: fmt.Sprintf("%d - %d %s", currentStart.Day(), bucketEnd.Day(), indonesianMonthName(currentStart.Month())), + DateFrom: currentStart, + DateTo: bucketEnd, + }) + + currentStart = time.Date(bucketEnd.Year(), bucketEnd.Month(), bucketEnd.Day(), 0, 0, 0, 0, bucketEnd.Location()).AddDate(0, 0, 1) + } + + return buckets +} + +func indonesianMonthName(month time.Month) string { + names := map[time.Month]string{ + time.January: "Januari", + time.February: "Februari", + time.March: "Maret", + time.April: "April", + time.May: "Mei", + time.June: "Juni", + time.July: "Juli", + time.August: "Agustus", + time.September: "September", + time.October: "Oktober", + time.November: "November", + time.December: "Desember", + } + return names[month] } diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 998831a..11373de 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -15,6 +15,7 @@ import ( type analyticsRepositoryStub struct { purchasingResult *entities.PurchasingAnalytics profitLossResult *entities.ProfitLossAnalytics + exclusiveResult *entities.ExclusiveSummaryAnalytics profitLossGroup string } @@ -47,6 +48,10 @@ func (s analyticsRepositoryStub) GetProfitLossAnalytics(_ context.Context, _ uui return s.profitLossResult, nil } +func (s analyticsRepositoryStub) GetExclusiveSummaryAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ExclusiveSummaryAnalytics, error) { + return s.exclusiveResult, nil +} + type expenseRepositoryStub struct{} func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil } @@ -61,6 +66,9 @@ 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 } @@ -72,7 +80,23 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) purchasingResult: &entities.PurchasingAnalytics{ OutletName: &outletName, Summary: entities.PurchasingSummary{ - TotalPurchases: 125, + TotalPurchases: 300, + RawMaterialPurchases: 125, + ExpensePurchases: 175, + TotalPurchaseOrders: 3, + RawMaterialPurchaseOrders: 1, + ExpenseCount: 2, + }, + Data: []entities.PurchasingAnalyticsData{ + { + Date: now, + Purchases: 300, + RawMaterialPurchases: 125, + ExpensePurchases: 175, + PurchaseOrders: 3, + RawMaterialPurchaseOrders: 1, + ExpenseCount: 2, + }, }, }, }, expenseRepositoryStub{}) @@ -89,7 +113,16 @@ 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(125), result.Summary.TotalPurchases) + require.Equal(t, float64(300), result.Summary.TotalPurchases) + require.Equal(t, float64(125), result.Summary.RawMaterialPurchases) + require.Equal(t, float64(175), result.Summary.ExpensePurchases) + require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders) + require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders) + require.Equal(t, int64(2), result.Summary.ExpenseCount) + require.Len(t, result.Data, 1) + require.Equal(t, float64(300), result.Data[0].Purchases) + require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases) + require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { @@ -165,3 +198,158 @@ func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t * require.NotEmpty(t, result.MainSummary) require.Equal(t, "total_omset", result.MainSummary[0].ID) } + +func TestAnalyticsProcessorGetProfitLossAnalyticsDynamicExpenseCategories(t *testing.T) { + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ + profitLossResult: &entities.ProfitLossAnalytics{ + Summary: entities.ProfitLossSummary{ + TotalRevenue: 10000, + TotalCost: 4000, + }, + TodayRevenue: 10000, + TodayCost: 4000, + MtdRevenue: 20000, + MtdCost: 8000, + TodayExpenseByCategory: []entities.ExpenseCategoryTotal{ + {CategoryName: "Gaji", Amount: 1500}, + {CategoryName: "Promosi", Amount: 300}, + {CategoryName: "Sewa", Amount: 500}, + }, + MtdExpenseByCategory: []entities.ExpenseCategoryTotal{ + {CategoryName: "Gaji", Amount: 3000}, + {CategoryName: "Promosi", Amount: 600}, + {CategoryName: "Sewa", Amount: 1000}, + }, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetProfitLossAnalytics(context.Background(), &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.NotNil(t, result) + + require.Len(t, result.MainSummary, 7) + + require.Equal(t, "total_omset", result.MainSummary[0].ID) + require.Equal(t, float64(10000), result.MainSummary[0].TodayNominal) + require.Equal(t, float64(20000), result.MainSummary[0].MtdNominal) + + require.Equal(t, "hpp", result.MainSummary[1].ID) + require.Equal(t, float64(4000), result.MainSummary[1].TodayNominal) + require.Equal(t, float64(8000), result.MainSummary[1].MtdNominal) + + require.Equal(t, "laba_kotor", result.MainSummary[2].ID) + require.Equal(t, float64(6000), result.MainSummary[2].TodayNominal) + require.Equal(t, float64(12000), result.MainSummary[2].MtdNominal) + + require.Equal(t, "biaya_ops", result.MainSummary[3].ID) + require.Equal(t, float64(800), result.MainSummary[3].TodayNominal) + require.Equal(t, float64(1600), result.MainSummary[3].MtdNominal) + require.Len(t, result.MainSummary[3].SubItems, 3) // 2 operational categories + 1 total + + require.Equal(t, "by_promosi", result.MainSummary[3].SubItems[0].ID) + require.Equal(t, float64(300), result.MainSummary[3].SubItems[0].TodayNominal) + require.Equal(t, float64(600), result.MainSummary[3].SubItems[0].MtdNominal) + + require.Equal(t, "by_sewa", result.MainSummary[3].SubItems[1].ID) + require.Equal(t, float64(500), result.MainSummary[3].SubItems[1].TodayNominal) + require.Equal(t, float64(1000), result.MainSummary[3].SubItems[1].MtdNominal) + + require.Equal(t, "total_biaya_ops", result.MainSummary[3].SubItems[2].ID) + require.True(t, result.MainSummary[3].SubItems[2].IsBold) + require.Equal(t, float64(800), result.MainSummary[3].SubItems[2].TodayNominal) + require.Equal(t, float64(1600), result.MainSummary[3].SubItems[2].MtdNominal) + + require.Equal(t, "laba_rugi_sblm_gaji", result.MainSummary[4].ID) + require.Equal(t, float64(5200), result.MainSummary[4].TodayNominal) + require.Equal(t, float64(10400), result.MainSummary[4].MtdNominal) + + require.Equal(t, "biaya_gaji", result.MainSummary[5].ID) + require.Equal(t, float64(1500), result.MainSummary[5].TodayNominal) + require.Equal(t, float64(3000), result.MainSummary[5].MtdNominal) + + require.Equal(t, "laba_rugi", result.MainSummary[6].ID) + require.Equal(t, float64(3700), result.MainSummary[6].TodayNominal) + require.Equal(t, float64(7400), result.MainSummary[6].MtdNominal) + require.True(t, result.MainSummary[6].IsBold) +} + +func TestAnalyticsProcessorGetExclusiveSummaryPeriodCalculatesSummaryAndReimburse(t *testing.T) { + now := time.Date(2026, 5, 26, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ + exclusiveResult: &entities.ExclusiveSummaryAnalytics{ + SalesTotal: 35619000, + HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "hpp_nusantara", CategoryName: "Nusantara", Amount: 19010552}, + }, + OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "biaya_gaji", CategoryName: "Gaji", Amount: 51758333}, + {CategoryCode: "biaya_lain", CategoryName: "Biaya Lain-lain", Amount: 1608605}, + }, + DailyTransactions: []entities.ExclusiveSummaryDailyTransaction{ + {Date: now, CategoryCode: "biaya_gaji", CategoryName: "Gaji", Description: "gaji kary", Amount: 48203333, Source: "expense"}, + {Date: now, CategoryCode: "biaya_gaji_dw", CategoryName: "Gaji DW", Description: "gaji karyawan", Amount: 3555000, Source: "expense"}, + }, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetExclusiveSummaryPeriod(context.Background(), &models.ExclusiveSummaryPeriodRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + ExcludeGajiStaffFromReimburse: true, + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, float64(35619000), result.Summary.Sales) + require.Equal(t, float64(19010552), result.Summary.HPP) + require.Equal(t, float64(16608448), result.Summary.GrossProfit) + require.Equal(t, float64(51758333), result.Summary.SalaryTotal) + require.Equal(t, float64(3555000), result.Summary.SalaryDW) + require.Equal(t, float64(48203333), result.Summary.SalaryStaff) + require.Equal(t, float64(53366938), result.Summary.OperationalExpensesTotal) + require.Equal(t, float64(72377490), result.Summary.TotalCost) + require.Equal(t, float64(-36758490), result.Summary.NetProfit) + require.Equal(t, float64(48203333), result.Reimburse.ExcludedSalaryStaff) + require.Equal(t, float64(24174157), result.Reimburse.TotalReimburse) +} + +func TestAnalyticsProcessorGetExclusiveSummaryMonthlyBuildsCalendarBucketsAndBankTemplate(t *testing.T) { + location, err := time.LoadLocation("Asia/Jakarta") + require.NoError(t, err) + month := time.Date(2026, 5, 1, 0, 0, 0, 0, location) + processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{ + exclusiveResult: &entities.ExclusiveSummaryAnalytics{ + SalesTotal: 1000, + HPPBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "hpp", CategoryName: "HPP", Amount: 400}, + }, + OperationalExpenseBreakdown: []entities.ExclusiveSummaryCategoryTotal{ + {CategoryCode: "ops", CategoryName: "OPS", Amount: 100}, + }, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetExclusiveSummaryMonthly(context.Background(), &models.ExclusiveSummaryMonthlyRequest{ + OrganizationID: uuid.New(), + Month: month, + }) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "2026-05", result.Month) + require.Equal(t, float64(1000), result.Summary.TotalSales) + require.Equal(t, float64(500), result.Summary.NetProfit) + require.Len(t, result.Periods, 5) + require.Equal(t, "1 - 3 Mei", result.Periods[0].Label) + require.Equal(t, "25 - 31 Mei", result.Periods[4].Label) + require.Len(t, result.BankBalance, 2) + require.Equal(t, "BCA", result.BankBalance[0].Bank) + require.Equal(t, "BRI", result.BankBalance[1].Bank) +} diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 4e0557b..5419b1c 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -19,15 +19,18 @@ 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 + expenseRepo ExpenseRepository + purchaseCategoryRepo PurchaseCategoryRepository } -func NewExpenseProcessorImpl(expenseRepo ExpenseRepository) *ExpenseProcessorImpl { +func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl { return &ExpenseProcessorImpl{ - expenseRepo: expenseRepo, + expenseRepo: expenseRepo, + purchaseCategoryRepo: purchaseCategoryRepo, } } @@ -47,6 +50,30 @@ 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, @@ -64,21 +91,10 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID return nil, fmt.Errorf("failed to create expense: %w", err) } - 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) - } + for i := range items { + items[i].ExpenseID = expenseEntity.ID - itemEntity := &entities.ExpenseItem{ - ExpenseID: expenseEntity.ID, - ChartOfAccountID: chartOfAccountID, - Item: itemReq.Item, - Description: itemReq.Description, - Amount: itemReq.Amount, - } - - err = p.expenseRepo.CreateItem(ctx, itemEntity) + err = p.expenseRepo.CreateItem(ctx, &items[i]) if err != nil { return nil, fmt.Errorf("failed to create expense item: %w", err) } @@ -134,13 +150,10 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati expenseEntity.Reserved1 = req.Reserved1 } + var items []entities.ExpenseItem if req.Items != nil { - 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 { + items = make([]entities.ExpenseItem, len(req.Items)) + for i, itemReq := range req.Items { chartOfAccountID := uuid.Nil if itemReq.ChartOfAccountID != nil { chartOfAccountID, err = uuid.Parse(*itemReq.ChartOfAccountID) @@ -149,6 +162,17 @@ 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 @@ -158,15 +182,23 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati item = *itemReq.Item } - itemEntity := &entities.ExpenseItem{ - ExpenseID: expenseEntity.ID, - ChartOfAccountID: chartOfAccountID, - Item: item, - Description: itemReq.Description, - Amount: amount, + items[i] = entities.ExpenseItem{ + ExpenseID: expenseEntity.ID, + ChartOfAccountID: chartOfAccountID, + PurchaseCategoryID: purchaseCategoryID, + Item: item, + Description: itemReq.Description, + Amount: amount, } + } - err = p.expenseRepo.CreateItem(ctx, itemEntity) + 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]) if err != nil { return nil, fmt.Errorf("failed to create expense item: %w", err) } @@ -221,3 +253,100 @@ 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 +} diff --git a/internal/processor/expense_processor_test.go b/internal/processor/expense_processor_test.go index 7afb0e7..ba0fb3e 100644 --- a/internal/processor/expense_processor_test.go +++ b/internal/processor/expense_processor_test.go @@ -3,6 +3,7 @@ package processor import ( "context" "testing" + "time" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" @@ -14,6 +15,46 @@ 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 { @@ -44,6 +85,9 @@ 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() @@ -57,7 +101,8 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { repo := &expenseRepositoryCaptureStub{} - p := NewExpenseProcessorImpl(repo) + purchaseCategoryID := uuid.New() + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) chartOfAccountID := uuid.New() resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -68,9 +113,10 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { Total: 10000, Items: []models.CreateExpenseItemRequest{ { - ChartOfAccountID: chartOfAccountID.String(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: chartOfAccountID.String(), + PurchaseCategoryID: purchaseCategoryID.String(), + Item: "Cleaning supplies", + Amount: 10000, }, }, }) @@ -79,13 +125,15 @@ 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{} - p := NewExpenseProcessorImpl(repo) + purchaseCategoryID := uuid.New() + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ Receiver: "Cashier", @@ -95,9 +143,10 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { Total: 10000, Items: []models.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: purchaseCategoryID.String(), + Item: "Cleaning supplies", + Amount: 10000, }, }, }) @@ -110,7 +159,8 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { repo := &expenseRepositoryCaptureStub{} - p := NewExpenseProcessorImpl(repo) + purchaseCategoryID := uuid.New() + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense)) status := "approved" resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -122,9 +172,10 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { Total: 10000, Items: []models.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: purchaseCategoryID.String(), + Item: "Cleaning supplies", + Amount: 10000, }, }, }) @@ -134,3 +185,107 @@ 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) +} diff --git a/internal/processor/expense_repository.go b/internal/processor/expense_repository.go index baefbc2..2b96c0b 100644 --- a/internal/processor/expense_repository.go +++ b/internal/processor/expense_repository.go @@ -3,6 +3,7 @@ package processor import ( "apskel-pos-be/internal/entities" "context" + "time" "github.com/google/uuid" ) @@ -14,6 +15,7 @@ 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 } diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 3d22958..6065005 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -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) 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, purchaseOrderItemID *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 } diff --git a/internal/processor/product_recipe_processor.go b/internal/processor/product_recipe_processor.go index 831257e..5f63fa1 100644 --- a/internal/processor/product_recipe_processor.go +++ b/internal/processor/product_recipe_processor.go @@ -235,7 +235,7 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe CreatedAt: entity.Ingredient.CreatedAt, UpdatedAt: entity.Ingredient.UpdatedAt, } - + // Add unit if available if entity.Ingredient.Unit != nil { symbol := "" @@ -253,4 +253,4 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe } return response -} \ No newline at end of file +} diff --git a/internal/processor/purchase_category_processor.go b/internal/processor/purchase_category_processor.go new file mode 100644 index 0000000..feda869 --- /dev/null +++ b/internal/processor/purchase_category_processor.go @@ -0,0 +1,210 @@ +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(), "_") +} diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index fd5863e..ca0819e 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -25,6 +25,7 @@ type PurchaseOrderProcessorImpl struct { purchaseOrderRepo PurchaseOrderRepository vendorRepo VendorRepository ingredientRepo IngredientRepository + purchaseCategoryRepo PurchaseCategoryRepository unitRepo UnitRepository fileRepo FileRepository inventoryMovementService InventoryMovementService @@ -35,6 +36,7 @@ func NewPurchaseOrderProcessorImpl( purchaseOrderRepo PurchaseOrderRepository, vendorRepo VendorRepository, ingredientRepo IngredientRepository, + purchaseCategoryRepo PurchaseCategoryRepository, unitRepo UnitRepository, fileRepo FileRepository, inventoryMovementService InventoryMovementService, @@ -44,6 +46,7 @@ func NewPurchaseOrderProcessorImpl( purchaseOrderRepo: purchaseOrderRepo, vendorRepo: vendorRepo, ingredientRepo: ingredientRepo, + purchaseCategoryRepo: purchaseCategoryRepo, unitRepo: unitRepo, fileRepo: fileRepo, inventoryMovementService: inventoryMovementService, @@ -64,8 +67,12 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber) } - // Validate ingredients and units exist + // Purchase orders are raw-material only because they affect ingredient stock. for i, item := range req.Items { + if err := p.validateRawMaterialPurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i); err != nil { + return nil, err + } + _, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) if err != nil { return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) @@ -109,12 +116,13 @@ 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, - Description: itemReq.Description, - Quantity: itemReq.Quantity, - UnitID: itemReq.UnitID, - Amount: itemReq.Amount, + PurchaseOrderID: poEntity.ID, + IngredientID: itemReq.IngredientID, + PurchaseCategoryID: itemReq.PurchaseCategoryID, + Description: itemReq.Description, + Quantity: itemReq.Quantity, + UnitID: itemReq.UnitID, + Amount: itemReq.Amount, } err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity) @@ -189,68 +197,70 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id // Update items if provided if req.Items != nil { - // Delete existing items + 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) + } + + if itemReq.IngredientID == nil { + return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i) + } + if itemReq.Quantity == nil { + return nil, fmt.Errorf("quantity is required for raw_material item %d", i) + } + if itemReq.UnitID == nil { + return nil, fmt.Errorf("unit_id is required for raw_material item %d", i) + } + + ingredientID := *itemReq.IngredientID + purchaseCategoryID := *itemReq.PurchaseCategoryID + unitID := *itemReq.UnitID + quantity := *itemReq.Quantity + amount := 0.0 + if itemReq.Amount != nil { + amount = *itemReq.Amount + } + description := itemReq.Description + + if err := p.validateRawMaterialPurchaseCategory(ctx, purchaseCategoryID, organizationID, i); err != nil { + return nil, err + } + + _, 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) + } + + 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. err = p.purchaseOrderRepo.DeleteItemsByPurchaseOrderID(ctx, poEntity.ID) if err != nil { return nil, fmt.Errorf("failed to delete existing items: %w", err) } - // Create new items - totalAmount := 0.0 - 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, - } - + for _, itemEntity := range items { err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity) if err != nil { return nil, fmt.Errorf("failed to create purchase order item: %w", err) } - - totalAmount += amount } poEntity.TotalAmount = totalAmount @@ -367,8 +377,6 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return nil, fmt.Errorf("purchase order not found: %w", err) } - fmt.Println("status:", po.Status) - // Check if status is changing to "received" and current status is not "received" if status == "received" && po.Status != "received" { // Get purchase order with items for inventory update @@ -419,6 +427,7 @@ 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) @@ -440,3 +449,20 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte return mappers.PurchaseOrderEntityToResponse(updatedPO), nil } + +func (p *PurchaseOrderProcessorImpl) validateRawMaterialPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) error { + category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) + if err != nil { + return fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err) + } + + if !category.IsActive { + return fmt.Errorf("purchase category for item %d is inactive", itemIndex) + } + + if category.Type != entities.PurchaseCategoryTypeRawMaterial { + return fmt.Errorf("purchase category for item %d must be raw_material", itemIndex) + } + + return nil +} diff --git a/internal/processor/split_bill_processor.go b/internal/processor/split_bill_processor.go index da7759c..5559125 100644 --- a/internal/processor/split_bill_processor.go +++ b/internal/processor/split_bill_processor.go @@ -22,8 +22,6 @@ const ( MetadataKeyLastSplitQuantities = "last_split_quantities" ) - - type SplitBillValidation struct { OrderItems map[uuid.UUID]*entities.OrderItem PaidQuantities map[uuid.UUID]int diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 62a9a90..6b2be98 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -18,6 +18,7 @@ type AnalyticsRepository interface { GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) + GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) } type AnalyticsRepositoryImpl struct { @@ -124,7 +125,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 +144,38 @@ 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, + COALESCE(SUM(poi.amount), 0) as raw_material_purchases, + 0 as expense_purchases, + COUNT(DISTINCT po.id) as total_purchase_orders, + COUNT(DISTINCT po.id) as raw_material_purchase_orders, + 0 as expense_count, + COALESCE(SUM(poi.quantity), 0) as total_quantity, CASE - WHEN COUNT(DISTINCT 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("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). + Joins("JOIN units u ON poi.unit_id = u.id"). + Where("po.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). + Where("po.status = ?", "received"). + 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 +184,41 @@ 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, + COALESCE(SUM(poi.amount), 0) as raw_material_purchases, + 0 as expense_purchases, + COUNT(DISTINCT po.id) as purchase_orders, + COUNT(DISTINCT po.id) as raw_material_purchase_orders, + 0 as expense_count, + COALESCE(SUM(poi.quantity), 0) as quantity, + COUNT(DISTINCT i.id) as ingredients, COUNT(DISTINCT po.vendor_id) as vendors `). - Joins("LEFT JOIN purchase_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("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). + Joins("JOIN units u ON poi.unit_id = u.id"). + Where("po.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). + Where("po.status = ?", "received"). + 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 +226,30 @@ 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 purchase_categories pc ON poi.purchase_category_id = pc.id"). + Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). + Joins("LEFT JOIN units u ON poi.unit_id = u.id"). + Where("po.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). + Where("po.status = ?", "received"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("i.id, i.name"). Order("total_cost DESC") - - 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 +257,27 @@ 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("JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). + Joins("JOIN units u ON poi.unit_id = u.id"). + Where("po.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). + Where("po.status = ?", "received"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). Group("v.id, v.name"). Order("total_cost DESC") - - 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 +292,21 @@ 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(` + EXISTS ( + SELECT 1 + FROM inventory_movements im + WHERE im.purchase_order_item_id = poi.id + AND im.movement_type = ? + AND im.outlet_id = ? + ) + `, entities.InventoryMovementTypePurchase, *outletID) +} + func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { var results []*entities.ProductAnalytics @@ -638,11 +669,11 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga query := r.db.WithContext(ctx). Table("expense_items ei"). - Select(`COALESCE(parent_coa.name, 'Lain-lain') as category_name, COALESCE(SUM(ei.amount), 0) as amount`). + Select(`pc.name as category_name, COALESCE(SUM(ei.amount), 0) as amount`). Joins("JOIN expenses e ON ei.expense_id = e.id"). - Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id"). - Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id"). + Joins("JOIN purchase_categories pc ON ei.purchase_category_id = pc.id"). Where("e.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeExpense). Where("e.status = ?", "approved"). Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) @@ -651,8 +682,8 @@ func (r *AnalyticsRepositoryImpl) getExpenseByCategory(ctx context.Context, orga } err := query. - Group("parent_coa.name"). - Order("parent_coa.name"). + Group("pc.id, pc.name, pc.sort_order"). + Order("pc.sort_order ASC, pc.name ASC"). Scan(&results).Error return results, err @@ -663,10 +694,11 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context query := r.db.WithContext(ctx). Table("expense_items ei"). - Select(`COALESCE(NULLIF(ei.item, ''), ei.description, coa.name) as item, COALESCE(SUM(ei.amount), 0) as amount`). + Select(`COALESCE(NULLIF(ei.item, ''), ei.description, pc.name) as item, COALESCE(SUM(ei.amount), 0) as amount`). Joins("JOIN expenses e ON ei.expense_id = e.id"). - Joins("JOIN chart_of_accounts coa ON ei.chart_of_account_id = coa.id"). + Joins("JOIN purchase_categories pc ON ei.purchase_category_id = pc.id"). Where("e.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeExpense). Where("e.status = ?", "approved"). Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) @@ -675,9 +707,222 @@ func (r *AnalyticsRepositoryImpl) getOperationalExpenseItems(ctx context.Context } err := query. - Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)"). + Group("COALESCE(NULLIF(ei.item, ''), ei.description, pc.name)"). Order("amount DESC"). Scan(&results).Error return results, err } + +func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) { + type salesResult struct { + SalesTotal float64 + SalesCount int64 + } + + var sales salesResult + salesQuery := r.db.WithContext(ctx). + Table("orders o"). + Select(` + COALESCE(SUM(o.total_amount), 0) as sales_total, + COUNT(o.id) as sales_count + `). + Where("o.organization_id = ?", organizationID). + Where("o.status = ?", entities.OrderStatusCompleted). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). + Where("o.is_void = false AND o.is_refund = false"). + Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) + salesQuery = r.resolveOutletID(salesQuery, outletID, "o.outlet_id") + if err := salesQuery.Scan(&sales).Error; err != nil { + return nil, err + } + + hppBreakdown, err := r.getExclusiveSummaryHPPBreakdown(ctx, organizationID, outletID, dateFrom, dateTo) + if err != nil { + return nil, err + } + + operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo) + if err != nil { + return nil, err + } + + dailySummary, err := r.getExclusiveSummaryDailySummary(ctx, organizationID, outletID, dateFrom, dateTo) + if err != nil { + return nil, err + } + + dailyTransactions, err := r.getExclusiveSummaryDailyTransactions(ctx, organizationID, outletID, dateFrom, dateTo) + if err != nil { + return nil, err + } + + return &entities.ExclusiveSummaryAnalytics{ + SalesTotal: sales.SalesTotal, + SalesCount: sales.SalesCount, + HPPBreakdown: hppBreakdown, + OperationalExpenseBreakdown: operationalExpenseBreakdown, + DailySummary: dailySummary, + DailyTransactions: dailyTransactions, + }, nil +} + +func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) { + var results []entities.ExclusiveSummaryCategoryTotal + + query := r.db.WithContext(ctx). + Table("purchase_order_items poi"). + Select(` + pc.code as category_code, + pc.name as category_name, + COALESCE(SUM(poi.amount), 0) as amount + `). + Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id"). + Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Joins("JOIN ingredients i ON poi.ingredient_id = i.id"). + Joins("LEFT JOIN units u ON poi.unit_id = u.id"). + Where("po.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeRawMaterial). + Where("po.status = ?", "received"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) + query = r.applyPurchaseOrderItemOutletFilter(query, outletID) + + err := query. + Group("pc.id, pc.code, pc.name, pc.sort_order"). + Order("pc.sort_order ASC, pc.name ASC"). + Scan(&results).Error + + return results, err +} + +func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) { + var results []entities.ExclusiveSummaryCategoryTotal + + query := r.db.WithContext(ctx). + Table("expense_items ei"). + Select(` + pc.code as category_code, + pc.name as category_name, + COALESCE(SUM(ei.amount), 0) as amount + `). + 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) + + if outletID != nil { + query = query.Where("e.outlet_id = ?", *outletID) + } + + err := query. + Group("pc.id, pc.code, pc.name, pc.sort_order"). + Order("pc.sort_order ASC, pc.name ASC"). + Scan(&results).Error + + return results, err +} + +func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailySummary(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailySummary, error) { + var results []entities.ExclusiveSummaryDailySummary + rawQuery, args := r.exclusiveSummaryTransactionUnionQuery(organizationID, outletID, dateFrom, dateTo) + + err := r.db.WithContext(ctx).Raw(` + SELECT date, COUNT(*) as transaction_count, COALESCE(SUM(amount), 0) as total_cost + FROM (`+rawQuery+`) transactions + GROUP BY date + ORDER BY date ASC + `, args...).Scan(&results).Error + + return results, err +} + +func (r *AnalyticsRepositoryImpl) getExclusiveSummaryDailyTransactions(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryDailyTransaction, error) { + var results []entities.ExclusiveSummaryDailyTransaction + rawQuery, args := r.exclusiveSummaryTransactionUnionQuery(organizationID, outletID, dateFrom, dateTo) + + err := r.db.WithContext(ctx).Raw(` + SELECT date, category_code, category_name, description, amount, source + FROM (`+rawQuery+`) transactions + ORDER BY date ASC, source ASC, category_name ASC, description ASC + `, args...).Scan(&results).Error + + return results, err +} + +func (r *AnalyticsRepositoryImpl) exclusiveSummaryTransactionUnionQuery(organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (string, []interface{}) { + poOutletFilter := "" + expenseOutletFilter := "" + args := []interface{}{ + organizationID, + entities.PurchaseCategoryTypeRawMaterial, + "received", + dateFrom, + dateTo, + } + + if outletID != nil { + poOutletFilter = `AND EXISTS ( + SELECT 1 + FROM inventory_movements im + WHERE im.purchase_order_item_id = poi.id + AND im.movement_type = 'purchase' + AND im.outlet_id = ? + )` + args = append(args, *outletID) + } + + args = append(args, + organizationID, + entities.PurchaseCategoryTypeExpense, + "approved", + dateFrom, + dateTo, + ) + + if outletID != nil { + expenseOutletFilter = "AND e.outlet_id = ?" + args = append(args, *outletID) + } + + query := ` + SELECT + DATE(po.transaction_date) as date, + pc.code as category_code, + pc.name as category_name, + COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as description, + poi.amount as amount, + 'purchase_order' as source + FROM purchase_order_items poi + JOIN purchase_orders po ON poi.purchase_order_id = po.id + JOIN purchase_categories pc ON poi.purchase_category_id = pc.id + JOIN ingredients i ON poi.ingredient_id = i.id + LEFT JOIN units u ON poi.unit_id = u.id + WHERE po.organization_id = ? + AND pc.type = ? + AND po.status = ? + AND po.transaction_date >= ? AND po.transaction_date <= ? + ` + poOutletFilter + ` + + UNION ALL + + SELECT + DATE(e.transaction_date) as date, + pc.code as category_code, + pc.name as category_name, + COALESCE(NULLIF(ei.item, ''), NULLIF(ei.description, ''), pc.name) as description, + ei.amount as amount, + 'expense' as source + FROM expense_items ei + JOIN expenses e ON ei.expense_id = e.id + JOIN purchase_categories pc ON ei.purchase_category_id = pc.id + WHERE e.organization_id = ? + AND pc.type = ? + AND e.status = ? + AND e.transaction_date >= ? AND e.transaction_date <= ? + ` + expenseOutletFilter + ` + ` + + return query, args +} diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index 335d0eb..8698db2 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -30,6 +30,7 @@ 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 @@ -41,6 +42,7 @@ 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 { @@ -107,6 +109,7 @@ 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). @@ -114,6 +117,165 @@ 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 } diff --git a/internal/repository/ingredient_unit_converter_repository.go b/internal/repository/ingredient_unit_converter_repository.go index 88ae55a..a8d723e 100644 --- a/internal/repository/ingredient_unit_converter_repository.go +++ b/internal/repository/ingredient_unit_converter_repository.go @@ -173,4 +173,3 @@ func (r *IngredientUnitConverterRepositoryImpl) ConvertQuantity(ctx context.Cont // If no converter found, return error return 0, fmt.Errorf("no conversion found between units %s and %s for ingredient %s", fromUnitID, toUnitID, ingredientID) } - diff --git a/internal/repository/purchase_category_repository.go b/internal/repository/purchase_category_repository.go new file mode 100644 index 0000000..331a423 --- /dev/null +++ b/internal/repository/purchase_category_repository.go @@ -0,0 +1,91 @@ +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 +} diff --git a/internal/repository/purchase_order_repository.go b/internal/repository/purchase_order_repository.go index 0d51a8e..8383eec 100644 --- a/internal/repository/purchase_order_repository.go +++ b/internal/repository/purchase_order_repository.go @@ -31,6 +31,7 @@ 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 @@ -45,6 +46,7 @@ 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). @@ -105,6 +107,7 @@ 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"). @@ -168,6 +171,7 @@ 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 @@ -179,6 +183,7 @@ 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 @@ -219,6 +224,7 @@ 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 diff --git a/internal/router/router.go b/internal/router/router.go index 3472b45..35aec41 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -36,6 +36,7 @@ type Router struct { productRecipeHandler *handler.ProductRecipeHandler vendorHandler *handler.VendorHandler purchaseOrderHandler *handler.PurchaseOrderHandler + purchaseCategoryHandler *handler.PurchaseCategoryHandler unitConverterHandler *handler.IngredientUnitConverterHandler chartOfAccountTypeHandler *handler.ChartOfAccountTypeHandler chartOfAccountHandler *handler.ChartOfAccountHandler @@ -57,7 +58,7 @@ type Router struct { 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, 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, 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 { return &Router{ config: cfg, @@ -82,6 +83,7 @@ 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), @@ -335,6 +337,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory) analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics) analytics.GET("/profit-loss", r.analyticsHandler.GetProfitLossAnalytics) + analytics.GET("/exclusive-summary/period", r.analyticsHandler.GetExclusiveSummaryPeriod) + analytics.GET("/exclusive-summary/monthly", r.analyticsHandler.GetExclusiveSummaryMonthly) } tables := protected.Group("/tables") @@ -387,6 +391,16 @@ 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()) { @@ -454,6 +468,7 @@ 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) diff --git a/internal/service/account_service.go b/internal/service/account_service.go index 83a5c9f..43c4f00 100644 --- a/internal/service/account_service.go +++ b/internal/service/account_service.go @@ -60,12 +60,12 @@ func (s *AccountServiceImpl) ListAccounts(ctx context.Context, req *contract.Lis if err != nil { return nil, 0, err } - + contractResp := make([]contract.AccountResponse, len(modelResp)) for i, resp := range modelResp { contractResp[i] = *mappers.ModelToContractAccountResponse(&resp) } - + return contractResp, total, nil } @@ -74,12 +74,12 @@ func (s *AccountServiceImpl) GetAccountsByOrganization(ctx context.Context, orga if err != nil { return nil, err } - + contractResp := make([]contract.AccountResponse, len(modelResp)) for i, resp := range modelResp { contractResp[i] = *mappers.ModelToContractAccountResponse(&resp) } - + return contractResp, nil } @@ -88,12 +88,12 @@ func (s *AccountServiceImpl) GetAccountsByChartOfAccount(ctx context.Context, ch if err != nil { return nil, err } - + contractResp := make([]contract.AccountResponse, len(modelResp)) for i, resp := range modelResp { contractResp[i] = *mappers.ModelToContractAccountResponse(&resp) } - + return contractResp, nil } diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index 5496ad2..b9dc137 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -18,6 +18,8 @@ type AnalyticsService interface { GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) + GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) + GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) } type AnalyticsServiceImpl struct { @@ -320,3 +322,69 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr return nil } + +func (s *AnalyticsServiceImpl) GetExclusiveSummaryPeriod(ctx context.Context, req *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + if err := s.validateExclusiveSummaryPeriodRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + response, err := s.analyticsProcessor.GetExclusiveSummaryPeriod(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get exclusive summary period: %w", err) + } + + return response, nil +} + +func (s *AnalyticsServiceImpl) GetExclusiveSummaryMonthly(ctx context.Context, req *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) { + if err := s.validateExclusiveSummaryMonthlyRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + response, err := s.analyticsProcessor.GetExclusiveSummaryMonthly(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get exclusive summary monthly: %w", err) + } + + return response, nil +} + +func (s *AnalyticsServiceImpl) validateExclusiveSummaryPeriodRequest(req *models.ExclusiveSummaryPeriodRequest) error { + if req == nil { + return fmt.Errorf("request cannot be nil") + } + + if req.OrganizationID == uuid.Nil { + return fmt.Errorf("organization_id is required") + } + + if req.DateFrom.IsZero() { + return fmt.Errorf("date_from is required") + } + + if req.DateTo.IsZero() { + return fmt.Errorf("date_to is required") + } + + if req.DateFrom.After(req.DateTo) { + return fmt.Errorf("date_from cannot be after date_to") + } + + return nil +} + +func (s *AnalyticsServiceImpl) validateExclusiveSummaryMonthlyRequest(req *models.ExclusiveSummaryMonthlyRequest) error { + if req == nil { + return fmt.Errorf("request cannot be nil") + } + + if req.OrganizationID == uuid.Nil { + return fmt.Errorf("organization_id is required") + } + + if req.Month.IsZero() { + return fmt.Errorf("month is required") + } + + return nil +} diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index c43419d..49b42ce 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -41,6 +41,14 @@ func (analyticsProcessorStub) GetProfitLossAnalytics(context.Context, *models.Pr return nil, nil } +func (analyticsProcessorStub) GetExclusiveSummaryPeriod(context.Context, *models.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodResponse, error) { + return &models.ExclusiveSummaryPeriodResponse{}, nil +} + +func (analyticsProcessorStub) GetExclusiveSummaryMonthly(context.Context, *models.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyResponse, error) { + return &models.ExclusiveSummaryMonthlyResponse{}, nil +} + func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) { service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) diff --git a/internal/service/chart_of_account_type_service.go b/internal/service/chart_of_account_type_service.go index a4085d9..9f3c66b 100644 --- a/internal/service/chart_of_account_type_service.go +++ b/internal/service/chart_of_account_type_service.go @@ -59,11 +59,11 @@ func (s *ChartOfAccountTypeServiceImpl) ListChartOfAccountTypes(ctx context.Cont if err != nil { return nil, 0, err } - + contractResp := make([]contract.ChartOfAccountTypeResponse, len(modelResp)) for i, resp := range modelResp { contractResp[i] = *mappers.ModelToContractChartOfAccountTypeResponse(&resp) } - + return contractResp, total, nil } diff --git a/internal/service/expense_service.go b/internal/service/expense_service.go index bb8a417..9e937b4 100644 --- a/internal/service/expense_service.go +++ b/internal/service/expense_service.go @@ -19,6 +19,7 @@ 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 { @@ -126,3 +127,24 @@ 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)) +} diff --git a/internal/service/ingredient_unit_converter_service.go b/internal/service/ingredient_unit_converter_service.go index 7d7d0a4..3432056 100644 --- a/internal/service/ingredient_unit_converter_service.go +++ b/internal/service/ingredient_unit_converter_service.go @@ -160,4 +160,3 @@ func (s *IngredientUnitConverterServiceImpl) GetUnitsByIngredientID(ctx context. contractResponse := transformer.IngredientUnitsModelResponseToResponse(unitsResponse) return contract.BuildSuccessResponse(contractResponse) } - diff --git a/internal/service/inventory_movement_service.go b/internal/service/inventory_movement_service.go index 9300a5a..4cda860 100644 --- a/internal/service/inventory_movement_service.go +++ b/internal/service/inventory_movement_service.go @@ -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) 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, purchaseOrderItemID *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) 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, purchaseOrderItemID *uuid.UUID) error { ingredient, err := s.ingredientRepo.GetByID(ctx, ingredientID, organizationID) if err != nil { return err @@ -36,22 +36,23 @@ 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, - 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, + PurchaseOrderItemID: purchaseOrderItemID, + UserID: userID, + Reason: &reason, + CreatedAt: time.Now(), } err = s.inventoryMovementRepo.Create(ctx, movement) diff --git a/internal/service/product_recipe_service.go b/internal/service/product_recipe_service.go index bfaf76d..fe0c5b6 100644 --- a/internal/service/product_recipe_service.go +++ b/internal/service/product_recipe_service.go @@ -111,4 +111,4 @@ func (s *ProductRecipeServiceImpl) BulkCreate(ctx context.Context, organizationI } return s.processor.BulkCreate(ctx, req.Recipes, organizationID) -} \ No newline at end of file +} diff --git a/internal/service/purchase_category_service.go b/internal/service/purchase_category_service.go new file mode 100644 index 0000000..eacc77c --- /dev/null +++ b/internal/service/purchase_category_service.go @@ -0,0 +1,107 @@ +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, + }) +} diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index f157598..9cd5dcd 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -169,12 +169,16 @@ 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, - PurchaseOrders: item.PurchaseOrders, - Quantity: item.Quantity, - Ingredients: item.Ingredients, - Vendors: item.Vendors, + 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, } } @@ -211,7 +215,11 @@ 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, @@ -551,3 +559,229 @@ func profitLossSummaryRowModelToContract(row models.ProfitLossSummaryRow) contra SubItems: subItems, } } + +func ExclusiveSummaryPeriodContractToModel(req *contract.ExclusiveSummaryPeriodRequest) (*models.ExclusiveSummaryPeriodRequest, error) { + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + + dateFrom, dateTo, err := parseFlexibleDateRangeToJakartaTime(req.DateFrom, req.DateTo) + if err != nil { + return nil, fmt.Errorf("invalid date range: %w", err) + } + + if dateFrom == nil { + return nil, fmt.Errorf("date_from is required") + } + + if dateTo == nil { + return nil, fmt.Errorf("date_to is required") + } + + return &models.ExclusiveSummaryPeriodRequest{ + OrganizationID: req.OrganizationID, + OutletID: parseOutletID(req.OutletID), + DateFrom: *dateFrom, + DateTo: *dateTo, + ExcludeGajiStaffFromReimburse: req.ExcludeGajiStaffFromReimburse, + }, nil +} + +func ExclusiveSummaryMonthlyContractToModel(req *contract.ExclusiveSummaryMonthlyRequest) (*models.ExclusiveSummaryMonthlyRequest, error) { + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + + month, err := parseMonthToJakartaTime(req.Month) + if err != nil { + return nil, fmt.Errorf("invalid month: %w", err) + } + + return &models.ExclusiveSummaryMonthlyRequest{ + OrganizationID: req.OrganizationID, + OutletID: parseOutletID(req.OutletID), + Month: month, + }, nil +} + +func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodResponse) *contract.ExclusiveSummaryPeriodResponse { + if resp == nil { + return nil + } + + hppBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.HPPBreakdown)) + for i, item := range resp.HPPBreakdown { + hppBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item) + } + + operationalBreakdown := make([]contract.ExclusiveSummaryCategoryBreakdown, len(resp.OperationalExpenseBreakdown)) + for i, item := range resp.OperationalExpenseBreakdown { + operationalBreakdown[i] = exclusiveSummaryCategoryBreakdownModelToContract(item) + } + + dailySummary := make([]contract.ExclusiveSummaryDailySummary, len(resp.DailySummary)) + for i, item := range resp.DailySummary { + dailySummary[i] = contract.ExclusiveSummaryDailySummary{ + Date: item.Date, + TransactionCount: item.TransactionCount, + TotalCost: item.TotalCost, + } + } + + dailyTransactions := make([]contract.ExclusiveSummaryDailyTransaction, len(resp.DailyTransactions)) + for i, item := range resp.DailyTransactions { + dailyTransactions[i] = contract.ExclusiveSummaryDailyTransaction{ + Date: item.Date, + CategoryCode: item.CategoryCode, + CategoryName: item.CategoryName, + Description: item.Description, + Amount: item.Amount, + Source: item.Source, + } + } + + return &contract.ExclusiveSummaryPeriodResponse{ + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + Period: contract.ExclusiveSummaryPeriodRange{ + DateFrom: resp.Period.DateFrom, + DateTo: resp.Period.DateTo, + }, + Summary: contract.ExclusiveSummaryPeriodSummary{ + Sales: resp.Summary.Sales, + HPP: resp.Summary.HPP, + GrossProfit: resp.Summary.GrossProfit, + SalaryTotal: resp.Summary.SalaryTotal, + SalaryDW: resp.Summary.SalaryDW, + SalaryStaff: resp.Summary.SalaryStaff, + SalaryOther: resp.Summary.SalaryOther, + OtherOperationalExpenses: resp.Summary.OtherOperationalExpenses, + OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal, + TotalCost: resp.Summary.TotalCost, + NetProfit: resp.Summary.NetProfit, + }, + Reimburse: contract.ExclusiveSummaryReimburse{ + TotalCost: resp.Reimburse.TotalCost, + ExcludedSalaryStaff: resp.Reimburse.ExcludedSalaryStaff, + TotalReimburse: resp.Reimburse.TotalReimburse, + }, + HPPBreakdown: hppBreakdown, + OperationalExpenseBreakdown: operationalBreakdown, + DailySummary: dailySummary, + DailyTransactions: dailyTransactions, + } +} + +func ExclusiveSummaryMonthlyModelToContract(resp *models.ExclusiveSummaryMonthlyResponse) *contract.ExclusiveSummaryMonthlyResponse { + if resp == nil { + return nil + } + + periods := make([]contract.ExclusiveSummaryMonthlyPeriod, len(resp.Periods)) + for i, item := range resp.Periods { + periods[i] = contract.ExclusiveSummaryMonthlyPeriod{ + Label: item.Label, + DateFrom: item.DateFrom, + DateTo: item.DateTo, + Sales: item.Sales, + HPP: item.HPP, + GrossProfit: item.GrossProfit, + GrossMargin: item.GrossMargin, + } + } + + bankBalance := make([]contract.ExclusiveSummaryBankBalance, len(resp.BankBalance)) + for i, item := range resp.BankBalance { + bankBalance[i] = contract.ExclusiveSummaryBankBalance{ + Bank: item.Bank, + OpeningBalance: item.OpeningBalance, + IncomingMutation: item.IncomingMutation, + OutgoingMutation: item.OutgoingMutation, + ClosingBalance: item.ClosingBalance, + Notes: item.Notes, + } + } + + return &contract.ExclusiveSummaryMonthlyResponse{ + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + Month: resp.Month, + Summary: contract.ExclusiveSummaryMonthlySummary{ + TotalSales: resp.Summary.TotalSales, + HPP: resp.Summary.HPP, + GrossProfit: resp.Summary.GrossProfit, + OperationalExpensesTotal: resp.Summary.OperationalExpensesTotal, + TotalCost: resp.Summary.TotalCost, + NetProfit: resp.Summary.NetProfit, + NetProfitMargin: resp.Summary.NetProfitMargin, + }, + Periods: periods, + BankBalance: bankBalance, + } +} + +func exclusiveSummaryCategoryBreakdownModelToContract(item models.ExclusiveSummaryCategoryBreakdown) contract.ExclusiveSummaryCategoryBreakdown { + return contract.ExclusiveSummaryCategoryBreakdown{ + CategoryCode: item.CategoryCode, + CategoryName: item.CategoryName, + Amount: item.Amount, + Percentage: item.Percentage, + } +} + +func parseFlexibleDateRangeToJakartaTime(dateFrom, dateTo string) (*time.Time, *time.Time, error) { + fromTime, toTime, err := util.ParseDateRangeToJakartaTime(dateFrom, dateTo) + if err == nil { + return fromTime, toTime, nil + } + + fromTime, err = parseISODateToJakartaTime(dateFrom, false) + if err != nil { + return nil, nil, err + } + + toTime, err = parseISODateToJakartaTime(dateTo, true) + if err != nil { + return nil, nil, err + } + + return fromTime, toTime, nil +} + +func parseISODateToJakartaTime(dateStr string, endOfDay bool) (*time.Time, error) { + if dateStr == "" { + return nil, nil + } + + date, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return nil, err + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, err + } + + if endOfDay { + result := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, location) + return &result, nil + } + + result := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location) + return &result, nil +} + +func parseMonthToJakartaTime(month string) (time.Time, error) { + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return time.Time{}, err + } + + parsed, err := time.ParseInLocation("2006-01", month, location) + if err != nil { + return time.Time{}, err + } + + return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, location), nil +} diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 2a7bc6c..270945d 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -52,17 +52,47 @@ 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) { @@ -152,3 +182,42 @@ func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) { require.Len(t, result.MainSummary, 1) require.Equal(t, "total_omset", result.MainSummary[0].ID) } + +func TestExclusiveSummaryPeriodContractToModelParsesISODateRange(t *testing.T) { + orgID := uuid.New() + outletID := uuid.New().String() + + result, err := ExclusiveSummaryPeriodContractToModel(&contract.ExclusiveSummaryPeriodRequest{ + OrganizationID: orgID, + OutletID: &outletID, + DateFrom: "2026-05-26", + DateTo: "2026-05-31", + ExcludeGajiStaffFromReimburse: true, + }) + + require.NoError(t, err) + require.Equal(t, orgID, result.OrganizationID) + require.NotNil(t, result.OutletID) + require.Equal(t, outletID, result.OutletID.String()) + require.True(t, result.ExcludeGajiStaffFromReimburse) + + location, err := time.LoadLocation("Asia/Jakarta") + require.NoError(t, err) + require.Equal(t, time.Date(2026, 5, 26, 0, 0, 0, 0, location), result.DateFrom) + require.Equal(t, time.Date(2026, 5, 31, 23, 59, 59, int(time.Second-time.Nanosecond), location), result.DateTo) +} + +func TestExclusiveSummaryMonthlyContractToModelParsesMonth(t *testing.T) { + orgID := uuid.New() + + result, err := ExclusiveSummaryMonthlyContractToModel(&contract.ExclusiveSummaryMonthlyRequest{ + OrganizationID: orgID, + Month: "2026-05", + }) + + require.NoError(t, err) + require.Equal(t, orgID, result.OrganizationID) + location, err := time.LoadLocation("Asia/Jakarta") + require.NoError(t, err) + require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.Month) +} diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index 5b3c47c..bc8606c 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -3,6 +3,7 @@ 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 { @@ -26,10 +27,11 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre func CreateExpenseItemRequestToModel(req *contract.CreateExpenseItemRequest) models.CreateExpenseItemRequest { return models.CreateExpenseItemRequest{ - ChartOfAccountID: req.ChartOfAccountID, - Item: req.Item, - Description: req.Description, - Amount: req.Amount, + ChartOfAccountID: req.ChartOfAccountID, + PurchaseCategoryID: req.PurchaseCategoryID, + Item: req.Item, + Description: req.Description, + Amount: req.Amount, } } @@ -59,10 +61,11 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd func UpdateExpenseItemRequestToModel(req *contract.UpdateExpenseItemRequest) models.UpdateExpenseItemRequest { return models.UpdateExpenseItemRequest{ - ChartOfAccountID: req.ChartOfAccountID, - Item: req.Item, - Description: req.Description, - Amount: req.Amount, + ChartOfAccountID: req.ChartOfAccountID, + PurchaseCategoryID: req.PurchaseCategoryID, + Item: req.Item, + Description: req.Description, + Amount: req.Amount, } } @@ -108,15 +111,19 @@ 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, - 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, + 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, } } @@ -134,3 +141,94 @@ 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, + } +} diff --git a/internal/transformer/ingredient_unit_converter_transformer.go b/internal/transformer/ingredient_unit_converter_transformer.go index e3512b0..2d64c49 100644 --- a/internal/transformer/ingredient_unit_converter_transformer.go +++ b/internal/transformer/ingredient_unit_converter_transformer.go @@ -175,4 +175,3 @@ func IngredientUnitsModelResponseToResponse(model *models.IngredientUnitsRespons return response } - diff --git a/internal/transformer/purchase_category_transformer.go b/internal/transformer/purchase_category_transformer.go new file mode 100644 index 0000000..24f3d97 --- /dev/null +++ b/internal/transformer/purchase_category_transformer.go @@ -0,0 +1,72 @@ +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 +} diff --git a/internal/transformer/purchase_order_transformer.go b/internal/transformer/purchase_order_transformer.go index 9860f8f..b61e2ef 100644 --- a/internal/transformer/purchase_order_transformer.go +++ b/internal/transformer/purchase_order_transformer.go @@ -11,11 +11,12 @@ 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, - Description: item.Description, - Quantity: item.Quantity, - UnitID: item.UnitID, - Amount: item.Amount, + IngredientID: item.IngredientID, + PurchaseCategoryID: item.PurchaseCategoryID, + Description: item.Description, + Quantity: item.Quantity, + UnitID: item.UnitID, + Amount: item.Amount, } } @@ -54,12 +55,13 @@ 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, - Description: item.Description, - Quantity: item.Quantity, - UnitID: item.UnitID, - Amount: item.Amount, + ID: item.ID, + IngredientID: item.IngredientID, + PurchaseCategoryID: item.PurchaseCategoryID, + Description: item.Description, + Quantity: item.Quantity, + UnitID: item.UnitID, + Amount: item.Amount, } } } @@ -154,15 +156,16 @@ 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, - 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, + PurchaseCategoryID: item.PurchaseCategoryID, + Description: item.Description, + Quantity: item.Quantity, + UnitID: item.UnitID, + Amount: item.Amount, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, } // Map ingredient if present @@ -173,6 +176,10 @@ 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{ diff --git a/internal/util/waste_util.go b/internal/util/waste_util.go index 696fafc..a627365 100644 --- a/internal/util/waste_util.go +++ b/internal/util/waste_util.go @@ -20,11 +20,11 @@ func CalculateWasteQuantities(productIngredients []*entities.ProductIngredient, for _, pi := range productIngredients { // Calculate net quantity (actual quantity needed for the product) netQty := pi.Quantity * quantity - + // Calculate gross quantity (including waste) wasteMultiplier := 1 + (pi.WastePercentage / 100) grossQty := netQty * wasteMultiplier - + // Calculate waste quantity wasteQty := grossQty - netQty diff --git a/internal/validator/expense_validator.go b/internal/validator/expense_validator.go index c9306eb..f5be379 100644 --- a/internal/validator/expense_validator.go +++ b/internal/validator/expense_validator.go @@ -68,12 +68,18 @@ 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 } @@ -126,6 +132,15 @@ 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 } diff --git a/internal/validator/expense_validator_test.go b/internal/validator/expense_validator_test.go index d9ae15d..8729b8f 100644 --- a/internal/validator/expense_validator_test.go +++ b/internal/validator/expense_validator_test.go @@ -21,8 +21,9 @@ func TestExpenseValidatorCreateRequiresItemName(t *testing.T) { Total: 10000, Items: []contract.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: uuid.NewString(), + Amount: 10000, }, }, } @@ -45,9 +46,10 @@ func TestExpenseValidatorCreateDoesNotRequireHeaderExpenseName(t *testing.T) { Total: 10000, Items: []contract.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, }, }, } @@ -71,9 +73,10 @@ func TestExpenseValidatorCreateAllowsValidOptionalStatus(t *testing.T) { Total: 10000, Items: []contract.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, }, }, } @@ -97,9 +100,10 @@ func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) { Total: 10000, Items: []contract.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, }, }, } @@ -114,10 +118,11 @@ func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) { func TestExpenseValidatorUpdateRejectsEmptyItemNameWhenProvided(t *testing.T) { v := NewExpenseValidator() empty := " " + purchaseCategoryID := uuid.NewString() req := &contract.UpdateExpenseRequest{ Items: []contract.UpdateExpenseItemRequest{ - {Item: &empty}, + {PurchaseCategoryID: &purchaseCategoryID, Item: &empty}, }, } diff --git a/internal/validator/ingredient_unit_converter_validator.go b/internal/validator/ingredient_unit_converter_validator.go index f2a0499..a5ae6e9 100644 --- a/internal/validator/ingredient_unit_converter_validator.go +++ b/internal/validator/ingredient_unit_converter_validator.go @@ -119,4 +119,3 @@ func (v *IngredientUnitConverterValidatorImpl) ValidateConvertUnitRequest(req *c return nil, "" } - diff --git a/internal/validator/purchase_category_validator.go b/internal/validator/purchase_category_validator.go new file mode 100644 index 0000000..e69376f --- /dev/null +++ b/internal/validator/purchase_category_validator.go @@ -0,0 +1,103 @@ +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 + } +} diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index 824ea1b..a62a7c7 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -2,11 +2,14 @@ package validator import ( "errors" + "strconv" "strings" "time" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" + + "github.com/google/uuid" ) type PurchaseOrderValidator interface { @@ -26,7 +29,7 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con return errors.New("request body is required"), constants.MissingFieldErrorCode } - if req.VendorID.String() == "" { + if req.VendorID == uuid.Nil { return errors.New("vendor_id is required"), constants.MissingFieldErrorCode } @@ -178,32 +181,48 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont } func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) { - if item.IngredientID.String() == "" { - return errors.New("items[" + string(rune(index)) + "].ingredient_id is required"), constants.MissingFieldErrorCode + if item.IngredientID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id is required"), constants.MissingFieldErrorCode + } + + if item.PurchaseCategoryID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode } if item.Quantity <= 0 { - return errors.New("items[" + string(rune(index)) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode + return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } - if item.UnitID.String() == "" { - return errors.New("items[" + string(rune(index)) + "].unit_id is required"), constants.MissingFieldErrorCode + if item.UnitID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode } if item.Amount < 0 { - return errors.New("items[" + string(rune(index)) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode + return errors.New("items[" + strconv.Itoa(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.Quantity != nil && *item.Quantity <= 0 { - return errors.New("items[" + string(rune(index)) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode + 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 is required"), constants.MissingFieldErrorCode + } + + if item.UnitID == nil || *item.UnitID == uuid.Nil { + return errors.New("items[" + strconv.Itoa(index) + "].unit_id is required"), constants.MissingFieldErrorCode + } + + if item.Quantity == nil || *item.Quantity <= 0 { + return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode } if item.Amount != nil && *item.Amount < 0 { - return errors.New("items[" + string(rune(index)) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode + return errors.New("items[" + strconv.Itoa(index) + "].amount must be greater than or equal to 0"), constants.MalformedFieldErrorCode } return nil, "" diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index 3cd821c..34c39f2 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -17,15 +17,28 @@ func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest { TransactionDate: "2026-05-29", Items: []contract.CreatePurchaseOrderItemRequest{ { - IngredientID: uuid.New(), - Quantity: 1, - UnitID: uuid.New(), - Amount: 1000, + IngredientID: uuid.New(), + PurchaseCategoryID: uuid.New(), + Quantity: 1, + UnitID: uuid.New(), + Amount: 1000, }, }, } } +func TestPurchaseOrderValidatorCreateRejectsMissingRawMaterialFields(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + req.Items[0].IngredientID = uuid.Nil + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MissingFieldErrorCode, code) + require.Contains(t, err.Error(), "ingredient_id is required") +} + func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) { validator := NewPurchaseOrderValidator() @@ -60,3 +73,31 @@ func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *test require.Equal(t, constants.MalformedFieldErrorCode, code) require.Contains(t, err.Error(), "due_date must be after transaction_date") } + +func TestPurchaseOrderValidatorUpdateItemsRequireFullReplacementFields(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := &contract.UpdatePurchaseOrderRequest{ + Items: []contract.UpdatePurchaseOrderItemRequest{ + { + PurchaseCategoryID: ptrUUID(uuid.New()), + Quantity: ptrFloat64(1), + UnitID: ptrUUID(uuid.New()), + Amount: ptrFloat64(1000), + }, + }, + } + + err, code := validator.ValidateUpdatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MissingFieldErrorCode, code) + require.Contains(t, err.Error(), "ingredient_id is required") +} + +func ptrUUID(id uuid.UUID) *uuid.UUID { + return &id +} + +func ptrFloat64(value float64) *float64 { + return &value +} diff --git a/migrations/000075_create_purchase_categories.down.sql b/migrations/000075_create_purchase_categories.down.sql new file mode 100644 index 0000000..350087f --- /dev/null +++ b/migrations/000075_create_purchase_categories.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS purchase_categories; +DROP TABLE IF EXISTS purchase_category_presets; diff --git a/migrations/000075_create_purchase_categories.up.sql b/migrations/000075_create_purchase_categories.up.sql new file mode 100644 index 0000000..0b419a3 --- /dev/null +++ b/migrations/000075_create_purchase_categories.up.sql @@ -0,0 +1,60 @@ +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; diff --git a/migrations/000076_add_default_purchase_categories_trigger.down.sql b/migrations/000076_add_default_purchase_categories_trigger.down.sql new file mode 100644 index 0000000..ccfc82e --- /dev/null +++ b/migrations/000076_add_default_purchase_categories_trigger.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS trigger_create_default_purchase_categories ON organizations; +DROP FUNCTION IF EXISTS create_default_purchase_categories(); diff --git a/migrations/000076_add_default_purchase_categories_trigger.up.sql b/migrations/000076_add_default_purchase_categories_trigger.up.sql new file mode 100644 index 0000000..d5ff930 --- /dev/null +++ b/migrations/000076_add_default_purchase_categories_trigger.up.sql @@ -0,0 +1,49 @@ +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(); diff --git a/migrations/000077_backfill_default_purchase_categories.down.sql b/migrations/000077_backfill_default_purchase_categories.down.sql new file mode 100644 index 0000000..baf5349 --- /dev/null +++ b/migrations/000077_backfill_default_purchase_categories.down.sql @@ -0,0 +1,3 @@ +DELETE FROM purchase_categories +WHERE is_system = true + AND preset_id IS NOT NULL; diff --git a/migrations/000077_backfill_default_purchase_categories.up.sql b/migrations/000077_backfill_default_purchase_categories.up.sql new file mode 100644 index 0000000..9901256 --- /dev/null +++ b/migrations/000077_backfill_default_purchase_categories.up.sql @@ -0,0 +1,39 @@ +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; diff --git a/migrations/000078_add_purchase_category_to_purchase_order_items.down.sql b/migrations/000078_add_purchase_category_to_purchase_order_items.down.sql new file mode 100644 index 0000000..1cf91b9 --- /dev/null +++ b/migrations/000078_add_purchase_category_to_purchase_order_items.down.sql @@ -0,0 +1,5 @@ +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; diff --git a/migrations/000078_add_purchase_category_to_purchase_order_items.up.sql b/migrations/000078_add_purchase_category_to_purchase_order_items.up.sql new file mode 100644 index 0000000..0d2af9b --- /dev/null +++ b/migrations/000078_add_purchase_category_to_purchase_order_items.up.sql @@ -0,0 +1,11 @@ +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); diff --git a/migrations/000079_add_purchase_category_to_expense_items.down.sql b/migrations/000079_add_purchase_category_to_expense_items.down.sql new file mode 100644 index 0000000..06940fa --- /dev/null +++ b/migrations/000079_add_purchase_category_to_expense_items.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_expense_items_purchase_category_id; +ALTER TABLE expense_items DROP COLUMN IF EXISTS purchase_category_id; diff --git a/migrations/000079_add_purchase_category_to_expense_items.up.sql b/migrations/000079_add_purchase_category_to_expense_items.up.sql new file mode 100644 index 0000000..d1e1abd --- /dev/null +++ b/migrations/000079_add_purchase_category_to_expense_items.up.sql @@ -0,0 +1,5 @@ +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); diff --git a/migrations/000080_rename_non_inventory_purchase_categories.down.sql b/migrations/000080_rename_non_inventory_purchase_categories.down.sql new file mode 100644 index 0000000..e90c1bd --- /dev/null +++ b/migrations/000080_rename_non_inventory_purchase_categories.down.sql @@ -0,0 +1,32 @@ +ALTER TABLE purchase_category_presets DROP CONSTRAINT IF EXISTS purchase_category_presets_type_check; +ALTER TABLE purchase_categories DROP CONSTRAINT IF EXISTS purchase_categories_type_check; + +UPDATE purchase_category_presets +SET type = 'non_inventory' +WHERE type = 'expense'; + +UPDATE purchase_categories +SET type = 'non_inventory' +WHERE type = 'expense'; + +UPDATE purchase_category_presets +SET code = 'hpp', name = 'HPP' +WHERE code = 'bahan_baku' AND type = 'raw_material'; + +UPDATE purchase_categories +SET code = 'hpp', name = 'HPP' +WHERE code = 'bahan_baku' AND type = 'raw_material'; + +UPDATE purchase_category_presets +SET is_active = true +WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material'; + +UPDATE purchase_categories +SET is_active = true +WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material'; + +ALTER TABLE purchase_category_presets +ADD CONSTRAINT purchase_category_presets_type_check CHECK (type IN ('raw_material', 'non_inventory')); + +ALTER TABLE purchase_categories +ADD CONSTRAINT purchase_categories_type_check CHECK (type IN ('raw_material', 'non_inventory')); diff --git a/migrations/000080_rename_non_inventory_purchase_categories.up.sql b/migrations/000080_rename_non_inventory_purchase_categories.up.sql new file mode 100644 index 0000000..923bd88 --- /dev/null +++ b/migrations/000080_rename_non_inventory_purchase_categories.up.sql @@ -0,0 +1,36 @@ +ALTER TABLE purchase_category_presets DROP CONSTRAINT IF EXISTS purchase_category_presets_type_check; +ALTER TABLE purchase_categories DROP CONSTRAINT IF EXISTS purchase_categories_type_check; + +UPDATE purchase_category_presets +SET type = 'expense' +WHERE type = 'non_inventory'; + +UPDATE purchase_categories +SET type = 'expense' +WHERE type = 'non_inventory'; + +UPDATE purchase_category_presets +SET code = 'bahan_baku', name = 'Bahan Baku' +WHERE code = 'hpp' AND type = 'raw_material'; + +UPDATE purchase_categories +SET code = 'bahan_baku', name = 'Bahan Baku' +WHERE code = 'hpp' AND type = 'raw_material'; + +UPDATE purchase_category_presets +SET is_active = false +WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material'; + +UPDATE purchase_categories +SET is_active = false +WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material'; + +ALTER TABLE purchase_category_presets +ADD CONSTRAINT purchase_category_presets_type_check CHECK (type IN ('raw_material', 'expense')); + +ALTER TABLE purchase_categories +ADD CONSTRAINT purchase_categories_type_check CHECK (type IN ('raw_material', 'expense')); + +ALTER TABLE purchase_order_items ALTER COLUMN ingredient_id DROP NOT NULL; +ALTER TABLE purchase_order_items ALTER COLUMN quantity DROP NOT NULL; +ALTER TABLE purchase_order_items ALTER COLUMN unit_id DROP NOT NULL; diff --git a/migrations/000081_enforce_raw_material_purchase_order_items.down.sql b/migrations/000081_enforce_raw_material_purchase_order_items.down.sql new file mode 100644 index 0000000..802b92d --- /dev/null +++ b/migrations/000081_enforce_raw_material_purchase_order_items.down.sql @@ -0,0 +1,8 @@ +DROP TRIGGER IF EXISTS trigger_validate_purchase_order_item_raw_material ON purchase_order_items; +DROP FUNCTION IF EXISTS validate_purchase_order_item_raw_material(); + +ALTER TABLE purchase_order_items + ALTER COLUMN purchase_category_id DROP NOT NULL, + ALTER COLUMN ingredient_id DROP NOT NULL, + ALTER COLUMN quantity DROP NOT NULL, + ALTER COLUMN unit_id DROP NOT NULL; diff --git a/migrations/000081_enforce_raw_material_purchase_order_items.up.sql b/migrations/000081_enforce_raw_material_purchase_order_items.up.sql new file mode 100644 index 0000000..d394149 --- /dev/null +++ b/migrations/000081_enforce_raw_material_purchase_order_items.up.sql @@ -0,0 +1,53 @@ +UPDATE purchase_order_items poi +SET purchase_category_id = pc.id +FROM purchase_orders po +JOIN purchase_categories pc ON pc.organization_id = po.organization_id + AND pc.code = 'bahan_baku' + AND pc.type = 'raw_material' +WHERE poi.purchase_order_id = po.id + AND poi.purchase_category_id IS NULL; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM purchase_order_items poi + LEFT JOIN purchase_categories pc ON pc.id = poi.purchase_category_id + WHERE poi.purchase_category_id IS NULL + OR pc.id IS NULL + OR pc.type <> 'raw_material' + OR poi.ingredient_id IS NULL + OR poi.quantity IS NULL + OR poi.unit_id IS NULL + ) THEN + RAISE EXCEPTION 'purchase_order_items contains non-raw-material or incomplete raw-material rows. Move expense rows to expenses and fill ingredient_id, quantity, and unit_id before running this migration.'; + END IF; +END $$; + +ALTER TABLE purchase_order_items + ALTER COLUMN purchase_category_id SET NOT NULL, + ALTER COLUMN ingredient_id SET NOT NULL, + ALTER COLUMN quantity SET NOT NULL, + ALTER COLUMN unit_id SET NOT NULL; + +CREATE OR REPLACE FUNCTION validate_purchase_order_item_raw_material() +RETURNS TRIGGER AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM purchase_categories pc + WHERE pc.id = NEW.purchase_category_id + AND pc.type = 'raw_material' + ) THEN + RAISE EXCEPTION 'purchase_order_items.purchase_category_id must reference a raw_material purchase category'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_validate_purchase_order_item_raw_material ON purchase_order_items; +CREATE TRIGGER trigger_validate_purchase_order_item_raw_material + BEFORE INSERT OR UPDATE OF purchase_category_id ON purchase_order_items + FOR EACH ROW + EXECUTE FUNCTION validate_purchase_order_item_raw_material(); diff --git a/migrations/000082_enforce_expense_items_expense_categories.down.sql b/migrations/000082_enforce_expense_items_expense_categories.down.sql new file mode 100644 index 0000000..2d79281 --- /dev/null +++ b/migrations/000082_enforce_expense_items_expense_categories.down.sql @@ -0,0 +1,5 @@ +DROP TRIGGER IF EXISTS trigger_validate_expense_item_expense_category ON expense_items; +DROP FUNCTION IF EXISTS validate_expense_item_expense_category(); + +ALTER TABLE expense_items + ALTER COLUMN purchase_category_id DROP NOT NULL; diff --git a/migrations/000082_enforce_expense_items_expense_categories.up.sql b/migrations/000082_enforce_expense_items_expense_categories.up.sql new file mode 100644 index 0000000..e218504 --- /dev/null +++ b/migrations/000082_enforce_expense_items_expense_categories.up.sql @@ -0,0 +1,55 @@ +UPDATE expense_items ei +SET purchase_category_id = pc.id +FROM expenses e +JOIN purchase_categories pc ON pc.organization_id = e.organization_id + AND pc.code = 'biaya_lain_lain' + AND pc.type = 'expense' +WHERE ei.expense_id = e.id + AND ( + ei.purchase_category_id IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM purchase_categories current_pc + WHERE current_pc.id = ei.purchase_category_id + AND current_pc.type = 'expense' + ) + ); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM expense_items ei + LEFT JOIN purchase_categories pc ON pc.id = ei.purchase_category_id + WHERE ei.purchase_category_id IS NULL + OR pc.id IS NULL + OR pc.type <> 'expense' + ) THEN + RAISE EXCEPTION 'expense_items contains missing or non-expense purchase categories. Assign valid expense categories before running this migration.'; + END IF; +END $$; + +ALTER TABLE expense_items + ALTER COLUMN purchase_category_id SET NOT NULL; + +CREATE OR REPLACE FUNCTION validate_expense_item_expense_category() +RETURNS TRIGGER AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM purchase_categories pc + WHERE pc.id = NEW.purchase_category_id + AND pc.type = 'expense' + ) THEN + RAISE EXCEPTION 'expense_items.purchase_category_id must reference an expense purchase category'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_validate_expense_item_expense_category ON expense_items; +CREATE TRIGGER trigger_validate_expense_item_expense_category + BEFORE INSERT OR UPDATE OF purchase_category_id ON expense_items + FOR EACH ROW + EXECUTE FUNCTION validate_expense_item_expense_category();