From e87f09c4f2a84b7dd20bde31e6f3f37f9fda7b35 Mon Sep 17 00:00:00 2001 From: abhishek Date: Mon, 7 Sep 2026 17:53:35 +0530 Subject: [PATCH] console categories --- controllers/productController.go | 42 +++++++++++++++++++++++++ models/product.go | 7 +++++ repositories/productRepository.go | 51 +++++++++++++++++++++++++++++++ routes/productroutes.go | 1 + services/productService.go | 6 ++++ 5 files changed, 107 insertions(+) diff --git a/controllers/productController.go b/controllers/productController.go index 82b1f89..e22dda4 100644 --- a/controllers/productController.go +++ b/controllers/productController.go @@ -787,6 +787,48 @@ func (ctl *ProductController) GetTenantCategories(c *fiber.Ctx) error { }) } +// Recategorise re-files a tenant's products in bulk. +// +// PUT and not POST: it changes rows that already exist and creates none. The +// response carries how many rows actually moved, which is the only way a caller +// can tell "all done" from "every id I sent was wrong". +func (ctl *ProductController) Recategorise(c *fiber.Ctx) error { + var body struct { + Tenantid int `json:"tenantid"` + Updates []models.ProductCategoryUpdate `json:"updates"` + } + 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, + }) + } + + moved, err := ctl.productService.RecategoriseProducts(body.Tenantid, body.Updates) + 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": fiber.Map{"moved": moved}, + }) +} + // ResolveCategories exchanges category names for this tenant's category ids. // // POST, not GET, because it CREATES the categories it cannot find — a sheet diff --git a/models/product.go b/models/product.go index 3a2eee4..5926fa6 100644 --- a/models/product.go +++ b/models/product.go @@ -21,6 +21,13 @@ type Productcount struct { Outofstock int `json:"outofstock"` } +// ProductCategoryUpdate is one row of a bulk re-filing: which product, and +// which category it should sit in. +type ProductCategoryUpdate struct { + Productid int `json:"productid"` + Categoryid int `json:"categoryid"` +} + type ProductCategory struct { Categoryid int `json:"categoryid"` Moduleid int `json:"moduleid"` diff --git a/repositories/productRepository.go b/repositories/productRepository.go index d95ff68..9c51c18 100644 --- a/repositories/productRepository.go +++ b/repositories/productRepository.go @@ -18,6 +18,7 @@ type ProductRepository interface { GetProductCount(tenantID, categoryID, subcategoryID int, approve string) ([]models.Productcount, error) GetProductCategory() ([]models.ProductCategory, error) EnsureTenantCategories(tenantID int, names []string) (map[string]int, error) + RecategoriseProducts(tenantID int, updates []models.ProductCategoryUpdate) (int64, 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) @@ -1404,6 +1405,56 @@ 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. +// RecategoriseProducts moves products into different categories, in bulk. +// +// Written for the backfill that re-files everything imported before categories +// existed: those rows all carry whichever single category the tenant had, while +// the catalogue has known all along what each product actually is. +// +// ── Two things this deliberately does not do ──────────────────────────────── +// +// It does not touch any column but categoryid. UpdateProduct next door runs +// GORM's Updates over a whole struct, which is why it can only be trusted with +// a hand-built partial — a bulk caller sending full rows would write back every +// field it happened to have read, including a price it never meant to change. +// One column, named explicitly, cannot do that. +// +// It is scoped by tenantid in the WHERE clause, not just trusted from the +// caller. A productid is global, so a mistyped id in a list would otherwise +// re-file another merchant's product into a category that does not exist for +// them — invisible to both shops and to the app. +func (r *productRepository) RecategoriseProducts(tenantID int, updates []models.ProductCategoryUpdate) (int64, error) { + if tenantID == 0 || len(updates) == 0 { + return 0, nil + } + + placeholders := make([]string, 0, len(updates)) + args := make([]any, 0, len(updates)*2) + for _, u := range updates { + if u.Productid == 0 || u.Categoryid == 0 { + // categoryid 0 is the value the customer app rejects outright. + // Refusing it here means a bad row is skipped rather than hiding a + // product from every shopper. + continue + } + placeholders = append(placeholders, "(?::bigint, ?::bigint)") + args = append(args, u.Productid, u.Categoryid) + } + if len(placeholders) == 0 { + return 0, nil + } + + args = append(args, tenantID) + tx := r.db.Exec(` + UPDATE products p + SET categoryid = v.categoryid + FROM (VALUES `+strings.Join(placeholders, ",")+`) AS v(productid, categoryid) + WHERE p.productid = v.productid + AND p.tenantid = ? + `, args...) + return tx.RowsAffected, tx.Error +} + // EnsureTenantCategories maps category NAMES to this tenant's category ids, // creating any that do not exist yet, and is safe to call repeatedly. // diff --git a/routes/productroutes.go b/routes/productroutes.go index 97004e0..fb8ebdf 100644 --- a/routes/productroutes.go +++ b/routes/productroutes.go @@ -37,6 +37,7 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) { products.Get("/gettenantcategories", f.ProductController.GetTenantCategories) products.Post("/resolvecategories", f.ProductController.ResolveCategories) + products.Put("/recategorise", f.ProductController.Recategorise) 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 ac87c8c..57b6c42 100644 --- a/services/productService.go +++ b/services/productService.go @@ -41,6 +41,7 @@ type ProductService interface { RelinkCatalogue(tenantid int, dryRun bool) (*RelinkReport, error) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) EnsureTenantCategories(tenantid int, names []string) (map[string]int, error) + RecategoriseProducts(tenantid int, updates []models.ProductCategoryUpdate) (int64, error) PublishProduct(tenantID, productID int, price, taxPercent float64) (int, error) UnpublishProduct(tenantID, productID int) (int, error) } @@ -511,6 +512,11 @@ func (s *productService) EnsureTenantCategories(tenantid int, names []string) (m return s.repo.EnsureTenantCategories(tenantid, names) } +// RecategoriseProducts moves a tenant's products into different categories. +func (s *productService) RecategoriseProducts(tenantid int, updates []models.ProductCategoryUpdate) (int64, error) { + return s.repo.RecategoriseProducts(tenantid, updates) +} + // 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