From fba42259ea1a173d55d4347aaf5a13d89964d4f1 Mon Sep 17 00:00:00 2001 From: abhishek Date: Tue, 11 Aug 2026 15:49:04 +0530 Subject: [PATCH] fix in the product import --- controllers/productController.go | 62 ++++++++++++ main.go | 24 +++++ models/product.go | 19 ++++ repositories/productPublishRepository.go | 119 +++++++++++++++++++++++ repositories/productRepository.go | 9 +- routes/productroutes.go | 6 ++ services/productService.go | 22 +++++ 7 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 repositories/productPublishRepository.go diff --git a/controllers/productController.go b/controllers/productController.go index b8acdd8..d5451ca 100644 --- a/controllers/productController.go +++ b/controllers/productController.go @@ -1,6 +1,7 @@ package controllers import ( + "fmt" "net/http" "strconv" @@ -691,3 +692,64 @@ func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error { "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}, + }) +} diff --git a/main.go b/main.go index 0968dd5..c04fedb 100644 --- a/main.go +++ b/main.go @@ -78,6 +78,30 @@ func main() { 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) routes.RegisterRoutes(app, f) diff --git a/models/product.go b/models/product.go index 9021cd4..582498d 100644 --- a/models/product.go +++ b/models/product.go @@ -161,6 +161,11 @@ type Locationproducts struct { Approve int `json:"approve" gorm:"default:0"` // Productstatus string `json:"productstatus" gorm:"default:available"` 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 { @@ -341,6 +346,20 @@ type Productlocations struct { Quantity int `json:"quantity" gorm:"<-:false"` Stocktype string `json:"stocktype" gorm:"<-:false"` 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 diff --git a/repositories/productPublishRepository.go b/repositories/productPublishRepository.go new file mode 100644 index 0000000..d945318 --- /dev/null +++ b/repositories/productPublishRepository.go @@ -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 +} diff --git a/repositories/productRepository.go b/repositories/productRepository.go index fcff05d..23b99ca 100644 --- a/repositories/productRepository.go +++ b/repositories/productRepository.go @@ -39,6 +39,11 @@ type ProductRepository interface { CreateProductLocation(input []models.Productlocations) error CreateProductVariant(input models.Productvariant) 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) CreateProductReturningID(product models.Products) (int, 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 // the same number in both columns and looked like stock never moved, even // 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) - SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' AND c.stockdate::date < CURRENT_DATE THEN c.quantity ELSE 0 END),0 ) AS opening, @@ -430,7 +435,7 @@ func (r *productRepository) GetStockStatement(tenantID, locationID, subcategoryI a.productid, a.productname, a.productimage, a.categoryid, a.subcategoryid, a.productunit, 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) if err := r.db.Raw(query, params...).Scan(&data).Error; err != nil { diff --git a/routes/productroutes.go b/routes/productroutes.go index 976894f..c2a2902 100644 --- a/routes/productroutes.go +++ b/routes/productroutes.go @@ -31,6 +31,12 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) { products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts) products.Get("/gettenantcategories", f.ProductController.GetTenantCategories) 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("/createstockrequest", f.StockRequestController.CreateStockRequest) diff --git a/services/productService.go b/services/productService.go index 311e0e1..c4286a0 100644 --- a/services/productService.go +++ b/services/productService.go @@ -35,6 +35,8 @@ type ProductService interface { ImportCatalogueProduct(reqs []models.ImportCatalogueProductRequest) error GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) + PublishProduct(tenantID, productID int, price float64) (int, error) + UnpublishProduct(tenantID, productID int) (int, error) } type productService struct { repo repositories.ProductRepository @@ -315,6 +317,14 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro // column — not products.retailprice — is what the store catalogue, the // 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. + // `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{ Tenantid: req.Tenantid, Locationid: req.Locationid, @@ -337,3 +347,15 @@ func (s *productService) GetTenantCategories(tenantid int) ([]models.TenantCateg 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) +}