fix on stocks on store catalogue

This commit is contained in:
2026-08-31 16:26:06 +05:30
parent d55f101834
commit bc10b589cd
4 changed files with 172 additions and 11 deletions

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -44,6 +44,8 @@ type fakeProductRepo struct {
ensuredRefs []models.ProductLocationRef
syncedRefs []models.ProductLocationRef
publishedRefs []models.ProductLocationRef
created []models.Products
categorySet map[int][2]int // productid -> {categoryid, subcategoryid}
}
@@ -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)
}
}