Files
apskel-pos-backend/internal/models/order.go
T
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

310 lines
9.1 KiB
Go

package models
import (
"apskel-pos-be/internal/constants"
"time"
"github.com/google/uuid"
)
type Order struct {
ID uuid.UUID
OrganizationID uuid.UUID
OutletID uuid.UUID
UserID uuid.UUID
CustomerID *uuid.UUID
OrderNumber string
TableNumber *string
OrderType constants.OrderType
Status constants.OrderStatus
Subtotal float64
TaxAmount float64
DiscountAmount float64
TotalAmount float64
TotalCost float64
RemainingAmount float64
PaymentStatus constants.PaymentStatus
RefundAmount float64
IsVoid bool
IsRefund bool
VoidReason *string
VoidedAt *time.Time
VoidedBy *uuid.UUID
RefundReason *string
RefundedAt *time.Time
RefundedBy *uuid.UUID
Metadata map[string]interface{}
CreatedAt time.Time
UpdatedAt time.Time
}
type OrderItem struct {
ID uuid.UUID
OrderID uuid.UUID
ProductID uuid.UUID
ProductVariantID *uuid.UUID
Quantity int
Weight *float64
UnitID *uuid.UUID
UnitPrice float64
TotalPrice float64
UnitCost float64
TotalCost float64
RefundAmount float64
RefundQuantity int
IsPartiallyRefunded bool
IsFullyRefunded bool
RefundReason *string
RefundedAt *time.Time
RefundedBy *uuid.UUID
Modifiers []map[string]interface{}
Status constants.OrderItemStatus
CreatedAt time.Time
UpdatedAt time.Time
}
type PaymentOrderItem struct {
ID uuid.UUID
PaymentID uuid.UUID
OrderItemID uuid.UUID
Quantity int // Quantity paid for this specific payment
Amount float64
CreatedAt time.Time
UpdatedAt time.Time
}
type OrderSequence struct {
ID uuid.UUID
OrganizationID uuid.UUID
OutletID uuid.UUID
Year int
Month int
SequenceNumber int
CreatedAt time.Time
UpdatedAt time.Time
}
type CreateOrderRequest struct {
OutletID uuid.UUID `validate:"required"`
UserID uuid.UUID `validate:"required"`
CustomerID *uuid.UUID `validate:"omitempty"`
TableID *uuid.UUID `validate:"omitempty"`
TableNumber *string `validate:"omitempty,max=50"`
OrderType constants.OrderType `validate:"required"`
OrderItems []CreateOrderItemRequest `validate:"required,min=1,dive"`
Notes *string `validate:"omitempty,max=1000"`
Metadata map[string]interface{}
CustomerName *string `validate:"omitempty,max=255"`
}
type CreateOrderItemRequest struct {
ProductID uuid.UUID `validate:"required"`
ProductVariantID *uuid.UUID `validate:"omitempty"`
Quantity int `validate:"required,min=1"`
Weight *float64 `validate:"omitempty,gt=0"`
UnitPrice *float64 `validate:"omitempty,min=0"` // Ignored: price always comes from the database
Modifiers []map[string]interface{} `validate:"omitempty"`
Notes *string `validate:"omitempty,max=500"`
Metadata map[string]interface{} `validate:"omitempty"`
}
type UpdateOrderRequest struct {
TableNumber *string `validate:"omitempty,max=50"`
Status *constants.OrderStatus `validate:"omitempty"`
DiscountAmount *float64 `validate:"omitempty,min=0"`
Notes *string `validate:"omitempty,max=1000"`
Metadata map[string]interface{}
}
type CreatePaymentOrderItemRequest struct {
OrderItemID uuid.UUID `validate:"required"`
Quantity int `validate:"required,min=1"` // Quantity being paid for
Amount float64 `validate:"required,min=0"`
}
type VoidOrderRequest struct {
OrderID uuid.UUID `validate:"required"`
Reason string `validate:"required"`
Type string `validate:"required,oneof=ALL ITEM"`
Items []VoidItemRequest `validate:"required_if=Type ITEM,dive"`
}
type VoidItemRequest struct {
OrderItemID uuid.UUID `validate:"required"`
Quantity int `validate:"required,min=1"`
}
type RefundOrderRequest struct {
Reason *string `validate:"omitempty,max=255"`
RefundAmount *float64 `validate:"omitempty,min=0"`
OrderItems []RefundOrderItemRequest `validate:"omitempty,dive"`
}
type RefundOrderItemRequest struct {
OrderItemID uuid.UUID `validate:"required"`
RefundQuantity int `validate:"omitempty,min=1"`
RefundAmount *float64 `validate:"omitempty,min=0"`
Reason *string `validate:"omitempty,max=255"`
}
type OrderResponse struct {
ID uuid.UUID
OrganizationID uuid.UUID
OutletID uuid.UUID
UserID uuid.UUID
CustomerID *uuid.UUID
OrderNumber string
TableNumber *string
OrderType constants.OrderType
Status constants.OrderStatus
Subtotal float64
TaxAmount float64
DiscountAmount float64
TotalAmount float64
TotalCost float64
RemainingAmount float64
PaymentStatus constants.PaymentStatus
RefundAmount float64
IsVoid bool
IsRefund bool
VoidReason *string
VoidedAt *time.Time
VoidedBy *uuid.UUID
RefundReason *string
RefundedAt *time.Time
RefundedBy *uuid.UUID
Notes *string
Metadata map[string]interface{}
CreatedAt time.Time
UpdatedAt time.Time
OrderItems []OrderItemResponse
Payments []PaymentResponse
TotalPaid float64
PaymentCount int
SplitType *string
}
type OrderItemResponse struct {
ID uuid.UUID
OrderID uuid.UUID
ProductID uuid.UUID
ProductName string
ProductVariantID *uuid.UUID
ProductVariantName *string
CategoryID *uuid.UUID
CategoryName *string
Quantity int
Weight *float64
UnitID *uuid.UUID
UnitName *string
UnitAbbreviation *string
UnitPrice float64
TotalPrice float64
UnitCost float64
TotalCost float64
RefundAmount float64
RefundQuantity int
IsPartiallyRefunded bool
IsFullyRefunded bool
RefundReason *string
RefundedAt *time.Time
RefundedBy *uuid.UUID
Modifiers []map[string]interface{}
Notes *string
Metadata map[string]interface{}
Status constants.OrderItemStatus
CreatedAt time.Time
UpdatedAt time.Time
PrinterType string
PrintToChecker bool
PaidQuantity int
}
type PaymentOrderItemResponse struct {
ID uuid.UUID
PaymentID uuid.UUID
OrderItemID uuid.UUID
Quantity int // Quantity paid for this specific payment
Amount float64
CreatedAt time.Time
UpdatedAt time.Time
}
type ListOrdersRequest struct {
OrganizationID *uuid.UUID
OutletID *uuid.UUID
UserID *uuid.UUID
CustomerID *uuid.UUID
OrderType *constants.OrderType
Status *constants.OrderStatus
PaymentStatus *constants.PaymentStatus
IsVoid *bool
IsRefund *bool
DateFrom *time.Time
DateTo *time.Time
Search string
Page int `validate:"required,min=1"`
Limit int `validate:"required,min=1,max=100"`
}
type ListOrdersResponse struct {
Orders []OrderResponse
Payments []PaymentResponse
TotalCount int
Page int
Limit int
TotalPages int
}
func (o *Order) CanBeModified() bool {
return o.Status == constants.OrderStatusPending || o.Status == constants.OrderStatusPreparing
}
func (o *Order) CanBeCancelled() bool {
return o.Status == constants.OrderStatusPending || o.Status == constants.OrderStatusPreparing
}
func (o *Order) CalculateTotalAmount(taxRate float64) float64 {
taxAmount := o.Subtotal * taxRate
return o.Subtotal + taxAmount - o.DiscountAmount
}
func (o *Order) IsComplete() bool {
return o.Status == constants.OrderStatusCompleted
}
func (o *Order) IsPaid() bool {
return o.Status == constants.OrderStatusPaid
}
func (r *CreateOrderRequest) Validate() bool {
return r.OrderType.IsValidOrderType()
}
// AddToOrderRequest represents the request to add items to an existing order
type AddToOrderRequest struct {
OrderItems []CreateOrderItemRequest `validate:"required,min=1,dive"`
Notes *string `validate:"omitempty,max=1000"`
Metadata map[string]interface{} `validate:"omitempty"`
}
// AddToOrderResponse represents the response when adding items to an existing order
type AddToOrderResponse struct {
OrderID uuid.UUID
OrderNumber string
AddedItems []OrderItemResponse
UpdatedOrder OrderResponse
}
// SetOrderCustomerRequest represents the request to set customer for an order
type SetOrderCustomerRequest struct {
CustomerID uuid.UUID `validate:"required"`
}
// SetOrderCustomerResponse represents the response when setting customer for an order
type SetOrderCustomerResponse struct {
OrderID uuid.UUID
CustomerID uuid.UUID
Message string
}