Make subcategory optional and fix category picker for real tenant data

subcategoryid is no longer required to import a catalogue product —
it's a display/grouping hint elsewhere (the codebase already has an
"Uncategorized" fallback for subcategoryid=0), so requiring it was
pure friction with no correctness payoff.

Fixes the category picker at the root: categoryid 2, which tenant
1135's real products actually use, has no row in productcategories at
all (not a filter bug — the master data is genuinely missing it).
Rather than inventing category master data, adds
GET /products/gettenantcategories, which lists categories a tenant's
own products actually use (falling back to a synthesized label when
the master table has no name), so the import category picker always
offers something real instead of an incomplete global list.

Also relaxes the subcategory lookup's tenant filter to include
unowned/global rows (tenantid NULL or 0), not just exact tenant
matches — categoryid 2's real subcategories carry no tenant at all,
so the strict filter was hiding them even when a tenant wanted one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-07-16 18:18:51 +05:30
parent fab06bb33e
commit a9c13292f0
5 changed files with 76 additions and 4 deletions

View File

@@ -535,10 +535,10 @@ func (ctl *ProductController) ImportCatalogueProduct(c *fiber.Ctx) error {
} }
for _, req := range data { for _, req := range data {
if req.Tenantid == 0 || req.Locationid == 0 || req.Brand == "" || req.Catalogueid == 0 || req.Categoryid == 0 || req.Subcategoryid == 0 { if req.Tenantid == 0 || req.Locationid == 0 || req.Brand == "" || req.Catalogueid == 0 || req.Categoryid == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{ return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "code": http.StatusBadRequest,
"message": "tenantid, locationid, brand, catalogueid, categoryid, and subcategoryid are required", "message": "tenantid, locationid, brand, catalogueid, and categoryid are required",
"status": false, "status": false,
}) })
} }
@@ -590,6 +590,33 @@ func (ctl *ProductController) GetImportedCatalogueProducts(c *fiber.Ctx) error {
}) })
} }
func (ctl *ProductController) GetTenantCategories(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
if tenantID == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid is required",
"status": false,
})
}
categories, err := ctl.productService.GetTenantCategories(tenantID)
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": categories,
})
}
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

@@ -256,6 +256,15 @@ type Subcategory struct {
Image string `json:"image" gorm:"column:image"` Image string `json:"image" gorm:"column:image"`
} }
// TenantCategory is a categoryid actually in use by a tenant's own products,
// with a best-effort name. Used instead of the global productcategories list
// for the import category picker, since that master table is missing rows
// for categoryids that are nonetheless in real use (e.g. categoryid 2).
type TenantCategory struct {
Categoryid int `json:"categoryid"`
Categoryname string `json:"categoryname"`
}
// ImportedCatalogueRef identifies a catalogue product a tenant has already // ImportedCatalogueRef identifies a catalogue product a tenant has already
// imported. Brand is always included, even when a caller filtered by a // imported. Brand is always included, even when a caller filtered by a
// single brand, because a bare catalogueid is ambiguous across brand tables. // single brand, because a bare catalogueid is ambiguous across brand tables.

View File

@@ -40,6 +40,7 @@ type ProductRepository interface {
FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*models.Products, error) FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*models.Products, error)
CreateProductReturningID(product models.Products) (int, error) CreateProductReturningID(product models.Products) (int, error)
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
GetTenantCategories(tenantid int) ([]models.TenantCategory, error)
UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error
} }
@@ -56,10 +57,18 @@ func (r *productRepository) GetProductSubCategory(categoryID, tenantID int) ([]m
var query strings.Builder var query strings.Builder
var args []interface{} var args []interface{}
query.WriteString("SELECT * FROM productsubcategories WHERE 1=1") // tenantid is selected via COALESCE (not SELECT *) because the relaxed
// filter below can now return rows where it's NULL, which won't scan
// into the model's non-pointer int field otherwise.
query.WriteString(`SELECT subcatid, categoryid, COALESCE(tenantid, 0) AS tenantid,
subcatname, image, status, sortorder, createdby, created, updated
FROM productsubcategories WHERE 1=1`)
if tenantID != 0 { if tenantID != 0 {
query.WriteString(" AND tenantid = ?") // Some subcategories are tenant-owned overrides, others are shared
// master data with no tenant attached (tenantid NULL/0) — match both
// so a tenant sees the global set in addition to their own.
query.WriteString(" AND (tenantid = ? OR tenantid IS NULL OR tenantid = 0)")
args = append(args, tenantID) args = append(args, tenantID)
} }
if categoryID != 0 { if categoryID != 0 {
@@ -804,6 +813,26 @@ func (r *productRepository) GetImportedCatalogueRefs(tenantid int, brand string)
return refs, err return refs, err
} }
// GetTenantCategories returns the distinct categoryids this tenant's own
// products actually use, LEFT JOINed against productcategories for a name
// (falling back to a synthesized label when that master row is missing —
// it's incomplete in practice, e.g. categoryid 2 has no productcategories
// 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.
func (r *productRepository) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) {
categories := make([]models.TenantCategory, 0)
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
ORDER BY categoryname
`, tenantid).Scan(&categories).Error
return categories, err
}
// UpdateProductPricing updates only the pricing fields on a product // UpdateProductPricing updates only the pricing fields on a product
// snapshot, used when a catalogue product is re-imported with new pricing. // snapshot, used when a catalogue product is re-imported with new pricing.
func (r *productRepository) UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error { func (r *productRepository) UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error {

View File

@@ -28,6 +28,7 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
products.Post("/createproductlocation", f.ProductController.CreateProductLocation) products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct) products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts) products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
products.Get("/gettenantcategories", f.ProductController.GetTenantCategories)
products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation) products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation)
products.Post("/createproductvariant", f.ProductController.CreateProductVariant) products.Post("/createproductvariant", f.ProductController.CreateProductVariant)
@@ -46,5 +47,6 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation) products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct) products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts) products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
products.Get("/gettenantcategories", f.ProductController.GetTenantCategories)
} }

View File

@@ -31,6 +31,7 @@ type ProductService interface {
DeleteProductLocation(tenantid, locationid, productid int) error DeleteProductLocation(tenantid, locationid, productid int) error
ImportCatalogueProduct(reqs []models.ImportCatalogueProductRequest) error ImportCatalogueProduct(reqs []models.ImportCatalogueProductRequest) error
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
GetTenantCategories(tenantid int) ([]models.TenantCategory, error)
} }
type productService struct { type productService struct {
repo repositories.ProductRepository repo repositories.ProductRepository
@@ -288,3 +289,7 @@ func (s *productService) GetImportedCatalogueRefs(tenantid int, brand string) ([
return s.repo.GetImportedCatalogueRefs(tenantid, brand) return s.repo.GetImportedCatalogueRefs(tenantid, brand)
} }
func (s *productService) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) {
return s.repo.GetTenantCategories(tenantid)
}