Files
backend_fiesta/services/productService.go
2026-08-31 15:51:36 +05:30

459 lines
19 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"
"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)
RelinkCatalogue(tenantid int, dryRun bool) (*RelinkReport, 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)
}
// 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.
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, 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
}
// 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: 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)
}