product variant id
This commit is contained in:
@@ -56,6 +56,9 @@ type ProductRepository interface {
|
||||
UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error
|
||||
UpdateProductCategory(productid, categoryid, subcategoryid int) error
|
||||
UpdateProductVariant(productid, variantid int) error
|
||||
AddProductVariant(v models.Productvariant) (models.Productvariant, error)
|
||||
RemoveProductVariant(tenantid, variantid int) error
|
||||
VariantsForProducts(tenantid, locationid int, productids []int) (map[int][]models.Productvariant, error)
|
||||
}
|
||||
|
||||
type productRepository struct {
|
||||
@@ -1023,6 +1026,31 @@ func (r *productRepository) GetProductByVariant(tenantid, variantid, locationid,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The sizes, attached to the products they belong to.
|
||||
//
|
||||
// This is what makes one call enough to draw the ordering screen: the app
|
||||
// asks for the product it was tapped on and gets back the product plus every
|
||||
// size hanging under it. A product with none comes back with an empty list,
|
||||
// which is the answer that lets an order proceed.
|
||||
//
|
||||
// A failure here does not fail the request. The product and its price are
|
||||
// the answer; the sizes are an enrichment, and returning the product without
|
||||
// them beats returning nothing at all.
|
||||
ids := make([]int, 0, len(data))
|
||||
for i := range data {
|
||||
ids = append(ids, data[i].Productid)
|
||||
}
|
||||
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 {
|
||||
// An explicit empty list, never a JSON null: a client that does
|
||||
// `variantoptions.length` must not have to null-check first.
|
||||
data[i].Variantoptions = []models.Productvariant{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
|
||||
153
repositories/productVariantLink.go
Normal file
153
repositories/productVariantLink.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"nearle/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
/*
|
||||
Variants, as a relationship between products.
|
||||
|
||||
The `productvariants` table always had `productid` (the parent) and
|
||||
`variantproductid` (the product a shopper actually orders for that size). The
|
||||
model did not map either, so no code could use them — which is why the ordering
|
||||
screen fell back to `products.variants`, a bare group number that is 0 on every
|
||||
product, and asked the backend for "everything in group 0". That matched the
|
||||
tenant's entire ungrouped catalogue.
|
||||
|
||||
This file is the missing half: attach a size to a product, and read the sizes
|
||||
back with the one call that fetches the product.
|
||||
*/
|
||||
|
||||
// ErrVariantParentMissing and friends are returned to the controller so a bad
|
||||
// request answers 400 with a reason rather than 500 with a stack trace.
|
||||
var (
|
||||
ErrVariantParentMissing = errors.New("a variant needs a parent product: send productid")
|
||||
ErrVariantProductMissing = errors.New("a variant needs a product to order: send variantproductid")
|
||||
ErrVariantSelfReference = errors.New("a product cannot be a variant of itself")
|
||||
ErrVariantNotYours = errors.New("both products must belong to this tenant")
|
||||
ErrVariantDuplicate = errors.New("that product is already a variant of this one")
|
||||
)
|
||||
|
||||
// AddProductVariant hangs one product under another as a size.
|
||||
//
|
||||
// Every check here is about a shape that would break the ordering screen rather
|
||||
// than merely store bad data:
|
||||
//
|
||||
// - No parent, or no variant product: the row could never be read back by
|
||||
// either side of the relationship.
|
||||
// - A product as its own variant: the size picker would offer the thing you
|
||||
// already tapped, and picking it would loop.
|
||||
// - A product from another tenant: one shop's basket could be filled from
|
||||
// another shop's shelf.
|
||||
// - The same pair twice: the picker would show the size twice, and there is
|
||||
// no way for a shopper to tell the duplicates apart.
|
||||
func (r *productRepository) AddProductVariant(v models.Productvariant) (models.Productvariant, error) {
|
||||
if v.Productid <= 0 {
|
||||
return v, ErrVariantParentMissing
|
||||
}
|
||||
if v.Variantproductid <= 0 {
|
||||
return v, ErrVariantProductMissing
|
||||
}
|
||||
if v.Productid == v.Variantproductid {
|
||||
return v, ErrVariantSelfReference
|
||||
}
|
||||
|
||||
// Both ends checked in one query: two rows back means both exist and both
|
||||
// are this tenant's. Anything less is a refusal, and the caller does not
|
||||
// need to know which end was wrong to fix the request.
|
||||
var owned int64
|
||||
if err := r.db.Table("products").
|
||||
Where("tenantid = ? AND productid IN (?, ?)", v.Tenantid, v.Productid, v.Variantproductid).
|
||||
Count(&owned).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
if owned < 2 {
|
||||
return v, ErrVariantNotYours
|
||||
}
|
||||
|
||||
var clash int64
|
||||
if err := r.db.Table("productvariants").
|
||||
Where("tenantid = ? AND productid = ? AND variantproductid = ?",
|
||||
v.Tenantid, v.Productid, v.Variantproductid).
|
||||
Count(&clash).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
if clash > 0 {
|
||||
return v, ErrVariantDuplicate
|
||||
}
|
||||
|
||||
if v.Status == "" {
|
||||
v.Status = "Active"
|
||||
}
|
||||
if err := r.db.Table("productvariants").Create(&v).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// RemoveProductVariant detaches a size. Scoped by tenant so an id from one
|
||||
// shop cannot delete another's row.
|
||||
func (r *productRepository) RemoveProductVariant(tenantid, variantid int) error {
|
||||
if tenantid <= 0 || variantid <= 0 {
|
||||
return ErrVariantParentMissing
|
||||
}
|
||||
return r.db.Table("productvariants").
|
||||
Where("tenantid = ? AND variantid = ?", tenantid, variantid).
|
||||
Delete(nil).Error
|
||||
}
|
||||
|
||||
// VariantsForProducts reads the sizes for a set of parents in ONE query.
|
||||
//
|
||||
// Batched deliberately: the alternative is a query per product inside the loop
|
||||
// that builds the response, which is the classic N+1 — a 200-product listing
|
||||
// would fire 200 extra round trips to add a field that is empty for almost
|
||||
// every row.
|
||||
//
|
||||
// The variant's own product supplies the name, the live price and the stock, so
|
||||
// the app can draw a size picker — including greying out a size that is out of
|
||||
// stock — without a second call. Price follows the same rule as everywhere
|
||||
// else: the store's own price when it has set one, the master price otherwise.
|
||||
func (r *productRepository) VariantsForProducts(tenantid, locationid int, productids []int) (map[int][]models.Productvariant, error) {
|
||||
out := map[int][]models.Productvariant{}
|
||||
if tenantid <= 0 || len(productids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var rows []models.Productvariant
|
||||
err := r.db.
|
||||
Table("productvariants v").
|
||||
Select(`
|
||||
v.*,
|
||||
p.productname AS variantproductname,
|
||||
COALESCE(NULLIF((
|
||||
SELECT pl.price FROM productlocations pl
|
||||
WHERE pl.productid = v.variantproductid AND pl.tenantid = v.tenantid AND pl.locationid = ?
|
||||
LIMIT 1
|
||||
), 0), p.retailprice, 0) AS variantprice,
|
||||
COALESCE((
|
||||
SELECT SUM(CASE WHEN LOWER(ps.stocktype) = 'in' THEN ps.quantity ELSE 0 END) -
|
||||
SUM(CASE WHEN LOWER(ps.stocktype) = 'out' THEN ps.quantity ELSE 0 END)
|
||||
FROM productstocks ps
|
||||
WHERE ps.productid = v.variantproductid AND ps.tenantid = v.tenantid AND ps.locationid = ?
|
||||
), 0) AS variantstock
|
||||
`, locationid, locationid).
|
||||
// INNER, and that is the intended filter: a variant whose product was
|
||||
// deleted is not a size a shopper can be offered.
|
||||
Joins("JOIN products p ON p.productid = v.variantproductid AND p.tenantid = v.tenantid").
|
||||
Where("v.tenantid = ? AND v.productid IN ?", tenantid, productids).
|
||||
Where("LOWER(COALESCE(v.status, 'active')) <> 'inactive'").
|
||||
Order("v.variantid").
|
||||
Scan(&rows).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return out, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
out[row.Productid] = append(out[row.Productid], row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
67
repositories/productVariantLink_test.go
Normal file
67
repositories/productVariantLink_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"nearle/models"
|
||||
)
|
||||
|
||||
/*
|
||||
Variants are a relationship between two products, and every test here is about
|
||||
a shape that would break the ordering screen rather than merely store bad data.
|
||||
|
||||
The bug this replaces: `productvariants` had `productid` and `variantproductid`
|
||||
all along, neither was mapped, so a variant could never be attached to a
|
||||
product. The app fell back to `products.variants` — a group number that is 0 on
|
||||
every product — and asked for "everything in group 0", which matched the
|
||||
tenant's whole ungrouped catalogue. Six unrelated products came back as each
|
||||
other's sizes and the order could not be placed.
|
||||
*/
|
||||
|
||||
func TestAVariantWithoutAParentIsRefused(t *testing.T) {
|
||||
r := &productRepository{}
|
||||
_, err := r.AddProductVariant(models.Productvariant{Tenantid: 1, Variantproductid: 7})
|
||||
if !errors.Is(err, ErrVariantParentMissing) {
|
||||
t.Fatalf("expected a missing-parent refusal, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVariantThatOrdersNothingIsRefused(t *testing.T) {
|
||||
// Without `variantproductid` there is no product to put in the basket, so
|
||||
// the size would be pickable and unbuyable.
|
||||
r := &productRepository{}
|
||||
_, err := r.AddProductVariant(models.Productvariant{Tenantid: 1, Productid: 7})
|
||||
if !errors.Is(err, ErrVariantProductMissing) {
|
||||
t.Fatalf("expected a missing-product refusal, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProductCannotBeItsOwnVariant(t *testing.T) {
|
||||
// The picker would offer the thing already tapped, and choosing it loops.
|
||||
r := &productRepository{}
|
||||
_, err := r.AddProductVariant(models.Productvariant{Tenantid: 1, Productid: 7, Variantproductid: 7})
|
||||
if !errors.Is(err, ErrVariantSelfReference) {
|
||||
t.Fatalf("expected a self-reference refusal, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoProductsMeansNoQueryAndNoVariants(t *testing.T) {
|
||||
// Guards the N+1: the batch loader must not run a query for an empty page.
|
||||
// `r.db` is nil here, so reaching the database at all would panic.
|
||||
r := &productRepository{}
|
||||
got, err := r.VariantsForProducts(1, 1, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("VariantsForProducts: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected no variants, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownTenantAsksTheDatabaseNothing(t *testing.T) {
|
||||
r := &productRepository{}
|
||||
if _, err := r.VariantsForProducts(0, 1, []int{7, 8}); err != nil {
|
||||
t.Fatalf("VariantsForProducts: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user