| ← All Docs Inventory Logic

Inventory Logic

Stock system, FIFO lots, guards, expiry, adjustments, and alerts

Three-Level Stock System

AquaDealers uses a three-tiered inventory architecture. Each level serves a distinct purpose — from the master stock count down to the immutable audit trail.

Level 1: inventory — Master Stock Level

Purpose

One row per (dealer, branch, product) combination. This is the source of truth for current stock quantity and default pricing.

ColumnTypeDescription
idUUIDPrimary key
dealer_idUUIDOwning dealer
branch_idUUIDBranch location
product_idUUIDProduct reference
quantity_in_stocknumericCurrent available quantity
cost_pricenumericLatest cost price (from most recent purchase)
selling_pricenumericDefault selling rate for feed products
mrpnumericMaximum retail price (medicines)
medicine_discount_percentagenumericDefault discount % for medicine products
min_stock_alertnumericLow-stock alert threshold
track_expirybooleanWhether expiry tracking is enabled (typically true for medicines)
expiry_datedateLegacy expiry field (lot-level expiry preferred)
Unique constraint: (dealer_id, branch_id, product_id) — ensures exactly one inventory row per product per branch per dealer. Duplicate rows are prevented at the database level.

Level 2: inventory_lots — FIFO Lot Tracking

Purpose

Each stock purchase creates one lot. Lots are consumed in FIFO order (earliest expiry first, then earliest received). This enables accurate COGS calculation and expiry management.

ColumnTypeDescription
idUUIDPrimary key
inventory_idUUIDFK to inventory table
stock_purchase_idUUIDFK to the purchase that created this lot
batch_numbertextManufacturer batch/lot number
expiry_datedateLot expiry date (null for non-expiry-tracked items)
quantity_receivednumericOriginal quantity when the lot was created
remaining_quantitynumericCurrent remaining quantity (decreases as lots are consumed)
cost_pricenumericCost price at time of purchase (for COGS)
selling_pricenumericSelling price at time of purchase
mrpnumericMRP at time of purchase
received_attimestamptzWhen the lot was received
is_expiredbooleanWhether the lot has been marked as expired
expired_attimestamptzWhen the lot was marked expired

Consumption order: expiry_date ASC, received_at ASC — earliest expiry first, then earliest received.

Level 3: inventory_movements — Immutable Audit Trail

Purpose

Every stock change — regardless of source — creates an immutable movement record. This is the complete audit trail for all inventory changes.

ColumnTypeDescription
idUUIDPrimary key
inventory_idUUIDFK to inventory table
product_idUUIDProduct reference (denormalized for queries)
lot_idUUIDFK to inventory_lots (if lot-level operation)
reference_typetextType of operation that caused the movement
reference_idUUIDID of the originating record
quantity_changenumericSigned quantity change (+increase, −decrease)
notestextOptional description
created_attimestamptzTimestamp of the movement

Reference Types

reference_typeDirectionDescription
bill− decreaseStock sold to a farmer
purchase+ increaseStock received from a supplier
bill_cancellation+ increaseStock returned from a cancelled bill
bill_edit± eitherStock adjustment from editing a bill’s quantities
manual_adjustment± eitherManual stock correction by dealer
bill_return+ increaseStock returned by farmer
transfer_out− decreaseStock transferred to another branch
transfer_in+ increaseStock received from another branch

FIFO Lot Consumption

How Lots Are Consumed During Billing

  1. Bill is created via create_bill_v2 RPC
  2. For each bill item, consume_inventory_lots is called
  3. Lots are fetched ordered by expiry_date ASC, received_at ASC
  4. Lots are deducted one by one until the requested quantity is fulfilled
  5. Each lot deduction creates a bill_item_lot_allocations record (lot_id, allocated_quantity)
  6. Each lot deduction creates an inventory_movements record
  7. The parent inventory.quantity_in_stock is decremented

Bill Item Lot Allocations

The bill_item_lot_allocations table records exactly which lots were consumed for each bill item. This is critical for:

FIFO Preview

The preview_fifo_bill_lines function provides a read-only preview of which lots would be consumed before the bill is actually saved. This lets the dealer see the cost basis and lot details before committing.

Stock Guards

Multiple layers of protection prevent inventory from going negative or being corrupted by concurrent operations.

Negative Stock Prevention Hard Block

create_bill_v2 raises an "Insufficient stock" error if quantity_in_stock < requested quantity. The bill is not created.

Adjustment Guard Hard Block

adjust_inventory_stock_v1 checks that the resulting quantity (new_qty) won't be negative. Rejects the adjustment if new_qty < 0.

Row-Level Locking Concurrency

During bill creation, the inventory row is locked with FOR UPDATE. This prevents race conditions where two simultaneous bills could both read the same stock level and both succeed, resulting in negative stock.

How row locking works

PostgreSQL's SELECT ... FOR UPDATE acquires a row-level exclusive lock. If two transactions try to lock the same inventory row simultaneously, the second one waits until the first commits or rolls back. This serializes concurrent stock deductions for the same product and guarantees the stock check is atomic with the deduction.

Auto-Fanout Triggers

When new products or branches are created, inventory rows are automatically scaffolded so that every branch always has an inventory entry for every product.

trg_fanout_product_to_branches

Fires on: New product created

Action: Creates empty inventory rows (quantity = 0) in all active branches for the new product.

trg_fanout_branch_to_products

Fires on: New branch created

Action: Creates empty inventory rows (quantity = 0) for all existing products in the new branch.

These triggers ensure the inventory matrix is always complete. Dealers never need to manually "add" a product to a branch — it's there as soon as the product or branch is created, ready for stock to be added via purchases.

Expiry Management

How Expiry Tracking Works

Expiry Processing

RPCPurpose
process_expired_inventory_lotsBatch job to scan and flag expired lots
mark_lot_as_expiredMarks a specific lot as expired (sets is_expired = true, expired_at = now())
Expired lots are skipped during FIFO consumption. When consume_inventory_lots fetches lots for deduction, it filters out lots where is_expired = true. This prevents selling expired medicines to farmers.

Dashboard Expiry Widgets

The dashboard shows medicines approaching expiry in configurable windows:

Stock Adjustments

adjust_inventory_stock_v1 RPC

Allows dealers to manually correct stock quantities outside of normal purchase/sale flows.

Adjustment Types

TypeDirectionUse Case
Increase+ stockFound damaged items intact, physical count correction (more than system)
Decrease− stockDamaged goods, lost items, expired stock, physical count correction (less than system)

Rate Adjustments

Bulk Rate Change Route: /inventory/rate-adjustment

Dealers can update pricing for multiple products at once.

Rate change impact on existing bills

Rate changes only affect future bills. Existing bills retain the rates they were created with. The per-farmer rate memory (upsertFarmerProductDiscount) will still hold the old rate until the farmer's next bill overwrites it with the new rate.

Low Stock Alerts

How Low Stock Alerts Work

Low stock alerts are passive — they appear on the dashboard as a list of items below threshold. There are no push notifications or automatic reorder functionality. Dealers check the dashboard to see what needs restocking.