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:
@@ -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"`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user