From bc10b589cd1c124e86e2ee529a9ab80ed9118332 Mon Sep 17 00:00:00 2001 From: abhishek Date: Mon, 31 Aug 2026 16:26:06 +0530 Subject: [PATCH] fix on stocks on store catalogue --- repositories/productPublishRepository.go | 43 ++++++++++ repositories/productRepository.go | 1 + services/productService.go | 40 ++++++++-- services/productVisibility_test.go | 99 +++++++++++++++++++++++- 4 files changed, 172 insertions(+), 11 deletions(-) diff --git a/repositories/productPublishRepository.go b/repositories/productPublishRepository.go index 1de3354..102e9fa 100644 --- a/repositories/productPublishRepository.go +++ b/repositories/productPublishRepository.go @@ -3,6 +3,8 @@ package repositories import ( "fmt" + "nearle/models" + "gorm.io/gorm" ) @@ -124,3 +126,44 @@ func (r *productRepository) UnpublishProduct(tenantID, productID int) (int, erro tenantID, productID) return int(res.RowsAffected), res.Error } + +// PublishPricedLocations releases the rows an import has just created, at the +// one branch it imported into. +// +// The publish gate exists to stop an unpriced product reaching a till that +// would ring it up at zero. An import from a spreadsheet CARRIES the price — it +// is on the row this writes — so the gate's condition is already met, and +// leaving these rows unpublished asks somebody to click a button that can only +// answer "yes" seventeen times. +// +// It was not a harmless extra step. Nothing in the backend filters on +// `publishedat`, so the products were already on sale in the customer app while +// the merchant's own store catalogue — the one screen that does read it — +// showed nothing. The shopkeeper could not see what their shoppers could buy. +// +// Three things keep this honest: +// +// - `price > 0` is checked in SQL, not by the caller. A row that somehow +// arrived unpriced stays unpublished, which is the rule the gate is for. +// - COALESCE keeps the first release date, so re-importing does not rewrite +// the history of a product that has been on sale for months. +// - Only the named branch. PublishProduct releases to every outlet a tenant +// runs and that is right for a deliberate release; an import names one +// branch, and quietly stocking the others would put products in shops +// nobody chose. +func (r *productRepository) PublishPricedLocations(refs []models.ProductLocationRef) error { + if len(refs) == 0 { + return nil + } + for _, ref := range refs { + if err := r.db.Exec(` + UPDATE productlocations + SET publishedat = COALESCE(publishedat, NOW()), updated = NOW() + WHERE tenantid = ? AND locationid = ? AND productid = ? + AND COALESCE(price, 0) > 0`, + ref.Tenantid, ref.Locationid, ref.Productid).Error; err != nil { + return err + } + } + return nil +} diff --git a/repositories/productRepository.go b/repositories/productRepository.go index 9730614..923a9bd 100644 --- a/repositories/productRepository.go +++ b/repositories/productRepository.go @@ -44,6 +44,7 @@ type ProductRepository interface { // Releasing a product from the admin catalogue to every outlet, and pulling // it back. See productPublishRepository.go. PublishProduct(tenantID, productID int, price, taxPercent float64) (int, error) + PublishPricedLocations(refs []models.ProductLocationRef) error UnpublishProduct(tenantID, productID int) (int, error) FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*models.Products, error) FindTenantProductByImageID(tenantid int, imageid string) (*models.Products, error) diff --git a/services/productService.go b/services/productService.go index fbf40c6..604b1f9 100644 --- a/services/productService.go +++ b/services/productService.go @@ -415,14 +415,21 @@ 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. + // `publishedat` is not written HERE — the field is `<-:false` — but the + // row does not stay unpublished. PublishPricedLocations below releases + // the ones that arrived with a price, which is every row of a + // spreadsheet import. // - // 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. + // The gate exists to stop an UNPRICED product reaching a till that would + // ring it up at zero. An import that carries a price has already met that + // condition, so demanding a second manual release protected nothing and + // cost a great deal: nothing in the backend filters on `publishedat`, so + // the products were on sale in the customer app while the merchant's own + // store catalogue — the one screen that does read it — showed nothing. + // + // Importing used to be publication unconditionally, which was the + // opposite error: 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, @@ -434,7 +441,24 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro }) } - return s.repo.CreateProductLocation(locations) + if err := s.repo.CreateProductLocation(locations); err != nil { + return err + } + + // Release what arrived priced. + // + // After the rows exist, not before: publishing addresses + // (tenantid, locationid, productid), and the productid of a brand-new + // snapshot is only known once it has been written. + refs := make([]models.ProductLocationRef, 0, len(locations)) + for _, location := range locations { + refs = append(refs, models.ProductLocationRef{ + Tenantid: location.Tenantid, + Locationid: location.Locationid, + Productid: location.Productid, + }) + } + return s.repo.PublishPricedLocations(refs) } func (s *productService) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) { diff --git a/services/productVisibility_test.go b/services/productVisibility_test.go index d1c1dbd..9e2c73c 100644 --- a/services/productVisibility_test.go +++ b/services/productVisibility_test.go @@ -42,9 +42,11 @@ type fakeProductRepo struct { existingByImage *models.Products linked [][2]any - ensuredRefs []models.ProductLocationRef - syncedRefs []models.ProductLocationRef - categorySet map[int][2]int // productid -> {categoryid, subcategoryid} + ensuredRefs []models.ProductLocationRef + syncedRefs []models.ProductLocationRef + publishedRefs []models.ProductLocationRef + created []models.Products + categorySet map[int][2]int // productid -> {categoryid, subcategoryid} } func newFakeRepo() *fakeProductRepo { @@ -107,6 +109,26 @@ func (f *fakeProductRepo) UpdateProductCategory(productid, categoryid, subcatego return nil } +// Publishing what an import brought in priced. +// +// The gate exists to stop an UNPRICED product reaching a till; a spreadsheet +// import carries the price, so demanding a second manual release protected +// nothing — and cost the merchant sight of their own stock, because nothing in +// the backend filters on `publishedat` but the store catalogue screen does. +// A fresh import creates the snapshot; the existing cases all take the +// re-import branch, so this only became reachable when publishing did. +func (f *fakeProductRepo) CreateProductReturningID(product models.Products) (int, error) { + f.calls = append(f.calls, "CreateProductReturningID") + f.created = append(f.created, product) + return 9001, nil +} + +func (f *fakeProductRepo) PublishPricedLocations(refs []models.ProductLocationRef) error { + f.calls = append(f.calls, "PublishPricedLocations") + f.publishedRefs = append(f.publishedRefs, refs...) + return nil +} + func (f *fakeProductRepo) CreateProductLocation(input []models.Productlocations) error { f.calls = append(f.calls, "CreateProductLocation") return nil @@ -513,3 +535,74 @@ func TestBrowseStillHidesWhatTheShopDoesNotHave(t *testing.T) { t.Fatalf("browse should still offer only the stocked product, got %v", names(offered)) } } + +/* +A priced import releases itself. + +The publish gate is there to stop an unpriced product reaching a till that would +ring it up at zero. A spreadsheet import carries the price on every row, so the +condition is already met at import time and a second manual release could only +ever answer "yes". + +It was not a harmless extra click. Nothing in the backend filters on +`publishedat` — only the merchant's own store-catalogue screen does — so on +2026-08-31 seventeen products at R mart were on sale in the customer app, priced +and stocked, while the shopkeeper's store catalogue showed none of them. The one +person who could not see the stock was the one selling it. +*/ +func TestImportPublishesWhatItPriced(t *testing.T) { + repo := newFakeRepo() + service := NewProductService(repo, &fakeCatalogueService{ + product: &models.CatalogueProduct{ + ID: 1, Brand: "kohinoor", ProductName: "Kohinoor Charminar Rice 5kg", + ImageID: "kohinoor_kohinoor_charminar_rice_5kg", + }, + }) + + if err := service.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{ + Tenantid: 1147, Locationid: 1185, Brand: "kohinoor", Catalogueid: 1, + Categoryid: 2, Retailprice: 395, Quantity: 22, Stocktype: "in", + }}); err != nil { + t.Fatalf("ImportCatalogueProduct: %v", err) + } + + if len(repo.publishedRefs) != 1 { + t.Fatalf("want the imported row released, got %d refs (calls: %v)", + len(repo.publishedRefs), repo.calls) + } + got := repo.publishedRefs[0] + // The branch that was imported into, and only that one. Publishing + // tenant-wide would put products in outlets nobody chose. + if got.Tenantid != 1147 || got.Locationid != 1185 { + t.Errorf("released the wrong row: %+v", got) + } +} + +// Order matters: publishing addresses (tenantid, locationid, productid), and a +// brand-new snapshot has no productid until its rows are written. +func TestImportPublishesAfterTheRowsExist(t *testing.T) { + repo := newFakeRepo() + service := NewProductService(repo, &fakeCatalogueService{ + product: &models.CatalogueProduct{ID: 1, Brand: "colin", ProductName: "Colin Glass Cleaner 500ml"}, + }) + + if err := service.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{ + Tenantid: 1147, Locationid: 1185, Brand: "colin", Catalogueid: 1, + Categoryid: 2, Retailprice: 105, Quantity: 42, + }}); err != nil { + t.Fatalf("ImportCatalogueProduct: %v", err) + } + + create, publish := -1, -1 + for i, call := range repo.calls { + if call == "CreateProductLocation" { + create = i + } + if call == "PublishPricedLocations" { + publish = i + } + } + if create < 0 || publish < 0 || publish < create { + t.Fatalf("want CreateProductLocation before PublishPricedLocations, got %v", repo.calls) + } +}