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>
122 lines
6.8 KiB
Go
122 lines
6.8 KiB
Go
package contract
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type CreateProductRequest struct {
|
|
CategoryID uuid.UUID `json:"category_id" validate:"required"`
|
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
|
SKU *string `json:"sku,omitempty"`
|
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
|
Description *string `json:"description,omitempty"`
|
|
Price float64 `json:"price" validate:"required,min=0"`
|
|
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
|
BusinessType *string `json:"business_type,omitempty"`
|
|
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"`
|
|
InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"`
|
|
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
|
|
CreateInventory bool `json:"create_inventory,omitempty"`
|
|
}
|
|
|
|
type UpdateProductRequest struct {
|
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
|
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
|
SKU *string `json:"sku,omitempty"`
|
|
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
|
Description *string `json:"description,omitempty"`
|
|
Price *float64 `json:"price,omitempty" validate:"omitempty,min=0"`
|
|
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
|
BusinessType *string `json:"business_type,omitempty"`
|
|
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"`
|
|
}
|
|
|
|
type CreateProductVariantRequest struct {
|
|
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
|
PriceModifier float64 `json:"price_modifier" validate:"required"`
|
|
Cost float64 `json:"cost" validate:"min=0"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type UpdateProductVariantRequest struct {
|
|
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
|
PriceModifier *float64 `json:"price_modifier,omitempty"`
|
|
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type ProductResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
CategoryID uuid.UUID `json:"category_id"`
|
|
CategoryName string `json:"category_name"`
|
|
SKU *string `json:"sku"`
|
|
Name string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Price float64 `json:"price"`
|
|
OutletPrice *float64 `json:"outlet_price,omitempty"`
|
|
OutletPrices []ProductOutletPriceResponse `json:"outlet_prices,omitempty"`
|
|
Cost float64 `json:"cost"`
|
|
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"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Category *CategoryResponse `json:"category,omitempty"`
|
|
Variants []ProductVariantResponse `json:"variants,omitempty"`
|
|
}
|
|
|
|
type ProductVariantResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ProductID uuid.UUID `json:"product_id"`
|
|
Name string `json:"name"`
|
|
PriceModifier float64 `json:"price_modifier"`
|
|
Cost float64 `json:"cost"`
|
|
Metadata map[string]interface{} `json:"metadata"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type ListProductsRequest struct {
|
|
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
|
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
|
BusinessType string `json:"business_type,omitempty"`
|
|
IsActive *bool `json:"is_active,omitempty"`
|
|
Search string `json:"search,omitempty"`
|
|
MinPrice *float64 `json:"min_price,omitempty" validate:"omitempty,min=0"`
|
|
MaxPrice *float64 `json:"max_price,omitempty" validate:"omitempty,min=0"`
|
|
Page int `json:"page" validate:"required,min=1"`
|
|
Limit int `json:"limit" validate:"required,min=1,max=100"`
|
|
}
|
|
|
|
type ListProductsResponse struct {
|
|
Products []ProductResponse `json:"products"`
|
|
TotalCount int `json:"total_count"`
|
|
Page int `json:"page"`
|
|
Limit int `json:"limit"`
|
|
TotalPages int `json:"total_pages"`
|
|
}
|