Files
backend_fiesta/repositories/productVariantLink.go
2026-09-03 15:44:09 +05:30

229 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repositories
import (
"errors"
"strings"
"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
}
// `price` is emitted as the EFFECTIVE price, not the raw override.
//
// The column holds an override where 0 means "no override", so a client
// reading the obvious field name got 0 for every size while the real figure
// sat in `variantprice` beside it. Measured on R mart: a grouped product
// whose three sizes are priced 50, 250 and 500 was emitting price 0 on all
// three, so the app showed them as free.
//
// Selected after `v.*` so it overwrites the raw column on the way out — the
// same trick GetProductByVariant uses for `quantity`. The stored override is
// untouched; only what is emitted changes.
var rows []models.Productvariant
err := r.db.
Table("productvariants v").
Select(`
v.*,
p.productname AS variantproductname,
COALESCE(p.unitvalue, '') AS variantunitvalue,
COALESCE(p.productunit, '') AS variantproductunit,
COALESCE(NULLIF(v.price, 0), 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 price,
-- The variant’s own override wins, then the outlet’s price, then the
-- master price. The override was being ignored entirely: a size
-- priced differently from its product was sold at the product’s
-- price. NULLIF because 0 in that column means "no override", not
-- "free".
COALESCE(NULLIF(v.price, 0), 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, 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
}
// 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
}