From c699a400c317197d6a32cfae7e3d549e7b0389dd Mon Sep 17 00:00:00 2001 From: abhishek Date: Mon, 7 Sep 2026 17:28:38 +0530 Subject: [PATCH] product categories --- controllers/productController.go | 45 ++++++++ repositories/productRepository.go | 170 ++++++++++++++++++++++++++++-- routes/productroutes.go | 1 + services/productService.go | 12 +++ 4 files changed, 222 insertions(+), 6 deletions(-) diff --git a/controllers/productController.go b/controllers/productController.go index 9209502..82b1f89 100644 --- a/controllers/productController.go +++ b/controllers/productController.go @@ -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 { var input struct { Tenantid int `json:"tenantid"` diff --git a/repositories/productRepository.go b/repositories/productRepository.go index 014b8ae..d95ff68 100644 --- a/repositories/productRepository.go +++ b/repositories/productRepository.go @@ -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 } diff --git a/routes/productroutes.go b/routes/productroutes.go index f370dac..97004e0 100644 --- a/routes/productroutes.go +++ b/routes/productroutes.go @@ -36,6 +36,7 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) { products.Get("/relinkcatalogue", f.ProductController.RelinkCatalogue) products.Get("/gettenantcategories", f.ProductController.GetTenantCategories) + products.Post("/resolvecategories", f.ProductController.ResolveCategories) products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation) // Releasing a product to the shops. Importing no longer does this: a diff --git a/services/productService.go b/services/productService.go index 5ceb264..ac87c8c 100644 --- a/services/productService.go +++ b/services/productService.go @@ -40,6 +40,7 @@ type ProductService interface { GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) RelinkCatalogue(tenantid int, dryRun bool) (*RelinkReport, 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) UnpublishProduct(tenantID, productID int) (int, error) } @@ -499,6 +500,17 @@ func (s *productService) GetTenantCategories(tenantid int) ([]models.TenantCateg 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. // // The price check lives in the repository rather than here, so it holds for any