product images added to the catalogue

This commit is contained in:
2026-08-11 15:05:27 +05:30
parent d35caf34d3
commit c179e4d642
9 changed files with 412 additions and 11 deletions

View File

@@ -0,0 +1,85 @@
package repositories
import (
"log"
"strconv"
"sync"
"time"
)
// Telling tills that a shop's shelf has moved.
//
// The broker topic `nearle/pos/{loc}/catalogue` and its publisher have existed
// since the POS integration landed, retained flag and all — and **nothing ever
// called it**. Every terminal's stock figure was therefore whatever it last
// pulled, with no signal that an app order had just sold the last of something.
//
// It could not be called from here, which is why it never was: `repositories`
// does not import `messaging`, and must not — wiring runs the other way, from
// main.go, and reversing it would be an import cycle. So the dependency is
// inverted through this interface. `messaging.PosMqttConsumer` already
// satisfies it; main.go registers it once at startup.
//
// A package-level registration rather than a constructor parameter because the
// two write paths that need it — POS bill ingest and createOrderTx — are
// reached through several layers that would each have to grow a field for a
// notification neither of them cares about the result of.
// CatalogueNotifier tells every till at a store to pull the catalogue again.
type CatalogueNotifier interface {
PublishCatalogueChanged(storeID, revision string) error
}
var (
catalogueNotifierMu sync.RWMutex
catalogueNotifier CatalogueNotifier
)
// SetCatalogueNotifier registers the publisher. Safe to leave unset — the API
// must run with the broker down, and a missed notification only means a till is
// stale until its next periodic pull.
func SetCatalogueNotifier(n CatalogueNotifier) {
catalogueNotifierMu.Lock()
defer catalogueNotifierMu.Unlock()
catalogueNotifier = n
}
// notifyCatalogueChanged asks the tills at one outlet to refresh.
//
// Three rules, all of them learned the hard way elsewhere in this codebase:
//
// 1. **Call it after the transaction commits, never inside.** A rolled-back
// sale must not announce a change that did not happen.
// 2. **It cannot fail a sale.** A publish error is logged and swallowed —
// exactly as syncProductLocationStatus already does for the availability
// flag. Stock is committed; a terminal being told about it late is not
// worth losing a bill over.
// 3. **Only the revision travels, never quantities.** The till pulls. Pushed
// numbers race with concurrent sales, and the catalogue endpoint already
// answers a delta from a revision.
//
// Fire-and-forget on its own goroutine: the publisher waits for the broker's
// acknowledgement, and a bill's response should not sit behind that.
func notifyCatalogueChanged(locationID int) {
if locationID <= 0 {
return
}
catalogueNotifierMu.RLock()
n := catalogueNotifier
catalogueNotifierMu.RUnlock()
if n == nil {
return
}
// Stamped a second in the past for the same reason Catalogue() does it: a
// revision must not claim to include a write that is still landing.
revision := posRevisionFor(locationID, time.Now().Add(-time.Second))
storeID := strconv.Itoa(locationID)
go func() {
if err := n.PublishCatalogueChanged(storeID, revision); err != nil {
log.Printf("catalogue notify: store %s: %v", storeID, err)
}
}()
}

View File

@@ -8,6 +8,7 @@ import (
"nearle/models"
"sort"
"strings"
"sync"
"time"
"gorm.io/gorm"
@@ -105,8 +106,100 @@ func NewCatalogueRepository(db *gorm.DB) CatalogueRepository {
return &catalogueRepository{db: db}
}
func tableForBrand(brand string) (string, error) {
table, ok := catalogueBrandTables[strings.ToLower(strings.TrimSpace(brand))]
// brandTables is the allowlist actually used, discovered from the catalogue
// database rather than hardcoded.
//
// The literal map above only ever exposed six brands, so a `brand_*` table
// added to the catalogue DB was invisible to the whole product until somebody
// shipped a release. Discovery removes that, and keeps the property the
// allowlist existed for: **table names still never come from a request.** They
// come from the database's own catalog, and the `brand` parameter is only ever
// looked up in the result.
//
// Two things it checks that a name alone would not:
//
// - the table has every column `catalogueProductColumns` selects. A
// `brand_*` table of a different shape would error on read, which is
// exactly the `brand_sakthi` failure — a table that exists in the map and
// cannot be queried.
// - discovery returning nothing falls back to the literal map, so a
// permissions problem on information_schema cannot take the catalogue dark.
//
// Cached with a TTL so a brand added today appears without a restart.
var (
brandTablesMu sync.RWMutex
brandTablesData map[string]string
brandTablesAt time.Time
)
const brandTablesTTL = 5 * time.Minute
func (r *catalogueRepository) brandTables() map[string]string {
brandTablesMu.RLock()
if brandTablesData != nil && time.Since(brandTablesAt) < brandTablesTTL {
defer brandTablesMu.RUnlock()
return brandTablesData
}
brandTablesMu.RUnlock()
discovered := catalogueBrandTables
if r.db != nil {
if found, err := r.discoverBrandTables(); err != nil {
log.Printf("catalogue: brand discovery failed, using the built-in list: %v", err)
} else if len(found) > 0 {
discovered = found
} else {
log.Println("catalogue: brand discovery found no usable tables, using the built-in list")
}
}
brandTablesMu.Lock()
brandTablesData, brandTablesAt = discovered, time.Now()
brandTablesMu.Unlock()
return discovered
}
// discoverBrandTables lists `brand_*` tables that carry every column the reader
// needs, in one query.
func (r *catalogueRepository) discoverBrandTables() (map[string]string, error) {
required := []string{
"id", "product_name", "title", "description", "category", "image_id", "size",
"variant_key", "product_sku", "sku_source", "price_range", "providers",
"fssai_license", "highlights", "nutrients", "search_query", "created_at", "updated_at",
}
// `IN (?)` with a slice rather than `= ANY(?)` with a driver array type:
// GORM expands the former itself, and the latter would pull in lib/pq for
// one call in a codebase that has no other use for it.
var names []string
err := r.db.Raw(`
SELECT c.table_name
FROM information_schema.columns c
WHERE c.table_schema = 'public'
AND c.table_name LIKE 'brand\_%'
AND c.column_name IN (?)
GROUP BY c.table_name
HAVING COUNT(DISTINCT c.column_name) = ?
ORDER BY c.table_name`,
required, len(required),
).Scan(&names).Error
if err != nil {
return nil, err
}
out := make(map[string]string, len(names))
for _, table := range names {
brand := strings.TrimPrefix(table, "brand_")
if brand == "" || brand == table {
continue
}
out[strings.ToLower(brand)] = table
}
return out, nil
}
func (r *catalogueRepository) tableForBrand(brand string) (string, error) {
table, ok := r.brandTables()[strings.ToLower(strings.TrimSpace(brand))]
if !ok {
return "", ErrUnknownBrand
}
@@ -121,7 +214,7 @@ func (r *catalogueRepository) GetBrands() ([]models.CatalogueBrand, error) {
var brands []models.CatalogueBrand
var failures int
for brand, table := range catalogueBrandTables {
for brand, table := range r.brandTables() {
var count int64
if err := r.db.Table(table).Count(&count).Error; err != nil {
// One brand's table being absent or unreadable must not hide the
@@ -138,7 +231,7 @@ func (r *catalogueRepository) GetBrands() ([]models.CatalogueBrand, error) {
// Every table failing is a different thing from every table being empty:
// the first is an outage and must be reported, the second is a fact.
if failures == len(catalogueBrandTables) {
if failures == len(r.brandTables()) {
return nil, fmt.Errorf("no catalogue brand table could be read (%d configured)", failures)
}
@@ -150,7 +243,7 @@ func (r *catalogueRepository) GetCategories(brand string) ([]string, error) {
return nil, ErrCatalogueDBUnavailable
}
table, err := tableForBrand(brand)
table, err := r.tableForBrand(brand)
if err != nil {
return nil, err
}
@@ -187,7 +280,7 @@ func (r *catalogueRepository) GetProducts(brand, category, keyword string, pagen
}
func (r *catalogueRepository) getProductsForBrand(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) {
table, err := tableForBrand(brand)
table, err := r.tableForBrand(brand)
if err != nil {
return nil, 0, err
}
@@ -229,7 +322,8 @@ func (r *catalogueRepository) getProductsAllBrands(category, keyword string, pag
whereClause, args := catalogueWhereClause(category, keyword)
brands := make([]string, 0, len(catalogueBrandTables))
for brand := range catalogueBrandTables {
tables := r.brandTables()
for brand := range tables {
brands = append(brands, brand)
}
sort.Strings(brands)
@@ -238,7 +332,7 @@ func (r *catalogueRepository) getProductsAllBrands(category, keyword string, pag
var failures int
for _, brand := range brands {
table := catalogueBrandTables[brand]
table := tables[brand]
var rows []catalogueProductRow
dataQuery := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY id`, catalogueProductColumns, table, whereClause)
@@ -302,7 +396,7 @@ func (r *catalogueRepository) GetProductBySKU(brand, sku string) (*models.Catalo
return nil, ErrCatalogueDBUnavailable
}
table, err := tableForBrand(brand)
table, err := r.tableForBrand(brand)
if err != nil {
return nil, err
}
@@ -329,7 +423,7 @@ func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.Ca
return nil, ErrCatalogueDBUnavailable
}
table, err := tableForBrand(brand)
table, err := r.tableForBrand(brand)
if err != nil {
return nil, err
}

View File

@@ -1354,6 +1354,13 @@ func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error)
return models.Orders{}, err
}
// An app order depletes the same shelf the counter sells from, so the tills
// at that outlet need to know as much as they do after a counter sale. This
// half was the one that would have been forgotten: it is easy to think of
// the catalogue as a POS concern, and a till overselling something the app
// just took is exactly the failure the push exists to prevent.
notifyCatalogueChanged(created.Locationid)
return r.reloadOrder(created.Orderheaderid)
}

View File

@@ -386,6 +386,11 @@ func (r *posRepository) importPosOrder(
return fmt.Sprintf("could not commit bill %s: %v", order.Invoicenumber, err)
}
// Stock has moved at this outlet, so every other till standing at the same
// counter is now holding a figure that is one sale out of date. After the
// commit, never before — a rolled-back bill must not announce itself.
notifyCatalogueChanged(ctx.Locationid)
return ""
}

View File

@@ -255,7 +255,7 @@ func (r *productRepository) GetProductStocks(tenantID, locationID string) ([]mod
MAX(a.stocktype) AS stocktype, MAX(a.maxquantity) AS maxquantity,
MAX(a.minquantity) AS minquantity, MAX(a.status) AS status,
b.applocationid, b.categoryid, b.subcategoryid, b.catalogueid, b.addonid, b.discountid, b.pricingid,
b.productname, b.productimage, b.productdesc, b.productsku, b.brandid, b.productbrand, b.productunit,
b.productname, b.productimage, COALESCE(b.productimages::text, '') AS productimages, b.productdesc, b.productsku, b.brandid, b.productbrand, b.productunit,
b.unitvalue, b.toppicks, b.productcost, b.taxamount, b.taxpercent, b.producttax, b.productstock,
b.productcombo, b.variants, b.retailprice, b.diffprice, b.diffpercent, b.othercost, b.approve,
b.productstatus, b.created, b.updated, c.subcatname AS subcategoryname,