Browse Templates/Inventory Management System

Inventory Management System

A comprehensive warehouse inventory management database schema suitable for retail, manufacturing, or distribution businesses. Includes multi-warehouse support, product catalog with SKU tracking, real-time stock movement logging (inbound, outbound, transfer, adjustment), supplier management, and purchase order workflow with line items. Designed to maintain accurate stock levels and provide full traceability of inventory changes across facilities.

PostgreSQL11 tablesE-Commerce
inventorywarehousestocksupplierspurchase-orderslogistics
Created with ERD Studio

About this schema

There are two answers to how much stock is there, and both are stored. stock_levels is the current quantity per product per warehouse, read on every screen. stock_movements is the append-only log of every change, with a movement_type and a reference back to whatever caused it. The first is fast and can be wrong; the second is slow and cannot, which is why inventory_audits exists to reconcile them.

Quantity is split into quantity and reserved_quantity on the same row. Reserved stock is physically present but already promised, so it must not be counted as available to sell and must not be treated as missing during a count. One number cannot carry both meanings without the application guessing which one it meant.

Receiving is modelled as three separate things because they fail separately. purchase_orders is what was ordered from a supplier, shipments is what is physically in transit with a carrier and tracking number, and purchase_order_items.quantity_received is what actually arrived. A short delivery is a normal state here, not an exception that breaks the order.

Key relationships

warehouses → warehouse_zones → stock_levels
Stock is located to a zone within a warehouse, so a pick list can send someone to a place rather than to a building.
products → stock_levels / stock_movements
One product has a level row per warehouse and a movement row per change. The level is derivable from the movements, and is kept anyway.
suppliers → purchase_orders → purchase_order_items
The order names the supplier and the destination warehouse; the lines carry quantity_ordered against quantity_received.
purchase_orders → shipments
One order can arrive in several shipments, each with its own carrier and tracking number, which is why this is not a column on the order.
stock_movements → reference_type + reference_id
A deliberate polymorphic link so one log can cite a purchase order, a sale or an audit without a column for each.
product_categories → product_categories (parent_id)
Self-referencing hierarchy, so a category tree can be deepened without a schema change.

Design decisions

Keep both the level and the log

Storing only stock_movements is the pure answer — the quantity is a sum — but it makes every product listing a running total over a table that grows forever. Storing only stock_levels makes the screens fast and leaves no way to explain how a number got there. Keeping both means a write touches two places in one transaction, and inventory_audits is the mechanism that catches the moment they disagree.

reserved_quantity is a column, not a separate reservations table

A reservations table would record who holds what and until when, which is the richer model. One column instead keeps the availability calculation to a single subtraction on the row already being read. The cost is that the schema cannot say who reserved it — if allocations need to be released by owner or expired individually, this column is the thing that has to grow into a table.

stock_movements uses a polymorphic reference

Movements are caused by purchases, sales, transfers and corrections, each living in a different table. Columns for each would leave most of them null on every row, and new causes would mean new columns. reference_type with reference_id keeps one shape at the price of no foreign key — nothing stops a dangling reference, so the application owns that integrity.

Audits store expected and actual, not just the fix

inventory_audits keeps expected_quantity, actual_quantity and the discrepancy rather than silently correcting stock_levels. The discrepancy is the useful figure: it is how shrinkage, miscounts and receiving errors are found. A correction with no record of what it corrected removes the only evidence that something is going wrong in a zone.

Tables in this template

warehousesWarehouses
9 cols
warehouse_zonesWarehouse Zones
7 cols
productsProducts
11 cols
product_categoriesProduct Categories
5 cols
stock_levelsStock Levels
8 cols
stock_movementsStock Movements
10 cols
suppliersSuppliers
11 cols
purchase_ordersPurchase Orders
11 cols
purchase_order_itemsPO Line Items
7 cols
shipmentsShipments
10 cols
inventory_auditsInventory Audits
9 cols

Frequently asked

Why not calculate stock from stock_movements every time?
Because the sum grows without bound. A product with years of history would make a simple stock listing scan millions of rows. stock_levels is the cached answer, and the movement log is what proves or repairs it.
How do I know what is actually sellable?
quantity minus reserved_quantity on the stock_levels row. Reserved units are on the shelf but already committed, so treating quantity alone as availability is what causes a sale of stock that is about to ship elsewhere.
Can one purchase order arrive in several deliveries?
Yes. shipments keys on the purchase order, so each delivery is its own row with its own tracking, and the line's quantity_received accumulates until it matches quantity_ordered — or does not, which is the short-ship case.
Where is reorder logic in this schema?
products.reorder_level holds the threshold and suppliers.lead_time_days holds how long replenishment takes. The comparison against stock_levels is a query the application runs; the schema stores the inputs rather than the decision.
Why does stock_movements have no foreign key for its reference?
Because the referenced row can be in any of several tables. That is the cost of one log covering every cause — the database cannot enforce it, so the writing code must, and an orphaned reference is possible.