products.productstatus is a per-product lifecycle field holding "Active"/"Inactive". CreateProductStock overwrote it with "available" on every stock receipt — an availability value written into a lifecycle column — which destroyed the real lifecycle state of the rows it touched. 136 products now read "available" and 12 "outofstock" with no way to recover what they were. A single column on products cannot express availability anyway: the same product can be stocked at one outlet and empty at another. That fact belongs to productlocations.status, which SyncProductLocationStatus already derives from the ledger, so the receipt path now updates only that and leaves productstatus alone. UpdateProductStatus remains available as an explicit admin operation; it is simply no longer called as a side effect of stock movement. GetProductCount counted available/outofstock off the same corrupted column and returned near-nonsense as a result: across 6245 products it matched 'available' on 136 and 'outofstock' on 12, leaving 6097 — the real answer — uncounted under "Active". It now derives both from the ledger, counting a product available when it holds positive stock at any of the tenant's outlets, so total = available + outofstock (6245 = 22 + 6223). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
312 lines
11 KiB
Go
312 lines
11 KiB
Go
package services
|
|
|
|
import (
|
|
"fmt"
|
|
"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
|
|
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)
|
|
FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error)
|
|
GetProductByVariant(tenantid, variantid, locationid 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)
|
|
}
|
|
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 {
|
|
if err := s.repo.SyncProductLocationStatus(locRefs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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) 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 int) ([]models.Products, error) {
|
|
|
|
var data []models.Products
|
|
|
|
result, err := s.repo.GetProductByVariant(tenantid, variantid, locationid)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data = result
|
|
|
|
return data, 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
|
|
}
|
|
|
|
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
|
|
}
|
|
} 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 {
|
|
snapshot.Productimage = catalogueProduct.Images[0]
|
|
}
|
|
|
|
productID, err = s.repo.CreateProductReturningID(snapshot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
locations = append(locations, models.Productlocations{
|
|
Tenantid: req.Tenantid,
|
|
Locationid: req.Locationid,
|
|
Productid: productID,
|
|
Quantity: req.Quantity,
|
|
Stocktype: req.Stocktype,
|
|
Status: req.Status,
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|