Files
apskel-pos-backend/internal/service/order_service.go
efrilmandClaude Opus 5.5 4b3beaed41 feat(loyalty): pay orders with EnakPoint at the cashier
Adds paying with the EnakPoint method (docs/prd-point-coin.md F9, K7,
PC-305).

POST /payments with the EnakPoint method now takes points and the
customer's payment code and goes through PointPaymentProcessor instead of
the generic path, which would record a payment without taking any balance.
After checking the order, its customer (not walk-in, active), the outlet
(accepts EnakPoint, minimum) and the method, it redeems the code, then in
one transaction locks the order row and the wallet, recomputes the F9
limits from fresh data, inserts the payment with points_used and the frozen
point_value, writes the PAYMENT ledger row (key payment:{id}, the outlet,
the cashier) and updates the order. The limits are
min(balance, floor(min(remaining, total × max_payment_percent / 100 − paid
with EnakPoint) / point_value)) in cents, so EnakPoint never pays more than
what is left and gives no change.

Unlike the generic CreatePayment, which always marks the order paid, an
EnakPoint payment leaves it partial with the right remaining amount until
it is settled, so the rest can be paid in cash. Settling it triggers
earning, whose basis leaves out the EnakPoint part. Splitting with the
EnakPoint method is refused. Refusals answer 400. The payment response
carries points_used and point_value for the receipt.

GET /orders/:id/point-payment/preview returns eligibility, balance, point
value and the maximum for the use-maximum button.

The payment and order repositories write outside transactions, so this
path uses its own repository that joins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:46:31 +07:00

819 lines
26 KiB
Go

package service
import (
"apskel-pos-be/internal/appcontext"
"context"
"database/sql"
"fmt"
"time"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/util"
"github.com/google/uuid"
)
// orderUserRepository is a minimal interface to fetch users by organization for notification purposes.
type orderUserRepository interface {
GetActiveByOutletID(ctx context.Context, organizationID, outletID uuid.UUID) ([]*entities.User, error)
}
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) (*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) 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 {
orderProcessor processor.OrderProcessor
tableRepo repository.TableRepositoryInterface
orderIngredientTransactionService *OrderIngredientTransactionService
orderIngredientTransactionProcessor processor.OrderIngredientTransactionProcessor
productRecipeRepo repository.ProductRecipeRepository
txManager *repository.TxManager
sessionRepo repository.SessionRepository
notificationProcessor processor.NotificationProcessor
userRepo orderUserRepository
}
func NewOrderServiceImpl(orderProcessor processor.OrderProcessor, tableRepo repository.TableRepositoryInterface, orderIngredientTransactionService *OrderIngredientTransactionService, orderIngredientTransactionProcessor processor.OrderIngredientTransactionProcessor, productRecipeRepo repository.ProductRecipeRepository, txManager *repository.TxManager, sessionRepo repository.SessionRepository, notificationProcessor processor.NotificationProcessor, userRepo orderUserRepository) *OrderServiceImpl {
return &OrderServiceImpl{
orderProcessor: orderProcessor,
tableRepo: tableRepo,
orderIngredientTransactionService: orderIngredientTransactionService,
orderIngredientTransactionProcessor: orderIngredientTransactionProcessor,
productRecipeRepo: productRecipeRepo,
txManager: txManager,
sessionRepo: sessionRepo,
notificationProcessor: notificationProcessor,
userRepo: userRepo,
}
}
func (s *OrderServiceImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) {
if err := s.validateCreateOrderRequest(req, organizationID); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
if req.TableID != nil {
if err := s.validateTable(ctx, req); err != nil {
return nil, fmt.Errorf("table validation failed: %w", err)
}
}
var response *models.OrderResponse
var ingredientTransactions []*contract.CreateOrderIngredientTransactionRequest
// Use transaction to ensure atomicity
err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
// Create the order
orderResp, err := s.orderProcessor.CreateOrder(txCtx, req, organizationID)
if err != nil {
return fmt.Errorf("failed to create order: %w", err)
}
response = orderResp
// Create ingredient transactions for each order item
ingredientTransactions, err = s.createIngredientTransactions(txCtx, response.ID, response.OrderItems)
if err != nil {
return fmt.Errorf("failed to create ingredient transactions: %w", err)
}
// Bulk create ingredient transactions
if len(ingredientTransactions) > 0 {
_, err = s.orderIngredientTransactionService.BulkCreateOrderIngredientTransactions(txCtx, ingredientTransactions)
if err != nil {
return fmt.Errorf("failed to bulk create ingredient transactions: %w", err)
}
}
// Occupy table if specified
if req.TableID != nil {
if err := s.occupyTableWithOrder(txCtx, *req.TableID, response.ID); err != nil {
// Log warning but don't fail the transaction
fmt.Printf("Warning: failed to occupy table %s with order %s: %v\n", *req.TableID, response.ID, err)
}
}
return nil
})
if err != nil {
return nil, err
}
// Send notification to all org users if this is a self-order
if isSelfOrder(req.Metadata) {
go s.sendSelfOrderNotification(context.Background(), response, organizationID)
}
return response, nil
}
// isSelfOrder checks if the order metadata indicates a self-order.
func isSelfOrder(metadata map[string]interface{}) bool {
if metadata == nil {
return false
}
v, ok := metadata["self_order"]
if !ok {
return false
}
b, ok := v.(bool)
return ok && b
}
// sendSelfOrderNotification sends a new-order notification to all active users
// that can access the outlet where the self-order was placed.
func (s *OrderServiceImpl) sendSelfOrderNotification(ctx context.Context, order *models.OrderResponse, organizationID uuid.UUID) {
if s.notificationProcessor == nil || s.userRepo == nil {
return
}
users, err := s.userRepo.GetActiveByOutletID(ctx, organizationID, order.OutletID)
if err != nil || len(users) == 0 {
return
}
receiverIDs := make([]uuid.UUID, 0, len(users))
for _, u := range users {
receiverIDs = append(receiverIDs, u.ID)
}
tableName := ""
if order.TableNumber != nil {
tableName = *order.TableNumber
}
title := "Pesanan Baru Masuk"
body := fmt.Sprintf("Ada pesanan baru dari meja %s", tableName)
if tableName == "" {
body = "Ada pesanan baru masuk"
}
orderID := order.ID
notifReq := &models.SendNotificationRequest{
Title: title,
Body: body,
Type: "order",
Category: "self_order",
NotifiableType: "order",
NotifiableID: &orderID,
ReceiverIDs: receiverIDs,
Data: map[string]interface{}{
"order_id": order.ID.String(),
"order_number": order.OrderNumber,
"table_name": tableName,
},
}
_, _ = s.notificationProcessor.Send(ctx, notifReq)
}
func (s *OrderServiceImpl) createIngredientTransactions(ctx context.Context, orderID uuid.UUID, orderItems []models.OrderItemResponse) ([]*contract.CreateOrderIngredientTransactionRequest, error) {
appCtx := appcontext.FromGinContext(ctx)
organizationID := appCtx.OrganizationID
var allTransactions []*contract.CreateOrderIngredientTransactionRequest
for _, orderItem := range orderItems {
// Get product recipes for this product
productRecipes, err := s.productRecipeRepo.GetByProductID(ctx, orderItem.ProductID, organizationID)
if err != nil {
return nil, fmt.Errorf("failed to get product recipes for product %s: %w", orderItem.ProductID, err)
}
if len(productRecipes) == 0 {
continue // Skip if no recipes
}
// 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", orderItem.ProductID, err)
}
// Set common fields for all transactions
for _, transaction := range transactions {
transaction.OrderID = orderID
transaction.OrderItemID = &orderItem.ID
transaction.ProductID = orderItem.ProductID
transaction.ProductVariantID = orderItem.ProductVariantID
}
allTransactions = append(allTransactions, transactions...)
}
return allTransactions, nil
}
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")
}
if err := s.validateAddToOrderRequest(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
var response *models.AddToOrderResponse
var ingredientTransactions []*contract.CreateOrderIngredientTransactionRequest
err := s.txManager.WithTransactionOptions(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
}, func(txCtx context.Context) error {
addResp, err := s.orderProcessor.AddToOrder(txCtx, orderID, req)
if err != nil {
return fmt.Errorf("failed to add items to order: %w", err)
}
response = addResp
ingredientTransactions, err = s.createIngredientTransactions(txCtx, orderID, response.AddedItems)
if err != nil {
return fmt.Errorf("failed to create ingredient transactions: %w", err)
}
if len(ingredientTransactions) > 0 {
_, err = s.orderIngredientTransactionService.BulkCreateOrderIngredientTransactions(txCtx, ingredientTransactions)
if err != nil {
return fmt.Errorf("failed to bulk create ingredient transactions: %w", err)
}
}
return nil
})
if err != nil {
return nil, err
}
return response, nil
}
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)
if err != nil {
return nil, fmt.Errorf("failed to update order: %w", err)
}
return response, nil
}
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)
if err != nil {
return nil, fmt.Errorf("failed to get order: %w", err)
}
return response, nil
}
func (s *OrderServiceImpl) ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error) {
if err := s.validateListOrdersRequest(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
response, err := s.orderProcessor.ListOrders(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to list orders: %w", err)
}
return response, nil
}
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")
}
if voidedBy == uuid.Nil {
return fmt.Errorf("invalid user ID")
}
err := s.txManager.WithTransactionOptions(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
}, func(txCtx context.Context) error {
if err := s.orderProcessor.VoidOrder(txCtx, req, voidedBy); err != nil {
return fmt.Errorf("failed to void order: %w", err)
}
return nil
})
if err != nil {
return err
}
if err := s.handleTableReleaseOnVoid(ctx, req.OrderID); err != nil {
fmt.Printf("Warning: failed to handle table release for voided order %s: %v\n", req.OrderID, err)
}
return nil
}
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")
}
if refundedBy == uuid.Nil {
return fmt.Errorf("invalid user ID")
}
// Validate refund request
if err := s.validateRefundOrderRequest(req); err != nil {
return fmt.Errorf("validation error: %w", err)
}
// Process order refund
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) (*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)
if err != nil {
return nil, fmt.Errorf("failed to create payment: %w", err)
}
if err := s.handleTableReleaseOnPayment(ctx, req.OrderID); err != nil {
fmt.Printf("Warning: failed to handle table release for order %s: %v\n", req.OrderID, err)
}
return response, nil
}
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")
}
if refundAmount <= 0 {
return fmt.Errorf("refund amount must be greater than zero")
}
if refundedBy == uuid.Nil {
return fmt.Errorf("invalid user ID")
}
// Process payment refund
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) (*models.SetOrderCustomerResponse, error) {
// Validate inputs
if orderID == uuid.Nil {
return nil, fmt.Errorf("invalid order ID")
}
if organizationID == uuid.Nil {
return nil, fmt.Errorf("invalid organization ID")
}
// Validate request
if err := s.validateSetOrderCustomerRequest(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
// Process setting customer for order
response, err := s.orderProcessor.SetOrderCustomer(ctx, orderID, req, organizationID)
if err != nil {
return nil, fmt.Errorf("failed to set customer for order: %w", err)
}
return response, nil
}
func (s *OrderServiceImpl) validateAddToOrderRequest(req *models.AddToOrderRequest) error {
if req == nil {
return fmt.Errorf("request cannot be nil")
}
if len(req.OrderItems) == 0 {
return fmt.Errorf("must add at least one item")
}
for i, item := range req.OrderItems {
if item.ProductID == uuid.Nil {
return fmt.Errorf("product ID is required for item %d", i+1)
}
if item.Quantity <= 0 {
return fmt.Errorf("quantity must be greater than zero for item %d", i+1)
}
if item.UnitPrice != nil && *item.UnitPrice < 0 {
return fmt.Errorf("unit price cannot be negative for item %d", i+1)
}
}
return nil
}
func (s *OrderServiceImpl) validateCreateOrderRequest(req *models.CreateOrderRequest, organizationID uuid.UUID) error {
if req == nil {
return fmt.Errorf("request cannot be nil")
}
if organizationID == uuid.Nil {
return fmt.Errorf("organization ID is required")
}
if req.OutletID == uuid.Nil {
return fmt.Errorf("outlet ID is required")
}
if req.UserID == uuid.Nil {
return fmt.Errorf("user ID is required")
}
// Validate table ID if provided
if req.TableID != nil && *req.TableID == uuid.Nil {
return fmt.Errorf("table ID cannot be nil if provided")
}
if len(req.OrderItems) == 0 {
return fmt.Errorf("order must have at least one item")
}
for i, item := range req.OrderItems {
if item.ProductID == uuid.Nil {
return fmt.Errorf("product ID is required for item %d", i+1)
}
if item.Quantity <= 0 {
return fmt.Errorf("quantity must be greater than zero for item %d", i+1)
}
if item.UnitPrice != nil && *item.UnitPrice < 0 {
return fmt.Errorf("unit price cannot be negative for item %d", i+1)
}
}
return nil
}
func (s *OrderServiceImpl) validateUpdateOrderRequest(req *models.UpdateOrderRequest) error {
if req == nil {
return fmt.Errorf("request cannot be nil")
}
if req.DiscountAmount != nil && *req.DiscountAmount < 0 {
return fmt.Errorf("discount amount cannot be negative")
}
return nil
}
func (s *OrderServiceImpl) validateListOrdersRequest(req *models.ListOrdersRequest) error {
if req == nil {
return fmt.Errorf("request cannot be nil")
}
if req.Page < 1 {
return fmt.Errorf("page must be greater than zero")
}
if req.Limit < 1 || req.Limit > 100 {
return fmt.Errorf("limit must be between 1 and 100")
}
return nil
}
func (s *OrderServiceImpl) validateRefundOrderRequest(req *models.RefundOrderRequest) error {
if req == nil {
return fmt.Errorf("request cannot be nil")
}
// Must have either refund amount or order items
if req.RefundAmount == nil && len(req.OrderItems) == 0 {
return fmt.Errorf("must specify either refund amount or order items to refund")
}
// Cannot have both refund amount and order items
if req.RefundAmount != nil && len(req.OrderItems) > 0 {
return fmt.Errorf("cannot specify both refund amount and order items")
}
if req.RefundAmount != nil && *req.RefundAmount <= 0 {
return fmt.Errorf("refund amount must be greater than zero")
}
// Validate order items if provided
for i, item := range req.OrderItems {
if item.OrderItemID == uuid.Nil {
return fmt.Errorf("order item ID is required for item %d", i+1)
}
if item.RefundQuantity < 0 {
return fmt.Errorf("refund quantity cannot be negative for item %d", i+1)
}
if item.RefundAmount != nil && *item.RefundAmount < 0 {
return fmt.Errorf("refund amount cannot be negative for item %d", i+1)
}
}
return nil
}
func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymentRequest) error {
if req == nil {
return fmt.Errorf("request cannot be nil")
}
if req.OrderID == uuid.Nil {
return fmt.Errorf("order ID is required")
}
if req.PaymentMethodID == uuid.Nil {
return fmt.Errorf("payment method ID is required")
}
// A payment with EnakPoint gives points instead; its amount is computed from them.
if req.Points == nil && req.Amount <= 0 {
return fmt.Errorf("payment amount must be greater than zero")
}
if len(req.PaymentOrderItems) > 0 {
totalItemAmount := float64(0)
for i, item := range req.PaymentOrderItems {
if item.OrderItemID == uuid.Nil {
return fmt.Errorf("order item ID is required for payment item %d", i+1)
}
if item.Amount < 0 {
return fmt.Errorf("payment item amount must be greater than zero for item %d", i+1)
}
fmt.Printf("[DEBUG] CreatePayment order_id=%s item[%d] order_item_id=%s amount=%.10f\n",
req.OrderID, i, item.OrderItemID, item.Amount)
totalItemAmount += item.Amount
}
fmt.Printf("[DEBUG] CreatePayment order_id=%s total_amount=%.10f sum_items=%.10f diff=%.10f\n",
req.OrderID, req.Amount, totalItemAmount, req.Amount-totalItemAmount)
if totalItemAmount != req.Amount {
return fmt.Errorf("sum of payment item amounts must equal total payment amount")
}
}
return nil
}
func (s *OrderServiceImpl) validateSetOrderCustomerRequest(req *models.SetOrderCustomerRequest) error {
if req == nil {
return fmt.Errorf("request cannot be nil")
}
if req.CustomerID == uuid.Nil {
return fmt.Errorf("customer ID is required")
}
return nil
}
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)
if err != nil {
return nil, fmt.Errorf("failed to split bill: %w", err)
}
if err := s.handleTableReleaseOnPayment(ctx, req.OrderID); err != nil {
fmt.Printf("Warning: failed to handle table release for order %s after split bill: %v\n", req.OrderID, err)
}
return response, nil
}
func (s *OrderServiceImpl) validateSplitBillRequest(req *models.SplitBillRequest) error {
if req == nil {
return fmt.Errorf("request cannot be nil")
}
if req.OrderID == uuid.Nil {
return fmt.Errorf("order ID is required")
}
if req.PaymentMethodID == uuid.Nil {
return fmt.Errorf("payment ID is required")
}
if req.Type != "ITEM" && req.Type != "AMOUNT" {
return fmt.Errorf("split type must be either ITEM or AMOUNT")
}
if req.Type == "ITEM" {
if len(req.Items) == 0 {
return fmt.Errorf("items are required when splitting by ITEM")
}
totalItemAmount := 0
for i, item := range req.Items {
if item.OrderItemID == uuid.Nil {
return fmt.Errorf("order item ID is required for item %d", i+1)
}
if item.Quantity <= 0 {
return fmt.Errorf("quantity must be greater than zero for item %d", i+1)
}
totalItemAmount += item.Quantity
}
if totalItemAmount <= 0 {
return fmt.Errorf("total item amount must be greater than zero")
}
}
if req.Type == "AMOUNT" {
if req.Amount <= 0 {
return fmt.Errorf("amount must be greater than zero when splitting by AMOUNT")
}
}
return nil
}
// validateTable validates that the table exists and is available for occupation
func (s *OrderServiceImpl) validateTable(ctx context.Context, req *models.CreateOrderRequest) error {
// Validate table exists and is available
table, err := s.tableRepo.GetByID(ctx, *req.TableID)
if err != nil {
return fmt.Errorf("table not found: %w", err)
}
// Check if table belongs to the same outlet
if table.OutletID != req.OutletID {
return fmt.Errorf("table does not belong to the specified outlet")
}
// Check if table is available for occupation
if !table.CanBeOccupied() {
return fmt.Errorf("table is not available for occupation (current status: %s)", table.Status)
}
return nil
}
func (s *OrderServiceImpl) occupyTableWithOrder(ctx context.Context, tableID, orderID uuid.UUID) error {
startTime := time.Now()
if err := s.tableRepo.OccupyTable(ctx, tableID, orderID, &startTime); err != nil {
return fmt.Errorf("failed to occupy table: %w", err)
}
return nil
}
func (s *OrderServiceImpl) handleTableReleaseOnPayment(ctx context.Context, orderID uuid.UUID) error {
order, err := s.orderProcessor.GetOrderByID(ctx, orderID)
if err != nil {
return fmt.Errorf("failed to get order: %w", err)
}
if order.PaymentStatus == "completed" {
table, err := s.tableRepo.GetByOrderID(ctx, orderID)
if err != nil {
return nil
}
if table != nil {
if err := s.tableRepo.ReleaseTable(ctx, table.ID, order.TotalAmount); err != nil {
return fmt.Errorf("failed to release table: %w", err)
}
if s.sessionRepo != nil {
if err := s.sessionRepo.CloseByTableID(ctx, table.ID); err != nil {
fmt.Printf("Warning: failed to close self-order session for table %s: %v\n", table.ID, err)
}
}
}
}
return nil
}
// handleTableReleaseOnVoid releases the table when an order is voided
func (s *OrderServiceImpl) handleTableReleaseOnVoid(ctx context.Context, orderID uuid.UUID) error {
table, err := s.tableRepo.GetByOrderID(ctx, orderID)
if err != nil {
// Table might not exist or not be occupied, which is fine
return nil
}
if table != nil {
if err := s.tableRepo.ReleaseTable(ctx, table.ID, 0); err != nil {
return fmt.Errorf("failed to release table: %w", err)
}
}
return nil
}
func (s *OrderServiceImpl) createOrderIngredientTransactions(ctx context.Context, order *models.Order, orderItems []*models.OrderItem) error {
for _, orderItem := range orderItems {
productRecipes, err := s.productRecipeRepo.GetByProductID(ctx, orderItem.ProductID, order.OrganizationID)
if err != nil {
return fmt.Errorf("failed to get product recipes for product %s: %w", orderItem.ProductID, err)
}
if len(productRecipes) == 0 {
continue // Skip if no recipes
}
// Calculate waste quantities using the utility function
transactions, err := s.calculateWasteQuantities(productRecipes, float64(orderItem.Quantity))
if err != nil {
return fmt.Errorf("failed to calculate waste quantities for product %s: %w", orderItem.ProductID, err)
}
// Set common fields for all transactions
for _, transaction := range transactions {
transaction.OrderID = order.ID
transaction.OrderItemID = &orderItem.ID
transaction.ProductID = orderItem.ProductID
transaction.ProductVariantID = orderItem.ProductVariantID
}
// Bulk create transactions
if len(transactions) > 0 {
_, err := s.orderIngredientTransactionService.BulkCreateOrderIngredientTransactions(ctx, transactions)
if err != nil {
return fmt.Errorf("failed to create order ingredient transactions for product %s: %w", orderItem.ProductID, err)
}
}
}
return nil
}
// calculateWasteQuantities calculates gross, net, and waste quantities for product recipes
func (s *OrderServiceImpl) calculateWasteQuantities(productRecipes []*entities.ProductRecipe, quantity float64) ([]*contract.CreateOrderIngredientTransactionRequest, error) {
if len(productRecipes) == 0 {
return []*contract.CreateOrderIngredientTransactionRequest{}, nil
}
transactions := make([]*contract.CreateOrderIngredientTransactionRequest, 0, len(productRecipes))
for _, pr := range productRecipes {
// Calculate net quantity (actual quantity needed for the product)
netQty := pr.Quantity * quantity
// Calculate gross quantity (including waste)
wasteMultiplier := 1 + (pr.WastePercentage / 100)
grossQty := netQty * wasteMultiplier
// Calculate waste quantity
wasteQty := grossQty - netQty
// Get unit name from ingredient
unitName := "unit" // default
if pr.Ingredient != nil && pr.Ingredient.Unit != nil {
unitName = pr.Ingredient.Unit.Name
}
transaction := &contract.CreateOrderIngredientTransactionRequest{
IngredientID: pr.IngredientID,
GrossQty: util.RoundToDecimalPlaces(grossQty, 3),
NetQty: util.RoundToDecimalPlaces(netQty, 3),
WasteQty: util.RoundToDecimalPlaces(wasteQty, 3),
Unit: unitName,
}
transactions = append(transactions, transaction)
}
return transactions, nil
}