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