const env = require('dotenv'); env.config(); const brickLinkAPI = require('./bricklink'); const brickowlAPI = require('./brickowl'); const _ = require('lodash'); const util = require('util'); const Cart = require('./Cart'); function getRandomizedDelay(){ return Math.random() * 500 + 500; } async function getListings(order) { // const results = [] var promises = order.map(lot => { return new Promise(async (resolve, reject) => { try { if(!lot.partNumber) { const details = await brickowlAPI.getDetails(lot.internalPartId); lot.colorId = details.color_id; lot.partNumber = _.get(_.find(details.ids, (i => i.type === 'design_id')), 'id', null) lot.desc = details.name if(!lot.partNumber) { console.log('Unable to get lego part number for ', lot.internalPartId) lot.partNumber = details.name } } // var listings = await brickowlAPI.getListingsByLegoPartNumber(lot.partNumber, lot.colorId); var listings = await brickowlAPI.getListings(lot.internalPartId, lot.colorId); console.log(listings.length + 'Listings found for', lot.internalPartId, `(#${lot.colorId}#)`); const lotsWithEnoughItems = listings.filter(l => l.quantity >= lot.quantity); // if(lotsWithEnoughItems.length === 0) return reject('Unable to source part ' + lot.blPartNumber) resolve(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.blPartNumber, error) reject(error); } }) }); // for (var i = 0; i < order.length; i++) { // const lot = order[i]; // } return await Promise.all(promises); } brickowlAPI.getSetInventory('2996-1').then((inventory) => { // console.log('Inventory', inventory) getListings(inventory).then(async parts => { var carts = []; const possibleLots = _.flatten(parts); possibleLots.forEach(lot => { let logging = false; 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.blPartNumber}-${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)) }) const fs = require('fs'); await fs.writeFileSync('./carts.json', JSON.stringify(carts)) //throw out any carts that won't meet min buy var aboveMinBuy = carts.filter((cart) => cart.getPrice() >= cart.minBuy); console.log(`Threw out ${carts.length - aboveMinBuy.length} carts due to min purchase requirements.`) 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...`) const bestAddition = getCartWithBestCoverage(aboveMinBuy, currentlyUnsourcedParts); if(!bestAddition) { //run out of coverage options break; } calculatedCarts.push(bestAddition); console.log(`Added new cart to cover ${bestAddition.lots.length} lots for ${bestAddition.getPrice()}`); //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.blPartNumber}`); 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.blPartNumber == partType); // console.log(cart, keepingLot, legoPartNumber, 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'); if(currentlyUnsourcedParts.length > 0) { console.log('Some lots couldnt be sourced from a single seller', currentlyUnsourcedParts) } 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.blPartNumber === item.internalPartId && 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. console.log('Lacking coverage for the following parts', partsList) // throw new Error('Cannot fulfill order, lacking coverage'); return false; } // 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.blPartNumber}_${lot.colorId}`); console.log(allLotsFromAllCarts.map(a => a.blPartNumber).join(',')); return partsList.filter((part) => { const coverageIndex = allLotsFromAllCarts.findIndex((lot) => { if(lot.blPartNumber === "868124-58") console.log(lot, part) return lot.blPartNumber === part.internalPartId && (!lot.colorId || lot.colorId === part.colorId) }) if(coverageIndex !== -1) console.log(`Coverage for ${part.internalPartId} found!`) return coverageIndex === -1 }) }