1125 lines
41 KiB
Go
1125 lines
41 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
|
|
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 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
|
|
FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*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
|
|
}
|
|
|
|
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, 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
|
|
}
|
|
|
|
// 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
|
|
|
|
params := []interface{}{tenantID, 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 "—".
|
|
query := `SELECT a.*, b.productlocationid, b.status,
|
|
COALESCE(NULLIF(b.price, 0), a.retailprice, 0) AS price,
|
|
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 = ? 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)+"%")
|
|
}
|
|
|
|
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, b.productlocationid, b.status, b.price
|
|
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.
|
|
query := r.db.
|
|
Table("products a").
|
|
Select(`
|
|
a.*,
|
|
b.status,
|
|
b.locationid,
|
|
c.categoryname,
|
|
d.subcatname AS subcategoryname,
|
|
COALESCE(ps.quantity, 0) AS productstock,
|
|
COALESCE(ps.quantity, 0) AS quantity
|
|
`).
|
|
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
|
|
}
|
|
|
|
func (r *productRepository) GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error) {
|
|
|
|
var data []models.Products
|
|
|
|
// 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.
|
|
err := 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((
|
|
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).
|
|
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).
|
|
Where("p.tenantid = ? AND p.variants = ?", tenantid, variantid).
|
|
Order("p.productid DESC").
|
|
Scan(&data).Error
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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").
|
|
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.
|
|
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
|
|
}
|