diff --git a/controllers/productController.go b/controllers/productController.go index 50a4070..5643cc8 100644 --- a/controllers/productController.go +++ b/controllers/productController.go @@ -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{ diff --git a/repositories/productRepository.go b/repositories/productRepository.go index 2d57bed..edc6f98 100644 --- a/repositories/productRepository.go +++ b/repositories/productRepository.go @@ -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...) } } diff --git a/repositories/productVariantLink.go b/repositories/productVariantLink.go index 25b2d42..a467e21 100644 --- a/repositories/productVariantLink.go +++ b/repositories/productVariantLink.go @@ -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 +} diff --git a/repositories/stockrequest.go b/repositories/stockrequest.go index 25136e9..bee46aa 100644 --- a/repositories/stockrequest.go +++ b/repositories/stockrequest.go @@ -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 } diff --git a/services/productService.go b/services/productService.go index a6784b9..af1b5a9 100644 --- a/services/productService.go +++ b/services/productService.go @@ -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) } diff --git a/services/productVisibility.go b/services/productVisibility.go index e278fde..30eff0d 100644 --- a/services/productVisibility.go +++ b/services/productVisibility.go @@ -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 +} diff --git a/services/productVisibility_test.go b/services/productVisibility_test.go index ea1974c..83fc27e 100644 --- a/services/productVisibility_test.go +++ b/services/productVisibility_test.go @@ -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") + } +}