We live in the worlds of eBay.com, craigslist.org, stock markets, and bargain shopping via search engines. Yet programmers do not understand how a market works. The first principles of a market are pretty simple.
1. Not everyone values things the same. A lobster dinner looks good to me, but deadly to someone with a shellfish allergy.
2. Personal valuations change over time. I might eat a second lobster dinner immediately after the first one, but a third lobster is too much to eat right now. Call me next week, please. People do not trade unless they feel what they gain is more valuable than what they lose. I want that lobster dinner more than I want the $25 in my pocket. But I want $50 in my pocket more than a lobster in my belly.
Bids have to stop at some point in time, so there is usually a deadline. They can be sealed-bid, where each bidder has a fixed number of bids (usually one) that they traditionally submit in an envelope. Open bid auctions are more lively; the bidders call out their offers until the auctioneer closes the bidding. You can enter and leave the auction at will.
These days, we do it online, so we have “bid snatcher” software that automatically submits a bid that is under your preset limit. The usual strategy is to wait until the last possible minute (second? microsecond?) to get in a bid in so short a time slot that nobody can type fast enough to beat you.
Auctions come in all kinds of flavors, but the basic parts are an offering of something (I have here a first edition of CAPTAIN BILLY’S WHIZBANG, 1919! I start the bidding at $500!) and bids (I’ll pay $1000 for it!). The bid can be accepted or rejected. If the bid is accepted, the item changes ownership.
English Auctions are also known as increasing or ascending price auctions. The auctioneer asks for starting price and the bidders increase the price until the item sells or the price offered is not high enough and the auction fails. This is the form that most of us know. It is how eBay works. Sort of eBay is more complicated.
The Japanese auction is an ascending price auction with levels or tiers. Once you drop out, you cannot re-enter the bidding. For example, if the starting bid is $10, every interested bidder agrees to meet it. We then go up a level or $15; anyone who fails to meet the new level is out and cannot re-enter the bidding, even if they are willing to pay a higher price later. You have a lot of information—the number of other bidders at every level and their exit prices. The auction continues until only one bidder remains.
Dutch Auctions are also known as decreasing or descending price auctions. The auctioneer asks for high starting price, and then drops the price lower and lower in steps until the item clears or auction fails. This form of auction used to be popular with small retail stores decades ago. The items would be put in the store window with a flip-chart price tag and an announcement that the price would decrease $x per day until sold.
Today, the Dutch auction is hidden in online clearance sales. Instead of looking at the store window on Main Street, you get a series of email advertisements at lower and lower prices.
Obviously, a winner can have “buyer’s remorse”—the feeling that they paid too much for the item. If you have seen bidding wars on eBay or real life, you understand how emotions can get mixed up in the heat of the moment.
The solution is Vickrey or second price auctions. The highest bidder wins, but the price paid is between the highest and second highest bids. The winner cannot be unhappy; he paid less than his bid and won. The losers cannot be unhappy; they all bid less than the winner.
The eBay proxy bid system is a form of second price auction. A variant of a Vickrey auction, named generalized second-price auction, is used in Google’s and Yahoo!’s online advertisement programs. The computer runs hundreds or thousands of auctions for the on-line adverting slots they sell.
What would the basic tables be? Let’s start with the bidders and a simple skeleton.
CREATE TABLE Bidders (bidder_id INTEGER NOT NULL PRIMARY KEY, ..)
When we log in an item for an auction, we need to identify the item, get a starting bid amount. The minimum bid is also called the reserve amount. If there is no bid, equal or greater than that amount, the auction is void.
Initially we do not have
CREATE TABLE Items (item_id INTEGER NOT NULL PRIMARY KEY, item_name VARCHAR(25) NOT NULL, initial_bid_amt DECIMAL (12,2) NOT NULL CHECK(initial_bid_amt >= 0.00), minimum_bid_amt DECIMAL (12,2) NOT NULL CHECK(initial_bid_amt >= 0.00));
A bid has to be timestamped and the bid amounts have to increase over time.
CREATE TABLE Bids (item_id INTEGER NOT NULL REFERENCES Items (item_id), bidder_id INTEGER NOT NULL REFERENCES Bidders (bidder_id), bid_timestamp TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP NOT NULL, bid_amt DECIMAL (12,2) NOT NULL CHECK(bid_amt >= 0.00), PRIMARY KEY (item_id, bidder_id, bid_timestamp) );
I recommend using a bid insertion procedure to ensure that bids are always increasing (or decreasing in the case of a Dutch auction) rather than in the DDL. The reason is that a bid can be pulled from the bids table.
Imagine a very simple inventory of one kind of item, Widgets, into which we add stock once a day. This inventory is then used to fill orders that also come in once a day. Here is the table for this toy problem.
CREATE TABLE WidgetInventory (receipt_nbr INTEGER NOT NULL PRIMARY KEY, purchase_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, qty_on_hand INTEGER NOT NULL CHECK (qty_on_hand >= 0), unit_price DECIMAL (12,4) NOT NULL);
With the following data:
WidgetInventory
Receipt_nbr | Purchase_date | Qty_on_hand | Unit_price |
1 | '2017-08-01' | 15 | 10 |
2 | '2017-08-02' | 25 | 12 |
3 | '2017-08-03' | 40 | 13 |
4 | '2017-08-04' | 35 | 12 |
5 | '2017-08-05' | 45 | 10 |
The business now sells 100 units on 2017-08-05. How do you calculate the value of the stock sold? There is not one right answer, but here are some options:
1. Use the current replacement cost, which is $10.00 per unit as of 2017-08-05. That would mean the sale cost us only $1000.00 because of a recent price break.
2. Use the current average price per unit. We have a total of 160 units, for which we paid a total of $1840.00; that gives us an average cost of $11.50 per unit, or $1150.00 in total inventory costs.
3. Use LIFO, which stands for, “Last In, First Out”. We start by looking at the most recent purchases and work backwards through time.
2017-08-05: 45 * $10.00 = $450.00 and 45 units 2017-08-04: 35 * $12.00 = $420.00 and 80 units 2017-08-03: 20 * $13.00 = $260.00 and 100 with 20 units left over
For a total of $1130.00 in inventory cost.
4. Use FIFO, which stands for “First In, First Out”. We start by looking at the earliest purchases and work forward through time.
2017-08-01: 15 * $10.00 = $150.00 and 15 units 2017-08-02: 25 * $12.00 = $300.00 and 40 units 2017-08-03: 40 * $13.00 = $520.00 and 80 units 2017-08-04: 20 * $12.00 = $240.00 with 15 units left over
For a total of $1210.00 in inventory costs.
The first two scenarios are trivial to program.
CREATE VIEW (current_replacement_cost) AS SELECT unit_price FROM WidgetInventory WHERE purchase_date = (SELECT MAX(purchase_date) FROM WidgetInventory); CREATE VIEW (average_replacement_cost) AS SELECT SUM(unit_price * qty_on_hand)/SUM(qty_on_hand) FROM WidgetInventory;
The LIFO and FIFO are more interesting because they involve looking at matching the order against the blocks of inventory in a particular order.
Consider this VIEW:
CREATE VIEW LIFO (stock_date, unit_price, tot_qty_on_hand, tot_cost) AS SELECT W1.purchase_date, W1.unit_price, SUM(W2.qty_on_hand), SUM(W2.qty_on_hand * W2.unit_price) FROM WidgetInventory AS W1, WidgetInventory AS W2 WHERE W2.purchase_date <= W1.purchase_date GROUP BY W1.purchase_date, W1.unit_price;
A row in this VIEW tells us the total quantity on hand, the total cost of the goods in inventory, and what we were paying for items on each date. The quantity on hand is a running total. We can get the LIFO cost with this query:
SELECT (tot_cost - ((tot_qty_on_hand - :order_qty) * unit_price)) AS cost FROM LIFO AS L1 WHERE stock_date = (SELECT MIN(stock_date) FROM LIFO AS L2 WHERE tot_qty_on_hand >= :order_qty);
This is straight algebra and a little logic. You need to find the most recent date when we had enough (or more) quantity on hand to meet the order. If, by dumb blind luck, there is a day when the quantity on hand exactly matched the order, return the total cost as the answer. If the order was for more than we have in stock, then return nothing. If we go back to a day when we had more in stock than the order was for, look at the unit price on that day, multiply by the overage, and subtract it.
Alternatively, you can use a derived table and a CASE expression. The CASE expression computes the cost of units that have a running total quantity less than the :order_qty and then performs algebra on the final block of inventory, which would put the running total over the limit. The outer query does a sum on these blocks:
SELECT SUM(W3.v) AS cost FROM (SELECT W1.unit_price * CASE WHEN SUM(W2.qty_on_hand) <= :order_qty THEN W1.qty_on_hand ELSE :order_qty - (SUM(W2.qty_on_hand) - W1.qty_on_hand) END FROM WidgetInventory AS W1, WidgetInventory AS W2 WHERE W1.purchase_date <= W2.purchase_date GROUP BY W1.purchase_date, W1.qty_on_hand, W1.unit_price HAVING (SUM(W2.qty_on_hand) - W1.qty_on_hand) <= :order_qty) AS W3(v);
FIFO can be found with a similar VIEW or derived table:
CREATE VIEW FIFO (stock_date, unit_price, tot_qty_on_hand, tot_cost) AS SELECT W1.purchase_date, W1.unit_price, SUM(W2.qty_on_hand), SUM(W2.qty_on_hand * W2.unit_price) FROM WidgetInventory AS W1, WidgetInventory AS W2 WHERE W2.purchase_date >= W1.purchase_date GROUP BY W1.purchase_date, W1.unit_price;
With the corresponding query:
SELECT (tot_cost - ((tot_qty_on_hand - :order_qty) * unit_price)) AS cost FROM FIFO AS F1 WHERE stock_date = (SELECT MAX (stock_date) FROM FIFO AS F2 WHERE tot_qty_on_hand >= :order_qty);
These queries and VIEWs only told us the value of the Widget inventory. Notice that we never actually shipped anything from the inventory.
How to write the UPDATE statements that let us change this simple inventory.
What we did not do in part 1 was to actually update the inventory when the Widgets were shipped out. Let’s build another VIEW that will make life easier.
CREATE VIEW StockLevels (purchase_date, previous_qty, current_qty) AS SELECT W1.purchase_date, SUM(CASE WHEN W2.purchase_date < W1.purchase_date THEN W2.qty_on_hand ELSE 0 END), SUM(CASE WHEN W2.purchase_date <= W1.purchase_date THEN W2.qty_on_hand ELSE 0 END) FROM WidgetInventory AS W1, WidgetInventory AS W2 WHERE W2.purchase_date <= W1.purchase_date GROUP BY W1.purchase_date, W1.unit_price; StockLevels
Purchase_date | Previous_qty | Current_qty |
'2017-08-01' | 0 | 15 |
'2017-08-02' | 15 | 40 |
'2017-08-03' | 40 | 80 |
'2017-08-04' | 80 | 115 |
'2017-08-05' | 115 | 160 |
Using CASE expressions will save you a self-join.
CREATE PROCEDURE RemoveQty (IN my_order_qty INTEGER) LANGUAGE SQL BEGIN IF my_order_qty > 0 THEN UPDATE WidgetInventory SET qty_on_hand = CASE WHEN my_order_qty >= (SELECT current_qty FROM StockLevels AS L WHERE L.purchase_date = WidgetInventory.purchase_date) THEN 0 WHEN my_order_qty < (SELECT previous_qty FROM StockLevels AS L WHERE L.purchase_date = WidgetInventory.purchase_date) THEN WidgetInventory.qty_on_hand ELSE (SELECT current_qty FROM StockLevels AS L WHERE L.purchase_date = WidgetInventory.purchase_date) - my_order_qty END; END IF; -- remove empty bins DELETE FROM WidgetInventory WHERE qty_on_hand = 0; END;
Another inventory problem is how to fill an order with the smallest or greatest number of bins. This assumes that the bins are not in order, so you are free to fill the order as you wish. Using the fewest bins would make less work for the order pickers. Using the greatest number of bins would clean out more storage in the warehouse.
For example, with this data, you could fill an order for 80 widgets by shipping out bins (1, 2, 3) or bins (4, 5). These bins happen to be in date and bin number order in the sample data, but that is not required.
Mathematicians call it (logically enough) a bin-packing problem and it belongs to the NP-complete family of problems. This kind of problem is too hard to solve for the general case because the work requires trying all the combinations, and this increases too fast for a computer to do it.
However, there are “greedy algorithms” that are often nearly optimal. The idea is to begin by taking the “biggest bite” you can until you have met or passed your goal. In procedural languages, you can back-track when you go over the target amount and try to find an exact match or apply a rule that dictates from which bin to take a partial pick.
This is not easy in SQL, because it is a declarative, set-oriented language. A procedural language can stop when it has a solution that is “good enough,” while an SQL query tends to find all of the correct answers no matter how long it takes. If you can put a limit on the number of bins you are willing to visit, you can fake an array in a table:
CREATE TABLE Picklists (order_nbr INTEGER NOT NULL PRIMARY KEY, goal_qty INTEGER NOT NULL CHECK (goal_qty > 0), bin_nbr_1 INTEGER NOT NULL UNIQUE, qty_on_hand_1 INTEGER DEFAULT 0 NOT NULL CHECK (qty_on_hand_1 >= 0), bin_nbr_2 INTEGER NOT NULL UNIQUE, qty_on_hand_2 INTEGER DEFAULT 0 NOT NULL CHECK (qty_on_hand_2 >= 0), bin_nbr_3 INTEGER NOT NULL UNIQUE, qty_on_hand_3 INTEGER DEFAULT 0 NOT NULL CHECK (qty_on_hand_3 >= 0), CONSTRAINT not_over_goal CHECK (qty_on_hand_1 + qty_on_hand_2 + qty_on_hand_3 <= goal_qty) CONSTRAINT bins_sorted CHECK (qty_on_hand_1 >= qty_on_hand_2 AND qty_on_hand_2 >= qty_on_hand_3));
Now you can start stuffing bins into the table. This query will give you the ways to fill or almost fill an order with three or fewer bins. The first trick is to load some empty dummy bins into the table. If you want at most (n) picks, then add (n-1) dummy bins:
INSERT INTO WidgetInventory VALUES (-1, '1990-01-01', 0 ,0.00), (-2, '1990-01-02', 0 ,0.00);
The following code shows how to build a common table expression (CTE) or VIEW with the possible pick lists:
CREATE VIEW PickCombos(total_pick, bin_1, qty_on_hand_1, bin_2, qty_on_hand_2, bin_3, qty_on_hand_3) AS SELECT DISTINCT (W1.qty_on_hand + W2.qty_on_hand + W3.qty_on_hand) AS total_pick, CASE WHEN W1.receipt_nbr < 0 THEN 0 ELSE W1.receipt_nbr END AS bin_1, W1.qty_on_hand, CASE WHEN W2.receipt_nbr < 0 THEN 0 ELSE W2.receipt_nbr END AS bin_2, W2.qty_on_hand, CASE WHEN W3.receipt_nbr < 0 THEN 0 ELSE W3.receipt_nbr END AS bin_3, W3.qty_on_hand FROM WidgetInventory AS W1, WidgetInventory AS W2, WidgetInventory AS W3 WHERE W1.receipt_nbr NOT IN (W2.receipt_nbr, W3.receipt_nbr) AND W2.receipt_nbr NOT IN (W1.receipt_nbr, W3.receipt_nbr) AND W1.qty_on_hand >= W2.qty_on_hand AND W2.qty_on_hand >= W3.qty_on_hand;
Now you need a procedure to find the pick combination that meets or comes closest to a certain quantity.
CREATE PROCEDURE OverPick (IN goal_qty INTEGER) LANGUAGE SQL BEGIN IF goal_qty > 0 THEN SELECT goal_qty, total_pick, bin_1, qty_on_hand_1, bin_2, qty_on_hand_2, bin_3, qty_on_hand_3 FROM PickCombos WHERE total_pick = (SELECT MIN (total_pick) FROM PickCombos WHERE total_pick >= goal_qty) END IF; END;
The VIEW could be put into a CTE and produce a query without a VIEW. With the current data and goal of 73 Widgets, you can find two picks that together equal 75, namely {3, 4} and {4, 2, 1}.
I will leave it as an exercise for the reader to find a query that under-picks a target quantity.
3.128.198.60