Stock shown in the console did not match the productstocks ledger, and two product endpoints were failing outright. Every cause was on the read side or in how the availability flag was maintained; the ledger writes themselves (CreateOrder's "out" entry, cancellation's "in" entry) were already correct. Read fixes, repositories/productRepository.go: - GetProductStocks returned SQLSTATE 42803 on every call: bare a.tenantid / a.stocktype / a.status under GROUP BY a.productid. The per-ledger-row columns are now aggregated and the grouping covers the identity columns. - FetchFilteredProducts filtered on an alias `e` that no query defines, so every /getallproducts call carrying a locationid failed with SQLSTATE 42P01 instead of returning products. - FetchFilteredProducts joined productlocations on productid alone and joined a (productid, locationid)-grouped stock subquery on productid alone, so a product carried by three outlets came back three times, each row showing another outlet's quantity and status. Both are now tenant-scoped subqueries collapsed to one row per product and scoped to the outlet when one is given. - GetProductStocks and FetchFilteredProducts compared stocktype = 'in' case-sensitively. Production holds 'in' and 'IN' both, so uppercase receipts were silently dropped from the balance: one outlet reported 0 for a product holding 50, another reported 0 for twelve products holding 200-840. - GetStockStatement summed opening over stockdate <= CURRENT_DATE, making it arithmetically identical to closing. The Inventory ledger showed the same number in both columns on every row, which reads as stock never moving. Availability flag: productlocations.status was maintained by two different rules — the order path derived it from the balance, the receiving path set 'available' on any "in" entry regardless of the resulting balance. A partial restock that left the balance at or below zero marked a product sellable, and a flag set by an old order never cleared for stock that arrived by a route the API did not own. Both paths now derive the flag from the live balance through one rule: SyncProductLocationStatus (receiving side) and syncProductLocationStatus (order side, inside the caller's transaction). ReactivateProductLocations is replaced by the former; the service no longer filters refs by stocktype, since the direction of the movement is no longer what decides the flag. A row that has already drifted now repairs itself on its next ledger entry. Verified against the live database: all four stock endpoints return matching balances, /getallproducts no longer duplicates rows, and the flag sync was exercised in both directions inside a rolled-back transaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
982 lines
34 KiB
Go
982 lines
34 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)
|
|
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
|
|
|
|
baseQuery := `
|
|
SELECT
|
|
COUNT(*) AS total,
|
|
SUM(CASE WHEN a.productstatus = 'available' THEN 1 ELSE 0 END) AS available,
|
|
SUM(CASE WHEN a.productstatus = 'outofstock' THEN 1 ELSE 0 END) AS outofstock
|
|
FROM products a
|
|
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.
|
|
query := `SELECT a.*, b.productlocationid, b.status, b.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
|
|
}
|
|
|
|
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").
|
|
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.
|
|
err := q.Select(`
|
|
a.*,
|
|
COALESCE(pd.discountvalue, 0) AS discountvalue,
|
|
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).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
|
|
}
|