product categories

This commit is contained in:
2026-09-07 17:28:38 +05:30
parent f1b3a5eba4
commit c699a400c3
4 changed files with 222 additions and 6 deletions

View File

@@ -787,6 +787,51 @@ func (ctl *ProductController) GetTenantCategories(c *fiber.Ctx) error {
}) })
} }
// ResolveCategories exchanges category names for this tenant's category ids.
//
// POST, not GET, because it CREATES the categories it cannot find — a sheet
// naming an aisle this shop has never stocked should not fail, it should open
// the aisle. The response maps the name a caller sent to the id it must store.
//
// Keyed on the lowercased, trimmed name, so a caller can look up whatever
// casing its own sheet used without having to guess how it was filed.
func (ctl *ProductController) ResolveCategories(c *fiber.Ctx) error {
var body struct {
Tenantid int `json:"tenantid"`
Names []string `json:"names"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "invalid body",
"status": false,
})
}
if body.Tenantid == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid is required",
"status": false,
})
}
resolved, err := ctl.productService.EnsureTenantCategories(body.Tenantid, body.Names)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"code": http.StatusInternalServerError,
"message": err.Error(),
"status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"details": resolved,
})
}
func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error { func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
var input struct { var input struct {
Tenantid int `json:"tenantid"` Tenantid int `json:"tenantid"`

View File

@@ -17,6 +17,7 @@ type ProductRepository interface {
GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error) GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error)
GetProductCount(tenantID, categoryID, subcategoryID int, approve string) ([]models.Productcount, error) GetProductCount(tenantID, categoryID, subcategoryID int, approve string) ([]models.Productcount, error)
GetProductCategory() ([]models.ProductCategory, error) GetProductCategory() ([]models.ProductCategory, error)
EnsureTenantCategories(tenantID int, names []string) (map[string]int, error)
GetProductVariants(tenantID, subcategoryID int) ([]models.Productvariant, error) GetProductVariants(tenantID, subcategoryID int) ([]models.Productvariant, error)
GetCatalougeProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Products, error) GetCatalougeProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Products, error)
GetProductStocks(tenantID, locationID string) ([]models.Productstocks, 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 // row despite being in real use). This is deliberately not the global
// productcategories list: that list can omit categoryids tenants actually // productcategories list: that list can omit categoryids tenants actually
// have products in, which would make the import category picker unusable. // 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) { func (r *productRepository) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) {
categories := make([]models.TenantCategory, 0) 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(` err := r.db.Raw(`
SELECT DISTINCT p.categoryid, SELECT categoryid, categoryname FROM (
COALESCE(NULLIF(pc.categoryname, ''), 'Category ' || p.categoryid) AS categoryname SELECT DISTINCT p.categoryid,
FROM products p COALESCE(NULLIF(pc.categoryname, ''), 'Category ' || p.categoryid) AS categoryname
LEFT JOIN productcategories pc ON pc.categoryid = p.categoryid FROM products p
WHERE p.tenantid = ? AND p.categoryid != 0 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 ORDER BY categoryname
`, tenantid).Scan(&categories).Error `, tenantid, tenantid).Scan(&categories).Error
return categories, err return categories, err
} }

View File

@@ -36,6 +36,7 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
products.Get("/relinkcatalogue", f.ProductController.RelinkCatalogue) products.Get("/relinkcatalogue", f.ProductController.RelinkCatalogue)
products.Get("/gettenantcategories", f.ProductController.GetTenantCategories) products.Get("/gettenantcategories", f.ProductController.GetTenantCategories)
products.Post("/resolvecategories", f.ProductController.ResolveCategories)
products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation) products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation)
// Releasing a product to the shops. Importing no longer does this: a // Releasing a product to the shops. Importing no longer does this: a

View File

@@ -40,6 +40,7 @@ type ProductService interface {
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
RelinkCatalogue(tenantid int, dryRun bool) (*RelinkReport, error) RelinkCatalogue(tenantid int, dryRun bool) (*RelinkReport, error)
GetTenantCategories(tenantid int) ([]models.TenantCategory, error) GetTenantCategories(tenantid int) ([]models.TenantCategory, error)
EnsureTenantCategories(tenantid int, names []string) (map[string]int, error)
PublishProduct(tenantID, productID int, price, taxPercent float64) (int, error) PublishProduct(tenantID, productID int, price, taxPercent float64) (int, error)
UnpublishProduct(tenantID, productID int) (int, error) UnpublishProduct(tenantID, productID int) (int, error)
} }
@@ -499,6 +500,17 @@ func (s *productService) GetTenantCategories(tenantid int) ([]models.TenantCateg
return s.repo.GetTenantCategories(tenantid) return s.repo.GetTenantCategories(tenantid)
} }
// EnsureTenantCategories turns category NAMES into this tenant's category ids,
// creating the ones that do not exist yet.
//
// The console works out a product's category from its title with a
// deterministic ladder, but the customer app browses by categoryid and rejects
// 0 — so an import has to exchange its names for ids before it creates
// anything. Idempotent: importing the same sheet twice creates no duplicates.
func (s *productService) EnsureTenantCategories(tenantid int, names []string) (map[string]int, error) {
return s.repo.EnsureTenantCategories(tenantid, names)
}
// PublishProduct releases a product to every outlet the tenant runs. // PublishProduct releases a product to every outlet the tenant runs.
// //
// The price check lives in the repository rather than here, so it holds for any // The price check lives in the repository rather than here, so it holds for any