Files
backend_fiesta/services/productService.go
2026-09-16 17:09:04 +05:30

621 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
func NewProductService(repo repositories.ProductRepository, catalogueService CatalogueService) ProductService {
return &productService{repo: repo, catalogueService: catalogueService}
}
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.
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)
}