You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
189 lines
8.4 KiB
JavaScript
189 lines
8.4 KiB
JavaScript
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
|
|
})
|
|
|
|
} |