console categories
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user