621 lines
26 KiB
Go
621 lines
26 KiB
Go
package services
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
|
||
"nearle/models"
|
||
"nearle/repositories"
|
||
)
|
||
|
||
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
|
||
AddProductVariant(v models.Productvariant) (models.Productvariant, error)
|
||
RemoveProductVariant(tenantid, variantid int) error
|
||
VariantChildIDs(tenantid int) (map[int]bool, error)
|
||
CreateProductStock(stocks []models.Productstock) error
|
||
CreateProduct(product models.Products) (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)
|
||
EnsureTenantCategories(tenantid int, names []string) (map[string]int, error)
|
||
RecategoriseProducts(tenantid int, updates []models.ProductCategoryUpdate) (int64, 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)
|
||
}
|
||
|
||
// AddProductVariant hangs a product under another as one of its sizes.
|
||
func (s *productService) AddProductVariant(v models.Productvariant) (models.Productvariant, error) {
|
||
return s.repo.AddProductVariant(v)
|
||
}
|
||
|
||
// RemoveProductVariant detaches a size, leaving both products themselves alone.
|
||
func (s *productService) RemoveProductVariant(tenantid, variantid int) error {
|
||
return s.repo.RemoveProductVariant(tenantid, variantid)
|
||
}
|
||
|
||
// VariantChildIDs lists the products that are a size of another, so a caller
|
||
// that lists products for a SHOPPER can leave them out.
|
||
func (s *productService) VariantChildIDs(tenantid int) (map[int]bool, error) {
|
||
return s.repo.VariantChildIDs(tenantid)
|
||
}
|
||
|
||
func (s *productService) UpdateProductStatus(productIDs []int, status string) error {
|
||
return s.repo.UpdateProductStatus(productIDs, status)
|
||
}
|
||
|
||
// CreateProduct stores one product and hands it back with its id filled in.
|
||
//
|
||
// It used to return only an error, and the id was lost on the way out: the
|
||
// repository took the struct by value, GORM wrote the generated productid onto
|
||
// that copy, and the copy was discarded — so the endpoint answered
|
||
// `productid: 0` for a row that certainly had one.
|
||
//
|
||
// The caller needs it. A product is not sellable until it has been priced at an
|
||
// outlet and stocked there, and both of those calls are keyed on productid, so
|
||
// every importer had to create, re-read the tenant's whole catalogue, and match
|
||
// its own rows back by SKU to carry on — which is also why creating two
|
||
// products with the same SKU quietly attached the second one's stock to the
|
||
// first.
|
||
func (s *productService) CreateProduct(product models.Products) (models.Products, error) {
|
||
id, err := s.repo.CreateProductReturningID(product)
|
||
if err != nil {
|
||
return models.Products{}, err
|
||
}
|
||
|
||
product.Productid = id
|
||
return product, nil
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// catalogueFactsOf collects the catalogue fields the product table has no
|
||
// column for, so an import keeps them instead of leaving them behind.
|
||
//
|
||
// Only what the catalogue actually stated: an empty field is omitted rather
|
||
// than written as `""` or `[]`, so a reader can tell "the catalogue did not say"
|
||
// from "the catalogue said none". The drawer prints a row per fact and an empty
|
||
// string would print an empty row.
|
||
//
|
||
// The keys are the catalogue's own wire names. They are what the console
|
||
// already reads off a live catalogue row, so the same rendering works against
|
||
// either source without a translation layer in between.
|
||
func catalogueFactsOf(p *models.CatalogueProduct) map[string]any {
|
||
facts := map[string]any{}
|
||
if p == nil {
|
||
return facts
|
||
}
|
||
|
||
put := func(key, value string) {
|
||
if v := strings.TrimSpace(value); v != "" {
|
||
facts[key] = v
|
||
}
|
||
}
|
||
putList := func(key string, values []string) {
|
||
kept := make([]string, 0, len(values))
|
||
for _, v := range values {
|
||
if t := strings.TrimSpace(v); t != "" {
|
||
kept = append(kept, t)
|
||
}
|
||
}
|
||
if len(kept) > 0 {
|
||
facts[key] = kept
|
||
}
|
||
}
|
||
|
||
put("title", p.Title)
|
||
put("category", p.Category)
|
||
put("variant_key", p.VariantKey)
|
||
put("sku_source", p.SKUSource)
|
||
put("price_range", p.PriceRange)
|
||
put("fssai_license", p.FSSAILicense)
|
||
put("search_query", p.SearchQuery)
|
||
putList("providers", p.Providers)
|
||
putList("highlights", p.Highlights)
|
||
putList("nutrients", p.Nutrients)
|
||
|
||
return facts
|
||
}
|
||
|
||
// 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.
|
||
// The price the product goes live at.
|
||
//
|
||
// The caller wins whenever it sends one — a merchant who typed a price
|
||
// is never overwritten by a catalogue suggestion. Only when it sends
|
||
// nothing does the catalogue’s own range fill the gap, so an imported
|
||
// product shows a price in the app instead of ₹0, which reads as free.
|
||
retail := req.Retailprice
|
||
if retail <= 0 {
|
||
retail = SuggestedPriceFrom(catalogueProduct.PriceRange)
|
||
}
|
||
|
||
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, retail, 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: retail,
|
||
Taxpercent: req.Taxpercent,
|
||
Approve: 1,
|
||
}
|
||
// Everything the snapshot has no column for, kept as the catalogue
|
||
// stated it.
|
||
//
|
||
// Ten of the catalogue's eighteen fields used to stop here. Two of
|
||
// them SHOULD — `category` is remapped to the platform's own
|
||
// categoryid, and `price_range` is replaced by the price the shop
|
||
// sets — but they are kept anyway, because what other retailers
|
||
// charge is the most useful thing on the drawer when somebody is
|
||
// deciding what to charge, and the catalogue's own category is how
|
||
// a mis-filed product gets noticed.
|
||
//
|
||
// Encoding failure is swallowed, like the images below: the product
|
||
// is worth creating without its facts, and refusing an import over
|
||
// a nutrition line would be the wrong trade.
|
||
if encoded, err := json.Marshal(catalogueFactsOf(catalogueProduct)); err == nil {
|
||
snapshot.Cataloguefacts = string(encoded)
|
||
} else {
|
||
log.Printf("import: could not encode catalogue facts for %s/%d: %v",
|
||
req.Brand, req.Catalogueid, err)
|
||
}
|
||
|
||
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 not written HERE — the field is `<-:false` — but the
|
||
// row does not stay unpublished. PublishPricedLocations below releases
|
||
// the ones that arrived with a price, which is every row of a
|
||
// spreadsheet import.
|
||
//
|
||
// The gate exists to stop an UNPRICED product reaching a till that would
|
||
// ring it up at zero. An import that carries a price has already met that
|
||
// condition, so demanding a second manual release protected nothing and
|
||
// cost a great deal: nothing in the backend filters on `publishedat`, so
|
||
// the products were on sale in the customer app while the merchant's own
|
||
// store catalogue — the one screen that does read it — showed nothing.
|
||
//
|
||
// Importing used to be publication unconditionally, which was the
|
||
// opposite error: 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),
|
||
})
|
||
}
|
||
|
||
if err := s.repo.CreateProductLocation(locations); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Release what arrived priced.
|
||
//
|
||
// After the rows exist, not before: publishing addresses
|
||
// (tenantid, locationid, productid), and the productid of a brand-new
|
||
// snapshot is only known once it has been written.
|
||
refs := make([]models.ProductLocationRef, 0, len(locations))
|
||
for _, location := range locations {
|
||
refs = append(refs, models.ProductLocationRef{
|
||
Tenantid: location.Tenantid,
|
||
Locationid: location.Locationid,
|
||
Productid: location.Productid,
|
||
})
|
||
}
|
||
return s.repo.PublishPricedLocations(refs)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// EnsureTenantCategories turns category NAMES into this tenant's category ids,
|
||
// creating the ones that do not exist yet.
|
||
//
|
||
// The console works out a product's category from its title with a
|
||
// deterministic ladder, but the customer app browses by categoryid and rejects
|
||
// 0 — so an import has to exchange its names for ids before it creates
|
||
// anything. Idempotent: importing the same sheet twice creates no duplicates.
|
||
func (s *productService) EnsureTenantCategories(tenantid int, names []string) (map[string]int, error) {
|
||
return s.repo.EnsureTenantCategories(tenantid, names)
|
||
}
|
||
|
||
// RecategoriseProducts moves a tenant's products into different categories.
|
||
func (s *productService) RecategoriseProducts(tenantid int, updates []models.ProductCategoryUpdate) (int64, error) {
|
||
return s.repo.RecategoriseProducts(tenantid, updates)
|
||
}
|
||
|
||
// 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)
|
||
}
|