fix in the product import

This commit is contained in:
2026-08-11 15:49:04 +05:30
parent c179e4d642
commit fba42259ea
7 changed files with 259 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
package controllers package controllers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@@ -691,3 +692,64 @@ func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
"status": true, "status": true,
}) })
} }
// PublishProduct releases a product from the admin catalogue to every outlet.
//
// The two rules this enforces — a price is required, and publishing covers the
// whole tenant — live in the repository, so they hold for any caller rather
// than only for the console form that happens to ask nicely.
func (ctl *ProductController) PublishProduct(c *fiber.Ctx) error {
var input struct {
Tenantid int `json:"tenantid"`
Productid int `json:"productid"`
Price float64 `json:"price"`
}
if err := c.BodyParser(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "Invalid request body", "status": false,
})
}
outlets, err := ctl.productService.PublishProduct(input.Tenantid, input.Productid, input.Price)
if err != nil {
// 400 rather than 500: every failure here is something the caller can
// act on — no price, no outlets, or a product that is not theirs.
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": fmt.Sprintf("Published to %d outlet(s)", outlets),
"details": fiber.Map{"productid": input.Productid, "outlets": outlets},
})
}
// UnpublishProduct withdraws a product from every shop, keeping its rows.
func (ctl *ProductController) UnpublishProduct(c *fiber.Ctx) error {
var input struct {
Tenantid int `json:"tenantid"`
Productid int `json:"productid"`
}
if err := c.BodyParser(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "Invalid request body", "status": false,
})
}
outlets, err := ctl.productService.UnpublishProduct(input.Tenantid, input.Productid)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": fmt.Sprintf("Withdrawn from %d outlet(s)", outlets),
"details": fiber.Map{"productid": input.Productid, "outlets": outlets},
})
}

24
main.go
View File

@@ -78,6 +78,30 @@ func main() {
log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err) log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err)
} }
// When a product became visible to a store, and the only thing that decides
// whether it is.
//
// `productlocations.status` cannot do this job and was never able to:
// syncProductLocationStatus overwrites it with 'available'/'outofstock' on
// every stock movement, so the 'Draft' the import wrote survived on exactly
// one row out of 6,755. A separate column is untouched by that, and "when
// was this published" is worth knowing regardless.
//
// The backfill is not optional and must land in the same deploy. Membership
// of a store catalogue is currently the *existence* of the row, so switching
// the read to `publishedat IS NOT NULL` without this empties every shop on
// the platform at once.
if err := db.DB.Exec(
`ALTER TABLE productlocations ADD COLUMN IF NOT EXISTS publishedat timestamp`).Error; err != nil {
log.Fatal("could not add productlocations.publishedat:", err)
}
if err := db.DB.Exec(`
UPDATE productlocations
SET publishedat = COALESCE(created, NOW())
WHERE publishedat IS NULL`).Error; err != nil {
log.Fatal("could not backfill productlocations.publishedat:", err)
}
f := facade.NewFacade(db.DB, db.CatalogueDB) f := facade.NewFacade(db.DB, db.CatalogueDB)
routes.RegisterRoutes(app, f) routes.RegisterRoutes(app, f)

View File

@@ -161,6 +161,11 @@ type Locationproducts struct {
Approve int `json:"approve" gorm:"default:0"` Approve int `json:"approve" gorm:"default:0"`
// Productstatus string `json:"productstatus" gorm:"default:available"` // Productstatus string `json:"productstatus" gorm:"default:available"`
Status string `json:"status" gorm:"default:outofstock"` Status string `json:"status" gorm:"default:outofstock"`
// Set only when the admin has released this product to the shops; NULL
// while it sits in the admin catalogue awaiting a price. This — not
// `Status` — is what a store view filters on. See models.Productlocations.
Publishedat *time.Time `json:"publishedat"`
} }
type Productstocks struct { type Productstocks struct {
@@ -341,6 +346,20 @@ type Productlocations struct {
Quantity int `json:"quantity" gorm:"<-:false"` Quantity int `json:"quantity" gorm:"<-:false"`
Stocktype string `json:"stocktype" gorm:"<-:false"` Stocktype string `json:"stocktype" gorm:"<-:false"`
Status string `json:"status"` Status string `json:"status"`
// When this product was released to the store, and the only thing that
// decides whether a store user can see it.
//
// NULL means the admin has imported it but not published it: it belongs to
// the admin catalogue alone. Deliberately NOT another `Status` value —
// syncProductLocationStatus rewrites that column on every stock movement,
// which is why the import's 'Draft' survived on one row out of 6,755.
//
// Read-only through this struct (`<-:false`). It is set by Publish and
// cleared by Unpublish, never as a side effect of an ordinary
// product-location write: setting a price must not be able to release a
// product to every shop in the tenant.
Publishedat *time.Time `json:"publishedat" gorm:"column:publishedat;<-:false"`
} }
// ProductLocationRef identifies a single (tenant, location, product) row in // ProductLocationRef identifies a single (tenant, location, product) row in

View File

@@ -0,0 +1,119 @@
package repositories
import (
"fmt"
"gorm.io/gorm"
)
// Releasing a product from the admin catalogue to the shops.
//
// Until this existed there was no such step. The import created a
// `productlocations` row and membership of that table *was* publication, so a
// product reached every store the moment it was imported — unpriced, because
// pricing had not happened yet. That is how the platform ended up with a
// catalogue of products a till could not ring up.
//
// Two rules, both enforced here rather than in the console, because a rule that
// only exists in a form is a rule that holds until somebody calls the API:
//
// 1. **A product cannot be published without a price.** An unpriced product
// reaching a shop is the exact failure this separation prevents — the POS
// catalogue sends it down as `is_active: false` and it cannot be sold.
// 2. **Publishing covers every outlet the tenant runs.** A price is a
// business-level decision, and a product live at one branch and absent from
// another is a support call nobody can explain.
// PublishProduct releases one product to every outlet of a tenant at the given
// price, creating the store link where it does not exist yet.
//
// Idempotent: publishing an already-published product re-prices it and leaves
// the original `publishedat` alone, so "when did this go live" survives a
// second click.
func (r *productRepository) PublishProduct(tenantID, productID int, price float64) (int, error) {
if tenantID <= 0 || productID <= 0 {
return 0, fmt.Errorf("tenantid and productid are required")
}
if price <= 0 {
return 0, fmt.Errorf("set a price before publishing — a product with no price cannot be sold at a till")
}
var affected int
err := r.db.Transaction(func(tx *gorm.DB) error {
// The tenant's outlets are read here rather than accepted from the
// caller. A console that sent its own list could publish to a subset by
// omission and nobody would notice which shop was missing.
var locationIDs []int
if err := tx.Raw(`
SELECT locationid FROM tenantlocations
WHERE tenantid = ? AND LOWER(COALESCE(status,'active')) <> 'inactive'
ORDER BY locationid`, tenantID).Scan(&locationIDs).Error; err != nil {
return err
}
if len(locationIDs) == 0 {
return fmt.Errorf("this business has no active outlet to publish to")
}
// Verify the product belongs to this tenant before writing anything
// against it. Everything below is keyed on (tenantid, productid), but a
// caller naming another tenant's product would otherwise create rows for
// a product that is not theirs.
var owned int64
if err := tx.Raw(`SELECT COUNT(1) FROM products WHERE productid = ? AND tenantid = ?`,
productID, tenantID).Scan(&owned).Error; err != nil {
return err
}
if owned == 0 {
return fmt.Errorf("product %d does not belong to this business", productID)
}
for _, locationID := range locationIDs {
// COALESCE on publishedat keeps the first release date through a
// re-publish; NOW() only applies to a row that has never been live.
res := tx.Exec(`
UPDATE productlocations
SET price = ?, publishedat = COALESCE(publishedat, NOW()), updated = NOW()
WHERE tenantid = ? AND locationid = ? AND productid = ?`,
price, tenantID, locationID, productID)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
if err := tx.Exec(`
INSERT INTO productlocations
(tenantid, locationid, productid, price, status, publishedat, created, updated)
VALUES (?, ?, ?, ?, 'Active', NOW(), NOW(), NOW())`,
tenantID, locationID, productID, price).Error; err != nil {
return err
}
}
affected++
}
// The master price follows, so an outlet that has no row of its own
// still reads the right figure through the COALESCE fallback in
// GetProducts.
return tx.Exec(`UPDATE products SET retailprice = ?, updated = NOW()
WHERE productid = ? AND tenantid = ?`,
price, productID, tenantID).Error
})
return affected, err
}
// UnpublishProduct withdraws a product from every shop.
//
// Clears `publishedat` and keeps the rows. Deleting them would lose the price,
// the stock ledger's link and any history, and a product pulled from sale for a
// week should come back the way it left.
func (r *productRepository) UnpublishProduct(tenantID, productID int) (int, error) {
if tenantID <= 0 || productID <= 0 {
return 0, fmt.Errorf("tenantid and productid are required")
}
res := r.db.Exec(`
UPDATE productlocations SET publishedat = NULL, updated = NOW()
WHERE tenantid = ? AND productid = ? AND publishedat IS NOT NULL`,
tenantID, productID)
return int(res.RowsAffected), res.Error
}

View File

@@ -39,6 +39,11 @@ type ProductRepository interface {
CreateProductLocation(input []models.Productlocations) error CreateProductLocation(input []models.Productlocations) error
CreateProductVariant(input models.Productvariant) error CreateProductVariant(input models.Productvariant) error
DeleteProductLocation(tenantid, locationid, productid int) error DeleteProductLocation(tenantid, locationid, productid int) error
// Releasing a product from the admin catalogue to every outlet, and pulling
// it back. See productPublishRepository.go.
PublishProduct(tenantID, productID int, price float64) (int, error)
UnpublishProduct(tenantID, productID int) (int, error)
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)
@@ -395,7 +400,7 @@ func (r *productRepository) GetStockStatement(tenantID, locationID, subcategoryI
// arithmetically identical to closing — the Inventory ledger then showed // arithmetically identical to closing — the Inventory ledger then showed
// the same number in both columns and looked like stock never moved, even // the same number in both columns and looked like stock never moved, even
// on days with sales. // on days with sales.
query := `SELECT a.productid,a.productname,a.productimage,a.categoryid,a.subcategoryid,a.productunit,a.unitvalue,a.productcost,a.taxpercent,a.taxamount,a.retailprice,b.tenantid,b.locationid, query := `SELECT a.productid,a.productname,a.productimage,a.categoryid,a.subcategoryid,a.productunit,a.unitvalue,a.productcost,a.taxpercent,a.taxamount,a.retailprice,b.tenantid,b.locationid,b.publishedat,
COALESCE( SUM(CASE WHEN UPPER(c.stocktype) = 'IN' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END) - COALESCE( SUM(CASE WHEN UPPER(c.stocktype) = 'IN' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END) -
SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END),0 ) SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END),0 )
AS opening, AS opening,
@@ -430,7 +435,7 @@ func (r *productRepository) GetStockStatement(tenantID, locationID, subcategoryI
a.productid, a.productname, a.productimage, a.productid, a.productname, a.productimage,
a.categoryid, a.subcategoryid, a.productunit, a.categoryid, a.subcategoryid, a.productunit,
a.productcost, a.taxpercent, a.taxamount, a.productcost, a.taxpercent, a.taxamount,
a.retailprice, b.tenantid, b.locationid a.retailprice, b.tenantid, b.locationid, b.publishedat
ORDER BY a.productid DESC LIMIT ` + strconv.Itoa(pagesize) + ` OFFSET ` + strconv.Itoa(offset) ORDER BY a.productid DESC LIMIT ` + strconv.Itoa(pagesize) + ` OFFSET ` + strconv.Itoa(offset)
if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil { if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil {

View File

@@ -31,6 +31,12 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts) products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
products.Get("/gettenantcategories", f.ProductController.GetTenantCategories) products.Get("/gettenantcategories", f.ProductController.GetTenantCategories)
products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation) products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation)
// Releasing a product to the shops. Importing no longer does this: a
// product lands in the admin catalogue unpublished and unpriced, and only
// this step — which refuses without a price — makes it sellable.
products.Post("/publishproduct", f.ProductController.PublishProduct)
products.Post("/unpublishproduct", f.ProductController.UnpublishProduct)
products.Post("/createproductvariant", f.ProductController.CreateProductVariant) products.Post("/createproductvariant", f.ProductController.CreateProductVariant)
products.Post("/createstockrequest", f.StockRequestController.CreateStockRequest) products.Post("/createstockrequest", f.StockRequestController.CreateStockRequest)

View File

@@ -35,6 +35,8 @@ type ProductService interface {
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) GetTenantCategories(tenantid int) ([]models.TenantCategory, error)
PublishProduct(tenantID, productID int, price float64) (int, error)
UnpublishProduct(tenantID, productID int) (int, error)
} }
type productService struct { type productService struct {
repo repositories.ProductRepository repo repositories.ProductRepository
@@ -315,6 +317,14 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro
// column — not products.retailprice — is what the store catalogue, the // column — not products.retailprice — is what the store catalogue, the
// customer app and each order line read. The result was a catalogue // customer app and each order line read. The result was a catalogue
// where nothing had a price and every order booked an amount of 0. // where nothing had a price and every order booked an amount of 0.
// `publishedat` is deliberately absent — the field is `<-:false`, so
// this row is created unpublished and belongs to the admin catalogue
// only. Releasing it to the shops is PublishProduct, which refuses
// without a price.
//
// Importing used to be publication: this row existed, and membership of
// this table was the whole test, so a product reached every shop the
// moment it was imported and before anyone had priced it.
locations = append(locations, models.Productlocations{ locations = append(locations, models.Productlocations{
Tenantid: req.Tenantid, Tenantid: req.Tenantid,
Locationid: req.Locationid, Locationid: req.Locationid,
@@ -337,3 +347,15 @@ func (s *productService) GetTenantCategories(tenantid int) ([]models.TenantCateg
return s.repo.GetTenantCategories(tenantid) return s.repo.GetTenantCategories(tenantid)
} }
// 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
// caller — the console form is a convenience, not the rule.
func (s *productService) PublishProduct(tenantID, productID int, price float64) (int, error) {
return s.repo.PublishProduct(tenantID, productID, price)
}
func (s *productService) UnpublishProduct(tenantID, productID int) (int, error) {
return s.repo.UnpublishProduct(tenantID, productID)
}