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 }