Compare commits

..
12 Commits
Author SHA1 Message Date
Efril 75fdb8e847 Merge branch 'hpp' of https://gits.altru.id/apksel-dev/apskel-pos-backend into hpp 2026-04-26 23:04:47 +07:00
Efril 8efa644680 payment amount fix 0 2026-04-26 23:04:31 +07:00
ryan ce99aef289 delete leftover 2026-04-24 18:48:40 +07:00
ryan ba970229a9 Fix HPP 2026-04-24 17:56:38 +07:00
ryan 9b606b4c8b Implementasi HPP std dan real 2026-04-23 11:28:51 +07:00
Efril d3dddea1c7 add category name at product response 2026-04-20 14:21:27 +07:00
Efril 80a78137a0 fix create outlet 2026-04-20 12:31:33 +07:00
efrilm 3826a6b7a9 create outlet route 2026-04-16 19:23:36 +07:00
efrilm d695bedc97 update product response 2026-04-16 17:14:18 +07:00
efrilm 3db4afbce6 fix update ingredients 2026-04-16 15:44:13 +07:00
efrilm 535e4c84f6 product variant route 2026-04-16 14:30:48 +07:00
Efril f25ec1c06f create outlet 2026-04-14 19:09:09 +07:00
22 changed files with 431 additions and 182 deletions
+15 -1
View File
@@ -2,6 +2,7 @@ package appcontext
import ( import (
"context" "context"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@@ -77,5 +78,18 @@ func FromContext(ctx context.Context) *ContextInfo {
if info, ok := ctx.Value(ctxKey).(*ContextInfo); ok { if info, ok := ctx.Value(ctxKey).(*ContextInfo); ok {
return info return info
} }
return nil // Fallback: construct ContextInfo from individual context values
return &ContextInfo{
CorrelationID: value(ctx, CorrelationIDKey),
UserID: uuidValue(ctx, UserIDKey),
OutletID: uuidValue(ctx, OutletIDKey),
OrganizationID: uuidValue(ctx, OrganizationIDKey),
AppVersion: value(ctx, AppVersionKey),
AppID: value(ctx, AppIDKey),
AppType: value(ctx, AppTypeKey),
Platform: value(ctx, PlatformKey),
DeviceOS: value(ctx, DeviceOSKey),
UserLocale: value(ctx, UserLocaleKey),
UserRole: value(ctx, UserRoleKey),
}
} }
+30 -23
View File
@@ -101,18 +101,23 @@ type ProductAnalyticsResponse struct {
Data []ProductAnalyticsData `json:"data"` Data []ProductAnalyticsData `json:"data"`
} }
// ProductAnalyticsData represents individual product analytics data
type ProductAnalyticsData struct { type ProductAnalyticsData struct {
ProductID uuid.UUID `json:"product_id"` ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"` ProductSku string `json:"product_sku"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"` CategoryOrder int `json:"category_order"`
QuantitySold int64 `json:"quantity_sold"` QuantitySold int64 `json:"quantity_sold"`
Revenue float64 `json:"revenue"` Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"` AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"` OrderCount int64 `json:"order_count"`
StandardHppPerUnit float64 `json:"standard_hpp_per_unit"`
StandardHppTotal float64 `json:"standard_hpp_total"`
FifoHppPerUnit float64 `json:"fifo_hpp_per_unit"`
FifoHppTotal float64 `json:"fifo_hpp_total"`
MovingAverageHppPerUnit float64 `json:"moving_average_hpp_per_unit"`
MovingAverageHppTotal float64 `json:"moving_average_hpp_total"`
} }
// ProductAnalyticsPerCategoryRequest represents the request for product analytics per category // ProductAnalyticsPerCategoryRequest represents the request for product analytics per category
@@ -125,21 +130,23 @@ type ProductAnalyticsPerCategoryRequest struct {
// ProductAnalyticsPerCategoryResponse represents the response for product analytics per category // ProductAnalyticsPerCategoryResponse represents the response for product analytics per category
type ProductAnalyticsPerCategoryResponse struct { type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"` Data []ProductAnalyticsPerCategoryData `json:"data"`
} }
// ProductAnalyticsPerCategoryData represents individual category analytics data
type ProductAnalyticsPerCategoryData struct { type ProductAnalyticsPerCategoryData struct {
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
TotalRevenue float64 `json:"total_revenue"` TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"` TotalQuantity int64 `json:"total_quantity"`
ProductCount int64 `json:"product_count"` ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"` OrderCount int64 `json:"order_count"`
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
} }
// DashboardAnalyticsRequest represents the request for dashboard analytics // DashboardAnalyticsRequest represents the request for dashboard analytics
+1 -1
View File
@@ -185,7 +185,7 @@ type CreatePaymentRequest struct {
type CreatePaymentOrderItemRequest struct { type CreatePaymentOrderItemRequest struct {
OrderItemID uuid.UUID `json:"order_item_id" validate:"required"` OrderItemID uuid.UUID `json:"order_item_id" validate:"required"`
Amount float64 `json:"amount" validate:"required,min=0"` Amount float64 `json:"amount" validate:"min=0"`
} }
type PaymentResponse struct { type PaymentResponse struct {
+1
View File
@@ -59,6 +59,7 @@ type ProductResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"`
SKU *string `json:"sku"` SKU *string `json:"sku"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description"` Description *string `json:"description"`
+25 -18
View File
@@ -27,28 +27,35 @@ type SalesAnalytics struct {
NetSales float64 `json:"net_sales"` NetSales float64 `json:"net_sales"`
} }
// ProductAnalytics represents product analytics data
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"`
ProductSku string `json:"product_sku"` ProductSku string `json:"product_sku"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"` CategoryOrder int `json:"category_order"`
QuantitySold int64 `json:"quantity_sold"` QuantitySold int64 `json:"quantity_sold"`
Revenue float64 `json:"revenue"` Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"` AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"` OrderCount int64 `json:"order_count"`
StandardHppPerUnit float64 `json:"standard_hpp_per_unit"`
StandardHppTotal float64 `json:"standard_hpp_total"`
FifoHppPerUnit float64 `json:"fifo_hpp_per_unit"`
FifoHppTotal float64 `json:"fifo_hpp_total"`
MovingAverageHppPerUnit float64 `json:"moving_average_hpp_per_unit"`
MovingAverageHppTotal float64 `json:"moving_average_hpp_total"`
} }
// ProductAnalyticsPerCategory represents product analytics data grouped by category
type ProductAnalyticsPerCategory struct { type ProductAnalyticsPerCategory struct {
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
TotalRevenue float64 `json:"total_revenue"` TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"` TotalQuantity int64 `json:"total_quantity"`
ProductCount int64 `json:"product_count"` ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"` OrderCount int64 `json:"order_count"`
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
} }
// DashboardOverview represents dashboard overview data // DashboardOverview represents dashboard overview data
+1 -1
View File
@@ -19,5 +19,5 @@ type Ingredient struct {
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
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"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"` Unit *Unit `gorm:"foreignKey:UnitID;references:ID" json:"unit,omitempty"`
} }
+31
View File
@@ -102,6 +102,37 @@ func (h *OutletHandler) GetOutlet(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, outletResponse, "OutletHandler::GetOutlet") util.HandleResponse(c.Writer, c.Request, outletResponse, "OutletHandler::GetOutlet")
} }
func (h *OutletHandler) CreateOutlet(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.CreateOutletRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("OutletHandler::CreateOutlet -> Failed to bind JSON")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid request body")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "OutletHandler::CreateOutlet")
return
}
req.OrganizationID = contextInfo.OrganizationID
validationError, validationErrorCode := h.outletValidator.ValidateCreateOutletRequest(&req)
if validationError != nil {
logger.FromContext(ctx).WithError(validationError).Error("OutletHandler::CreateOutlet -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "OutletHandler::CreateOutlet")
return
}
outletResponse := h.outletService.CreateOutlet(ctx, &req)
if outletResponse.HasErrors() {
errorResp := outletResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("OutletHandler::CreateOutlet -> Failed to create outlet from service")
}
util.HandleResponse(c.Writer, c.Request, outletResponse, "OutletHandler::CreateOutlet")
}
func (h *OutletHandler) UpdateOutlet(c *gin.Context) { func (h *OutletHandler) UpdateOutlet(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx) contextInfo := appcontext.FromGinContext(ctx)
+9
View File
@@ -4,6 +4,8 @@ import (
"apskel-pos-be/internal/constants" "apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"github.com/google/uuid"
) )
func ProductEntityToModel(entity *entities.Product) *models.Product { func ProductEntityToModel(entity *entities.Product) *models.Product {
@@ -118,10 +120,17 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
} }
} }
// Get category name from the Category relation
categoryName := ""
if entity.Category.ID != uuid.Nil {
categoryName = entity.Category.Name
}
return &models.ProductResponse{ return &models.ProductResponse{
ID: entity.ID, ID: entity.ID,
OrganizationID: entity.OrganizationID, OrganizationID: entity.OrganizationID,
CategoryID: entity.CategoryID, CategoryID: entity.CategoryID,
CategoryName: categoryName,
SKU: entity.SKU, SKU: entity.SKU,
Name: entity.Name, Name: entity.Name,
Description: entity.Description, Description: entity.Description,
+30 -23
View File
@@ -105,18 +105,23 @@ type ProductAnalyticsResponse struct {
Data []ProductAnalyticsData `json:"data"` Data []ProductAnalyticsData `json:"data"`
} }
// ProductAnalyticsData represents individual product analytics data
type ProductAnalyticsData struct { type ProductAnalyticsData struct {
ProductID uuid.UUID `json:"product_id"` ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
ProductSku string `json:"product_sku"` ProductSku string `json:"product_sku"`
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"` CategoryOrder int `json:"category_order"`
QuantitySold int64 `json:"quantity_sold"` QuantitySold int64 `json:"quantity_sold"`
Revenue float64 `json:"revenue"` Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"` AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"` OrderCount int64 `json:"order_count"`
StandardHppPerUnit float64 `json:"standard_hpp_per_unit"`
StandardHppTotal float64 `json:"standard_hpp_total"`
FifoHppPerUnit float64 `json:"fifo_hpp_per_unit"`
FifoHppTotal float64 `json:"fifo_hpp_total"`
MovingAverageHppPerUnit float64 `json:"moving_average_hpp_per_unit"`
MovingAverageHppTotal float64 `json:"moving_average_hpp_total"`
} }
// ProductAnalyticsPerCategoryRequest represents the request for product analytics per category // ProductAnalyticsPerCategoryRequest represents the request for product analytics per category
@@ -129,21 +134,23 @@ type ProductAnalyticsPerCategoryRequest struct {
// ProductAnalyticsPerCategoryResponse represents the response for product analytics per category // ProductAnalyticsPerCategoryResponse represents the response for product analytics per category
type ProductAnalyticsPerCategoryResponse struct { type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"` Data []ProductAnalyticsPerCategoryData `json:"data"`
} }
// ProductAnalyticsPerCategoryData represents individual category analytics data
type ProductAnalyticsPerCategoryData struct { type ProductAnalyticsPerCategoryData struct {
CategoryID uuid.UUID `json:"category_id"` CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
TotalRevenue float64 `json:"total_revenue"` TotalRevenue float64 `json:"total_revenue"`
TotalQuantity int64 `json:"total_quantity"` TotalQuantity int64 `json:"total_quantity"`
ProductCount int64 `json:"product_count"` ProductCount int64 `json:"product_count"`
OrderCount int64 `json:"order_count"` OrderCount int64 `json:"order_count"`
TotalStandardHpp float64 `json:"total_standard_hpp"`
TotalFifoHpp float64 `json:"total_fifo_hpp"`
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
} }
// DashboardAnalyticsRequest represents the request for dashboard analytics // DashboardAnalyticsRequest represents the request for dashboard analytics
+1
View File
@@ -95,6 +95,7 @@ type ProductResponse struct {
ID uuid.UUID ID uuid.UUID
OrganizationID uuid.UUID OrganizationID uuid.UUID
CategoryID uuid.UUID CategoryID uuid.UUID
CategoryName string
SKU *string SKU *string
Name string Name string
Description *string Description *string
+25 -16
View File
@@ -185,16 +185,22 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
var resultData []models.ProductAnalyticsData var resultData []models.ProductAnalyticsData
for _, data := range analyticsData { for _, data := range analyticsData {
resultData = append(resultData, models.ProductAnalyticsData{ resultData = append(resultData, models.ProductAnalyticsData{
ProductID: data.ProductID, ProductID: data.ProductID,
ProductName: data.ProductName, ProductName: data.ProductName,
ProductSku: data.ProductSku, ProductSku: data.ProductSku,
CategoryID: data.CategoryID, CategoryID: data.CategoryID,
CategoryName: data.CategoryName, CategoryName: data.CategoryName,
CategoryOrder: data.CategoryOrder, CategoryOrder: data.CategoryOrder,
QuantitySold: data.QuantitySold, QuantitySold: data.QuantitySold,
Revenue: data.Revenue, Revenue: data.Revenue,
AveragePrice: data.AveragePrice, AveragePrice: data.AveragePrice,
OrderCount: data.OrderCount, OrderCount: data.OrderCount,
StandardHppPerUnit: data.StandardHppPerUnit,
StandardHppTotal: data.StandardHppTotal,
FifoHppPerUnit: data.FifoHppPerUnit,
FifoHppTotal: data.FifoHppTotal,
MovingAverageHppPerUnit: data.MovingAverageHppPerUnit,
MovingAverageHppTotal: data.MovingAverageHppTotal,
}) })
} }
@@ -223,12 +229,15 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont
var resultData []models.ProductAnalyticsPerCategoryData var resultData []models.ProductAnalyticsPerCategoryData
for _, data := range analyticsData { for _, data := range analyticsData {
resultData = append(resultData, models.ProductAnalyticsPerCategoryData{ resultData = append(resultData, models.ProductAnalyticsPerCategoryData{
CategoryID: data.CategoryID, CategoryID: data.CategoryID,
CategoryName: data.CategoryName, CategoryName: data.CategoryName,
TotalRevenue: data.TotalRevenue, TotalRevenue: data.TotalRevenue,
TotalQuantity: data.TotalQuantity, TotalQuantity: data.TotalQuantity,
ProductCount: data.ProductCount, ProductCount: data.ProductCount,
OrderCount: data.OrderCount, OrderCount: data.OrderCount,
TotalStandardHpp: data.TotalStandardHpp,
TotalFifoHpp: data.TotalFifoHpp,
TotalMovingAverageHpp: data.TotalMovingAverageHpp,
}) })
} }
+4 -1
View File
@@ -180,7 +180,10 @@ func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid.
} }
// Update fields // Update fields
existingIngredient.OutletID = req.OutletID if req.OutletID != nil {
existingIngredient.OutletID = req.OutletID
}
existingIngredient.Name = req.Name existingIngredient.Name = req.Name
existingIngredient.UnitID = req.UnitID existingIngredient.UnitID = req.UnitID
existingIngredient.Cost = req.Cost existingIngredient.Cost = req.Cost
+2 -2
View File
@@ -64,7 +64,7 @@ func (p *OutletProcessorImpl) GetOutletByID(ctx context.Context, organizationID,
func (p *OutletProcessorImpl) CreateOutlet(ctx context.Context, req *models.CreateOutletRequest) (*models.OutletResponse, error) { func (p *OutletProcessorImpl) CreateOutlet(ctx context.Context, req *models.CreateOutletRequest) (*models.OutletResponse, error) {
// Get organization ID from context // Get organization ID from context
contextInfo := appcontext.FromContext(ctx) contextInfo := appcontext.FromContext(ctx)
if contextInfo.OrganizationID == uuid.Nil { if contextInfo == nil || contextInfo.OrganizationID == uuid.Nil {
return nil, fmt.Errorf("organization ID not found in context") return nil, fmt.Errorf("organization ID not found in context")
} }
@@ -124,7 +124,7 @@ func (p *OutletProcessorImpl) UpdateOutlet(ctx context.Context, outletID uuid.UU
func (p *OutletProcessorImpl) DeleteOutlet(ctx context.Context, outletID uuid.UUID) error { func (p *OutletProcessorImpl) DeleteOutlet(ctx context.Context, outletID uuid.UUID) error {
contextInfo := appcontext.FromContext(ctx) contextInfo := appcontext.FromContext(ctx)
if contextInfo.OrganizationID == uuid.Nil { if contextInfo == nil || contextInfo.OrganizationID == uuid.Nil {
return fmt.Errorf("organization ID not found in context") return fmt.Errorf("organization ID not found in context")
} }
@@ -1,12 +1,11 @@
package processor package processor
import ( import (
"context"
"fmt"
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers" "apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"context"
"fmt"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -368,6 +367,8 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
return nil, fmt.Errorf("purchase order not found: %w", err) return nil, fmt.Errorf("purchase order not found: %w", err)
} }
fmt.Println("status:", po.Status)
// Check if status is changing to "received" and current status is not "received" // Check if status is changing to "received" and current status is not "received"
if status == "received" && po.Status != "received" { if status == "received" && po.Status != "received" {
// Get purchase order with items for inventory update // Get purchase order with items for inventory update
+56 -3
View File
@@ -124,11 +124,45 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity) WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity)
ELSE 0 ELSE 0
END as average_price, END as average_price,
COUNT(DISTINCT oi.order_id) as order_count COUNT(DISTINCT oi.order_id) as order_count,
COALESCE((
SELECT SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost)
FROM product_recipes pr
JOIN ingredients i ON pr.ingredient_id = i.id
WHERE pr.product_id = p.id
), p.cost, 0) as standard_hpp_per_unit,
COALESCE((
SELECT SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost)
FROM product_recipes pr
JOIN ingredients i ON pr.ingredient_id = i.id
WHERE pr.product_id = p.id
), p.cost, 0) * COALESCE(SUM(oi.quantity), 0) as standard_hpp_total,
CASE
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(oi.quantity)
ELSE 0
END as fifo_hpp_per_unit,
COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total,
COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit,
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(oi.quantity), 0) as moving_average_hpp_total
`). `).
Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id"). Joins("JOIN categories c ON p.category_id = c.id").
Joins("JOIN orders o ON oi.order_id = o.id"). Joins("JOIN orders o ON oi.order_id = o.id").
Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id",
r.db.Table("product_recipes pr2").
Select("pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit").
Joins("JOIN ingredients ing ON pr2.ingredient_id = ing.id").
Joins("LEFT JOIN (?) ma ON ma.ingredient_id = pr2.ingredient_id",
r.db.Table("inventory_movements im").
Select("im.item_id as ingredient_id, CASE WHEN SUM(im.quantity) > 0 THEN SUM(im.total_cost) / SUM(im.quantity) ELSE 0 END as moving_avg_cost").
Where("im.movement_type = ?", "purchase").
Where("im.item_type = ?", "INGREDIENT").
Where("im.organization_id = ?", organizationID).
Where("im.created_at <= ?", dateTo).
Group("im.item_id"),
).
Group("pr2.product_id"),
).
Where("o.organization_id = ?", organizationID). Where("o.organization_id = ?", organizationID).
Where("o.is_void = ?", false). Where("o.is_void = ?", false).
Where("o.is_refund = ?", false). Where("o.is_refund = ?", false).
@@ -141,7 +175,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
} }
err := query. err := query.
Group("p.id, p.name, c.id, c.name, c.order"). Group("p.id, p.name, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit").
Order("revenue DESC"). Order("revenue DESC").
Limit(limit). Limit(limit).
Scan(&results).Error Scan(&results).Error
@@ -160,11 +194,30 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
COUNT(DISTINCT p.id) as product_count, COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT oi.order_id) as order_count COUNT(DISTINCT oi.order_id) as order_count,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_standard_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) ELSE 0 END), 0) as total_moving_average_hpp
`). `).
Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN products p ON oi.product_id = p.id").
Joins("JOIN categories c ON p.category_id = c.id"). Joins("JOIN categories c ON p.category_id = c.id").
Joins("JOIN orders o ON oi.order_id = o.id"). Joins("JOIN orders o ON oi.order_id = o.id").
Joins("LEFT JOIN (SELECT pr.product_id, SUM(pr.quantity * (1 + COALESCE(pr.waste_percentage, 0)/100.0) * i.cost) as hpp_per_unit FROM product_recipes pr JOIN ingredients i ON pr.ingredient_id = i.id GROUP BY pr.product_id) shpp ON shpp.product_id = p.id").
Joins("LEFT JOIN (?) mahpp ON mahpp.product_id = p.id",
r.db.Table("product_recipes pr2").
Select("pr2.product_id, SUM(pr2.quantity * (1 + COALESCE(pr2.waste_percentage, 0)/100.0) * COALESCE(ma.moving_avg_cost, ing.cost)) as hpp_per_unit").
Joins("JOIN ingredients ing ON pr2.ingredient_id = ing.id").
Joins("LEFT JOIN (?) ma ON ma.ingredient_id = pr2.ingredient_id",
r.db.Table("inventory_movements im").
Select("im.item_id as ingredient_id, CASE WHEN SUM(im.quantity) > 0 THEN SUM(im.total_cost) / SUM(im.quantity) ELSE 0 END as moving_avg_cost").
Where("im.movement_type = ?", "purchase").
Where("im.item_type = ?", "INGREDIENT").
Where("im.organization_id = ?", organizationID).
Where("im.created_at <= ?", dateTo).
Group("im.item_id"),
).
Group("pr2.product_id"),
).
Where("o.organization_id = ?", organizationID). Where("o.organization_id = ?", organizationID).
Where("o.is_void = ?", false). Where("o.is_void = ?", false).
Where("o.is_refund = ?", false). Where("o.is_refund = ?", false).
+5 -1
View File
@@ -66,7 +66,11 @@ func (r *IngredientRepository) GetAll(ctx context.Context, organizationID uuid.U
} }
func (r *IngredientRepository) Update(ctx context.Context, ingredient *entities.Ingredient) error { func (r *IngredientRepository) Update(ctx context.Context, ingredient *entities.Ingredient) error {
result := r.db.WithContext(ctx).Where("id = ? AND organization_id = ?", ingredient.ID, ingredient.OrganizationID).Save(ingredient) result := r.db.WithContext(ctx).
Model(&entities.Ingredient{}).
Where("id = ? AND organization_id = ?", ingredient.ID, ingredient.OrganizationID).
Select("outlet_id", "name", "unit_id", "cost", "stock", "is_semi_finished", "is_active", "metadata", "updated_at").
Updates(ingredient)
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
} }
@@ -27,6 +27,7 @@ func (r *ProductRecipeRepository) GetByID(ctx context.Context, id, organizationI
Preload("Product"). Preload("Product").
Preload("ProductVariant"). Preload("ProductVariant").
Preload("Ingredient"). Preload("Ingredient").
Preload("Ingredient.Unit").
Where("id = ? AND organization_id = ?", id, organizationID). Where("id = ? AND organization_id = ?", id, organizationID).
First(&productRecipe).Error First(&productRecipe).Error
if err != nil { if err != nil {
@@ -41,6 +42,7 @@ func (r *ProductRecipeRepository) GetByProductID(ctx context.Context, productID,
Preload("Product"). Preload("Product").
Preload("ProductVariant"). Preload("ProductVariant").
Preload("Ingredient"). Preload("Ingredient").
Preload("Ingredient.Unit").
Where("product_id = ? AND organization_id = ?", productID, organizationID). Where("product_id = ? AND organization_id = ?", productID, organizationID).
Order("created_at DESC"). Order("created_at DESC").
Find(&productRecipes).Error Find(&productRecipes).Error
@@ -56,6 +58,7 @@ func (r *ProductRecipeRepository) GetByProductAndVariantID(ctx context.Context,
Preload("Product"). Preload("Product").
Preload("ProductVariant"). Preload("ProductVariant").
Preload("Ingredient"). Preload("Ingredient").
Preload("Ingredient.Unit").
Where("product_id = ? AND organization_id = ?", productID, organizationID) Where("product_id = ? AND organization_id = ?", productID, organizationID)
if variantID != nil { if variantID != nil {
@@ -77,6 +80,7 @@ func (r *ProductRecipeRepository) GetByIngredientID(ctx context.Context, ingredi
Preload("Product"). Preload("Product").
Preload("ProductVariant"). Preload("ProductVariant").
Preload("Ingredient"). Preload("Ingredient").
Preload("Ingredient.Unit").
Where("ingredient_id = ? AND organization_id = ?", ingredientID, organizationID). Where("ingredient_id = ? AND organization_id = ?", ingredientID, organizationID).
Order("created_at DESC"). Order("created_at DESC").
Find(&productRecipes).Error Find(&productRecipes).Error
+11 -59
View File
@@ -50,65 +50,7 @@ type Router struct {
customerAuthMiddleware *middleware.CustomerAuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware
} }
func NewRouter(cfg *config.Config, func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware) *Router {
healthHandler *handler.HealthHandler,
authService service.AuthService,
authMiddleware *middleware.AuthMiddleware,
userService *service.UserServiceImpl,
userValidator *validator.UserValidatorImpl,
organizationService service.OrganizationService,
organizationValidator validator.OrganizationValidator,
outletService service.OutletService,
outletValidator validator.OutletValidator,
outletSettingService service.OutletSettingService,
categoryService service.CategoryService,
categoryValidator validator.CategoryValidator,
productService service.ProductService,
productValidator validator.ProductValidator,
productVariantService service.ProductVariantService,
productVariantValidator validator.ProductVariantValidator,
inventoryService service.InventoryService,
inventoryValidator validator.InventoryValidator,
orderService service.OrderService,
orderValidator validator.OrderValidator,
fileService service.FileService,
fileValidator validator.FileValidator,
customerService service.CustomerService,
customerValidator validator.CustomerValidator,
paymentMethodService service.PaymentMethodService,
paymentMethodValidator validator.PaymentMethodValidator,
analyticsService *service.AnalyticsServiceImpl,
reportService service.ReportService,
tableService *service.TableServiceImpl,
tableValidator *validator.TableValidator,
unitService handler.UnitService,
ingredientService handler.IngredientService,
productRecipeService service.ProductRecipeService,
vendorService service.VendorService,
vendorValidator validator.VendorValidator,
purchaseOrderService service.PurchaseOrderService,
purchaseOrderValidator validator.PurchaseOrderValidator,
unitConverterService service.IngredientUnitConverterService,
unitConverterValidator validator.IngredientUnitConverterValidator,
chartOfAccountTypeService service.ChartOfAccountTypeService,
chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator,
chartOfAccountService service.ChartOfAccountService,
chartOfAccountValidator validator.ChartOfAccountValidator,
accountService service.AccountService,
accountValidator validator.AccountValidator,
orderIngredientTransactionService service.OrderIngredientTransactionService,
orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator,
gamificationService service.GamificationService,
gamificationValidator validator.GamificationValidator,
rewardService service.RewardService,
rewardValidator validator.RewardValidator,
campaignService service.CampaignService,
campaignValidator validator.CampaignValidator,
customerAuthService service.CustomerAuthService,
customerAuthValidator validator.CustomerAuthValidator,
customerPointsService service.CustomerPointsService,
spinGameService service.SpinGameService,
customerAuthMiddleware *middleware.CustomerAuthMiddleware) *Router {
return &Router{ return &Router{
config: cfg, config: cfg,
@@ -146,6 +88,7 @@ func NewRouter(cfg *config.Config,
spinGameHandler: handler.NewSpinGameHandler(spinGameService), spinGameHandler: handler.NewSpinGameHandler(spinGameService),
authMiddleware: authMiddleware, authMiddleware: authMiddleware,
customerAuthMiddleware: customerAuthMiddleware, customerAuthMiddleware: customerAuthMiddleware,
productVariantHandler: handler.NewProductVariantHandler(productVariantService, productVariantValidator),
} }
} }
@@ -270,6 +213,14 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
products.DELETE("/:id", r.productHandler.DeleteProduct) products.DELETE("/:id", r.productHandler.DeleteProduct)
} }
productVariants := protected.Group("/product-variants")
{
productVariants.POST("", r.productVariantHandler.CreateProductVariant)
productVariants.PUT("/:id", r.productVariantHandler.UpdateProductVariant)
productVariants.DELETE("/:id", r.productVariantHandler.DeleteProductVariant)
productVariants.GET("/:id", r.productVariantHandler.GetProductVariant)
}
inventory := protected.Group("/inventory") inventory := protected.Group("/inventory")
inventory.Use(r.authMiddleware.RequireAdminOrManager()) inventory.Use(r.authMiddleware.RequireAdminOrManager())
{ {
@@ -594,6 +545,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
outlets := protected.Group("/outlets") outlets := protected.Group("/outlets")
outlets.Use(r.authMiddleware.RequireAdminOrManager()) outlets.Use(r.authMiddleware.RequireAdminOrManager())
{ {
outlets.POST("", r.outletHandler.CreateOutlet)
outlets.GET("/list", r.outletHandler.ListOutlets) outlets.GET("/list", r.outletHandler.ListOutlets)
outlets.GET("/detail/:id", r.outletHandler.GetOutlet) outlets.GET("/detail/:id", r.outletHandler.GetOutlet)
outlets.PUT("/detail/:id", r.outletHandler.UpdateOutlet) outlets.PUT("/detail/:id", r.outletHandler.UpdateOutlet)
+39 -24
View File
@@ -155,16 +155,22 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
var data []contract.ProductAnalyticsData var data []contract.ProductAnalyticsData
for _, item := range resp.Data { for _, item := range resp.Data {
data = append(data, contract.ProductAnalyticsData{ data = append(data, contract.ProductAnalyticsData{
ProductID: item.ProductID, ProductID: item.ProductID,
ProductName: item.ProductName, ProductName: item.ProductName,
ProductSku: item.ProductSku, ProductSku: item.ProductSku,
CategoryID: item.CategoryID, CategoryID: item.CategoryID,
CategoryName: item.CategoryName, CategoryName: item.CategoryName,
CategoryOrder: item.CategoryOrder, CategoryOrder: item.CategoryOrder,
QuantitySold: item.QuantitySold, QuantitySold: item.QuantitySold,
Revenue: item.Revenue, Revenue: item.Revenue,
AveragePrice: item.AveragePrice, AveragePrice: item.AveragePrice,
OrderCount: item.OrderCount, OrderCount: item.OrderCount,
StandardHppPerUnit: item.StandardHppPerUnit,
StandardHppTotal: item.StandardHppTotal,
FifoHppPerUnit: item.FifoHppPerUnit,
FifoHppTotal: item.FifoHppTotal,
MovingAverageHppPerUnit: item.MovingAverageHppPerUnit,
MovingAverageHppTotal: item.MovingAverageHppTotal,
}) })
} }
@@ -208,12 +214,15 @@ func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPer
var data []contract.ProductAnalyticsPerCategoryData var data []contract.ProductAnalyticsPerCategoryData
for _, item := range resp.Data { for _, item := range resp.Data {
data = append(data, contract.ProductAnalyticsPerCategoryData{ data = append(data, contract.ProductAnalyticsPerCategoryData{
CategoryID: item.CategoryID, CategoryID: item.CategoryID,
CategoryName: item.CategoryName, CategoryName: item.CategoryName,
TotalRevenue: item.TotalRevenue, TotalRevenue: item.TotalRevenue,
TotalQuantity: item.TotalQuantity, TotalQuantity: item.TotalQuantity,
ProductCount: item.ProductCount, ProductCount: item.ProductCount,
OrderCount: item.OrderCount, OrderCount: item.OrderCount,
TotalStandardHpp: item.TotalStandardHpp,
TotalFifoHpp: item.TotalFifoHpp,
TotalMovingAverageHpp: item.TotalMovingAverageHpp,
}) })
} }
@@ -257,14 +266,20 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
var topProducts []contract.ProductAnalyticsData var topProducts []contract.ProductAnalyticsData
for _, item := range resp.TopProducts { for _, item := range resp.TopProducts {
topProducts = append(topProducts, contract.ProductAnalyticsData{ topProducts = append(topProducts, contract.ProductAnalyticsData{
ProductID: item.ProductID, ProductID: item.ProductID,
ProductName: item.ProductName, ProductName: item.ProductName,
CategoryID: item.CategoryID, CategoryID: item.CategoryID,
CategoryName: item.CategoryName, CategoryName: item.CategoryName,
QuantitySold: item.QuantitySold, QuantitySold: item.QuantitySold,
Revenue: item.Revenue, Revenue: item.Revenue,
AveragePrice: item.AveragePrice, AveragePrice: item.AveragePrice,
OrderCount: item.OrderCount, OrderCount: item.OrderCount,
StandardHppPerUnit: item.StandardHppPerUnit,
StandardHppTotal: item.StandardHppTotal,
FifoHppPerUnit: item.FifoHppPerUnit,
FifoHppTotal: item.FifoHppTotal,
MovingAverageHppPerUnit: item.MovingAverageHppPerUnit,
MovingAverageHppTotal: item.MovingAverageHppTotal,
}) })
} }
@@ -101,6 +101,7 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
ID: prod.ID, ID: prod.ID,
OrganizationID: prod.OrganizationID, OrganizationID: prod.OrganizationID,
CategoryID: prod.CategoryID, CategoryID: prod.CategoryID,
CategoryName: prod.CategoryName,
SKU: prod.SKU, SKU: prod.SKU,
Name: prod.Name, Name: prod.Name,
Description: prod.Description, Description: prod.Description,
@@ -0,0 +1,39 @@
-- =========================
-- DROP ORDER DISCOUNTS
-- =========================
DROP INDEX IF EXISTS idx_order_discounts_discount;
DROP INDEX IF EXISTS idx_order_discounts_outlet;
DROP INDEX IF EXISTS idx_order_discounts_order;
DROP TABLE IF EXISTS order_discounts;
-- =========================
-- DROP DISCOUNT CATEGORIES
-- =========================
DROP INDEX IF EXISTS idx_discount_categories_category;
DROP INDEX IF EXISTS idx_discount_categories_discount;
DROP TABLE IF EXISTS discount_categories;
-- =========================
-- DROP DISCOUNT PRODUCTS
-- =========================
DROP INDEX IF EXISTS idx_discount_products_product;
DROP INDEX IF EXISTS idx_discount_products_discount;
DROP TABLE IF EXISTS discount_products;
-- =========================
-- DROP DISCOUNT OUTLETS
-- =========================
DROP INDEX IF EXISTS idx_discount_outlets_outlet;
DROP INDEX IF EXISTS idx_discount_outlets_discount;
DROP TABLE IF EXISTS discount_outlets;
-- =========================
-- DROP DISCOUNTS
-- =========================
DROP INDEX IF EXISTS idx_discounts_customer_type;
DROP INDEX IF EXISTS idx_discounts_dates;
DROP INDEX IF EXISTS idx_discounts_active;
DROP INDEX IF EXISTS idx_discounts_code;
DROP INDEX IF EXISTS idx_discounts_organization;
DROP INDEX IF EXISTS idx_discounts_campaign;
DROP TABLE IF EXISTS discounts;
@@ -0,0 +1,91 @@
CREATE TABLE discounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID NULL,
organization_id UUID NOT NULL,
code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('percentage', 'fixed_amount', 'free_product')),
value DECIMAL(15,2) NOT NULL,
min_purchase_qty INT DEFAULT 0,
min_purchase_amount DECIMAL(15,2) DEFAULT 0,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
usage_limit_per_customer INT DEFAULT NULL,
usage_limit_total INT DEFAULT NULL,
usage_count INT DEFAULT 0,
customer_type VARCHAR(20) DEFAULT 'all' CHECK (customer_type IN ('all', 'member', 'vip')),
is_stackable BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
priority INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (campaign_id) REFERENCES campaigns(id) ON DELETE SET NULL,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT chk_discount_ownership CHECK (campaign_id IS NOT NULL OR organization_id IS NOT NULL),
CONSTRAINT unique_code_per_org UNIQUE (organization_id, code)
);
CREATE INDEX idx_discounts_campaign ON discounts(campaign_id);
CREATE INDEX idx_discounts_organization ON discounts(organization_id);
CREATE INDEX idx_discounts_code ON discounts(code);
CREATE INDEX idx_discounts_active ON discounts(is_active);
CREATE INDEX idx_discounts_dates ON discounts(start_date, end_date);
CREATE INDEX idx_discounts_customer_type ON discounts(customer_type);
CREATE TABLE discount_outlets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
discount_id UUID NOT NULL,
outlet_id UUID NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (discount_id) REFERENCES discounts(id) ON DELETE CASCADE,
FOREIGN KEY (outlet_id) REFERENCES outlets(id) ON DELETE CASCADE,
CONSTRAINT unique_discount_outlet UNIQUE (discount_id, outlet_id)
);
CREATE INDEX idx_discount_outlets_discount ON discount_outlets(discount_id);
CREATE INDEX idx_discount_outlets_outlet ON discount_outlets(outlet_id);
CREATE TABLE discount_products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
discount_id UUID NOT NULL,
product_id UUID NOT NULL,
rule_type VARCHAR(20) NOT NULL CHECK (rule_type IN ('required', 'free', 'excluded')),
quantity INT DEFAULT 1,
free_quantity INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (discount_id) REFERENCES discounts(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
);
CREATE INDEX idx_discount_products_discount ON discount_products(discount_id);
CREATE INDEX idx_discount_products_product ON discount_products(product_id);
CREATE TABLE discount_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
discount_id UUID NOT NULL,
category_id UUID NOT NULL,
rule_type VARCHAR(20) NOT NULL CHECK (rule_type IN ('included', 'excluded')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (discount_id) REFERENCES discounts(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE INDEX idx_discount_categories_discount ON discount_categories(discount_id);
CREATE INDEX idx_discount_categories_category ON discount_categories(category_id);
CREATE TABLE order_discounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL,
outlet_id UUID NOT NULL,
discount_id UUID NOT NULL,
discount_amount DECIMAL(15,2) NOT NULL,
applied_rules JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (outlet_id) REFERENCES outlets(id) ON DELETE RESTRICT,
FOREIGN KEY (discount_id) REFERENCES discounts(id) ON DELETE RESTRICT
);
CREATE INDEX idx_order_discounts_order ON order_discounts(order_id);
CREATE INDEX idx_order_discounts_outlet ON order_discounts(outlet_id);
CREATE INDEX idx_order_discounts_discount ON order_discounts(discount_id);