430 lines
18 KiB
Go
430 lines
18 KiB
Go
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)
|
||
}
|