package repositories import ( "errors" "fmt" "strconv" "strings" "time" "nearle/models" "gorm.io/gorm" "gorm.io/gorm/clause" ) type ProductRepository interface { GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error) GetProductCount(tenantID, categoryID, subcategoryID int, approve string) ([]models.Productcount, error) GetProductCategory() ([]models.ProductCategory, error) EnsureTenantCategories(tenantID int, names []string) (map[string]int, error) RecategoriseProducts(tenantID int, updates []models.ProductCategoryUpdate) (int64, error) GetProductVariants(tenantID, subcategoryID int) ([]models.Productvariant, error) GetCatalougeProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Products, error) GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error) CreateProductStock(stocks []models.Productstock) error UpdateProductStatus(productIDs []int, status string) error SyncProductLocationStatus(refs []models.ProductLocationRef) error EnsureProductLocation(refs []models.ProductLocationRef) error CreateProduct(product models.Products) error UpdateProduct(product models.Products) error DeleteProduct(productID int) error GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error) GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error) GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error) GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error) FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error) GetProductByVariant(tenantid, variantid, locationid, productid int) ([]models.Products, error) GetSubcategories(categoryID int) ([]models.Subcategory, error) GetProducts(params models.ProductFilter) ([]models.Products, error) GetTenantInfo(tenantID, applocationID int) (map[string]interface{}, error) UpdateProductLocation(input models.Productlocations) error CreateProductLocation(input []models.Productlocations) error CreateProductVariant(input models.Productvariant) error DeleteProductLocation(tenantid, locationid, productid int) error // Releasing a product from the admin catalogue to every outlet, and pulling // it back. See productPublishRepository.go. PublishProduct(tenantID, productID int, price, taxPercent float64) (int, error) PublishPricedLocations(refs []models.ProductLocationRef) error UnpublishProduct(tenantID, productID int) (int, error) FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*models.Products, error) FindTenantProductByImageID(tenantid int, imageid string) (*models.Products, error) SetCatalogueLink(productid int, imageid string, catalogueid int) error ListCatalogueLinkedProducts(tenantid int) ([]models.Products, error) CreateProductReturningID(product models.Products) (int, error) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error UpdateProductCategory(productid, categoryid, subcategoryid int) error UpdateProductVariant(productid, variantid int) error AddProductVariant(v models.Productvariant) (models.Productvariant, error) RemoveProductVariant(tenantid, variantid int) error VariantsForProducts(tenantid, locationid int, productids []int) (map[int][]models.Productvariant, error) VariantChildIDs(tenantid int) (map[int]bool, error) } type productRepository struct { db *gorm.DB } func NewProductRepository(db *gorm.DB) ProductRepository { return &productRepository{db: db} } func (r *productRepository) GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error) { var data []models.ProductSubCategory var query strings.Builder var args []interface{} // tenantid is selected via COALESCE (not SELECT *) because the relaxed // filter below can now return rows where it's NULL, which won't scan // into the model's non-pointer int field otherwise. query.WriteString(`SELECT subcatid, categoryid, COALESCE(tenantid, 0) AS tenantid, subcatname, image, status, sortorder, createdby, created, updated FROM productsubcategories WHERE 1=1`) if tenantID != 0 { // Some subcategories are tenant-owned overrides, others are shared // master data with no tenant attached (tenantid NULL/0) — match both // so a tenant sees the global set in addition to their own. query.WriteString(" AND (tenantid = ? OR tenantid IS NULL OR tenantid = 0)") args = append(args, tenantID) } if categoryID != 0 { query.WriteString(" AND categoryid = ?") args = append(args, categoryID) } if err := r.db.Raw(query.String(), args...).Scan(&data).Error; err != nil { return nil, err } // print() return data, nil } func (r *productRepository) GetProductCount(tenantid, categoryid, subcategory int, approve string) ([]models.Productcount, error) { var data []models.Productcount // available/outofstock are counted from the ledger, not from // products.productstatus. That column is a lifecycle field ("Active" / // "Inactive") that a bug in the stock-receipt path used to overwrite with // availability values, so counting it returned near-nonsense: of 6245 // products it matched 'available' on 136 and 'outofstock' on 12, with the // rest — the real answer — invisible under "Active". // // A product counts as available when it holds positive stock at any one of // the tenant's outlets, which is the only sensible tenant-wide reading of a // quantity that is really per-outlet. total = available + outofstock. baseQuery := ` SELECT COUNT(*) AS total, SUM(CASE WHEN COALESCE(s.balance, 0) > 0 THEN 1 ELSE 0 END) AS available, SUM(CASE WHEN COALESCE(s.balance, 0) <= 0 THEN 1 ELSE 0 END) AS outofstock FROM products a LEFT JOIN ( SELECT productid, tenantid, SUM(CASE WHEN LOWER(stocktype) = 'in' THEN quantity ELSE 0 END) - SUM(CASE WHEN LOWER(stocktype) = 'out' THEN quantity ELSE 0 END) AS balance FROM productstocks GROUP BY productid, tenantid ) s ON s.productid = a.productid AND s.tenantid = a.tenantid WHERE 1 = 1 ` var conditions []string var params []interface{} if tenantid != 0 { conditions = append(conditions, "a.tenantid = ?") params = append(params, tenantid) } if categoryid != 0 { conditions = append(conditions, "a.categoryid = ?") params = append(params, categoryid) } if subcategory != 0 { conditions = append(conditions, "a.subcategoryid = ?") params = append(params, subcategory) } if approve != "" { conditions = append(conditions, "a.approve = ?") params = append(params, approve) } if len(conditions) > 0 { baseQuery += " AND " + strings.Join(conditions, " AND ") } if err := r.db.Raw(baseQuery, params...).Scan(&data).Error; err != nil { return nil, err } print(baseQuery) return data, nil } func (r *productRepository) GetProductCategory() ([]models.ProductCategory, error) { var data []models.ProductCategory q1 := `SELECT * FROM productcategories WHERE moduleid = 2 AND status = 'Active'` r.db.Raw(q1).Scan(&data) print(q1) return data, nil } func (r *productRepository) GetProductVariants(tenantID int, subcategoryID int) ([]models.Productvariant, error) { var data []models.Productvariant var query string var params []interface{} query = ` SELECT a.*, b.categoryname FROM productvariants a JOIN app_category b ON a.categoryid = b.categoryid WHERE a.tenantid = ? ` params = append(params, tenantID) if subcategoryID != 0 { query += " AND a.subcategoryid = ?" params = append(params, subcategoryID) } r.db.Raw(query, params...).Scan(&data) //print(query) return data, nil } func (r *productRepository) GetCatalougeProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Products, error) { var data []models.Products if pageno < 1 { pageno = 1 } if pagesize < 1 { pagesize = 10 } offset := (pageno - 1) * pagesize params := []interface{}{locationID, tenantID} // Base query query := ` SELECT a.* FROM products a LEFT JOIN productlocations b ON a.productid = b.productid AND b.locationid = ? AND b.tenantid = a.tenantid WHERE a.approve = 1 AND a.tenantid = ? AND b.productid IS NULL ` // Optional filters if subcategoryID != 0 { query += " AND a.subcategoryid = ?" params = append(params, subcategoryID) } if keyword != "" { query += " AND LOWER(a.productname) LIKE ?" params = append(params, "%"+strings.ToLower(keyword)+"%") } // Pagination query += " ORDER BY a.productid DESC LIMIT " + strconv.Itoa(pagesize) + " OFFSET " + strconv.Itoa(offset) // Debug logs fmt.Println("Executing query:", query) fmt.Println("Params:", params) // Execute query if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil { return nil, err } return data, nil } func (r *productRepository) GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error) { var stocks []models.Productstocks var params []interface{} var conditions []string // One row per product+location holding the live balance, so every // per-ledger-row column has to be aggregated: this rolls up many // productstocks rows and Postgres rejects a bare a.tenantid/a.stocktype // under GROUP BY a.productid (that alone made this endpoint return a // 42803 error instead of any stock at all). // // stocktype is matched case-insensitively because the ledger holds a mix // of 'in' and 'IN' in production — a bare = 'in' silently dropped every // uppercase receipt, which understated stock rather than erroring. query := ` SELECT a.productid, a.tenantid, a.locationid, MAX(a.stockdate) AS stockdate, MAX(a.stocktype) AS stocktype, MAX(a.maxquantity) AS maxquantity, MAX(a.minquantity) AS minquantity, MAX(a.status) AS status, b.applocationid, b.categoryid, b.subcategoryid, b.catalogueid, b.addonid, b.discountid, b.pricingid, b.productname, b.productimage, COALESCE(b.productimages::text, '') AS productimages, b.productdesc, b.productsku, b.brandid, b.productbrand, b.productunit, b.unitvalue, b.toppicks, b.productcost, b.taxamount, b.taxpercent, b.producttax, b.productstock, b.productcombo, b.variants, b.retailprice, b.diffprice, b.diffpercent, b.othercost, b.approve, b.productstatus, b.created, b.updated, c.subcatname AS subcategoryname, SUM(CASE WHEN LOWER(a.stocktype) = 'in' THEN a.quantity ELSE 0 END) - SUM(CASE WHEN LOWER(a.stocktype) = 'out' THEN a.quantity ELSE 0 END) AS quantity FROM productstocks a JOIN products b ON a.productid = b.productid INNER JOIN productsubcategories c ON c.subcatid = b.subcategoryid ` if tenantID != "" { conditions = append(conditions, "a.tenantid = ?") params = append(params, tenantID) } if locationID != "" { conditions = append(conditions, "a.locationid = ?") params = append(params, locationID) } if len(conditions) > 0 { query += " WHERE " + strings.Join(conditions, " AND ") } // b.* / c.* ride along on the primary keys' functional dependency. query += " GROUP BY a.productid, a.tenantid, a.locationid, b.productid, c.subcatid" if err := r.db.Raw(query, params...).Scan(&stocks).Error; err != nil { return nil, err } return stocks, nil } func (r *productRepository) CreateProductStock(stocks []models.Productstock) error { return r.db.Table("productstocks").Create(&stocks).Error } // EnsureProductLocation puts a product on an outlet's shelf if it is not there // already, and leaves it completely alone if it is. // // Receiving stock used to write the ledger and nothing else. The ledger is not // what the customer app reads: GetProducts joins productlocations and then // filters `pl.locationid = ?`, so a product with no row for that outlet is // dropped by the join no matter how much stock arrived. A shop could request a // product, have it approved, receive it, watch the stock rise in the console — // and the product was still not for sale, with nothing anywhere saying why. // // SyncProductLocationStatus below cannot cover this: it is an UPDATE, so with // no row to update it succeeds having changed nothing. // // INSERT ... SELECT ... WHERE NOT EXISTS rather than an upsert, deliberately. // An upsert here would carry a price, and the only price this code could supply // is the master retailprice — which would overwrite an outlet's own, carefully // different, price every time a delivery arrived. Existing rows must not be // touched; this only ever creates the missing one. // // The seeded status is "outofstock" because it is true at the instant of the // insert. The caller runs SyncProductLocationStatus immediately after, which // derives the real value from the ledger, so a genuine receipt corrects it in // the same call. func (r *productRepository) EnsureProductLocation(refs []models.ProductLocationRef) error { for _, ref := range refs { if err := r.db.Exec(` INSERT INTO productlocations (tenantid, locationid, productid, price, status) SELECT ?, ?, ?, COALESCE(p.retailprice, 0), 'outofstock' FROM products p WHERE p.productid = ? AND p.tenantid = ? AND NOT EXISTS ( SELECT 1 FROM productlocations pl WHERE pl.tenantid = ? AND pl.locationid = ? AND pl.productid = ? )`, ref.Tenantid, ref.Locationid, ref.Productid, ref.Productid, ref.Tenantid, ref.Tenantid, ref.Locationid, ref.Productid).Error; err != nil { return err } } return nil } // SyncProductLocationStatus recomputes productlocations.status for each ref // from the productstocks ledger: "available" when the live SUM(in)-SUM(out) // balance is positive, "outofstock" when it is not. // // It replaces an earlier version that flipped the flag to "available" // unconditionally on any receipt. That left the flag drifting from reality in // both directions — a receipt that only partly covered a negative balance // marked the product sellable when it wasn't, and stock that arrived by any // route the API didn't own (a direct insert, an import) never cleared an // "outofstock" set by a much earlier order. Deriving the flag instead of // assuming it means every write path converges on the same answer, and a row // that has already drifted repairs itself on the next ledger entry. func (r *productRepository) SyncProductLocationStatus(refs []models.ProductLocationRef) error { for _, ref := range refs { if err := r.db.Exec(` UPDATE productlocations SET status = CASE WHEN ( SELECT COALESCE( SUM(CASE WHEN LOWER(stocktype) = 'in' THEN quantity ELSE 0 END) - SUM(CASE WHEN LOWER(stocktype) = 'out' THEN quantity ELSE 0 END), 0) FROM productstocks WHERE productid = ? AND tenantid = ? AND locationid = ? ) > 0 THEN 'available' ELSE 'outofstock' END WHERE tenantid = ? AND locationid = ? AND productid = ?`, ref.Productid, ref.Tenantid, ref.Locationid, ref.Tenantid, ref.Locationid, ref.Productid).Error; err != nil { return err } } return nil } func (r *productRepository) UpdateProductStatus(productIDs []int, status string) error { return r.db.Table("products"). Where("productid IN ?", productIDs). Update("productstatus", status).Error } func (r *productRepository) CreateProduct(product models.Products) error { tx := r.db.Begin() if err := tx.Create(&product).Error; err != nil { tx.Rollback() return err } if err := tx.Commit().Error; err != nil { return err } return nil } func (r *productRepository) UpdateProduct(product models.Products) error { tx := r.db.Begin() if err := tx.Table("productlocations"). Where("productid = ?", product.Productid). Select("status"). // only update 'approve' field Updates(product).Error; err != nil { tx.Rollback() return err } return tx.Commit().Error } func (r *productRepository) DeleteProduct(productID int) error { tx := r.db.Begin() if err := tx.Table("products").Where("productid = ?", productID).Delete(&models.Products{}).Error; err != nil { tx.Rollback() return err } if err := tx.Commit().Error; err != nil { return err } return nil } func (r *productRepository) GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error) { data := make([]models.Productstockstatement, 0) if pageno < 1 { pageno = 1 } if pagesize < 1 { pagesize = 10 } offset := (pageno - 1) * pagesize params := []interface{}{tenantID, locationID} // opening is the balance carried in from *before* today, so it stops at // stockdate < CURRENT_DATE. It used to include today (<=), which made it // arithmetically identical to closing — the Inventory ledger then showed // the same number in both columns and looked like stock never moved, even // on days with sales. query := `SELECT a.productid,a.productname,a.productimage,a.categoryid,a.subcategoryid,a.productunit,a.unitvalue,a.productcost,a.taxpercent,a.taxamount,a.retailprice,b.tenantid,b.locationid, COALESCE( SUM(CASE WHEN UPPER(c.stocktype) = 'IN' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END) - SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END),0 ) AS opening, COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' AND c.stockdate::date = CURRENT_DATE THEN c.quantity ELSE 0 END), 0) AS credit, COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' AND c.stockdate::date = CURRENT_DATE THEN c.quantity ELSE 0 END), 0) AS debit, COALESCE( ( SUM(CASE WHEN UPPER(c.stocktype) = 'IN' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END) - SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END) ) + SUM(CASE WHEN UPPER(c.stocktype) = 'IN' AND c.stockdate::date = CURRENT_DATE THEN c.quantity ELSE 0 END) - SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' AND c.stockdate::date = CURRENT_DATE THEN c.quantity ELSE 0 END), 0 ) AS closing FROM products a JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid LEFT JOIN productstocks c ON a.productid = c.productid AND b.locationid = c.locationid AND b.tenantid = c.tenantid WHERE b.tenantid = ? AND b.locationid = ?` if subcategoryID != 0 { query += " AND a.subcategoryid = ?" params = append(params, subcategoryID) } if keyword != "" { query += " AND (CAST(a.productid AS TEXT) LIKE ? OR LOWER(a.productname) LIKE ?)" likeParam := "%" + strings.ToLower(keyword) + "%" params = append(params, likeParam, likeParam) } query += ` GROUP BY a.productid, a.productname, a.productimage, a.categoryid, a.subcategoryid, a.productunit, a.productcost, a.taxpercent, a.taxamount, a.retailprice, b.tenantid, b.locationid ORDER BY a.productid DESC LIMIT ` + strconv.Itoa(pagesize) + ` OFFSET ` + strconv.Itoa(offset) if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil { return nil, err } print(query) return data, nil } func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error) { var data []models.Locationproducts if pageno < 1 { pageno = 1 } if pagesize < 1 { pagesize = 10 } offset := (pageno - 1) * pagesize // locationID 0 means EVERY branch this tenant runs. // // There was no such read, and the console papered over it by listing the // FIRST branch under "All branches" — so a merchant with five outlets saw // RS Puram's four products and not NSN's five, with nothing on screen // saying so. Picking one branch showed MORE than picking all of them, which // is the one shape a filter must never have. // // Aggregated per product rather than one row per branch: this is the admin // catalogue answering "what do we sell", and the same product repeated five // times is a different question nobody asked. Stock sums across branches — // each productstocks row belongs to exactly one branch, so the join cannot // double-count it — while price, publication and status collapse to the // best answer any branch has, which is what a tenant-wide view of a // per-branch column means. allBranches := locationID <= 0 params := []interface{}{tenantID} if !allBranches { params = append(params, locationID) } // b.price is the per-store selling price. It has to be selected explicitly: // a.* only covers products (whose price column is retailprice, the master // price), so without this the catalogue could never read back a price set // for this outlet via CreateProductLocation. // quantity/productstock both alias the same live SUM(in)-SUM(out) balance // from productstocks — placed after a.* so they overwrite the static, // never-decremented products.quantity column GORM would otherwise scan // into Locationproducts.Quantity, which is what made the console's stock // column look frozen after an order despite CreateOrder recording the // "out" ledger entry correctly. // COALESCE so `price` means the same thing here as in GetProducts: the // effective selling price at this outlet, falling back to the master // retailprice when the store hasn't set its own. Returning a bare b.price // reported 0 for any product priced only at tenant level, which the store // catalogue then rendered as "—". // b.publishedat decides whether a store user may see this row at all, so it // has to be selected explicitly for the same reason b.price does: `a.*` // covers products, and publication is a per-outlet fact on // productlocations. Without it the column came back null for every row — // including ones that had just been published — and the store catalogue, // which filters on it, showed nothing. // Per-branch columns are aggregated when every branch is asked for, and // selected plainly when one is. Postgres needs one or the other — a column // that is neither grouped nor aggregated is a hard error, not a warning — // so the two shapes are written out rather than patched together. perBranch := `b.productlocationid, b.status, b.publishedat, COALESCE(NULLIF(b.price, 0), a.retailprice, 0) AS price,` if allBranches { perBranch = `MIN(b.productlocationid) AS productlocationid, MAX(b.status) AS status, MAX(b.publishedat) AS publishedat, COALESCE(NULLIF(MAX(b.price), 0), a.retailprice, 0) AS price,` } query := `SELECT a.*, ` + perBranch + ` COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END), 0) AS total_in, COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' THEN c.quantity ELSE 0 END), 0) AS total_out, COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END) - SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' THEN c.quantity ELSE 0 END), 0) AS productstock, COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END) - SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' THEN c.quantity ELSE 0 END), 0) AS quantity FROM products a INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid LEFT JOIN productstocks c ON a.productid = c.productid AND b.locationid = c.locationid AND a.tenantid = c.tenantid WHERE a.approve=1 AND a.tenantid = ?` if !allBranches { query += ` AND b.locationid = ?` } if subcategoryID != 0 { query += " AND a.subcategoryid = ?" params = append(params, subcategoryID) } if keyword != "" { query += " AND LOWER(a.productname) LIKE ?" params = append(params, "%"+strings.ToLower(keyword)+"%") } // Grouped by the product alone when every branch is asked for, so one // product is one row however many outlets stock it. Including b.locationid // there would return the same product five times over — which is what a // merchant with five branches would have seen instead of a catalogue. groupBy := ` GROUP BY a.productid, a.productname, a.productimage, a.categoryid, a.subcategoryid, a.productunit, a.productcost, a.taxpercent, a.taxamount, a.retailprice, b.tenantid, b.locationid, b.productlocationid, b.status, b.price, b.publishedat` if allBranches { groupBy = ` GROUP BY a.productid, a.productname, a.productimage, a.categoryid, a.subcategoryid, a.productunit, a.productcost, a.taxpercent, a.taxamount, a.retailprice` } query += groupBy + ` ORDER BY a.productid DESC LIMIT ? OFFSET ?` params = append(params, pagesize, offset) if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil { return nil, err } print(query) return data, nil } // GetSaleTemplate lists products stocked at a tenant's branches, with each // one's live ledger balance, so the web app can generate a pre-filled // offline-sales spreadsheet. // // locationID = 0 means "every branch this tenant runs", which is the normal // case: a merchant with several outlets gets ONE workbook covering all of them, // with tenantid and locationid stamped on every row. The row's own locationid // is what later decides which branch a sale is deducted from, so the operator // never has to pick a store or juggle a file per outlet. Passing a specific // locationID narrows it to that branch, which is what a store user gets. // // It deliberately returns the whole catalogue unpaged — a spreadsheet meant to // be filled in and handed back is only useful if it contains every product that // could have been sold. // // The balance is the same SUM(in) - SUM(out) expression CreateOrder validates // against, so the "currentstock" read in the sheet is exactly the number the // import will check the typed quantity against. LOWER() covers the mixed-case // stocktype values in production ('out', 'IN', 'in'). // // The INNER JOIN on tenantlocations is load-bearing: it confines the result to // branches the tenant actually owns, so a template can never disclose another // merchant's catalogue even if a stray productlocations row pointed at one. func (r *productRepository) GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error) { if tenantID <= 0 { return nil, errors.New("tenantid is required") } if locationID < 0 { locationID = 0 } // Only checked when the caller narrowed to one branch. Without it a // mistyped locationid would silently yield an empty template rather than // saying the outlet is not theirs. if locationID > 0 { var locationName string err := r.db.Raw( `SELECT COALESCE(locationname, '') FROM tenantlocations WHERE tenantid = ? AND locationid = ?`, tenantID, locationID, ).Scan(&locationName).Error if err != nil { return nil, err } if strings.TrimSpace(locationName) == "" { return nil, fmt.Errorf("location %d does not belong to tenant %d", locationID, tenantID) } } rows := make([]models.SaleTemplateRow, 0) query := ` SELECT a.tenantid, b.locationid, COALESCE(tl.locationname, '') AS locationname, a.productid, a.productname, COALESCE(a.productunit, '') AS productunit, COALESCE(a.unitvalue, '') AS unitvalue, COALESCE(d.categoryname, '') AS categoryname, COALESCE(SUM(CASE WHEN LOWER(c.stocktype) = 'in' THEN c.quantity ELSE 0 END) - SUM(CASE WHEN LOWER(c.stocktype) = 'out' THEN c.quantity ELSE 0 END), 0) AS currentstock, CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price, COALESCE(a.taxpercent, 0) AS taxpercent FROM products a INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid INNER JOIN tenantlocations tl ON tl.locationid = b.locationid AND tl.tenantid = a.tenantid LEFT JOIN productstocks c ON a.productid = c.productid AND b.locationid = c.locationid AND a.tenantid = c.tenantid LEFT JOIN productcategories d ON a.categoryid = d.categoryid WHERE a.approve = 1 AND a.tenantid = ? AND (? = 0 OR b.locationid = ?) GROUP BY a.tenantid, b.locationid, tl.locationname, a.productid, a.productname, a.productunit, a.unitvalue, d.categoryname, b.price, a.retailprice, a.taxpercent ORDER BY tl.locationname ASC, a.productname ASC` if err := r.db.Raw(query, tenantID, locationID, locationID).Scan(&rows).Error; err != nil { return nil, err } // Summarised from the rows themselves rather than queried separately, so // the branch list can never disagree with what the sheet actually contains. locations := make([]models.SaleTemplateLocation, 0) seen := make(map[int]int) for _, row := range rows { if idx, ok := seen[row.Locationid]; ok { locations[idx].Productcount++ continue } seen[row.Locationid] = len(locations) locations = append(locations, models.SaleTemplateLocation{ Locationid: row.Locationid, Locationname: strings.TrimSpace(row.Locationname), Productcount: 1, }) } return &models.SaleTemplate{ Tenantid: tenantID, Locationid: locationID, Locations: locations, Products: rows, }, nil } func (r *productRepository) GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error) { data := make([]models.ProductSummary, 0) query := ` SELECT a.subcatid AS subcategoryid, a.subcatname AS subcategroyname, a.image, COUNT(DISTINCT c.productid) AS productcount FROM productsubcategories a LEFT JOIN products b ON a.subcatid = b.subcategoryid AND b.approve = 1 AND b.tenantid = ? LEFT JOIN productlocations c ON b.productid = c.productid AND c.tenantid = ? AND c.locationid = ? WHERE a.categoryid = 2 GROUP BY a.subcatid, a.subcatname, a.image ORDER BY a.subcatid; ` // Only 3 params: tenantID for products, tenantID for locations, locationID params := []interface{}{tenantID, tenantID, locationID} if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil { return nil, err } // Correct "All" count: sum of only products that exist in this location total := 0 for _, d := range data { total += d.Productcount } all := models.ProductSummary{ Subcategoryid: 0, Subcategroyname: "All", Productcount: total, } data = append([]models.ProductSummary{all}, data...) return data, nil } func (r *productRepository) FetchFilteredProducts( categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int, ) ([]models.Tenantproducts, error) { offset := (pageno - 1) * pagesize results := make([]models.Tenantproducts, 0) if tenantID == 0 { return results, nil } // Fetch tenant info var tenant models.TenantInfo if err := r.db.Table("tenants").Where("tenantid = ?", tenantID).First(&tenant).Error; err != nil { return nil, err } // Build product query var products []models.Products // Both derived tables collapse to one row per product before joining, and // both are scoped by tenant + (optionally) location. Three things were // wrong here and each one showed up as bad stock in the console: // • productlocations was joined on productid alone, so a product stocked // in three outlets came back three times, each row carrying another // outlet's status. // • the stock subquery grouped by (productid, locationid) but joined on // productid only, so a product's quantity was whichever outlet's row // the planner happened to pair it with — not this outlet's. // • stocktype was compared case-sensitively against 'in'/'out' while the // ledger stores a mix of 'in' and 'IN', so uppercase receipts were // dropped from the balance. // locationID 0 means "not scoped to an outlet": stock is then the tenant's // total across outlets, which is what an unscoped listing should show. // // `price` is computed here for the same reason it is computed in GetProducts // and GetProductByVariant, and its absence was the same bug all three times. // `a.*` carries `products.price`, which nothing ever writes — the selling // price lives in `productlocations.price`, set per outlet when the admin // prices a product, with `products.retailprice` as the tenant-wide fallback. // Without this the endpoint returned price 0 for every row while // retailprice held the real figure, so an app reading `price` showed nothing // at all. Measured on tenant 1135: price 0 / retailprice 50 on this // endpoint, against price 50 on the other three. // // A correlated subquery rather than a read off the joined `b`, because that // derived table takes MAX(locationid) to collapse duplicates and would hand // back an arbitrary outlet's price whenever the caller did not scope to one. // Ordering by locationid at least makes the unscoped answer deterministic. query := r.db. Table("products a"). Select(` a.*, b.status, b.locationid, c.categoryname, d.subcatname AS subcategoryname, COALESCE(NULLIF(( SELECT pl2.price FROM productlocations pl2 WHERE pl2.productid = a.productid AND pl2.tenantid = a.tenantid AND (? = 0 OR pl2.locationid = ?) ORDER BY pl2.locationid LIMIT 1 ), 0), a.retailprice, 0) AS price, COALESCE(ps.quantity, 0) AS productstock, COALESCE(ps.quantity, 0) AS quantity `, locationID, locationID). Joins(` LEFT JOIN ( SELECT productid, tenantid, MAX(locationid) AS locationid, MAX(status) AS status FROM productlocations WHERE (? = 0 OR locationid = ?) GROUP BY productid, tenantid ) b ON b.productid = a.productid AND b.tenantid = a.tenantid `, locationID, locationID). Joins("LEFT JOIN productcategories c ON a.categoryid = c.categoryid"). Joins("LEFT JOIN productsubcategories d ON a.subcategoryid = d.subcatid"). Joins(` LEFT JOIN ( SELECT productid, tenantid, SUM(CASE WHEN LOWER(stocktype) = 'in' THEN quantity ELSE 0 END) - SUM(CASE WHEN LOWER(stocktype) = 'out' THEN quantity ELSE 0 END) AS quantity FROM productstocks WHERE (? = 0 OR locationid = ?) GROUP BY productid, tenantid ) ps ON ps.productid = a.productid AND ps.tenantid = a.tenantid `, locationID, locationID). Where("a.tenantid = ?", tenantID). Order("a.productid DESC") if categoryID != 0 { query = query.Where("a.categoryid = ?", categoryID) } if subcategoryID != 0 { query = query.Where("a.subcategoryid = ?", subcategoryID) } if productID != 0 { query = query.Where("a.productid = ?", productID) } if productStatus != "" { query = query.Where("a.productstatus = ?", productStatus) } if locationID != 0 { // The outlet scope is already applied inside the productlocations and // productstocks subqueries above; this only narrows the result to // products actually carried by that outlet. It used to reference an // alias `e` that no query in this file defines, so every call that // passed a locationid failed outright with "missing FROM-clause entry // for table e" instead of returning products. query = query.Where("b.locationid = ?", locationID) } if approve != "" { query = query.Where("a.approve = ?", approve) } if keyword != "" { like := "%" + strings.ToLower(keyword) + "%" query = query.Where( r.db.Where("LOWER(a.productname) LIKE ?", like). Or("LOWER(a.unitvalue) LIKE ?", like). Or("LOWER(CAST(a.productcost AS TEXT)) LIKE ?", like), ) } if pagesize > 0 && offset >= 0 { query = query.Limit(pagesize).Offset(offset) } if err := query.Scan(&products).Error; err != nil { return nil, err } if products == nil { products = []models.Products{} } results = append(results, models.Tenantproducts{ Tenant: tenant, Products: products, }) print(query) return results, nil } // GetProductByVariant returns what the app can offer for one tapped product. // // Two shapes, decided by the product rather than by the caller: a product that // belongs to a variant group comes back with ALL its siblings, so the shopper // picks a size; a product in no group comes back alone, ready to order. That is // what the ordering screen needs, and until now only the first half existed. // // The query keyed solely on `p.variants = ?`, so an ungrouped product — which // means `variants = 0`, and that is EVERY product for most tenants — could not // be fetched at all. Asking for one returned an empty list and the app had // nothing to place an order against. `productid` is the way in: given one, this // reads that product's own group and answers accordingly. // // An explicit `variantid` still wins, so existing callers are unaffected. func (r *productRepository) GetProductByVariant(tenantid, variantid, locationid, productid int) ([]models.Products, error) { var data []models.Products // Which group to return, resolved from the product when not named outright. group := variantid if group <= 0 && productid > 0 { var found struct{ Variants int } if err := r.db.Table("products"). Select("variants"). Where("productid = ? AND tenantid = ?", productid, tenantid). Scan(&found).Error; err != nil { return nil, err } group = found.Variants } // productstock/quantity are correlated subqueries (not a JOIN+GROUP BY) so // they can coexist with `p.*` without having to enumerate every products // column. quantity is duplicated on purpose: it's placed after `p.*` so it // overwrites the static, never-decremented products.quantity column that // would otherwise scan into Products.Quantity. When locationid is 0 // (caller didn't scope to a store), both subqueries and the // productlocations join simply match nothing, so // Productstock/Quantity/Locationstatus come back zero-valued — same // response shape as before this field existed, not an error. q := r.db. Table("products p"). Select(` p.*, c.categoryname, d.subcatname AS subcategoryname, COALESCE(pd.discountvalue, 0) AS discountvalue, pd.discountid, pl.status AS locationstatus, COALESCE(NULLIF(( SELECT pl2.price FROM productlocations pl2 WHERE pl2.productid = p.productid AND pl2.tenantid = p.tenantid AND pl2.locationid = ? LIMIT 1 ), 0), p.retailprice, 0) AS price, 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 = p.productid AND ps.tenantid = p.tenantid AND ps.locationid = ? ), 0) AS productstock, 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 = p.productid AND ps.tenantid = p.tenantid AND ps.locationid = ? ), 0) AS quantity `, locationid, locationid, locationid). Joins("LEFT JOIN productcategories c ON p.categoryid = c.categoryid"). Joins("LEFT JOIN productsubcategories d ON p.subcategoryid = d.subcatid"). Joins("LEFT JOIN productdiscounts pd ON pd.productid = p.productid"). Joins("LEFT JOIN productlocations pl ON pl.productid = p.productid AND pl.tenantid = p.tenantid AND pl.locationid = ?", locationid) // A group returns the whole family; no group returns the one product. // Neither given keeps the original behaviour exactly, so nothing that // called this before sees a different answer. switch { case group > 0: q = q.Where("p.tenantid = ? AND p.variants = ?", tenantid, group) case productid > 0: q = q.Where("p.tenantid = ? AND p.productid = ?", tenantid, productid) default: // Neither a group nor a product was named, so there is nothing to // return — and returning nothing is the point. // // This branch used to run `WHERE p.variants = 0`, which is not "no // match": EVERY ungrouped product carries variants 0, so asking for // variant 0 handed back the tenant's entire ungrouped catalogue as if // those products were variants of one another. Measured on tenant 1135: // six unrelated products — a chocolate bar, a chewing gum and an apple — // returned as each other's variants. // // That is what stops an order being placed. The app sends the tapped // product's `variants` value, which is 0 for almost every product, and // receives six things it must choose between. There is no correct choice // to make, so the screen cannot proceed. // // An empty result is the honest answer to a question that named nothing. // The controller rejects this case outright with a message naming // `productid`; this is the second line of defence, so no future caller // can reach the match-everything behaviour by another route. return data, nil } err := q. Order("p.productid DESC"). Scan(&data).Error if err != nil { return nil, err } // The sizes, attached to the products they belong to. // // This is what makes one call enough to draw the ordering screen: the app // asks for the product it was tapped on and gets back the product plus every // size hanging under it. A product with none comes back with an empty list, // which is the answer that lets an order proceed. // // A failure here does not fail the request. The product and its price are // the answer; the sizes are an enrichment, and returning the product without // them beats returning nothing at all. ids := make([]int, 0, len(data)) for i := range data { ids = append(ids, data[i].Productid) } if byParent, vErr := r.VariantsForProducts(tenantid, locationid, ids); vErr == nil { for i := range data { children := byParent[data[i].Productid] if len(children) == 0 { // An explicit empty list, never a JSON null: a client that does // `variantoptions.length` must not have to null-check first, and // an empty list is the complete answer “order this one directly”. data[i].Variantoptions = []models.Productvariant{} continue } // The product being looked at is one of its own sizes. // // Without this the picker on “Aachi Baby Fryums 100g” offers 500g and // 1kg and not the 100g the shopper is already reading — so the size // they came for is the one option they cannot choose, and there is no // way back to it once they move off. Every screen in the group now // shows the same complete list. // // Built from the product row already in hand rather than by another // query: it carries the effective price and the live balance this // query just computed, so the entry cannot disagree with the row it // came from. self := models.Productvariant{ Tenantid: data[i].Tenantid, Productid: data[i].Productid, Variantproductid: data[i].Productid, Variantname: variantLabelFor(data[i]), Status: "Active", // The parent is one of its own sizes, so its entry has to carry // money in the same field the others do. Without this the first // option in every picker read 0 while the rest were priced. Price: data[i].Price, Variantproductname: data[i].Productname, Variantprice: data[i].Price, Variantstock: data[i].Productstock, Variantunitvalue: data[i].Unitvalue, Variantproductunit: data[i].Productunit, } data[i].Variantoptions = append([]models.Productvariant{self}, children...) } } return data, nil } func (r *productRepository) GetSubcategories(categoryID int) ([]models.Subcategory, error) { var subcats []models.Subcategory err := r.db.Table("productsubcategories"). Where("categoryid = ?", categoryID). Find(&subcats).Error return subcats, err } func (r *productRepository) GetProducts(params models.ProductFilter) ([]models.Products, error) { var products []models.Products q := r.db.Table("products a"). Joins("LEFT JOIN productlocations pl ON pl.productid = a.productid AND pl.tenantid = a.tenantid"). Joins("LEFT JOIN productdiscounts pd ON pd.productid = a.productid"). Joins("LEFT JOIN productcategories c ON a.categoryid = c.categoryid"). Where("a.categoryid = ?", params.CategoryID) if params.TenantID > 0 { q = q.Where("a.tenantid = ?", params.TenantID) } if params.LocationID > 0 { q = q.Where("pl.locationid = ?", params.LocationID) } if params.AppLocationID > 0 { q = q.Where("a.applocationid = ?", params.AppLocationID) } if params.ProductID > 0 { q = q.Where("a.productid = ?", params.ProductID) } if params.Keyword != "" { like := "%" + strings.ToLower(params.Keyword) + "%" q = q.Where( r.db.Where("LOWER(a.productname) LIKE ?", like). Or("LOWER(a.unitvalue) LIKE ?", like). Or("LOWER(CAST(a.productcost AS TEXT)) LIKE ?", like), ) } // productstock/quantity are correlated subqueries computing the live // SUM(in)-SUM(out) balance from productstocks, scoped to params.LocationID // (0 if the caller didn't scope to a store, matching nothing so both come // back zero). quantity is placed after `a.*` so it overwrites the static, // never-decremented products.quantity column — same fix as // GetLocationProducts/GetProductByVariant, otherwise this endpoint would // keep showing stock that never reduces after an order. // price is the effective selling price at params.LocationID: the store's own // productlocations.price, falling back to the master products.retailprice // when that outlet hasn't set one. It has to be here — this endpoint feeds // the customer app's browse-by-subcategory view, and `a.*` only carries // retailprice, which the admin catalogue never writes. So a price the admin // set per store could never reach the app; every product priced as 0. // // Deliberately a correlated subquery rather than a read off the joined `pl`: // that join isn't outlet-scoped unless params.LocationID is set, so reading // pl.price directly would pick an arbitrary branch's price (and multiply the // rows) whenever the caller didn't scope to one. Same shape as the // productstock subqueries below, for the same reason. err := q.Select(` a.*, COALESCE(pd.discountvalue, 0) AS discountvalue, COALESCE(NULLIF(( SELECT pl2.price FROM productlocations pl2 WHERE pl2.productid = a.productid AND pl2.tenantid = a.tenantid AND pl2.locationid = ? LIMIT 1 ), 0), a.retailprice, 0) AS price, 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 = a.productid AND ps.tenantid = a.tenantid AND ps.locationid = ? ), 0) AS productstock, 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 = a.productid AND ps.tenantid = a.tenantid AND ps.locationid = ? ), 0) AS quantity `, params.LocationID, params.LocationID, params.LocationID).Find(&products).Error return products, err } func (r *productRepository) GetTenantInfo(tenantID, applocationID int) (map[string]interface{}, error) { var tenant struct { Tenantname string Address string Licenseno string Primaryemail string Primarycontact string Locationname string Pickuplocationid int Suburb string City string Latitude string Longitude string Postcode string } err := r.db.Raw(` SELECT t.tenantname, t.address, t.licenseno, t.primaryemail, t.primarycontact, l.locationid AS pickuplocationid, l.suburb, l.city, l.latitude, l.longitude, l.postcode, a.locationname FROM tenants t LEFT JOIN tenantlocations l ON t.tenantid = l.tenantid LEFT JOIN app_location a ON l.applocationid = a.applocationid WHERE t.tenantid = ? AND t.applocationid = ? LIMIT 1 `, tenantID, applocationID).Scan(&tenant).Error if err != nil { return nil, err } return map[string]interface{}{ "tenantname": tenant.Tenantname, "address": tenant.Address, "licenseno": tenant.Licenseno, "primaryemail": tenant.Primaryemail, "primarycontact": tenant.Primarycontact, "locationname": tenant.Locationname, "pickuplocationid": tenant.Pickuplocationid, "suburb": tenant.Suburb, "city": tenant.City, "pickuplat": tenant.Latitude, "pickuplong": tenant.Longitude, "postcode": tenant.Postcode, }, nil } func (r *productRepository) UpdateProductLocation(input models.Productlocations) error { tx := r.db.Begin() t1 := tx.Where("productlocationid = ?", input.Productlocationid).Updates(&input) if t1.Error != nil { tx.Rollback() return t1.Error } if err := tx.Commit().Error; err != nil { return err } return nil } func (r *productRepository) CreateProductLocation(input []models.Productlocations) error { var stk []models.Productstock tx := r.db.Begin() // Insert or update product location if err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "tenantid"}, {Name: "locationid"}, {Name: "productid"}}, DoUpdates: clause.AssignmentColumns([]string{"price", "minquantity", "maxquantity", "status"}), }).Create(&input).Error; err != nil { tx.Rollback() return err } // Prepare product stock entries for _, loc := range input { if loc.Quantity > 0 { stk = append(stk, models.Productstock{ Tenantid: loc.Tenantid, Stockdate: time.Now(), Locationid: loc.Locationid, Productid: loc.Productid, Quantity: loc.Quantity, Stocktype: loc.Stocktype, }) } } // Insert stock records if available if len(stk) > 0 { if err := tx.Create(&stk).Error; err != nil { tx.Rollback() return err } } // Commit transaction if err := tx.Commit().Error; err != nil { return err } return nil } func (r *productRepository) CreateProductVariant(input models.Productvariant) error { tx := r.db.Begin() if err := tx.Create(&input).Error; err != nil { tx.Rollback() return err } if err := tx.Commit().Error; err != nil { return err } return nil } func (r *productRepository) DeleteProductLocation(tenantid, locationid, productid int) error { if err := r.db.Where("tenantid = ? AND locationid = ? AND productid = ?", tenantid, locationid, productid).Delete(&models.Productlocations{}).Error; err != nil { return err } return nil } // FindTenantProductByCatalogueRef looks up the tenant's existing snapshot of // a catalogue product, keyed on (tenantid, brand, catalogueid) since a // catalogue row's bare id is only unique within its own brand table. func (r *productRepository) FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*models.Products, error) { var product models.Products result := r.db.Table("products"). Where("tenantid = ? AND productbrand = ? AND catalogueid = ?", tenantid, brand, catalogueid). First(&product) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, nil } return nil, result.Error } return &product, nil } // CreateProductReturningID inserts a new product snapshot and returns its // generated productid. Kept separate from CreateProduct so existing callers // of CreateProduct are unaffected. func (r *productRepository) CreateProductReturningID(product models.Products) (int, error) { if err := r.db.Create(&product).Error; err != nil { return 0, err } return product.Productid, nil } // FindTenantProductByImageID looks up a tenant's snapshot by the catalogue's // own stable key. // // Preferred over FindTenantProductByCatalogueRef wherever an image_id is // available, because that one keys on a number the catalogue renumbers. After a // re-scrape the id no longer names the product it was stored for, so the lookup // misses, the import believes it is seeing the product for the first time, and // the shop gets a second copy of something it already stocks. // // No brand in the key: image_id already carries it (`cheetos_chips_2d6bf74f`, // `pepsico_kurkure_masala_munch_90g`) and is unique across the whole catalogue, // which is exactly what the bare catalogueid is not. func (r *productRepository) FindTenantProductByImageID(tenantid int, imageid string) (*models.Products, error) { if strings.TrimSpace(imageid) == "" { return nil, nil } var product models.Products result := r.db.Table("products"). Where("tenantid = ? AND imageid = ?", tenantid, imageid). First(&product) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, nil } return nil, result.Error } return &product, nil } // SetCatalogueLink repairs one product's pointer back into the catalogue. // // Both halves move together and both can be cleared, which is the point. // `imageid` is what a product should be linked by from now on; a `catalogueid` // of 0 marks a link that could not be repaired at all — the row it named is // gone and the re-scrape left nothing matching behind it. Clearing it is not // data loss: the pointer already pointed at nothing, and leaving it in place // makes a browse screen claim the product is imported from a row that does not // exist, and makes re-importing fail outright. func (r *productRepository) SetCatalogueLink(productid int, imageid string, catalogueid int) error { return r.db.Table("products"). Where("productid = ?", productid). Updates(map[string]any{"imageid": imageid, "catalogueid": catalogueid}).Error } // ListCatalogueLinkedProducts returns every product of a tenant that claims to // have come from the catalogue, so each claim can be checked. func (r *productRepository) ListCatalogueLinkedProducts(tenantid int) ([]models.Products, error) { products := make([]models.Products, 0) err := r.db.Table("products"). Where("tenantid = ? AND catalogueid IS NOT NULL AND catalogueid != 0", tenantid). Find(&products).Error return products, err } // GetImportedCatalogueRefs returns the (brand, catalogueid) pairs this // tenant has already imported, so a catalogue browse screen can mark items // as already-imported without diffing full product lists client-side. Brand // is optional: omitted, it covers every brand at once — needed because the // all-brands browse view mixes products whose bare catalogueid can collide // across brand tables, so brand must travel with every id. func (r *productRepository) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) { refs := make([]models.ImportedCatalogueRef, 0) query := r.db.Table("products"). Select("productbrand AS brand, catalogueid, COALESCE(imageid, '') AS imageid"). Where("tenantid = ? AND catalogueid IS NOT NULL AND catalogueid != 0", tenantid) if brand != "" { query = query.Where("productbrand = ?", brand) } err := query.Scan(&refs).Error return refs, err } // GetTenantCategories returns the distinct categoryids this tenant's own // products actually use, LEFT JOINed against productcategories for a name // (falling back to a synthesized label when that master row is missing — // it's incomplete in practice, e.g. categoryid 2 has no productcategories // row despite being in real use). This is deliberately not the global // productcategories list: that list can omit categoryids tenants actually // have products in, which would make the import category picker unusable. // RecategoriseProducts moves products into different categories, in bulk. // // Written for the backfill that re-files everything imported before categories // existed: those rows all carry whichever single category the tenant had, while // the catalogue has known all along what each product actually is. // // ── Two things this deliberately does not do ──────────────────────────────── // // It does not touch any column but categoryid. UpdateProduct next door runs // GORM's Updates over a whole struct, which is why it can only be trusted with // a hand-built partial — a bulk caller sending full rows would write back every // field it happened to have read, including a price it never meant to change. // One column, named explicitly, cannot do that. // // It is scoped by tenantid in the WHERE clause, not just trusted from the // caller. A productid is global, so a mistyped id in a list would otherwise // re-file another merchant's product into a category that does not exist for // them — invisible to both shops and to the app. func (r *productRepository) RecategoriseProducts(tenantID int, updates []models.ProductCategoryUpdate) (int64, error) { if tenantID == 0 || len(updates) == 0 { return 0, nil } placeholders := make([]string, 0, len(updates)) args := make([]any, 0, len(updates)*3) for _, u := range updates { if u.Productid == 0 || u.Categoryid == 0 { // categoryid 0 is the value the customer app rejects outright. // Refusing it here means a bad row is skipped rather than hiding a // product from every shopper. continue } placeholders = append(placeholders, "(?::bigint, ?::bigint, ?::bigint)") args = append(args, u.Productid, u.Categoryid, u.Subcategoryid) } if len(placeholders) == 0 { return 0, nil } // subcategoryid is written only when the caller sent one. // // It is the field the customer app groups by, so a 0 arriving from a caller // that does not know about aisles would silently move a product back into // the app's "Uncategorized" bucket. Zero therefore means "leave it alone", // the same rule UpdateProductCategory already follows. args = append(args, tenantID) tx := r.db.Exec(` UPDATE products p SET categoryid = v.categoryid, subcategoryid = CASE WHEN v.subcategoryid > 0 THEN v.subcategoryid ELSE p.subcategoryid END FROM (VALUES `+strings.Join(placeholders, ",")+`) AS v(productid, categoryid, subcategoryid) WHERE p.productid = v.productid AND p.tenantid = ? `, args...) return tx.RowsAffected, tx.Error } // EnsureTenantCategories maps category NAMES to this tenant's category ids, // creating any that do not exist yet, and is safe to call repeatedly. // // The console resolves a category name from a product's title using a // deterministic ladder, but the customer app browses by categoryid and rejects // 0 outright — so a name is useless until it has an id. This is the bridge. // // ── Why the id is computed rather than defaulted ──────────────────────────── // // productcategories.categoryid is a plain bigint: no identity, no default, no // sequence. Every insert has to supply one, so two shops uploading a sheet at // the same moment would both read the same MAX and write the same id. // // Both halves of the guard matter: // // - pg_advisory_xact_lock serialises callers on one key for the length of the // transaction, so the read-then-write cannot interleave. It is released // when the transaction ends, including on rollback. // - ON CONFLICT DO NOTHING makes the insert idempotent against the unique // name-per-tenant a caller may add later, and harmless if two requests // somehow race anyway. // // Names are matched case-insensitively on trimmed text: "Dairy" and "dairy " // are one category, because a merchant's sheet will spell it both ways and two // ids for one aisle splits their shop in the app. func (r *productRepository) EnsureTenantCategories(tenantID int, names []string) (map[string]int, error) { out := make(map[string]int) if tenantID == 0 || len(names) == 0 { return out, nil } // Deduplicate on the same key the lookup uses, so one insert per aisle. wanted := make([]string, 0, len(names)) seen := make(map[string]bool) for _, name := range names { trimmed := strings.TrimSpace(name) if trimmed == "" { continue } key := strings.ToLower(trimmed) if seen[key] { continue } seen[key] = true wanted = append(wanted, trimmed) } if len(wanted) == 0 { return out, nil } // A parameterised VALUES list rather than a Postgres array literal. Two // reasons: no array driver is vendored here, and "Pulses, Grains & Spices" // contains a comma — any delimiter-joined string would split it into three // categories and file a bag of dal under "Grains". placeholders := make([]string, 0, len(wanted)) args := make([]any, 0, len(wanted)) for _, name := range wanted { placeholders = append(placeholders, "(?)") args = append(args, name) } values := strings.Join(placeholders, ",") tx := r.db.Begin() if tx.Error != nil { return nil, tx.Error } defer func() { if rec := recover(); rec != nil { tx.Rollback() } }() // One key for every caller of this function. Held until commit or rollback. if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", categoryLockKey).Error; err != nil { tx.Rollback() return nil, err } // Insert whatever is missing, numbering from the current maximum. The whole // batch is one statement, so the MAX is read once and cannot drift mid-way. insertArgs := append(append([]any{}, args...), tenantID, tenantID) if err := tx.Exec(` WITH incoming(categoryname) AS (VALUES `+values+`), numbered AS ( SELECT trim(categoryname) AS categoryname, row_number() OVER (ORDER BY trim(categoryname)) AS n FROM incoming ), missing AS ( SELECT n.categoryname, row_number() OVER (ORDER BY n.n) AS seq FROM numbered n WHERE NOT EXISTS ( SELECT 1 FROM productcategories pc WHERE pc.tenantid = ? AND lower(trim(pc.categoryname)) = lower(n.categoryname) ) ) INSERT INTO productcategories (categoryid, tenantid, moduleid, categoryname, sortorder, status, created) SELECT COALESCE((SELECT MAX(categoryid) FROM productcategories), 0) + m.seq, ?, 2, m.categoryname, m.seq, 'Active', now() FROM missing m ON CONFLICT DO NOTHING `, insertArgs...).Error; err != nil { tx.Rollback() return nil, err } var rows []models.TenantCategory selectArgs := append([]any{tenantID}, args...) if err := tx.Raw(` SELECT categoryid, categoryname FROM productcategories WHERE tenantid = ? AND lower(trim(categoryname)) IN ( SELECT lower(trim(categoryname)) FROM (VALUES `+values+`) AS w(categoryname) ) `, selectArgs...).Scan(&rows).Error; err != nil { tx.Rollback() return nil, err } if err := tx.Commit().Error; err != nil { return nil, err } for _, row := range rows { out[strings.ToLower(strings.TrimSpace(row.Categoryname))] = row.Categoryid } return out, nil } // The advisory-lock key for category creation. An arbitrary constant — it only // has to be the same number in every caller and different from other locks. const categoryLockKey = 8710431 func (r *productRepository) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) { categories := make([]models.TenantCategory, 0) // Two sources, unioned. // // The first is the categories this tenant's products actually sit in, which // is what this always returned and is still the important half: it is the // only thing that describes a shop stocked before productcategories had rows // for it, and its 'Category N' fallback names those orphans rather than // dropping them. // // The second is the tenant's own rows in productcategories. Without it a // category created by an import is invisible until a product lands in it — // so the import picker offering "where shall I file this?" could not offer // the aisle it had just opened. err := r.db.Raw(` SELECT categoryid, categoryname FROM ( SELECT DISTINCT p.categoryid, COALESCE(NULLIF(pc.categoryname, ''), 'Category ' || p.categoryid) AS categoryname FROM products p LEFT JOIN productcategories pc ON pc.categoryid = p.categoryid WHERE p.tenantid = ? AND p.categoryid != 0 UNION SELECT c.categoryid, c.categoryname FROM productcategories c WHERE c.tenantid = ? AND c.status = 'Active' AND COALESCE(c.categoryname, '') != '' ) AS merged ORDER BY categoryname `, tenantid, tenantid).Scan(&categories).Error return categories, err } // UpdateProductPricing updates only the pricing fields on a product // snapshot, used when a catalogue product is re-imported with new pricing. // UpdateProductCategory files a product under a category after the fact. // // It exists because nothing else could. `UpdateProduct` writes only // productlocations.status despite its name, and re-importing a product the // tenant already has took the `existing != nil` branch, which corrected the // pricing and left the category as it was. So a product imported with // categoryid 0 was permanently invisible to the customer app — the endpoint it // browses rejects categoryid 0 outright — with no API able to repair it. Seven // products across three tenants were in that state, six outlets showing an // empty shop to shoppers while the console listed their stock. // // A zero is never written. Callers pass whatever the import request carried, // and a request that omits the category must not erase one that is already // correct — the guard belongs here rather than in each caller. func (r *productRepository) UpdateProductCategory(productid, categoryid, subcategoryid int) error { if categoryid <= 0 { return nil } updates := map[string]interface{}{"categoryid": categoryid} if subcategoryid > 0 { updates["subcategoryid"] = subcategoryid } return r.db.Table("products").Where("productid = ?", productid).Updates(updates).Error } // UpdateProductVariant puts an existing product into a variant group, or takes // it out of one. // // It exists because nothing else could. `products.variants` is the grouping the // ordering screen reads — it is what makes three pack sizes one choice rather // than three unrelated products — and until now it could only ever be set at // CREATE time, by a caller that already knew the group id: // // products/create writes whatever the body carries, variants included // importcatalogueproduct never sets it, so every imported product is 0 // UpdateProduct writes productlocations.status only, despite the name // // Since importing from the catalogue is how products actually arrive, every // product this console creates is ungrouped and there was no call that could // change that. Groups could be created (createproductvariant) and never used. // // Zero is allowed here, unlike UpdateProductCategory: ungrouping a product is a // real thing to want, and 0 is what ungrouped means. The guard that matters for // this column lives in the read path, which refuses to treat 0 as a group. func (r *productRepository) UpdateProductVariant(productid, variantid int) error { if productid <= 0 { return fmt.Errorf("productid is required") } if variantid < 0 { variantid = 0 } return r.db.Table("products"). Where("productid = ?", productid). Update("variants", variantid).Error } func (r *productRepository) UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error { return r.db.Table("products"). Where("productid = ?", productid). Updates(map[string]interface{}{ "retailprice": retailprice, "productcost": productcost, "taxpercent": taxpercent, }).Error }