213 lines
8.0 KiB
Go
213 lines
8.0 KiB
Go
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
|
||
}
|
||
|
||
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,
|
||
-- 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).
|
||
// 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
|
||
}
|