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>
281 lines
14 KiB
Go
281 lines
14 KiB
Go
package contract
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type CreateOrderRequest struct {
|
|
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
|
UserID uuid.UUID `json:"user_id" validate:"required"`
|
|
CustomerID *uuid.UUID `json:"customer_id"`
|
|
TableID *uuid.UUID `json:"table_id,omitempty" validate:"omitempty"`
|
|
TableNumber *string `json:"table_number,omitempty" validate:"omitempty,max=50"`
|
|
OrderType string `json:"order_type" validate:"required,oneof=dine_in takeaway delivery"`
|
|
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000"`
|
|
OrderItems []CreateOrderItemRequest `json:"order_items" validate:"required,min=1,dive"`
|
|
CustomerName *string `json:"customer_name,omitempty" validate:"omitempty,max=255"`
|
|
}
|
|
|
|
type AddToOrderRequest struct {
|
|
OrderItems []CreateOrderItemRequest `json:"order_items" validate:"required,min=1,dive"`
|
|
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type AddToOrderResponse struct {
|
|
OrderID uuid.UUID `json:"order_id"`
|
|
OrderNumber string `json:"order_number"`
|
|
AddedItems []OrderItemResponse `json:"added_items"`
|
|
UpdatedOrder OrderResponse `json:"updated_order"`
|
|
}
|
|
|
|
type UpdateOrderRequest struct {
|
|
TableNumber *string `json:"table_number,omitempty" validate:"omitempty,max=50"`
|
|
Status *string `json:"status,omitempty" validate:"omitempty,oneof=pending preparing ready completed cancelled"`
|
|
DiscountAmount *float64 `json:"discount_amount,omitempty" validate:"omitempty,min=0"`
|
|
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type CreateOrderItemRequest struct {
|
|
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
|
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
|
Quantity int `json:"quantity" validate:"required,min=1"`
|
|
// Weight is required for products with sell_by = "weight" and must be absent otherwise.
|
|
// One weighing is one line, so quantity stays 1 and the line is priced weight * unit_price.
|
|
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
|
|
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Ignored: price always comes from the database
|
|
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
|
|
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type UpdateOrderItemRequest struct {
|
|
Quantity *int `json:"quantity,omitempty" validate:"omitempty,min=1"`
|
|
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"`
|
|
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
|
|
Status *string `json:"status,omitempty" validate:"omitempty,oneof=pending preparing completed cancelled"`
|
|
}
|
|
|
|
type OrderResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrderNumber string `json:"order_number"`
|
|
OutletID uuid.UUID `json:"outlet_id"`
|
|
UserID uuid.UUID `json:"user_id"`
|
|
TableNumber *string `json:"table_number"`
|
|
OrderType string `json:"order_type"`
|
|
Status string `json:"status"`
|
|
Subtotal float64 `json:"subtotal"`
|
|
TaxAmount float64 `json:"tax_amount"`
|
|
DiscountAmount float64 `json:"discount_amount"`
|
|
TotalAmount float64 `json:"total_amount"`
|
|
TotalCost float64 `json:"total_cost"`
|
|
RemainingAmount float64 `json:"remaining_amount"`
|
|
PaymentStatus string `json:"payment_status"`
|
|
RefundAmount float64 `json:"refund_amount"`
|
|
IsVoid bool `json:"is_void"`
|
|
IsRefund bool `json:"is_refund"`
|
|
VoidReason *string `json:"void_reason,omitempty"`
|
|
VoidedAt *time.Time `json:"voided_at,omitempty"`
|
|
VoidedBy *uuid.UUID `json:"voided_by,omitempty"`
|
|
RefundReason *string `json:"refund_reason,omitempty"`
|
|
RefundedAt *time.Time `json:"refunded_at,omitempty"`
|
|
RefundedBy *uuid.UUID `json:"refunded_by,omitempty"`
|
|
Notes *string `json:"notes"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
OrderItems []OrderItemResponse `json:"order_items,omitempty"`
|
|
Payments []PaymentResponse `json:"payments,omitempty"`
|
|
TotalPaid float64 `json:"total_paid"`
|
|
PaymentCount int `json:"payment_count"`
|
|
SplitType *string `json:"split_type,omitempty"`
|
|
}
|
|
|
|
type OrderItemResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrderID uuid.UUID `json:"order_id"`
|
|
ProductID uuid.UUID `json:"product_id"`
|
|
ProductName string `json:"product_name"`
|
|
ProductVariantID *uuid.UUID `json:"product_variant_id"`
|
|
ProductVariantName *string `json:"product_variant_name,omitempty"`
|
|
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
|
CategoryName *string `json:"category_name,omitempty"`
|
|
Quantity int `json:"quantity"`
|
|
// Weight is set only for products sold by weight; the line is priced weight * unit_price.
|
|
Weight *float64 `json:"weight,omitempty"`
|
|
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
|
UnitName *string `json:"unit_name,omitempty"`
|
|
UnitAbbreviation *string `json:"unit_abbreviation,omitempty"`
|
|
UnitPrice float64 `json:"unit_price"`
|
|
TotalPrice float64 `json:"total_price"`
|
|
Modifiers []map[string]interface{} `json:"modifiers"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
PrinterType string `json:"printer_type"`
|
|
PrintToChecker bool `json:"print_to_checker"`
|
|
PaidQuantity int `json:"paid_quantity"`
|
|
}
|
|
|
|
type ListOrdersQuery struct {
|
|
OrganizationID string `form:"organization_id"`
|
|
OutletID string `form:"outlet_id"`
|
|
UserID string `form:"user_id"`
|
|
CustomerID string `form:"customer_id"`
|
|
OrderType string `form:"order_type"`
|
|
Status string `form:"status"`
|
|
PaymentStatus string `form:"payment_status"`
|
|
IsVoid string `form:"is_void"`
|
|
IsRefund string `form:"is_refund"`
|
|
DateFrom string `form:"date_from"`
|
|
DateTo string `form:"date_to"`
|
|
Search string `form:"search"`
|
|
Page int `form:"page,default=1"`
|
|
Limit int `form:"limit,default=10"`
|
|
}
|
|
|
|
type ListOrdersRequest struct {
|
|
Page int `json:"page" validate:"min=1"`
|
|
Limit int `json:"limit" validate:"min=1,max=100"`
|
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
|
UserID *uuid.UUID `json:"user_id,omitempty"`
|
|
Status *string `json:"status,omitempty" validate:"omitempty,oneof=pending preparing ready completed cancelled"`
|
|
OrderType *string `json:"order_type,omitempty" validate:"omitempty,oneof=dine_in takeaway delivery"`
|
|
DateFrom *time.Time `json:"date_from,omitempty"`
|
|
DateTo *time.Time `json:"date_to,omitempty"`
|
|
}
|
|
|
|
type ListOrdersResponse struct {
|
|
Orders []OrderResponse `json:"orders"`
|
|
Payments []PaymentResponse `json:"payments"`
|
|
TotalCount int `json:"total_count"`
|
|
Page int `json:"page"`
|
|
Limit int `json:"limit"`
|
|
TotalPages int `json:"total_pages"`
|
|
}
|
|
|
|
type VoidOrderRequest struct {
|
|
OrderID uuid.UUID `json:"order_id" validate:"required"`
|
|
Reason string `json:"reason" validate:"required"`
|
|
Type string `json:"type" validate:"required,oneof=ALL ITEM"`
|
|
Items []VoidItemRequest `json:"items,omitempty" validate:"required_if=Type ITEM,dive"`
|
|
}
|
|
|
|
type VoidItemRequest struct {
|
|
OrderItemID uuid.UUID `json:"order_item_id" validate:"required"`
|
|
Quantity int `json:"quantity" validate:"required,min=1"`
|
|
}
|
|
|
|
type SetOrderCustomerRequest struct {
|
|
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
|
}
|
|
|
|
type SetOrderCustomerResponse struct {
|
|
OrderID uuid.UUID `json:"order_id"`
|
|
CustomerID uuid.UUID `json:"customer_id"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type CreatePaymentRequest struct {
|
|
OrderID uuid.UUID `json:"order_id" validate:"required"`
|
|
PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"`
|
|
Amount float64 `json:"amount" validate:"required,min=0"`
|
|
TransactionID *string `json:"transaction_id,omitempty" validate:"omitempty"`
|
|
SplitNumber int `json:"split_number,omitempty" validate:"omitempty,min=1"`
|
|
SplitTotal int `json:"split_total,omitempty" validate:"omitempty,min=1"`
|
|
SplitType *string `json:"split_type,omitempty" validate:"omitempty,oneof=AMOUNT ITEM"`
|
|
SplitDescription *string `json:"split_description,omitempty" validate:"omitempty,max=255"`
|
|
PaymentOrderItems []CreatePaymentOrderItemRequest `json:"payment_order_items,omitempty" validate:"omitempty,dive"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type CreatePaymentOrderItemRequest struct {
|
|
OrderItemID uuid.UUID `json:"order_item_id" validate:"required"`
|
|
Amount float64 `json:"amount" validate:"min=0"`
|
|
}
|
|
|
|
type PaymentResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrderID uuid.UUID `json:"order_id"`
|
|
PaymentMethodID uuid.UUID `json:"payment_method_id"`
|
|
PaymentMethodName string `json:"payment_method_name"`
|
|
PaymentMethodType string `json:"payment_method_type"`
|
|
Amount float64 `json:"amount"`
|
|
Status string `json:"status"`
|
|
TransactionID *string `json:"transaction_id,omitempty"`
|
|
SplitNumber int `json:"split_number"`
|
|
SplitTotal int `json:"split_total"`
|
|
SplitType *string `json:"split_type,omitempty"`
|
|
SplitDescription *string `json:"split_description,omitempty"`
|
|
RefundAmount float64 `json:"refund_amount"`
|
|
RefundReason *string `json:"refund_reason,omitempty"`
|
|
RefundedAt *time.Time `json:"refunded_at,omitempty"`
|
|
RefundedBy *uuid.UUID `json:"refunded_by,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
PaymentOrderItems []PaymentOrderItemResponse `json:"payment_order_items,omitempty"`
|
|
}
|
|
|
|
type PaymentOrderItemResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
PaymentID uuid.UUID `json:"payment_id"`
|
|
OrderItemID uuid.UUID `json:"order_item_id"`
|
|
Amount float64 `json:"amount"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type RefundOrderRequest struct {
|
|
Reason *string `json:"reason,omitempty" validate:"omitempty,max=255"`
|
|
RefundAmount *float64 `json:"refund_amount,omitempty" validate:"omitempty,min=0"`
|
|
OrderItems []RefundOrderItemRequest `json:"order_items,omitempty" validate:"omitempty,dive"`
|
|
}
|
|
|
|
type RefundOrderItemRequest struct {
|
|
OrderItemID uuid.UUID `json:"order_item_id" validate:"required"`
|
|
RefundQuantity int `json:"refund_quantity,omitempty" validate:"omitempty,min=1"`
|
|
RefundAmount *float64 `json:"refund_amount,omitempty" validate:"omitempty,min=0"`
|
|
Reason *string `json:"reason,omitempty" validate:"omitempty,max=255"`
|
|
}
|
|
|
|
type RefundPaymentRequest struct {
|
|
RefundAmount float64 `json:"refund_amount" validate:"required,min=0"`
|
|
Reason string `json:"reason" validate:"omitempty,max=255"`
|
|
}
|
|
|
|
type SplitBillRequest struct {
|
|
OrderID uuid.UUID `json:"order_id" validate:"required"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"`
|
|
CustomerID uuid.UUID `json:"customer_id"`
|
|
Type string `json:"type" validate:"required,oneof=ITEM AMOUNT"`
|
|
Items []SplitBillItemRequest `json:"items,omitempty" validate:"required_if=Type ITEM,dive"`
|
|
Amount float64 `json:"amount,omitempty" validate:"required_if=Type AMOUNT,min=0"`
|
|
}
|
|
|
|
type SplitBillItemRequest struct {
|
|
OrderItemID uuid.UUID `json:"order_item_id" validate:"required"`
|
|
Quantity int `json:"quantity" validate:"required,min=0"`
|
|
}
|
|
|
|
type SplitBillResponse struct {
|
|
PaymentID uuid.UUID `json:"payment_id"`
|
|
OrderID uuid.UUID `json:"order_id"`
|
|
CustomerID uuid.UUID `json:"customer_id"`
|
|
Type string `json:"type"`
|
|
Amount float64 `json:"amount"`
|
|
Items []SplitBillItemResponse `json:"items,omitempty"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type SplitBillItemResponse struct {
|
|
OrderItemID uuid.UUID `json:"order_item_id"`
|
|
Amount float64 `json:"amount"`
|
|
}
|