fix on stocks on store catalogue
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user