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:
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user