Stock shown in the console did not match the productstocks ledger, and two product endpoints were failing outright. Every cause was on the read side or in how the availability flag was maintained; the ledger writes themselves (CreateOrder's "out" entry, cancellation's "in" entry) were already correct. Read fixes, repositories/productRepository.go: - GetProductStocks returned SQLSTATE 42803 on every call: bare a.tenantid / a.stocktype / a.status under GROUP BY a.productid. The per-ledger-row columns are now aggregated and the grouping covers the identity columns. - FetchFilteredProducts filtered on an alias `e` that no query defines, so every /getallproducts call carrying a locationid failed with SQLSTATE 42P01 instead of returning products. - FetchFilteredProducts joined productlocations on productid alone and joined a (productid, locationid)-grouped stock subquery on productid alone, so a product carried by three outlets came back three times, each row showing another outlet's quantity and status. Both are now tenant-scoped subqueries collapsed to one row per product and scoped to the outlet when one is given. - GetProductStocks and FetchFilteredProducts compared stocktype = 'in' case-sensitively. Production holds 'in' and 'IN' both, so uppercase receipts were silently dropped from the balance: one outlet reported 0 for a product holding 50, another reported 0 for twelve products holding 200-840. - GetStockStatement summed opening over stockdate <= CURRENT_DATE, making it arithmetically identical to closing. The Inventory ledger showed the same number in both columns on every row, which reads as stock never moving. Availability flag: productlocations.status was maintained by two different rules — the order path derived it from the balance, the receiving path set 'available' on any "in" entry regardless of the resulting balance. A partial restock that left the balance at or below zero marked a product sellable, and a flag set by an old order never cleared for stock that arrived by a route the API did not own. Both paths now derive the flag from the live balance through one rule: SyncProductLocationStatus (receiving side) and syncProductLocationStatus (order side, inside the caller's transaction). ReactivateProductLocations is replaced by the former; the service no longer filters refs by stocktype, since the direction of the movement is no longer what decides the flag. A row that has already drifted now repairs itself on its next ledger entry. Verified against the live database: all four stock endpoints return matching balances, /getallproducts no longer duplicates rows, and the flag sync was exercised in both directions inside a rolled-back transaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
316 lines
11 KiB
Go
316 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
|
|
}
|
|
|
|
idMap := make(map[int]struct{})
|
|
var productIDs []int
|
|
locMap := make(map[models.ProductLocationRef]struct{})
|
|
var locRefs []models.ProductLocationRef
|
|
for _, stk := range stocks {
|
|
if stk.Productid > 0 {
|
|
if _, exists := idMap[stk.Productid]; !exists {
|
|
idMap[stk.Productid] = struct{}{}
|
|
productIDs = append(productIDs, stk.Productid)
|
|
}
|
|
}
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(productIDs) > 0 {
|
|
if err := s.repo.UpdateProductStatus(productIDs, "available"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|