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 }