flamingo.me/flamingo-commerce/v3@v3.11.0/sourcing/domain/service.go (about)

     1  package domain
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"math"
     8  
     9  	cartDomain "flamingo.me/flamingo-commerce/v3/cart/domain/cart"
    10  	"flamingo.me/flamingo-commerce/v3/cart/domain/decorator"
    11  	"flamingo.me/flamingo-commerce/v3/product/domain"
    12  
    13  	"flamingo.me/flamingo/v3/framework/flamingo"
    14  )
    15  
    16  const MaxSourceQty = math.MaxInt64
    17  
    18  type (
    19  	// SourcingService describes the main port used by the sourcing logic.
    20  	SourcingService interface {
    21  		// AllocateItems returns Sources for the given item in the given cart
    22  		// e.g. use this during place order to know
    23  		// throws ErrInsufficientSourceQty if not enough stock is available for the amount of items in the cart
    24  		// throws ErrNoSourceAvailable if no source is available at all for one of the items
    25  		// throws ErrNeedMoreDetailsSourceCannotBeDetected if information on the cart (or delivery is missing)
    26  		AllocateItems(ctx context.Context, decoratedCart *decorator.DecoratedCart) (ItemAllocations, error)
    27  
    28  		// GetAvailableSources returns possible Sources for the product and the desired delivery.
    29  		// Optional the existing cart can be passed so that existing items in the cart can be evaluated also (e.g. deduct stock)
    30  		// e.g. use this before a product should be placed in the cart to know if and from where an item can be sourced
    31  		// throws ErrNeedMoreDetailsSourceCannotBeDetected
    32  		// throws ErrNoSourceAvailable if no source is available for the product and the given delivery
    33  		GetAvailableSources(ctx context.Context, product domain.BasicProduct, deliveryInfo *cartDomain.DeliveryInfo, decoratedCart *decorator.DecoratedCart) (AvailableSourcesPerProduct, error)
    34  	}
    35  
    36  	// ItemID string alias
    37  	ItemID string
    38  
    39  	// ItemAllocations represents the allocated Qtys per itemID
    40  	ItemAllocations map[ItemID]ItemAllocation
    41  
    42  	// ItemAllocation info
    43  	ItemAllocation struct {
    44  		AllocatedQtys map[ProductID]AllocatedQtys
    45  		Error         error
    46  	}
    47  
    48  	ProductID string
    49  
    50  	AvailableSourcesPerProduct map[ProductID]AvailableSources
    51  
    52  	// AllocatedQtys represents the allocated Qty per source
    53  	AllocatedQtys map[Source]int
    54  
    55  	// Source descriptor for a single location
    56  	Source struct {
    57  		// LocationCode identifies the warehouse or stock location
    58  		LocationCode string
    59  		// ExternalLocationCode identifies the source location in an external system
    60  		ExternalLocationCode string
    61  	}
    62  
    63  	// AvailableSources is the result value object containing the available Qty per Source
    64  	AvailableSources map[Source]int
    65  
    66  	// DefaultSourcingService provides a default implementation of the SourcingService interface.
    67  	// This default implementation is used unless a project overrides the interface binding.
    68  	DefaultSourcingService struct {
    69  		availableSourcesProvider AvailableSourcesProvider
    70  		stockProvider            StockProvider
    71  		logger                   flamingo.Logger
    72  	}
    73  
    74  	// AvailableSourcesProvider interface for DefaultSourcingService
    75  	AvailableSourcesProvider interface {
    76  		GetPossibleSources(ctx context.Context, product domain.BasicProduct, deliveryInfo *cartDomain.DeliveryInfo) ([]Source, error)
    77  	}
    78  
    79  	// StockProvider interface for DefaultSourcingService
    80  	StockProvider interface {
    81  		GetStock(ctx context.Context, product domain.BasicProduct, source Source, deliveryInfo *cartDomain.DeliveryInfo) (int, error)
    82  	}
    83  )
    84  
    85  var (
    86  	_ SourcingService = new(DefaultSourcingService)
    87  
    88  	// ErrInsufficientSourceQty - use to indicate that the requested qty exceeds the available qty
    89  	ErrInsufficientSourceQty = errors.New("available Source Qty insufficient")
    90  
    91  	// ErrNoSourceAvailable - use to indicate that no source for item is available at all
    92  	ErrNoSourceAvailable = errors.New("no Available Source Qty")
    93  
    94  	// ErrNeedMoreDetailsSourceCannotBeDetected - use to indicate that information are missing to determine a source
    95  	ErrNeedMoreDetailsSourceCannotBeDetected = errors.New("source cannot be detected")
    96  
    97  	// ErrUnsupportedProductType return when product type is not supported by the service
    98  	ErrUnsupportedProductType = errors.New("unsupported product type")
    99  
   100  	// ErrEmptyProductIdentifier return when product id is missing
   101  	ErrEmptyProductIdentifier = errors.New("product identifier is empty")
   102  
   103  	// ErrProductIsNil returned when nil product is received
   104  	ErrProductIsNil = errors.New("received product in nil")
   105  
   106  	// ErrStockProviderNotFound returned stock provider is nil
   107  	ErrStockProviderNotFound = errors.New("no Stock Provider bound")
   108  
   109  	// ErrSourceProviderNotFound returned source provider is nil
   110  	ErrSourceProviderNotFound = errors.New("no Source Provider bound")
   111  
   112  	// ErrCartNotProvided cart not provided
   113  	ErrCartNotProvided = errors.New("cart not provided")
   114  )
   115  
   116  // Inject the dependencies
   117  func (d *DefaultSourcingService) Inject(
   118  	logger flamingo.Logger,
   119  	dep *struct {
   120  		AvailableSourcesProvider AvailableSourcesProvider `inject:",optional"`
   121  		StockProvider            StockProvider            `inject:",optional"`
   122  	},
   123  ) *DefaultSourcingService {
   124  	d.logger = logger.WithField(flamingo.LogKeyModule, "sourcing").WithField(flamingo.LogKeyCategory, "DefaultSourcingService")
   125  
   126  	if dep != nil {
   127  		d.availableSourcesProvider = dep.AvailableSourcesProvider
   128  		d.stockProvider = dep.StockProvider
   129  	}
   130  
   131  	return d
   132  }
   133  
   134  // GetAvailableSources - see description in Interface
   135  //
   136  //nolint:cyclop // more readable this way
   137  func (d *DefaultSourcingService) GetAvailableSources(ctx context.Context, product domain.BasicProduct, deliveryInfo *cartDomain.DeliveryInfo, decoratedCart *decorator.DecoratedCart) (AvailableSourcesPerProduct, error) {
   138  	if err := d.checkConfiguration(); err != nil {
   139  		return nil, err
   140  	}
   141  
   142  	if product == nil {
   143  		return nil, ErrProductIsNil
   144  	}
   145  
   146  	if product.GetIdentifier() == "" {
   147  		return nil, ErrEmptyProductIdentifier
   148  	}
   149  
   150  	if product.Type() == domain.TypeBundle || product.Type() == domain.TypeConfigurable {
   151  		return nil, fmt.Errorf("%w: %s", ErrUnsupportedProductType, product.Type())
   152  	}
   153  
   154  	if bundle, ok := product.(domain.BundleProductWithActiveChoices); ok {
   155  		availableSourceForBundle := make(AvailableSourcesPerProduct)
   156  
   157  		for _, choice := range bundle.ActiveChoices {
   158  			// check here so we don't need to check further
   159  			if choice.Product.GetIdentifier() == "" {
   160  				return nil, ErrEmptyProductIdentifier
   161  			}
   162  
   163  			qtys, err := d.getAvailableSourcesForASingleProduct(ctx, choice.Product, deliveryInfo, decoratedCart)
   164  			if err != nil {
   165  				return nil, err
   166  			}
   167  
   168  			availableSourceForBundle[ProductID(choice.Product.GetIdentifier())] = qtys
   169  		}
   170  
   171  		return availableSourceForBundle, nil
   172  	}
   173  
   174  	qtys, err := d.getAvailableSourcesForASingleProduct(ctx, product, deliveryInfo, decoratedCart)
   175  	if err != nil {
   176  		return nil, err
   177  	}
   178  
   179  	return AvailableSourcesPerProduct{ProductID(product.GetIdentifier()): qtys}, nil
   180  }
   181  
   182  //nolint:cyclop // more readable this way
   183  func (d *DefaultSourcingService) getAvailableSourcesForASingleProduct(ctx context.Context, product domain.BasicProduct, deliveryInfo *cartDomain.DeliveryInfo, decoratedCart *decorator.DecoratedCart) (AvailableSources, error) {
   184  	sources, err := d.availableSourcesProvider.GetPossibleSources(ctx, product, deliveryInfo)
   185  	if err != nil {
   186  		return nil, fmt.Errorf("error getting possible sources for product with identifier %s: %w", product.GetIdentifier(), err)
   187  	}
   188  
   189  	var lastStockError error
   190  	availableSources := AvailableSources{}
   191  	for _, source := range sources {
   192  		qty, err := d.stockProvider.GetStock(ctx, product, source, deliveryInfo)
   193  		if err != nil {
   194  			d.logger.Error(err)
   195  			lastStockError = err
   196  
   197  			continue
   198  		}
   199  		if qty > 0 {
   200  			availableSources[source] = qty
   201  		}
   202  	}
   203  
   204  	// if a cart is given we need to deduct the possible allocated items in the cart
   205  	if decoratedCart != nil {
   206  		allocatedSources, err := d.AllocateItems(ctx, decoratedCart)
   207  		if err != nil {
   208  			return nil, err
   209  		}
   210  
   211  		itemIdsWithProduct := getItemIdsWithProduct(decoratedCart, product)
   212  
   213  		for _, itemID := range itemIdsWithProduct {
   214  			if _, ok := allocatedSources[itemID]; ok {
   215  				availableSources = availableSources.Reduce(allocatedSources[itemID].AllocatedQtys[ProductID(product.GetIdentifier())])
   216  			}
   217  		}
   218  	}
   219  
   220  	if len(availableSources) == 0 {
   221  		if lastStockError != nil {
   222  			errString := err.Error()
   223  			return availableSources, fmt.Errorf("%w with error: %s", ErrNoSourceAvailable, errString)
   224  		}
   225  		return availableSources, fmt.Errorf("%w %s", ErrNoSourceAvailable, formatSources(sources))
   226  	}
   227  
   228  	return availableSources, nil
   229  }
   230  
   231  func (d *DefaultSourcingService) checkConfiguration() error {
   232  	if d.availableSourcesProvider == nil {
   233  		d.logger.Error("no Source Provider bound")
   234  		return ErrSourceProviderNotFound
   235  	}
   236  
   237  	if d.stockProvider == nil {
   238  		d.logger.Error("no Stock Provider bound")
   239  		return ErrStockProviderNotFound
   240  	}
   241  
   242  	return nil
   243  }
   244  
   245  func getItemIdsWithProduct(dc *decorator.DecoratedCart, product domain.BasicProduct) []ItemID {
   246  	var result []ItemID
   247  
   248  	for _, di := range dc.GetAllDecoratedItems() {
   249  		if bundle, ok := di.Product.(domain.BundleProductWithActiveChoices); ok {
   250  			for _, choice := range bundle.ActiveChoices {
   251  				if choice.Product.GetIdentifier() == product.GetIdentifier() {
   252  					result = append(result, ItemID(di.Item.ID))
   253  				}
   254  			}
   255  		}
   256  
   257  		if di.Product.GetIdentifier() == product.GetIdentifier() {
   258  			result = append(result, ItemID(di.Item.ID))
   259  		}
   260  	}
   261  
   262  	return result
   263  }
   264  
   265  // AllocateItems - see description in Interface
   266  func (d *DefaultSourcingService) AllocateItems(ctx context.Context, decoratedCart *decorator.DecoratedCart) (ItemAllocations, error) {
   267  	if err := d.checkConfiguration(); err != nil {
   268  		return nil, err
   269  	}
   270  
   271  	if decoratedCart == nil {
   272  		return nil, ErrCartNotProvided
   273  	}
   274  
   275  	productSourcestock := make(map[string]map[Source]int)
   276  
   277  	if len(decoratedCart.DecoratedDeliveries) == 0 {
   278  		return nil, ErrNeedMoreDetailsSourceCannotBeDetected
   279  	}
   280  
   281  	resultItemAllocations := make(ItemAllocations)
   282  
   283  	for _, delivery := range decoratedCart.DecoratedDeliveries {
   284  		deliveryInfo := delivery.Delivery.DeliveryInfo // create a new variable to avoid memory aliasing
   285  
   286  		for _, decoratedItem := range delivery.DecoratedItems {
   287  			item := decoratedItem // create a new variable to avoid memory aliasing
   288  
   289  			itemAllocation, err := d.allocateItem(ctx, productSourcestock, &item, deliveryInfo)
   290  			if err != nil {
   291  				return nil, err
   292  			}
   293  
   294  			resultItemAllocations[ItemID(item.Item.ID)] = itemAllocation
   295  		}
   296  	}
   297  
   298  	return resultItemAllocations, nil
   299  }
   300  
   301  func (d *DefaultSourcingService) allocateItem(
   302  	ctx context.Context,
   303  	productSourcestock map[string]map[Source]int,
   304  	decoratedItem *decorator.DecoratedCartItem,
   305  	deliveryInfo cartDomain.DeliveryInfo,
   306  ) (ItemAllocation, error) {
   307  	if decoratedItem.Product.Type() == domain.TypeBundle || decoratedItem.Product.Type() == domain.TypeConfigurable {
   308  		return ItemAllocation{}, fmt.Errorf("%w: %s", ErrUnsupportedProductType, decoratedItem.Product.Type())
   309  	}
   310  
   311  	if bundleProduct, ok := decoratedItem.Product.(domain.BundleProductWithActiveChoices); ok {
   312  		itemAllocation := d.allocateBundleWithActiveChoices(ctx, decoratedItem.Item.Qty, productSourcestock, bundleProduct, deliveryInfo)
   313  		return itemAllocation, nil
   314  	}
   315  
   316  	// check here so we don't need to check further
   317  	if decoratedItem.Product.GetIdentifier() == "" {
   318  		return ItemAllocation{
   319  			AllocatedQtys: nil,
   320  			Error:         ErrEmptyProductIdentifier,
   321  		}, nil
   322  	}
   323  
   324  	allocatedQtys, err := d.allocateProduct(ctx, productSourcestock, decoratedItem.Product, decoratedItem.Item.Qty, deliveryInfo)
   325  
   326  	itemAllocation := ItemAllocation{
   327  		AllocatedQtys: map[ProductID]AllocatedQtys{ProductID(decoratedItem.Product.GetIdentifier()): allocatedQtys},
   328  		Error:         err,
   329  	}
   330  
   331  	return itemAllocation, nil
   332  }
   333  
   334  func (d *DefaultSourcingService) allocateBundleWithActiveChoices(
   335  	ctx context.Context,
   336  	itemQty int,
   337  	productSourcestock map[string]map[Source]int,
   338  	bundleProduct domain.BundleProductWithActiveChoices,
   339  	deliveryInfo cartDomain.DeliveryInfo,
   340  ) ItemAllocation {
   341  	var resultItemAllocation ItemAllocation
   342  
   343  	for _, activeChoice := range bundleProduct.ActiveChoices {
   344  		qty := activeChoice.Qty * itemQty
   345  
   346  		if activeChoice.Product.GetIdentifier() == "" {
   347  			return ItemAllocation{
   348  				AllocatedQtys: nil,
   349  				Error:         ErrEmptyProductIdentifier,
   350  			}
   351  		}
   352  
   353  		allocatedQtys, err := d.allocateProduct(ctx, productSourcestock, activeChoice.Product, qty, deliveryInfo)
   354  
   355  		if resultItemAllocation.AllocatedQtys == nil {
   356  			resultItemAllocation.AllocatedQtys = make(map[ProductID]AllocatedQtys)
   357  		}
   358  
   359  		if err != nil {
   360  			resultItemAllocation.Error = err
   361  		}
   362  
   363  		resultItemAllocation.AllocatedQtys[ProductID(activeChoice.Product.GetIdentifier())] = allocatedQtys
   364  	}
   365  
   366  	return resultItemAllocation
   367  }
   368  
   369  func (d *DefaultSourcingService) allocateProduct(
   370  	ctx context.Context,
   371  	productSourcestock map[string]map[Source]int,
   372  	product domain.BasicProduct,
   373  	qtyToAllocate int,
   374  	deliveryInfo cartDomain.DeliveryInfo,
   375  ) (AllocatedQtys, error) {
   376  	sources, err := d.availableSourcesProvider.GetPossibleSources(ctx, product, &deliveryInfo)
   377  	if err != nil {
   378  		return nil,
   379  			fmt.Errorf("error getting possible sources for product with identifier %s: %w", product.GetIdentifier(), err)
   380  	}
   381  
   382  	if len(sources) == 0 {
   383  		return nil, fmt.Errorf("%w: for product with identifier %s", ErrNoSourceAvailable, product.GetIdentifier())
   384  	}
   385  
   386  	allocatedQtys := make(AllocatedQtys)
   387  
   388  	allocatedQty := d.allocateFromSources(ctx, productSourcestock, product, qtyToAllocate, sources, &deliveryInfo, allocatedQtys)
   389  
   390  	if allocatedQty < qtyToAllocate {
   391  		return allocatedQtys, ErrInsufficientSourceQty
   392  	}
   393  
   394  	return allocatedQtys, nil
   395  }
   396  
   397  func (d *DefaultSourcingService) allocateFromSources(
   398  	ctx context.Context,
   399  	productSourcestock map[string]map[Source]int,
   400  	product domain.BasicProduct,
   401  	qtyToAllocate int,
   402  	sources []Source,
   403  	deliveryInfo *cartDomain.DeliveryInfo,
   404  	allocatedQtys AllocatedQtys,
   405  ) int {
   406  	productID := product.GetIdentifier()
   407  	allocatedQty := 0
   408  
   409  	if _, exists := productSourcestock[productID]; !exists {
   410  		productSourcestock[productID] = make(map[Source]int)
   411  	}
   412  
   413  	for _, source := range sources {
   414  		sourceStock, err := d.getSourceStock(ctx, productSourcestock, product, source, deliveryInfo)
   415  		if err != nil {
   416  			d.logger.Error(err)
   417  
   418  			continue
   419  		}
   420  
   421  		if sourceStock == 0 {
   422  			continue
   423  		}
   424  
   425  		stockToAllocate := min(qtyToAllocate-allocatedQty, sourceStock)
   426  		productSourcestock[productID][source] -= stockToAllocate
   427  		allocatedQty += stockToAllocate
   428  		allocatedQtys[source] = stockToAllocate // Added this line to update allocatedQtys map
   429  	}
   430  
   431  	return allocatedQty
   432  }
   433  
   434  func (d *DefaultSourcingService) getSourceStock(
   435  	ctx context.Context,
   436  	remainingSourcestock map[string]map[Source]int,
   437  	product domain.BasicProduct,
   438  	source Source,
   439  	deliveryInfo *cartDomain.DeliveryInfo,
   440  ) (int, error) {
   441  	if _, exists := remainingSourcestock[product.GetIdentifier()][source]; !exists {
   442  		sourceStock, err := d.stockProvider.GetStock(ctx, product, source, deliveryInfo)
   443  		if err != nil {
   444  			return 0, fmt.Errorf("error getting stock product: %w", err)
   445  		}
   446  
   447  		remainingSourcestock[product.GetIdentifier()][source] = sourceStock
   448  	}
   449  
   450  	return remainingSourcestock[product.GetIdentifier()][source], nil
   451  }
   452  
   453  // QtySum returns the sum of all sourced items
   454  func (s AvailableSources) QtySum() int {
   455  	qty := 0
   456  	for _, sqty := range s {
   457  		// check against max int 32 to avoid overflowing int
   458  		if sqty == MaxSourceQty || qty > math.MaxInt32 {
   459  			return MaxSourceQty
   460  		}
   461  
   462  		qty = qty + sqty
   463  	}
   464  	return qty
   465  }
   466  
   467  // Reduce returns new AvailableSources reduced by the given AvailableSources
   468  func (s AvailableSources) Reduce(reducedBy AllocatedQtys) AvailableSources {
   469  	newAvailableSources := make(AvailableSources)
   470  	for source, availableQty := range s {
   471  		if allocated, ok := reducedBy[source]; ok {
   472  			newQty := availableQty - allocated
   473  			if newQty > 0 {
   474  				newAvailableSources[source] = newQty
   475  			}
   476  		} else {
   477  			newAvailableSources[source] = availableQty
   478  		}
   479  	}
   480  	return newAvailableSources
   481  }
   482  
   483  // min returns minimum of 2 ints
   484  func min(a int, b int) int {
   485  	if a < b {
   486  		return a
   487  	}
   488  	return b
   489  }
   490  
   491  func formatSources(sources []Source) string {
   492  	checkedSources := "Checked sources:"
   493  
   494  	for _, source := range sources {
   495  		checkedSources += fmt.Sprintf(" SourceCode: %q ExternalSourceCode: %q", source.LocationCode, source.ExternalLocationCode)
   496  	}
   497  
   498  	return checkedSources
   499  }
   500  
   501  func (as AvailableSourcesPerProduct) FindSourcesWithLeastAvailableQty() AvailableSources {
   502  	var minAvailableSources AvailableSources
   503  
   504  	minQtySum := MaxSourceQty
   505  
   506  	for _, availableSources := range as {
   507  		qtySum := availableSources.QtySum()
   508  		if qtySum <= minQtySum {
   509  			minQtySum = qtySum
   510  			minAvailableSources = availableSources
   511  		}
   512  	}
   513  
   514  	return minAvailableSources
   515  }