Compare commits

..
21 changed files with 955 additions and 166 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# 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
WORKDIR /src
COPY go.mod go.sum ./
+57
View File
@@ -83,6 +83,63 @@ type SalesAnalyticsData struct {
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
type ProductAnalyticsRequest struct {
OrganizationID uuid.UUID
+45
View File
@@ -27,6 +27,51 @@ type SalesAnalytics struct {
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 {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
+24
View File
@@ -85,6 +85,30 @@ func (h *AnalyticsHandler) GetSalesAnalytics(c *gin.Context) {
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) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
+9 -41
View File
@@ -53,14 +53,6 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
}
func (h *OrderHandler) GetOrderByID(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
if contextInfo.OutletID == uuid.Nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "OrderHandler::GetOrderByID", "outlet ID is required")}), "OrderHandler::GetOrderByID")
return
}
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
@@ -68,7 +60,7 @@ func (h *OrderHandler) GetOrderByID(c *gin.Context) {
return
}
response, err := h.orderService.GetOrderByID(c.Request.Context(), id, contextInfo.OutletID)
response, err := h.orderService.GetOrderByID(c.Request.Context(), id)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::GetOrderByID", err.Error())}), "OrderHandler::GetOrderByID")
return
@@ -79,14 +71,6 @@ func (h *OrderHandler) GetOrderByID(c *gin.Context) {
}
func (h *OrderHandler) UpdateOrder(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
if contextInfo.OutletID == uuid.Nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "OrderHandler::UpdateOrder", "outlet ID is required")}), "OrderHandler::UpdateOrder")
return
}
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
@@ -101,7 +85,7 @@ func (h *OrderHandler) UpdateOrder(c *gin.Context) {
}
modelReq := transformer.UpdateOrderContractToModel(&req)
response, err := h.orderService.UpdateOrder(c.Request.Context(), id, modelReq, contextInfo.OutletID)
response, err := h.orderService.UpdateOrder(c.Request.Context(), id, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::UpdateOrder", err.Error())}), "OrderHandler::UpdateOrder")
return
@@ -112,14 +96,6 @@ func (h *OrderHandler) UpdateOrder(c *gin.Context) {
}
func (h *OrderHandler) AddToOrder(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
if contextInfo.OutletID == uuid.Nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "OrderHandler::AddToOrder", "outlet ID is required")}), "OrderHandler::AddToOrder")
return
}
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
@@ -134,7 +110,7 @@ func (h *OrderHandler) AddToOrder(c *gin.Context) {
}
modelReq := transformer.AddToOrderContractToModel(&req)
response, err := h.orderService.AddToOrder(c.Request.Context(), id, modelReq, contextInfo.OutletID)
response, err := h.orderService.AddToOrder(c.Request.Context(), id, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::AddToOrder", err.Error())}), "OrderHandler::AddToOrder")
return
@@ -191,7 +167,7 @@ func (h *OrderHandler) VoidOrder(c *gin.Context) {
}
modelReq := transformer.VoidOrderContractToModel(&req)
if err := h.orderService.VoidOrder(ctx, modelReq, userID, contextInfo.OutletID); err != nil {
if err := h.orderService.VoidOrder(ctx, modelReq, userID); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::VoidOrder", err.Error())}), "OrderHandler::VoidOrder")
return
}
@@ -224,7 +200,7 @@ func (h *OrderHandler) RefundOrder(c *gin.Context) {
return
}
if err := h.orderService.RefundOrder(ctx, id, modelReq, userID, contextInfo.OutletID); err != nil {
if err := h.orderService.RefundOrder(ctx, id, modelReq, userID); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::RefundOrder", err.Error())}), "OrderHandler::RefundOrder")
return
}
@@ -233,14 +209,6 @@ func (h *OrderHandler) RefundOrder(c *gin.Context) {
}
func (h *OrderHandler) CreatePayment(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
if contextInfo.OutletID == uuid.Nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "OrderHandler::CreatePayment", "outlet ID is required")}), "OrderHandler::CreatePayment")
return
}
var req contract.CreatePaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "OrderHandler::CreatePayment", err.Error())}), "OrderHandler::CreatePayment")
@@ -254,7 +222,7 @@ func (h *OrderHandler) CreatePayment(c *gin.Context) {
modelReq := transformer.CreatePaymentContractToModel(&req)
response, err := h.orderService.CreatePayment(c.Request.Context(), modelReq, contextInfo.OutletID)
response, err := h.orderService.CreatePayment(c.Request.Context(), modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::CreatePayment", err.Error())}), "OrderHandler::CreatePayment")
return
@@ -292,7 +260,7 @@ func (h *OrderHandler) RefundPayment(c *gin.Context) {
return
}
if err := h.orderService.RefundPayment(ctx, paymentID, req.RefundAmount, req.Reason, userID, contextInfo.OutletID); err != nil {
if err := h.orderService.RefundPayment(ctx, paymentID, req.RefundAmount, req.Reason, userID); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::RefundPayment", err.Error())}), "OrderHandler::RefundPayment")
return
}
@@ -319,7 +287,7 @@ func (h *OrderHandler) SetOrderCustomer(c *gin.Context) {
modelReq := transformer.SetOrderCustomerContractToModel(&req)
response, err := h.orderService.SetOrderCustomer(ctx, orderID, modelReq, contextInfo.OrganizationID, contextInfo.OutletID)
response, err := h.orderService.SetOrderCustomer(ctx, orderID, modelReq, contextInfo.OrganizationID)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::SetOrderCustomer", err.Error())}), "OrderHandler::SetOrderCustomer")
return
@@ -346,7 +314,7 @@ func (h *OrderHandler) SplitBill(c *gin.Context) {
req.OrganizationID = contextInfo.OrganizationID
modelReq := transformer.SplitBillContractToModel(&req)
response, err := h.orderService.SplitBill(c.Request.Context(), modelReq, contextInfo.OutletID)
response, err := h.orderService.SplitBill(c.Request.Context(), modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::SplitBill", err.Error())}), "OrderHandler::SplitBill")
return
+63
View File
@@ -87,6 +87,69 @@ type SalesAnalyticsData struct {
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
type ProductAnalyticsRequest struct {
OrganizationID uuid.UUID `validate:"required"`
+72
View File
@@ -12,6 +12,7 @@ import (
type AnalyticsProcessor interface {
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, 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)
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, 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
}
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) {
// Validate date range
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)
}
+45 -44
View File
@@ -17,33 +17,33 @@ import (
type OrderProcessor interface {
CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error)
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest, outletID uuid.UUID) (*models.AddToOrderResponse, error)
UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest, outletID uuid.UUID) (*models.OrderResponse, error)
GetOrderByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*models.OrderResponse, error)
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error)
UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error)
GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error)
ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error)
VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID, outletID uuid.UUID) error
RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID, outletID uuid.UUID) error
CreatePayment(ctx context.Context, req *models.CreatePaymentRequest, outletID uuid.UUID) (*models.PaymentResponse, error)
RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID, outletID uuid.UUID) error
SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID, outletID uuid.UUID) (*models.SetOrderCustomerResponse, error)
SplitBill(ctx context.Context, req *models.SplitBillRequest, outletID uuid.UUID) (*models.SplitBillResponse, error)
VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error
RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID) error
CreatePayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error)
RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error
SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID) (*models.SetOrderCustomerResponse, error)
SplitBill(ctx context.Context, req *models.SplitBillRequest) (*models.SplitBillResponse, error)
}
type OrderRepository interface {
Create(ctx context.Context, order *entities.Order) error
GetByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*entities.Order, error)
GetWithRelations(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*entities.Order, error)
GetByID(ctx context.Context, id uuid.UUID) (*entities.Order, error)
GetWithRelations(ctx context.Context, id uuid.UUID) (*entities.Order, error)
Update(ctx context.Context, order *entities.Order) error
UpdateStatusSuccess(ctx context.Context, id uuid.UUID, orderStatus entities.OrderStatus, paymentStatus entities.PaymentStatus, outletID uuid.UUID) error
UpdateStatusSuccess(ctx context.Context, id uuid.UUID, orderStatus entities.OrderStatus, paymentStatus entities.PaymentStatus) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Order, int64, error)
GetByOrderNumber(ctx context.Context, orderNumber string) (*entities.Order, error)
ExistsByOrderNumber(ctx context.Context, orderNumber string) (bool, error)
VoidOrder(ctx context.Context, id uuid.UUID, reason string, voidedBy uuid.UUID, outletID uuid.UUID) error
VoidOrderWithStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, reason string, voidedBy uuid.UUID, outletID uuid.UUID) error
RefundOrder(ctx context.Context, id uuid.UUID, reason string, refundedBy uuid.UUID, outletID uuid.UUID) error
UpdatePaymentStatus(ctx context.Context, id uuid.UUID, status entities.PaymentStatus, outletID uuid.UUID) error
UpdateStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, outletID uuid.UUID) error
VoidOrder(ctx context.Context, id uuid.UUID, reason string, voidedBy uuid.UUID) error
VoidOrderWithStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, reason string, voidedBy uuid.UUID) error
RefundOrder(ctx context.Context, id uuid.UUID, reason string, refundedBy uuid.UUID) error
UpdatePaymentStatus(ctx context.Context, id uuid.UUID, status entities.PaymentStatus) error
UpdateStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus) error
GetNextOrderNumber(ctx context.Context, organizationID, outletID uuid.UUID) (string, error)
}
@@ -260,7 +260,7 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
}
}
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, order.ID, uuid.Nil)
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, order.ID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve created order: %w", err)
}
@@ -269,8 +269,8 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
return response, nil
}
func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest, outletID uuid.UUID) (*models.AddToOrderResponse, error) {
order, err := p.orderRepo.GetByID(ctx, orderID, outletID)
func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error) {
order, err := p.orderRepo.GetByID(ctx, orderID)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
@@ -415,7 +415,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
addedItemResponses = append(addedItemResponses, itemResponse)
}
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID, outletID)
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve updated order: %w", err)
}
@@ -430,8 +430,9 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
}, nil
}
func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest, outletID uuid.UUID) (*models.OrderResponse, error) {
order, err := p.orderRepo.GetByID(ctx, id, outletID)
func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) {
// Get existing order
order, err := p.orderRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
@@ -476,11 +477,11 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req
order.Status = entities.OrderStatusCompleted
order.PaymentStatus = entities.PaymentStatusCompleted
if err := p.orderRepo.UpdateStatusSuccess(ctx, order.ID, order.Status, order.PaymentStatus, outletID); err != nil {
if err := p.orderRepo.UpdateStatusSuccess(ctx, order.ID, order.Status, order.PaymentStatus); err != nil {
return nil, fmt.Errorf("failed to update order: %w", err)
}
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, id, outletID)
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to retrieve updated order: %w", err)
}
@@ -489,8 +490,8 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req
return response, nil
}
func (p *OrderProcessorImpl) GetOrderByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*models.OrderResponse, error) {
order, err := p.orderRepo.GetWithRelations(ctx, id, outletID)
func (p *OrderProcessorImpl) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) {
order, err := p.orderRepo.GetWithRelations(ctx, id)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
@@ -573,12 +574,12 @@ func (p *OrderProcessorImpl) ListOrders(ctx context.Context, req *models.ListOrd
}, nil
}
func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID, outletID uuid.UUID) error {
func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error {
if req.OrderID != req.OrderID {
return fmt.Errorf("order ID mismatch: path parameter does not match request body")
}
order, err := p.orderRepo.GetByID(ctx, req.OrderID, outletID)
order, err := p.orderRepo.GetByID(ctx, req.OrderID)
if err != nil {
return fmt.Errorf("order not found: %w", err)
}
@@ -592,7 +593,7 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
}
if req.Type == "ALL" {
if err := p.orderRepo.VoidOrderWithStatus(ctx, req.OrderID, entities.OrderStatusCancelled, req.Reason, voidedBy, outletID); err != nil {
if err := p.orderRepo.VoidOrderWithStatus(ctx, req.OrderID, entities.OrderStatusCancelled, req.Reason, voidedBy); err != nil {
return fmt.Errorf("failed to void order: %w", err)
}
} else if req.Type == "ITEM" {
@@ -658,7 +659,7 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
}
if !hasActiveItems {
if err := p.orderRepo.VoidOrderWithStatus(ctx, req.OrderID, entities.OrderStatusCancelled, req.Reason, voidedBy, outletID); err != nil {
if err := p.orderRepo.VoidOrderWithStatus(ctx, req.OrderID, entities.OrderStatusCancelled, req.Reason, voidedBy); err != nil {
return fmt.Errorf("failed to void order after all items voided: %w", err)
}
}
@@ -669,8 +670,8 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return nil
}
func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID, outletID uuid.UUID) error {
order, err := p.orderRepo.GetWithRelations(ctx, id, outletID)
func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID) error {
order, err := p.orderRepo.GetWithRelations(ctx, id)
if err != nil {
return fmt.Errorf("order not found: %w", err)
}
@@ -702,7 +703,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
}
// Mark order as refunded
if err := p.orderRepo.RefundOrder(ctx, id, reason, refundedBy, outletID); err != nil {
if err := p.orderRepo.RefundOrder(ctx, id, reason, refundedBy); err != nil {
return fmt.Errorf("failed to mark order as refunded: %w", err)
}
@@ -756,7 +757,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
}
// Mark order as refunded
if err := p.orderRepo.RefundOrder(ctx, id, reason, refundedBy, outletID); err != nil {
if err := p.orderRepo.RefundOrder(ctx, id, reason, refundedBy); err != nil {
return fmt.Errorf("failed to mark order as refunded: %w", err)
}
}
@@ -764,8 +765,8 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
return nil
}
func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.CreatePaymentRequest, outletID uuid.UUID) (*models.PaymentResponse, error) {
order, err := p.orderRepo.GetByID(ctx, req.OrderID, outletID)
func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) {
order, err := p.orderRepo.GetByID(ctx, req.OrderID)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
@@ -802,7 +803,7 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea
return response, nil
}
func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID, outletID uuid.UUID) error {
func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error {
payment, err := p.paymentRepo.GetByID(ctx, paymentID)
if err != nil {
return fmt.Errorf("payment not found: %w", err)
@@ -874,7 +875,7 @@ func (p *OrderProcessorImpl) updateOrderStatus(ctx context.Context, orderID uuid
PaymentStatus: entities.PaymentStatusCompleted,
}
if err := p.orderRepo.UpdateStatusSuccess(ctx, orderID, orderUpdate.Status, orderUpdate.PaymentStatus, uuid.Nil); err != nil {
if err := p.orderRepo.UpdateStatusSuccess(ctx, orderID, orderUpdate.Status, orderUpdate.PaymentStatus); err != nil {
return fmt.Errorf("failed to update order status: %w", err)
}
@@ -1070,7 +1071,7 @@ func (p *OrderProcessorImpl) processRefund(ctx context.Context, paymentID uuid.U
}
func (p *OrderProcessorImpl) updateOrderRefundAmount(ctx context.Context, orderID uuid.UUID, refundAmount float64) error {
order, err := p.orderRepo.GetByID(ctx, orderID, uuid.Nil)
order, err := p.orderRepo.GetByID(ctx, orderID)
if err != nil {
return fmt.Errorf("failed to get order: %w", err)
}
@@ -1083,8 +1084,8 @@ func (p *OrderProcessorImpl) updateOrderRefundAmount(ctx context.Context, orderI
return nil
}
func (p *OrderProcessorImpl) SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID, outletID uuid.UUID) (*models.SetOrderCustomerResponse, error) {
order, err := p.orderRepo.GetByID(ctx, orderID, outletID)
func (p *OrderProcessorImpl) SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID) (*models.SetOrderCustomerResponse, error) {
order, err := p.orderRepo.GetByID(ctx, orderID)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
@@ -1118,8 +1119,8 @@ func (p *OrderProcessorImpl) SetOrderCustomer(ctx context.Context, orderID uuid.
return response, nil
}
func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBillRequest, outletID uuid.UUID) (*models.SplitBillResponse, error) {
order, err := p.orderRepo.GetWithRelations(ctx, req.OrderID, outletID)
func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBillRequest) (*models.SplitBillResponse, error) {
order, err := p.orderRepo.GetWithRelations(ctx, req.OrderID)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
+1 -1
View File
@@ -137,7 +137,7 @@ func (p *TableProcessor) OccupyTable(ctx context.Context, tableID uuid.UUID, req
}
// Verify order exists
_, err = p.orderRepo.GetByID(ctx, req.OrderID, uuid.Nil)
_, err = p.orderRepo.GetByID(ctx, req.OrderID)
if err != nil {
return nil, errors.New("order not found")
}
+154
View File
@@ -13,6 +13,7 @@ import (
type AnalyticsRepository interface {
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)
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)
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)
@@ -122,6 +123,159 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz
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) {
var results []*entities.ProductAnalytics
+31 -49
View File
@@ -13,20 +13,19 @@ import (
type OrderRepository interface {
Create(ctx context.Context, order *entities.Order) error
GetByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*entities.Order, error)
GetWithRelations(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*entities.Order, error)
GetByID(ctx context.Context, id uuid.UUID) (*entities.Order, error)
GetWithRelations(ctx context.Context, id uuid.UUID) (*entities.Order, error)
Update(ctx context.Context, order *entities.Order) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Order, int64, error)
ListBySessionID(ctx context.Context, sessionID string) ([]*entities.Order, error)
GetByOrderNumber(ctx context.Context, orderNumber string) (*entities.Order, error)
ExistsByOrderNumber(ctx context.Context, orderNumber string) (bool, error)
VoidOrder(ctx context.Context, id uuid.UUID, reason string, voidedBy uuid.UUID, outletID uuid.UUID) error
VoidOrderWithStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, reason string, voidedBy uuid.UUID, outletID uuid.UUID) error
RefundOrder(ctx context.Context, id uuid.UUID, reason string, refundedBy uuid.UUID, outletID uuid.UUID) error
UpdatePaymentStatus(ctx context.Context, id uuid.UUID, status entities.PaymentStatus, outletID uuid.UUID) error
UpdateStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, outletID uuid.UUID) error
UpdateStatusSuccess(ctx context.Context, id uuid.UUID, orderStatus entities.OrderStatus, paymentStatus entities.PaymentStatus, outletID uuid.UUID) error
VoidOrder(ctx context.Context, id uuid.UUID, reason string, voidedBy uuid.UUID) error
VoidOrderWithStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, reason string, voidedBy uuid.UUID) error
RefundOrder(ctx context.Context, id uuid.UUID, reason string, refundedBy uuid.UUID) error
UpdatePaymentStatus(ctx context.Context, id uuid.UUID, status entities.PaymentStatus) error
UpdateStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus) error
GetNextOrderNumber(ctx context.Context, organizationID, outletID uuid.UUID) (string, error)
}
@@ -44,27 +43,18 @@ func (r *OrderRepositoryImpl) Create(ctx context.Context, order *entities.Order)
return r.db.WithContext(ctx).Create(order).Error
}
func applyOutletFilter(query *gorm.DB, outletID uuid.UUID) *gorm.DB {
if outletID != uuid.Nil {
return query.Where("outlet_id = ?", outletID)
}
return query
}
func (r *OrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*entities.Order, error) {
func (r *OrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Order, error) {
var order entities.Order
query := r.db.WithContext(ctx)
query = applyOutletFilter(query, outletID)
err := query.First(&order, "id = ?", id).Error
err := r.db.WithContext(ctx).First(&order, "id = ?", id).Error
if err != nil {
return nil, err
}
return &order, nil
}
func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*entities.Order, error) {
func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID) (*entities.Order, error) {
var order entities.Order
query := r.db.WithContext(ctx).
err := r.db.WithContext(ctx).
Preload("Organization").
Preload("Outlet").
Preload("User").
@@ -73,9 +63,8 @@ func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID
Preload("OrderItems.ProductVariant").
Preload("Payments").
Preload("Payments.PaymentMethod").
Preload("Payments.PaymentOrderItems")
query = applyOutletFilter(query, outletID)
err := query.First(&order, "id = ?", id).Error
Preload("Payments.PaymentOrderItems").
First(&order, "id = ?", id).Error
if err != nil {
return nil, err
}
@@ -91,12 +80,10 @@ func (r *OrderRepositoryImpl) UpdateStatusSuccess(
id uuid.UUID,
orderStatus entities.OrderStatus,
paymentStatus entities.PaymentStatus,
outletID uuid.UUID,
) error {
query := r.db.WithContext(ctx).
Model(&entities.Order{})
query = applyOutletFilter(query, outletID)
return query.Where("id = ?", id.String()).
return r.db.WithContext(ctx).
Model(&entities.Order{}).
Where("id = ?", id.String()).
Updates(map[string]interface{}{
"status": orderStatus,
"payment_status": paymentStatus,
@@ -177,11 +164,10 @@ func (r *OrderRepositoryImpl) ExistsByOrderNumber(ctx context.Context, orderNumb
return count > 0, err
}
func (r *OrderRepositoryImpl) VoidOrder(ctx context.Context, id uuid.UUID, reason string, voidedBy uuid.UUID, outletID uuid.UUID) error {
func (r *OrderRepositoryImpl) VoidOrder(ctx context.Context, id uuid.UUID, reason string, voidedBy uuid.UUID) error {
now := time.Now()
query := r.db.WithContext(ctx).Model(&entities.Order{})
query = applyOutletFilter(query, outletID)
return query.Where("id = ?", id).
return r.db.WithContext(ctx).Model(&entities.Order{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"is_void": true,
"void_reason": reason,
@@ -190,11 +176,10 @@ func (r *OrderRepositoryImpl) VoidOrder(ctx context.Context, id uuid.UUID, reaso
}).Error
}
func (r *OrderRepositoryImpl) VoidOrderWithStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, reason string, voidedBy uuid.UUID, outletID uuid.UUID) error {
func (r *OrderRepositoryImpl) VoidOrderWithStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, reason string, voidedBy uuid.UUID) error {
now := time.Now()
query := r.db.WithContext(ctx).Model(&entities.Order{})
query = applyOutletFilter(query, outletID)
return query.Where("id = ?", id).
return r.db.WithContext(ctx).Model(&entities.Order{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"status": status,
"is_void": true,
@@ -204,11 +189,10 @@ func (r *OrderRepositoryImpl) VoidOrderWithStatus(ctx context.Context, id uuid.U
}).Error
}
func (r *OrderRepositoryImpl) RefundOrder(ctx context.Context, id uuid.UUID, reason string, refundedBy uuid.UUID, outletID uuid.UUID) error {
func (r *OrderRepositoryImpl) RefundOrder(ctx context.Context, id uuid.UUID, reason string, refundedBy uuid.UUID) error {
now := time.Now()
query := r.db.WithContext(ctx).Model(&entities.Order{})
query = applyOutletFilter(query, outletID)
return query.Where("id = ?", id).
return r.db.WithContext(ctx).Model(&entities.Order{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"is_refund": true,
"refund_reason": reason,
@@ -217,17 +201,15 @@ func (r *OrderRepositoryImpl) RefundOrder(ctx context.Context, id uuid.UUID, rea
}).Error
}
func (r *OrderRepositoryImpl) UpdatePaymentStatus(ctx context.Context, id uuid.UUID, status entities.PaymentStatus, outletID uuid.UUID) error {
query := r.db.WithContext(ctx).Model(&entities.Order{})
query = applyOutletFilter(query, outletID)
return query.Where("id = ?", id).
func (r *OrderRepositoryImpl) UpdatePaymentStatus(ctx context.Context, id uuid.UUID, status entities.PaymentStatus) error {
return r.db.WithContext(ctx).Model(&entities.Order{}).
Where("id = ?", id).
Update("payment_status", status).Error
}
func (r *OrderRepositoryImpl) UpdateStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, outletID uuid.UUID) error {
query := r.db.WithContext(ctx).Model(&entities.Order{})
query = applyOutletFilter(query, outletID)
return query.Where("id = ?", id).
func (r *OrderRepositoryImpl) UpdateStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus) error {
return r.db.WithContext(ctx).Model(&entities.Order{}).
Where("id = ?", id).
Update("status", status).Error
}
@@ -105,7 +105,7 @@ func (r *OrganizationRepositoryImpl) GetTotalOmset(ctx context.Context, organiza
var total float64
err := r.db.WithContext(ctx).
Table("orders").
Where("organization_id = ? AND payment_status = ? AND is_void = ? AND is_refund = ?", organizationID, "completed", false, false).
Where("organization_id = ? AND payment_status = ?", organizationID, "completed").
Select("COALESCE(SUM(total_amount), 0)").
Scan(&total).Error
return total, err
+4
View File
@@ -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.
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 {
ctxTx := context.WithValue(ctx, txKey, tx)
return fn(ctxTx)
+1
View File
@@ -325,6 +325,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
{
analytics.GET("/payment-methods", r.analyticsHandler.GetPaymentMethodAnalytics)
analytics.GET("/sales", r.analyticsHandler.GetSalesAnalytics)
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
+50
View File
@@ -13,6 +13,7 @@ import (
type AnalyticsService interface {
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, 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)
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, 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
}
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) {
// Validate request
if err := s.validateProductAnalyticsRequest(req); err != nil {
@@ -168,6 +182,42 @@ func (s *AnalyticsServiceImpl) validateSalesAnalyticsRequest(req *models.SalesAn
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 {
if req.OrganizationID == uuid.Nil {
return fmt.Errorf("organization ID is required")
+121
View File
@@ -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)
}
+29 -29
View File
@@ -23,16 +23,16 @@ type orderUserRepository interface {
type OrderService interface {
CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error)
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest, outletID uuid.UUID) (*models.AddToOrderResponse, error)
UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest, outletID uuid.UUID) (*models.OrderResponse, error)
GetOrderByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*models.OrderResponse, error)
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error)
UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error)
GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error)
ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error)
VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID, outletID uuid.UUID) error
RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID, outletID uuid.UUID) error
CreatePayment(ctx context.Context, req *models.CreatePaymentRequest, outletID uuid.UUID) (*models.PaymentResponse, error)
RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID, outletID uuid.UUID) error
SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID, outletID uuid.UUID) (*models.SetOrderCustomerResponse, error)
SplitBill(ctx context.Context, req *models.SplitBillRequest, outletID uuid.UUID) (*models.SplitBillResponse, error)
VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error
RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID) error
CreatePayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error)
RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error
SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID) (*models.SetOrderCustomerResponse, error)
SplitBill(ctx context.Context, req *models.SplitBillRequest) (*models.SplitBillResponse, error)
}
type OrderServiceImpl struct {
@@ -199,7 +199,7 @@ func (s *OrderServiceImpl) createIngredientTransactions(ctx context.Context, ord
// Calculate waste quantities
transactions, err := s.calculateWasteQuantities(productRecipes, float64(orderItem.Quantity))
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
@@ -216,7 +216,7 @@ func (s *OrderServiceImpl) createIngredientTransactions(ctx context.Context, ord
return allTransactions, nil
}
func (s *OrderServiceImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest, outletID uuid.UUID) (*models.AddToOrderResponse, error) {
func (s *OrderServiceImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error) {
if orderID == uuid.Nil {
return nil, fmt.Errorf("invalid order ID")
}
@@ -229,7 +229,7 @@ func (s *OrderServiceImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, re
var ingredientTransactions []*contract.CreateOrderIngredientTransactionRequest
err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
addResp, err := s.orderProcessor.AddToOrder(txCtx, orderID, req, outletID)
addResp, err := s.orderProcessor.AddToOrder(txCtx, orderID, req)
if err != nil {
return fmt.Errorf("failed to add items to order: %w", err)
}
@@ -257,12 +257,12 @@ func (s *OrderServiceImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, re
return response, nil
}
func (s *OrderServiceImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest, outletID uuid.UUID) (*models.OrderResponse, error) {
func (s *OrderServiceImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) {
if err := s.validateUpdateOrderRequest(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
response, err := s.orderProcessor.UpdateOrder(ctx, id, req, outletID)
response, err := s.orderProcessor.UpdateOrder(ctx, id, req)
if err != nil {
return nil, fmt.Errorf("failed to update order: %w", err)
}
@@ -270,12 +270,12 @@ func (s *OrderServiceImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req *m
return response, nil
}
func (s *OrderServiceImpl) GetOrderByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*models.OrderResponse, error) {
func (s *OrderServiceImpl) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) {
if id == uuid.Nil {
return nil, fmt.Errorf("invalid order ID")
}
response, err := s.orderProcessor.GetOrderByID(ctx, id, outletID)
response, err := s.orderProcessor.GetOrderByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get order: %w", err)
}
@@ -296,7 +296,7 @@ func (s *OrderServiceImpl) ListOrders(ctx context.Context, req *models.ListOrder
return response, nil
}
func (s *OrderServiceImpl) VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID, outletID uuid.UUID) error {
func (s *OrderServiceImpl) VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error {
if req.OrderID == uuid.Nil {
return fmt.Errorf("invalid order ID")
}
@@ -305,7 +305,7 @@ func (s *OrderServiceImpl) VoidOrder(ctx context.Context, req *models.VoidOrderR
return fmt.Errorf("invalid user ID")
}
if err := s.orderProcessor.VoidOrder(ctx, req, voidedBy, outletID); err != nil {
if err := s.orderProcessor.VoidOrder(ctx, req, voidedBy); err != nil {
return fmt.Errorf("failed to void order: %w", err)
}
@@ -316,7 +316,7 @@ func (s *OrderServiceImpl) VoidOrder(ctx context.Context, req *models.VoidOrderR
return nil
}
func (s *OrderServiceImpl) RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID, outletID uuid.UUID) error {
func (s *OrderServiceImpl) RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID) error {
// Validate inputs
if id == uuid.Nil {
return fmt.Errorf("invalid order ID")
@@ -331,19 +331,19 @@ func (s *OrderServiceImpl) RefundOrder(ctx context.Context, id uuid.UUID, req *m
}
// Process order refund
if err := s.orderProcessor.RefundOrder(ctx, id, req, refundedBy, outletID); err != nil {
if err := s.orderProcessor.RefundOrder(ctx, id, req, refundedBy); err != nil {
return fmt.Errorf("failed to refund order: %w", err)
}
return nil
}
func (s *OrderServiceImpl) CreatePayment(ctx context.Context, req *models.CreatePaymentRequest, outletID uuid.UUID) (*models.PaymentResponse, error) {
func (s *OrderServiceImpl) CreatePayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) {
if err := s.validateCreatePaymentRequest(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
response, err := s.orderProcessor.CreatePayment(ctx, req, outletID)
response, err := s.orderProcessor.CreatePayment(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to create payment: %w", err)
}
@@ -355,7 +355,7 @@ func (s *OrderServiceImpl) CreatePayment(ctx context.Context, req *models.Create
return response, nil
}
func (s *OrderServiceImpl) RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID, outletID uuid.UUID) error {
func (s *OrderServiceImpl) RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error {
// Validate inputs
if paymentID == uuid.Nil {
return fmt.Errorf("invalid payment ID")
@@ -368,14 +368,14 @@ func (s *OrderServiceImpl) RefundPayment(ctx context.Context, paymentID uuid.UUI
}
// Process payment refund
if err := s.orderProcessor.RefundPayment(ctx, paymentID, refundAmount, reason, refundedBy, outletID); err != nil {
if err := s.orderProcessor.RefundPayment(ctx, paymentID, refundAmount, reason, refundedBy); err != nil {
return fmt.Errorf("failed to refund payment: %w", err)
}
return nil
}
func (s *OrderServiceImpl) SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID, outletID uuid.UUID) (*models.SetOrderCustomerResponse, error) {
func (s *OrderServiceImpl) SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID) (*models.SetOrderCustomerResponse, error) {
// Validate inputs
if orderID == uuid.Nil {
return nil, fmt.Errorf("invalid order ID")
@@ -391,7 +391,7 @@ func (s *OrderServiceImpl) SetOrderCustomer(ctx context.Context, orderID uuid.UU
}
// Process setting customer for order
response, err := s.orderProcessor.SetOrderCustomer(ctx, orderID, req, organizationID, outletID)
response, err := s.orderProcessor.SetOrderCustomer(ctx, orderID, req, organizationID)
if err != nil {
return nil, fmt.Errorf("failed to set customer for order: %w", err)
}
@@ -584,12 +584,12 @@ func (s *OrderServiceImpl) validateSetOrderCustomerRequest(req *models.SetOrderC
return nil
}
func (s *OrderServiceImpl) SplitBill(ctx context.Context, req *models.SplitBillRequest, outletID uuid.UUID) (*models.SplitBillResponse, error) {
func (s *OrderServiceImpl) SplitBill(ctx context.Context, req *models.SplitBillRequest) (*models.SplitBillResponse, error) {
if err := s.validateSplitBillRequest(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
response, err := s.orderProcessor.SplitBill(ctx, req, outletID)
response, err := s.orderProcessor.SplitBill(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to split bill: %w", err)
}
@@ -680,7 +680,7 @@ func (s *OrderServiceImpl) occupyTableWithOrder(ctx context.Context, tableID, or
}
func (s *OrderServiceImpl) handleTableReleaseOnPayment(ctx context.Context, orderID uuid.UUID) error {
order, err := s.orderProcessor.GetOrderByID(ctx, orderID, uuid.Nil)
order, err := s.orderProcessor.GetOrderByID(ctx, orderID)
if err != nil {
return fmt.Errorf("failed to get order: %w", err)
}
@@ -114,6 +114,14 @@ func (m *MockTableRepository) GetByID(ctx context.Context, id uuid.UUID) (*entit
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) {
args := m.Called(ctx, outletID)
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)
}
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) {
// Setup
ctx := context.Background()
@@ -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
func ProductAnalyticsContractToModel(req *contract.ProductAnalyticsRequest) *models.ProductAnalyticsRequest {
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")
}