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>
144 lines
5.5 KiB
Go
144 lines
5.5 KiB
Go
package entities
|
|
|
|
import (
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type Modifiers []map[string]interface{}
|
|
|
|
func (m Modifiers) Value() (driver.Value, error) {
|
|
return json.Marshal(m)
|
|
}
|
|
|
|
func (m *Modifiers) Scan(value interface{}) error {
|
|
if value == nil {
|
|
*m = make(Modifiers, 0)
|
|
return nil
|
|
}
|
|
|
|
bytes, ok := value.([]byte)
|
|
if !ok {
|
|
return errors.New("type assertion to []byte failed")
|
|
}
|
|
|
|
return json.Unmarshal(bytes, m)
|
|
}
|
|
|
|
type OrderItemStatus string
|
|
|
|
const (
|
|
OrderItemStatusPending OrderItemStatus = "pending"
|
|
OrderItemStatusPreparing OrderItemStatus = "preparing"
|
|
OrderItemStatusReady OrderItemStatus = "ready"
|
|
OrderItemStatusServed OrderItemStatus = "served"
|
|
OrderItemStatusCancelled OrderItemStatus = "cancelled"
|
|
OrderItemStatusPaid OrderItemStatus = "paid"
|
|
)
|
|
|
|
type OrderItem struct {
|
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
|
OrderID uuid.UUID `gorm:"type:uuid;not null;index" json:"order_id" validate:"required"`
|
|
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"`
|
|
TotalCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"total_cost"`
|
|
RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"`
|
|
RefundQuantity int `gorm:"default:0" json:"refund_quantity"`
|
|
IsPartiallyRefunded bool `gorm:"default:false" json:"is_partially_refunded"`
|
|
IsFullyRefunded bool `gorm:"default:false" json:"is_fully_refunded"`
|
|
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
|
|
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
|
|
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
|
|
Modifiers Modifiers `gorm:"type:jsonb;default:'[]'" json:"modifiers"`
|
|
Notes *string `gorm:"size:500" json:"notes,omitempty"`
|
|
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
|
Status OrderItemStatus `gorm:"default:'pending';size:50" json:"status"`
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
|
|
|
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 {
|
|
if oi.ID == uuid.Nil {
|
|
oi.ID = uuid.New()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
|
|
}
|
|
|
|
func (oi *OrderItem) CalculateTotalCost() {
|
|
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
|
|
}
|
|
|
|
func (oi *OrderItem) CanBeModified() bool {
|
|
return oi.Status == OrderItemStatusPending
|
|
}
|