|
|
|
|
@@ -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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|