Files
apskel-pos-backend/internal/transformer/product_transformer.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

234 lines
6.5 KiB
Go

package transformer
import (
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
)
func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreateProductRequest) *models.CreateProductRequest {
cost := float64(0)
if req.Cost != nil {
cost = *req.Cost
}
businessType := constants.BusinessTypeRestaurant
if req.BusinessType != nil {
businessType = constants.BusinessType(*req.BusinessType)
}
sellBy := constants.SellByUnit
if req.SellBy != nil {
sellBy = *req.SellBy
}
var variants []models.CreateProductVariantRequest
if req.Variants != nil {
variants = make([]models.CreateProductVariantRequest, len(req.Variants))
for i, variant := range req.Variants {
variants[i] = models.CreateProductVariantRequest{
ProductID: variant.ProductID,
Name: variant.Name,
PriceModifier: variant.PriceModifier,
Cost: variant.Cost,
Metadata: variant.Metadata,
}
}
}
metadata := req.Metadata
if metadata == nil {
metadata = make(map[string]interface{})
}
// Prioritize outlet_id from context, fallback to request body
outletID := apctx.OutletID
if outletID == uuid.Nil && req.OutletID != nil {
outletID = *req.OutletID
}
return &models.CreateProductRequest{
OrganizationID: apctx.OrganizationID,
OutletID: outletID,
CategoryID: req.CategoryID,
SKU: req.SKU,
Name: req.Name,
Description: req.Description,
Price: req.Price,
Cost: cost,
BusinessType: businessType,
ImageURL: req.ImageURL,
PrinterType: req.PrinterType,
PrintToChecker: req.PrintToChecker,
UnitID: req.UnitID,
SellBy: sellBy,
Metadata: metadata,
Variants: variants,
}
}
func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.UpdateProductRequest) *models.UpdateProductRequest {
metadata := req.Metadata
if metadata == nil {
metadata = make(map[string]interface{})
}
// Prioritize outlet_id from context, fallback to request body
outletID := apctx.OutletID
if outletID == uuid.Nil && req.OutletID != nil {
outletID = *req.OutletID
}
return &models.UpdateProductRequest{
OutletID: outletID,
CategoryID: req.CategoryID,
SKU: req.SKU,
Name: req.Name,
Description: req.Description,
Price: req.Price,
Cost: req.Cost,
ImageURL: req.ImageURL,
PrinterType: req.PrinterType,
PrintToChecker: req.PrintToChecker,
UnitID: req.UnitID,
SellBy: req.SellBy,
Metadata: metadata,
IsActive: req.IsActive,
}
}
// Model to Contract conversions
func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.ProductResponse {
if prod == nil {
return nil
}
// Convert variants to contract responses
var variantResponses []contract.ProductVariantResponse
if prod.Variants != nil {
variantResponses = make([]contract.ProductVariantResponse, len(prod.Variants))
for i, variant := range prod.Variants {
variantResponses[i] = contract.ProductVariantResponse{
ID: variant.ID,
ProductID: variant.ProductID,
Name: variant.Name,
PriceModifier: variant.PriceModifier,
Cost: variant.Cost,
Metadata: variant.Metadata,
CreatedAt: variant.CreatedAt,
UpdatedAt: variant.UpdatedAt,
}
}
}
// Convert outlet prices
var outletPriceResponses []contract.ProductOutletPriceResponse
if len(prod.OutletPrices) > 0 {
outletPriceResponses = make([]contract.ProductOutletPriceResponse, len(prod.OutletPrices))
for i, op := range prod.OutletPrices {
outletPriceResponses[i] = contract.ProductOutletPriceResponse{
OutletID: op.OutletID,
OutletName: op.OutletName,
Price: op.Price,
PrintToChecker: op.PrintToChecker,
}
}
}
return &contract.ProductResponse{
ID: prod.ID,
OrganizationID: prod.OrganizationID,
CategoryID: prod.CategoryID,
CategoryName: prod.CategoryName,
SKU: prod.SKU,
Name: prod.Name,
Description: prod.Description,
Price: prod.Price,
OutletPrice: prod.OutletPrice,
OutletPrices: outletPriceResponses,
Cost: prod.Cost,
BusinessType: string(prod.BusinessType),
ImageURL: prod.ImageURL,
PrinterType: prod.PrinterType,
PrintToChecker: prod.PrintToChecker,
UnitID: prod.UnitID,
SellBy: prod.SellBy,
Metadata: prod.Metadata,
IsActive: prod.IsActive,
CreatedAt: prod.CreatedAt,
UpdatedAt: prod.UpdatedAt,
Variants: variantResponses,
}
}
// Slice conversions
func ProductsToResponses(products []models.ProductResponse) []contract.ProductResponse {
responses := make([]contract.ProductResponse, len(products))
for i, prod := range products {
response := ProductModelResponseToResponse(&prod)
if response != nil {
responses[i] = *response
}
}
return responses
}
// Product Variant Transformers
func CreateProductVariantRequestToModel(req *contract.CreateProductVariantRequest) *models.CreateProductVariantRequest {
if req == nil {
return nil
}
return &models.CreateProductVariantRequest{
ProductID: req.ProductID,
Name: req.Name,
PriceModifier: req.PriceModifier,
Cost: req.Cost,
Metadata: req.Metadata,
}
}
func UpdateProductVariantRequestToModel(req *contract.UpdateProductVariantRequest) *models.UpdateProductVariantRequest {
if req == nil {
return nil
}
return &models.UpdateProductVariantRequest{
Name: req.Name,
PriceModifier: req.PriceModifier,
Cost: req.Cost,
Metadata: req.Metadata,
}
}
func ProductVariantModelResponseToResponse(variant *models.ProductVariantResponse) *contract.ProductVariantResponse {
if variant == nil {
return nil
}
return &contract.ProductVariantResponse{
ID: variant.ID,
ProductID: variant.ProductID,
Name: variant.Name,
PriceModifier: variant.PriceModifier,
Cost: variant.Cost,
Metadata: variant.Metadata,
CreatedAt: variant.CreatedAt,
UpdatedAt: variant.UpdatedAt,
}
}
func ProductVariantsToResponses(variants []models.ProductVariantResponse) []contract.ProductVariantResponse {
responses := make([]contract.ProductVariantResponse, len(variants))
for i, variant := range variants {
response := ProductVariantModelResponseToResponse(&variant)
if response != nil {
responses[i] = *response
}
}
return responses
}