Compare commits

...
Author SHA1 Message Date
Efril 7a7ac25dcf fix conflict 2026-06-11 16:47:03 +07:00
Efril d0a548f44e merger 2026-06-11 16:43:53 +07:00
ryan f4172fcea7 Fix purchase analytic by outlet 2026-06-11 16:27:50 +07:00
ryan d5216e7994 Fix purchase analytic zero for group by today 2026-06-11 15:57:19 +07:00
ryan 1718c5adab Fix expense to be nullable without raw material. 2026-06-10 14:25:23 +07:00
ryan d0c090a657 Update purchase analytics 2026-06-10 13:18:49 +07:00
ryan c3db919531 Update expense for product category (non-inventory type) 2026-06-10 12:42:53 +07:00
ryan e09feff36d Update purchase for product category (inventory type) 2026-06-09 15:59:34 +07:00
ryan e7dd9660da Merge remote-tracking branch 'origin' into feature/expense
# Conflicts:
#	internal/router/router.go
2026-06-09 13:25:09 +07:00
Efril c57620beeb feat: update product analytic 2026-06-08 19:32:30 +07:00
aefril 6e3fc43d86 Merge pull request 'feat: implement idempotency key for critical API endpoints' (#14) from feature/activity-logs into main
Reviewed-on: #14
2026-06-03 17:51:48 +00:00
Efril 021ec152e9 feat: implement idempotency key for critical API endpoints 2026-06-04 00:49:45 +07:00
Efril ea9dceb333 fix: prevent race condition on order subtotal calculation 2026-06-03 23:59:15 +07:00
Efril afa1aa5b75 Merge branch 'main' of https://gits.altru.id/apksel-dev/apskel-pos-backend 2026-06-03 22:02:14 +07:00
Efril 328336ea5a fix log error and omset tracker scheduled 2026-06-03 22:01:58 +07:00
aefril 343aa25230 Merge pull request 'feature/expense' (#13) from feature/expense into main
Reviewed-on: #13
2026-06-01 17:20:28 +00:00
53 changed files with 1462 additions and 597 deletions
+6
View File
@@ -83,6 +83,12 @@ migration-up:
migration-down: migration-down:
@migrate -database $(DB_URL) -path ./migrations down 1 @migrate -database $(DB_URL) -path ./migrations down 1
# Force migration to specific version
.SILENT: migration-force
migration-force:
@migrate -database $(DB_URL) -path ./migrations force $(version)
.SILENT: seeder-create .SILENT: seeder-create
seeder-create: seeder-create:
@migrate create -ext sql -dir ./seeders -seq $(name) @migrate create -ext sql -dir ./seeders -seq $(name)
+6 -4
View File
@@ -48,6 +48,7 @@ func (a *App) Initialize(cfg *config.Config) error {
// Initialize omset milestone scheduler // Initialize omset milestone scheduler
a.omsetScheduler = service.NewOmsetMilestoneScheduler( a.omsetScheduler = service.NewOmsetMilestoneScheduler(
repos.organizationRepo, repos.organizationRepo,
repos.outletRepo,
repos.userRepo, repos.userRepo,
processors.notificationProcessor, processors.notificationProcessor,
) )
@@ -139,15 +140,16 @@ func (a *App) Initialize(cfg *config.Config) error {
selfOrderHandler, selfOrderHandler,
services.expenseService, services.expenseService,
validators.expenseValidator, validators.expenseValidator,
a.redisClient,
) )
return nil return nil
} }
func (a *App) Start(port string) error { func (a *App) Start(port string) error {
// Start the omset milestone scheduler (checks every hour) // Start the omset milestone scheduler (checks every 5 minutes for daily omset milestones)
if a.omsetScheduler != nil { if a.omsetScheduler != nil {
a.omsetScheduler.Start(1 * time.Hour) a.omsetScheduler.Start(5 * time.Minute)
} }
engine := a.router.Init() engine := a.router.Init()
@@ -370,7 +372,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo), ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo), 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), purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo), unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo), chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
@@ -394,7 +396,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo), userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
notificationProcessor: buildNotificationProcessor(cfg, repos), notificationProcessor: buildNotificationProcessor(cfg, repos),
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo), productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo), expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo),
} }
} }
+15 -6
View File
@@ -106,7 +106,11 @@ type PurchasingAnalyticsResponse struct {
type PurchasingSummary struct { type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"` TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"` TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"` TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"` TotalIngredients int64 `json:"total_ingredients"`
@@ -114,12 +118,16 @@ type PurchasingSummary struct {
} }
type PurchasingAnalyticsData struct { type PurchasingAnalyticsData struct {
Date time.Time `json:"date"` Date time.Time `json:"date"`
Purchases float64 `json:"purchases"` Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"` RawMaterialPurchases float64 `json:"raw_material_purchases"`
Quantity float64 `json:"quantity"` ExpensePurchases float64 `json:"expense_purchases"`
Ingredients int64 `json:"ingredients"` PurchaseOrders int64 `json:"purchase_orders"`
Vendors int64 `json:"vendors"` 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 { type PurchasingIngredientData struct {
@@ -162,6 +170,7 @@ type ProductAnalyticsData struct {
ProductID uuid.UUID `json:"product_id"` ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"` ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"` CategoryOrder int `json:"category_order"`
+42 -26
View File
@@ -19,10 +19,11 @@ type CreateExpenseRequest struct {
} }
type CreateExpenseItemRequest struct { type CreateExpenseItemRequest struct {
ChartOfAccountID string `json:"chart_of_account_id" validate:"required"` ChartOfAccountID string `json:"chart_of_account_id" validate:"required"`
Item string `json:"item" validate:"required"` PurchaseCategoryID string `json:"purchase_category_id" validate:"required"`
Description *string `json:"description,omitempty"` Item string `json:"item" validate:"required"`
Amount float64 `json:"amount" validate:"required"` Description *string `json:"description,omitempty"`
Amount float64 `json:"amount" validate:"required"`
} }
type UpdateExpenseRequest struct { type UpdateExpenseRequest struct {
@@ -39,10 +40,11 @@ type UpdateExpenseRequest struct {
} }
type UpdateExpenseItemRequest struct { type UpdateExpenseItemRequest struct {
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
Item *string `json:"item,omitempty"` PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
Description *string `json:"description,omitempty"` Item *string `json:"item,omitempty"`
Amount *float64 `json:"amount,omitempty"` Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
} }
type ExpenseResponse struct { type ExpenseResponse struct {
@@ -63,15 +65,19 @@ type ExpenseResponse struct {
} }
type ExpenseItemResponse struct { type ExpenseItemResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"` ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"` ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
Item string `json:"item"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description"` PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
Amount float64 `json:"amount"` PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
CreatedAt time.Time `json:"created_at"` PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
UpdatedAt time.Time `json:"updated_at"` 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 { type ListExpenseRequest struct {
@@ -100,15 +106,16 @@ type ExpenseAnalyticsRequest struct {
} }
type ExpenseAnalyticsResponse struct { type ExpenseAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
Summary ExpenseAnalyticsSummary `json:"summary"` Summary ExpenseAnalyticsSummary `json:"summary"`
Data []ExpenseAnalyticsData `json:"data"` Data []ExpenseAnalyticsData `json:"data"`
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"` CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"` ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
} }
type ExpenseAnalyticsSummary struct { type ExpenseAnalyticsSummary struct {
@@ -130,6 +137,15 @@ type ExpenseAnalyticsData struct {
} }
type ExpenseAnalyticsCategoryData struct { 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"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name"` ChartOfAccountName string `json:"chart_of_account_name"`
TotalAmount float64 `json:"total_amount"` TotalAmount float64 `json:"total_amount"`
@@ -10,7 +10,7 @@ type CreatePurchaseCategoryRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"` ParentID *uuid.UUID `json:"parent_id,omitempty"`
Code *string `json:"code,omitempty"` Code *string `json:"code,omitempty"`
Name string `json:"name" validate:"required,min=1,max=255"` Name string `json:"name" validate:"required,min=1,max=255"`
Type string `json:"type" validate:"required,oneof=raw_material non_inventory"` Type string `json:"type" validate:"required,oneof=raw_material expense"`
SortOrder *int `json:"sort_order,omitempty"` SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
} }
@@ -19,14 +19,14 @@ type UpdatePurchaseCategoryRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"` ParentID *uuid.UUID `json:"parent_id,omitempty"`
Code *string `json:"code,omitempty"` Code *string `json:"code,omitempty"`
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material non_inventory"` Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
SortOrder *int `json:"sort_order,omitempty"` SortOrder *int `json:"sort_order,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
} }
type ListPurchaseCategoriesRequest struct { type ListPurchaseCategoriesRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"` ParentID *uuid.UUID `json:"parent_id,omitempty"`
Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material non_inventory"` Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
Search string `json:"search,omitempty"` Search string `json:"search,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
Page int `json:"page" validate:"required,min=1"` Page int `json:"page" validate:"required,min=1"`
+26 -22
View File
@@ -19,11 +19,12 @@ type CreatePurchaseOrderRequest struct {
} }
type CreatePurchaseOrderItemRequest struct { type CreatePurchaseOrderItemRequest struct {
IngredientID uuid.UUID `json:"ingredient_id" validate:"required"` IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
Description *string `json:"description,omitempty" validate:"omitempty"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
Quantity float64 `json:"quantity" validate:"required,gt=0"` Description *string `json:"description,omitempty" validate:"omitempty"`
UnitID uuid.UUID `json:"unit_id" validate:"required"` Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
Amount float64 `json:"amount" validate:"required,gte=0"` UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
Amount float64 `json:"amount" validate:"required,gte=0"`
} }
type UpdatePurchaseOrderRequest struct { type UpdatePurchaseOrderRequest struct {
@@ -39,12 +40,13 @@ type UpdatePurchaseOrderRequest struct {
} }
type UpdatePurchaseOrderItemRequest struct { type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // For existing items ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"` IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
Description *string `json:"description,omitempty" validate:"omitempty"` PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"`
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"` Description *string `json:"description,omitempty" validate:"omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"` Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"` UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"`
} }
type PurchaseOrderResponse struct { type PurchaseOrderResponse struct {
@@ -66,17 +68,19 @@ type PurchaseOrderResponse struct {
} }
type PurchaseOrderItemResponse struct { type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID *uuid.UUID `json:"ingredient_id"`
Description *string `json:"description"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Quantity float64 `json:"quantity"` Description *string `json:"description"`
UnitID uuid.UUID `json:"unit_id"` Quantity *float64 `json:"quantity"`
Amount float64 `json:"amount"` UnitID *uuid.UUID `json:"unit_id"`
CreatedAt time.Time `json:"created_at"` Amount float64 `json:"amount"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"` UpdatedAt time.Time `json:"updated_at"`
Unit *UnitResponse `json:"unit,omitempty"` Ingredient *IngredientResponse `json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
} }
type PurchaseOrderAttachmentResponse struct { type PurchaseOrderAttachmentResponse struct {
+15 -6
View File
@@ -38,7 +38,11 @@ type PurchasingAnalytics struct {
type PurchasingSummary struct { type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"` TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"` TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"` TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"` TotalIngredients int64 `json:"total_ingredients"`
@@ -46,12 +50,16 @@ type PurchasingSummary struct {
} }
type PurchasingAnalyticsData struct { type PurchasingAnalyticsData struct {
Date time.Time `json:"date"` Date time.Time `json:"date"`
Purchases float64 `json:"purchases"` Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"` RawMaterialPurchases float64 `json:"raw_material_purchases"`
Quantity float64 `json:"quantity"` ExpensePurchases float64 `json:"expense_purchases"`
Ingredients int64 `json:"ingredients"` PurchaseOrders int64 `json:"purchase_orders"`
Vendors int64 `json:"vendors"` 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 { type PurchasingIngredientData struct {
@@ -76,6 +84,7 @@ type ProductAnalytics struct {
ProductID uuid.UUID `json:"product_id"` ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"` ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"` CategoryOrder int `json:"category_order"`
+14 -4
View File
@@ -29,10 +29,11 @@ type Expense struct {
} }
type ExpenseAnalytics struct { type ExpenseAnalytics struct {
Summary ExpenseAnalyticsSummary Summary ExpenseAnalyticsSummary
Data []ExpenseAnalyticsData Data []ExpenseAnalyticsData
CategoryData []ExpenseAnalyticsCategoryData CategoryData []ExpenseAnalyticsCategoryData
ItemData []ExpenseAnalyticsItemData ChartOfAccountData []ExpenseAnalyticsChartOfAccountData
ItemData []ExpenseAnalyticsItemData
} }
type ExpenseAnalyticsSummary struct { type ExpenseAnalyticsSummary struct {
@@ -54,6 +55,15 @@ type ExpenseAnalyticsData struct {
} }
type ExpenseAnalyticsCategoryData struct { type ExpenseAnalyticsCategoryData struct {
PurchaseCategoryID uuid.UUID
PurchaseCategoryName string
PurchaseCategoryType string
TotalAmount float64
ExpenseCount int64
ItemCount int64
}
type ExpenseAnalyticsChartOfAccountData struct {
ChartOfAccountID uuid.UUID ChartOfAccountID uuid.UUID
ChartOfAccountName string ChartOfAccountName string
TotalAmount float64 TotalAmount float64
+12 -10
View File
@@ -9,17 +9,19 @@ import (
) )
type ExpenseItem struct { type ExpenseItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_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"` ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"`
Item string `gorm:"not null;size:255" json:"item"` PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id"`
Description *string `gorm:"type:text" json:"description"` Item string `gorm:"not null;size:255" json:"item"`
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"` Description *string `gorm:"type:text" json:"description"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"` Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"`
ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,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 { func (e *ExpenseItem) BeforeCreate(tx *gorm.DB) error {
+29 -27
View File
@@ -36,34 +36,36 @@ const (
) )
type InventoryMovement struct { type InventoryMovement struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` 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"` 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"` 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" 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"` 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"` Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"`
PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"` PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"`
NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"` NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"`
UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"` 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"` TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"`
ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"` ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"`
ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"` ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"`
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"` PurchaseOrderItemID *uuid.UUID `gorm:"type:uuid;index" json:"purchase_order_item_id"`
PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"` OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"` PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"`
Reason *string `gorm:"size:255" json:"reason"` UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"`
Notes *string `gorm:"type:text" json:"notes"` Reason *string `gorm:"size:255" json:"reason"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` Notes *string `gorm:"type:text" json:"notes"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"` Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"` Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"`
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"` PurchaseOrderItem *PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderItemID" json:"purchase_order_item,omitempty"`
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"` Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,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 { func (im *InventoryMovement) BeforeCreate(tx *gorm.DB) error {
+2 -2
View File
@@ -10,8 +10,8 @@ import (
type PurchaseCategoryType string type PurchaseCategoryType string
const ( const (
PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material" PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material"
PurchaseCategoryTypeNonInventory PurchaseCategoryType = "non_inventory" PurchaseCategoryTypeExpense PurchaseCategoryType = "expense"
) )
type PurchaseCategoryPreset struct { type PurchaseCategoryPreset struct {
+14 -12
View File
@@ -41,19 +41,21 @@ func (PurchaseOrder) TableName() string {
} }
type PurchaseOrderItem struct { type PurchaseOrderItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"` PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"` IngredientID *uuid.UUID `gorm:"type:uuid" json:"ingredient_id" validate:"omitempty"`
Description *string `gorm:"type:text" json:"description" validate:"omitempty"` PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"`
Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"` Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"` Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"`
Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"` UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" validate:"omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` 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"` PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"` Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,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 { func (poi *PurchaseOrderItem) BeforeCreate(tx *gorm.DB) error {
+3 -1
View File
@@ -1,6 +1,8 @@
package handler package handler
import ( import (
"apskel-pos-be/internal/logger"
"fmt"
"net/http" "net/http"
"time" "time"
) )
@@ -47,7 +49,7 @@ func (m *CommonMiddleware) Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
logger.FromContext(r.Context()).Error("Recovery", fmt.Sprintf("panic recovered: %v", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
} }
}() }()
+15 -8
View File
@@ -95,20 +95,27 @@ func ExpenseItemEntityToResponse(entity *entities.ExpenseItem) *models.ExpenseIt
} }
response := &models.ExpenseItemResponse{ response := &models.ExpenseItemResponse{
ID: entity.ID, ID: entity.ID,
ExpenseID: entity.ExpenseID, ExpenseID: entity.ExpenseID,
ChartOfAccountID: entity.ChartOfAccountID, ChartOfAccountID: entity.ChartOfAccountID,
Item: entity.Item, PurchaseCategoryID: entity.PurchaseCategoryID,
Description: entity.Description, Item: entity.Item,
Amount: entity.Amount, Description: entity.Description,
CreatedAt: entity.CreatedAt, Amount: entity.Amount,
UpdatedAt: entity.UpdatedAt, CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
} }
if entity.ChartOfAccount != nil { if entity.ChartOfAccount != nil {
response.ChartOfAccountName = entity.ChartOfAccount.Name 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 return response
} }
+34 -27
View File
@@ -91,15 +91,16 @@ func PurchaseOrderItemEntityToModel(entity *entities.PurchaseOrderItem) *models.
} }
return &models.PurchaseOrderItem{ return &models.PurchaseOrderItem{
ID: entity.ID, ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID, PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID, IngredientID: entity.IngredientID,
Description: entity.Description, PurchaseCategoryID: entity.PurchaseCategoryID,
Quantity: entity.Quantity, Description: entity.Description,
UnitID: entity.UnitID, Quantity: entity.Quantity,
Amount: entity.Amount, UnitID: entity.UnitID,
CreatedAt: entity.CreatedAt, Amount: entity.Amount,
UpdatedAt: entity.UpdatedAt, CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
} }
} }
@@ -109,15 +110,16 @@ func PurchaseOrderItemModelToEntity(model *models.PurchaseOrderItem) *entities.P
} }
return &entities.PurchaseOrderItem{ return &entities.PurchaseOrderItem{
ID: model.ID, ID: model.ID,
PurchaseOrderID: model.PurchaseOrderID, PurchaseOrderID: model.PurchaseOrderID,
IngredientID: model.IngredientID, IngredientID: model.IngredientID,
Description: model.Description, PurchaseCategoryID: model.PurchaseCategoryID,
Quantity: model.Quantity, Description: model.Description,
UnitID: model.UnitID, Quantity: model.Quantity,
Amount: model.Amount, UnitID: model.UnitID,
CreatedAt: model.CreatedAt, Amount: model.Amount,
UpdatedAt: model.UpdatedAt, CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
} }
} }
@@ -127,15 +129,16 @@ func PurchaseOrderItemEntityToResponse(entity *entities.PurchaseOrderItem) *mode
} }
response := &models.PurchaseOrderItemResponse{ response := &models.PurchaseOrderItemResponse{
ID: entity.ID, ID: entity.ID,
PurchaseOrderID: entity.PurchaseOrderID, PurchaseOrderID: entity.PurchaseOrderID,
IngredientID: entity.IngredientID, IngredientID: entity.IngredientID,
Description: entity.Description, PurchaseCategoryID: entity.PurchaseCategoryID,
Quantity: entity.Quantity, Description: entity.Description,
UnitID: entity.UnitID, Quantity: entity.Quantity,
Amount: entity.Amount, UnitID: entity.UnitID,
CreatedAt: entity.CreatedAt, Amount: entity.Amount,
UpdatedAt: entity.UpdatedAt, CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
} }
// Map ingredient if present // 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 // Map unit if present
if entity.Unit != nil { if entity.Unit != nil {
response.Unit = &models.UnitResponse{ response.Unit = &models.UnitResponse{
+137
View File
@@ -0,0 +1,137 @@
package middleware
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
IdempotencyKeyHeader = "X-Idempotency-Key"
idempotencyTTL = 24 * time.Hour
idempotencyPrefix = "idempotency:"
)
type cachedResponse struct {
StatusCode int `json:"status_code"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
// IdempotencyMiddleware returns a Gin middleware that ensures idempotent processing
// for mutating operations. Client must send X-Idempotency-Key header.
func IdempotencyMiddleware(redisClient *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
key := c.GetHeader(IdempotencyKeyHeader)
if key == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"success": false,
"errors": []gin.H{
{
"code": "missing_idempotency_key",
"entity": "IdempotencyMiddleware",
"cause": "X-Idempotency-Key header is required",
},
},
})
return
}
redisKey := fmt.Sprintf("%s%s", idempotencyPrefix, key)
ctx := context.Background()
fmt.Printf("[DEBUG] IdempotencyMiddleware: key=%s redisKey=%s\n", key, redisKey)
// Check if key already exists (request was already processed)
cached, err := redisClient.Get(ctx, redisKey).Result()
if err == nil {
// Key exists — return cached response
fmt.Printf("[DEBUG] IdempotencyMiddleware: cache HIT for key=%s\n", key)
var resp cachedResponse
if err := json.Unmarshal([]byte(cached), &resp); err == nil {
for k, v := range resp.Headers {
c.Writer.Header().Set(k, v)
}
c.Writer.Header().Set("X-Idempotent-Replay", "true")
c.Data(resp.StatusCode, "application/json", []byte(resp.Body))
c.Abort()
return
}
} else {
fmt.Printf("[DEBUG] IdempotencyMiddleware: cache MISS for key=%s err=%v\n", key, err)
}
// Mark key as in-progress to prevent concurrent duplicates
set, err := redisClient.SetNX(ctx, redisKey, "processing", idempotencyTTL).Result()
if err != nil {
// Redis error — proceed without idempotency (fail open)
c.Next()
return
}
if !set {
// Another request with the same key is being processed
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
"success": false,
"errors": []gin.H{
{
"code": "request_in_progress",
"entity": "IdempotencyMiddleware",
"cause": "A request with this idempotency key is already being processed",
},
},
})
return
}
// Capture response using a custom writer
writer := &responseCapture{
ResponseWriter: c.Writer,
body: &bytes.Buffer{},
}
c.Writer = writer
c.Next()
// After handler completes, cache the response only if successful (2xx)
statusCode := writer.Status()
if statusCode >= 200 && statusCode < 300 {
resp := cachedResponse{
StatusCode: statusCode,
Headers: map[string]string{
"Content-Type": writer.Header().Get("Content-Type"),
},
Body: writer.body.String(),
}
respJSON, err := json.Marshal(resp)
if err == nil {
redisClient.Set(ctx, redisKey, string(respJSON), idempotencyTTL)
}
} else {
// Remove the in-progress key so the client can retry with the same key
redisClient.Del(ctx, redisKey)
}
}
}
// responseCapture wraps gin.ResponseWriter to capture the response body
type responseCapture struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w *responseCapture) Write(b []byte) (int, error) {
w.body.Write(b)
return w.ResponseWriter.Write(b)
}
func (w *responseCapture) WriteString(s string) (int, error) {
w.body.WriteString(s)
return w.ResponseWriter.WriteString(s)
}
+15 -6
View File
@@ -113,7 +113,11 @@ type PurchasingAnalyticsResponse struct {
// PurchasingSummary represents the summary of purchasing analytics // PurchasingSummary represents the summary of purchasing analytics
type PurchasingSummary struct { type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"` TotalPurchases float64 `json:"total_purchases"`
RawMaterialPurchases float64 `json:"raw_material_purchases"`
ExpensePurchases float64 `json:"expense_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"` TotalPurchaseOrders int64 `json:"total_purchase_orders"`
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
ExpenseCount int64 `json:"expense_count"`
TotalQuantity float64 `json:"total_quantity"` TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"` TotalIngredients int64 `json:"total_ingredients"`
@@ -122,12 +126,16 @@ type PurchasingSummary struct {
// PurchasingAnalyticsData represents purchasing analytics by time period // PurchasingAnalyticsData represents purchasing analytics by time period
type PurchasingAnalyticsData struct { type PurchasingAnalyticsData struct {
Date time.Time `json:"date"` Date time.Time `json:"date"`
Purchases float64 `json:"purchases"` Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"` RawMaterialPurchases float64 `json:"raw_material_purchases"`
Quantity float64 `json:"quantity"` ExpensePurchases float64 `json:"expense_purchases"`
Ingredients int64 `json:"ingredients"` PurchaseOrders int64 `json:"purchase_orders"`
Vendors int64 `json:"vendors"` 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 // PurchasingIngredientData represents purchasing analytics for an ingredient
@@ -172,6 +180,7 @@ type ProductAnalyticsData struct {
ProductID uuid.UUID `json:"product_id"` ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"` ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"` CategoryOrder int `json:"category_order"`
+51 -34
View File
@@ -23,14 +23,15 @@ type Expense struct {
} }
type ExpenseItem struct { type ExpenseItem struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"` ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
Item string `json:"item"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description"` Item string `json:"item"`
Amount float64 `json:"amount"` Description *string `json:"description"`
CreatedAt time.Time `json:"created_at"` Amount float64 `json:"amount"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type ExpenseResponse struct { type ExpenseResponse struct {
@@ -51,15 +52,19 @@ type ExpenseResponse struct {
} }
type ExpenseItemResponse struct { type ExpenseItemResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ExpenseID uuid.UUID `json:"expense_id"` ExpenseID uuid.UUID `json:"expense_id"`
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name,omitempty"` ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
Item string `json:"item"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Description *string `json:"description"` PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
Amount float64 `json:"amount"` PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
CreatedAt time.Time `json:"created_at"` PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
UpdatedAt time.Time `json:"updated_at"` 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 { type CreateExpenseRequest struct {
@@ -75,10 +80,11 @@ type CreateExpenseRequest struct {
} }
type CreateExpenseItemRequest struct { type CreateExpenseItemRequest struct {
ChartOfAccountID string `json:"chart_of_account_id"` ChartOfAccountID string `json:"chart_of_account_id"`
Item string `json:"item"` PurchaseCategoryID string `json:"purchase_category_id"`
Description *string `json:"description,omitempty"` Item string `json:"item"`
Amount float64 `json:"amount"` Description *string `json:"description,omitempty"`
Amount float64 `json:"amount"`
} }
type UpdateExpenseRequest struct { type UpdateExpenseRequest struct {
@@ -95,10 +101,11 @@ type UpdateExpenseRequest struct {
} }
type UpdateExpenseItemRequest struct { type UpdateExpenseItemRequest struct {
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
Item *string `json:"item,omitempty"` PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
Description *string `json:"description,omitempty"` Item *string `json:"item,omitempty"`
Amount *float64 `json:"amount,omitempty"` Description *string `json:"description,omitempty"`
Amount *float64 `json:"amount,omitempty"`
} }
type ListExpenseRequest struct { type ListExpenseRequest struct {
@@ -128,15 +135,16 @@ type ExpenseAnalyticsRequest struct {
} }
type ExpenseAnalyticsResponse struct { type ExpenseAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
Summary ExpenseAnalyticsSummary `json:"summary"` Summary ExpenseAnalyticsSummary `json:"summary"`
Data []ExpenseAnalyticsData `json:"data"` Data []ExpenseAnalyticsData `json:"data"`
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"` CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"` ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
} }
type ExpenseAnalyticsSummary struct { type ExpenseAnalyticsSummary struct {
@@ -158,6 +166,15 @@ type ExpenseAnalyticsData struct {
} }
type ExpenseAnalyticsCategoryData struct { 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"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
ChartOfAccountName string `json:"chart_of_account_name"` ChartOfAccountName string `json:"chart_of_account_name"`
TotalAmount float64 `json:"total_amount"` TotalAmount float64 `json:"total_amount"`
+36 -31
View File
@@ -22,15 +22,16 @@ type PurchaseOrder struct {
} }
type PurchaseOrderItem struct { type PurchaseOrderItem struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID *uuid.UUID `json:"ingredient_id"`
Description *string `json:"description"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Quantity float64 `json:"quantity"` Description *string `json:"description"`
UnitID uuid.UUID `json:"unit_id"` Quantity *float64 `json:"quantity"`
Amount float64 `json:"amount"` UnitID *uuid.UUID `json:"unit_id"`
CreatedAt time.Time `json:"created_at"` Amount float64 `json:"amount"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type PurchaseOrderAttachment struct { type PurchaseOrderAttachment struct {
@@ -59,17 +60,19 @@ type PurchaseOrderResponse struct {
} }
type PurchaseOrderItemResponse struct { type PurchaseOrderItemResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
PurchaseOrderID uuid.UUID `json:"purchase_order_id"` PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID *uuid.UUID `json:"ingredient_id"`
Description *string `json:"description"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Quantity float64 `json:"quantity"` Description *string `json:"description"`
UnitID uuid.UUID `json:"unit_id"` Quantity *float64 `json:"quantity"`
Amount float64 `json:"amount"` UnitID *uuid.UUID `json:"unit_id"`
CreatedAt time.Time `json:"created_at"` Amount float64 `json:"amount"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
Ingredient *IngredientResponse `json:"ingredient,omitempty"` UpdatedAt time.Time `json:"updated_at"`
Unit *UnitResponse `json:"unit,omitempty"` Ingredient *IngredientResponse `json:"ingredient,omitempty"`
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
Unit *UnitResponse `json:"unit,omitempty"`
} }
type PurchaseOrderAttachmentResponse struct { type PurchaseOrderAttachmentResponse struct {
@@ -93,11 +96,12 @@ type CreatePurchaseOrderRequest struct {
} }
type CreatePurchaseOrderItemRequest struct { type CreatePurchaseOrderItemRequest struct {
IngredientID uuid.UUID `json:"ingredient_id"` IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
Description *string `json:"description,omitempty"` PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
Quantity float64 `json:"quantity"` Description *string `json:"description,omitempty"`
UnitID uuid.UUID `json:"unit_id"` Quantity *float64 `json:"quantity,omitempty"`
Amount float64 `json:"amount"` UnitID *uuid.UUID `json:"unit_id,omitempty"`
Amount float64 `json:"amount"`
} }
type UpdatePurchaseOrderRequest struct { type UpdatePurchaseOrderRequest struct {
@@ -113,12 +117,13 @@ type UpdatePurchaseOrderRequest struct {
} }
type UpdatePurchaseOrderItemRequest struct { type UpdatePurchaseOrderItemRequest struct {
ID *uuid.UUID `json:"id,omitempty"` // For existing items ID *uuid.UUID `json:"id,omitempty"` // For existing items
IngredientID *uuid.UUID `json:"ingredient_id,omitempty"` IngredientID *uuid.UUID `json:"ingredient_id,omitempty"`
Description *string `json:"description,omitempty"` PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty"`
Quantity *float64 `json:"quantity,omitempty"` Description *string `json:"description,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"` Quantity *float64 `json:"quantity,omitempty"`
Amount *float64 `json:"amount,omitempty"` UnitID *uuid.UUID `json:"unit_id,omitempty"`
Amount *float64 `json:"amount,omitempty"`
} }
type ListPurchaseOrdersRequest struct { type ListPurchaseOrdersRequest struct {
+15 -6
View File
@@ -185,12 +185,16 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
data := make([]models.PurchasingAnalyticsData, len(result.Data)) data := make([]models.PurchasingAnalyticsData, len(result.Data))
for i, item := range result.Data { for i, item := range result.Data {
data[i] = models.PurchasingAnalyticsData{ data[i] = models.PurchasingAnalyticsData{
Date: item.Date, Date: item.Date,
Purchases: item.Purchases, Purchases: item.Purchases,
PurchaseOrders: item.PurchaseOrders, RawMaterialPurchases: item.RawMaterialPurchases,
Quantity: item.Quantity, ExpensePurchases: item.ExpensePurchases,
Ingredients: item.Ingredients, PurchaseOrders: item.PurchaseOrders,
Vendors: item.Vendors, RawMaterialPurchaseOrders: item.RawMaterialPurchaseOrders,
ExpenseCount: item.ExpenseCount,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
} }
} }
@@ -227,7 +231,11 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
Summary: models.PurchasingSummary{ Summary: models.PurchasingSummary{
TotalPurchases: result.Summary.TotalPurchases, TotalPurchases: result.Summary.TotalPurchases,
RawMaterialPurchases: result.Summary.RawMaterialPurchases,
ExpensePurchases: result.Summary.ExpensePurchases,
TotalPurchaseOrders: result.Summary.TotalPurchaseOrders, TotalPurchaseOrders: result.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: result.Summary.RawMaterialPurchaseOrders,
ExpenseCount: result.Summary.ExpenseCount,
TotalQuantity: result.Summary.TotalQuantity, TotalQuantity: result.Summary.TotalQuantity,
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
TotalIngredients: result.Summary.TotalIngredients, TotalIngredients: result.Summary.TotalIngredients,
@@ -263,6 +271,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
ProductID: data.ProductID, ProductID: data.ProductID,
ProductName: data.ProductName, ProductName: data.ProductName,
ProductSku: data.ProductSku, ProductSku: data.ProductSku,
ProductPrice: data.ProductPrice,
CategoryID: data.CategoryID, CategoryID: data.CategoryID,
CategoryName: data.CategoryName, CategoryName: data.CategoryName,
CategoryOrder: data.CategoryOrder, CategoryOrder: data.CategoryOrder,
+27 -2
View File
@@ -75,7 +75,23 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
purchasingResult: &entities.PurchasingAnalytics{ purchasingResult: &entities.PurchasingAnalytics{
OutletName: &outletName, OutletName: &outletName,
Summary: entities.PurchasingSummary{ Summary: entities.PurchasingSummary{
TotalPurchases: 125, TotalPurchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
TotalPurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
Data: []entities.PurchasingAnalyticsData{
{
Date: now,
Purchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
PurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
}, },
}, },
}, expenseRepositoryStub{}) }, expenseRepositoryStub{})
@@ -92,7 +108,16 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
require.Equal(t, &outletID, result.OutletID) require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, result.OutletName) require.NotNil(t, result.OutletName)
require.Equal(t, outletName, *result.OutletName) require.Equal(t, outletName, *result.OutletName)
require.Equal(t, float64(125), result.Summary.TotalPurchases) require.Equal(t, float64(300), result.Summary.TotalPurchases)
require.Equal(t, float64(125), result.Summary.RawMaterialPurchases)
require.Equal(t, float64(175), result.Summary.ExpensePurchases)
require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders)
require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders)
require.Equal(t, int64(2), result.Summary.ExpenseCount)
require.Len(t, result.Data, 1)
require.Equal(t, float64(300), result.Data[0].Purchases)
require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases)
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
} }
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
+94 -33
View File
@@ -23,12 +23,14 @@ type ExpenseProcessor interface {
} }
type ExpenseProcessorImpl struct { type ExpenseProcessorImpl struct {
expenseRepo ExpenseRepository expenseRepo ExpenseRepository
purchaseCategoryRepo PurchaseCategoryRepository
} }
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository) *ExpenseProcessorImpl { func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl {
return &ExpenseProcessorImpl{ return &ExpenseProcessorImpl{
expenseRepo: expenseRepo, expenseRepo: expenseRepo,
purchaseCategoryRepo: purchaseCategoryRepo,
} }
} }
@@ -48,6 +50,30 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
status = *req.Status 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{ expenseEntity := &entities.Expense{
OrganizationID: organizationID, OrganizationID: organizationID,
OutletID: outletID, OutletID: outletID,
@@ -65,21 +91,10 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
return nil, fmt.Errorf("failed to create expense: %w", err) return nil, fmt.Errorf("failed to create expense: %w", err)
} }
for _, itemReq := range req.Items { for i := range items {
chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID) items[i].ExpenseID = expenseEntity.ID
if err != nil {
return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err)
}
itemEntity := &entities.ExpenseItem{ err = p.expenseRepo.CreateItem(ctx, &items[i])
ExpenseID: expenseEntity.ID,
ChartOfAccountID: chartOfAccountID,
Item: itemReq.Item,
Description: itemReq.Description,
Amount: itemReq.Amount,
}
err = p.expenseRepo.CreateItem(ctx, itemEntity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create expense item: %w", err) return nil, fmt.Errorf("failed to create expense item: %w", err)
} }
@@ -135,13 +150,10 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati
expenseEntity.Reserved1 = req.Reserved1 expenseEntity.Reserved1 = req.Reserved1
} }
var items []entities.ExpenseItem
if req.Items != nil { if req.Items != nil {
err = p.expenseRepo.DeleteItemsByExpenseID(ctx, expenseEntity.ID) items = make([]entities.ExpenseItem, len(req.Items))
if err != nil { for i, itemReq := range req.Items {
return nil, fmt.Errorf("failed to delete existing items: %w", err)
}
for _, itemReq := range req.Items {
chartOfAccountID := uuid.Nil chartOfAccountID := uuid.Nil
if itemReq.ChartOfAccountID != nil { if itemReq.ChartOfAccountID != nil {
chartOfAccountID, err = uuid.Parse(*itemReq.ChartOfAccountID) chartOfAccountID, err = uuid.Parse(*itemReq.ChartOfAccountID)
@@ -150,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 amount := 0.0
if itemReq.Amount != nil { if itemReq.Amount != nil {
amount = *itemReq.Amount amount = *itemReq.Amount
@@ -159,15 +182,23 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati
item = *itemReq.Item item = *itemReq.Item
} }
itemEntity := &entities.ExpenseItem{ items[i] = entities.ExpenseItem{
ExpenseID: expenseEntity.ID, ExpenseID: expenseEntity.ID,
ChartOfAccountID: chartOfAccountID, ChartOfAccountID: chartOfAccountID,
Item: item, PurchaseCategoryID: purchaseCategoryID,
Description: itemReq.Description, Item: item,
Amount: amount, 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 { if err != nil {
return nil, fmt.Errorf("failed to create expense item: %w", err) return nil, fmt.Errorf("failed to create expense item: %w", err)
} }
@@ -252,6 +283,18 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod
categoryData := make([]models.ExpenseAnalyticsCategoryData, len(result.CategoryData)) categoryData := make([]models.ExpenseAnalyticsCategoryData, len(result.CategoryData))
for i, item := range result.CategoryData { for i, item := range result.CategoryData {
categoryData[i] = models.ExpenseAnalyticsCategoryData{ 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, ChartOfAccountID: item.ChartOfAccountID,
ChartOfAccountName: item.ChartOfAccountName, ChartOfAccountName: item.ChartOfAccountName,
TotalAmount: item.TotalAmount, TotalAmount: item.TotalAmount,
@@ -284,8 +327,26 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod
TotalCategories: result.Summary.TotalCategories, TotalCategories: result.Summary.TotalCategories,
TotalItems: result.Summary.TotalItems, TotalItems: result.Summary.TotalItems,
}, },
Data: data, Data: data,
CategoryData: categoryData, CategoryData: categoryData,
ItemData: itemData, ChartOfAccountData: chartOfAccountData,
ItemData: itemData,
}, nil }, 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
}
+99 -14
View File
@@ -18,6 +18,45 @@ type expenseRepositoryCaptureStub struct {
analytics *entities.ExpenseAnalytics 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 { func (s *expenseRepositoryCaptureStub) Create(_ context.Context, expense *entities.Expense) error {
if expense.ID == uuid.Nil { if expense.ID == uuid.Nil {
expense.ID = uuid.New() expense.ID = uuid.New()
@@ -62,7 +101,8 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui
func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
repo := &expenseRepositoryCaptureStub{} repo := &expenseRepositoryCaptureStub{}
p := NewExpenseProcessorImpl(repo) purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
chartOfAccountID := uuid.New() chartOfAccountID := uuid.New()
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
@@ -73,9 +113,10 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
Total: 10000, Total: 10000,
Items: []models.CreateExpenseItemRequest{ Items: []models.CreateExpenseItemRequest{
{ {
ChartOfAccountID: chartOfAccountID.String(), ChartOfAccountID: chartOfAccountID.String(),
Item: "Cleaning supplies", PurchaseCategoryID: purchaseCategoryID.String(),
Amount: 10000, Item: "Cleaning supplies",
Amount: 10000,
}, },
}, },
}) })
@@ -84,13 +125,15 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
require.NotNil(t, resp) require.NotNil(t, resp)
require.Len(t, repo.createdItems, 1) require.Len(t, repo.createdItems, 1)
require.Equal(t, "Cleaning supplies", repo.createdItems[0].Item) require.Equal(t, "Cleaning supplies", repo.createdItems[0].Item)
require.Equal(t, purchaseCategoryID, repo.createdItems[0].PurchaseCategoryID)
require.Len(t, resp.Items, 1) require.Len(t, resp.Items, 1)
require.Equal(t, "Cleaning supplies", resp.Items[0].Item) require.Equal(t, "Cleaning supplies", resp.Items[0].Item)
} }
func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
repo := &expenseRepositoryCaptureStub{} 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{ resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
Receiver: "Cashier", Receiver: "Cashier",
@@ -100,9 +143,10 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
Total: 10000, Total: 10000,
Items: []models.CreateExpenseItemRequest{ Items: []models.CreateExpenseItemRequest{
{ {
ChartOfAccountID: uuid.NewString(), ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies", PurchaseCategoryID: purchaseCategoryID.String(),
Amount: 10000, Item: "Cleaning supplies",
Amount: 10000,
}, },
}, },
}) })
@@ -115,7 +159,8 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
repo := &expenseRepositoryCaptureStub{} repo := &expenseRepositoryCaptureStub{}
p := NewExpenseProcessorImpl(repo) purchaseCategoryID := uuid.New()
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
status := "approved" status := "approved"
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
@@ -127,9 +172,10 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
Total: 10000, Total: 10000,
Items: []models.CreateExpenseItemRequest{ Items: []models.CreateExpenseItemRequest{
{ {
ChartOfAccountID: uuid.NewString(), ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies", PurchaseCategoryID: purchaseCategoryID.String(),
Amount: 10000, Item: "Cleaning supplies",
Amount: 10000,
}, },
}, },
}) })
@@ -140,8 +186,35 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
require.Equal(t, "approved", resp.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) { func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) {
coaID := uuid.New() coaID := uuid.New()
purchaseCategoryID := uuid.New()
outletID := uuid.New() outletID := uuid.New()
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
repo := &expenseRepositoryCaptureStub{ repo := &expenseRepositoryCaptureStub{
@@ -165,6 +238,16 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te
}, },
}, },
CategoryData: []entities.ExpenseAnalyticsCategoryData{ CategoryData: []entities.ExpenseAnalyticsCategoryData{
{
PurchaseCategoryID: purchaseCategoryID,
PurchaseCategoryName: "Operational Supplies",
PurchaseCategoryType: "expense",
TotalAmount: 100000,
ExpenseCount: 2,
ItemCount: 2,
},
},
ChartOfAccountData: []entities.ExpenseAnalyticsChartOfAccountData{
{ {
ChartOfAccountID: coaID, ChartOfAccountID: coaID,
ChartOfAccountName: "Operational", ChartOfAccountName: "Operational",
@@ -183,7 +266,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te
}, },
}, },
} }
p := NewExpenseProcessorImpl(repo) p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{ resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{
OrganizationID: uuid.New(), OrganizationID: uuid.New(),
@@ -200,7 +283,9 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te
require.Len(t, resp.Data, 1) require.Len(t, resp.Data, 1)
require.Equal(t, int64(2), resp.Data[0].ExpenseCount) require.Equal(t, int64(2), resp.Data[0].ExpenseCount)
require.Len(t, resp.CategoryData, 1) require.Len(t, resp.CategoryData, 1)
require.Equal(t, coaID, resp.CategoryData[0].ChartOfAccountID) 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.Len(t, resp.ItemData, 1)
require.Equal(t, "Cleaning supplies", resp.ItemData[0].Item) require.Equal(t, "Cleaning supplies", resp.ItemData[0].Item)
} }
+13 -3
View File
@@ -86,7 +86,7 @@ type CustomerRepository interface {
} }
type InventoryMovementService 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 CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
} }
@@ -338,7 +338,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
ProductID: itemReq.ProductID, ProductID: itemReq.ProductID,
ProductVariantID: itemReq.ProductVariantID, ProductVariantID: itemReq.ProductVariantID,
Quantity: itemReq.Quantity, Quantity: itemReq.Quantity,
UnitPrice: unitPrice, // Use price from database UnitPrice: unitPrice,
TotalPrice: itemTotalPrice, TotalPrice: itemTotalPrice,
UnitCost: unitCost, UnitCost: unitCost,
TotalCost: itemTotalCost, TotalCost: itemTotalCost,
@@ -594,6 +594,10 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return fmt.Errorf("order item does not belong to this order") return fmt.Errorf("order item does not belong to this order")
} }
if orderItem.Status == entities.OrderItemStatusCancelled {
return fmt.Errorf("order item %s is already cancelled", orderItemID)
}
if itemVoid.Quantity > orderItem.Quantity { if itemVoid.Quantity > orderItem.Quantity {
return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID) return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID)
} }
@@ -614,9 +618,15 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return fmt.Errorf("outlet not found: %w", err) return fmt.Errorf("outlet not found: %w", err)
} }
// Reload order to get latest state
order, err = p.orderRepo.GetByID(ctx, req.OrderID)
if err != nil {
return fmt.Errorf("failed to reload order: %w", err)
}
order.Subtotal -= totalVoidedAmount order.Subtotal -= totalVoidedAmount
order.TotalCost -= totalVoidedCost order.TotalCost -= totalVoidedCost
order.TaxAmount = order.Subtotal * outlet.TaxRate // Recalculate tax using outlet's tax rate order.TaxAmount = order.Subtotal * outlet.TaxRate
order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount
if err := p.orderRepo.Update(ctx, order); err != nil { if err := p.orderRepo.Update(ctx, order); err != nil {
+139 -73
View File
@@ -25,6 +25,7 @@ type PurchaseOrderProcessorImpl struct {
purchaseOrderRepo PurchaseOrderRepository purchaseOrderRepo PurchaseOrderRepository
vendorRepo VendorRepository vendorRepo VendorRepository
ingredientRepo IngredientRepository ingredientRepo IngredientRepository
purchaseCategoryRepo PurchaseCategoryRepository
unitRepo UnitRepository unitRepo UnitRepository
fileRepo FileRepository fileRepo FileRepository
inventoryMovementService InventoryMovementService inventoryMovementService InventoryMovementService
@@ -35,6 +36,7 @@ func NewPurchaseOrderProcessorImpl(
purchaseOrderRepo PurchaseOrderRepository, purchaseOrderRepo PurchaseOrderRepository,
vendorRepo VendorRepository, vendorRepo VendorRepository,
ingredientRepo IngredientRepository, ingredientRepo IngredientRepository,
purchaseCategoryRepo PurchaseCategoryRepository,
unitRepo UnitRepository, unitRepo UnitRepository,
fileRepo FileRepository, fileRepo FileRepository,
inventoryMovementService InventoryMovementService, inventoryMovementService InventoryMovementService,
@@ -44,6 +46,7 @@ func NewPurchaseOrderProcessorImpl(
purchaseOrderRepo: purchaseOrderRepo, purchaseOrderRepo: purchaseOrderRepo,
vendorRepo: vendorRepo, vendorRepo: vendorRepo,
ingredientRepo: ingredientRepo, ingredientRepo: ingredientRepo,
purchaseCategoryRepo: purchaseCategoryRepo,
unitRepo: unitRepo, unitRepo: unitRepo,
fileRepo: fileRepo, fileRepo: fileRepo,
inventoryMovementService: inventoryMovementService, inventoryMovementService: inventoryMovementService,
@@ -64,16 +67,40 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber) return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber)
} }
// Validate ingredients and units exist // Validate categories and inventory fields per item type.
for i, item := range req.Items { for i, item := range req.Items {
_, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i)
if err != nil { if err != nil {
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err) return nil, err
} }
_, err = p.unitRepo.GetByID(ctx, item.UnitID, organizationID) switch category.Type {
if err != nil { case entities.PurchaseCategoryTypeRawMaterial:
return nil, fmt.Errorf("unit not found for item %d: %w", i, err) if item.IngredientID == nil {
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
}
if item.Quantity == nil {
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
}
if item.UnitID == nil {
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
}
_, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err)
}
_, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found for item %d: %w", i, err)
}
case entities.PurchaseCategoryTypeExpense:
if item.IngredientID != nil || item.Quantity != nil || item.UnitID != nil {
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
}
default:
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
} }
} }
@@ -109,12 +136,13 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
// Create purchase order items // Create purchase order items
for _, itemReq := range req.Items { for _, itemReq := range req.Items {
itemEntity := &entities.PurchaseOrderItem{ itemEntity := &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID, PurchaseOrderID: poEntity.ID,
IngredientID: itemReq.IngredientID, IngredientID: itemReq.IngredientID,
Description: itemReq.Description, PurchaseCategoryID: itemReq.PurchaseCategoryID,
Quantity: itemReq.Quantity, Description: itemReq.Description,
UnitID: itemReq.UnitID, Quantity: itemReq.Quantity,
Amount: itemReq.Amount, UnitID: itemReq.UnitID,
Amount: itemReq.Amount,
} }
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity) err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
@@ -189,68 +217,80 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
// Update items if provided // Update items if provided
if req.Items != nil { if req.Items != nil {
// Delete existing items totalAmount := 0.0
items := make([]*entities.PurchaseOrderItem, len(req.Items))
for i, itemReq := range req.Items {
if itemReq.PurchaseCategoryID == nil {
return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
}
ingredientID := itemReq.IngredientID
purchaseCategoryID := *itemReq.PurchaseCategoryID
unitID := itemReq.UnitID
quantity := itemReq.Quantity
amount := 0.0
if itemReq.Amount != nil {
amount = *itemReq.Amount
}
description := itemReq.Description
category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i)
if err != nil {
return nil, err
}
switch category.Type {
case entities.PurchaseCategoryTypeRawMaterial:
if ingredientID == nil {
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
}
if quantity == nil {
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
}
if unitID == nil {
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
}
_, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("ingredient not found: %w", err)
}
_, err = p.unitRepo.GetByID(ctx, *unitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found: %w", err)
}
case entities.PurchaseCategoryTypeExpense:
if ingredientID != nil || quantity != nil || unitID != nil {
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
}
default:
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
}
items[i] = &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID,
IngredientID: ingredientID,
PurchaseCategoryID: purchaseCategoryID,
Description: description,
Quantity: quantity,
UnitID: unitID,
Amount: amount,
}
totalAmount += amount
}
// Delete and recreate only after all replacement items are valid.
err = p.purchaseOrderRepo.DeleteItemsByPurchaseOrderID(ctx, poEntity.ID) err = p.purchaseOrderRepo.DeleteItemsByPurchaseOrderID(ctx, poEntity.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to delete existing items: %w", err) return nil, fmt.Errorf("failed to delete existing items: %w", err)
} }
// Create new items for _, itemEntity := range items {
totalAmount := 0.0
for _, itemReq := range req.Items {
// Validate ingredients and units exist
if itemReq.IngredientID != nil {
_, err := p.ingredientRepo.GetByID(ctx, *itemReq.IngredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("ingredient not found: %w", err)
}
}
if itemReq.UnitID != nil {
_, err := p.unitRepo.GetByID(ctx, *itemReq.UnitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found: %w", err)
}
}
// Use existing values if not provided
ingredientID := poEntity.Items[0].IngredientID // This is a simplified approach
unitID := poEntity.Items[0].UnitID
quantity := poEntity.Items[0].Quantity
amount := poEntity.Items[0].Amount
description := poEntity.Items[0].Description
if itemReq.IngredientID != nil {
ingredientID = *itemReq.IngredientID
}
if itemReq.UnitID != nil {
unitID = *itemReq.UnitID
}
if itemReq.Quantity != nil {
quantity = *itemReq.Quantity
}
if itemReq.Amount != nil {
amount = *itemReq.Amount
}
if itemReq.Description != nil {
description = itemReq.Description
}
itemEntity := &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID,
IngredientID: ingredientID,
Description: description,
Quantity: quantity,
UnitID: unitID,
Amount: amount,
}
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity) err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create purchase order item: %w", err) return nil, fmt.Errorf("failed to create purchase order item: %w", err)
} }
totalAmount += amount
} }
poEntity.TotalAmount = totalAmount poEntity.TotalAmount = totalAmount
@@ -379,19 +419,27 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
// Update inventory for each item // Update inventory for each item
for _, item := range poWithItems.Items { for _, item := range poWithItems.Items {
if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense {
continue
}
if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil {
return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID)
}
// Get ingredient to find its base unit // Get ingredient to find its base unit
ingredient, err := p.ingredientRepo.GetByID(ctx, item.IngredientID, organizationID) ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ingredient %s: %w", item.IngredientID, err) return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err)
} }
// Convert quantity to ingredient's base unit if needed // Convert quantity to ingredient's base unit if needed
quantityToAdd := item.Quantity quantityToAdd := *item.Quantity
if item.UnitID != ingredient.UnitID { if *item.UnitID != ingredient.UnitID {
// Convert from purchase unit to ingredient's base unit // Convert from purchase unit to ingredient's base unit
convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, item.IngredientID, item.UnitID, ingredient.UnitID, organizationID, item.Quantity) convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", item.IngredientID, item.UnitID, ingredient.UnitID, err) return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err)
} }
quantityToAdd = convertedQuantity quantityToAdd = convertedQuantity
} }
@@ -409,7 +457,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
err = p.inventoryMovementService.CreateIngredientMovement( err = p.inventoryMovementService.CreateIngredientMovement(
ctx, ctx,
item.IngredientID, *item.IngredientID,
organizationID, organizationID,
outletID, outletID,
userID, userID,
@@ -419,9 +467,10 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
reason, reason,
&referenceType, &referenceType,
referenceID, referenceID,
&item.ID,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", item.IngredientID, err) return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err)
} }
} }
} }
@@ -440,3 +489,20 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
} }
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
if err != nil {
return nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
}
if !category.IsActive {
return nil, fmt.Errorf("purchase category for item %d is inactive", itemIndex)
}
if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense {
return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex)
}
return category, nil
}
+66 -60
View File
@@ -124,7 +124,6 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz
} }
func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) {
var summary entities.PurchasingSummary
var outletName *string var outletName *string
if outletID != nil { if outletID != nil {
@@ -144,29 +143,32 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
outletName = &outlet.Name outletName = &outlet.Name
} }
} }
return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, outletName, dateFrom, dateTo, groupBy)
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) {
var summary entities.PurchasingSummary
summaryQuery := r.db.WithContext(ctx). summaryQuery := r.db.WithContext(ctx).
Table("inventory_movements im"). Table("purchase_orders po").
Select(` Select(`
COALESCE(SUM(im.total_cost), 0) as total_purchases, COALESCE(SUM(poi.amount), 0) as total_purchases,
COUNT(DISTINCT im.reference_id) as total_purchase_orders, COUNT(DISTINCT po.id) as total_purchase_orders,
COALESCE(SUM(im.quantity), 0) as total_quantity, COALESCE(SUM(poi.quantity), 0) as total_quantity,
CASE CASE
WHEN COUNT(DISTINCT im.reference_id) > 0 WHEN COUNT(DISTINCT po.id) > 0
THEN COALESCE(SUM(im.total_cost), 0) / COUNT(DISTINCT im.reference_id) THEN COALESCE(SUM(poi.amount), 0) / COUNT(DISTINCT po.id)
ELSE 0 ELSE 0
END as average_purchase_order_value, END as average_purchase_order_value,
COUNT(DISTINCT im.item_id) as total_ingredients, COUNT(DISTINCT i.id) as total_ingredients,
COUNT(DISTINCT po.vendor_id) as total_vendors COUNT(DISTINCT po.vendor_id) as total_vendors
`). `).
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id"). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Where("im.organization_id = ?", organizationID). Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("im.item_type = ?", "INGREDIENT"). Where("po.organization_id = ?", organizationID).
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). Where("po.status != ?", "cancelled").
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo) Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
summaryQuery = r.resolveOutletID(summaryQuery, outletID, "im.outlet_id")
if err := summaryQuery.Scan(&summary).Error; err != nil { if err := summaryQuery.Scan(&summary).Error; err != nil {
return nil, err return nil, err
@@ -175,36 +177,35 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
var dateFormat string var dateFormat string
switch groupBy { switch groupBy {
case "hour": case "hour":
dateFormat = "DATE_TRUNC('hour', im.created_at)" dateFormat = "DATE_TRUNC('hour', po.created_at)"
case "week": case "week":
dateFormat = "DATE_TRUNC('week', im.created_at)" dateFormat = "DATE_TRUNC('week', po.transaction_date::timestamp)"
case "month": case "month":
dateFormat = "DATE_TRUNC('month', im.created_at)" dateFormat = "DATE_TRUNC('month', po.transaction_date::timestamp)"
default: default:
dateFormat = "DATE_TRUNC('day', im.created_at)" dateFormat = "DATE_TRUNC('day', po.transaction_date::timestamp)"
} }
var data []entities.PurchasingAnalyticsData var data []entities.PurchasingAnalyticsData
dataQuery := r.db.WithContext(ctx). dataQuery := r.db.WithContext(ctx).
Table("inventory_movements im"). Table("purchase_orders po").
Select(` Select(`
`+dateFormat+` as date, `+dateFormat+` as date,
COALESCE(SUM(im.total_cost), 0) as purchases, COALESCE(SUM(poi.amount), 0) as purchases,
COUNT(DISTINCT im.reference_id) as purchase_orders, COUNT(DISTINCT po.id) as purchase_orders,
COALESCE(SUM(im.quantity), 0) as quantity, COALESCE(SUM(poi.quantity), 0) as quantity,
COUNT(DISTINCT im.item_id) as ingredients, COUNT(DISTINCT i.id) as ingredients,
COUNT(DISTINCT po.vendor_id) as vendors COUNT(DISTINCT po.vendor_id) as vendors
`). `).
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id"). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Where("im.organization_id = ?", organizationID). Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("im.item_type = ?", "INGREDIENT"). Where("po.organization_id = ?", organizationID).
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). Where("po.status != ?", "cancelled").
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group(dateFormat). Group(dateFormat).
Order(dateFormat) Order(dateFormat)
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID)
dataQuery = r.resolveOutletID(dataQuery, outletID, "im.outlet_id")
if err := dataQuery.Scan(&data).Error; err != nil { if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err return nil, err
@@ -212,29 +213,28 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
var ingredientData []entities.PurchasingIngredientData var ingredientData []entities.PurchasingIngredientData
ingredientQuery := r.db.WithContext(ctx). ingredientQuery := r.db.WithContext(ctx).
Table("inventory_movements im"). Table("purchase_order_items poi").
Select(` Select(`
i.id as ingredient_id, i.id as ingredient_id,
i.name as ingredient_name, i.name as ingredient_name,
COALESCE(SUM(im.quantity), 0) as quantity, COALESCE(SUM(poi.quantity), 0) as quantity,
COALESCE(SUM(im.total_cost), 0) as total_cost, COALESCE(SUM(poi.amount), 0) as total_cost,
CASE CASE
WHEN SUM(im.quantity) > 0 WHEN SUM(poi.quantity) > 0
THEN COALESCE(SUM(im.total_cost), 0) / SUM(im.quantity) THEN COALESCE(SUM(poi.amount), 0) / SUM(poi.quantity)
ELSE 0 ELSE 0
END as average_unit_cost, END as average_unit_cost,
COUNT(DISTINCT im.reference_id) as purchase_order_count COUNT(DISTINCT po.id) as purchase_order_count
`). `).
Joins("JOIN ingredients i ON im.item_id = i.id"). Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Where("im.organization_id = ?", organizationID). Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("im.item_type = ?", "INGREDIENT"). Where("po.organization_id = ?", organizationID).
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). Where("po.status != ?", "cancelled").
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("i.id, i.name"). Group("i.id, i.name").
Order("total_cost DESC") Order("total_cost DESC")
ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID)
ingredientQuery = r.resolveOutletID(ingredientQuery, outletID, "im.outlet_id")
if err := ingredientQuery.Scan(&ingredientData).Error; err != nil { if err := ingredientQuery.Scan(&ingredientData).Error; err != nil {
return nil, err return nil, err
@@ -242,26 +242,25 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
var vendorData []entities.PurchasingVendorData var vendorData []entities.PurchasingVendorData
vendorQuery := r.db.WithContext(ctx). vendorQuery := r.db.WithContext(ctx).
Table("inventory_movements im"). Table("purchase_orders po").
Select(` Select(`
v.id as vendor_id, v.id as vendor_id,
v.name as vendor_name, v.name as vendor_name,
COALESCE(SUM(im.total_cost), 0) as total_cost, COALESCE(SUM(poi.amount), 0) as total_cost,
COUNT(DISTINCT im.reference_id) as purchase_order_count, COUNT(DISTINCT po.id) as purchase_order_count,
COUNT(DISTINCT im.item_id) as ingredient_count, COUNT(DISTINCT i.id) as ingredient_count,
COALESCE(SUM(im.quantity), 0) as quantity COALESCE(SUM(poi.quantity), 0) as quantity
`). `).
Joins("JOIN purchase_orders po ON im.reference_id = po.id").
Joins("JOIN vendors v ON po.vendor_id = v.id"). Joins("JOIN vendors v ON po.vendor_id = v.id").
Where("im.organization_id = ?", organizationID). Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id").
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase). Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Where("im.item_type = ?", "INGREDIENT"). Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder). Where("po.organization_id = ?", organizationID).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo). Where("po.status != ?", "cancelled").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Group("v.id, v.name"). Group("v.id, v.name").
Order("total_cost DESC") Order("total_cost DESC")
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
vendorQuery = r.resolveOutletID(vendorQuery, outletID, "im.outlet_id")
if err := vendorQuery.Scan(&vendorData).Error; err != nil { if err := vendorQuery.Scan(&vendorData).Error; err != nil {
return nil, err return nil, err
@@ -276,6 +275,13 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
}, nil }, nil
} }
func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB {
if outletID == nil {
return query
}
return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
}
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
var results []*entities.ProductAnalytics var results []*entities.ProductAnalytics
+38 -8
View File
@@ -30,6 +30,7 @@ func (r *ExpenseRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*ent
var expense entities.Expense var expense entities.Expense
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Preload("Items.ChartOfAccount"). Preload("Items.ChartOfAccount").
Preload("Items.PurchaseCategory").
First(&expense, "id = ?", id).Error First(&expense, "id = ?", id).Error
if err != nil { if err != nil {
return nil, err return nil, err
@@ -41,6 +42,7 @@ func (r *ExpenseRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id
var expense entities.Expense var expense entities.Expense
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Preload("Items.ChartOfAccount"). Preload("Items.ChartOfAccount").
Preload("Items.PurchaseCategory").
Where("id = ? AND organization_id = ?", id, organizationID). Where("id = ? AND organization_id = ?", id, organizationID).
First(&expense).Error First(&expense).Error
if err != nil { if err != nil {
@@ -107,6 +109,7 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU
err := query. err := query.
Preload("Items.ChartOfAccount"). Preload("Items.ChartOfAccount").
Preload("Items.PurchaseCategory").
Order("created_at DESC"). Order("created_at DESC").
Limit(limit). Limit(limit).
Offset(offset). Offset(offset).
@@ -139,7 +142,7 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID
Table("expense_items ei"). Table("expense_items ei").
Select(` Select(`
COUNT(ei.id) as total_items, COUNT(ei.id) as total_items,
COUNT(DISTINCT ei.chart_of_account_id) as total_categories COUNT(DISTINCT ei.purchase_category_id) as total_categories
`). `).
Joins("JOIN expenses e ON ei.expense_id = e.id"). Joins("JOIN expenses e ON ei.expense_id = e.id").
Where("e.organization_id = ?", organizationID). Where("e.organization_id = ?", organizationID).
@@ -174,7 +177,7 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID
COALESCE(SUM(item_counts.categories), 0) as categories COALESCE(SUM(item_counts.categories), 0) as categories
`). `).
Joins(`LEFT JOIN ( Joins(`LEFT JOIN (
SELECT expense_id, COUNT(id) as items, COUNT(DISTINCT chart_of_account_id) as categories SELECT expense_id, COUNT(id) as items, COUNT(DISTINCT purchase_category_id) as categories
FROM expense_items FROM expense_items
GROUP BY expense_id GROUP BY expense_id
) item_counts ON item_counts.expense_id = e.id`). ) item_counts ON item_counts.expense_id = e.id`).
@@ -192,6 +195,32 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID
var categoryData []entities.ExpenseAnalyticsCategoryData var categoryData []entities.ExpenseAnalyticsCategoryData
categoryQuery := r.db.WithContext(ctx). 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"). Table("expense_items ei").
Select(` Select(`
COALESCE(parent_coa.id, coa.id) as chart_of_account_id, COALESCE(parent_coa.id, coa.id) as chart_of_account_id,
@@ -209,9 +238,9 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID
Group("COALESCE(parent_coa.id, coa.id), COALESCE(parent_coa.name, coa.name, 'Lain-lain')"). Group("COALESCE(parent_coa.id, coa.id), COALESCE(parent_coa.name, coa.name, 'Lain-lain')").
Order("total_amount DESC") Order("total_amount DESC")
if outletID != nil { if outletID != nil {
categoryQuery = categoryQuery.Where("e.outlet_id = ?", *outletID) chartOfAccountQuery = chartOfAccountQuery.Where("e.outlet_id = ?", *outletID)
} }
if err := categoryQuery.Scan(&categoryData).Error; err != nil { if err := chartOfAccountQuery.Scan(&chartOfAccountData).Error; err != nil {
return nil, err return nil, err
} }
@@ -239,10 +268,11 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID
} }
return &entities.ExpenseAnalytics{ return &entities.ExpenseAnalytics{
Summary: summary, Summary: summary,
Data: data, Data: data,
CategoryData: categoryData, CategoryData: categoryData,
ItemData: itemData, ChartOfAccountData: chartOfAccountData,
ItemData: itemData,
}, nil }, nil
} }
@@ -2,6 +2,8 @@ package repository
import ( import (
"context" "context"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
@@ -110,3 +112,29 @@ func (r *OrganizationRepositoryImpl) GetTotalOmset(ctx context.Context, organiza
Scan(&total).Error Scan(&total).Error
return total, err return total, err
} }
// GetTodayOmset returns the total revenue from completed orders for an organization on the current calendar day.
func (r *OrganizationRepositoryImpl) GetTodayOmset(ctx context.Context, organizationID uuid.UUID) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("orders").
Where(
"organization_id = ? AND payment_status = ? AND is_void = ? AND is_refund = ? AND created_at >= ? AND created_at < ?",
organizationID, "completed", false, false,
todayStart(), tomorrowStart(),
).
Select("COALESCE(SUM(total_amount), 0)").
Scan(&total).Error
return total, err
}
// todayStart returns midnight of the current local day.
func todayStart() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
}
// tomorrowStart returns midnight of the next local day.
func tomorrowStart() time.Time {
return todayStart().AddDate(0, 0, 1)
}
+20
View File
@@ -3,6 +3,7 @@ package repository
import ( import (
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"context" "context"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
@@ -103,3 +104,22 @@ func (r *OutletRepositoryImpl) Count(ctx context.Context, filters map[string]int
err := query.Count(&count).Error err := query.Count(&count).Error
return count, err return count, err
} }
// GetTodayOmset returns the total revenue from completed orders for an outlet on the current calendar day.
func (r *OutletRepositoryImpl) GetTodayOmset(ctx context.Context, outletID uuid.UUID) (float64, error) {
var total float64
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
tomorrowStart := todayStart.AddDate(0, 0, 1)
err := r.db.WithContext(ctx).
Table("orders").
Where(
"outlet_id = ? AND payment_status = ? AND is_void = ? AND is_refund = ? AND created_at >= ? AND created_at < ?",
outletID, "completed", false, false,
todayStart, tomorrowStart,
).
Select("COALESCE(SUM(total_amount), 0)").
Scan(&total).Error
return total, err
}
@@ -31,6 +31,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID)
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Preload("Vendor"). Preload("Vendor").
Preload("Items.Ingredient"). Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit"). Preload("Items.Unit").
Preload("Attachments.File"). Preload("Attachments.File").
First(&po, "id = ?", id).Error First(&po, "id = ?", id).Error
@@ -45,6 +46,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Preload("Vendor"). Preload("Vendor").
Preload("Items.Ingredient"). Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit"). Preload("Items.Unit").
Preload("Attachments.File"). Preload("Attachments.File").
Where("id = ? AND organization_id = ?", id, organizationID). Where("id = ? AND organization_id = ?", id, organizationID).
@@ -105,6 +107,7 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
err := query. err := query.
Preload("Vendor"). Preload("Vendor").
Preload("Items.Ingredient"). Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit"). Preload("Items.Unit").
Preload("Attachments.File"). Preload("Attachments.File").
Order("created_at DESC"). Order("created_at DESC").
@@ -168,6 +171,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByStatus(ctx context.Context, organizat
Where("organization_id = ? AND status = ?", organizationID, status). Where("organization_id = ? AND status = ?", organizationID, status).
Preload("Vendor"). Preload("Vendor").
Preload("Items.Ingredient"). Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit"). Preload("Items.Unit").
Find(&pos).Error Find(&pos).Error
return pos, err 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"}). Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}).
Preload("Vendor"). Preload("Vendor").
Preload("Items.Ingredient"). Preload("Items.Ingredient").
Preload("Items.PurchaseCategory").
Preload("Items.Unit"). Preload("Items.Unit").
Find(&pos).Error Find(&pos).Error
return pos, err return pos, err
@@ -219,6 +224,7 @@ func (r *PurchaseOrderRepositoryImpl) GetItemsByPurchaseOrderID(ctx context.Cont
var items []*entities.PurchaseOrderItem var items []*entities.PurchaseOrderItem
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Preload("Ingredient"). Preload("Ingredient").
Preload("PurchaseCategory").
Preload("Unit"). Preload("Unit").
Where("purchase_order_id = ?", purchaseOrderID). Where("purchase_order_id = ?", purchaseOrderID).
Find(&items).Error Find(&items).Error
+13
View File
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"database/sql"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -37,3 +38,15 @@ func (m *TxManager) WithTransaction(ctx context.Context, fn func(ctx context.Con
return fn(ctxTx) return fn(ctxTx)
}) })
} }
// WithTransactionOptions runs fn inside a DB transaction with custom TxOptions (e.g. isolation level).
func (m *TxManager) WithTransactionOptions(ctx context.Context, opts *sql.TxOptions, fn func(ctx context.Context) error) error {
if m == nil || m.db == nil {
return fn(ctx)
}
return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
ctxTx := context.WithValue(ctx, txKey, tx)
return fn(ctxTx)
}, opts)
}
+9 -6
View File
@@ -9,6 +9,7 @@ import (
"apskel-pos-be/internal/validator" "apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
) )
type Router struct { type Router struct {
@@ -54,9 +55,10 @@ type Router struct {
expenseHandler *handler.ExpenseHandler expenseHandler *handler.ExpenseHandler
authMiddleware *middleware.AuthMiddleware authMiddleware *middleware.AuthMiddleware
customerAuthMiddleware *middleware.CustomerAuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware
redisClient *redis.Client
} }
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl) *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{ return &Router{
config: cfg, config: cfg,
@@ -101,6 +103,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
selfOrderHandler: selfOrderHandler, selfOrderHandler: selfOrderHandler,
productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator), productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator),
expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator), expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator),
redisClient: redisClient,
} }
} }
@@ -276,19 +279,19 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
orders.GET("", r.orderHandler.ListOrders) orders.GET("", r.orderHandler.ListOrders)
orders.GET("/:id", r.orderHandler.GetOrderByID) orders.GET("/:id", r.orderHandler.GetOrderByID)
orders.POST("", r.orderHandler.CreateOrder) orders.POST("", r.orderHandler.CreateOrder)
orders.POST("/:id/add-items", r.orderHandler.AddToOrder) orders.POST("/:id/add-items", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.AddToOrder)
orders.PUT("/:id", r.orderHandler.UpdateOrder) orders.PUT("/:id", r.orderHandler.UpdateOrder)
orders.PUT("/:id/customer", r.orderHandler.SetOrderCustomer) orders.PUT("/:id/customer", r.orderHandler.SetOrderCustomer)
orders.POST("/void", r.orderHandler.VoidOrder) orders.POST("/void", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.VoidOrder)
orders.POST("/:id/refund", r.orderHandler.RefundOrder) orders.POST("/:id/refund", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.RefundOrder)
orders.POST("/split-bill", r.orderHandler.SplitBill) orders.POST("/split-bill", r.orderHandler.SplitBill)
} }
payments := protected.Group("/payments") payments := protected.Group("/payments")
payments.Use(r.authMiddleware.RequireAdminOrManager()) payments.Use(r.authMiddleware.RequireAdminOrManager())
{ {
payments.POST("", r.orderHandler.CreatePayment) payments.POST("", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.CreatePayment)
payments.POST("/:id/refund", r.orderHandler.RefundPayment) payments.POST("/:id/refund", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.RefundPayment)
} }
paymentMethods := protected.Group("/payment-methods") paymentMethods := protected.Group("/payment-methods")
+19 -18
View File
@@ -10,7 +10,7 @@ import (
) )
type InventoryMovementService 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 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) ingredient, err := s.ingredientRepo.GetByID(ctx, ingredientID, organizationID)
if err != nil { if err != nil {
return err return err
@@ -36,22 +36,23 @@ func (s *InventoryMovementServiceImpl) CreateIngredientMovement(ctx context.Cont
newQuantity := previousQuantity + quantity newQuantity := previousQuantity + quantity
movement := &entities.InventoryMovement{ movement := &entities.InventoryMovement{
ID: uuid.New(), ID: uuid.New(),
OrganizationID: organizationID, OrganizationID: organizationID,
OutletID: outletID, OutletID: outletID,
ItemID: ingredientID, ItemID: ingredientID,
ItemType: "INGREDIENT", ItemType: "INGREDIENT",
MovementType: movementType, MovementType: movementType,
Quantity: quantity, Quantity: quantity,
PreviousQuantity: previousQuantity, PreviousQuantity: previousQuantity,
NewQuantity: newQuantity, NewQuantity: newQuantity,
UnitCost: unitCost, UnitCost: unitCost,
TotalCost: unitCost * quantity, TotalCost: unitCost * quantity,
ReferenceType: referenceType, ReferenceType: referenceType,
ReferenceID: referenceID, ReferenceID: referenceID,
UserID: userID, PurchaseOrderItemID: purchaseOrderItemID,
Reason: &reason, UserID: userID,
CreatedAt: time.Now(), Reason: &reason,
CreatedAt: time.Now(),
} }
err = s.inventoryMovementRepo.Create(ctx, movement) err = s.inventoryMovementRepo.Create(ctx, movement)
+79 -46
View File
@@ -4,11 +4,11 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"math"
"sync" "sync"
"time" "time"
"apskel-pos-be/internal/constants" "apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor" "apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository" "apskel-pos-be/internal/repository"
@@ -17,32 +17,38 @@ import (
) )
const ( const (
defaultCheckInterval = 1 * time.Hour defaultCheckInterval = 5 * time.Minute
OmsetMillionRupiah = 1_000_000.0 OmsetMillionRupiah = 1_000_000.0
) )
// OmsetMilestoneScheduler periodically checks each organization's total omset // OmsetMilestoneScheduler periodically checks each outlet's omset for the
// and sends a notification to owner/admin users when a milestone is reached. // current calendar day and sends a notification every time it crosses a new
// multiple of OmsetMillionRupiah (1 jt, 2 jt, 3 jt, …).
// //
// NOTE: Milestone tracking is in-memory; notifications may re-trigger after a restart. // The notified state is keyed by "outletID:YYYY-MM-DD:N" so each multiple is
// For persistent tracking, persist the notified state in the database. // only notified once per day. State resets naturally on the next day (new key).
// NOTE: state is in-memory; a server restart within the same day may re-send
// notifications for already-crossed milestones.
type OmsetMilestoneScheduler struct { type OmsetMilestoneScheduler struct {
orgRepo *repository.OrganizationRepositoryImpl orgRepo *repository.OrganizationRepositoryImpl
outletRepo *repository.OutletRepositoryImpl
userRepo *repository.UserRepositoryImpl userRepo *repository.UserRepositoryImpl
notificationProc processor.NotificationProcessor notificationProc processor.NotificationProcessor
mu sync.Mutex mu sync.Mutex
notified map[string]bool // "orgID:milestone" -> already notified notified map[string]bool // "outletID:YYYY-MM-DD:N" -> already notified
stopCh chan struct{} stopCh chan struct{}
} }
func NewOmsetMilestoneScheduler( func NewOmsetMilestoneScheduler(
orgRepo *repository.OrganizationRepositoryImpl, orgRepo *repository.OrganizationRepositoryImpl,
outletRepo *repository.OutletRepositoryImpl,
userRepo *repository.UserRepositoryImpl, userRepo *repository.UserRepositoryImpl,
notificationProc processor.NotificationProcessor, notificationProc processor.NotificationProcessor,
) *OmsetMilestoneScheduler { ) *OmsetMilestoneScheduler {
return &OmsetMilestoneScheduler{ return &OmsetMilestoneScheduler{
orgRepo: orgRepo, orgRepo: orgRepo,
outletRepo: outletRepo,
userRepo: userRepo, userRepo: userRepo,
notificationProc: notificationProc, notificationProc: notificationProc,
notified: make(map[string]bool), notified: make(map[string]bool),
@@ -57,8 +63,8 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
} }
go func() { go func() {
// Perform an initial check immediately. // Perform an initial check immediately on startup.
s.checkAllOrganizations() s.checkAllOutlets()
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
@@ -66,7 +72,7 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
s.checkAllOrganizations() s.checkAllOutlets()
case <-s.stopCh: case <-s.stopCh:
log.Println("Omset milestone scheduler stopped") log.Println("Omset milestone scheduler stopped")
return return
@@ -74,7 +80,7 @@ func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
} }
}() }()
log.Println("Omset milestone scheduler started") log.Printf("Omset milestone scheduler started (interval: %s)", interval)
} }
// Stop signals the scheduler to stop. // Stop signals the scheduler to stop.
@@ -82,7 +88,7 @@ func (s *OmsetMilestoneScheduler) Stop() {
close(s.stopCh) close(s.stopCh)
} }
func (s *OmsetMilestoneScheduler) checkAllOrganizations() { func (s *OmsetMilestoneScheduler) checkAllOutlets() {
ctx := context.Background() ctx := context.Background()
orgs, _, err := s.orgRepo.List(ctx, nil, 1000, 0) orgs, _, err := s.orgRepo.List(ctx, nil, 1000, 0)
@@ -92,25 +98,38 @@ func (s *OmsetMilestoneScheduler) checkAllOrganizations() {
} }
for _, org := range orgs { for _, org := range orgs {
s.checkOrganization(ctx, org) outlets, err := s.outletRepo.GetByOrganizationID(ctx, org.ID)
} if err != nil {
} log.Printf("OmsetMilestoneScheduler: failed to list outlets for org %s: %v", org.ID, err)
func (s *OmsetMilestoneScheduler) checkOrganization(ctx context.Context, org *entities.Organization) {
totalOmset, err := s.orgRepo.GetTotalOmset(ctx, org.ID)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to get total omset for org %s: %v", org.ID, err)
return
}
milestones := []float64{OmsetMillionRupiah}
for _, milestone := range milestones {
if totalOmset < milestone {
continue continue
} }
key := fmt.Sprintf("%s:%.0f", org.ID.String(), milestone) for _, outlet := range outlets {
if !outlet.IsActive {
continue
}
s.checkOutlet(ctx, org.ID, outlet.ID, outlet.Name)
}
}
}
func (s *OmsetMilestoneScheduler) checkOutlet(ctx context.Context, organizationID, outletID uuid.UUID, outletName string) {
todayOmset, err := s.outletRepo.GetTodayOmset(ctx, outletID)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to get today's omset for outlet %s: %v", outletID, err)
return
}
if todayOmset < OmsetMillionRupiah {
return
}
// How many full multiples of 1 juta have been crossed today?
crossedMultiple := int(math.Floor(todayOmset / OmsetMillionRupiah))
today := time.Now().Format("2006-01-02")
for n := 1; n <= crossedMultiple; n++ {
key := fmt.Sprintf("%s:%s:%d", outletID.String(), today, n)
s.mu.Lock() s.mu.Lock()
if s.notified[key] { if s.notified[key] {
@@ -120,23 +139,31 @@ func (s *OmsetMilestoneScheduler) checkOrganization(ctx context.Context, org *en
s.notified[key] = true s.notified[key] = true
s.mu.Unlock() s.mu.Unlock()
s.sendMilestoneNotification(ctx, org, totalOmset, milestone) milestone := float64(n) * OmsetMillionRupiah
s.sendMilestoneNotification(ctx, organizationID, outletID, outletName, todayOmset, milestone, n)
} }
} }
func (s *OmsetMilestoneScheduler) sendMilestoneNotification(ctx context.Context, org *entities.Organization, totalOmset float64, milestone float64) { func (s *OmsetMilestoneScheduler) sendMilestoneNotification(
users, err := s.userRepo.GetByOrganizationID(ctx, org.ID) ctx context.Context,
organizationID, outletID uuid.UUID,
outletName string,
todayOmset, milestone float64,
multiple int,
) {
// Fetch all users in the org, then filter to owner and manager only.
// These roles are not assigned to a specific outlet, so we query by org.
users, err := s.userRepo.GetByOrganizationID(ctx, organizationID)
if err != nil { if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", org.ID, err) log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", organizationID, err)
return return
} }
// Notify owner and admin users.
var receiverIDs []uuid.UUID var receiverIDs []uuid.UUID
for _, user := range users { for _, u := range users {
roleStr := string(user.Role) role := string(u.Role)
if roleStr == string(constants.RoleOwner) || roleStr == string(constants.RoleAdmin) { if role == string(constants.RoleOwner) || role == string(constants.RoleManager) {
receiverIDs = append(receiverIDs, user.ID) receiverIDs = append(receiverIDs, u.ID)
} }
} }
@@ -144,28 +171,34 @@ func (s *OmsetMilestoneScheduler) sendMilestoneNotification(ctx context.Context,
return return
} }
orgID := org.ID title := fmt.Sprintf("🎉 Omset %s Hari Ini Mencapai Rp %.0f!", outletName, milestone)
title := "🎉 Selamat! Omset Telah Mencapai 1 Juta Rupiah" body := fmt.Sprintf(
body := fmt.Sprintf("Organisasi %s telah mencapai omset Rp %.0f. Terus tingkatkan prestasinya!", org.Name, totalOmset) "Selamat! Omset outlet %s hari ini sudah menembus Rp %.0f (total hari ini: Rp %.0f). Terus semangat!",
outletName, milestone, todayOmset,
)
notifReq := &models.SendNotificationRequest{ notifReq := &models.SendNotificationRequest{
Title: title, Title: title,
Body: body, Body: body,
Type: "milestone", Type: "milestone",
Category: "omset_milestone", Category: "omset_milestone",
NotifiableType: "organization", NotifiableType: "outlet",
NotifiableID: &orgID, NotifiableID: &outletID,
ReceiverIDs: receiverIDs, ReceiverIDs: receiverIDs,
Data: map[string]interface{}{ Data: map[string]interface{}{
"organization_id": org.ID.String(), "organization_id": organizationID.String(),
"total_omset": totalOmset, "outlet_id": outletID.String(),
"outlet_name": outletName,
"today_omset": todayOmset,
"milestone": milestone, "milestone": milestone,
"multiple": multiple,
}, },
} }
if _, err := s.notificationProc.Send(ctx, notifReq); err != nil { if _, err := s.notificationProc.Send(ctx, notifReq); err != nil {
log.Printf("OmsetMilestoneScheduler: failed to send notification for org %s: %v", org.ID, err) log.Printf("OmsetMilestoneScheduler: failed to send notification for outlet %s: %v", outletID, err)
} else { } else {
log.Printf("OmsetMilestoneScheduler: sent milestone notification to org %s (omset: %.0f)", org.ID, totalOmset) log.Printf("OmsetMilestoneScheduler: sent milestone x%d (Rp %.0f) for outlet %s (today omset: %.0f)",
multiple, milestone, outletName, todayOmset)
} }
} }
+19 -3
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/appcontext"
"context" "context"
"database/sql"
"fmt" "fmt"
"time" "time"
@@ -228,7 +229,9 @@ func (s *OrderServiceImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, re
var response *models.AddToOrderResponse var response *models.AddToOrderResponse
var ingredientTransactions []*contract.CreateOrderIngredientTransactionRequest var ingredientTransactions []*contract.CreateOrderIngredientTransactionRequest
err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { err := s.txManager.WithTransactionOptions(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
}, func(txCtx context.Context) error {
addResp, err := s.orderProcessor.AddToOrder(txCtx, orderID, req) addResp, err := s.orderProcessor.AddToOrder(txCtx, orderID, req)
if err != nil { if err != nil {
return fmt.Errorf("failed to add items to order: %w", err) return fmt.Errorf("failed to add items to order: %w", err)
@@ -305,8 +308,16 @@ func (s *OrderServiceImpl) VoidOrder(ctx context.Context, req *models.VoidOrderR
return fmt.Errorf("invalid user ID") return fmt.Errorf("invalid user ID")
} }
if err := s.orderProcessor.VoidOrder(ctx, req, voidedBy); err != nil { err := s.txManager.WithTransactionOptions(ctx, &sql.TxOptions{
return fmt.Errorf("failed to void order: %w", err) Isolation: sql.LevelSerializable,
}, func(txCtx context.Context) error {
if err := s.orderProcessor.VoidOrder(txCtx, req, voidedBy); err != nil {
return fmt.Errorf("failed to void order: %w", err)
}
return nil
})
if err != nil {
return err
} }
if err := s.handleTableReleaseOnVoid(ctx, req.OrderID); err != nil { if err := s.handleTableReleaseOnVoid(ctx, req.OrderID); err != nil {
@@ -561,9 +572,14 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen
return fmt.Errorf("payment item amount must be greater than zero for item %d", i+1) return fmt.Errorf("payment item amount must be greater than zero for item %d", i+1)
} }
fmt.Printf("[DEBUG] CreatePayment order_id=%s item[%d] order_item_id=%s amount=%.10f\n",
req.OrderID, i, item.OrderItemID, item.Amount)
totalItemAmount += item.Amount totalItemAmount += item.Amount
} }
fmt.Printf("[DEBUG] CreatePayment order_id=%s total_amount=%.10f sum_items=%.10f diff=%.10f\n",
req.OrderID, req.Amount, totalItemAmount, req.Amount-totalItemAmount)
if totalItemAmount != req.Amount { if totalItemAmount != req.Amount {
return fmt.Errorf("sum of payment item amounts must equal total payment amount") return fmt.Errorf("sum of payment item amounts must equal total payment amount")
} }
+16 -6
View File
@@ -169,12 +169,16 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
data := make([]contract.PurchasingAnalyticsData, len(resp.Data)) data := make([]contract.PurchasingAnalyticsData, len(resp.Data))
for i, item := range resp.Data { for i, item := range resp.Data {
data[i] = contract.PurchasingAnalyticsData{ data[i] = contract.PurchasingAnalyticsData{
Date: item.Date, Date: item.Date,
Purchases: item.Purchases, Purchases: item.Purchases,
PurchaseOrders: item.PurchaseOrders, RawMaterialPurchases: item.RawMaterialPurchases,
Quantity: item.Quantity, ExpensePurchases: item.ExpensePurchases,
Ingredients: item.Ingredients, PurchaseOrders: item.PurchaseOrders,
Vendors: item.Vendors, 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, GroupBy: resp.GroupBy,
Summary: contract.PurchasingSummary{ Summary: contract.PurchasingSummary{
TotalPurchases: resp.Summary.TotalPurchases, TotalPurchases: resp.Summary.TotalPurchases,
RawMaterialPurchases: resp.Summary.RawMaterialPurchases,
ExpensePurchases: resp.Summary.ExpensePurchases,
TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders, TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders,
RawMaterialPurchaseOrders: resp.Summary.RawMaterialPurchaseOrders,
ExpenseCount: resp.Summary.ExpenseCount,
TotalQuantity: resp.Summary.TotalQuantity, TotalQuantity: resp.Summary.TotalQuantity,
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue, AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
TotalIngredients: resp.Summary.TotalIngredients, TotalIngredients: resp.Summary.TotalIngredients,
@@ -257,6 +265,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
ProductID: item.ProductID, ProductID: item.ProductID,
ProductName: item.ProductName, ProductName: item.ProductName,
ProductSku: item.ProductSku, ProductSku: item.ProductSku,
ProductPrice: item.ProductPrice,
CategoryID: item.CategoryID, CategoryID: item.CategoryID,
CategoryName: item.CategoryName, CategoryName: item.CategoryName,
CategoryOrder: item.CategoryOrder, CategoryOrder: item.CategoryOrder,
@@ -367,6 +376,7 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
topProducts = append(topProducts, contract.ProductAnalyticsData{ topProducts = append(topProducts, contract.ProductAnalyticsData{
ProductID: item.ProductID, ProductID: item.ProductID,
ProductName: item.ProductName, ProductName: item.ProductName,
ProductPrice: item.ProductPrice,
CategoryID: item.CategoryID, CategoryID: item.CategoryID,
CategoryName: item.CategoryName, CategoryName: item.CategoryName,
QuantitySold: item.QuantitySold, QuantitySold: item.QuantitySold,
@@ -52,17 +52,47 @@ func TestPurchasingAnalyticsContractToModelIgnoresInvalidOutlet(t *testing.T) {
func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) { func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) {
outletID := uuid.New() outletID := uuid.New()
outletName := "Main Outlet" outletName := "Main Outlet"
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
OrganizationID: uuid.New(), OrganizationID: uuid.New(),
OutletID: &outletID, OutletID: &outletID,
OutletName: &outletName, OutletName: &outletName,
Summary: models.PurchasingSummary{
TotalPurchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
TotalPurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
Data: []models.PurchasingAnalyticsData{
{
Date: now,
Purchases: 300,
RawMaterialPurchases: 125,
ExpensePurchases: 175,
PurchaseOrders: 3,
RawMaterialPurchaseOrders: 1,
ExpenseCount: 2,
},
},
}) })
require.NotNil(t, result) require.NotNil(t, result)
require.Equal(t, &outletID, result.OutletID) require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, result.OutletName) require.NotNil(t, result.OutletName)
require.Equal(t, outletName, *result.OutletName) require.Equal(t, outletName, *result.OutletName)
require.Equal(t, float64(300), result.Summary.TotalPurchases)
require.Equal(t, float64(125), result.Summary.RawMaterialPurchases)
require.Equal(t, float64(175), result.Summary.ExpensePurchases)
require.Equal(t, int64(3), result.Summary.TotalPurchaseOrders)
require.Equal(t, int64(1), result.Summary.RawMaterialPurchaseOrders)
require.Equal(t, int64(2), result.Summary.ExpenseCount)
require.Len(t, result.Data, 1)
require.Equal(t, float64(300), result.Data[0].Purchases)
require.Equal(t, float64(125), result.Data[0].RawMaterialPurchases)
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
} }
func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
+39 -20
View File
@@ -27,10 +27,11 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre
func CreateExpenseItemRequestToModel(req *contract.CreateExpenseItemRequest) models.CreateExpenseItemRequest { func CreateExpenseItemRequestToModel(req *contract.CreateExpenseItemRequest) models.CreateExpenseItemRequest {
return models.CreateExpenseItemRequest{ return models.CreateExpenseItemRequest{
ChartOfAccountID: req.ChartOfAccountID, ChartOfAccountID: req.ChartOfAccountID,
Item: req.Item, PurchaseCategoryID: req.PurchaseCategoryID,
Description: req.Description, Item: req.Item,
Amount: req.Amount, Description: req.Description,
Amount: req.Amount,
} }
} }
@@ -60,10 +61,11 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd
func UpdateExpenseItemRequestToModel(req *contract.UpdateExpenseItemRequest) models.UpdateExpenseItemRequest { func UpdateExpenseItemRequestToModel(req *contract.UpdateExpenseItemRequest) models.UpdateExpenseItemRequest {
return models.UpdateExpenseItemRequest{ return models.UpdateExpenseItemRequest{
ChartOfAccountID: req.ChartOfAccountID, ChartOfAccountID: req.ChartOfAccountID,
Item: req.Item, PurchaseCategoryID: req.PurchaseCategoryID,
Description: req.Description, Item: req.Item,
Amount: req.Amount, Description: req.Description,
Amount: req.Amount,
} }
} }
@@ -109,15 +111,19 @@ func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.E
func ExpenseItemModelResponseToResponse(item *models.ExpenseItemResponse) contract.ExpenseItemResponse { func ExpenseItemModelResponseToResponse(item *models.ExpenseItemResponse) contract.ExpenseItemResponse {
return contract.ExpenseItemResponse{ return contract.ExpenseItemResponse{
ID: item.ID, ID: item.ID,
ExpenseID: item.ExpenseID, ExpenseID: item.ExpenseID,
ChartOfAccountID: item.ChartOfAccountID, ChartOfAccountID: item.ChartOfAccountID,
ChartOfAccountName: item.ChartOfAccountName, ChartOfAccountName: item.ChartOfAccountName,
Item: item.Item, PurchaseCategoryID: item.PurchaseCategoryID,
Description: item.Description, PurchaseCategoryName: item.PurchaseCategoryName,
Amount: item.Amount, PurchaseCategoryType: item.PurchaseCategoryType,
CreatedAt: item.CreatedAt, PurchaseCategory: PurchaseCategoryModelResponseToResponse(item.PurchaseCategory),
UpdatedAt: item.UpdatedAt, Item: item.Item,
Description: item.Description,
Amount: item.Amount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
} }
} }
@@ -176,6 +182,18 @@ func ExpenseAnalyticsModelToContract(resp *models.ExpenseAnalyticsResponse) *con
categoryData := make([]contract.ExpenseAnalyticsCategoryData, len(resp.CategoryData)) categoryData := make([]contract.ExpenseAnalyticsCategoryData, len(resp.CategoryData))
for i, item := range resp.CategoryData { for i, item := range resp.CategoryData {
categoryData[i] = contract.ExpenseAnalyticsCategoryData{ 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, ChartOfAccountID: item.ChartOfAccountID,
ChartOfAccountName: item.ChartOfAccountName, ChartOfAccountName: item.ChartOfAccountName,
TotalAmount: item.TotalAmount, TotalAmount: item.TotalAmount,
@@ -208,8 +226,9 @@ func ExpenseAnalyticsModelToContract(resp *models.ExpenseAnalyticsResponse) *con
TotalCategories: resp.Summary.TotalCategories, TotalCategories: resp.Summary.TotalCategories,
TotalItems: resp.Summary.TotalItems, TotalItems: resp.Summary.TotalItems,
}, },
Data: data, Data: data,
CategoryData: categoryData, CategoryData: categoryData,
ItemData: itemData, ChartOfAccountData: chartOfAccountData,
ItemData: itemData,
} }
} }
@@ -11,11 +11,12 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest)
items := make([]models.CreatePurchaseOrderItemRequest, len(req.Items)) items := make([]models.CreatePurchaseOrderItemRequest, len(req.Items))
for i, item := range req.Items { for i, item := range req.Items {
items[i] = models.CreatePurchaseOrderItemRequest{ items[i] = models.CreatePurchaseOrderItemRequest{
IngredientID: item.IngredientID, IngredientID: item.IngredientID,
Description: item.Description, PurchaseCategoryID: item.PurchaseCategoryID,
Quantity: item.Quantity, Description: item.Description,
UnitID: item.UnitID, Quantity: item.Quantity,
Amount: item.Amount, UnitID: item.UnitID,
Amount: item.Amount,
} }
} }
@@ -54,12 +55,13 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
items = make([]models.UpdatePurchaseOrderItemRequest, len(req.Items)) items = make([]models.UpdatePurchaseOrderItemRequest, len(req.Items))
for i, item := range req.Items { for i, item := range req.Items {
items[i] = models.UpdatePurchaseOrderItemRequest{ items[i] = models.UpdatePurchaseOrderItemRequest{
ID: item.ID, ID: item.ID,
IngredientID: item.IngredientID, IngredientID: item.IngredientID,
Description: item.Description, PurchaseCategoryID: item.PurchaseCategoryID,
Quantity: item.Quantity, Description: item.Description,
UnitID: item.UnitID, Quantity: item.Quantity,
Amount: item.Amount, UnitID: item.UnitID,
Amount: item.Amount,
} }
} }
} }
@@ -154,15 +156,16 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
response.Items = make([]contract.PurchaseOrderItemResponse, len(po.Items)) response.Items = make([]contract.PurchaseOrderItemResponse, len(po.Items))
for i, item := range po.Items { for i, item := range po.Items {
response.Items[i] = contract.PurchaseOrderItemResponse{ response.Items[i] = contract.PurchaseOrderItemResponse{
ID: item.ID, ID: item.ID,
PurchaseOrderID: item.PurchaseOrderID, PurchaseOrderID: item.PurchaseOrderID,
IngredientID: item.IngredientID, IngredientID: item.IngredientID,
Description: item.Description, PurchaseCategoryID: item.PurchaseCategoryID,
Quantity: item.Quantity, Description: item.Description,
UnitID: item.UnitID, Quantity: item.Quantity,
Amount: item.Amount, UnitID: item.UnitID,
CreatedAt: item.CreatedAt, Amount: item.Amount,
UpdatedAt: item.UpdatedAt, CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
} }
// Map ingredient if present // 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 // Map unit if present
if item.Unit != nil { if item.Unit != nil {
response.Items[i].Unit = &contract.UnitResponse{ response.Items[i].Unit = &contract.UnitResponse{
@@ -12,15 +12,19 @@ import (
) )
func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) { func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) {
ingredientID := uuid.New()
quantity := 1.0
unitID := uuid.New()
result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{ result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{
VendorID: uuid.New(), VendorID: uuid.New(),
PONumber: "PO-001", PONumber: "PO-001",
TransactionDate: "2026-05-29", TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{ Items: []contract.CreatePurchaseOrderItemRequest{
{ {
IngredientID: uuid.New(), IngredientID: &ingredientID,
Quantity: 1, Quantity: &quantity,
UnitID: uuid.New(), UnitID: &unitID,
Amount: 1000, Amount: 1000,
}, },
}, },
+6
View File
@@ -16,6 +16,12 @@ func HandleResponse(w http.ResponseWriter, r *http.Request, response *contract.R
} else { } else {
responseError := response.GetErrors()[0] responseError := response.GetErrors()[0]
statusCode = MapErrorCodeToHttpStatus(responseError.GetCode()) statusCode = MapErrorCodeToHttpStatus(responseError.GetCode())
logger.FromContext(r.Context()).WithFields(map[string]interface{}{
"error_code": responseError.GetCode(),
"error_entity": responseError.GetEntity(),
"error_cause": responseError.GetCause(),
"status_code": statusCode,
}).Error(methodName)
} }
WriteResponse(w, r, *response, statusCode, methodName) WriteResponse(w, r, *response, statusCode, methodName)
} }
+15
View File
@@ -68,12 +68,18 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create
if strings.TrimSpace(item.ChartOfAccountID) == "" { if strings.TrimSpace(item.ChartOfAccountID) == "" {
return fmt.Errorf("item %d: chart_of_account_id is required", i), constants.MissingFieldErrorCode 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) == "" { if strings.TrimSpace(item.Item) == "" {
return fmt.Errorf("item %d: item is required", i), constants.MissingFieldErrorCode return fmt.Errorf("item %d: item is required", i), constants.MissingFieldErrorCode
} }
if _, err := uuid.Parse(item.ChartOfAccountID); err != nil { 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 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 { if item.Amount <= 0 {
return fmt.Errorf("item %d: amount must be greater than 0", i), constants.MalformedFieldErrorCode 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 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) == "" { if item.Item != nil && strings.TrimSpace(*item.Item) == "" {
return fmt.Errorf("item %d: item cannot be empty", i), constants.MalformedFieldErrorCode return fmt.Errorf("item %d: item cannot be empty", i), constants.MalformedFieldErrorCode
} }
+17 -12
View File
@@ -21,8 +21,9 @@ func TestExpenseValidatorCreateRequiresItemName(t *testing.T) {
Total: 10000, Total: 10000,
Items: []contract.CreateExpenseItemRequest{ Items: []contract.CreateExpenseItemRequest{
{ {
ChartOfAccountID: uuid.NewString(), ChartOfAccountID: uuid.NewString(),
Amount: 10000, PurchaseCategoryID: uuid.NewString(),
Amount: 10000,
}, },
}, },
} }
@@ -45,9 +46,10 @@ func TestExpenseValidatorCreateDoesNotRequireHeaderExpenseName(t *testing.T) {
Total: 10000, Total: 10000,
Items: []contract.CreateExpenseItemRequest{ Items: []contract.CreateExpenseItemRequest{
{ {
ChartOfAccountID: uuid.NewString(), ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies", PurchaseCategoryID: uuid.NewString(),
Amount: 10000, Item: "Cleaning supplies",
Amount: 10000,
}, },
}, },
} }
@@ -71,9 +73,10 @@ func TestExpenseValidatorCreateAllowsValidOptionalStatus(t *testing.T) {
Total: 10000, Total: 10000,
Items: []contract.CreateExpenseItemRequest{ Items: []contract.CreateExpenseItemRequest{
{ {
ChartOfAccountID: uuid.NewString(), ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies", PurchaseCategoryID: uuid.NewString(),
Amount: 10000, Item: "Cleaning supplies",
Amount: 10000,
}, },
}, },
} }
@@ -97,9 +100,10 @@ func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) {
Total: 10000, Total: 10000,
Items: []contract.CreateExpenseItemRequest{ Items: []contract.CreateExpenseItemRequest{
{ {
ChartOfAccountID: uuid.NewString(), ChartOfAccountID: uuid.NewString(),
Item: "Cleaning supplies", PurchaseCategoryID: uuid.NewString(),
Amount: 10000, Item: "Cleaning supplies",
Amount: 10000,
}, },
}, },
} }
@@ -114,10 +118,11 @@ func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) {
func TestExpenseValidatorUpdateRejectsEmptyItemNameWhenProvided(t *testing.T) { func TestExpenseValidatorUpdateRejectsEmptyItemNameWhenProvided(t *testing.T) {
v := NewExpenseValidator() v := NewExpenseValidator()
empty := " " empty := " "
purchaseCategoryID := uuid.NewString()
req := &contract.UpdateExpenseRequest{ req := &contract.UpdateExpenseRequest{
Items: []contract.UpdateExpenseItemRequest{ Items: []contract.UpdateExpenseItemRequest{
{Item: &empty}, {PurchaseCategoryID: &purchaseCategoryID, Item: &empty},
}, },
} }
@@ -34,7 +34,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateCreatePurchaseCategoryRequest(re
} }
if !isValidPurchaseCategoryType(req.Type) { if !isValidPurchaseCategoryType(req.Type) {
return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode
} }
if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 { if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 {
@@ -63,7 +63,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateUpdatePurchaseCategoryRequest(re
} }
if req.Type != nil && !isValidPurchaseCategoryType(*req.Type) { if req.Type != nil && !isValidPurchaseCategoryType(*req.Type) {
return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode
} }
if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 { if req.Code != nil && len(strings.TrimSpace(*req.Code)) > 100 {
@@ -87,7 +87,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(re
} }
if req.Type != "" && !isValidPurchaseCategoryType(req.Type) { if req.Type != "" && !isValidPurchaseCategoryType(req.Type) {
return errors.New("type must be raw_material or non_inventory"), constants.MalformedFieldErrorCode return errors.New("type must be raw_material or expense"), constants.MalformedFieldErrorCode
} }
return nil, "" return nil, ""
@@ -95,7 +95,7 @@ func (v *PurchaseCategoryValidatorImpl) ValidateListPurchaseCategoriesRequest(re
func isValidPurchaseCategoryType(categoryType string) bool { func isValidPurchaseCategoryType(categoryType string) bool {
switch categoryType { switch categoryType {
case "raw_material", "non_inventory": case "raw_material", "expense":
return true return true
default: default:
return false return false
+29 -10
View File
@@ -2,11 +2,14 @@ package validator
import ( import (
"errors" "errors"
"strconv"
"strings" "strings"
"time" "time"
"apskel-pos-be/internal/constants" "apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract" "apskel-pos-be/internal/contract"
"github.com/google/uuid"
) )
type PurchaseOrderValidator interface { type PurchaseOrderValidator interface {
@@ -26,7 +29,7 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
return errors.New("request body is required"), constants.MissingFieldErrorCode 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 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) { func (v *PurchaseOrderValidatorImpl) validatePurchaseOrderItem(item *contract.CreatePurchaseOrderItemRequest, index int) (error, string) {
if item.IngredientID.String() == "" { if item.PurchaseCategoryID == uuid.Nil {
return errors.New("items[" + string(rune(index)) + "].ingredient_id is required"), constants.MissingFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
} }
if item.Quantity <= 0 { if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
return errors.New("items[" + string(rune(index)) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
} }
if item.UnitID.String() == "" { if item.Quantity != nil && *item.Quantity <= 0 {
return errors.New("items[" + string(rune(index)) + "].unit_id is required"), constants.MissingFieldErrorCode return errors.New("items[" + strconv.Itoa(index) + "].quantity must be greater than 0"), constants.MalformedFieldErrorCode
}
if item.UnitID != nil && *item.UnitID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
} }
if item.Amount < 0 { 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, "" return nil, ""
} }
func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contract.UpdatePurchaseOrderItemRequest, index int) (error, string) { func (v *PurchaseOrderValidatorImpl) validateUpdatePurchaseOrderItem(item *contract.UpdatePurchaseOrderItemRequest, index int) (error, string) {
if item.PurchaseCategoryID == nil || *item.PurchaseCategoryID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].purchase_category_id is required"), constants.MissingFieldErrorCode
}
if item.IngredientID != nil && *item.IngredientID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].ingredient_id cannot be empty"), constants.MalformedFieldErrorCode
}
if item.UnitID != nil && *item.UnitID == uuid.Nil {
return errors.New("items[" + strconv.Itoa(index) + "].unit_id cannot be empty"), constants.MalformedFieldErrorCode
}
if item.Quantity != nil && *item.Quantity <= 0 { if item.Quantity != nil && *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.Amount != nil && *item.Amount < 0 { 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, "" return nil, ""
@@ -11,16 +11,21 @@ import (
) )
func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest { func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest {
ingredientID := uuid.New()
quantity := 1.0
unitID := uuid.New()
return &contract.CreatePurchaseOrderRequest{ return &contract.CreatePurchaseOrderRequest{
VendorID: uuid.New(), VendorID: uuid.New(),
PONumber: "PO-001", PONumber: "PO-001",
TransactionDate: "2026-05-29", TransactionDate: "2026-05-29",
Items: []contract.CreatePurchaseOrderItemRequest{ Items: []contract.CreatePurchaseOrderItemRequest{
{ {
IngredientID: uuid.New(), IngredientID: &ingredientID,
Quantity: 1, PurchaseCategoryID: uuid.New(),
UnitID: uuid.New(), Quantity: &quantity,
Amount: 1000, UnitID: &unitID,
Amount: 1000,
}, },
}, },
} }
@@ -3,7 +3,7 @@ CREATE TABLE purchase_category_presets (
parent_id UUID REFERENCES purchase_category_presets(id) ON DELETE SET NULL, parent_id UUID REFERENCES purchase_category_presets(id) ON DELETE SET NULL,
code VARCHAR(100) NOT NULL UNIQUE, code VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'non_inventory')), type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'expense')),
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true, is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
@@ -17,7 +17,7 @@ CREATE TABLE purchase_categories (
parent_id UUID REFERENCES purchase_categories(id) ON DELETE SET NULL, parent_id UUID REFERENCES purchase_categories(id) ON DELETE SET NULL,
code VARCHAR(100) NOT NULL, code VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'non_inventory')), type VARCHAR(20) NOT NULL CHECK (type IN ('raw_material', 'expense')),
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
is_system BOOLEAN NOT NULL DEFAULT false, is_system BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true, is_active BOOLEAN NOT NULL DEFAULT true,
@@ -38,8 +38,8 @@ CREATE INDEX idx_purchase_categories_is_active ON purchase_categories(is_active)
INSERT INTO purchase_category_presets (code, name, type, sort_order) INSERT INTO purchase_category_presets (code, name, type, sort_order)
VALUES VALUES
('hpp', 'HPP', 'raw_material', 1), ('bahan_baku', 'Bahan Baku', 'raw_material', 1),
('biaya_lain_lain', 'Biaya Lain-lain', 'non_inventory', 2) ('biaya_lain_lain', 'Biaya Lain-lain', 'expense', 2)
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
INSERT INTO purchase_category_presets (parent_id, code, name, type, sort_order) INSERT INTO purchase_category_presets (parent_id, code, name, type, sort_order)
@@ -47,18 +47,14 @@ SELECT parent.id, child.code, child.name, child.type, child.sort_order
FROM purchase_category_presets parent FROM purchase_category_presets parent
JOIN ( JOIN (
VALUES VALUES
('hpp', 'hpp_bakso_mie_ayam', 'Bakso & Mie Ayam', 'raw_material', 1), ('biaya_lain_lain', 'biaya_atk_perlengkapan', 'ATK & Perlengkapan', 'expense', 1),
('hpp', 'hpp_nusantara', 'Nusantara', 'raw_material', 2), ('biaya_lain_lain', 'biaya_makan_karyawan', 'Makan Karyawan', 'expense', 2),
('hpp', 'hpp_ramen', 'Ramen', 'raw_material', 3), ('biaya_lain_lain', 'biaya_bensin_parkir', 'Bensin & Parkir', 'expense', 3),
('hpp', 'hpp_minuman_kopi', 'Minuman/Kopi', 'raw_material', 4), ('biaya_lain_lain', 'biaya_kebersihan_keamanan', 'Kebersihan & Keamanan', 'expense', 4),
('biaya_lain_lain', 'biaya_atk_perlengkapan', 'ATK & Perlengkapan', 'non_inventory', 1), ('biaya_lain_lain', 'biaya_gaji_dw', 'Gaji DW', 'expense', 5),
('biaya_lain_lain', 'biaya_makan_karyawan', 'Makan Karyawan', 'non_inventory', 2), ('biaya_lain_lain', 'biaya_gaji_staff', 'Gaji Staff', 'expense', 6),
('biaya_lain_lain', 'biaya_bensin_parkir', 'Bensin & Parkir', 'non_inventory', 3), ('biaya_lain_lain', 'biaya_internet_server', 'Internet & Server', 'expense', 7),
('biaya_lain_lain', 'biaya_kebersihan_keamanan', 'Kebersihan & Keamanan', 'non_inventory', 4), ('biaya_lain_lain', 'biaya_air_listrik', 'Air & Listrik', 'expense', 8),
('biaya_lain_lain', 'biaya_gaji_dw', 'Gaji DW', 'non_inventory', 5), ('biaya_lain_lain', 'biaya_promosi', 'Promosi', 'expense', 9)
('biaya_lain_lain', 'biaya_gaji_staff', 'Gaji Staff', 'non_inventory', 6),
('biaya_lain_lain', 'biaya_internet_server', 'Internet & Server', 'non_inventory', 7),
('biaya_lain_lain', 'biaya_air_listrik', 'Air & Listrik', 'non_inventory', 8),
('biaya_lain_lain', 'biaya_promosi', 'Promosi', 'non_inventory', 9)
) AS child(parent_code, code, name, type, sort_order) ON parent.code = child.parent_code ) AS child(parent_code, code, name, type, sort_order) ON parent.code = child.parent_code
ON CONFLICT (code) DO NOTHING; ON CONFLICT (code) DO NOTHING;
@@ -0,0 +1,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;
@@ -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);
@@ -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;
@@ -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);
@@ -0,0 +1,32 @@
ALTER TABLE purchase_category_presets DROP CONSTRAINT IF EXISTS purchase_category_presets_type_check;
ALTER TABLE purchase_categories DROP CONSTRAINT IF EXISTS purchase_categories_type_check;
UPDATE purchase_category_presets
SET type = 'non_inventory'
WHERE type = 'expense';
UPDATE purchase_categories
SET type = 'non_inventory'
WHERE type = 'expense';
UPDATE purchase_category_presets
SET code = 'hpp', name = 'HPP'
WHERE code = 'bahan_baku' AND type = 'raw_material';
UPDATE purchase_categories
SET code = 'hpp', name = 'HPP'
WHERE code = 'bahan_baku' AND type = 'raw_material';
UPDATE purchase_category_presets
SET is_active = true
WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material';
UPDATE purchase_categories
SET is_active = true
WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material';
ALTER TABLE purchase_category_presets
ADD CONSTRAINT purchase_category_presets_type_check CHECK (type IN ('raw_material', 'non_inventory'));
ALTER TABLE purchase_categories
ADD CONSTRAINT purchase_categories_type_check CHECK (type IN ('raw_material', 'non_inventory'));
@@ -0,0 +1,36 @@
ALTER TABLE purchase_category_presets DROP CONSTRAINT IF EXISTS purchase_category_presets_type_check;
ALTER TABLE purchase_categories DROP CONSTRAINT IF EXISTS purchase_categories_type_check;
UPDATE purchase_category_presets
SET type = 'expense'
WHERE type = 'non_inventory';
UPDATE purchase_categories
SET type = 'expense'
WHERE type = 'non_inventory';
UPDATE purchase_category_presets
SET code = 'bahan_baku', name = 'Bahan Baku'
WHERE code = 'hpp' AND type = 'raw_material';
UPDATE purchase_categories
SET code = 'bahan_baku', name = 'Bahan Baku'
WHERE code = 'hpp' AND type = 'raw_material';
UPDATE purchase_category_presets
SET is_active = false
WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material';
UPDATE purchase_categories
SET is_active = false
WHERE code LIKE 'hpp\_%' ESCAPE '\' AND type = 'raw_material';
ALTER TABLE purchase_category_presets
ADD CONSTRAINT purchase_category_presets_type_check CHECK (type IN ('raw_material', 'expense'));
ALTER TABLE purchase_categories
ADD CONSTRAINT purchase_categories_type_check CHECK (type IN ('raw_material', 'expense'));
ALTER TABLE purchase_order_items ALTER COLUMN ingredient_id DROP NOT NULL;
ALTER TABLE purchase_order_items ALTER COLUMN quantity DROP NOT NULL;
ALTER TABLE purchase_order_items ALTER COLUMN unit_id DROP NOT NULL;