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

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
})
}