variants as a single product
This commit is contained in:
@@ -401,6 +401,20 @@ func (ctl *ProductController) GetAllProducts(c *fiber.Ctx) error {
|
||||
// list and the figure beside it cannot disagree.
|
||||
if err == nil && isAppRequest(c) {
|
||||
details = services.InStockOnlyGrouped(details)
|
||||
|
||||
// One product, three sizes — not three products.
|
||||
//
|
||||
// A shop stocking Aachi Baby Fryums in 100g, 500g and 1kg has three
|
||||
// product rows, each with its own price and its own shelf. The shopper
|
||||
// should meet ONE listing and pick the size; the sizes travel with the
|
||||
// parent in `variantoptions`, so nothing becomes unreachable.
|
||||
//
|
||||
// Best-effort, like the stock filter above: if the lookup fails the
|
||||
// shopper sees the three rows they see today, which is worse than the
|
||||
// grouping and much better than an error.
|
||||
if isChild, cErr := ctl.productService.VariantChildIDs(tenantID); cErr == nil {
|
||||
details = services.WithoutVariantChildren(details, isChild)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return c.JSON(fiber.Map{
|
||||
|
||||
@@ -59,6 +59,7 @@ type ProductRepository interface {
|
||||
AddProductVariant(v models.Productvariant) (models.Productvariant, error)
|
||||
RemoveProductVariant(tenantid, variantid int) error
|
||||
VariantsForProducts(tenantid, locationid int, productids []int) (map[int][]models.Productvariant, error)
|
||||
VariantChildIDs(tenantid int) (map[int]bool, error)
|
||||
}
|
||||
|
||||
type productRepository struct {
|
||||
@@ -1042,12 +1043,40 @@ func (r *productRepository) GetProductByVariant(tenantid, variantid, locationid,
|
||||
}
|
||||
if byParent, vErr := r.VariantsForProducts(tenantid, locationid, ids); vErr == nil {
|
||||
for i := range data {
|
||||
data[i].Variantoptions = byParent[data[i].Productid]
|
||||
if data[i].Variantoptions == nil {
|
||||
children := byParent[data[i].Productid]
|
||||
if len(children) == 0 {
|
||||
// An explicit empty list, never a JSON null: a client that does
|
||||
// `variantoptions.length` must not have to null-check first.
|
||||
// `variantoptions.length` must not have to null-check first, and
|
||||
// an empty list is the complete answer “order this one directly”.
|
||||
data[i].Variantoptions = []models.Productvariant{}
|
||||
continue
|
||||
}
|
||||
|
||||
// The product being looked at is one of its own sizes.
|
||||
//
|
||||
// Without this the picker on “Aachi Baby Fryums 100g” offers 500g and
|
||||
// 1kg and not the 100g the shopper is already reading — so the size
|
||||
// they came for is the one option they cannot choose, and there is no
|
||||
// way back to it once they move off. Every screen in the group now
|
||||
// shows the same complete list.
|
||||
//
|
||||
// Built from the product row already in hand rather than by another
|
||||
// query: it carries the effective price and the live balance this
|
||||
// query just computed, so the entry cannot disagree with the row it
|
||||
// came from.
|
||||
self := models.Productvariant{
|
||||
Tenantid: data[i].Tenantid,
|
||||
Productid: data[i].Productid,
|
||||
Variantproductid: data[i].Productid,
|
||||
Variantname: variantLabelFor(data[i]),
|
||||
Status: "Active",
|
||||
Variantproductname: data[i].Productname,
|
||||
Variantprice: data[i].Price,
|
||||
Variantstock: data[i].Productstock,
|
||||
Variantunitvalue: data[i].Unitvalue,
|
||||
Variantproductunit: data[i].Productunit,
|
||||
}
|
||||
data[i].Variantoptions = append([]models.Productvariant{self}, children...)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"nearle/models"
|
||||
|
||||
@@ -158,3 +159,54 @@ func (r *productRepository) VariantsForProducts(tenantid, locationid int, produc
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// variantLabelFor names the product's own size for the picker.
|
||||
//
|
||||
// The unit if it has one — "500g" is what a shopper is choosing between. A
|
||||
// product with no unit recorded falls back to its name rather than to an empty
|
||||
// label, because a blank option in a size picker is unpickable.
|
||||
func variantLabelFor(p models.Products) string {
|
||||
unit := strings.TrimSpace(strings.TrimSpace(p.Unitvalue) + " " + strings.TrimSpace(p.Productunit))
|
||||
if unit != "" {
|
||||
return unit
|
||||
}
|
||||
return p.Productname
|
||||
}
|
||||
|
||||
// VariantChildIDs is every product that is a size OF something else.
|
||||
//
|
||||
// These are the rows the customer app must not list on their own. A shop that
|
||||
// stocks Aachi Baby Fryums in 100g, 500g and 1kg has three product rows, and a
|
||||
// shopper should meet one product with three sizes — not three products that
|
||||
// happen to share a name and differ by a suffix they have to read carefully.
|
||||
//
|
||||
// One query for the whole tenant rather than per product: the alternative is a
|
||||
// lookup inside the loop that builds the response, which is the classic N+1 on
|
||||
// a listing that can run to hundreds of rows.
|
||||
//
|
||||
// A product that is a size of something is NOT deleted or hidden from the
|
||||
// merchant — the console still lists all three, because each has its own price,
|
||||
// its own barcode and its own shelf to restock.
|
||||
func (r *productRepository) VariantChildIDs(tenantid int) (map[int]bool, error) {
|
||||
out := map[int]bool{}
|
||||
if tenantid <= 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var ids []int
|
||||
err := r.db.Table("productvariants").
|
||||
Where("tenantid = ?", tenantid).
|
||||
Where("LOWER(COALESCE(status, 'active')) <> 'inactive'").
|
||||
// A row that is its own parent would remove the group's only visible
|
||||
// product. It should not exist — AddProductVariant refuses it — but a
|
||||
// listing is the wrong place to discover that it does.
|
||||
Where("variantproductid <> productid").
|
||||
Pluck("variantproductid", &ids).Error
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
out[id] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -80,6 +80,25 @@ func (r *stockRequestRepository) GetStockRequestByID(requestID int) (*models.Sto
|
||||
return &req, err
|
||||
}
|
||||
|
||||
// UpdateStockRequest sets one request’s status, and refuses an id that is not
|
||||
// there.
|
||||
//
|
||||
// An UPDATE that matches no row is not an SQL error, so this used to report
|
||||
// success for a request that does not exist. Approving happened to catch it —
|
||||
// the service reads the row first to move the stock — but rejecting went
|
||||
// straight to the UPDATE and said it had worked.
|
||||
//
|
||||
// Harmless for one id typed by hand; not harmless in a batch, where the answer
|
||||
// is a count. Measured on production 2026-09-02: rejecting [31,32,33,9999999]
|
||||
// answered "4 updated" when only three requests existed. A merchant clearing a
|
||||
// stale queue would be told every row was dealt with.
|
||||
func (r *stockRequestRepository) UpdateStockRequest(requestID int, status string) error {
|
||||
return r.db.Model(&models.StockRequest{}).Where("requestid = ?", requestID).Update("status", status).Error
|
||||
result := r.db.Model(&models.StockRequest{}).Where("requestid = ?", requestID).Update("status", status)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type ProductService interface {
|
||||
UpdateProductVariant(productid, variantid int) error
|
||||
AddProductVariant(v models.Productvariant) (models.Productvariant, error)
|
||||
RemoveProductVariant(tenantid, variantid int) error
|
||||
VariantChildIDs(tenantid int) (map[int]bool, error)
|
||||
CreateProductStock(stocks []models.Productstock) error
|
||||
CreateProduct(product models.Products) error
|
||||
UpdateProduct(product models.Products) error
|
||||
@@ -156,6 +157,12 @@ func (s *productService) RemoveProductVariant(tenantid, variantid int) error {
|
||||
return s.repo.RemoveProductVariant(tenantid, variantid)
|
||||
}
|
||||
|
||||
// VariantChildIDs lists the products that are a size of another, so a caller
|
||||
// that lists products for a SHOPPER can leave them out.
|
||||
func (s *productService) VariantChildIDs(tenantid int) (map[int]bool, error) {
|
||||
return s.repo.VariantChildIDs(tenantid)
|
||||
}
|
||||
|
||||
func (s *productService) UpdateProductStatus(productIDs []int, status string) error {
|
||||
return s.repo.UpdateProductStatus(productIDs, status)
|
||||
}
|
||||
|
||||
@@ -85,3 +85,35 @@ func InStockOnlyGrouped(groups []models.Tenantproducts) []models.Tenantproducts
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// WithoutVariantChildren drops the products that are a size of another one.
|
||||
//
|
||||
// The companion to InStockOnlyGrouped, and applied only for the customer app.
|
||||
// A shop stocking Aachi Baby Fryums in 100g, 500g and 1kg has three rows; the
|
||||
// shopper should meet one product with three sizes, not three near-identical
|
||||
// listings they have to read the suffix of to tell apart.
|
||||
//
|
||||
// The group's visible row is the PARENT, and it carries the whole set in
|
||||
// `variantoptions` — including its own size — so nothing is unreachable: every
|
||||
// size is one tap away from the one row that remains.
|
||||
//
|
||||
// The console is deliberately unaffected. A merchant prices, restocks and
|
||||
// publishes each size separately, so all three must stay visible there.
|
||||
func WithoutVariantChildren(groups []models.Tenantproducts, isChild map[int]bool) []models.Tenantproducts {
|
||||
if len(isChild) == 0 {
|
||||
return groups
|
||||
}
|
||||
out := make([]models.Tenantproducts, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
kept := make([]models.Products, 0, len(group.Products))
|
||||
for _, product := range group.Products {
|
||||
if isChild[product.Productid] {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, product)
|
||||
}
|
||||
group.Products = kept
|
||||
out = append(out, group)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -667,3 +667,51 @@ func TestFilteringGroupsDoesNotDisturbTheCallersSlice(t *testing.T) {
|
||||
t.Errorf("the caller's slice was modified: %d products left", len(original[0].Products))
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
One product, three sizes.
|
||||
|
||||
R mart stocks Aachi Baby Fryums as three product rows — 100g, 500g and 1kg —
|
||||
each with its own price and its own shelf. In the app that read as three
|
||||
near-identical listings a shopper had to tell apart by a suffix. Grouped, it is
|
||||
one listing with a size picker.
|
||||
*/
|
||||
|
||||
func TestASizeOfAnotherProductIsNotListedOnItsOwn(t *testing.T) {
|
||||
groups := []models.Tenantproducts{{Products: []models.Products{
|
||||
{Productid: 7112}, // 100g — the parent
|
||||
{Productid: 7113}, // 1kg — a size of it
|
||||
{Productid: 7114}, // 500g — a size of it
|
||||
}}}
|
||||
got := WithoutVariantChildren(groups, map[int]bool{7113: true, 7114: true})
|
||||
if len(got[0].Products) != 1 || got[0].Products[0].Productid != 7112 {
|
||||
t.Fatalf("expected only the parent, got %v", got[0].Products)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAShopWithNoGroupsIsUntouched(t *testing.T) {
|
||||
// The common case by far, and it must not pay for the feature.
|
||||
groups := []models.Tenantproducts{{Products: []models.Products{{Productid: 1}, {Productid: 2}}}}
|
||||
got := WithoutVariantChildren(groups, map[int]bool{})
|
||||
if len(got[0].Products) != 2 {
|
||||
t.Errorf("an ungrouped shop lost products: %v", got[0].Products)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheShopSurvivesEvenIfEveryProductIsASize(t *testing.T) {
|
||||
// Should not happen — a group always has a parent — but a listing is the
|
||||
// wrong place to discover bad data, and an empty list beats a crash.
|
||||
groups := []models.Tenantproducts{{Products: []models.Products{{Productid: 5}}}}
|
||||
got := WithoutVariantChildren(groups, map[int]bool{5: true})
|
||||
if len(got) != 1 || len(got[0].Products) != 0 {
|
||||
t.Errorf("expected the shop to survive with nothing listed, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteringChildrenDoesNotDisturbTheCallersSlice(t *testing.T) {
|
||||
original := []models.Tenantproducts{{Products: []models.Products{{Productid: 1}, {Productid: 2}}}}
|
||||
_ = WithoutVariantChildren(original, map[int]bool{2: true})
|
||||
if len(original[0].Products) != 2 {
|
||||
t.Errorf("the caller's slice was modified")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user