const brickLinkAPI = require('./bricklink'); const _ = require('lodash'); const util = require('util'); const Cart = require('./Cart'); function getRandomizedDelay(){ return Math.random() * 500 + 500; } async function getListings(order) { const results = [] for (var i = 0; i < order.length; i++) { const lot = order[i]; try { var listings = await brickLinkAPI.getListingsByLegoPartNumber(lot.partNumber, lot.colorId); console.log(listings.length + 'Listings found for', lot.partNumber, `(#${lot.colorId}#)`); const lotsWithEnoughItems = listings.filter(l => l.quantity >= lot.quantity); results.push(lotsWithEnoughItems.map(lota => { //update the quantity to only what we need lota.quantity = lot.quantity; return lota; })); //Wait added to not cause a 403. dont want to dos bricklink await new Promise(resolve => setTimeout(resolve, getRandomizedDelay())); } catch (error) { console.error('Unable to source: ', lot.partNumber, error) } } return results; } brickLinkAPI.getSetInventory('4990-1').then((inventory) => { getListings(inventory).then(parts => { var carts = []; const possibleLots = _.flatten(parts); possibleLots.forEach(lot => { const group = _.findIndex(carts, l => l.sellerUsername === lot.sellerUsername); if (group === -1) { const newGroup = new Cart(lot.sellerUsername, lot.minBuy, [lot]); carts.push(newGroup); } else { carts[group].addToCart(lot); } }) //sometimes sellers make multiple listings of the same part in the same color. filter for the cheapest carts = carts.map(cart => { const groupedLots = _.groupBy(cart.lots, (lot) => `${lot.legoPartNumber}-${lot.colorId}`); const listingsToRemove = []; _.keys(groupedLots).forEach((partType) => { if (groupedLots[partType].length <= 1) return; const pricesPerLot = groupedLots[partType].map((lot) => lot.getPrice(lot.quantity)); const minPrice = Math.min(...pricesPerLot); const lotsAtMinPrice = groupedLots[partType].filter((lot) => lot.getPrice(lot.quantity) === minPrice); if (lotsAtMinPrice.length === 1) { listingsToRemove.push(..._.difference(groupedLots[partType], ...lotsAtMinPrice)) return; } //Just keep the first one at this point. they're the same price. listingsToRemove.push(...groupedLots[partType].slice(1)); }) if(listingsToRemove.length > 0) console.log(`Removing ${listingsToRemove.length} listings for duplication from seller ${cart.sellerUsername}....`); return new Cart( cart.sellerUsername, cart.minBuy, _.differenceBy(cart.lots, listingsToRemove, (a) => a.listingId)) }) //throw out any carts that won't meet min buy var aboveMinBuy = carts.filter((cart) => cart.getPrice() >= cart.minBuy); console.log(`Found ${aboveMinBuy.length} possible carts`); //brute force things here for now //generage every possible order combo //for every cart, see if we have everything. var currentlyUnsourcedParts = inventory; var calculatedCarts = []; while (currentlyUnsourcedParts.length > 0) { console.log(`Searching for parts to cover ${currentlyUnsourcedParts.length} lots...`, currentlyUnsourcedParts) const bestAddition = getCartWithBestCoverage(aboveMinBuy, currentlyUnsourcedParts); calculatedCarts.push(bestAddition); console.log(`Added new cart to cover ${bestAddition.lots.length} lots.`, bestAddition); //check for duplicated lots across carts and keep the lowest price one, so long as it doesn't push us below min buy const lotsFromAllCarts = calculatedCarts.reduce((acc, cart) => { acc.push(...cart.lots); return acc; }, []) const groupedLots = _.groupBy(lotsFromAllCarts, (lot) => `${lot.legoPartNumber}-${lot.colorId}`); const listingsToRemove = []; _.keys(groupedLots).forEach((partType) => { const legoPartNumber = partType.split('-')[0]; const colorId = partType.split('-')[1]; if (groupedLots[partType].length <= 1) return; const pricesPerLot = groupedLots[partType].map((lot) => lot.getPrice(lot.quantity)); const minPrice = Math.min(...pricesPerLot); const lotsAtMinPrice = groupedLots[partType].filter((lot) => lot.getPrice(lot.quantity) === minPrice); if (lotsAtMinPrice.length === 1) { //TODO possibly perform check to see if we're gonna push the others below min buy listingsToRemove.push(...(_.difference(groupedLots[partType], lotsAtMinPrice).map(i => i.listingId))) return; } //everything is the same price here, lets just keep the one in the largest lot const cartSizesPerLot = lotsAtMinPrice.map(lot => { //what cart do I belong to? const parentCart = calculatedCarts.find((cart) => cart.hasListingId(lot.listingId)); return parentCart.lots.length; }); const largestCartSize = Math.max(...cartSizesPerLot); const cartsAtLargestSize = calculatedCarts.filter(cart => cart.lots.length === largestCartSize); if (cartsAtLargestSize.length === 1) { //keep this cart, remove the item from the others. //get the listing in this cart const cart = cartsAtLargestSize[0]; const keepingLot = cart.lots.find((lot) => lot.legoPartNumber == legoPartNumber && lot.colorId == colorId); listingsToRemove.push(...(groupedLots[partType].filter(lot => lot.listingId !== keepingLot.listingId).map(i => i.listingId))) return; } //at this point, they're same price, and same cart sizes. Just pick the first one listingsToRemove.push(...groupedLots[partType].slice(1).map(i => i.listingId)); }) calculatedCarts.forEach(cart => { listingsToRemove.forEach(listingId => cart.removeListingIdFromCart(listingId)) }); currentlyUnsourcedParts = getUnsourcedParts(calculatedCarts, currentlyUnsourcedParts); } // console.log('Final cart', util.inspect(calculatedCarts, false, null, true)); console.log('Total price without shipping: ', calculatedCarts.reduce((acc, cart) => acc + cart.getPrice(), 0), 'across', calculatedCarts.length, 'stores'); calculatedCarts.forEach((cart) => console.log(cart.toString())); }) }) function getCartWithBestCoverage(carts, partsList) { var coverages = carts.map(cart => { const itemsNotInThisCart = partsList.filter(item => { return _.findIndex(cart.lots, lot => lot.legoPartNumber === item.partNumber && lot.colorId === item.colorId) === -1; }) if (itemsNotInThisCart.length === 0) { //ONE STORE SOLUTION FOUND, NICE! // console.log('Coverage found: ', cart); return 0; } return itemsNotInThisCart.length; }) if (coverages.reduce((acc, val) => acc + val, 0) === 0) { } const bestCoverage = Math.min(...coverages); const coverageOptions = carts.filter((value, index, array) => { return coverages[index] === bestCoverage }).sort((a, b) => a.getPrice() - b.getPrice()); if(bestCoverage === partsList.length){ //no carts are providing coverage. throw new Error('Cannot fulfill order, lacking coverage'); } // if(Math.min(...coverages)) === ) // const bestCoverageIndex = coverages.indexOf(); return coverageOptions[0]; } function getUnsourcedParts(carts, partsList) { const allLotsFromAllCarts = _.uniqBy(carts.reduce((acc, cart) => { acc.push(...cart.lots); return acc; }, []), (lot) => `${lot.legoPartNumber}_${lot.colorId}`); return partsList.filter((part) => { return allLotsFromAllCarts.findIndex((lot) => lot.legoPartNumber === part.partNumber && lot.colorId === part.colorId) === -1 }) }