fix on stocks on store catalogue
This commit is contained in:
@@ -3,6 +3,8 @@ package repositories
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,3 +126,44 @@ func (r *productRepository) UnpublishProduct(tenantID, productID int) (int, erro
|
|||||||
tenantID, productID)
|
tenantID, productID)
|
||||||
return int(res.RowsAffected), res.Error
|
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
|
// Releasing a product from the admin catalogue to every outlet, and pulling
|
||||||
// it back. See productPublishRepository.go.
|
// it back. See productPublishRepository.go.
|
||||||
PublishProduct(tenantID, productID int, price, taxPercent float64) (int, error)
|
PublishProduct(tenantID, productID int, price, taxPercent float64) (int, error)
|
||||||
|
PublishPricedLocations(refs []models.ProductLocationRef) error
|
||||||
UnpublishProduct(tenantID, productID int) (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)
|
||||||
FindTenantProductByImageID(tenantid int, imageid string) (*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
|
// 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
|
// `publishedat` is not written HERE — the field is `<-:false` — but the
|
||||||
// this row is created unpublished and belongs to the admin catalogue
|
// row does not stay unpublished. PublishPricedLocations below releases
|
||||||
// only. Releasing it to the shops is PublishProduct, which refuses
|
// the ones that arrived with a price, which is every row of a
|
||||||
// without a price.
|
// spreadsheet import.
|
||||||
//
|
//
|
||||||
// Importing used to be publication: this row existed, and membership of
|
// The gate exists to stop an UNPRICED product reaching a till that would
|
||||||
// this table was the whole test, so a product reached every shop the
|
// ring it up at zero. An import that carries a price has already met that
|
||||||
// moment it was imported and before anyone had priced it.
|
// 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{
|
locations = append(locations, models.Productlocations{
|
||||||
Tenantid: req.Tenantid,
|
Tenantid: req.Tenantid,
|
||||||
Locationid: req.Locationid,
|
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) {
|
func (s *productService) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) {
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ type fakeProductRepo struct {
|
|||||||
existingByImage *models.Products
|
existingByImage *models.Products
|
||||||
linked [][2]any
|
linked [][2]any
|
||||||
|
|
||||||
ensuredRefs []models.ProductLocationRef
|
ensuredRefs []models.ProductLocationRef
|
||||||
syncedRefs []models.ProductLocationRef
|
syncedRefs []models.ProductLocationRef
|
||||||
categorySet map[int][2]int // productid -> {categoryid, subcategoryid}
|
publishedRefs []models.ProductLocationRef
|
||||||
|
created []models.Products
|
||||||
|
categorySet map[int][2]int // productid -> {categoryid, subcategoryid}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeRepo() *fakeProductRepo {
|
func newFakeRepo() *fakeProductRepo {
|
||||||
@@ -107,6 +109,26 @@ func (f *fakeProductRepo) UpdateProductCategory(productid, categoryid, subcatego
|
|||||||
return nil
|
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 {
|
func (f *fakeProductRepo) CreateProductLocation(input []models.Productlocations) error {
|
||||||
f.calls = append(f.calls, "CreateProductLocation")
|
f.calls = append(f.calls, "CreateProductLocation")
|
||||||
return nil
|
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))
|
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