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
+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"`