product categories
This commit is contained in:
@@ -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 {
|
func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
|
||||||
var input struct {
|
var input struct {
|
||||||
Tenantid int `json:"tenantid"`
|
Tenantid int `json:"tenantid"`
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type ProductRepository interface {
|
|||||||
GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error)
|
GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error)
|
||||||
GetProductCount(tenantID, categoryID, subcategoryID int, approve string) ([]models.Productcount, error)
|
GetProductCount(tenantID, categoryID, subcategoryID int, approve string) ([]models.Productcount, error)
|
||||||
GetProductCategory() ([]models.ProductCategory, error)
|
GetProductCategory() ([]models.ProductCategory, error)
|
||||||
|
EnsureTenantCategories(tenantID int, names []string) (map[string]int, error)
|
||||||
GetProductVariants(tenantID, subcategoryID int) ([]models.Productvariant, error)
|
GetProductVariants(tenantID, subcategoryID int) ([]models.Productvariant, error)
|
||||||
GetCatalougeProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Products, error)
|
GetCatalougeProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Products, error)
|
||||||
GetProductStocks(tenantID, locationID string) ([]models.Productstocks, 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
|
// row despite being in real use). This is deliberately not the global
|
||||||
// productcategories list: that list can omit categoryids tenants actually
|
// productcategories list: that list can omit categoryids tenants actually
|
||||||
// have products in, which would make the import category picker unusable.
|
// 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) {
|
func (r *productRepository) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) {
|
||||||
categories := make([]models.TenantCategory, 0)
|
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(`
|
err := r.db.Raw(`
|
||||||
SELECT DISTINCT p.categoryid,
|
SELECT categoryid, categoryname FROM (
|
||||||
COALESCE(NULLIF(pc.categoryname, ''), 'Category ' || p.categoryid) AS categoryname
|
SELECT DISTINCT p.categoryid,
|
||||||
FROM products p
|
COALESCE(NULLIF(pc.categoryname, ''), 'Category ' || p.categoryid) AS categoryname
|
||||||
LEFT JOIN productcategories pc ON pc.categoryid = p.categoryid
|
FROM products p
|
||||||
WHERE p.tenantid = ? AND p.categoryid != 0
|
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
|
ORDER BY categoryname
|
||||||
`, tenantid).Scan(&categories).Error
|
`, tenantid, tenantid).Scan(&categories).Error
|
||||||
return categories, err
|
return categories, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
products.Get("/relinkcatalogue", f.ProductController.RelinkCatalogue)
|
products.Get("/relinkcatalogue", f.ProductController.RelinkCatalogue)
|
||||||
|
|
||||||
products.Get("/gettenantcategories", f.ProductController.GetTenantCategories)
|
products.Get("/gettenantcategories", f.ProductController.GetTenantCategories)
|
||||||
|
products.Post("/resolvecategories", f.ProductController.ResolveCategories)
|
||||||
products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation)
|
products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation)
|
||||||
|
|
||||||
// Releasing a product to the shops. Importing no longer does this: a
|
// Releasing a product to the shops. Importing no longer does this: a
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type ProductService interface {
|
|||||||
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
|
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
|
||||||
RelinkCatalogue(tenantid int, dryRun bool) (*RelinkReport, error)
|
RelinkCatalogue(tenantid int, dryRun bool) (*RelinkReport, error)
|
||||||
GetTenantCategories(tenantid int) ([]models.TenantCategory, 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)
|
PublishProduct(tenantID, productID int, price, taxPercent float64) (int, error)
|
||||||
UnpublishProduct(tenantID, productID int) (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)
|
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.
|
// 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
|
// The price check lives in the repository rather than here, so it holds for any
|
||||||
|
|||||||
Reference in New Issue
Block a user