160 lines
7.4 KiB
Go
160 lines
7.4 KiB
Go
package services
|
|
|
|
import "nearle/models"
|
|
|
|
// InStockOnly drops the products an outlet cannot actually sell.
|
|
//
|
|
// Being on a shelf is not the same as being in stock. Suriya Store's Peelamedu
|
|
// branch listed three products in the customer app of which exactly one had any
|
|
// stock, so a shopper could add a chocolate bar to their basket and order
|
|
// something the shop did not have. The rule the business wants is the simple
|
|
// one: if we have the stock, it can be ordered — otherwise it is not on offer.
|
|
//
|
|
// ── Why here and not in the SQL ──────────────────────────────────────────────
|
|
//
|
|
// The obvious implementation is another correlated subquery in GetProducts'
|
|
// WHERE clause. This is better for a reason that outlives the convenience:
|
|
// `Productstock` is the number the app DISPLAYS, computed by that query as the
|
|
// live SUM(in)-SUM(out) balance. Filtering on the same value that is shown makes
|
|
// it impossible for the two to disagree. A second copy of the balance
|
|
// expression in a WHERE clause is a copy that can drift from the one in the
|
|
// SELECT, and the failure it produces — a product listed as "3 in stock" that
|
|
// the filter considers empty, or worse the reverse — is very hard to see.
|
|
//
|
|
// It also filters on the ledger rather than productlocations.status. That flag
|
|
// is derived and admits drift: SyncProductLocationStatus repairs it on the next
|
|
// ledger entry, so between an order and that entry it can still read
|
|
// "available" for an empty shelf. The ledger cannot drift from itself.
|
|
//
|
|
// ── The locationID guard ─────────────────────────────────────────────────────
|
|
//
|
|
// Not defensive padding. GetProducts computes the stock balance scoped to
|
|
// params.LocationID, and with no outlet the subquery matches nothing, so EVERY
|
|
// product comes back with Productstock 0. Filtering that would turn an unscoped
|
|
// browse into an empty catalogue rather than an unfiltered one.
|
|
//
|
|
// The console is deliberately unaffected: its inventory screens read
|
|
// GetLocationProducts, a different query, and they must keep showing the empty
|
|
// lines — restocking them is the entire point of that screen.
|
|
func InStockOnly(products []models.Products, locationID int) []models.Products {
|
|
if locationID <= 0 {
|
|
return products
|
|
}
|
|
|
|
// Rebuilt rather than filtered in place: the caller's slice is the
|
|
// repository's own result and nothing here should be writing through it.
|
|
inStock := make([]models.Products, 0, len(products))
|
|
for _, p := range products {
|
|
if p.Productstock > 0 {
|
|
inStock = append(inStock, p)
|
|
}
|
|
}
|
|
return inStock
|
|
}
|
|
|
|
// InStockOnlyGrouped is InStockOnly for the grouped shape `getallproducts`
|
|
// returns: a tenant with its products, rather than a flat list.
|
|
//
|
|
// ── Why this one does NOT skip when locationID is 0 ──────────────────────────
|
|
//
|
|
// InStockOnly above returns early without an outlet, because the query behind
|
|
// it computes stock scoped to that outlet and answers 0 for everything when
|
|
// there is none — filtering that would empty the catalogue rather than filter
|
|
// it. FetchFilteredProducts is built differently: its stock subquery reads
|
|
// `(? = 0 OR locationid = ?)`, so with no outlet it aggregates the ledger across
|
|
// the tenant's outlets and still returns a real number. Measured 2026-09-02 on
|
|
// R mart: 24 products unscoped, 20 with a live balance and 4 at zero.
|
|
//
|
|
// So the number is trustworthy either way, and the rule can be applied either
|
|
// way: a shop that holds none of something anywhere is not offering it.
|
|
//
|
|
// A tenant left with no sellable products keeps its group, with an empty list.
|
|
// Dropping the group would tell the app the shop does not exist, which is a
|
|
// different and wrong statement — the shop is open, it has nothing on the shelf.
|
|
func InStockOnlyGrouped(groups []models.Tenantproducts) []models.Tenantproducts {
|
|
out := make([]models.Tenantproducts, 0, len(groups))
|
|
for _, group := range groups {
|
|
kept := make([]models.Products, 0, len(group.Products))
|
|
for _, product := range group.Products {
|
|
if product.Productstock > 0 {
|
|
kept = append(kept, product)
|
|
}
|
|
}
|
|
group.Products = kept
|
|
out = append(out, group)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// WithoutVariantChildren drops the products that are a size of another one.
|
|
//
|
|
// The companion to InStockOnlyGrouped, and applied only for the customer app.
|
|
// A shop stocking Aachi Baby Fryums in 100g, 500g and 1kg has three rows; the
|
|
// shopper should meet one product with three sizes, not three near-identical
|
|
// listings they have to read the suffix of to tell apart.
|
|
//
|
|
// The group's visible row is the PARENT, and it carries the whole set in
|
|
// `variantoptions` — including its own size — so nothing is unreachable: every
|
|
// size is one tap away from the one row that remains.
|
|
//
|
|
// The console is deliberately unaffected. A merchant prices, restocks and
|
|
// publishes each size separately, so all three must stay visible there.
|
|
func WithoutVariantChildren(groups []models.Tenantproducts, isChild map[int]bool) []models.Tenantproducts {
|
|
if len(isChild) == 0 {
|
|
return groups
|
|
}
|
|
out := make([]models.Tenantproducts, 0, len(groups))
|
|
for _, group := range groups {
|
|
kept := make([]models.Products, 0, len(group.Products))
|
|
for _, product := range group.Products {
|
|
if isChild[product.Productid] {
|
|
continue
|
|
}
|
|
kept = append(kept, product)
|
|
}
|
|
group.Products = kept
|
|
out = append(out, group)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// PricedOnly drops the products a shopper cannot be charged for.
|
|
//
|
|
// The companion to InStockOnlyGrouped, and the same argument: being in a shop's
|
|
// catalogue is not the same as being on sale. A product with no price is not
|
|
// free, it is unfinished — but ₹0 on a phone reads as free, and a shopper who
|
|
// taps it either expects to pay nothing or decides the shop is broken.
|
|
//
|
|
// ── Where the ₹0 products come from ─────────────────────────────────────────
|
|
//
|
|
// Import used to write no price at all, and 159 of the 1,572 catalogue rows
|
|
// still carry no price for it to copy — loose produce has none by design, and
|
|
// some scraped rows never had one. So an import lands at ₹0, the merchant adds
|
|
// stock, and the product goes on sale at nothing. Measured 2026-09-03: Kmart's
|
|
// entire customer-facing catalogue was five products, every one at ₹0.
|
|
//
|
|
// The merchant is not left guessing: the console lists these as "unpriced" with
|
|
// a Set price button, and pricing one puts it on sale immediately.
|
|
//
|
|
// Deliberately NOT applied to the console, for the same reason as the stock
|
|
// filter — an unpriced product is precisely what a merchant needs to see in
|
|
// order to fix it.
|
|
func PricedOnly(groups []models.Tenantproducts) []models.Tenantproducts {
|
|
out := make([]models.Tenantproducts, 0, len(groups))
|
|
for _, group := range groups {
|
|
kept := make([]models.Products, 0, len(group.Products))
|
|
for _, product := range group.Products {
|
|
// `Price` is the effective figure the same query computed — the
|
|
// outlet's own price, falling back to the master. Filtering on the
|
|
// number that is DISPLAYED means the list and the label cannot
|
|
// disagree.
|
|
if product.Price > 0 {
|
|
kept = append(kept, product)
|
|
}
|
|
}
|
|
group.Products = kept
|
|
out = append(out, group)
|
|
}
|
|
return out
|
|
}
|