Double-Entry Warehouse Management
Overview
The New-Years 2014/2015 inventory issues and cleanup I did got me thinking. We currently have a "sort of double-entry" accounting for stock movement going on, but it's incomplete, and thus not realizing all the benefits it could.
Currently, we have a virtual "Stock" location, and any stock in any sub-locations (Warehouse, etc) is counted as stock. When things get shipped out, the shipment-location gets moved to some other location outside of the Stock location. Physical counts can then be done by getting the total count of some item anywhere in the Stock container.
However, when stock comes in to the system, it's just created out of thin air and put in QC. When stock goes out for reasons other than shipments (shrink, etc), it disappears into the ether.
Doing it Right
Tracking stock should work like a real double-entry accounting system. Certain Locations/Containers should be allowed to have negative stock values, so that the sum of stock in all_locations should always be zero.
For Instance:
-
Each supplier would be represented by a Location. When we receive stock from that supplier, the quantity value of that SKU in the supplier-location should go down (and the quantity in the Stock location would go up by the same amount).
-
There would be a location for most types of "remove quantity" reason (Shrink, Damage, Destroyed, etc). When removing stock from the inventory, it would not be obliterated (as it is now), but moved to the appropriate location.
-
This setup will allow us to better track, audit, and control stock movements. All stock movements could be represented in the system by a kind of StockMove object, that would track the origin, destination, quantity, responsible-person, and other metadata about the movement.
-
We could improve our InventoryService class to consume these StockMove commands. It would validate them, emit messages (events) for related modules to consume (FIFO, etc), and ultimately perform the stock-movement on the database model, and crucially, log the StockMove.
So some locations would be faucets -- the source of stock -- and would maintain the invariant that they would always hold zero or negative stock.
Other location would be sinks -- where stock goes after it's sold. Example include "Customers" (a single where sold units go), "Damaged", "Shrink", etc.
Possible Implications
StockMove, in particular, might create some other ways to improve the internal model we use, beyond just keeping a complete audit log. For example, a Shipment (either inbound or outbound) is literally a stock-movement (or a collection of stock-movements).
Picking is moving stock, so a PickList could be represented a collection of StockMove objects (movement from a PickLocation to ShipmentContainer). Cartonization probably complicates this a bit, but not much. If a user wanted to do direct cartonization (at picking time), the system could just change the PickingStockMove.destination to point to a newly-created carton, and then execute it. Later-stage cartonization would just be a CartonizationStockMove.
When an order comes in, maybe we create a OutboundShipmentStockMove, in "planned" state. When shipment is actually confirmed, the InventoryService would "execute" that command, attaching whatever metadata (trackingNumber, etc) was accumulated throughout the process. In this way, Shipments, as we know them now, would simply be a collection of StockMoves composed with metadata.
This architecture also creates a greatly simplified model for infinite inventory. Products in infinite-mode would spring into existence from some faucet.
In the inbound side of things, submitting a PO could create a planned stock move from "Supplier Inventory" to "Supplier Egress" or similar, confirming the PO would execute that move. "Supplier Egress" would actually be an internal location (from the POV of the user), since they now own the yet-to-be-delivered stock. Uploading a shipping invoice from a supplier would create a planned StockMove from Supplier Egress to some internal stock location (Receiving, QC, Picking Area, etc). Or perhaps there would be an "Inbound Supplier Shipment" location (also "in stock", because we own it) as an intermediate step.
Supplier turn-around time, shipping times, etc, could all be attached to the StockMove entities as metadata to be sued as inputs into forecasting functions.
The Devil is (maybe) in PickLists
One of the most complicated bits of the current software is Picking. General Allocations are pretty simple to reason about, but when stock in a particular location is reserved for a PickList, things can currently get a bit hairy. Designating particular units to be picked currently results in records called ContainerItemAllocations (distinct from Allocations).
This bit of essentially functionality deserves careful consideration, and could throw a wrench into this nice StockMove business.
A Possible Solution
One idea that shows some promise is to designate a particular Container type as a Bin. A Bin would represent just what it sounds like, in most cases: a bin you can pick from. Technically speaking, a Bin would be a "leaf" on the location tree (it can contain only stock units, no nested containers).
So, consider a container tree similar to a setup we had in GZ at one time:
- Stock - The container representing all stock the company has
- Warehouse - A Container representing a physical warehouse
- QC
- Picking Area - A Container representing the main picking floor.
- Bin_1 - A Bin containing 100 units of SKU 500500
- Bin_2 - A Bin containing 50 units of SKU 500501
- Bin_3 - A Bin containing 50 units of SKU 500501
- Packing Area - A Container representing an area of the warehouse where products are staged for shipment
- Shipment_12 - A Shipment Container + Carton_1 + Carton_2
- Warehouse - A Container representing a physical warehouse
(Obviously, there could be more bins, more shipments, and more cartons within those Shipments.
Let's say Shipment_12 is to going to be 100 units of SKU 500500 and 75 units of 5005001. Upon shipment-creation, two StockMoves are created:
[
{ id: 'StockMove#100', state: 'planned', sku: '500500', origin: 'Picking Area', destination: 'Shipment_12', qty: '100' },
{ id: 'StockMove#101', state: 'planned', sku: '500501', origin: 'Picking Area', destination: 'Shipment_12', qty: '75' }
]
Note that the "origin"s are set to point at the "Picking Area" location, and not at any particular Bin. This provides General Allocation, because we define availableQty(Product p, Container c) as a function:
AvailableQty(Product p, Container c) -> [ Qty of p in c ] - [ Sum of StockMoves where: state='planned' AND (origin=c OR origin.contains(c) ]
(Note that "Qty of p in c" means the aggregate quantity of units, no matter how many levels nested.)
That seems pretty easy to reason about.
When we actually want to pick that stock, we need to realize those Allocations. That is to say we need to decide exactly which units to pick (down to the bin level).
Well, that should be easy. We just define a function R() that consumes a collection of StockMoves (S1), and returns a different collection (S2) where the 'origin' must always be a bin. Given our example warehouse situation:
R(S1) = [
{ id: 'StockMove#102', state: 'planned', sku: '500500', origin: 'Bin_1', destination: 'Shipment_12', qty: '100' },
{ id: 'StockMove#103', state: 'planned', sku: '500501', origin: 'Bin_2', destination: 'Shipment_12', qty: '50' },
{ id: 'StockMove#104', state: 'planned', sku: '500501', origin: 'Bin_3', destination: 'Shipment_12', qty: '25' }
]
And that right there is basically a PickList. While the StockMoves are still marked as 'planned', the following will all hold true:
- QtyAvailable('500500', 'Stock') = 0
- QtyAvailable('500500', 'PickingArea') = 0
- QtyAvailable('500500', 'Bin_1') = 0
- QtyAvailable('500501', 'PickingArea') = 25
- QtyAvailable('500501', 'Bin_2') = 0
- QtyAvailable('500501', 'Bin_3') = 25
Even better, when the PickList is reported (let's pretend it was 100% successful), the state of the StockMoves changes from planned => executed, and the stock-quantities are changed. But the StockMove record still exists in the database, providing us with a nice audit trail that we currently don't have.
Final Thoughts
This is not too far from what we have now, but it represents a great improvement in back-end simplicity and hopefully flexibility. The big differences are negative-qty locations for sources of stock, and the creation of the StockMove command class. Those changes could potentially provide a real audit log for stock movements, the ability to have multiple active pick-lists for a single shipment, and more.
Potential Pitfalls
-
What happens when a planned StockMove turns out to be physically impossible?
This happens all the time. How do we handle pick-list results in the double-entry system? It probably looks relatively similar to what we have today. We could simply modify the
plannedStockMoves to reflect reality before executing them. The only downside here is that we would lose some data about the originally-computed pick-list. However, we could "snapshot" pick-lists as a document of some sort, for archival/forensic purposes.Upon reflection, that seems to be the way to go. The pick-list should be memorialized upon creation, but can just be saved as a static data document (like a CSV file) that can be used later for analysis/forensics/etc. CSV is nice because we can consume it programmatically, or format it for display.