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 }