feat(order): support weight-based products
Products like fish are sold per weighing (4.2 ons, 5.6 ons), which the order line could not represent: quantity is INTEGER and prices are always computed as quantity * unit_price. Model one weighing as one order line. quantity stays INTEGER and keeps meaning "how many items"; the measured amount goes into a new nullable order_items.weight, and the line is priced weight * unit_price. Two weighings of the same product are two lines, never merged into one. Keeping quantity integral avoids float comparisons in void, refund and split bill, where accumulated rounding error would silently misbehave — "1.4 + 1.4 + 1.4" is not 4.2 in float64, which would leave a fully paid split-bill item marked unpaid. BillableQuantity() is now the single place that decides between weight and count; every price and cost calculation goes through it. Missing one would bill a 4.2 ons fish as a single ons — wrong money, no error. Two database constraints back the design: a weighed line always carries a positive weight, and its quantity is pinned to 1. The latter also makes void all-or-nothing for weighed lines, so the row-splitting branch can never produce a zero-weight remainder row. Also wires product.unit_id through the API, which was previously not settable at all, and corrects the misleading comment on the request's unit_price field — that value has never been used; price always comes from the database. Design notes and the audit of every price multiplication site are in docs/rfc-weight-based-products.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
@@ -149,6 +150,33 @@ func NewOrderProcessorImpl(
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLineQuantity reconciles the requested quantity and weight against how the
|
||||
// product is sold, returning the values to store on the order line.
|
||||
//
|
||||
// For weight-based products a line is exactly one weighing, so quantity is pinned to 1
|
||||
// and the weighed amount is what gets priced. This mirrors
|
||||
// chk_order_items_weight_single_line in the database.
|
||||
func resolveLineQuantity(product *entities.Product, quantity int, weight *float64) (int, *float64, error) {
|
||||
if product.SellBy == constants.SellByWeight {
|
||||
if weight == nil {
|
||||
return 0, nil, fmt.Errorf("product %s is sold by weight and requires a weight", product.Name)
|
||||
}
|
||||
rounded := entities.RoundWeight(*weight)
|
||||
if rounded <= 0 {
|
||||
return 0, nil, fmt.Errorf("weight for product %s must be greater than 0", product.Name)
|
||||
}
|
||||
return 1, &rounded, nil
|
||||
}
|
||||
|
||||
if weight != nil {
|
||||
return 0, nil, fmt.Errorf("product %s is not sold by weight and must not carry a weight", product.Name)
|
||||
}
|
||||
if quantity < 1 {
|
||||
return 0, nil, fmt.Errorf("quantity for product %s must be at least 1", product.Name)
|
||||
}
|
||||
return quantity, nil, nil
|
||||
}
|
||||
|
||||
func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) {
|
||||
orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID)
|
||||
if err != nil {
|
||||
@@ -194,25 +222,29 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
|
||||
}
|
||||
}
|
||||
|
||||
itemTotalPrice := float64(itemReq.Quantity) * unitPrice
|
||||
itemTotalCost := float64(itemReq.Quantity) * unitCost
|
||||
|
||||
subtotal += itemTotalPrice
|
||||
totalCost += itemTotalCost
|
||||
quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orderItem := &entities.OrderItem{
|
||||
ProductID: itemReq.ProductID,
|
||||
ProductVariantID: itemReq.ProductVariantID,
|
||||
Quantity: itemReq.Quantity,
|
||||
Quantity: quantity,
|
||||
Weight: weight,
|
||||
UnitID: product.UnitID,
|
||||
UnitPrice: unitPrice,
|
||||
TotalPrice: itemTotalPrice,
|
||||
UnitCost: unitCost,
|
||||
TotalCost: itemTotalCost,
|
||||
Modifiers: entities.Modifiers(itemReq.Modifiers),
|
||||
Notes: itemReq.Notes,
|
||||
Metadata: entities.Metadata(itemReq.Metadata),
|
||||
Status: entities.OrderItemStatusPending,
|
||||
}
|
||||
orderItem.CalculateTotalPrice()
|
||||
orderItem.CalculateTotalCost()
|
||||
|
||||
subtotal += orderItem.TotalPrice
|
||||
totalCost += orderItem.TotalCost
|
||||
|
||||
orderItems = append(orderItems, orderItem)
|
||||
}
|
||||
@@ -327,26 +359,30 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
||||
}
|
||||
}
|
||||
|
||||
itemTotalPrice := float64(itemReq.Quantity) * unitPrice
|
||||
itemTotalCost := float64(itemReq.Quantity) * unitCost
|
||||
|
||||
newSubtotal += itemTotalPrice
|
||||
newTotalCost += itemTotalCost
|
||||
quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orderItem := &entities.OrderItem{
|
||||
OrderID: orderID,
|
||||
ProductID: itemReq.ProductID,
|
||||
ProductVariantID: itemReq.ProductVariantID,
|
||||
Quantity: itemReq.Quantity,
|
||||
Quantity: quantity,
|
||||
Weight: weight,
|
||||
UnitID: product.UnitID,
|
||||
UnitPrice: unitPrice,
|
||||
TotalPrice: itemTotalPrice,
|
||||
UnitCost: unitCost,
|
||||
TotalCost: itemTotalCost,
|
||||
Modifiers: entities.Modifiers(itemReq.Modifiers),
|
||||
Notes: itemReq.Notes,
|
||||
Metadata: entities.Metadata(itemReq.Metadata),
|
||||
Status: entities.OrderItemStatusPending,
|
||||
}
|
||||
orderItem.CalculateTotalPrice()
|
||||
orderItem.CalculateTotalCost()
|
||||
|
||||
newSubtotal += orderItem.TotalPrice
|
||||
newTotalCost += orderItem.TotalCost
|
||||
|
||||
addedOrderItems = append(addedOrderItems, orderItem)
|
||||
}
|
||||
@@ -602,8 +638,9 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
|
||||
return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID)
|
||||
}
|
||||
|
||||
voidedAmount := float64(itemVoid.Quantity) * orderItem.UnitPrice
|
||||
voidedCost := float64(itemVoid.Quantity) * orderItem.UnitCost
|
||||
billable := orderItem.BillableQuantityFor(itemVoid.Quantity)
|
||||
voidedAmount := entities.RoundMoney(billable * orderItem.UnitPrice)
|
||||
voidedCost := entities.RoundMoney(billable * orderItem.UnitCost)
|
||||
|
||||
totalVoidedAmount += voidedAmount
|
||||
totalVoidedCost += voidedCost
|
||||
@@ -720,7 +757,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
|
||||
return fmt.Errorf("refund quantity cannot exceed original quantity")
|
||||
}
|
||||
|
||||
refundAmount := float64(refundQuantity) * orderItem.UnitPrice
|
||||
refundAmount := entities.RoundMoney(orderItem.BillableQuantityFor(refundQuantity) * orderItem.UnitPrice)
|
||||
if itemRefund.RefundAmount != nil {
|
||||
refundAmount = *itemRefund.RefundAmount
|
||||
}
|
||||
@@ -1016,7 +1053,7 @@ func (p *OrderProcessorImpl) createInventoryMovement(ctx context.Context, item *
|
||||
PreviousQuantity: float64(updatedInventory.Quantity + item.Quantity), // Add back the quantity that was subtracted
|
||||
NewQuantity: float64(updatedInventory.Quantity),
|
||||
UnitCost: item.UnitCost,
|
||||
TotalCost: float64(item.Quantity) * item.UnitCost,
|
||||
TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
|
||||
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
||||
t := entities.InventoryMovementReferenceTypePayment
|
||||
return &t
|
||||
@@ -1314,7 +1351,7 @@ func (p *OrderProcessorImpl) prepareProductInventoryMovement(item *entities.Orde
|
||||
PreviousQuantity: float64(previousQuantity),
|
||||
NewQuantity: float64(updatedInventory.Quantity),
|
||||
UnitCost: item.UnitCost,
|
||||
TotalCost: float64(item.Quantity) * item.UnitCost,
|
||||
TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
|
||||
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
||||
t := entities.InventoryMovementReferenceTypePayment
|
||||
return &t
|
||||
@@ -1380,7 +1417,7 @@ type ingredientRecipeItem struct {
|
||||
|
||||
// prepareIngredientRecipeItem prepares data for a single ingredient recipe without making database calls
|
||||
func (p *OrderProcessorImpl) prepareIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeItem, error) {
|
||||
totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
|
||||
totalIngredientQuantity := recipe.Quantity * item.BillableQuantity()
|
||||
|
||||
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
|
||||
if err != nil {
|
||||
@@ -1478,7 +1515,7 @@ func (p *OrderProcessorImpl) prepareRefundProductInventoryMovement(item *entitie
|
||||
PreviousQuantity: float64(previousQuantity),
|
||||
NewQuantity: float64(updatedInventory.Quantity),
|
||||
UnitCost: item.UnitCost,
|
||||
TotalCost: float64(refundedQuantity) * item.UnitCost,
|
||||
TotalCost: entities.RoundMoney(item.BillableQuantityFor(refundedQuantity) * item.UnitCost),
|
||||
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
||||
t := entities.InventoryMovementReferenceTypeRefund
|
||||
return &t
|
||||
@@ -1544,7 +1581,7 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeData(ctx context.Con
|
||||
// prepareRefundedIngredientRecipeItem prepares data for a single ingredient recipe restoration
|
||||
func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, refundRatio float64, reason string, refundedBy uuid.UUID, refundAmount float64) (*ingredientRecipeItem, error) {
|
||||
// Calculate total ingredient quantity needed based on order item quantity
|
||||
totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
|
||||
totalIngredientQuantity := recipe.Quantity * item.BillableQuantity()
|
||||
|
||||
// Get current ingredient details
|
||||
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
|
||||
|
||||
Reference in New Issue
Block a user