Files
apskel-pos-backend/internal/models/product.go
efrilmandClaude Opus 5 992bb04816 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>
2026-09-06 17:14:42 +07:00

162 lines
5.2 KiB
Go

package models
import (
"apskel-pos-be/internal/constants"
"time"
"github.com/google/uuid"
)
type Product struct {
ID uuid.UUID
OrganizationID uuid.UUID
CategoryID uuid.UUID
SKU *string
Name string
Description *string
Price float64
Cost float64
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
SellBy string
UnitID *uuid.UUID
HasIngredients bool
Metadata map[string]interface{}
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
}
type ProductVariant struct {
ID uuid.UUID
ProductID uuid.UUID
Name string
PriceModifier float64
Cost float64
Metadata map[string]interface{}
CreatedAt time.Time
UpdatedAt time.Time
}
type CreateProductRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID uuid.UUID `validate:"omitempty"` // If set, upsert product_outlet_prices on create
CategoryID uuid.UUID `validate:"required"`
SKU *string `validate:"omitempty,max=100"`
Name string `validate:"required,min=1,max=255"`
Description *string `validate:"omitempty,max=1000"`
Price float64 `validate:"required,min=0"`
Cost float64 `validate:"min=0"`
BusinessType constants.BusinessType `validate:"required"`
ImageURL *string `validate:"omitempty,max=500"`
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"`
// Stock management fields
InitialStock *int `validate:"omitempty,min=0"` // Initial stock quantity for all outlets
ReorderLevel *int `validate:"omitempty,min=0"` // Reorder level for all outlets
CreateInventory bool `validate:"omitempty"` // Whether to create inventory records for all outlets
}
type UpdateProductRequest struct {
OutletID uuid.UUID `validate:"omitempty"` // If set, upsert product_outlet_prices on update
CategoryID *uuid.UUID `validate:"omitempty"`
SKU *string `validate:"omitempty,max=100"`
Name *string `validate:"omitempty,min=1,max=255"`
Description *string `validate:"omitempty,max=1000"`
Price *float64 `validate:"omitempty,min=0"`
Cost *float64 `validate:"omitempty,min=0"`
ImageURL *string `validate:"omitempty,max=500"`
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
// Stock management fields
ReorderLevel *int `validate:"omitempty,min=0"` // Update reorder level for all existing inventory records
}
type CreateProductVariantRequest struct {
ProductID uuid.UUID `validate:"required"`
Name string `validate:"required,min=1,max=255"`
PriceModifier float64 `validate:"required"`
Cost float64 `validate:"min=0"`
Metadata map[string]interface{}
}
type UpdateProductVariantRequest struct {
Name *string `validate:"omitempty,min=1,max=255"`
PriceModifier *float64
Cost *float64 `validate:"omitempty,min=0"`
Metadata map[string]interface{}
}
type ProductResponse struct {
ID uuid.UUID
OrganizationID uuid.UUID
CategoryID uuid.UUID
CategoryName string
SKU *string
Name string
Description *string
Price float64
OutletPrice *float64 // outlet-specific price, nil if not set
OutletPrices []OutletPrice // all outlet prices, populated when no outletID in context
Cost float64
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
SellBy string
PrintToChecker bool
UnitID *uuid.UUID
HasIngredients bool
Metadata map[string]interface{}
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
Variants []ProductVariantResponse
}
type OutletPrice struct {
OutletID uuid.UUID
OutletName string
Price float64
PrintToChecker bool
}
type ProductVariantResponse struct {
ID uuid.UUID
ProductID uuid.UUID
Name string
PriceModifier float64
Cost float64
Metadata map[string]interface{}
CreatedAt time.Time
UpdatedAt time.Time
}
func (p *Product) GetFinalPrice(variant *ProductVariant) float64 {
if variant == nil {
return p.Price
}
return p.Price + variant.PriceModifier
}
func (p *Product) GetProfitMargin() float64 {
if p.Price == 0 {
return 0
}
return ((p.Price - p.Cost) / p.Price) * 100
}
func (p *Product) IsValidPrice() bool {
return p.Price > 0 && p.Cost >= 0 && p.Price > p.Cost
}