package services import ( "encoding/json" "fmt" "log" "strings" "time" "nearle/models" "nearle/repositories" ) type ProductService interface { GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error) GetProductCount(tenantID, categoryID, subcategoryID int, approve string) ([]models.Productcount, error) GetProductCategory() ([]models.ProductCategory, error) GetProductVariants(tenantID, subcategoryID int) ([]models.Productvariant, error) GetCatalougeProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Products, error) GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error) UpdateProductStatus(productIDs []int, status string) error UpdateProductVariant(productid, variantid int) error AddProductVariant(v models.Productvariant) (models.Productvariant, error) RemoveProductVariant(tenantid, variantid int) error VariantChildIDs(tenantid int) (map[int]bool, error) CreateProductStock(stocks []models.Productstock) error CreateProduct(product models.Products) (models.Products, error) UpdateProduct(product models.Products) error DeleteProduct(productID int) error GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error) GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error) GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error) GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error) FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error) GetProductByVariant(tenantid, variantid, locationid, productid int) ([]models.Products, error) GetProductsBySubcategory(params models.ProductFilter) (map[string]interface{}, error) UpdateProductLocation(input models.Productlocations) error CreateProductLocation(input []models.Productlocations) error CreateProductVariant(input models.Productvariant) error DeleteProductLocation(tenantid, locationid, productid int) error ImportCatalogueProduct(reqs []models.ImportCatalogueProductRequest) error GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) RelinkCatalogue(tenantid int, dryRun bool) (*RelinkReport, error) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) EnsureTenantCategories(tenantid int, names []string) (map[string]int, error) RecategoriseProducts(tenantid int, updates []models.ProductCategoryUpdate) (int64, error) PublishProduct(tenantID, productID int, price, taxPercent float64) (int, error) UnpublishProduct(tenantID, productID int) (int, error) } type productService struct { repo repositories.ProductRepository catalogueService CatalogueService // May be nil. A deployment without the catalogue-intelligence service still // serves every product screen, without a nutrition panel on it. nutrition NutritionService } func NewProductService( repo repositories.ProductRepository, catalogueService CatalogueService, nutrition NutritionService, ) ProductService { return &productService{repo: repo, catalogueService: catalogueService, nutrition: nutrition} } func (s *productService) GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error) { return s.repo.GetProductSubCategory(categoryID, tenantID) } func (s *productService) GetProductCount(tenantID, categoryID, subcategoryID int, approve string) ([]models.Productcount, error) { return s.repo.GetProductCount(tenantID, categoryID, subcategoryID, approve) } func (s *productService) GetProductCategory() ([]models.ProductCategory, error) { return s.repo.GetProductCategory() } func (s *productService) GetProductVariants(tenantID, subcategoryID int) ([]models.Productvariant, error) { return s.repo.GetProductVariants(tenantID, subcategoryID) } func (s *productService) GetCatalougeProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Products, error) { return s.repo.GetCatalougeProducts(tenantID, locationID, subcategoryID, pageno, pagesize, keyword) } func (s *productService) GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error) { return s.repo.GetProductStocks(tenantID, locationID) } func (s *productService) CreateProductStock(stocks []models.Productstock) error { for i := range stocks { stocks[i].Stockdate = time.Now() } if err := s.repo.CreateProductStock(stocks); err != nil { return err } locMap := make(map[models.ProductLocationRef]struct{}) var locRefs []models.ProductLocationRef for _, stk := range stocks { // Every entry gets synced, "in" and "out" alike: the status is now // derived from the resulting balance rather than assumed from the // direction of the movement, so an "out" that empties a location // flags it outofstock and a partial "in" that leaves the balance at // or below zero correctly does not mark it sellable. if stk.Productid > 0 && stk.Locationid > 0 && stk.Tenantid > 0 { ref := models.ProductLocationRef{Tenantid: stk.Tenantid, Locationid: stk.Locationid, Productid: stk.Productid} if _, exists := locMap[ref]; !exists { locMap[ref] = struct{}{} locRefs = append(locRefs, ref) } } } // products.productstatus is deliberately NOT touched here. It is a // per-product lifecycle field holding "Active"/"Inactive", and receiving // stock used to overwrite it with "available" — an availability value in a // lifecycle column, which is how 136 products ended up reading "available" // and 12 "outofstock" with their real lifecycle state destroyed. // // Availability is a per-outlet fact and belongs to productlocations.status, // which SyncProductLocationStatus derives from the ledger below. A single // column on products cannot express it anyway: the same product can be // stocked at one outlet and empty at another. if len(locRefs) > 0 { // Shelve before deriving availability, and in that order. // // Stock arriving at an outlet that has no productlocations row is the // case this exists for: the ledger was written, the console showed the // balance climbing, and the customer app never saw the product because // its query joins productlocations and filters on the outlet. The whole // request-approve-receive path ended in an invisible product. // // SyncProductLocationStatus cannot do this itself — it is an UPDATE, // and with no row it changes nothing and reports success. Running it // second means the row created here immediately gets its real status // derived from the ledger rather than keeping the seeded one. if err := s.repo.EnsureProductLocation(locRefs); err != nil { return err } if err := s.repo.SyncProductLocationStatus(locRefs); err != nil { return err } } return nil } // UpdateProductVariant groups an existing product, or ungroups it with 0. // // The ordering screen reads products.variants to decide whether a product is // one choice or several, and nothing could write that column after creation — // so a catalogue import, which is how products actually arrive, produced // permanently ungrouped products. func (s *productService) UpdateProductVariant(productid, variantid int) error { return s.repo.UpdateProductVariant(productid, variantid) } // AddProductVariant hangs a product under another as one of its sizes. func (s *productService) AddProductVariant(v models.Productvariant) (models.Productvariant, error) { return s.repo.AddProductVariant(v) } // RemoveProductVariant detaches a size, leaving both products themselves alone. func (s *productService) RemoveProductVariant(tenantid, variantid int) error { return s.repo.RemoveProductVariant(tenantid, variantid) } // VariantChildIDs lists the products that are a size of another, so a caller // that lists products for a SHOPPER can leave them out. func (s *productService) VariantChildIDs(tenantid int) (map[int]bool, error) { return s.repo.VariantChildIDs(tenantid) } func (s *productService) UpdateProductStatus(productIDs []int, status string) error { return s.repo.UpdateProductStatus(productIDs, status) } // CreateProduct stores one product and hands it back with its id filled in. // // It used to return only an error, and the id was lost on the way out: the // repository took the struct by value, GORM wrote the generated productid onto // that copy, and the copy was discarded — so the endpoint answered // `productid: 0` for a row that certainly had one. // // The caller needs it. A product is not sellable until it has been priced at an // outlet and stocked there, and both of those calls are keyed on productid, so // every importer had to create, re-read the tenant's whole catalogue, and match // its own rows back by SKU to carry on — which is also why creating two // products with the same SKU quietly attached the second one's stock to the // first. func (s *productService) CreateProduct(product models.Products) (models.Products, error) { id, err := s.repo.CreateProductReturningID(product) if err != nil { return models.Products{}, err } product.Productid = id return product, nil } func (s *productService) UpdateProduct(product models.Products) error { return s.repo.UpdateProduct(product) } func (s *productService) DeleteProduct(productID int) error { return s.repo.DeleteProduct(productID) } func (s *productService) GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error) { return s.repo.GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize, keyword) } func (s *productService) GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Locationproducts, error) { return s.repo.GetLocationProducts(tenantID, locationID, subcategoryID, pageno, pagesize, keyword) } func (s *productService) GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error) { return s.repo.GetLocationProductSummary(tenantID, locationID) } func (s *productService) GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error) { return s.repo.GetSaleTemplate(tenantID, locationID) } func (s *productService) FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error) { return s.repo.FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID, keyword, productStatus, approve, pageno, pagesize) } func (s *productService) GetProductByVariant(tenantid, variantid, locationid, productid int) ([]models.Products, error) { result, err := s.repo.GetProductByVariant(tenantid, variantid, locationid, productid) if err != nil { return nil, err } // NOT filtered by stock, and that is the opposite of the browse list on // purpose. // // This endpoint backs the product screen — the one a shopper reaches by // tapping something. It must always return what they tapped, plus every // member of its variant group, whatever the shelf holds. Filtered, it // produced two failures a shopper would meet immediately: an out-of-stock // product answered with ZERO rows, leaving the screen with nothing to draw; // and tapping "Apple" in a family where only Pineapple was stocked returned // Pineapple, so the page showed something the shopper had not asked for. // // The rule the business wants — only order what we have — is still kept, // in the two places that actually enforce it. The browse list offers only // stocked products, and CreateOrder independently refuses a line it cannot // fill (assertStockAvailable). So showing an empty size here cannot sell // one; it only lets the app grey it out, the way every storefront does. // // `productstock` on each row is what the app disables on. A single ungrouped // product comes back as a one-member group, so the app has one code path: // count the rows, and skip the picker when there is only one. decorateNutrition(s.nutrition, result) return result, nil } func (s *productService) GetProductsBySubcategory(params models.ProductFilter) (map[string]interface{}, error) { subcategories, err := s.repo.GetSubcategories(params.CategoryID) if err != nil { return nil, err } products, err := s.repo.GetProducts(params) if err != nil { return nil, err } products = InStockOnly(products, params.LocationID) var details []models.SubcategoryProductResponse var uncategorized []models.Products for _, sub := range subcategories { var subProducts []models.Products for _, p := range products { if p.Subcategoryid == sub.Subcategoryid { subProducts = append(subProducts, p) } } if len(subProducts) > 0 { details = append(details, models.SubcategoryProductResponse{ SubcategoryID: sub.Subcategoryid, SubcategoryName: sub.Subcategoryname, Image: sub.Image, Products: subProducts, }) } } for _, p := range products { if p.Subcategoryid == 0 { uncategorized = append(uncategorized, p) } } if len(uncategorized) > 0 { details = append(details, models.SubcategoryProductResponse{ SubcategoryID: 0, SubcategoryName: "Uncategorized", Products: uncategorized, }) } if params.TenantID > 0 { tenantInfo, err := s.repo.GetTenantInfo(params.TenantID, params.AppLocationID) if err == nil && tenantInfo != nil { tenantInfo["details"] = details return tenantInfo, nil } } return map[string]interface{}{"details": details}, nil } func (s *productService) UpdateProductLocation(input models.Productlocations) error { return s.repo.UpdateProductLocation(input) } func (s *productService) CreateProductLocation(input []models.Productlocations) error { return s.repo.CreateProductLocation(input) } func (s *productService) CreateProductVariant(input models.Productvariant) error { return s.repo.CreateProductVariant(input) } func (s *productService) DeleteProductLocation(tenantid, locationid, productid int) error { return s.repo.DeleteProductLocation(tenantid, locationid, productid) } // catalogueFactsOf collects the catalogue fields the product table has no // column for, so an import keeps them instead of leaving them behind. // // Only what the catalogue actually stated: an empty field is omitted rather // than written as `""` or `[]`, so a reader can tell "the catalogue did not say" // from "the catalogue said none". The drawer prints a row per fact and an empty // string would print an empty row. // // The keys are the catalogue's own wire names. They are what the console // already reads off a live catalogue row, so the same rendering works against // either source without a translation layer in between. func catalogueFactsOf(p *models.CatalogueProduct) map[string]any { facts := map[string]any{} if p == nil { return facts } put := func(key, value string) { if v := strings.TrimSpace(value); v != "" { facts[key] = v } } putList := func(key string, values []string) { kept := make([]string, 0, len(values)) for _, v := range values { if t := strings.TrimSpace(v); t != "" { kept = append(kept, t) } } if len(kept) > 0 { facts[key] = kept } } put("title", p.Title) put("category", p.Category) put("variant_key", p.VariantKey) put("sku_source", p.SKUSource) put("price_range", p.PriceRange) put("fssai_license", p.FSSAILicense) put("search_query", p.SearchQuery) putList("providers", p.Providers) putList("highlights", p.Highlights) putList("nutrients", p.Nutrients) return facts } // ImportCatalogueProduct bridges a global catalogue product (CatalogueDB) into // a tenant's own store catalogue: it snapshots the catalogue product into the // tenant's `products` table on first import (keyed on brand+catalogueid so // re-imports are recognized), then links it to the location via the existing // CreateProductLocation upsert, which already handles conflicting // (tenantid, locationid, productid) rows and stock-ledger entries. func (s *productService) ImportCatalogueProduct(reqs []models.ImportCatalogueProductRequest) error { locations := make([]models.Productlocations, 0, len(reqs)) for _, req := range reqs { catalogueProduct, err := s.catalogueService.GetProductByID(req.Brand, req.Catalogueid) if err != nil { return err } if catalogueProduct == nil { return fmt.Errorf("catalogue product not found: brand=%s id=%d", req.Brand, req.Catalogueid) } // image_id first, catalogueid second. // // The order is the whole fix. `catalogueid` is renumbered by every // re-scrape, so after one the ref lookup misses, this import believes it // is seeing the product for the first time, and the shop ends up with a // second copy of something it already stocks. `image_id` is the key the // catalogue itself deduplicates on and survives both a renumber and a // rename. // // The fallback is not dead code and will not become so: products // imported before `imageid` existed carry only the id, and a re-import is // how they acquire the stable key. // The price the product goes live at. // // The caller wins whenever it sends one — a merchant who typed a price // is never overwritten by a catalogue suggestion. Only when it sends // nothing does the catalogue’s own range fill the gap, so an imported // product shows a price in the app instead of ₹0, which reads as free. retail := req.Retailprice if retail <= 0 { retail = SuggestedPriceFrom(catalogueProduct.PriceRange) } existing, err := s.repo.FindTenantProductByImageID(req.Tenantid, catalogueProduct.ImageID) if err != nil { return err } if existing == nil { existing, err = s.repo.FindTenantProductByCatalogueRef(req.Tenantid, req.Brand, req.Catalogueid) if err != nil { return err } } productID := 0 if existing != nil { productID = existing.Productid if err := s.repo.UpdateProductPricing(productID, retail, req.Productcost, req.Taxpercent); err != nil { return err } // Re-importing corrects the CATEGORY too, not just the price. // // This branch used to update pricing alone, which made a product // imported without a category permanently unreachable: the customer // app’s endpoint rejects categoryid 0, UpdateProduct writes only // productlocations.status, and re-importing — the obvious repair — // silently changed nothing. There was no path back. // // UpdateProductCategory ignores a zero, so an import that does not // carry a category still cannot erase one that is already right. if err := s.repo.UpdateProductCategory(productID, req.Categoryid, req.Subcategoryid); err != nil { return err } // A re-import is also how an older product acquires the stable key, // and how one whose id has since moved gets pointed back at the row // it actually came from. Both are written together so they can never // disagree about which catalogue row this is. if catalogueProduct.ImageID != "" && existing.Imageid != catalogueProduct.ImageID { if err := s.repo.SetCatalogueLink(productID, catalogueProduct.ImageID, int(catalogueProduct.ID)); err != nil { return err } } // Re-importing corrects the CATEGORY too, not just the price. // // This branch used to update pricing alone, which made a product // imported without a category permanently unreachable: the customer // app's endpoint rejects categoryid 0, `UpdateProduct` writes only // productlocations.status, and re-importing — the obvious repair — // silently changed nothing. There was no path back. // // UpdateProductCategory ignores a zero, so an import that does not // carry a category still cannot erase one that is already right. } else { snapshot := models.Products{ Tenantid: req.Tenantid, Categoryid: req.Categoryid, Subcategoryid: req.Subcategoryid, Productname: catalogueProduct.ProductName, Productdesc: catalogueProduct.Description, Productsku: catalogueProduct.ProductSKU, Productbrand: catalogueProduct.Brand, Catalogueid: int(catalogueProduct.ID), // Stored beside the id, and the one that will still mean // something after the next scrape. Imageid: catalogueProduct.ImageID, Productunit: catalogueProduct.Size, Productcost: req.Productcost, Retailprice: retail, Taxpercent: req.Taxpercent, Approve: 1, } // Everything the snapshot has no column for, kept as the catalogue // stated it. // // Ten of the catalogue's eighteen fields used to stop here. Two of // them SHOULD — `category` is remapped to the platform's own // categoryid, and `price_range` is replaced by the price the shop // sets — but they are kept anyway, because what other retailers // charge is the most useful thing on the drawer when somebody is // deciding what to charge, and the catalogue's own category is how // a mis-filed product gets noticed. // // Encoding failure is swallowed, like the images below: the product // is worth creating without its facts, and refusing an import over // a nutrition line would be the wrong trade. if encoded, err := json.Marshal(catalogueFactsOf(catalogueProduct)); err == nil { snapshot.Cataloguefacts = string(encoded) } else { log.Printf("import: could not encode catalogue facts for %s/%d: %v", req.Brand, req.Catalogueid, err) } if len(catalogueProduct.Images) > 0 { // The first stays where every reader already looks for it. snapshot.Productimage = catalogueProduct.Images[0] // The rest used to be dropped on the floor here — a product with // ten photos in the global catalogue arrived in a shop with one, // and there was no way to get them back short of re-importing. // // Encoding failure is swallowed: the product itself is fine // without a gallery, and refusing an import over a photo list // would be the wrong trade. if encoded, err := json.Marshal(catalogueProduct.Images); err == nil { snapshot.Productimages = string(encoded) } else { log.Printf("import: could not encode images for catalogue %s/%d: %v", req.Brand, req.Catalogueid, err) } } productID, err = s.repo.CreateProductReturningID(snapshot) if err != nil { return err } } // Price carries the selling price onto the per-store row. Omitting it // left productlocations.price at 0 for every imported product, and that // 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 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. // // 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, Productid: productID, Quantity: req.Quantity, Stocktype: req.Stocktype, Status: req.Status, Price: float32(req.Retailprice), }) } 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) { return s.repo.GetImportedCatalogueRefs(tenantid, brand) } func (s *productService) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) { return s.repo.GetTenantCategories(tenantid) } // EnsureTenantCategories turns category NAMES into this tenant's category ids, // creating the ones that do not exist yet. // // The console works out a product's category from its title with a // deterministic ladder, but the customer app browses by categoryid and rejects // 0 — so an import has to exchange its names for ids before it creates // anything. Idempotent: importing the same sheet twice creates no duplicates. func (s *productService) EnsureTenantCategories(tenantid int, names []string) (map[string]int, error) { return s.repo.EnsureTenantCategories(tenantid, names) } // RecategoriseProducts moves a tenant's products into different categories. func (s *productService) RecategoriseProducts(tenantid int, updates []models.ProductCategoryUpdate) (int64, error) { return s.repo.RecategoriseProducts(tenantid, updates) } // PublishProduct releases a product to every outlet the tenant runs. // // The price check lives in the repository rather than here, so it holds for any // caller — the console form is a convenience, not the rule. func (s *productService) PublishProduct(tenantID, productID int, price, taxPercent float64) (int, error) { return s.repo.PublishProduct(tenantID, productID, price, taxPercent) } func (s *productService) UnpublishProduct(tenantID, productID int) (int, error) { return s.repo.UnpublishProduct(tenantID, productID) } /* decorateNutrition attaches each product's nutrition panel. ── Why the endpoint does this and the query does not ─────────────────────── The figures are not in Fiesta's database. They live in the catalogue- intelligence service, keyed by brand and image_id — the two things a tenant's product already carries from import — so there is nothing to join to and nothing for the SQL to select. See nutritionService.go. ── Why a loop of network calls is acceptable here and nowhere else ───────── This endpoint answers one tapped product plus its variant siblings: one row for most products, a handful for a size family. Never a page of sixty. Each lookup is cached for six hours and capped at three seconds, and a failure costs that product its panel and nothing else. It is deliberately NOT on the browse lists. Sixty products would be sixty lookups on a screen that shows no nutrition anyway. */ func decorateNutrition(nutrition NutritionService, products []models.Products) { if nutrition == nil { return } for i := range products { // One call per product, not two: the panel and the score come from the // same upstream record. found := nutrition.ForProduct(products[i].Productbrand, products[i].Imageid) products[i].Nutrition = found.Panel products[i].Healthscore = found.Health } }