feat: add printer types

This commit is contained in:
efrilm
2026-10-01 22:26:44 +07:00
parent 892575202b
commit b0ef226af1
25 changed files with 2048 additions and 66 deletions
+1 -1
View File
@@ -121,7 +121,7 @@ type OrderItemResponse struct {
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PrinterType string `json:"printer_type"`
PrinterTypes []string `json:"printer_types"`
PrintToChecker bool `json:"print_to_checker"`
PaidQuantity int `json:"paid_quantity"`
}
+3 -3
View File
@@ -16,7 +16,7 @@ type CreateProductRequest struct {
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"`
PrinterTypes []string `json:"printer_types,omitempty" validate:"omitempty,dive,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"`
@@ -38,7 +38,7 @@ type UpdateProductRequest struct {
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"`
PrinterTypes []string `json:"printer_types,omitempty" validate:"omitempty,dive,max=50"` // Replaces the whole list when sent
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"`
@@ -76,7 +76,7 @@ type ProductResponse struct {
Cost float64 `json:"cost"`
BusinessType string `json:"business_type"`
ImageURL *string `json:"image_url"`
PrinterType string `json:"printer_type"`
PrinterTypes []string `json:"printer_types"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
SellBy string `json:"sell_by"`
PrintToChecker bool `json:"print_to_checker"`
+47 -18
View File
@@ -1,6 +1,7 @@
package entities
import (
"strings"
"time"
"github.com/google/uuid"
@@ -8,24 +9,26 @@ import (
)
type Product struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
SKU *string `gorm:"size:100;index" json:"sku"`
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
Description *string `gorm:"type:text" json:"description"`
Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"`
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"`
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
ImageURL *string `gorm:"size:500" json:"image_url"`
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
SKU *string `gorm:"size:100;index" json:"sku"`
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
Description *string `gorm:"type:text" json:"description"`
Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"`
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"`
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
ImageURL *string `gorm:"size:500" json:"image_url"`
// PrinterTypes are the stations the product is printed at. Set them through
// SetPrinterTypes.
PrinterTypes StringSlice `gorm:"type:jsonb;not null;default:'[\"kitchen\"]'" json:"printer_types"`
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
@@ -48,6 +51,32 @@ func (Product) TableName() string {
return "products"
}
// SetPrinterTypes records the stations the product is printed at, in order, dropping
// blanks and repeats.
func (p *Product) SetPrinterTypes(printerTypes []string) {
cleaned := StringSlice{}
seen := make(map[string]bool, len(printerTypes))
for _, printerType := range printerTypes {
printerType = strings.TrimSpace(printerType)
if printerType == "" || seen[printerType] {
continue
}
seen[printerType] = true
cleaned = append(cleaned, printerType)
}
p.PrinterTypes = cleaned
}
// GetPrinterTypes returns the stations the product is printed at, never nil, so a
// response always carries a list. A product that was not loaded has none.
func (p *Product) GetPrinterTypes() []string {
if p.PrinterTypes == nil {
return []string{}
}
return []string(p.PrinterTypes)
}
type ProductVariant struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
+1 -1
View File
@@ -149,7 +149,7 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
Status: constants.OrderItemStatus(item.Status),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PrinterType: item.Product.PrinterType,
PrinterTypes: item.Product.GetPrinterTypes(),
PrintToChecker: printToChecker,
}
+13 -12
View File
@@ -24,7 +24,7 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
Cost: entity.Cost,
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
PrinterTypes: entity.GetPrinterTypes(),
UnitID: entity.UnitID,
SellBy: entity.SellBy,
HasIngredients: entity.HasIngredients,
@@ -51,7 +51,7 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
Cost: model.Cost,
BusinessType: string(model.BusinessType),
ImageURL: model.ImageURL,
PrinterType: model.PrinterType,
PrinterTypes: entities.StringSlice(model.PrinterTypes),
UnitID: model.UnitID,
SellBy: model.SellBy,
HasIngredients: model.HasIngredients,
@@ -77,11 +77,6 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
businessType = string(req.BusinessType)
}
printerType := "kitchen"
if req.PrinterType != nil && *req.PrinterType != "" {
printerType = *req.PrinterType
}
sellBy := constants.SellByUnit
if constants.IsValidSellBy(req.SellBy) {
sellBy = req.SellBy
@@ -92,7 +87,7 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
metadata = entities.Metadata(req.Metadata)
}
return &entities.Product{
product := &entities.Product{
OrganizationID: req.OrganizationID,
CategoryID: req.CategoryID,
SKU: req.SKU,
@@ -102,12 +97,18 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
Cost: cost,
BusinessType: businessType,
ImageURL: req.ImageURL,
PrinterType: printerType,
UnitID: req.UnitID,
SellBy: sellBy,
Metadata: metadata,
IsActive: true, // Default to active
}
product.SetPrinterTypes(req.PrinterTypes)
if len(product.PrinterTypes) == 0 {
product.SetPrinterTypes([]string{"kitchen"})
}
return product
}
func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
@@ -152,7 +153,7 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
Cost: entity.Cost,
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
PrinterTypes: entity.GetPrinterTypes(),
UnitID: entity.UnitID,
SellBy: entity.SellBy,
Metadata: map[string]interface{}(entity.Metadata),
@@ -196,8 +197,8 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
entity.ImageURL = req.ImageURL
}
if req.PrinterType != nil {
entity.PrinterType = *req.PrinterType
if req.PrinterTypes != nil {
entity.SetPrinterTypes(req.PrinterTypes)
}
if req.UnitID != nil {
@@ -0,0 +1,102 @@
package mappers
import (
"testing"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestCreateProductRequestToEntityPrinterTypes(t *testing.T) {
tests := []struct {
name string
printerTypes []string
want []string
}{
{
name: "nothing sent prints in the kitchen",
want: []string{"kitchen"},
},
{
name: "a meal-and-drink package prints in the kitchen and the bar",
printerTypes: []string{"kitchen", "bar"},
want: []string{"kitchen", "bar"},
},
{
name: "blanks and repeats are dropped",
printerTypes: []string{" kitchen ", "", "kitchen", "bar"},
want: []string{"kitchen", "bar"},
},
{
name: "a list of only blanks falls back to the kitchen",
printerTypes: []string{" ", ""},
want: []string{"kitchen"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
product := CreateProductRequestToEntity(&models.CreateProductRequest{
OrganizationID: uuid.New(),
CategoryID: uuid.New(),
Name: "Paket Makan Minum",
PrinterTypes: tt.printerTypes,
})
assert.Equal(t, entities.StringSlice(tt.want), product.PrinterTypes)
})
}
}
func TestUpdateProductEntityFromRequestPrinterTypes(t *testing.T) {
tests := []struct {
name string
printerTypes []string
want []string
}{
{
name: "a save that does not send printers keeps them",
want: []string{"kitchen", "bar"},
},
{
name: "printer_types replaces the whole list",
printerTypes: []string{"bar"},
want: []string{"bar"},
},
{
name: "an empty printer_types prints nowhere",
printerTypes: []string{},
want: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
product := &entities.Product{}
product.SetPrinterTypes([]string{"kitchen", "bar"})
UpdateProductEntityFromRequest(product, &models.UpdateProductRequest{PrinterTypes: tt.printerTypes})
assert.Equal(t, entities.StringSlice(tt.want), product.PrinterTypes)
})
}
}
func TestOrderItemEntityToResponsePrinterTypes(t *testing.T) {
product := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"}
product.SetPrinterTypes([]string{"kitchen", "bar"})
response := OrderItemEntityToResponse(&entities.OrderItem{ProductID: product.ID, Product: product}, uuid.New())
assert.Equal(t, []string{"kitchen", "bar"}, response.PrinterTypes)
}
func TestProductEntityToResponseAlwaysHasAList(t *testing.T) {
// The POS reads printer_types as a list, so a product without one sends [] not null.
response := ProductEntityToResponse(&entities.Product{})
assert.Equal(t, []string{}, response.PrinterTypes)
}
+1 -1
View File
@@ -218,7 +218,7 @@ type OrderItemResponse struct {
Status constants.OrderItemStatus
CreatedAt time.Time
UpdatedAt time.Time
PrinterType string
PrinterTypes []string
PrintToChecker bool
PaidQuantity int
}
+4 -4
View File
@@ -18,7 +18,7 @@ type Product struct {
Cost float64
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
PrinterTypes []string
SellBy string
UnitID *uuid.UUID
HasIngredients bool
@@ -50,7 +50,7 @@ type CreateProductRequest struct {
Cost float64 `validate:"min=0"`
BusinessType constants.BusinessType `validate:"required"`
ImageURL *string `validate:"omitempty,max=500"`
PrinterType *string `validate:"omitempty,max=50"`
PrinterTypes []string `validate:"omitempty,dive,max=50"`
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"`
SellBy string `validate:"omitempty,oneof=unit weight"`
@@ -72,7 +72,7 @@ type UpdateProductRequest struct {
Price *float64 `validate:"omitempty,min=0"`
Cost *float64 `validate:"omitempty,min=0"`
ImageURL *string `validate:"omitempty,max=500"`
PrinterType *string `validate:"omitempty,max=50"`
PrinterTypes []string `validate:"omitempty,dive,max=50"` // Replaces the whole list when not nil
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"`
SellBy *string `validate:"omitempty,oneof=unit weight"`
@@ -112,7 +112,7 @@ type ProductResponse struct {
Cost float64
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
PrinterTypes []string
SellBy string
PrintToChecker bool
UnitID *uuid.UUID
@@ -0,0 +1,46 @@
package processor
import (
"testing"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The items AddToOrder creates have no product loaded, so their responses used to go out
// without a name or a printer, and the POS could not print them.
func TestAddedItemsFromOrderUsesTheReloadedProduct(t *testing.T) {
outletID := uuid.New()
paket := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"}
paket.SetPrinterTypes([]string{"kitchen", "bar"})
paket.ProductOutletPrices = []entities.ProductOutletPrice{{OutletID: outletID, PrintToChecker: false}}
esTeh := entities.Product{ID: uuid.New(), Name: "Es Teh"}
esTeh.SetPrinterTypes([]string{"bar"})
existing := entities.OrderItem{ID: uuid.New(), ProductID: esTeh.ID, Product: esTeh}
addedPaket := &entities.OrderItem{ID: uuid.New(), ProductID: paket.ID}
addedEsTeh := &entities.OrderItem{ID: uuid.New(), ProductID: esTeh.ID}
order := &entities.Order{
OutletID: outletID,
OrderItems: []entities.OrderItem{
existing,
{ID: addedEsTeh.ID, ProductID: esTeh.ID, Product: esTeh},
{ID: addedPaket.ID, ProductID: paket.ID, Product: paket},
},
}
responses := addedItemsFromOrder([]*entities.OrderItem{addedPaket, addedEsTeh}, order)
require.Len(t, responses, 2, "only the added items, not the ones already on the order")
assert.Equal(t, addedPaket.ID, responses[0].ID, "in the order they were requested")
assert.Equal(t, "Paket Makan Minum", responses[0].ProductName)
assert.Equal(t, []string{"kitchen", "bar"}, responses[0].PrinterTypes)
assert.False(t, responses[0].PrintToChecker, "the outlet's print_to_checker is loaded too")
assert.Equal(t, addedEsTeh.ID, responses[1].ID)
assert.Equal(t, []string{"bar"}, responses[1].PrinterTypes)
}
+24 -6
View File
@@ -568,16 +568,10 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
return nil, fmt.Errorf("failed to update order: %w", err)
}
var addedItemResponses []models.OrderItemResponse
for _, orderItem := range addedOrderItems {
if err := p.orderItemRepo.Create(ctx, orderItem); err != nil {
return nil, fmt.Errorf("failed to create order item: %w", err)
}
itemResponse := mappers.OrderItemEntityToResponse(orderItem, order.OutletID)
if itemResponse != nil {
addedItemResponses = append(addedItemResponses, *itemResponse)
}
}
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID)
@@ -585,6 +579,10 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
return nil, fmt.Errorf("failed to retrieve updated order: %w", err)
}
// The items just created carry no product, so take them from the reloaded order: the
// POS prints added items from this response and needs their name and printers.
addedItemResponses := addedItemsFromOrder(addedOrderItems, orderWithRelations)
updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations)
p.attachEarnings(ctx, updatedOrderResponse)
@@ -596,6 +594,26 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
}, nil
}
// addedItemsFromOrder maps the added items in the order they were requested, using their
// copies in the reloaded order, which have the product and its outlet prices loaded.
func addedItemsFromOrder(added []*entities.OrderItem, order *entities.Order) []models.OrderItemResponse {
reloaded := make(map[uuid.UUID]*entities.OrderItem, len(order.OrderItems))
for i := range order.OrderItems {
reloaded[order.OrderItems[i].ID] = &order.OrderItems[i]
}
var responses []models.OrderItemResponse
for _, item := range added {
if loaded, ok := reloaded[item.ID]; ok {
item = loaded
}
if response := mappers.OrderItemEntityToResponse(item, order.OutletID); response != nil {
responses = append(responses, *response)
}
}
return responses
}
func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) {
// Get existing order
order, err := p.orderRepo.GetByID(ctx, id)
@@ -199,7 +199,7 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe
Cost: entity.Product.Cost,
BusinessType: string(entity.Product.BusinessType),
ImageURL: entity.Product.ImageURL,
PrinterType: entity.Product.PrinterType,
PrinterTypes: entity.Product.GetPrinterTypes(),
Metadata: entity.Product.Metadata,
IsActive: entity.Product.IsActive,
CreatedAt: entity.Product.CreatedAt,
@@ -39,7 +39,7 @@ func (r *ProductIngredientRepository) Create(ctx context.Context, productIngredi
func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.ProductIngredient, error) {
query := `
SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at,
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_types, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at
FROM product_ingredients pi
LEFT JOIN products p ON pi.product_id = p.id
@@ -70,7 +70,7 @@ func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizat
&product.Cost,
&product.BusinessType,
&product.ImageURL,
&product.PrinterType,
&product.PrinterTypes,
&product.UnitID,
&product.HasIngredients,
&product.Metadata,
@@ -103,7 +103,7 @@ func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizat
func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, productID, organizationID uuid.UUID) ([]*entities.ProductIngredient, error) {
query := `
SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at,
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_types, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at
FROM product_ingredients pi
LEFT JOIN products p ON pi.product_id = p.id
@@ -143,7 +143,7 @@ func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, produc
&product.Cost,
&product.BusinessType,
&product.ImageURL,
&product.PrinterType,
&product.PrinterTypes,
&product.UnitID,
&product.HasIngredients,
&product.Metadata,
@@ -178,7 +178,7 @@ func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, produc
func (r *ProductIngredientRepository) GetByIngredientID(ctx context.Context, ingredientID, organizationID uuid.UUID) ([]*entities.ProductIngredient, error) {
query := `
SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at,
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_types, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at
FROM product_ingredients pi
LEFT JOIN products p ON pi.product_id = p.id
@@ -218,7 +218,7 @@ func (r *ProductIngredientRepository) GetByIngredientID(ctx context.Context, ing
&product.Cost,
&product.BusinessType,
&product.ImageURL,
&product.PrinterType,
&product.PrinterTypes,
&product.UnitID,
&product.HasIngredients,
&product.Metadata,
@@ -0,0 +1,48 @@
package repository
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/entities"
)
// Needs TEST_DATABASE_URL pointing at a migrated database; see walletTestDB.
func TestProductPrinterTypes_AgainstPostgres(t *testing.T) {
db := walletTestDB(t)
ctx := context.Background()
org, category := uuid.New(), uuid.New()
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'printer test', 'basic')`, org).Error)
require.NoError(t, db.Exec(`INSERT INTO categories (id, organization_id, name) VALUES (?, ?, 'Paket')`, category, org).Error)
t.Cleanup(func() {
db.Exec(`DELETE FROM products WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM categories WHERE id = ?`, category)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
repo := NewProductRepositoryImpl(db)
product := &entities.Product{OrganizationID: org, CategoryID: category, Name: "Paket Makan Minum", Price: 25000}
product.SetPrinterTypes([]string{"kitchen", "bar"})
require.NoError(t, repo.Create(ctx, product))
stored, err := repo.GetByID(ctx, product.ID)
require.NoError(t, err)
assert.Equal(t, entities.StringSlice{"kitchen", "bar"}, stored.PrinterTypes)
stored.SetPrinterTypes([]string{"bar"})
require.NoError(t, repo.Update(ctx, stored))
var printerTypes string
require.NoError(t, db.Raw(`SELECT printer_types::text FROM products WHERE id = ?`, product.ID).Scan(&printerTypes).Error)
assert.Equal(t, `["bar"]`, printerTypes)
// The old single column is gone.
var columns int64
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'printer_type'`).Scan(&columns).Error)
assert.Zero(t, columns)
}
@@ -44,8 +44,9 @@ func InventoryModelResponseToResponse(inv *models.InventoryResponse) *contract.I
IsLowStock: inv.IsLowStock,
UpdatedAt: inv.UpdatedAt,
Product: &contract.ProductResponse{
ID: inv.ProductID,
Name: inv.ProductName,
ID: inv.ProductID,
Name: inv.ProductName,
PrinterTypes: []string{},
},
}
}
+2 -1
View File
@@ -118,7 +118,7 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse {
Status: string(item.Status),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PrinterType: item.PrinterType,
PrinterTypes: item.PrinterTypes,
PrintToChecker: item.PrintToChecker,
PaidQuantity: item.PaidQuantity,
}
@@ -195,6 +195,7 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO
Status: string(item.Status),
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
PrinterTypes: item.PrinterTypes,
PrintToChecker: item.PrintToChecker,
}
}
@@ -0,0 +1,50 @@
package transformer
import (
"encoding/json"
"testing"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The POS prints items added to an open order from this response, so each one has to
// say where it is printed.
func TestAddToOrderModelToContractCarriesPrinters(t *testing.T) {
result := AddToOrderModelToContract(&models.AddToOrderResponse{
AddedItems: []models.OrderItemResponse{{PrinterTypes: []string{"kitchen", "bar"}}},
})
require.Len(t, result.AddedItems, 1)
assert.Equal(t, []string{"kitchen", "bar"}, result.AddedItems[0].PrinterTypes)
}
// Follows an order from the database row to the JSON the POS reads.
func TestOrderJSONCarriesPrinters(t *testing.T) {
paket := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"}
paket.SetPrinterTypes([]string{"kitchen", "bar"})
order := &entities.Order{
ID: uuid.New(),
OrderItems: []entities.OrderItem{{ID: uuid.New(), ProductID: paket.ID, Product: paket, Quantity: 1}},
}
body, err := json.Marshal(OrderModelToContract(mappers.OrderEntityToResponse(order)))
require.NoError(t, err)
assert.Contains(t, string(body), `"printer_types":["kitchen","bar"]`)
assert.NotContains(t, string(body), `"printer_type":`, "printer_types replaced it")
}
func TestOrderModelToContractCarriesPrinters(t *testing.T) {
result := OrderModelToContract(&models.OrderResponse{
OrderItems: []models.OrderItemResponse{{PrinterTypes: []string{"kitchen", "bar"}}},
})
require.Len(t, result.OrderItems, 1)
assert.Equal(t, []string{"kitchen", "bar"}, result.OrderItems[0].PrinterTypes)
}
+3 -3
View File
@@ -61,7 +61,7 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
Cost: cost,
BusinessType: businessType,
ImageURL: req.ImageURL,
PrinterType: req.PrinterType,
PrinterTypes: req.PrinterTypes,
PrintToChecker: req.PrintToChecker,
UnitID: req.UnitID,
SellBy: sellBy,
@@ -91,7 +91,7 @@ func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Up
Price: req.Price,
Cost: req.Cost,
ImageURL: req.ImageURL,
PrinterType: req.PrinterType,
PrinterTypes: req.PrinterTypes,
PrintToChecker: req.PrintToChecker,
UnitID: req.UnitID,
SellBy: req.SellBy,
@@ -152,7 +152,7 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
Cost: prod.Cost,
BusinessType: string(prod.BusinessType),
ImageURL: prod.ImageURL,
PrinterType: prod.PrinterType,
PrinterTypes: prod.PrinterTypes,
PrintToChecker: prod.PrintToChecker,
UnitID: prod.UnitID,
SellBy: prod.SellBy,
+17 -5
View File
@@ -59,8 +59,8 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create
return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode
}
if req.PrinterType != nil && len(*req.PrinterType) > 50 {
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
if err, code := validatePrinterTypes(req.PrinterTypes); err != nil {
return err, code
}
if err, code := validateSellBy(req.SellBy, req.UnitID); err != nil {
@@ -70,6 +70,18 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create
return nil, ""
}
// validatePrinterTypes holds each printer to the 50 characters the single printer_type
// column allowed.
func validatePrinterTypes(printerTypes []string) (error, string) {
for _, printerType := range printerTypes {
if len(strings.TrimSpace(printerType)) > 50 {
return errors.New("each printer_types entry cannot exceed 50 characters"), constants.MalformedFieldErrorCode
}
}
return nil, ""
}
// validateSellBy checks how a product is sold and that it carries what that choice
// needs. A weight-based product without a unit would produce order lines with no unit
// to print, so the receipt could show "4,2" with no idea of what.
@@ -100,7 +112,7 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
// At least one field should be provided for update
if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil &&
req.Price == nil && req.Cost == nil && req.BusinessType == nil && req.ImageURL == nil &&
req.PrinterType == nil && req.PrintToChecker == nil && req.UnitID == nil &&
req.PrinterTypes == nil && req.PrintToChecker == nil && req.UnitID == nil &&
req.SellBy == nil && req.Metadata == nil && req.IsActive == nil {
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
}
@@ -134,8 +146,8 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode
}
if req.PrinterType != nil && len(*req.PrinterType) > 50 {
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
if err, code := validatePrinterTypes(req.PrinterTypes); err != nil {
return err, code
}
// Only the value is checked here. Whether the product ends up with a unit depends on
@@ -0,0 +1,35 @@
package validator
import (
"strings"
"testing"
"apskel-pos-be/internal/contract"
)
func TestValidateProductRequestPrinterTypes(t *testing.T) {
v := NewProductValidator()
tooLong := strings.Repeat("a", 51)
create := baseCreateRequest()
create.PrinterTypes = []string{"kitchen", "bar"}
if err, _ := v.ValidateCreateProductRequest(create); err != nil {
t.Fatalf("kitchen and bar should be accepted, got: %v", err)
}
create.PrinterTypes = []string{"kitchen", tooLong}
if err, _ := v.ValidateCreateProductRequest(create); err == nil {
t.Fatal("expected an error for a printer longer than 50 characters")
}
err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{PrinterTypes: []string{tooLong}})
if err == nil {
t.Fatal("expected an error for a printer longer than 50 characters on update")
}
// An update that only changes the printers is a real update.
err, _ = v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{PrinterTypes: []string{"kitchen", "bar"}})
if err != nil {
t.Fatalf("an update of only printer_types should be accepted, got: %v", err)
}
}