Files
backend_fiesta/repositories/productVariantLink.go
2026-09-02 13:11:01 +05:30

154 lines
5.6 KiB
Go

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
}