Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84222fc7f4 | ||
|
|
23ac572e3f | ||
|
|
957c1ae53d | ||
|
|
d0378b5ac4 | ||
|
|
91960f0e57 | ||
|
|
72f67cb519 | ||
|
|
35c4cf2f2f | ||
|
|
c9ef90f5ea | ||
|
|
35e7152abb | ||
|
|
6d735c20cb | ||
|
|
9c143a43aa | ||
|
|
cad4e6c816 | ||
|
|
30dff17272 | ||
|
|
f8c732f0ff | ||
|
|
e92c487815 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# 1) Build stage
|
# 1) Build stage
|
||||||
FROM golang:1.21-alpine AS build
|
FROM golang:1.24-alpine AS build
|
||||||
RUN apk --no-cache add ca-certificates tzdata git curl
|
RUN apk --no-cache add ca-certificates tzdata git curl
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
|||||||
@@ -83,6 +83,63 @@ type SalesAnalyticsData struct {
|
|||||||
NetSales float64 `json:"net_sales"`
|
NetSales float64 `json:"net_sales"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PurchasingAnalyticsRequest struct {
|
||||||
|
OrganizationID uuid.UUID
|
||||||
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
|
DateFrom string `form:"date_from" validate:"required"`
|
||||||
|
DateTo string `form:"date_to" validate:"required"`
|
||||||
|
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchasingAnalyticsResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
GroupBy string `json:"group_by"`
|
||||||
|
Summary PurchasingSummary `json:"summary"`
|
||||||
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
|
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchasingSummary struct {
|
||||||
|
TotalPurchases float64 `json:"total_purchases"`
|
||||||
|
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
|
||||||
|
TotalQuantity float64 `json:"total_quantity"`
|
||||||
|
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||||
|
TotalIngredients int64 `json:"total_ingredients"`
|
||||||
|
TotalVendors int64 `json:"total_vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchasingAnalyticsData struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Purchases float64 `json:"purchases"`
|
||||||
|
PurchaseOrders int64 `json:"purchase_orders"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Ingredients int64 `json:"ingredients"`
|
||||||
|
Vendors int64 `json:"vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchasingIngredientData struct {
|
||||||
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
|
IngredientName string `json:"ingredient_name"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
AverageUnitCost float64 `json:"average_unit_cost"`
|
||||||
|
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchasingVendorData struct {
|
||||||
|
VendorID uuid.UUID `json:"vendor_id"`
|
||||||
|
VendorName string `json:"vendor_name"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||||
|
IngredientCount int64 `json:"ingredient_count"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
// ProductAnalyticsRequest represents the request for product analytics
|
// ProductAnalyticsRequest represents the request for product analytics
|
||||||
type ProductAnalyticsRequest struct {
|
type ProductAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type CreateCategoryRequest struct {
|
|||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
BusinessType *string `json:"business_type,omitempty"`
|
BusinessType *string `json:"business_type,omitempty"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
Order *int `json:"order,omitempty"`
|
Order *int `json:"order,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -18,12 +19,14 @@ type UpdateCategoryRequest struct {
|
|||||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
BusinessType *string `json:"business_type,omitempty"`
|
BusinessType *string `json:"business_type,omitempty"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
Order *int `json:"order,omitempty"`
|
Order *int `json:"order,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListCategoriesRequest struct {
|
type ListCategoriesRequest struct {
|
||||||
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
BusinessType string `json:"business_type,omitempty"`
|
BusinessType string `json:"business_type,omitempty"`
|
||||||
Search string `json:"search,omitempty"`
|
Search string `json:"search,omitempty"`
|
||||||
Page int `json:"page" validate:"required,min=1"`
|
Page int `json:"page" validate:"required,min=1"`
|
||||||
@@ -34,6 +37,7 @@ type ListCategoriesRequest struct {
|
|||||||
type CategoryResponse struct {
|
type CategoryResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
BusinessType string `json:"business_type"`
|
BusinessType string `json:"business_type"`
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ type OrderItemResponse struct {
|
|||||||
ProductName string `json:"product_name"`
|
ProductName string `json:"product_name"`
|
||||||
ProductVariantID *uuid.UUID `json:"product_variant_id"`
|
ProductVariantID *uuid.UUID `json:"product_variant_id"`
|
||||||
ProductVariantName *string `json:"product_variant_name,omitempty"`
|
ProductVariantName *string `json:"product_variant_name,omitempty"`
|
||||||
|
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||||
|
CategoryName *string `json:"category_name,omitempty"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
UnitPrice float64 `json:"unit_price"`
|
UnitPrice float64 `json:"unit_price"`
|
||||||
TotalPrice float64 `json:"total_price"`
|
TotalPrice float64 `json:"total_price"`
|
||||||
@@ -108,6 +110,7 @@ type OrderItemResponse struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
PrinterType string `json:"printer_type"`
|
PrinterType string `json:"printer_type"`
|
||||||
|
PrintToChecker bool `json:"print_to_checker"`
|
||||||
PaidQuantity int `json:"paid_quantity"`
|
PaidQuantity int `json:"paid_quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
type CreateProductRequest struct {
|
type CreateProductRequest struct {
|
||||||
CategoryID uuid.UUID `json:"category_id" validate:"required"`
|
CategoryID uuid.UUID `json:"category_id" validate:"required"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
SKU *string `json:"sku,omitempty"`
|
SKU *string `json:"sku,omitempty"`
|
||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
@@ -16,15 +17,17 @@ type CreateProductRequest struct {
|
|||||||
BusinessType *string `json:"business_type,omitempty"`
|
BusinessType *string `json:"business_type,omitempty"`
|
||||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||||
|
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
|
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
|
||||||
InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"` // Initial stock quantity for all outlets
|
InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"`
|
||||||
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Reorder level for all outlets
|
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
|
||||||
CreateInventory bool `json:"create_inventory,omitempty"` // Whether to create inventory records for all outlets
|
CreateInventory bool `json:"create_inventory,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProductRequest struct {
|
type UpdateProductRequest struct {
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||||
SKU *string `json:"sku,omitempty"`
|
SKU *string `json:"sku,omitempty"`
|
||||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||||
@@ -34,10 +37,10 @@ type UpdateProductRequest struct {
|
|||||||
BusinessType *string `json:"business_type,omitempty"`
|
BusinessType *string `json:"business_type,omitempty"`
|
||||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||||
|
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
// Stock management fields
|
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
|
||||||
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Update reorder level for all existing inventory records
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateProductVariantRequest struct {
|
type CreateProductVariantRequest struct {
|
||||||
@@ -70,6 +73,7 @@ type ProductResponse struct {
|
|||||||
BusinessType string `json:"business_type"`
|
BusinessType string `json:"business_type"`
|
||||||
ImageURL *string `json:"image_url"`
|
ImageURL *string `json:"image_url"`
|
||||||
PrinterType string `json:"printer_type"`
|
PrinterType string `json:"printer_type"`
|
||||||
|
PrintToChecker bool `json:"print_to_checker"`
|
||||||
Metadata map[string]interface{} `json:"metadata"`
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ type CreateProductOutletPriceRequest struct {
|
|||||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||||
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
||||||
Price float64 `json:"price" validate:"required,min=0"`
|
Price float64 `json:"price" validate:"required,min=0"`
|
||||||
|
PrintToChecker bool `json:"print_to_checker"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProductOutletPriceRequest struct {
|
type UpdateProductOutletPriceRequest struct {
|
||||||
Price float64 `json:"price" validate:"required,min=0"`
|
Price float64 `json:"price" validate:"required,min=0"`
|
||||||
|
PrintToChecker *bool `json:"print_to_checker"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProductOutletPriceResponse struct {
|
type ProductOutletPriceResponse struct {
|
||||||
@@ -22,6 +24,7 @@ type ProductOutletPriceResponse struct {
|
|||||||
OutletID uuid.UUID `json:"outlet_id"`
|
OutletID uuid.UUID `json:"outlet_id"`
|
||||||
OutletName string `json:"outlet_name,omitempty"`
|
OutletName string `json:"outlet_name,omitempty"`
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
|
PrintToChecker bool `json:"print_to_checker"`
|
||||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -39,4 +42,5 @@ type BulkCreateProductOutletPriceRequest struct {
|
|||||||
type CreateProductOutletPricePerOutletRequest struct {
|
type CreateProductOutletPricePerOutletRequest struct {
|
||||||
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
||||||
Price float64 `json:"price" validate:"required,min=0"`
|
Price float64 `json:"price" validate:"required,min=0"`
|
||||||
|
PrintToChecker bool `json:"print_to_checker"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,51 @@ type SalesAnalytics struct {
|
|||||||
NetSales float64 `json:"net_sales"`
|
NetSales float64 `json:"net_sales"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PurchasingAnalytics represents purchasing analytics data
|
||||||
|
type PurchasingAnalytics struct {
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
Summary PurchasingSummary `json:"summary"`
|
||||||
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
|
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchasingSummary struct {
|
||||||
|
TotalPurchases float64 `json:"total_purchases"`
|
||||||
|
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
|
||||||
|
TotalQuantity float64 `json:"total_quantity"`
|
||||||
|
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||||
|
TotalIngredients int64 `json:"total_ingredients"`
|
||||||
|
TotalVendors int64 `json:"total_vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchasingAnalyticsData struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Purchases float64 `json:"purchases"`
|
||||||
|
PurchaseOrders int64 `json:"purchase_orders"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Ingredients int64 `json:"ingredients"`
|
||||||
|
Vendors int64 `json:"vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchasingIngredientData struct {
|
||||||
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
|
IngredientName string `json:"ingredient_name"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
AverageUnitCost float64 `json:"average_unit_cost"`
|
||||||
|
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchasingVendorData struct {
|
||||||
|
VendorID uuid.UUID `json:"vendor_id"`
|
||||||
|
VendorName string `json:"vendor_name"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||||
|
IngredientCount int64 `json:"ingredient_count"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProductAnalytics struct {
|
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"`
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func (m *Metadata) Scan(value interface{}) error {
|
|||||||
type Category struct {
|
type Category 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;index" json:"outlet_id"`
|
||||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||||
Description *string `gorm:"type:text" json:"description"`
|
Description *string `gorm:"type:text" json:"description"`
|
||||||
Order int `gorm:"default:0" json:"order"`
|
Order int `gorm:"default:0" json:"order"`
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Product struct {
|
|||||||
ProductRecipes []ProductRecipe `gorm:"foreignKey:ProductID" json:"product_recipes,omitempty"`
|
ProductRecipes []ProductRecipe `gorm:"foreignKey:ProductID" json:"product_recipes,omitempty"`
|
||||||
Inventory []Inventory `gorm:"foreignKey:ProductID" json:"inventory,omitempty"`
|
Inventory []Inventory `gorm:"foreignKey:ProductID" json:"inventory,omitempty"`
|
||||||
OrderItems []OrderItem `gorm:"foreignKey:ProductID" json:"order_items,omitempty"`
|
OrderItems []OrderItem `gorm:"foreignKey:ProductID" json:"order_items,omitempty"`
|
||||||
|
ProductOutletPrices []ProductOutletPrice `gorm:"foreignKey:ProductID" json:"product_outlet_prices,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Product) BeforeCreate(tx *gorm.DB) error {
|
func (p *Product) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type ProductOutletPrice struct {
|
|||||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"`
|
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"`
|
||||||
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
|
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
|
||||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"`
|
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"`
|
||||||
|
PrintToChecker bool `gorm:"not null;default:true" json:"print_to_checker"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,30 @@ func (h *AnalyticsHandler) GetSalesAnalytics(c *gin.Context) {
|
|||||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetSalesAnalytics")
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetSalesAnalytics")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AnalyticsHandler) GetPurchasingAnalytics(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|
||||||
|
var req contract.PurchasingAnalyticsRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetPurchasingAnalytics", err.Error())}), "AnalyticsHandler::GetPurchasingAnalytics")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.OrganizationID = contextInfo.OrganizationID
|
||||||
|
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||||
|
modelReq := transformer.PurchasingAnalyticsContractToModel(&req)
|
||||||
|
|
||||||
|
response, err := h.analyticsService.GetPurchasingAnalytics(ctx, modelReq)
|
||||||
|
if err != nil {
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetPurchasingAnalytics", err.Error())}), "AnalyticsHandler::GetPurchasingAnalytics")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contractResp := transformer.PurchasingAnalyticsModelToContract(response)
|
||||||
|
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetPurchasingAnalytics")
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) {
|
func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
contextInfo := appcontext.FromGinContext(ctx)
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ func (h *CategoryHandler) CreateCategory(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject outlet_id from context if user has one and request doesn't provide it
|
||||||
|
if req.OutletID == nil && contextInfo.OutletID != uuid.Nil {
|
||||||
|
req.OutletID = &contextInfo.OutletID
|
||||||
|
}
|
||||||
|
|
||||||
validationError, validationErrorCode := h.categoryValidator.ValidateCreateCategoryRequest(&req)
|
validationError, validationErrorCode := h.categoryValidator.ValidateCreateCategoryRequest(&req)
|
||||||
if validationError != nil {
|
if validationError != nil {
|
||||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||||
@@ -149,6 +154,11 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
|
|||||||
OrganizationID: &contextInfo.OrganizationID,
|
OrganizationID: &contextInfo.OrganizationID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject outlet_id from context if user has one
|
||||||
|
if contextInfo.OutletID != uuid.Nil {
|
||||||
|
req.OutletID = &contextInfo.OutletID
|
||||||
|
}
|
||||||
|
|
||||||
// Parse query parameters
|
// Parse query parameters
|
||||||
if pageStr := c.Query("page"); pageStr != "" {
|
if pageStr := c.Query("page"); pageStr != "" {
|
||||||
if page, err := strconv.Atoi(pageStr); err == nil {
|
if page, err := strconv.Atoi(pageStr); err == nil {
|
||||||
@@ -176,6 +186,11 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
|
||||||
|
if outletID, err := uuid.Parse(outletIDStr); err == nil {
|
||||||
|
req.OutletID = &outletID
|
||||||
|
}
|
||||||
|
}
|
||||||
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
|
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
|
||||||
if validationError != nil {
|
if validationError != nil {
|
||||||
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
|
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ func (h *ProductHandler) CreateProduct(c *gin.Context) {
|
|||||||
|
|
||||||
func (h *ProductHandler) UpdateProduct(c *gin.Context) {
|
func (h *ProductHandler) UpdateProduct(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|
||||||
productIDStr := c.Param("id")
|
productIDStr := c.Param("id")
|
||||||
productID, err := uuid.Parse(productIDStr)
|
productID, err := uuid.Parse(productIDStr)
|
||||||
@@ -85,7 +86,7 @@ func (h *ProductHandler) UpdateProduct(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
productResponse := h.productService.UpdateProduct(ctx, productID, &req)
|
productResponse := h.productService.UpdateProduct(ctx, contextInfo, productID, &req)
|
||||||
if productResponse.HasErrors() {
|
if productResponse.HasErrors() {
|
||||||
errorResp := productResponse.GetErrors()[0]
|
errorResp := productResponse.GetErrors()[0]
|
||||||
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::UpdateProduct -> Failed to update product from service")
|
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::UpdateProduct -> Failed to update product from service")
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ func CategoryEntityToModel(entity *entities.Category) *models.Category {
|
|||||||
return &models.Category{
|
return &models.Category{
|
||||||
ID: entity.ID,
|
ID: entity.ID,
|
||||||
OrganizationID: entity.OrganizationID,
|
OrganizationID: entity.OrganizationID,
|
||||||
|
OutletID: entity.OutletID,
|
||||||
Name: entity.Name,
|
Name: entity.Name,
|
||||||
Description: entity.Description,
|
Description: entity.Description,
|
||||||
ImageURL: nil, // Entity doesn't have ImageURL, model does
|
ImageURL: nil,
|
||||||
Order: entity.Order, // Entity doesn't have SortOrder, model does
|
Order: entity.Order,
|
||||||
IsActive: true, // Entity doesn't have IsActive, default to true
|
IsActive: true,
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
UpdatedAt: entity.UpdatedAt,
|
UpdatedAt: entity.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -32,14 +33,14 @@ func CategoryModelToEntity(model *models.Category) *entities.Category {
|
|||||||
if model.ImageURL != nil {
|
if model.ImageURL != nil {
|
||||||
metadata["image_url"] = *model.ImageURL
|
metadata["image_url"] = *model.ImageURL
|
||||||
}
|
}
|
||||||
// metadata["sort_order"] = model.SortOrder
|
|
||||||
|
|
||||||
return &entities.Category{
|
return &entities.Category{
|
||||||
ID: model.ID,
|
ID: model.ID,
|
||||||
OrganizationID: model.OrganizationID,
|
OrganizationID: model.OrganizationID,
|
||||||
|
OutletID: model.OutletID,
|
||||||
Name: model.Name,
|
Name: model.Name,
|
||||||
Description: model.Description,
|
Description: model.Description,
|
||||||
BusinessType: "restaurant", // Default business type
|
BusinessType: "restaurant",
|
||||||
Order: model.Order,
|
Order: model.Order,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
CreatedAt: model.CreatedAt,
|
CreatedAt: model.CreatedAt,
|
||||||
@@ -56,14 +57,14 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
|
|||||||
if req.ImageURL != nil {
|
if req.ImageURL != nil {
|
||||||
metadata["image_url"] = *req.ImageURL
|
metadata["image_url"] = *req.ImageURL
|
||||||
}
|
}
|
||||||
// metadata["sort_order"] = req.SortOrder
|
|
||||||
|
|
||||||
return &entities.Category{
|
return &entities.Category{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Order: req.Order,
|
Order: req.Order,
|
||||||
BusinessType: "restaurant", // Default business type
|
BusinessType: "restaurant",
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,11 +88,12 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
|
|||||||
return &models.CategoryResponse{
|
return &models.CategoryResponse{
|
||||||
ID: entity.ID,
|
ID: entity.ID,
|
||||||
OrganizationID: entity.OrganizationID,
|
OrganizationID: entity.OrganizationID,
|
||||||
|
OutletID: entity.OutletID,
|
||||||
Name: entity.Name,
|
Name: entity.Name,
|
||||||
Description: entity.Description,
|
Description: entity.Description,
|
||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
Order: entity.Order,
|
Order: entity.Order,
|
||||||
IsActive: true, // Default to true since entity doesn't have this field
|
IsActive: true,
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
UpdatedAt: entity.UpdatedAt,
|
UpdatedAt: entity.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -121,6 +123,10 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
|
|||||||
if req.Order != nil {
|
if req.Order != nil {
|
||||||
entity.Order = *req.Order
|
entity.Order = *req.Order
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.OutletID != nil {
|
||||||
|
entity.OutletID = req.OutletID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {
|
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func OrderEntityToResponse(order *entities.Order) *models.OrderResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, item := range order.OrderItems {
|
for i, item := range order.OrderItems {
|
||||||
resp := OrderItemEntityToResponse(&item)
|
resp := OrderItemEntityToResponse(&item, order.OutletID)
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
resp.PaidQuantity = paidQtyByOrderItem[item.ID]
|
resp.PaidQuantity = paidQtyByOrderItem[item.ID]
|
||||||
response.OrderItems[i] = *resp
|
response.OrderItems[i] = *resp
|
||||||
@@ -101,11 +101,20 @@ func OrderEntityToResponse(order *entities.Order) *models.OrderResponse {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
func OrderItemEntityToResponse(item *entities.OrderItem) *models.OrderItemResponse {
|
func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *models.OrderItemResponse {
|
||||||
if item == nil {
|
if item == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve print_to_checker from preloaded outlet prices
|
||||||
|
printToChecker := true // default
|
||||||
|
for _, op := range item.Product.ProductOutletPrices {
|
||||||
|
if op.OutletID == outletID {
|
||||||
|
printToChecker = op.PrintToChecker
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response := &models.OrderItemResponse{
|
response := &models.OrderItemResponse{
|
||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
OrderID: item.OrderID,
|
OrderID: item.OrderID,
|
||||||
@@ -130,10 +139,19 @@ func OrderItemEntityToResponse(item *entities.OrderItem) *models.OrderItemRespon
|
|||||||
CreatedAt: item.CreatedAt,
|
CreatedAt: item.CreatedAt,
|
||||||
UpdatedAt: item.UpdatedAt,
|
UpdatedAt: item.UpdatedAt,
|
||||||
PrinterType: item.Product.PrinterType,
|
PrinterType: item.Product.PrinterType,
|
||||||
|
PrintToChecker: printToChecker,
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.Product.ID != uuid.Nil {
|
if item.Product.ID != uuid.Nil {
|
||||||
response.ProductName = item.Product.Name
|
response.ProductName = item.Product.Name
|
||||||
|
if item.Product.CategoryID != uuid.Nil {
|
||||||
|
categoryID := item.Product.CategoryID
|
||||||
|
response.CategoryID = &categoryID
|
||||||
|
}
|
||||||
|
if item.Product.Category.ID != uuid.Nil {
|
||||||
|
categoryName := item.Product.Category.Name
|
||||||
|
response.CategoryName = &categoryName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.ProductVariant != nil {
|
if item.ProductVariant != nil {
|
||||||
@@ -316,14 +334,14 @@ func OrderEntitiesToResponses(orders []*entities.Order) []models.OrderResponse {
|
|||||||
return responses
|
return responses
|
||||||
}
|
}
|
||||||
|
|
||||||
func OrderItemEntitiesToResponses(items []*entities.OrderItem) []models.OrderItemResponse {
|
func OrderItemEntitiesToResponses(items []*entities.OrderItem, outletID uuid.UUID) []models.OrderItemResponse {
|
||||||
if items == nil {
|
if items == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
responses := make([]models.OrderItemResponse, len(items))
|
responses := make([]models.OrderItemResponse, len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
response := OrderItemEntityToResponse(item)
|
response := OrderItemEntityToResponse(item, outletID)
|
||||||
if response != nil {
|
if response != nil {
|
||||||
responses[i] = *response
|
responses[i] = *response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestOrderItemEntityToResponse_WithProductNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
result := OrderItemEntityToResponse(orderItem)
|
result := OrderItemEntityToResponse(orderItem, uuid.Nil)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assert.NotNil(t, result)
|
assert.NotNil(t, result)
|
||||||
@@ -89,7 +89,7 @@ func TestOrderItemEntityToResponse_WithoutProductVariant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
result := OrderItemEntityToResponse(orderItem)
|
result := OrderItemEntityToResponse(orderItem, uuid.Nil)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assert.NotNil(t, result)
|
assert.NotNil(t, result)
|
||||||
@@ -129,7 +129,7 @@ func TestOrderItemEntityToResponse_WithoutProductPreload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
result := OrderItemEntityToResponse(orderItem)
|
result := OrderItemEntityToResponse(orderItem, uuid.Nil)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assert.NotNil(t, result)
|
assert.NotNil(t, result)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func ProductOutletPriceEntityToModel(entity *entities.ProductOutletPrice) *model
|
|||||||
ProductID: entity.ProductID,
|
ProductID: entity.ProductID,
|
||||||
OutletID: entity.OutletID,
|
OutletID: entity.OutletID,
|
||||||
Price: entity.Price,
|
Price: entity.Price,
|
||||||
|
PrintToChecker: entity.PrintToChecker,
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
UpdatedAt: entity.UpdatedAt,
|
UpdatedAt: entity.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ func ProductOutletPriceModelToEntity(model *models.ProductOutletPrice) *entities
|
|||||||
ProductID: model.ProductID,
|
ProductID: model.ProductID,
|
||||||
OutletID: model.OutletID,
|
OutletID: model.OutletID,
|
||||||
Price: model.Price,
|
Price: model.Price,
|
||||||
|
PrintToChecker: model.PrintToChecker,
|
||||||
CreatedAt: model.CreatedAt,
|
CreatedAt: model.CreatedAt,
|
||||||
UpdatedAt: model.UpdatedAt,
|
UpdatedAt: model.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,69 @@ type SalesAnalyticsData struct {
|
|||||||
NetSales float64 `json:"net_sales"`
|
NetSales float64 `json:"net_sales"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PurchasingAnalyticsRequest represents the request for purchasing analytics
|
||||||
|
type PurchasingAnalyticsRequest struct {
|
||||||
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
|
DateFrom time.Time `validate:"required"`
|
||||||
|
DateTo time.Time `validate:"required"`
|
||||||
|
GroupBy string `validate:"omitempty,oneof=day hour week month"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchasingAnalyticsResponse represents the response for purchasing analytics
|
||||||
|
type PurchasingAnalyticsResponse struct {
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
DateFrom time.Time `json:"date_from"`
|
||||||
|
DateTo time.Time `json:"date_to"`
|
||||||
|
GroupBy string `json:"group_by"`
|
||||||
|
Summary PurchasingSummary `json:"summary"`
|
||||||
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
|
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchasingSummary represents the summary of purchasing analytics
|
||||||
|
type PurchasingSummary struct {
|
||||||
|
TotalPurchases float64 `json:"total_purchases"`
|
||||||
|
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
|
||||||
|
TotalQuantity float64 `json:"total_quantity"`
|
||||||
|
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||||
|
TotalIngredients int64 `json:"total_ingredients"`
|
||||||
|
TotalVendors int64 `json:"total_vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchasingAnalyticsData represents purchasing analytics by time period
|
||||||
|
type PurchasingAnalyticsData struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Purchases float64 `json:"purchases"`
|
||||||
|
PurchaseOrders int64 `json:"purchase_orders"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Ingredients int64 `json:"ingredients"`
|
||||||
|
Vendors int64 `json:"vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchasingIngredientData represents purchasing analytics for an ingredient
|
||||||
|
type PurchasingIngredientData struct {
|
||||||
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
|
IngredientName string `json:"ingredient_name"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
AverageUnitCost float64 `json:"average_unit_cost"`
|
||||||
|
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchasingVendorData represents purchasing analytics for a vendor
|
||||||
|
type PurchasingVendorData struct {
|
||||||
|
VendorID uuid.UUID `json:"vendor_id"`
|
||||||
|
VendorName string `json:"vendor_name"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||||
|
IngredientCount int64 `json:"ingredient_count"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
// ProductAnalyticsRequest represents the request for product analytics
|
// ProductAnalyticsRequest represents the request for product analytics
|
||||||
type ProductAnalyticsRequest struct {
|
type ProductAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID `validate:"required"`
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
type Category struct {
|
type Category struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
|
OutletID *uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
Description *string
|
Description *string
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
@@ -20,6 +21,7 @@ type Category struct {
|
|||||||
|
|
||||||
type CreateCategoryRequest struct {
|
type CreateCategoryRequest struct {
|
||||||
OrganizationID uuid.UUID `validate:"required"`
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
|
OutletID *uuid.UUID
|
||||||
Name string `validate:"required,min=1,max=255"`
|
Name string `validate:"required,min=1,max=255"`
|
||||||
Description *string `validate:"omitempty,max=1000"`
|
Description *string `validate:"omitempty,max=1000"`
|
||||||
ImageURL *string `validate:"omitempty,url"`
|
ImageURL *string `validate:"omitempty,url"`
|
||||||
@@ -30,6 +32,7 @@ type UpdateCategoryRequest struct {
|
|||||||
Name *string `validate:"omitempty,min=1,max=255"`
|
Name *string `validate:"omitempty,min=1,max=255"`
|
||||||
Description *string `validate:"omitempty,max=1000"`
|
Description *string `validate:"omitempty,max=1000"`
|
||||||
ImageURL *string `validate:"omitempty,url"`
|
ImageURL *string `validate:"omitempty,url"`
|
||||||
|
OutletID *uuid.UUID
|
||||||
Order *int `validate:"omitempty,min=0"`
|
Order *int `validate:"omitempty,min=0"`
|
||||||
IsActive *bool
|
IsActive *bool
|
||||||
}
|
}
|
||||||
@@ -37,6 +40,7 @@ type UpdateCategoryRequest struct {
|
|||||||
type CategoryResponse struct {
|
type CategoryResponse struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
|
OutletID *uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
Description *string
|
Description *string
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ type OrderItemResponse struct {
|
|||||||
ProductName string
|
ProductName string
|
||||||
ProductVariantID *uuid.UUID
|
ProductVariantID *uuid.UUID
|
||||||
ProductVariantName *string
|
ProductVariantName *string
|
||||||
|
CategoryID *uuid.UUID
|
||||||
|
CategoryName *string
|
||||||
Quantity int
|
Quantity int
|
||||||
UnitPrice float64
|
UnitPrice float64
|
||||||
TotalPrice float64
|
TotalPrice float64
|
||||||
@@ -207,6 +209,7 @@ type OrderItemResponse struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
PrinterType string
|
PrinterType string
|
||||||
|
PrintToChecker bool
|
||||||
PaidQuantity int
|
PaidQuantity int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type ProductVariant struct {
|
|||||||
|
|
||||||
type CreateProductRequest struct {
|
type CreateProductRequest struct {
|
||||||
OrganizationID uuid.UUID `validate:"required"`
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
|
OutletID uuid.UUID `validate:"omitempty"` // If set, upsert product_outlet_prices on create
|
||||||
CategoryID uuid.UUID `validate:"required"`
|
CategoryID uuid.UUID `validate:"required"`
|
||||||
SKU *string `validate:"omitempty,max=100"`
|
SKU *string `validate:"omitempty,max=100"`
|
||||||
Name string `validate:"required,min=1,max=255"`
|
Name string `validate:"required,min=1,max=255"`
|
||||||
@@ -49,6 +50,7 @@ type CreateProductRequest struct {
|
|||||||
BusinessType constants.BusinessType `validate:"required"`
|
BusinessType constants.BusinessType `validate:"required"`
|
||||||
ImageURL *string `validate:"omitempty,max=500"`
|
ImageURL *string `validate:"omitempty,max=500"`
|
||||||
PrinterType *string `validate:"omitempty,max=50"`
|
PrinterType *string `validate:"omitempty,max=50"`
|
||||||
|
PrintToChecker *bool `validate:"omitempty"`
|
||||||
UnitID *uuid.UUID `validate:"omitempty"`
|
UnitID *uuid.UUID `validate:"omitempty"`
|
||||||
HasIngredients bool `validate:"omitempty"`
|
HasIngredients bool `validate:"omitempty"`
|
||||||
Metadata map[string]interface{}
|
Metadata map[string]interface{}
|
||||||
@@ -60,6 +62,7 @@ type CreateProductRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProductRequest struct {
|
type UpdateProductRequest struct {
|
||||||
|
OutletID uuid.UUID `validate:"omitempty"` // If set, upsert product_outlet_prices on update
|
||||||
CategoryID *uuid.UUID `validate:"omitempty"`
|
CategoryID *uuid.UUID `validate:"omitempty"`
|
||||||
SKU *string `validate:"omitempty,max=100"`
|
SKU *string `validate:"omitempty,max=100"`
|
||||||
Name *string `validate:"omitempty,min=1,max=255"`
|
Name *string `validate:"omitempty,min=1,max=255"`
|
||||||
@@ -68,6 +71,7 @@ type UpdateProductRequest struct {
|
|||||||
Cost *float64 `validate:"omitempty,min=0"`
|
Cost *float64 `validate:"omitempty,min=0"`
|
||||||
ImageURL *string `validate:"omitempty,max=500"`
|
ImageURL *string `validate:"omitempty,max=500"`
|
||||||
PrinterType *string `validate:"omitempty,max=50"`
|
PrinterType *string `validate:"omitempty,max=50"`
|
||||||
|
PrintToChecker *bool `validate:"omitempty"`
|
||||||
UnitID *uuid.UUID `validate:"omitempty"`
|
UnitID *uuid.UUID `validate:"omitempty"`
|
||||||
HasIngredients *bool `validate:"omitempty"`
|
HasIngredients *bool `validate:"omitempty"`
|
||||||
Metadata map[string]interface{}
|
Metadata map[string]interface{}
|
||||||
@@ -106,6 +110,7 @@ type ProductResponse struct {
|
|||||||
BusinessType constants.BusinessType
|
BusinessType constants.BusinessType
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
PrinterType string
|
PrinterType string
|
||||||
|
PrintToChecker bool
|
||||||
UnitID *uuid.UUID
|
UnitID *uuid.UUID
|
||||||
HasIngredients bool
|
HasIngredients bool
|
||||||
Metadata map[string]interface{}
|
Metadata map[string]interface{}
|
||||||
@@ -119,6 +124,7 @@ type OutletPrice struct {
|
|||||||
OutletID uuid.UUID
|
OutletID uuid.UUID
|
||||||
OutletName string
|
OutletName string
|
||||||
Price float64
|
Price float64
|
||||||
|
PrintToChecker bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProductVariantResponse struct {
|
type ProductVariantResponse struct {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type ProductOutletPrice struct {
|
|||||||
ProductID uuid.UUID
|
ProductID uuid.UUID
|
||||||
OutletID uuid.UUID
|
OutletID uuid.UUID
|
||||||
Price float64
|
Price float64
|
||||||
|
PrintToChecker bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -19,10 +20,12 @@ type CreateProductOutletPriceRequest struct {
|
|||||||
ProductID uuid.UUID `validate:"required"`
|
ProductID uuid.UUID `validate:"required"`
|
||||||
OutletID uuid.UUID `validate:"required"`
|
OutletID uuid.UUID `validate:"required"`
|
||||||
Price float64 `validate:"required,min=0"`
|
Price float64 `validate:"required,min=0"`
|
||||||
|
PrintToChecker bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProductOutletPriceRequest struct {
|
type UpdateProductOutletPriceRequest struct {
|
||||||
Price *float64 `validate:"required,min=0"`
|
Price *float64 `validate:"required,min=0"`
|
||||||
|
PrintToChecker *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProductOutletPriceResponse struct {
|
type ProductOutletPriceResponse struct {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
type AnalyticsProcessor interface {
|
type AnalyticsProcessor interface {
|
||||||
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error)
|
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error)
|
||||||
GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error)
|
GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error)
|
||||||
|
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
|
||||||
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
||||||
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
||||||
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
||||||
@@ -164,6 +165,77 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) {
|
||||||
|
if req.DateFrom.After(req.DateTo) {
|
||||||
|
return nil, fmt.Errorf("date_from cannot be after date_to")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.GroupBy == "" {
|
||||||
|
req.GroupBy = "day"
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]models.PurchasingAnalyticsData, len(result.Data))
|
||||||
|
for i, item := range result.Data {
|
||||||
|
data[i] = models.PurchasingAnalyticsData{
|
||||||
|
Date: item.Date,
|
||||||
|
Purchases: item.Purchases,
|
||||||
|
PurchaseOrders: item.PurchaseOrders,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Ingredients: item.Ingredients,
|
||||||
|
Vendors: item.Vendors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData))
|
||||||
|
for i, item := range result.IngredientData {
|
||||||
|
ingredientData[i] = models.PurchasingIngredientData{
|
||||||
|
IngredientID: item.IngredientID,
|
||||||
|
IngredientName: item.IngredientName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
TotalCost: item.TotalCost,
|
||||||
|
AverageUnitCost: item.AverageUnitCost,
|
||||||
|
PurchaseOrderCount: item.PurchaseOrderCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vendorData := make([]models.PurchasingVendorData, len(result.VendorData))
|
||||||
|
for i, item := range result.VendorData {
|
||||||
|
vendorData[i] = models.PurchasingVendorData{
|
||||||
|
VendorID: item.VendorID,
|
||||||
|
VendorName: item.VendorName,
|
||||||
|
TotalCost: item.TotalCost,
|
||||||
|
PurchaseOrderCount: item.PurchaseOrderCount,
|
||||||
|
IngredientCount: item.IngredientCount,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.PurchasingAnalyticsResponse{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
OutletName: result.OutletName,
|
||||||
|
DateFrom: req.DateFrom,
|
||||||
|
DateTo: req.DateTo,
|
||||||
|
GroupBy: req.GroupBy,
|
||||||
|
Summary: models.PurchasingSummary{
|
||||||
|
TotalPurchases: result.Summary.TotalPurchases,
|
||||||
|
TotalPurchaseOrders: result.Summary.TotalPurchaseOrders,
|
||||||
|
TotalQuantity: result.Summary.TotalQuantity,
|
||||||
|
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
|
||||||
|
TotalIngredients: result.Summary.TotalIngredients,
|
||||||
|
TotalVendors: result.Summary.TotalVendors,
|
||||||
|
},
|
||||||
|
Data: data,
|
||||||
|
IngredientData: ingredientData,
|
||||||
|
VendorData: vendorData,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) {
|
func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) {
|
||||||
// Validate date range
|
// Validate date range
|
||||||
if req.DateFrom.After(req.DateTo) {
|
if req.DateFrom.After(req.DateTo) {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package processor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type analyticsRepositoryStub struct {
|
||||||
|
purchasingResult *entities.PurchasingAnalytics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s analyticsRepositoryStub) GetPurchasingAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.PurchasingAnalytics, error) {
|
||||||
|
return s.purchasingResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsRepositoryStub) GetProductAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, int) ([]*entities.ProductAnalytics, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerCategory, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ProfitLossAnalytics, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) {
|
||||||
|
outletID := uuid.New()
|
||||||
|
outletName := "Main Outlet"
|
||||||
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
|
||||||
|
purchasingResult: &entities.PurchasingAnalytics{
|
||||||
|
OutletName: &outletName,
|
||||||
|
Summary: entities.PurchasingSummary{
|
||||||
|
TotalPurchases: 125,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
OutletID: &outletID,
|
||||||
|
DateFrom: now,
|
||||||
|
DateTo: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, &outletID, result.OutletID)
|
||||||
|
require.NotNil(t, result.OutletName)
|
||||||
|
require.Equal(t, outletName, *result.OutletName)
|
||||||
|
require.Equal(t, float64(125), result.Summary.TotalPurchases)
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package processor
|
package processor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"apskel-pos-be/internal/constants"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -388,31 +387,10 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
|||||||
return nil, fmt.Errorf("failed to create order item: %w", err)
|
return nil, fmt.Errorf("failed to create order item: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
itemResponse := models.OrderItemResponse{
|
itemResponse := mappers.OrderItemEntityToResponse(orderItem, order.OutletID)
|
||||||
ID: orderItem.ID,
|
if itemResponse != nil {
|
||||||
OrderID: orderItem.OrderID,
|
addedItemResponses = append(addedItemResponses, *itemResponse)
|
||||||
ProductID: orderItem.ProductID,
|
|
||||||
ProductVariantID: orderItem.ProductVariantID,
|
|
||||||
Quantity: orderItem.Quantity,
|
|
||||||
UnitPrice: orderItem.UnitPrice,
|
|
||||||
TotalPrice: orderItem.TotalPrice,
|
|
||||||
UnitCost: orderItem.UnitCost,
|
|
||||||
TotalCost: orderItem.TotalCost,
|
|
||||||
RefundAmount: orderItem.RefundAmount,
|
|
||||||
RefundQuantity: orderItem.RefundQuantity,
|
|
||||||
IsPartiallyRefunded: orderItem.IsPartiallyRefunded,
|
|
||||||
IsFullyRefunded: orderItem.IsFullyRefunded,
|
|
||||||
RefundReason: orderItem.RefundReason,
|
|
||||||
RefundedAt: orderItem.RefundedAt,
|
|
||||||
RefundedBy: orderItem.RefundedBy,
|
|
||||||
Modifiers: []map[string]interface{}(orderItem.Modifiers),
|
|
||||||
Notes: orderItem.Notes,
|
|
||||||
Metadata: map[string]interface{}(orderItem.Metadata),
|
|
||||||
Status: constants.OrderItemStatus(orderItem.Status),
|
|
||||||
CreatedAt: orderItem.CreatedAt,
|
|
||||||
UpdatedAt: orderItem.UpdatedAt,
|
|
||||||
}
|
}
|
||||||
addedItemResponses = append(addedItemResponses, itemResponse)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID)
|
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func (p *ProductOutletPriceProcessorImpl) Upsert(ctx context.Context, req *model
|
|||||||
ProductID: req.ProductID,
|
ProductID: req.ProductID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
Price: req.Price,
|
Price: req.Price,
|
||||||
|
PrintToChecker: req.PrintToChecker,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := p.repo.Upsert(ctx, entity); err != nil {
|
if err := p.repo.Upsert(ctx, entity); err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
|
"apskel-pos-be/internal/logger"
|
||||||
"apskel-pos-be/internal/mappers"
|
"apskel-pos-be/internal/mappers"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
"apskel-pos-be/internal/repository"
|
"apskel-pos-be/internal/repository"
|
||||||
@@ -39,6 +40,7 @@ type ProductRepository interface {
|
|||||||
ExistsBySKU(ctx context.Context, organizationID uuid.UUID, sku string, excludeID *uuid.UUID) (bool, error)
|
ExistsBySKU(ctx context.Context, organizationID uuid.UUID, sku string, excludeID *uuid.UUID) (bool, error)
|
||||||
GetByName(ctx context.Context, organizationID uuid.UUID, name string) (*entities.Product, error)
|
GetByName(ctx context.Context, organizationID uuid.UUID, name string) (*entities.Product, error)
|
||||||
ExistsByName(ctx context.Context, organizationID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error)
|
ExistsByName(ctx context.Context, organizationID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error)
|
||||||
|
ExistsByNameInOutlet(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error)
|
||||||
UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error
|
UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error
|
||||||
GetLowCostProducts(ctx context.Context, organizationID uuid.UUID, maxCost float64) ([]*entities.Product, error)
|
GetLowCostProducts(ctx context.Context, organizationID uuid.UUID, maxCost float64) ([]*entities.Product, error)
|
||||||
}
|
}
|
||||||
@@ -79,12 +81,12 @@ func (p *ProductProcessorImpl) CreateProduct(ctx context.Context, req *models.Cr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exists, err := p.productRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil)
|
exists, err := p.productRepo.ExistsByNameInOutlet(ctx, req.OrganizationID, req.OutletID, req.Name, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check product name uniqueness: %w", err)
|
return nil, fmt.Errorf("failed to check product name uniqueness: %w", err)
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
return nil, fmt.Errorf("product with name '%s' already exists for this organization", req.Name)
|
return nil, fmt.Errorf("product with name '%s' already exists for this outlet", req.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
productEntity := mappers.CreateProductRequestToEntity(req)
|
productEntity := mappers.CreateProductRequestToEntity(req)
|
||||||
@@ -122,6 +124,23 @@ func (p *ProductProcessorImpl) CreateProduct(ctx context.Context, req *models.Cr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upsert outlet-specific price if outlet context is present
|
||||||
|
if req.OutletID != uuid.Nil {
|
||||||
|
printToChecker := true // default
|
||||||
|
if req.PrintToChecker != nil {
|
||||||
|
printToChecker = *req.PrintToChecker
|
||||||
|
}
|
||||||
|
outletPriceEntity := &entities.ProductOutletPrice{
|
||||||
|
ProductID: productEntity.ID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
Price: req.Price,
|
||||||
|
PrintToChecker: printToChecker,
|
||||||
|
}
|
||||||
|
if err := p.outletPriceRepo.Upsert(ctx, outletPriceEntity); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to assign outlet price: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
productWithCategory, err := p.productRepo.GetWithCategory(ctx, productEntity.ID)
|
productWithCategory, err := p.productRepo.GetWithCategory(ctx, productEntity.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to retrieve created product: %w", err)
|
return nil, fmt.Errorf("failed to retrieve created product: %w", err)
|
||||||
@@ -161,12 +180,12 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Name != nil && *req.Name != existingProduct.Name {
|
if req.Name != nil && *req.Name != existingProduct.Name {
|
||||||
exists, err := p.productRepo.ExistsByName(ctx, existingProduct.OrganizationID, *req.Name, &id)
|
exists, err := p.productRepo.ExistsByNameInOutlet(ctx, existingProduct.OrganizationID, req.OutletID, *req.Name, &id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check product name uniqueness: %w", err)
|
return nil, fmt.Errorf("failed to check product name uniqueness: %w", err)
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
return nil, fmt.Errorf("product with name '%s' already exists for this organization", *req.Name)
|
return nil, fmt.Errorf("product with name '%s' already exists for this outlet", *req.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +202,41 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upsert outlet-specific price if outlet context is present and price or print_to_checker is provided
|
||||||
|
if req.OutletID != uuid.Nil && (req.Price != nil || req.PrintToChecker != nil) {
|
||||||
|
// Fetch existing outlet price to use as fallback for fields not provided
|
||||||
|
existing, _ := p.outletPriceRepo.GetByProductAndOutlet(ctx, id, req.OutletID)
|
||||||
|
|
||||||
|
price := float64(0)
|
||||||
|
if existing != nil {
|
||||||
|
price = existing.Price
|
||||||
|
}
|
||||||
|
if req.Price != nil {
|
||||||
|
price = *req.Price
|
||||||
|
}
|
||||||
|
|
||||||
|
printToChecker := true // default
|
||||||
|
if existing != nil {
|
||||||
|
printToChecker = existing.PrintToChecker
|
||||||
|
}
|
||||||
|
if req.PrintToChecker != nil {
|
||||||
|
printToChecker = *req.PrintToChecker
|
||||||
|
}
|
||||||
|
|
||||||
|
outletPriceEntity := &entities.ProductOutletPrice{
|
||||||
|
ProductID: id,
|
||||||
|
OutletID: req.OutletID,
|
||||||
|
Price: price,
|
||||||
|
PrintToChecker: printToChecker,
|
||||||
|
}
|
||||||
|
logger.FromContext(ctx).Infof("ProductProcessor::UpdateProduct -> upserting outlet price: productID=%s outletID=%s price=%f printToChecker=%v", id, req.OutletID, price, printToChecker)
|
||||||
|
if err := p.outletPriceRepo.Upsert(ctx, outletPriceEntity); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to assign outlet price: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.FromContext(ctx).Infof("ProductProcessor::UpdateProduct -> skipping outlet price upsert: outletID=%s price=%v printToChecker=%v", req.OutletID, req.Price, req.PrintToChecker)
|
||||||
|
}
|
||||||
|
|
||||||
productWithCategory, err := p.productRepo.GetWithCategory(ctx, id)
|
productWithCategory, err := p.productRepo.GetWithCategory(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to retrieve updated product: %w", err)
|
return nil, fmt.Errorf("failed to retrieve updated product: %w", err)
|
||||||
@@ -231,6 +285,7 @@ func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID,
|
|||||||
outletPrice, err := p.outletPriceRepo.GetByProductAndOutlet(ctx, id, outletID)
|
outletPrice, err := p.outletPriceRepo.GetByProductAndOutlet(ctx, id, outletID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
response.OutletPrice = &outletPrice.Price
|
response.OutletPrice = &outletPrice.Price
|
||||||
|
response.PrintToChecker = outletPrice.PrintToChecker
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No outlet context — return all outlet prices for this product
|
// No outlet context — return all outlet prices for this product
|
||||||
@@ -242,6 +297,7 @@ func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID,
|
|||||||
OutletID: op.OutletID,
|
OutletID: op.OutletID,
|
||||||
OutletName: op.Outlet.Name,
|
OutletName: op.Outlet.Name,
|
||||||
Price: op.Price,
|
Price: op.Price,
|
||||||
|
PrintToChecker: op.PrintToChecker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response.OutletPrices = prices
|
response.OutletPrices = prices
|
||||||
@@ -278,12 +334,37 @@ func (p *ProductProcessorImpl) ListProducts(ctx context.Context, filters map[str
|
|||||||
}
|
}
|
||||||
|
|
||||||
responses := make([]models.ProductResponse, len(productEntities))
|
responses := make([]models.ProductResponse, len(productEntities))
|
||||||
|
if outletID != uuid.Nil && len(productEntities) > 0 {
|
||||||
|
// Bulk-fetch outlet prices to populate OutletPrice and PrintToChecker per product
|
||||||
|
productIDs := make([]uuid.UUID, len(productEntities))
|
||||||
|
for i, e := range productEntities {
|
||||||
|
productIDs[i] = e.ID
|
||||||
|
}
|
||||||
|
outletPrices, opErr := p.outletPriceRepo.GetByProductsAndOutlet(ctx, productIDs, outletID)
|
||||||
|
priceMap := make(map[uuid.UUID]*entities.ProductOutletPrice)
|
||||||
|
if opErr == nil {
|
||||||
|
for _, op := range outletPrices {
|
||||||
|
priceMap[op.ProductID] = op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, entity := range productEntities {
|
||||||
|
response := mappers.ProductEntityToResponse(entity)
|
||||||
|
if response != nil {
|
||||||
|
if op, ok := priceMap[entity.ID]; ok {
|
||||||
|
response.OutletPrice = &op.Price
|
||||||
|
response.PrintToChecker = op.PrintToChecker
|
||||||
|
}
|
||||||
|
responses[i] = *response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
for i, entity := range productEntities {
|
for i, entity := range productEntities {
|
||||||
response := mappers.ProductEntityToResponse(entity)
|
response := mappers.ProductEntityToResponse(entity)
|
||||||
if response != nil {
|
if response != nil {
|
||||||
responses[i] = *response
|
responses[i] = *response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return responses, int(total), nil
|
return responses, int(total), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
type AnalyticsRepository interface {
|
type AnalyticsRepository interface {
|
||||||
GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error)
|
GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error)
|
||||||
GetSalesAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]*entities.SalesAnalytics, error)
|
GetSalesAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]*entities.SalesAnalytics, error)
|
||||||
|
GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error)
|
||||||
GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error)
|
GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error)
|
||||||
GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error)
|
GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error)
|
||||||
GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error)
|
GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error)
|
||||||
@@ -122,6 +123,159 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz
|
|||||||
return results, err
|
return results, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) {
|
||||||
|
var summary entities.PurchasingSummary
|
||||||
|
var outletName *string
|
||||||
|
|
||||||
|
if outletID != nil {
|
||||||
|
var outlet struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
result := r.db.WithContext(ctx).
|
||||||
|
Table("outlets").
|
||||||
|
Select("name").
|
||||||
|
Where("id = ? AND organization_id = ?", *outletID, organizationID).
|
||||||
|
Limit(1).
|
||||||
|
Scan(&outlet)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected > 0 {
|
||||||
|
outletName = &outlet.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryQuery := r.db.WithContext(ctx).
|
||||||
|
Table("inventory_movements im").
|
||||||
|
Select(`
|
||||||
|
COALESCE(SUM(im.total_cost), 0) as total_purchases,
|
||||||
|
COUNT(DISTINCT im.reference_id) as total_purchase_orders,
|
||||||
|
COALESCE(SUM(im.quantity), 0) as total_quantity,
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(DISTINCT im.reference_id) > 0
|
||||||
|
THEN COALESCE(SUM(im.total_cost), 0) / COUNT(DISTINCT im.reference_id)
|
||||||
|
ELSE 0
|
||||||
|
END as average_purchase_order_value,
|
||||||
|
COUNT(DISTINCT im.item_id) as total_ingredients,
|
||||||
|
COUNT(DISTINCT po.vendor_id) as total_vendors
|
||||||
|
`).
|
||||||
|
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id").
|
||||||
|
Where("im.organization_id = ?", organizationID).
|
||||||
|
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
|
||||||
|
Where("im.item_type = ?", "INGREDIENT").
|
||||||
|
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
|
||||||
|
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo)
|
||||||
|
|
||||||
|
summaryQuery = r.resolveOutletID(summaryQuery, outletID, "im.outlet_id")
|
||||||
|
|
||||||
|
if err := summaryQuery.Scan(&summary).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateFormat string
|
||||||
|
switch groupBy {
|
||||||
|
case "hour":
|
||||||
|
dateFormat = "DATE_TRUNC('hour', im.created_at)"
|
||||||
|
case "week":
|
||||||
|
dateFormat = "DATE_TRUNC('week', im.created_at)"
|
||||||
|
case "month":
|
||||||
|
dateFormat = "DATE_TRUNC('month', im.created_at)"
|
||||||
|
default:
|
||||||
|
dateFormat = "DATE_TRUNC('day', im.created_at)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var data []entities.PurchasingAnalyticsData
|
||||||
|
dataQuery := r.db.WithContext(ctx).
|
||||||
|
Table("inventory_movements im").
|
||||||
|
Select(`
|
||||||
|
`+dateFormat+` as date,
|
||||||
|
COALESCE(SUM(im.total_cost), 0) as purchases,
|
||||||
|
COUNT(DISTINCT im.reference_id) as purchase_orders,
|
||||||
|
COALESCE(SUM(im.quantity), 0) as quantity,
|
||||||
|
COUNT(DISTINCT im.item_id) as ingredients,
|
||||||
|
COUNT(DISTINCT po.vendor_id) as vendors
|
||||||
|
`).
|
||||||
|
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id").
|
||||||
|
Where("im.organization_id = ?", organizationID).
|
||||||
|
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
|
||||||
|
Where("im.item_type = ?", "INGREDIENT").
|
||||||
|
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
|
||||||
|
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
|
||||||
|
Group(dateFormat).
|
||||||
|
Order(dateFormat)
|
||||||
|
|
||||||
|
dataQuery = r.resolveOutletID(dataQuery, outletID, "im.outlet_id")
|
||||||
|
|
||||||
|
if err := dataQuery.Scan(&data).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ingredientData []entities.PurchasingIngredientData
|
||||||
|
ingredientQuery := r.db.WithContext(ctx).
|
||||||
|
Table("inventory_movements im").
|
||||||
|
Select(`
|
||||||
|
i.id as ingredient_id,
|
||||||
|
i.name as ingredient_name,
|
||||||
|
COALESCE(SUM(im.quantity), 0) as quantity,
|
||||||
|
COALESCE(SUM(im.total_cost), 0) as total_cost,
|
||||||
|
CASE
|
||||||
|
WHEN SUM(im.quantity) > 0
|
||||||
|
THEN COALESCE(SUM(im.total_cost), 0) / SUM(im.quantity)
|
||||||
|
ELSE 0
|
||||||
|
END as average_unit_cost,
|
||||||
|
COUNT(DISTINCT im.reference_id) as purchase_order_count
|
||||||
|
`).
|
||||||
|
Joins("JOIN ingredients i ON im.item_id = i.id").
|
||||||
|
Where("im.organization_id = ?", organizationID).
|
||||||
|
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
|
||||||
|
Where("im.item_type = ?", "INGREDIENT").
|
||||||
|
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
|
||||||
|
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
|
||||||
|
Group("i.id, i.name").
|
||||||
|
Order("total_cost DESC")
|
||||||
|
|
||||||
|
ingredientQuery = r.resolveOutletID(ingredientQuery, outletID, "im.outlet_id")
|
||||||
|
|
||||||
|
if err := ingredientQuery.Scan(&ingredientData).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var vendorData []entities.PurchasingVendorData
|
||||||
|
vendorQuery := r.db.WithContext(ctx).
|
||||||
|
Table("inventory_movements im").
|
||||||
|
Select(`
|
||||||
|
v.id as vendor_id,
|
||||||
|
v.name as vendor_name,
|
||||||
|
COALESCE(SUM(im.total_cost), 0) as total_cost,
|
||||||
|
COUNT(DISTINCT im.reference_id) as purchase_order_count,
|
||||||
|
COUNT(DISTINCT im.item_id) as ingredient_count,
|
||||||
|
COALESCE(SUM(im.quantity), 0) as quantity
|
||||||
|
`).
|
||||||
|
Joins("JOIN purchase_orders po ON im.reference_id = po.id").
|
||||||
|
Joins("JOIN vendors v ON po.vendor_id = v.id").
|
||||||
|
Where("im.organization_id = ?", organizationID).
|
||||||
|
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
|
||||||
|
Where("im.item_type = ?", "INGREDIENT").
|
||||||
|
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
|
||||||
|
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
|
||||||
|
Group("v.id, v.name").
|
||||||
|
Order("total_cost DESC")
|
||||||
|
|
||||||
|
vendorQuery = r.resolveOutletID(vendorQuery, outletID, "im.outlet_id")
|
||||||
|
|
||||||
|
if err := vendorQuery.Scan(&vendorData).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &entities.PurchasingAnalytics{
|
||||||
|
OutletName: outletName,
|
||||||
|
Summary: summary,
|
||||||
|
Data: data,
|
||||||
|
IngredientData: ingredientData,
|
||||||
|
VendorData: vendorData,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
|
|||||||
case "search":
|
case "search":
|
||||||
searchValue := "%" + value.(string) + "%"
|
searchValue := "%" + value.(string) + "%"
|
||||||
query = query.Where("name ILIKE ? OR description ILIKE ?", searchValue, searchValue)
|
query = query.Where("name ILIKE ? OR description ILIKE ?", searchValue, searchValue)
|
||||||
|
case "outlet_id":
|
||||||
|
// Include outlet-specific categories AND global categories (outlet_id IS NULL)
|
||||||
|
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
|
||||||
default:
|
default:
|
||||||
query = query.Where(key+" = ?", value)
|
query = query.Where(key+" = ?", value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID
|
|||||||
Preload("User").
|
Preload("User").
|
||||||
Preload("OrderItems").
|
Preload("OrderItems").
|
||||||
Preload("OrderItems.Product").
|
Preload("OrderItems.Product").
|
||||||
|
Preload("OrderItems.Product.Category").
|
||||||
|
Preload("OrderItems.Product.ProductOutletPrices").
|
||||||
Preload("OrderItems.ProductVariant").
|
Preload("OrderItems.ProductVariant").
|
||||||
Preload("Payments").
|
Preload("Payments").
|
||||||
Preload("Payments.PaymentMethod").
|
Preload("Payments.PaymentMethod").
|
||||||
@@ -139,6 +141,8 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter
|
|||||||
Preload("User").
|
Preload("User").
|
||||||
Preload("OrderItems").
|
Preload("OrderItems").
|
||||||
Preload("OrderItems.Product").
|
Preload("OrderItems.Product").
|
||||||
|
Preload("OrderItems.Product.Category").
|
||||||
|
Preload("OrderItems.Product.ProductOutletPrices").
|
||||||
Preload("OrderItems.ProductVariant").
|
Preload("OrderItems.ProductVariant").
|
||||||
Preload("Payments").
|
Preload("Payments").
|
||||||
Preload("Payments.PaymentMethod").
|
Preload("Payments.PaymentMethod").
|
||||||
@@ -155,6 +159,8 @@ func (r *OrderRepositoryImpl) ListBySessionID(ctx context.Context, sessionID str
|
|||||||
Preload("User").
|
Preload("User").
|
||||||
Preload("OrderItems").
|
Preload("OrderItems").
|
||||||
Preload("OrderItems.Product").
|
Preload("OrderItems.Product").
|
||||||
|
Preload("OrderItems.Product.Category").
|
||||||
|
Preload("OrderItems.Product.ProductOutletPrices").
|
||||||
Preload("OrderItems.ProductVariant").
|
Preload("OrderItems.ProductVariant").
|
||||||
Preload("Payments").
|
Preload("Payments").
|
||||||
Preload("Payments.PaymentMethod").
|
Preload("Payments.PaymentMethod").
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProductOutletPriceRepository interface {
|
type ProductOutletPriceRepository interface {
|
||||||
@@ -53,10 +52,18 @@ func (r *ProductOutletPriceRepositoryImpl) GetByOutlet(ctx context.Context, outl
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ProductOutletPriceRepositoryImpl) Upsert(ctx context.Context, price *entities.ProductOutletPrice) error {
|
func (r *ProductOutletPriceRepositoryImpl) Upsert(ctx context.Context, price *entities.ProductOutletPrice) error {
|
||||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
if price.ID == uuid.Nil {
|
||||||
Columns: []clause.Column{{Name: "product_id"}, {Name: "outlet_id"}},
|
price.ID = uuid.New()
|
||||||
DoUpdates: clause.AssignmentColumns([]string{"price", "updated_at"}),
|
}
|
||||||
}).Create(price).Error
|
return r.db.WithContext(ctx).Exec(`
|
||||||
|
INSERT INTO product_outlet_prices (id, product_id, outlet_id, price, print_to_checker, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW(), NOW())
|
||||||
|
ON CONFLICT (product_id, outlet_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
price = EXCLUDED.price,
|
||||||
|
print_to_checker = EXCLUDED.print_to_checker,
|
||||||
|
updated_at = NOW()
|
||||||
|
`, price.ID, price.ProductID, price.OutletID, price.Price, price.PrintToChecker).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ProductOutletPriceRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
func (r *ProductOutletPriceRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
|||||||
@@ -178,6 +178,26 @@ func (r *ProductRepositoryImpl) ExistsByName(ctx context.Context, organizationID
|
|||||||
return count > 0, err
|
return count > 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExistsByNameInOutlet checks name uniqueness scoped to a specific outlet via product_outlet_prices.
|
||||||
|
// Falls back to organization-scoped check when outletID is zero.
|
||||||
|
func (r *ProductRepositoryImpl) ExistsByNameInOutlet(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error) {
|
||||||
|
if outletID == uuid.Nil {
|
||||||
|
return r.ExistsByName(ctx, organizationID, name, excludeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.db.WithContext(ctx).Model(&entities.Product{}).
|
||||||
|
Joins("INNER JOIN product_outlet_prices pop ON pop.product_id = products.id AND pop.outlet_id = ?", outletID).
|
||||||
|
Where("products.organization_id = ? AND products.name = ?", organizationID, name)
|
||||||
|
|
||||||
|
if excludeID != nil {
|
||||||
|
query = query.Where("products.id != ?", *excludeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := query.Count(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ProductRepositoryImpl) UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error {
|
func (r *ProductRepositoryImpl) UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error {
|
||||||
return r.db.WithContext(ctx).Model(&entities.Product{}).
|
return r.db.WithContext(ctx).Model(&entities.Product{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ func NewTxManager(db *gorm.DB) *TxManager { return &TxManager{db: db} }
|
|||||||
|
|
||||||
// WithTransaction runs fn inside a DB transaction, injecting the *gorm.DB tx into ctx.
|
// WithTransaction runs fn inside a DB transaction, injecting the *gorm.DB tx into ctx.
|
||||||
func (m *TxManager) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
|
func (m *TxManager) WithTransaction(ctx context.Context, 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 {
|
return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
ctxTx := context.WithValue(ctx, txKey, tx)
|
ctxTx := context.WithValue(ctx, txKey, tx)
|
||||||
return fn(ctxTx)
|
return fn(ctxTx)
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
{
|
{
|
||||||
analytics.GET("/payment-methods", r.analyticsHandler.GetPaymentMethodAnalytics)
|
analytics.GET("/payment-methods", r.analyticsHandler.GetPaymentMethodAnalytics)
|
||||||
analytics.GET("/sales", r.analyticsHandler.GetSalesAnalytics)
|
analytics.GET("/sales", r.analyticsHandler.GetSalesAnalytics)
|
||||||
|
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
|
||||||
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
|
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
|
||||||
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
|
||||||
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
type AnalyticsService interface {
|
type AnalyticsService interface {
|
||||||
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error)
|
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error)
|
||||||
GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error)
|
GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error)
|
||||||
|
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
|
||||||
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
|
||||||
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
|
||||||
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
|
||||||
@@ -57,6 +58,19 @@ func (s *AnalyticsServiceImpl) GetSalesAnalytics(ctx context.Context, req *model
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) {
|
||||||
|
if err := s.validatePurchasingAnalyticsRequest(req); err != nil {
|
||||||
|
return nil, fmt.Errorf("validation error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.analyticsProcessor.GetPurchasingAnalytics(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AnalyticsServiceImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) {
|
func (s *AnalyticsServiceImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) {
|
||||||
// Validate request
|
// Validate request
|
||||||
if err := s.validateProductAnalyticsRequest(req); err != nil {
|
if err := s.validateProductAnalyticsRequest(req); err != nil {
|
||||||
@@ -168,6 +182,42 @@ func (s *AnalyticsServiceImpl) validateSalesAnalyticsRequest(req *models.SalesAn
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.PurchasingAnalyticsRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return fmt.Errorf("request cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.OrganizationID == uuid.Nil {
|
||||||
|
return fmt.Errorf("organization ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateFrom.IsZero() {
|
||||||
|
return fmt.Errorf("date_from is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateTo.IsZero() {
|
||||||
|
return fmt.Errorf("date_to is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateFrom.After(req.DateTo) {
|
||||||
|
return fmt.Errorf("date_from cannot be after date_to")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.GroupBy != "" {
|
||||||
|
validGroupBy := map[string]bool{
|
||||||
|
"day": true,
|
||||||
|
"hour": true,
|
||||||
|
"week": true,
|
||||||
|
"month": true,
|
||||||
|
}
|
||||||
|
if !validGroupBy[req.GroupBy] {
|
||||||
|
return fmt.Errorf("invalid group_by value: %s", req.GroupBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AnalyticsServiceImpl) validateProductAnalyticsRequest(req *models.ProductAnalyticsRequest) error {
|
func (s *AnalyticsServiceImpl) validateProductAnalyticsRequest(req *models.ProductAnalyticsRequest) error {
|
||||||
if req.OrganizationID == uuid.Nil {
|
if req.OrganizationID == uuid.Nil {
|
||||||
return fmt.Errorf("organization ID is required")
|
return fmt.Errorf("organization ID is required")
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type analyticsProcessorStub struct{}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetPaymentMethodAnalytics(context.Context, *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetSalesAnalytics(context.Context, *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetPurchasingAnalytics(context.Context, *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) {
|
||||||
|
return &models.PurchasingAnalyticsResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetProductAnalytics(context.Context, *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetProductAnalyticsPerCategory(context.Context, *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetDashboardAnalytics(context.Context, *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (analyticsProcessorStub) GetProfitLossAnalytics(context.Context, *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
|
||||||
|
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||||
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req *models.PurchasingAnalyticsRequest
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing organization",
|
||||||
|
req: &models.PurchasingAnalyticsRequest{
|
||||||
|
DateFrom: now,
|
||||||
|
DateTo: now,
|
||||||
|
},
|
||||||
|
wantErr: "organization ID is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing date_from",
|
||||||
|
req: &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateTo: now,
|
||||||
|
},
|
||||||
|
wantErr: "date_from is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing date_to",
|
||||||
|
req: &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: now,
|
||||||
|
},
|
||||||
|
wantErr: "date_to is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reversed dates",
|
||||||
|
req: &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: now.AddDate(0, 0, 1),
|
||||||
|
DateTo: now,
|
||||||
|
},
|
||||||
|
wantErr: "date_from cannot be after date_to",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid group_by",
|
||||||
|
req: &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: now,
|
||||||
|
DateTo: now,
|
||||||
|
GroupBy: "quarter",
|
||||||
|
},
|
||||||
|
wantErr: "invalid group_by value: quarter",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resp, err := service.GetPurchasingAnalytics(context.Background(), tt.req)
|
||||||
|
|
||||||
|
require.Nil(t, resp)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyticsServiceGetPurchasingAnalyticsAllowsEmptyGroupBy(t *testing.T) {
|
||||||
|
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
|
||||||
|
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
resp, err := service.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
DateFrom: now,
|
||||||
|
DateTo: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
}
|
||||||
@@ -85,6 +85,9 @@ func (s *CategoryServiceImpl) ListCategories(ctx context.Context, req *contract.
|
|||||||
if req.OrganizationID != nil {
|
if req.OrganizationID != nil {
|
||||||
filters["organization_id"] = *req.OrganizationID
|
filters["organization_id"] = *req.OrganizationID
|
||||||
}
|
}
|
||||||
|
if req.OutletID != nil {
|
||||||
|
filters["outlet_id"] = *req.OutletID
|
||||||
|
}
|
||||||
if req.BusinessType != "" {
|
if req.BusinessType != "" {
|
||||||
filters["business_type"] = req.BusinessType
|
filters["business_type"] = req.BusinessType
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ func (s *OrderServiceImpl) createIngredientTransactions(ctx context.Context, ord
|
|||||||
// Calculate waste quantities
|
// Calculate waste quantities
|
||||||
transactions, err := s.calculateWasteQuantities(productRecipes, float64(orderItem.Quantity))
|
transactions, err := s.calculateWasteQuantities(productRecipes, float64(orderItem.Quantity))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to calculate waste quantities for product %s: %w", err)
|
return nil, fmt.Errorf("failed to calculate waste quantities for product %s: %w", orderItem.ProductID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set common fields for all transactions
|
// Set common fields for all transactions
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ func (m *MockTableRepository) GetByID(ctx context.Context, id uuid.UUID) (*entit
|
|||||||
return args.Get(0).(*entities.Table), args.Error(1)
|
return args.Get(0).(*entities.Table), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockTableRepository) GetByToken(ctx context.Context, token string) (*entities.Table, error) {
|
||||||
|
args := m.Called(ctx, token)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*entities.Table), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockTableRepository) GetByOutletID(ctx context.Context, outletID uuid.UUID) ([]entities.Table, error) {
|
func (m *MockTableRepository) GetByOutletID(ctx context.Context, outletID uuid.UUID) ([]entities.Table, error) {
|
||||||
args := m.Called(ctx, outletID)
|
args := m.Called(ctx, outletID)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
@@ -182,6 +190,11 @@ func (m *MockTableRepository) GetByOrderID(ctx context.Context, orderID uuid.UUI
|
|||||||
return args.Get(0).(*entities.Table), args.Error(1)
|
return args.Get(0).(*entities.Table), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockTableRepository) UpdateToken(ctx context.Context, tableID uuid.UUID, token string) error {
|
||||||
|
args := m.Called(ctx, tableID, token)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateOrderWithTableOccupation(t *testing.T) {
|
func TestCreateOrderWithTableOccupation(t *testing.T) {
|
||||||
// Setup
|
// Setup
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ func (s *ProductOutletPriceServiceImpl) BulkUpsert(ctx context.Context, req *con
|
|||||||
ProductID: req.ProductID,
|
ProductID: req.ProductID,
|
||||||
OutletID: p.OutletID,
|
OutletID: p.OutletID,
|
||||||
Price: p.Price,
|
Price: p.Price,
|
||||||
|
PrintToChecker: p.PrintToChecker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
type ProductService interface {
|
type ProductService interface {
|
||||||
CreateProduct(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateProductRequest) *contract.Response
|
CreateProduct(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateProductRequest) *contract.Response
|
||||||
UpdateProduct(ctx context.Context, id uuid.UUID, req *contract.UpdateProductRequest) *contract.Response
|
UpdateProduct(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateProductRequest) *contract.Response
|
||||||
DeleteProduct(ctx context.Context, id uuid.UUID) *contract.Response
|
DeleteProduct(ctx context.Context, id uuid.UUID) *contract.Response
|
||||||
GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) *contract.Response
|
GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) *contract.Response
|
||||||
ListProducts(ctx context.Context, req *contract.ListProductsRequest) *contract.Response
|
ListProducts(ctx context.Context, req *contract.ListProductsRequest) *contract.Response
|
||||||
@@ -44,8 +44,8 @@ func (s *ProductServiceImpl) CreateProduct(ctx context.Context, apctx *appcontex
|
|||||||
return contract.BuildSuccessResponse(contractResponse)
|
return contract.BuildSuccessResponse(contractResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProductServiceImpl) UpdateProduct(ctx context.Context, id uuid.UUID, req *contract.UpdateProductRequest) *contract.Response {
|
func (s *ProductServiceImpl) UpdateProduct(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateProductRequest) *contract.Response {
|
||||||
modelReq := transformer.UpdateProductRequestToModel(req)
|
modelReq := transformer.UpdateProductRequestToModel(apctx, req)
|
||||||
|
|
||||||
productResponse, err := s.productProcessor.UpdateProduct(ctx, id, modelReq)
|
productResponse, err := s.productProcessor.UpdateProduct(ctx, id, modelReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -138,6 +138,91 @@ func SalesAnalyticsModelToContract(resp *models.SalesAnalyticsResponse) *contrac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PurchasingAnalyticsContractToModel converts contract request to model
|
||||||
|
func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest) *models.PurchasingAnalyticsRequest {
|
||||||
|
var dateFrom, dateTo time.Time
|
||||||
|
|
||||||
|
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
|
||||||
|
if fromTime != nil {
|
||||||
|
dateFrom = *fromTime
|
||||||
|
}
|
||||||
|
if toTime != nil {
|
||||||
|
dateTo = *toTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: req.OrganizationID,
|
||||||
|
OutletID: parseOutletID(req.OutletID),
|
||||||
|
DateFrom: dateFrom,
|
||||||
|
DateTo: dateTo,
|
||||||
|
GroupBy: req.GroupBy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchasingAnalyticsModelToContract converts model response to contract
|
||||||
|
func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse) *contract.PurchasingAnalyticsResponse {
|
||||||
|
if resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]contract.PurchasingAnalyticsData, len(resp.Data))
|
||||||
|
for i, item := range resp.Data {
|
||||||
|
data[i] = contract.PurchasingAnalyticsData{
|
||||||
|
Date: item.Date,
|
||||||
|
Purchases: item.Purchases,
|
||||||
|
PurchaseOrders: item.PurchaseOrders,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Ingredients: item.Ingredients,
|
||||||
|
Vendors: item.Vendors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ingredientData := make([]contract.PurchasingIngredientData, len(resp.IngredientData))
|
||||||
|
for i, item := range resp.IngredientData {
|
||||||
|
ingredientData[i] = contract.PurchasingIngredientData{
|
||||||
|
IngredientID: item.IngredientID,
|
||||||
|
IngredientName: item.IngredientName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
TotalCost: item.TotalCost,
|
||||||
|
AverageUnitCost: item.AverageUnitCost,
|
||||||
|
PurchaseOrderCount: item.PurchaseOrderCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vendorData := make([]contract.PurchasingVendorData, len(resp.VendorData))
|
||||||
|
for i, item := range resp.VendorData {
|
||||||
|
vendorData[i] = contract.PurchasingVendorData{
|
||||||
|
VendorID: item.VendorID,
|
||||||
|
VendorName: item.VendorName,
|
||||||
|
TotalCost: item.TotalCost,
|
||||||
|
PurchaseOrderCount: item.PurchaseOrderCount,
|
||||||
|
IngredientCount: item.IngredientCount,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &contract.PurchasingAnalyticsResponse{
|
||||||
|
OrganizationID: resp.OrganizationID,
|
||||||
|
OutletID: resp.OutletID,
|
||||||
|
OutletName: resp.OutletName,
|
||||||
|
DateFrom: resp.DateFrom,
|
||||||
|
DateTo: resp.DateTo,
|
||||||
|
GroupBy: resp.GroupBy,
|
||||||
|
Summary: contract.PurchasingSummary{
|
||||||
|
TotalPurchases: resp.Summary.TotalPurchases,
|
||||||
|
TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders,
|
||||||
|
TotalQuantity: resp.Summary.TotalQuantity,
|
||||||
|
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
|
||||||
|
TotalIngredients: resp.Summary.TotalIngredients,
|
||||||
|
TotalVendors: resp.Summary.TotalVendors,
|
||||||
|
},
|
||||||
|
Data: data,
|
||||||
|
IngredientData: ingredientData,
|
||||||
|
VendorData: vendorData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ProductAnalyticsContractToModel converts contract request to model
|
// ProductAnalyticsContractToModel converts contract request to model
|
||||||
func ProductAnalyticsContractToModel(req *contract.ProductAnalyticsRequest) *models.ProductAnalyticsRequest {
|
func ProductAnalyticsContractToModel(req *contract.ProductAnalyticsRequest) *models.ProductAnalyticsRequest {
|
||||||
var dateFrom, dateTo time.Time
|
var dateFrom, dateTo time.Time
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package transformer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/contract"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPurchasingAnalyticsContractToModelParsesDateRangeAndOutlet(t *testing.T) {
|
||||||
|
orgID := uuid.New()
|
||||||
|
outletID := uuid.New().String()
|
||||||
|
|
||||||
|
req := &contract.PurchasingAnalyticsRequest{
|
||||||
|
OrganizationID: orgID,
|
||||||
|
OutletID: &outletID,
|
||||||
|
DateFrom: "01-05-2026",
|
||||||
|
DateTo: "02-05-2026",
|
||||||
|
GroupBy: "week",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := PurchasingAnalyticsContractToModel(req)
|
||||||
|
|
||||||
|
require.Equal(t, orgID, result.OrganizationID)
|
||||||
|
require.NotNil(t, result.OutletID)
|
||||||
|
require.Equal(t, outletID, result.OutletID.String())
|
||||||
|
require.Equal(t, "week", result.GroupBy)
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.DateFrom)
|
||||||
|
require.Equal(t, time.Date(2026, 5, 2, 23, 59, 59, int(time.Second-time.Nanosecond), location), result.DateTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchasingAnalyticsContractToModelIgnoresInvalidOutlet(t *testing.T) {
|
||||||
|
outletID := "not-a-uuid"
|
||||||
|
|
||||||
|
result := PurchasingAnalyticsContractToModel(&contract.PurchasingAnalyticsRequest{
|
||||||
|
OutletID: &outletID,
|
||||||
|
DateFrom: "01-05-2026",
|
||||||
|
DateTo: "02-05-2026",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Nil(t, result.OutletID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) {
|
||||||
|
outletID := uuid.New()
|
||||||
|
outletName := "Main Outlet"
|
||||||
|
|
||||||
|
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
OutletID: &outletID,
|
||||||
|
OutletName: &outletName,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Equal(t, &outletID, result.OutletID)
|
||||||
|
require.NotNil(t, result.OutletName)
|
||||||
|
require.Equal(t, outletName, *result.OutletName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
|
||||||
|
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
||||||
|
OrganizationID: uuid.New(),
|
||||||
|
})
|
||||||
|
|
||||||
|
payload, err := json.Marshal(result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(payload), "outlet_name")
|
||||||
|
}
|
||||||
@@ -7,12 +7,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreateCategoryRequest) *models.CreateCategoryRequest {
|
func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreateCategoryRequest) *models.CreateCategoryRequest {
|
||||||
|
order := 0
|
||||||
|
if req.Order != nil {
|
||||||
|
order = *req.Order
|
||||||
|
}
|
||||||
return &models.CreateCategoryRequest{
|
return &models.CreateCategoryRequest{
|
||||||
OrganizationID: apctx.OrganizationID,
|
OrganizationID: apctx.OrganizationID,
|
||||||
|
OutletID: req.OutletID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
ImageURL: nil,
|
ImageURL: nil,
|
||||||
Order: *req.Order,
|
Order: order,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +26,7 @@ func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.U
|
|||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
ImageURL: nil,
|
ImageURL: nil,
|
||||||
|
OutletID: req.OutletID,
|
||||||
Order: req.Order,
|
Order: req.Order,
|
||||||
IsActive: nil,
|
IsActive: nil,
|
||||||
}
|
}
|
||||||
@@ -34,9 +40,10 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat
|
|||||||
return &contract.CategoryResponse{
|
return &contract.CategoryResponse{
|
||||||
ID: cat.ID,
|
ID: cat.ID,
|
||||||
OrganizationID: cat.OrganizationID,
|
OrganizationID: cat.OrganizationID,
|
||||||
|
OutletID: cat.OutletID,
|
||||||
Name: cat.Name,
|
Name: cat.Name,
|
||||||
Description: cat.Description,
|
Description: cat.Description,
|
||||||
BusinessType: "restaurant", // Default business type
|
BusinessType: "restaurant",
|
||||||
Order: cat.Order,
|
Order: cat.Order,
|
||||||
Metadata: map[string]interface{}{},
|
Metadata: map[string]interface{}{},
|
||||||
CreatedAt: cat.CreatedAt,
|
CreatedAt: cat.CreatedAt,
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse {
|
|||||||
ProductName: item.ProductName,
|
ProductName: item.ProductName,
|
||||||
ProductVariantID: item.ProductVariantID,
|
ProductVariantID: item.ProductVariantID,
|
||||||
ProductVariantName: item.ProductVariantName,
|
ProductVariantName: item.ProductVariantName,
|
||||||
|
CategoryID: item.CategoryID,
|
||||||
|
CategoryName: item.CategoryName,
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
UnitPrice: item.UnitPrice,
|
UnitPrice: item.UnitPrice,
|
||||||
TotalPrice: item.TotalPrice,
|
TotalPrice: item.TotalPrice,
|
||||||
@@ -110,6 +112,7 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse {
|
|||||||
CreatedAt: item.CreatedAt,
|
CreatedAt: item.CreatedAt,
|
||||||
UpdatedAt: item.UpdatedAt,
|
UpdatedAt: item.UpdatedAt,
|
||||||
PrinterType: item.PrinterType,
|
PrinterType: item.PrinterType,
|
||||||
|
PrintToChecker: item.PrintToChecker,
|
||||||
PaidQuantity: item.PaidQuantity,
|
PaidQuantity: item.PaidQuantity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +171,8 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO
|
|||||||
ProductName: item.ProductName,
|
ProductName: item.ProductName,
|
||||||
ProductVariantID: item.ProductVariantID,
|
ProductVariantID: item.ProductVariantID,
|
||||||
ProductVariantName: item.ProductVariantName,
|
ProductVariantName: item.ProductVariantName,
|
||||||
|
CategoryID: item.CategoryID,
|
||||||
|
CategoryName: item.CategoryName,
|
||||||
Quantity: item.Quantity,
|
Quantity: item.Quantity,
|
||||||
UnitPrice: item.UnitPrice,
|
UnitPrice: item.UnitPrice,
|
||||||
TotalPrice: item.TotalPrice,
|
TotalPrice: item.TotalPrice,
|
||||||
@@ -177,6 +182,7 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO
|
|||||||
Status: string(item.Status),
|
Status: string(item.Status),
|
||||||
CreatedAt: item.CreatedAt,
|
CreatedAt: item.CreatedAt,
|
||||||
UpdatedAt: item.UpdatedAt,
|
UpdatedAt: item.UpdatedAt,
|
||||||
|
PrintToChecker: item.PrintToChecker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &contract.AddToOrderResponse{
|
return &contract.AddToOrderResponse{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func CreateProductOutletPriceRequestToModel(req *contract.CreateProductOutletPri
|
|||||||
ProductID: req.ProductID,
|
ProductID: req.ProductID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
Price: req.Price,
|
Price: req.Price,
|
||||||
|
PrintToChecker: req.PrintToChecker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ func UpdateProductOutletPriceRequestToModel(req *contract.UpdateProductOutletPri
|
|||||||
|
|
||||||
return &models.UpdateProductOutletPriceRequest{
|
return &models.UpdateProductOutletPriceRequest{
|
||||||
Price: &req.Price,
|
Price: &req.Price,
|
||||||
|
PrintToChecker: req.PrintToChecker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ func ProductOutletPriceModelToResponse(m *models.ProductOutletPrice) *contract.P
|
|||||||
ProductID: m.ProductID,
|
ProductID: m.ProductID,
|
||||||
OutletID: m.OutletID,
|
OutletID: m.OutletID,
|
||||||
Price: m.Price,
|
Price: m.Price,
|
||||||
|
PrintToChecker: m.PrintToChecker,
|
||||||
CreatedAt: m.CreatedAt,
|
CreatedAt: m.CreatedAt,
|
||||||
UpdatedAt: m.UpdatedAt,
|
UpdatedAt: m.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"apskel-pos-be/internal/constants"
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/contract"
|
"apskel-pos-be/internal/contract"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreateProductRequest) *models.CreateProductRequest {
|
func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreateProductRequest) *models.CreateProductRequest {
|
||||||
@@ -37,8 +39,15 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
|
|||||||
metadata = make(map[string]interface{})
|
metadata = make(map[string]interface{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prioritize outlet_id from context, fallback to request body
|
||||||
|
outletID := apctx.OutletID
|
||||||
|
if outletID == uuid.Nil && req.OutletID != nil {
|
||||||
|
outletID = *req.OutletID
|
||||||
|
}
|
||||||
|
|
||||||
return &models.CreateProductRequest{
|
return &models.CreateProductRequest{
|
||||||
OrganizationID: apctx.OrganizationID,
|
OrganizationID: apctx.OrganizationID,
|
||||||
|
OutletID: outletID,
|
||||||
CategoryID: req.CategoryID,
|
CategoryID: req.CategoryID,
|
||||||
SKU: req.SKU,
|
SKU: req.SKU,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@@ -48,18 +57,26 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
|
|||||||
BusinessType: businessType,
|
BusinessType: businessType,
|
||||||
ImageURL: req.ImageURL,
|
ImageURL: req.ImageURL,
|
||||||
PrinterType: req.PrinterType,
|
PrinterType: req.PrinterType,
|
||||||
|
PrintToChecker: req.PrintToChecker,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
Variants: variants,
|
Variants: variants,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateProductRequestToModel(req *contract.UpdateProductRequest) *models.UpdateProductRequest {
|
func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.UpdateProductRequest) *models.UpdateProductRequest {
|
||||||
metadata := req.Metadata
|
metadata := req.Metadata
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
metadata = make(map[string]interface{})
|
metadata = make(map[string]interface{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prioritize outlet_id from context, fallback to request body
|
||||||
|
outletID := apctx.OutletID
|
||||||
|
if outletID == uuid.Nil && req.OutletID != nil {
|
||||||
|
outletID = *req.OutletID
|
||||||
|
}
|
||||||
|
|
||||||
return &models.UpdateProductRequest{
|
return &models.UpdateProductRequest{
|
||||||
|
OutletID: outletID,
|
||||||
CategoryID: req.CategoryID,
|
CategoryID: req.CategoryID,
|
||||||
SKU: req.SKU,
|
SKU: req.SKU,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@@ -68,6 +85,7 @@ func UpdateProductRequestToModel(req *contract.UpdateProductRequest) *models.Upd
|
|||||||
Cost: req.Cost,
|
Cost: req.Cost,
|
||||||
ImageURL: req.ImageURL,
|
ImageURL: req.ImageURL,
|
||||||
PrinterType: req.PrinterType,
|
PrinterType: req.PrinterType,
|
||||||
|
PrintToChecker: req.PrintToChecker,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
IsActive: req.IsActive,
|
IsActive: req.IsActive,
|
||||||
}
|
}
|
||||||
@@ -106,6 +124,7 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
|
|||||||
OutletID: op.OutletID,
|
OutletID: op.OutletID,
|
||||||
OutletName: op.OutletName,
|
OutletName: op.OutletName,
|
||||||
Price: op.Price,
|
Price: op.Price,
|
||||||
|
PrintToChecker: op.PrintToChecker,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +144,7 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
|
|||||||
BusinessType: string(prod.BusinessType),
|
BusinessType: string(prod.BusinessType),
|
||||||
ImageURL: prod.ImageURL,
|
ImageURL: prod.ImageURL,
|
||||||
PrinterType: prod.PrinterType,
|
PrinterType: prod.PrinterType,
|
||||||
|
PrintToChecker: prod.PrintToChecker,
|
||||||
Metadata: prod.Metadata,
|
Metadata: prod.Metadata,
|
||||||
IsActive: prod.IsActive,
|
IsActive: prod.IsActive,
|
||||||
CreatedAt: prod.CreatedAt,
|
CreatedAt: prod.CreatedAt,
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Remove outlet_id column from categories table
|
||||||
|
DROP INDEX IF EXISTS idx_categories_outlet_id;
|
||||||
|
|
||||||
|
ALTER TABLE categories
|
||||||
|
DROP COLUMN IF EXISTS outlet_id;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add outlet_id column to categories table (nullable)
|
||||||
|
ALTER TABLE categories
|
||||||
|
ADD COLUMN outlet_id UUID REFERENCES outlets(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Index for outlet_id filter
|
||||||
|
CREATE INDEX idx_categories_outlet_id ON categories(outlet_id);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE product_outlet_prices DROP COLUMN IF EXISTS print_to_checker;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE product_outlet_prices ADD COLUMN print_to_checker BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
Reference in New Issue
Block a user