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 {
|
||||
var input struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
|
||||
@@ -17,6 +17,7 @@ type ProductRepository interface {
|
||||
GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error)
|
||||
GetProductCount(tenantID, categoryID, subcategoryID int, approve string) ([]models.Productcount, error)
|
||||
GetProductCategory() ([]models.ProductCategory, error)
|
||||
EnsureTenantCategories(tenantID int, names []string) (map[string]int, 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)
|
||||
@@ -1403,16 +1404,173 @@ 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.
|
||||
// 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) {
|
||||
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(`
|
||||
SELECT categoryid, categoryname FROM (
|
||||
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
|
||||
|
||||
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
|
||||
`, tenantid).Scan(&categories).Error
|
||||
`, tenantid, tenantid).Scan(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
|
||||
products.Get("/relinkcatalogue", f.ProductController.RelinkCatalogue)
|
||||
|
||||
products.Get("/gettenantcategories", f.ProductController.GetTenantCategories)
|
||||
products.Post("/resolvecategories", f.ProductController.ResolveCategories)
|
||||
products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation)
|
||||
|
||||
// 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)
|
||||
RelinkCatalogue(tenantid int, dryRun bool) (*RelinkReport, 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)
|
||||
UnpublishProduct(tenantID, productID int) (int, error)
|
||||
}
|
||||
@@ -499,6 +500,17 @@ func (s *productService) GetTenantCategories(tenantid int) ([]models.TenantCateg
|
||||
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.
|
||||
//
|
||||
// The price check lives in the repository rather than here, so it holds for any
|
||||
|
||||
Reference in New Issue
Block a user