fix on shelf

This commit is contained in:
2026-08-31 15:51:36 +05:30
parent 4bce5ac854
commit d55f101834
11 changed files with 704 additions and 55 deletions

View File

@@ -154,3 +154,57 @@ func TestNoRecordedUrlsFallsBackToTheBucket(t *testing.T) {
t.Errorf("expected no photos without an image store, got %#v", got)
}
}
/*
Brand names arrive in two spellings, and only one of them is a key.
The catalogue keys brands by table suffix (`24_mantra`); the ingest service's
run manifest reports the display name for the same brand ("24 Mantra"). Anything
acting on a manifest — the step that puts an uploaded sheet on a shop's shelf,
above all — holds the second and has to be able to look up the first.
Real values, taken from the manifest of the R mart upload on 2026-08-31, which
failed with "Unknown brand: 24 Mantra" and shelved nothing.
*/
func TestNormaliseBrandKeyFoldsDisplayNamesOntoTableKeys(t *testing.T) {
cases := map[string]string{
"24 Mantra": "24_mantra",
"Paper Boat": "paper_boat",
"Too Yumm": "too_yumm",
"Clinic All Clear": "clinic_all_clear",
"hindustan unilever": "hindustan_unilever",
"coca-cola": "coca_cola",
// Single-word brands were never broken; they must stay unbroken.
"Colin": "colin",
"Society": "society",
"itc": "itc",
"Kohinoor": "kohinoor",
// A key that is already in table form passes through untouched, which is
// what makes the fallback safe to apply to every lookup.
"24_mantra": "24_mantra",
// Separators collapse rather than doubling up, and the edges are clean —
// " P&G " must not become "__p_g_".
" P&G ": "p_g",
"Dabur Red": "dabur_red",
}
for input, want := range cases {
if got := normaliseBrandKey(input); got != want {
t.Errorf("normaliseBrandKey(%q) = %q, want %q", input, got, want)
}
}
}
// A brand of nothing but punctuation yields an empty key rather than a stray
// underscore. Empty misses the map and answers "unknown brand", which is the
// right answer; "_" could in principle collide with a real table suffix.
func TestNormaliseBrandKeyRefusesToInventAKey(t *testing.T) {
for _, input := range []string{"", " ", "---", "&&&"} {
if got := normaliseBrandKey(input); got != "" {
t.Errorf("normaliseBrandKey(%q) = %q, want empty", input, got)
}
}
}

View File

@@ -396,12 +396,56 @@ func (r *catalogueRepository) discoverBrandTables() (map[string]string, error) {
return out, nil
}
// tableForBrand resolves a brand to its table, tolerating the display spelling.
//
// The catalogue keys brands by table suffix — `24_mantra`, `paper_boat`,
// `clinic_all_clear` — while the ingest service's run manifest reports the
// DISPLAY name for the same brand: "24 Mantra", "Paper Boat", "Clinic All
// Clear", "hindustan unilever", "coca-cola". They are the same brand written two
// ways, and anything holding a manifest is holding the second.
//
// That mismatch broke shelving outright. Putting an uploaded sheet on a shop's
// shelf reads the catalogue once per brand to resolve `image_id`, and a single
// multi-word brand failed the whole batch with "Unknown brand: 24 Mantra".
// Every brand of more than one word was affected, which in a twenty-product
// sheet is most of them.
//
// The exact key is still tried first, so a brand whose real key genuinely
// contains a separator can never be shadowed by the normalised form.
func (r *catalogueRepository) tableForBrand(brand string) (string, error) {
table, ok := r.brandTables()[strings.ToLower(strings.TrimSpace(brand))]
if !ok {
return "", ErrUnknownBrand
tables := r.brandTables()
if table, ok := tables[strings.ToLower(strings.TrimSpace(brand))]; ok {
return table, nil
}
return table, nil
if table, ok := tables[normaliseBrandKey(brand)]; ok {
return table, nil
}
return "", ErrUnknownBrand
}
// normaliseBrandKey folds a display brand name onto the catalogue's own key.
//
// Anything that is not a letter or a digit becomes an underscore, and runs of
// them collapse — which is exactly how the catalogue builds both its table names
// and the `image_id` prefix: "24 Mantra" becomes `24_mantra`, and the product
// key `24_mantra_24_mantra_organic_moong_dal_500g`. Deliberately not a general
// slugifier; matching that one convention is its whole job.
func normaliseBrandKey(brand string) string {
out := make([]rune, 0, len(brand))
lastWasSep := true // leading separators are dropped rather than kept
for _, r := range strings.ToLower(strings.TrimSpace(brand)) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
out = append(out, r)
lastWasSep = false
continue
}
if !lastWasSep {
out = append(out, '_')
lastWasSep = true
}
}
return strings.TrimSuffix(string(out), "_")
}
func (r *catalogueRepository) GetBrands() ([]models.CatalogueBrand, error) {

View File

@@ -46,6 +46,9 @@ type ProductRepository interface {
PublishProduct(tenantID, productID int, price, taxPercent float64) (int, 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)
SetCatalogueLink(productid int, imageid string, catalogueid int) error
ListCatalogueLinkedProducts(tenantid int) ([]models.Products, error)
CreateProductReturningID(product models.Products) (int, error)
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
GetTenantCategories(tenantid int) ([]models.TenantCategory, error)
@@ -1216,6 +1219,60 @@ func (r *productRepository) CreateProductReturningID(product models.Products) (i
return product.Productid, nil
}
// FindTenantProductByImageID looks up a tenant's snapshot by the catalogue's
// own stable key.
//
// Preferred over FindTenantProductByCatalogueRef wherever an image_id is
// available, because that one keys on a number the catalogue renumbers. After a
// re-scrape the id no longer names the product it was stored for, so the lookup
// misses, the import believes it is seeing the product for the first time, and
// the shop gets a second copy of something it already stocks.
//
// No brand in the key: image_id already carries it (`cheetos_chips_2d6bf74f`,
// `pepsico_kurkure_masala_munch_90g`) and is unique across the whole catalogue,
// which is exactly what the bare catalogueid is not.
func (r *productRepository) FindTenantProductByImageID(tenantid int, imageid string) (*models.Products, error) {
if strings.TrimSpace(imageid) == "" {
return nil, nil
}
var product models.Products
result := r.db.Table("products").
Where("tenantid = ? AND imageid = ?", tenantid, imageid).
First(&product)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, result.Error
}
return &product, nil
}
// SetCatalogueLink repairs one product's pointer back into the catalogue.
//
// Both halves move together and both can be cleared, which is the point.
// `imageid` is what a product should be linked by from now on; a `catalogueid`
// of 0 marks a link that could not be repaired at all — the row it named is
// gone and the re-scrape left nothing matching behind it. Clearing it is not
// data loss: the pointer already pointed at nothing, and leaving it in place
// makes a browse screen claim the product is imported from a row that does not
// exist, and makes re-importing fail outright.
func (r *productRepository) SetCatalogueLink(productid int, imageid string, catalogueid int) error {
return r.db.Table("products").
Where("productid = ?", productid).
Updates(map[string]any{"imageid": imageid, "catalogueid": catalogueid}).Error
}
// ListCatalogueLinkedProducts returns every product of a tenant that claims to
// have come from the catalogue, so each claim can be checked.
func (r *productRepository) ListCatalogueLinkedProducts(tenantid int) ([]models.Products, error) {
products := make([]models.Products, 0)
err := r.db.Table("products").
Where("tenantid = ? AND catalogueid IS NOT NULL AND catalogueid != 0", tenantid).
Find(&products).Error
return products, err
}
// GetImportedCatalogueRefs returns the (brand, catalogueid) pairs this
// tenant has already imported, so a catalogue browse screen can mark items
// as already-imported without diffing full product lists client-side. Brand
@@ -1225,7 +1282,7 @@ func (r *productRepository) CreateProductReturningID(product models.Products) (i
func (r *productRepository) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) {
refs := make([]models.ImportedCatalogueRef, 0)
query := r.db.Table("products").
Select("productbrand AS brand, catalogueid").
Select("productbrand AS brand, catalogueid, COALESCE(imageid, '') AS imageid").
Where("tenantid = ? AND catalogueid IS NOT NULL AND catalogueid != 0", tenantid)
if brand != "" {
query = query.Where("productbrand = ?", brand)
@@ -1289,9 +1346,9 @@ func (r *productRepository) UpdateProductCategory(productid, categoryid, subcate
// than three unrelated products — and until now it could only ever be set at
// CREATE time, by a caller that already knew the group id:
//
// products/create writes whatever the body carries, variants included
// importcatalogueproduct never sets it, so every imported product is 0
// UpdateProduct writes productlocations.status only, despite the name
// products/create writes whatever the body carries, variants included
// importcatalogueproduct never sets it, so every imported product is 0
// UpdateProduct writes productlocations.status only, despite the name
//
// Since importing from the catalogue is how products actually arrive, every
// product this console creates is ungrouped and there was no call that could