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:
efrilm
2026-09-06 17:14:42 +07:00
co-authored by Claude Opus 5
parent 1e5573af75
commit 992bb04816
30 changed files with 901 additions and 66 deletions
+17
View File
@@ -0,0 +1,17 @@
package constants
// SellBy describes how a product's quantity is expressed on an order line.
type SellBy = string
const (
// SellByUnit is the default: the line counts discrete items (2 portions, 3 bottles).
SellByUnit SellBy = "unit"
// SellByWeight means the line carries a weighed amount. One weighing is one line,
// so quantity stays 1 and the measured value lives in order_items.weight.
SellByWeight SellBy = "weight"
)
// IsValidSellBy reports whether s is a supported sell_by value.
func IsValidSellBy(s string) bool {
return s == SellByUnit || s == SellByWeight
}
+2
View File
@@ -198,6 +198,7 @@ type ProductAnalyticsData struct {
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`
QuantitySold int64 `json:"quantity_sold"`
WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
@@ -324,6 +325,7 @@ type ParentCategoryAnalyticsProductData struct {
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
QuantitySold int64 `json:"quantity_sold"`
WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
+35 -27
View File
@@ -40,13 +40,16 @@ type UpdateOrderRequest struct {
}
type CreateOrderItemRequest struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
Quantity int `json:"quantity" validate:"required,min=1"`
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Optional, will use database price if not provided
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
ProductID uuid.UUID `json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
Quantity int `json:"quantity" validate:"required,min=1"`
// Weight is required for products with sell_by = "weight" and must be absent otherwise.
// One weighing is one line, so quantity stays 1 and the line is priced weight * unit_price.
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Ignored: price always comes from the database
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type UpdateOrderItemRequest struct {
@@ -92,26 +95,31 @@ type OrderResponse struct {
}
type OrderItemResponse struct {
ID uuid.UUID `json:"id"`
OrderID uuid.UUID `json:"order_id"`
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductVariantID *uuid.UUID `json:"product_variant_id"`
ProductVariantName *string `json:"product_variant_name,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
CategoryName *string `json:"category_name,omitempty"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
Modifiers []map[string]interface{} `json:"modifiers"`
Notes *string `json:"notes,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PrinterType string `json:"printer_type"`
PrintToChecker bool `json:"print_to_checker"`
PaidQuantity int `json:"paid_quantity"`
ID uuid.UUID `json:"id"`
OrderID uuid.UUID `json:"order_id"`
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductVariantID *uuid.UUID `json:"product_variant_id"`
ProductVariantName *string `json:"product_variant_name,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
CategoryName *string `json:"category_name,omitempty"`
Quantity int `json:"quantity"`
// Weight is set only for products sold by weight; the line is priced weight * unit_price.
Weight *float64 `json:"weight,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
UnitName *string `json:"unit_name,omitempty"`
UnitAbbreviation *string `json:"unit_abbreviation,omitempty"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
Modifiers []map[string]interface{} `json:"modifiers"`
Notes *string `json:"notes,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PrinterType string `json:"printer_type"`
PrintToChecker bool `json:"print_to_checker"`
PaidQuantity int `json:"paid_quantity"`
}
type ListOrdersQuery struct {
+6
View File
@@ -18,6 +18,8 @@ type CreateProductRequest struct {
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
PrintToChecker *bool `json:"print_to_checker,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
@@ -38,6 +40,8 @@ type UpdateProductRequest struct {
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
PrintToChecker *bool `json:"print_to_checker,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
@@ -73,6 +77,8 @@ type ProductResponse struct {
BusinessType string `json:"business_type"`
ImageURL *string `json:"image_url"`
PrinterType string `json:"printer_type"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
SellBy string `json:"sell_by"`
PrintToChecker bool `json:"print_to_checker"`
Metadata map[string]interface{} `json:"metadata"`
IsActive bool `json:"is_active"`
+1
View File
@@ -58,6 +58,7 @@ type SelfOrderCreateOrderItem struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
Quantity int `json:"quantity" validate:"required,min=1"`
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
Notes *string `json:"notes,omitempty"`
}
+1
View File
@@ -114,6 +114,7 @@ type ProductAnalytics struct {
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`
QuantitySold int64 `json:"quantity_sold"`
WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
+54 -1
View File
@@ -4,6 +4,7 @@ import (
"database/sql/driver"
"encoding/json"
"errors"
"math"
"time"
"github.com/google/uuid"
@@ -47,6 +48,8 @@ type OrderItem struct {
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `gorm:"type:uuid;index" json:"product_variant_id"`
Quantity int `gorm:"not null" json:"quantity" validate:"required,min=1"`
Weight *float64 `gorm:"type:decimal(12,3)" json:"weight,omitempty"`
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id,omitempty"`
UnitPrice float64 `gorm:"type:decimal(10,2);not null" json:"unit_price" validate:"required,min=0"`
TotalPrice float64 `gorm:"type:decimal(10,2);not null" json:"total_price" validate:"required,min=0"`
UnitCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"unit_cost"`
@@ -68,6 +71,7 @@ type OrderItem struct {
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
}
func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error {
@@ -81,8 +85,57 @@ func (OrderItem) TableName() string {
return "order_items"
}
// IsWeighed reports whether this line was sold by weight rather than by count.
func (oi *OrderItem) IsWeighed() bool {
return oi.Weight != nil
}
// BillableQuantity is the multiplier for unit price and unit cost on this line:
// the weighed amount for weight-based products, the item count otherwise.
//
// This is the ONLY place that decides between the two. Every price or cost
// calculation must go through it — multiplying by Quantity directly would bill a
// 4.2 ons fish as a single ons.
//
// Weighed lines are pinned to quantity = 1 by chk_order_items_weight_single_line,
// so there is no ambiguity between counting and weighing.
func (oi *OrderItem) BillableQuantity() float64 {
if oi.Weight != nil {
return *oi.Weight
}
return float64(oi.Quantity)
}
// BillableQuantityFor is BillableQuantity for a partial action on this line, such as
// voiding or refunding `units` of it.
//
// Weighed lines are always acted on in full — quantity is 1, so any void or refund
// covers the whole weighing — hence the full weight is returned regardless of units.
func (oi *OrderItem) BillableQuantityFor(units int) float64 {
if oi.Weight != nil {
return *oi.Weight
}
return float64(units)
}
// RoundMoney rounds to the 2 decimals that every monetary column stores, so the
// value written back always matches what the database keeps.
func RoundMoney(amount float64) float64 {
return math.Round(amount*100) / 100
}
// RoundWeight rounds to the 3 decimals of order_items.weight, so a validated value
// is identical to the stored one.
func RoundWeight(weight float64) float64 {
return math.Round(weight*1000) / 1000
}
func (oi *OrderItem) CalculateTotalPrice() {
oi.TotalPrice = float64(oi.Quantity) * oi.UnitPrice
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
}
func (oi *OrderItem) CalculateTotalCost() {
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
}
func (oi *OrderItem) CanBeModified() bool {
+104
View File
@@ -0,0 +1,104 @@
package entities
import "testing"
func weightPtr(v float64) *float64 { return &v }
func TestBillableQuantity(t *testing.T) {
tests := []struct {
name string
item OrderItem
want float64
}{
{"unit line counts items", OrderItem{Quantity: 3}, 3},
{"weighed line uses the weight", OrderItem{Quantity: 1, Weight: weightPtr(4.2)}, 4.2},
{"weight of zero is still a weighed line", OrderItem{Quantity: 1, Weight: weightPtr(0)}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.item.BillableQuantity(); got != tt.want {
t.Errorf("BillableQuantity() = %v, want %v", got, tt.want)
}
})
}
}
// A weighed line is voided or refunded whole, so the units asked for must not shrink
// the amount: refunding "1" of a 4.2 ons line refunds all 4.2 ons.
func TestBillableQuantityForIgnoresUnitsOnWeighedLines(t *testing.T) {
weighed := OrderItem{Quantity: 1, Weight: weightPtr(5.6)}
if got := weighed.BillableQuantityFor(1); got != 5.6 {
t.Errorf("weighed BillableQuantityFor(1) = %v, want 5.6", got)
}
counted := OrderItem{Quantity: 3}
if got := counted.BillableQuantityFor(2); got != 2 {
t.Errorf("unit BillableQuantityFor(2) = %v, want 2", got)
}
}
// The regression this whole feature exists to prevent: a 4.2 ons fish priced per ons
// must not be billed as a single ons.
func TestCalculateTotalPriceForWeighedLine(t *testing.T) {
item := OrderItem{Quantity: 1, Weight: weightPtr(4.2), UnitPrice: 4500, UnitCost: 3000}
item.CalculateTotalPrice()
item.CalculateTotalCost()
if item.TotalPrice != 18900 {
t.Errorf("TotalPrice = %v, want 18900", item.TotalPrice)
}
if item.TotalCost != 12600 {
t.Errorf("TotalCost = %v, want 12600", item.TotalCost)
}
}
func TestCalculateTotalPriceForUnitLine(t *testing.T) {
item := OrderItem{Quantity: 3, UnitPrice: 25000, UnitCost: 10000}
item.CalculateTotalPrice()
item.CalculateTotalCost()
if item.TotalPrice != 75000 {
t.Errorf("TotalPrice = %v, want 75000", item.TotalPrice)
}
if item.TotalCost != 30000 {
t.Errorf("TotalCost = %v, want 30000", item.TotalCost)
}
}
// Sub-rupiah residue must not survive into a decimal(10,2) column.
func TestCalculateTotalPriceRoundsToStoredPrecision(t *testing.T) {
item := OrderItem{Quantity: 1, Weight: weightPtr(4.237), UnitPrice: 4500}
item.CalculateTotalPrice()
if item.TotalPrice != 19066.5 {
t.Errorf("TotalPrice = %v, want 19066.5", item.TotalPrice)
}
}
func TestIsWeighed(t *testing.T) {
if (&OrderItem{Quantity: 1}).IsWeighed() {
t.Error("unit line reported as weighed")
}
if !(&OrderItem{Quantity: 1, Weight: weightPtr(1.5)}).IsWeighed() {
t.Error("weighed line reported as unit")
}
}
func TestRoundWeight(t *testing.T) {
tests := []struct {
in float64
want float64
}{
{4.2, 4.2},
{4.2374, 4.237},
{4.2375, 4.238},
{0.0004, 0},
}
for _, tt := range tests {
if got := RoundWeight(tt.in); got != tt.want {
t.Errorf("RoundWeight(%v) = %v, want %v", tt.in, got, tt.want)
}
}
}
+1
View File
@@ -20,6 +20,7 @@ type Product struct {
ImageURL *string `gorm:"size:500" json:"image_url"`
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
IsActive bool `gorm:"default:true" json:"is_active"`
+1
View File
@@ -344,6 +344,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
ProductID: item.ProductID,
ProductVariantID: item.ProductVariantID,
Quantity: item.Quantity,
Weight: item.Weight,
Notes: item.Notes,
})
}
+11
View File
@@ -115,12 +115,23 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
}
}
// Unit is only meaningful for weighed lines, but carry it whenever it was captured.
var unitName, unitAbbreviation *string
if item.Unit != nil {
unitName = &item.Unit.Name
unitAbbreviation = item.Unit.Abbreviation
}
response := &models.OrderItemResponse{
ID: item.ID,
OrderID: item.OrderID,
ProductID: item.ProductID,
ProductVariantID: item.ProductVariantID,
Quantity: item.Quantity,
Weight: item.Weight,
UnitID: item.UnitID,
UnitName: unitName,
UnitAbbreviation: unitAbbreviation,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
UnitCost: item.UnitCost,
+18
View File
@@ -25,6 +25,7 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
SellBy: entity.SellBy,
Metadata: map[string]interface{}(entity.Metadata),
IsActive: entity.IsActive,
CreatedAt: entity.CreatedAt,
@@ -76,6 +77,11 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
printerType = *req.PrinterType
}
sellBy := constants.SellByUnit
if constants.IsValidSellBy(req.SellBy) {
sellBy = req.SellBy
}
metadata := entities.Metadata{}
if req.Metadata != nil {
metadata = entities.Metadata(req.Metadata)
@@ -92,6 +98,8 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
BusinessType: businessType,
ImageURL: req.ImageURL,
PrinterType: printerType,
UnitID: req.UnitID,
SellBy: sellBy,
Metadata: metadata,
IsActive: true, // Default to active
}
@@ -140,6 +148,8 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
UnitID: entity.UnitID,
SellBy: entity.SellBy,
Metadata: map[string]interface{}(entity.Metadata),
IsActive: entity.IsActive,
CreatedAt: entity.CreatedAt,
@@ -185,6 +195,14 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
entity.PrinterType = *req.PrinterType
}
if req.UnitID != nil {
entity.UnitID = req.UnitID
}
if req.SellBy != nil && constants.IsValidSellBy(*req.SellBy) {
entity.SellBy = *req.SellBy
}
if req.Metadata != nil {
if entity.Metadata == nil {
entity.Metadata = make(entities.Metadata)
+2
View File
@@ -233,6 +233,7 @@ type ProductAnalyticsData struct {
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`
QuantitySold int64 `json:"quantity_sold"`
WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
@@ -359,6 +360,7 @@ type ParentCategoryAnalyticsProductData struct {
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
QuantitySold int64 `json:"quantity_sold"`
WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
+8 -1
View File
@@ -44,6 +44,8 @@ type OrderItem struct {
ProductID uuid.UUID
ProductVariantID *uuid.UUID
Quantity int
Weight *float64
UnitID *uuid.UUID
UnitPrice float64
TotalPrice float64
UnitCost float64
@@ -99,7 +101,8 @@ type CreateOrderItemRequest struct {
ProductID uuid.UUID `validate:"required"`
ProductVariantID *uuid.UUID `validate:"omitempty"`
Quantity int `validate:"required,min=1"`
UnitPrice *float64 `validate:"omitempty,min=0"` // Optional, will use database price if not provided
Weight *float64 `validate:"omitempty,gt=0"`
UnitPrice *float64 `validate:"omitempty,min=0"` // Ignored: price always comes from the database
Modifiers []map[string]interface{} `validate:"omitempty"`
Notes *string `validate:"omitempty,max=500"`
Metadata map[string]interface{} `validate:"omitempty"`
@@ -191,6 +194,10 @@ type OrderItemResponse struct {
CategoryID *uuid.UUID
CategoryName *string
Quantity int
Weight *float64
UnitID *uuid.UUID
UnitName *string
UnitAbbreviation *string
UnitPrice float64
TotalPrice float64
UnitCost float64
+4
View File
@@ -19,6 +19,7 @@ type Product struct {
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
SellBy string
UnitID *uuid.UUID
HasIngredients bool
Metadata map[string]interface{}
@@ -52,6 +53,7 @@ type CreateProductRequest struct {
PrinterType *string `validate:"omitempty,max=50"`
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"`
SellBy string `validate:"omitempty,oneof=unit weight"`
HasIngredients bool `validate:"omitempty"`
Metadata map[string]interface{}
Variants []CreateProductVariantRequest `validate:"omitempty,dive"`
@@ -73,6 +75,7 @@ type UpdateProductRequest struct {
PrinterType *string `validate:"omitempty,max=50"`
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"`
SellBy *string `validate:"omitempty,oneof=unit weight"`
HasIngredients *bool `validate:"omitempty"`
Metadata map[string]interface{}
IsActive *bool
@@ -110,6 +113,7 @@ type ProductResponse struct {
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
SellBy string
PrintToChecker bool
UnitID *uuid.UUID
HasIngredients bool
@@ -322,6 +322,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
CategoryName: data.CategoryName,
CategoryOrder: data.CategoryOrder,
QuantitySold: data.QuantitySold,
WeightSold: data.WeightSold,
Revenue: data.Revenue,
AveragePrice: data.AveragePrice,
OrderCount: data.OrderCount,
@@ -447,6 +448,7 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
ProductSku: product.ProductSku,
ProductPrice: product.ProductPrice,
QuantitySold: product.QuantitySold,
WeightSold: product.WeightSold,
Revenue: product.Revenue,
AveragePrice: product.AveragePrice,
OrderCount: product.OrderCount,
+61 -24
View File
@@ -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)
+106
View File
@@ -0,0 +1,106 @@
package processor
import (
"testing"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
func fPtr(v float64) *float64 { return &v }
func TestResolveLineQuantity(t *testing.T) {
weightProduct := &entities.Product{Name: "Ikan Tude", SellBy: constants.SellByWeight}
unitProduct := &entities.Product{Name: "Nasi Goreng", SellBy: constants.SellByUnit}
tests := []struct {
name string
product *entities.Product
quantity int
weight *float64
wantQuantity int
wantWeight *float64
wantErr bool
}{
{
name: "weighed line is pinned to quantity 1",
// Mirrors chk_order_items_weight_single_line: even if a client sends 3,
// one weighing is one line.
product: weightProduct, quantity: 3, weight: fPtr(4.2),
wantQuantity: 1, wantWeight: fPtr(4.2),
},
{
name: "weight is rounded to stored precision",
product: weightProduct, quantity: 1, weight: fPtr(5.6004),
wantQuantity: 1, wantWeight: fPtr(5.6),
},
{
name: "weight product without a weight is rejected",
product: weightProduct, quantity: 1, weight: nil,
wantErr: true,
},
{
name: "weight that rounds away to zero is rejected",
product: weightProduct, quantity: 1, weight: fPtr(0.0004),
wantErr: true,
},
{
name: "unit line keeps its quantity and carries no weight",
product: unitProduct, quantity: 3, weight: nil,
wantQuantity: 3, wantWeight: nil,
},
{
name: "weight on a unit product is rejected",
// Otherwise the line would silently be priced by weight.
product: unitProduct, quantity: 1, weight: fPtr(4.2),
wantErr: true,
},
{
name: "non-positive quantity on a unit product is rejected",
product: unitProduct, quantity: 0, weight: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotQty, gotWeight, err := resolveLineQuantity(tt.product, tt.quantity, tt.weight)
if tt.wantErr {
if err == nil {
t.Fatalf("expected an error, got quantity=%v weight=%v", gotQty, gotWeight)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotQty != tt.wantQuantity {
t.Errorf("quantity = %v, want %v", gotQty, tt.wantQuantity)
}
switch {
case tt.wantWeight == nil && gotWeight != nil:
t.Errorf("weight = %v, want nil", *gotWeight)
case tt.wantWeight != nil && gotWeight == nil:
t.Errorf("weight = nil, want %v", *tt.wantWeight)
case tt.wantWeight != nil && *gotWeight != *tt.wantWeight:
t.Errorf("weight = %v, want %v", *gotWeight, *tt.wantWeight)
}
})
}
}
// A product created before this feature has sell_by defaulted to 'unit' by the
// migration, but a zero-valued struct must behave the same way.
func TestResolveLineQuantityTreatsEmptySellByAsUnit(t *testing.T) {
legacy := &entities.Product{Name: "Legacy"}
quantity, weight, err := resolveLineQuantity(legacy, 2, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if quantity != 2 || weight != nil {
t.Errorf("got quantity=%v weight=%v, want quantity=2 weight=nil", quantity, weight)
}
}
+3 -3
View File
@@ -140,7 +140,7 @@ func calculateSplitAmounts(req *models.SplitBillRequest, validation *SplitBillVa
for _, item := range req.Items {
orderItem := validation.OrderItems[item.OrderItemID]
itemAmount := float64(item.Quantity) * orderItem.UnitPrice
itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice)
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
totalItemAmount := itemAmount + itemTaxAmount
@@ -186,7 +186,7 @@ func (p *SplitBillProcessorImpl) createSplitPayment(ctx context.Context, req *mo
func (p *SplitBillProcessorImpl) createPaymentOrderItems(ctx context.Context, splitPayment *entities.Payment, req *models.SplitBillRequest, validation *SplitBillValidation) error {
for _, item := range req.Items {
orderItem := validation.OrderItems[item.OrderItemID]
itemAmount := float64(item.Quantity) * orderItem.UnitPrice
itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice)
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
totalItemAmount := itemAmount + itemTaxAmount
@@ -228,7 +228,7 @@ func (p *SplitBillProcessorImpl) updateOrderAfterSplit(ctx context.Context, orde
quantityInfo[item.OrderItemID.String()] = map[string]interface{}{
"quantity": item.Quantity,
"unit_price": orderItem.UnitPrice,
"total_amount": float64(item.Quantity) * orderItem.UnitPrice,
"total_amount": entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice),
}
}
order.Metadata[MetadataKeyLastSplitQuantities] = quantityInfo
+4 -3
View File
@@ -461,6 +461,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
c.name as category_name,
c.order as category_order,
COALESCE(SUM(oi.quantity), 0) as quantity_sold,
COALESCE(SUM(oi.weight), 0) as weight_sold,
COALESCE(SUM(oi.total_price), 0) as revenue,
CASE
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity)
@@ -1167,9 +1168,9 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C
}
type purchasingTotals struct {
Total float64
RawMaterial float64
Expense float64
Total float64
RawMaterial float64
Expense float64
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) {
+14 -7
View File
@@ -50,6 +50,7 @@ func (r *OrderItemRepositoryImpl) GetByOrderID(ctx context.Context, orderID uuid
err := r.db.WithContext(ctx).
Preload("Product").
Preload("ProductVariant").
Preload("Unit").
Where("order_id = ?", orderID).
Find(&orderItems).Error
return orderItems, err
@@ -109,8 +110,12 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
return err
}
if voidQuantity >= orderItem.Quantity {
voidedAmount := float64(voidQuantity) * orderItem.UnitPrice
// A weighed line is one weighing, so it is voided whole — never split into a
// remainder row. chk_order_items_weight_single_line pins its quantity to 1, which
// already sends it down this branch; the explicit check keeps that guarantee
// visible and independent of the comparison below.
if orderItem.IsWeighed() || voidQuantity >= orderItem.Quantity {
voidedAmount := entities.RoundMoney(orderItem.BillableQuantityFor(voidQuantity) * orderItem.UnitPrice)
updates := map[string]interface{}{
"refund_quantity": voidQuantity,
@@ -128,16 +133,18 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
Updates(updates).Error
}
// Only unit-priced lines reach here: weighed lines were voided whole above.
voidedOrderItem := entities.OrderItem{
OrderID: orderItem.OrderID,
ProductID: orderItem.ProductID,
ProductVariantID: orderItem.ProductVariantID,
Quantity: voidQuantity,
UnitID: orderItem.UnitID,
UnitPrice: orderItem.UnitPrice,
TotalPrice: float64(voidQuantity) * orderItem.UnitPrice,
TotalPrice: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice),
UnitCost: orderItem.UnitCost,
TotalCost: float64(voidQuantity) * orderItem.UnitCost,
RefundAmount: float64(voidQuantity) * orderItem.UnitPrice,
TotalCost: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitCost),
RefundAmount: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice),
RefundQuantity: voidQuantity,
IsPartiallyRefunded: false,
IsFullyRefunded: true,
@@ -155,8 +162,8 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
}
remainingQuantity := orderItem.Quantity - voidQuantity
remainingTotalPrice := float64(remainingQuantity) * orderItem.UnitPrice
remainingTotalCost := float64(remainingQuantity) * orderItem.UnitCost
remainingTotalPrice := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitPrice)
remainingTotalCost := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitCost)
updates := map[string]interface{}{
"quantity": remainingQuantity,
+3
View File
@@ -63,6 +63,7 @@ func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID
Preload("OrderItems.Product.Category").
Preload("OrderItems.Product.ProductOutletPrices").
Preload("OrderItems.ProductVariant").
Preload("OrderItems.Unit").
Preload("Payments").
Preload("Payments.PaymentMethod").
Preload("Payments.PaymentOrderItems").
@@ -144,6 +145,7 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter
Preload("OrderItems.Product.Category").
Preload("OrderItems.Product.ProductOutletPrices").
Preload("OrderItems.ProductVariant").
Preload("OrderItems.Unit").
Preload("Payments").
Preload("Payments.PaymentMethod").
Preload("Payments.PaymentOrderItems").
@@ -162,6 +164,7 @@ func (r *OrderRepositoryImpl) ListBySessionID(ctx context.Context, sessionID str
Preload("OrderItems.Product.Category").
Preload("OrderItems.Product.ProductOutletPrices").
Preload("OrderItems.ProductVariant").
Preload("OrderItems.Unit").
Preload("Payments").
Preload("Payments.PaymentMethod").
Preload("Payments.PaymentOrderItems").
+12
View File
@@ -64,6 +64,7 @@ type reportSummary struct {
type reportItem struct {
Name string
Quantity int64
Weight string
GrossSales string
Discount string
NetSales string
@@ -161,6 +162,7 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org
items = append(items, reportItem{
Name: p.ProductName,
Quantity: p.QuantitySold,
Weight: formatWeight(p.WeightSold),
GrossSales: formatCurrency(p.Revenue),
Discount: formatCurrency(0),
NetSales: formatCurrency(p.Revenue),
@@ -489,6 +491,16 @@ func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLoss
return result
}
// formatWeight renders the weight sold for a product, or a dash when the product is
// not sold by weight. Trailing zeros are trimmed so 4.200 prints as "4,2".
func formatWeight(weight float64) string {
if weight <= 0 {
return "-"
}
s := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.3f", weight), "0"), ".")
return strings.Replace(s, ".", ",", 1)
}
func formatCurrencyOrDash(amount float64) string {
if amount == 0 {
return "-"
@@ -291,6 +291,7 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
CategoryName: item.CategoryName,
CategoryOrder: item.CategoryOrder,
QuantitySold: item.QuantitySold,
WeightSold: item.WeightSold,
Revenue: item.Revenue,
AveragePrice: item.AveragePrice,
OrderCount: item.OrderCount,
@@ -506,6 +507,7 @@ func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAna
ProductSku: product.ProductSku,
ProductPrice: product.ProductPrice,
QuantitySold: product.QuantitySold,
WeightSold: product.WeightSold,
Revenue: product.Revenue,
AveragePrice: product.AveragePrice,
OrderCount: product.OrderCount,
@@ -592,6 +594,7 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
CategoryID: item.CategoryID,
CategoryName: item.CategoryName,
QuantitySold: item.QuantitySold,
WeightSold: item.WeightSold,
Revenue: item.Revenue,
AveragePrice: item.AveragePrice,
OrderCount: item.OrderCount,
@@ -103,6 +103,10 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse {
CategoryID: item.CategoryID,
CategoryName: item.CategoryName,
Quantity: item.Quantity,
Weight: item.Weight,
UnitID: item.UnitID,
UnitName: item.UnitName,
UnitAbbreviation: item.UnitAbbreviation,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
Modifiers: item.Modifiers,
@@ -174,6 +178,10 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO
CategoryID: item.CategoryID,
CategoryName: item.CategoryName,
Quantity: item.Quantity,
Weight: item.Weight,
UnitID: item.UnitID,
UnitName: item.UnitName,
UnitAbbreviation: item.UnitAbbreviation,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
Modifiers: item.Modifiers,
@@ -20,6 +20,11 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
businessType = constants.BusinessType(*req.BusinessType)
}
sellBy := constants.SellByUnit
if req.SellBy != nil {
sellBy = *req.SellBy
}
var variants []models.CreateProductVariantRequest
if req.Variants != nil {
variants = make([]models.CreateProductVariantRequest, len(req.Variants))
@@ -58,6 +63,8 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
ImageURL: req.ImageURL,
PrinterType: req.PrinterType,
PrintToChecker: req.PrintToChecker,
UnitID: req.UnitID,
SellBy: sellBy,
Metadata: metadata,
Variants: variants,
}
@@ -86,6 +93,8 @@ func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Up
ImageURL: req.ImageURL,
PrinterType: req.PrinterType,
PrintToChecker: req.PrintToChecker,
UnitID: req.UnitID,
SellBy: req.SellBy,
Metadata: metadata,
IsActive: req.IsActive,
}
@@ -145,6 +154,8 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
ImageURL: prod.ImageURL,
PrinterType: prod.PrinterType,
PrintToChecker: prod.PrintToChecker,
UnitID: prod.UnitID,
SellBy: prod.SellBy,
Metadata: prod.Metadata,
IsActive: prod.IsActive,
CreatedAt: prod.CreatedAt,