Files
backend_fiesta/repositories/productRepository.go
2026-09-03 10:43:05 +05:30

1482 lines
58 KiB
Go

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)
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",
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.
func (r *productRepository) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) {
categories := make([]models.TenantCategory, 0)
err := r.db.Raw(`
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
ORDER BY categoryname
`, 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
}