product categories
This commit is contained in:
@@ -17,6 +17,7 @@ type ProductRepository interface {
|
||||
GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error)
|
||||
GetProductCount(tenantID, categoryID, subcategoryID int, approve string) ([]models.Productcount, error)
|
||||
GetProductCategory() ([]models.ProductCategory, error)
|
||||
EnsureTenantCategories(tenantID int, names []string) (map[string]int, error)
|
||||
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)
|
||||
@@ -1403,16 +1404,173 @@ func (r *productRepository) GetImportedCatalogueRefs(tenantid int, brand string)
|
||||
// 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.
|
||||
// EnsureTenantCategories maps category NAMES to this tenant's category ids,
|
||||
// creating any that do not exist yet, and is safe to call repeatedly.
|
||||
//
|
||||
// The console resolves a category name from a product's title using a
|
||||
// deterministic ladder, but the customer app browses by categoryid and rejects
|
||||
// 0 outright — so a name is useless until it has an id. This is the bridge.
|
||||
//
|
||||
// ── Why the id is computed rather than defaulted ────────────────────────────
|
||||
//
|
||||
// productcategories.categoryid is a plain bigint: no identity, no default, no
|
||||
// sequence. Every insert has to supply one, so two shops uploading a sheet at
|
||||
// the same moment would both read the same MAX and write the same id.
|
||||
//
|
||||
// Both halves of the guard matter:
|
||||
//
|
||||
// - pg_advisory_xact_lock serialises callers on one key for the length of the
|
||||
// transaction, so the read-then-write cannot interleave. It is released
|
||||
// when the transaction ends, including on rollback.
|
||||
// - ON CONFLICT DO NOTHING makes the insert idempotent against the unique
|
||||
// name-per-tenant a caller may add later, and harmless if two requests
|
||||
// somehow race anyway.
|
||||
//
|
||||
// Names are matched case-insensitively on trimmed text: "Dairy" and "dairy "
|
||||
// are one category, because a merchant's sheet will spell it both ways and two
|
||||
// ids for one aisle splits their shop in the app.
|
||||
func (r *productRepository) EnsureTenantCategories(tenantID int, names []string) (map[string]int, error) {
|
||||
out := make(map[string]int)
|
||||
if tenantID == 0 || len(names) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Deduplicate on the same key the lookup uses, so one insert per aisle.
|
||||
wanted := make([]string, 0, len(names))
|
||||
seen := make(map[string]bool)
|
||||
for _, name := range names {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(trimmed)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
wanted = append(wanted, trimmed)
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// A parameterised VALUES list rather than a Postgres array literal. Two
|
||||
// reasons: no array driver is vendored here, and "Pulses, Grains & Spices"
|
||||
// contains a comma — any delimiter-joined string would split it into three
|
||||
// categories and file a bag of dal under "Grains".
|
||||
placeholders := make([]string, 0, len(wanted))
|
||||
args := make([]any, 0, len(wanted))
|
||||
for _, name := range wanted {
|
||||
placeholders = append(placeholders, "(?)")
|
||||
args = append(args, name)
|
||||
}
|
||||
values := strings.Join(placeholders, ",")
|
||||
|
||||
tx := r.db.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// One key for every caller of this function. Held until commit or rollback.
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", categoryLockKey).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Insert whatever is missing, numbering from the current maximum. The whole
|
||||
// batch is one statement, so the MAX is read once and cannot drift mid-way.
|
||||
insertArgs := append(append([]any{}, args...), tenantID, tenantID)
|
||||
if err := tx.Exec(`
|
||||
WITH incoming(categoryname) AS (VALUES `+values+`),
|
||||
numbered AS (
|
||||
SELECT trim(categoryname) AS categoryname,
|
||||
row_number() OVER (ORDER BY trim(categoryname)) AS n
|
||||
FROM incoming
|
||||
),
|
||||
missing AS (
|
||||
SELECT n.categoryname, row_number() OVER (ORDER BY n.n) AS seq
|
||||
FROM numbered n
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM productcategories pc
|
||||
WHERE pc.tenantid = ?
|
||||
AND lower(trim(pc.categoryname)) = lower(n.categoryname)
|
||||
)
|
||||
)
|
||||
INSERT INTO productcategories
|
||||
(categoryid, tenantid, moduleid, categoryname, sortorder, status, created)
|
||||
SELECT
|
||||
COALESCE((SELECT MAX(categoryid) FROM productcategories), 0) + m.seq,
|
||||
?, 2, m.categoryname, m.seq, 'Active', now()
|
||||
FROM missing m
|
||||
ON CONFLICT DO NOTHING
|
||||
`, insertArgs...).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []models.TenantCategory
|
||||
selectArgs := append([]any{tenantID}, args...)
|
||||
if err := tx.Raw(`
|
||||
SELECT categoryid, categoryname
|
||||
FROM productcategories
|
||||
WHERE tenantid = ?
|
||||
AND lower(trim(categoryname)) IN (
|
||||
SELECT lower(trim(categoryname)) FROM (VALUES `+values+`) AS w(categoryname)
|
||||
)
|
||||
`, selectArgs...).Scan(&rows).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
out[strings.ToLower(strings.TrimSpace(row.Categoryname))] = row.Categoryid
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// The advisory-lock key for category creation. An arbitrary constant — it only
|
||||
// has to be the same number in every caller and different from other locks.
|
||||
const categoryLockKey = 8710431
|
||||
|
||||
func (r *productRepository) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) {
|
||||
categories := make([]models.TenantCategory, 0)
|
||||
// Two sources, unioned.
|
||||
//
|
||||
// The first is the categories this tenant's products actually sit in, which
|
||||
// is what this always returned and is still the important half: it is the
|
||||
// only thing that describes a shop stocked before productcategories had rows
|
||||
// for it, and its 'Category N' fallback names those orphans rather than
|
||||
// dropping them.
|
||||
//
|
||||
// The second is the tenant's own rows in productcategories. Without it a
|
||||
// category created by an import is invisible until a product lands in it —
|
||||
// so the import picker offering "where shall I file this?" could not offer
|
||||
// the aisle it had just opened.
|
||||
err := r.db.Raw(`
|
||||
SELECT 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
|
||||
SELECT categoryid, categoryname FROM (
|
||||
SELECT DISTINCT p.categoryid,
|
||||
COALESCE(NULLIF(pc.categoryname, ''), 'Category ' || p.categoryid) AS categoryname
|
||||
FROM products p
|
||||
LEFT JOIN productcategories pc ON pc.categoryid = p.categoryid
|
||||
WHERE p.tenantid = ? AND p.categoryid != 0
|
||||
|
||||
UNION
|
||||
|
||||
SELECT c.categoryid, c.categoryname
|
||||
FROM productcategories c
|
||||
WHERE c.tenantid = ? AND c.status = 'Active' AND COALESCE(c.categoryname, '') != ''
|
||||
) AS merged
|
||||
ORDER BY categoryname
|
||||
`, tenantid).Scan(&categories).Error
|
||||
`, tenantid, tenantid).Scan(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user