package services import ( "encoding/json" "fmt" "log" "nearle/models" "nearle/repositories" "time" ) 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 CreateProductStock(stocks []models.Productstock) error CreateProduct(product 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) GetTenantCategories(tenantid int) ([]models.TenantCategory, 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) } func (s *productService) UpdateProductStatus(productIDs []int, status string) error { return s.repo.UpdateProductStatus(productIDs, status) } func (s *productService) CreateProduct(product models.Products) error { return s.repo.CreateProduct(product) } 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) } // 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) } 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, req.Retailprice, 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 } // 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), Productunit: catalogueProduct.Size, Productcost: req.Productcost, Retailprice: req.Retailprice, Taxpercent: req.Taxpercent, Approve: 1, } 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 deliberately absent — the field is `<-:false`, so // this row is created unpublished and belongs to the admin catalogue // only. Releasing it to the shops is PublishProduct, which refuses // without a price. // // Importing used to be publication: this row existed, and membership of // this table was the whole test, so 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), }) } return s.repo.CreateProductLocation(locations) } 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) } // 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) }