January 27, 2026

Item Master vs Stock Receipts: Where Does Package Info Belong?

A user asked a simple question: "I have 50 KG of cooking oil to add. Should I name it '50 KG Cooking Oil'?"

The answer is no - just call it "Cooking Oil." But this leads to a deeper question: If both 50 KG bags and 100 KG bags are just "Cooking Oil", what differentiates them?

Two Concepts, Two Roles

Item Master (InventoryItem)

The Item Master defines WHAT something is:

FieldPurpose
Name"Cooking Oil"
SKU"CKO-001"
Unit of MeasureLitre (L)
CategoryCooking Oils
SupplierDefault supplier
Reorder Level20 L

The item master is the identity of the product. It doesn't change based on how you buy it.

Stock Receipt (GRN / Quick Add)

The Stock Receipt records HOW you received it:

FieldPurpose
Packages Received2
Package Size50 L per jerrycan
Package Cost6,500 KES per jerrycan
Total Quantity100 L
Unit Cost130 KES/L
Batch NumberBATCH-2026-01
Expiry Date2027-01-15

The receipt is a transaction - it varies each time you buy.

The Problem With Package Info on Item Master

Current Design

InventoryItem: Cooking Oil
├── package_size: 50         ← Fixed!
├── package_unit: JERRYCAN
├── package_cost: 6,500
└── cost_price: 130/L

What Happens When Reality Changes?

WeekWhat You BuyProblem
Week 12 × 50L jerrycan @ 6,500Works fine
Week 21 × 100L drum @ 12,000Must edit master!
Week 34 × 20L jerrycan @ 2,800Edit again!

You're forced to constantly update the item master, losing historical data about what you actually received.

The Correct Architecture

Package Info Belongs on the Receipt

InventoryItem: Cooking Oil    ← Identity only
├── name: Cooking Oil
├── SKU: CKO-001
├── UOM: Litre
└── cost_price: 128.50/L      ← Weighted average

Receipt #1 (Jan 15):          ← Package info here
├── packages: 2
├── package_size: 50 L
├── package_unit: JERRYCAN
├── package_cost: 6,500
├── total_qty: 100 L
└── unit_cost: 130/L

Receipt #2 (Jan 22):          ← Different package, same item
├── packages: 1
├── package_size: 100 L
├── package_unit: DRUM
├── package_cost: 12,000
├── total_qty: 100 L
└── unit_cost: 120/L

Stock Balance: 200 L @ avg 125/L

How This Works for Different Users

Enterprise Mode: Full GRN Process

Large retailers use the complete Goods Received Note workflow:

Purchase Order
    ↓
Goods Received Note (GRN)
├── Supplier: Bidco Kenya
├── PO Reference: PO-2026-0042
├── Items:
│   ├── Cooking Oil
│   │   ├── packages_received: 2
│   │   ├── package_size: 50 L
│   │   ├── package_unit: JERRYCAN
│   │   ├── package_cost: 6,500
│   │   ├── quantity_received: 100 L
│   │   ├── quantity_accepted: 100 L
│   │   ├── batch_number: BATCH-2026-01
│   │   └── expiry_date: 2027-01-15
│   └── Sugar
│       ├── packages_received: 5
│       ├── package_size: 50 KG
│       └── ...
└── Complete GRN → Stock Updated

Benefits:

  • Full audit trail
  • Quality checks (accepted vs rejected)
  • Batch/expiry tracking
  • Supplier accountability
  • Discrepancy notes

Duka Mode: Quick Add Stock

Small shops use the simplified quick adjustment:

Quick Add Stock
├── Item: Cooking Oil
├── Adjustment Type: ADD
├── Quantity: 100 L
├── Unit Cost: 130/L
├── Reason: Opening Stock
└── Submit → Stock Updated Immediately

Benefits:

  • No purchase order needed
  • Single API call
  • Auto-approved
  • Instant stock update
  • Perfect for opening stock

The Duka Challenge: Where's Package Info?

Current Quick Add Schema

QuickStockAdjustmentSchema:
    - inventory_item_id (required)
    - adjustment_type: ADD/SUBTRACT/SET
    - quantity (in base units)
    - unit_cost (optional)
    - reason
    - notes

Missing: No package fields!

A duka owner buying 2 jerrycans of 50L each must:

  1. Calculate: 2 × 50 = 100 L
  2. Calculate: 6,500 ÷ 50 = 130/L
  3. Enter: quantity=100, unit_cost=130

They can't just say: "I bought 2 jerrycans at 6,500 each."

Enhanced Quick Add (Proposed)

QuickStockAdjustmentSchema:
    - inventory_item_id (required)
    - adjustment_type: ADD/SUBTRACT/SET

    # Option A: Direct quantity entry (current)
    - quantity (in base units)
    - unit_cost

    # Option B: Package entry (new)
    - packages_received
    - package_size
    - package_unit
    - package_cost
    # System calculates: quantity = packages × size
    # System calculates: unit_cost = package_cost ÷ size

    - reason
    - notes

UI can show:

┌─ Add Stock: Cooking Oil ────────────────────┐
│                                              │
│  ○ Enter by quantity                         │
│    Quantity: [____] L  Cost: [____]/L        │
│                                              │
│  ● Enter by package                          │
│    Packages: [2]  Size: [50] L               │
│    Cost per package: [6,500] KES             │
│                                              │
│    → Adding 100 L at 130 KES/L               │
│                                              │
│  [Cancel]                    [Add Stock]     │
└──────────────────────────────────────────────┘

Weighted Average Cost

When you receive stock at different costs, the system calculates weighted average:

Before: 100 L @ 130/L = 13,000 value
Receipt: 100 L @ 120/L = 12,000 value

After: 200 L, Total value = 25,000
New avg cost = 25,000 ÷ 200 = 125/L

This works regardless of whether package info is captured - but capturing packages gives you:

  • Historical accuracy
  • Supplier analysis
  • Reorder intelligence ("We usually buy 50L jerrycans")

Implementation Recommendations

For Enterprise Mode (GRN)

Add to GoodsReceivedItem model:

# Package info captured at receipt time
packages_received = Column(Numeric(10, 2))
package_size = Column(Numeric(15, 3))
package_unit = Column(String(50))
package_cost = Column(Numeric(15, 2))

For Duka Mode (Quick Add)

Add optional package fields to QuickStockAdjustmentSchema:

# Optional: Enter by package instead of quantity
packages = fields.Decimal(allow_none=True)
package_size = fields.Decimal(allow_none=True)
package_unit = fields.String(allow_none=True)
package_cost = fields.Decimal(allow_none=True)

Service calculates:

if data.get('packages') and data.get('package_size'):
    quantity = data['packages'] * data['package_size']
    unit_cost = data['package_cost'] / data['package_size']
else:
    quantity = data['quantity']
    unit_cost = data.get('unit_cost')

Keep Default Package on Item Master

For convenience, keep default package info on InventoryItem:

  • Pre-fills the receipt form
  • Suggests "You usually buy this in 50L jerrycans"
  • Can be overridden per receipt

Summary

ConceptWhat It StoresChanges When
Item MasterIdentity (name, SKU, UOM)Rarely (new product, rename)
Stock ReceiptTransaction (packages, cost, batch)Every purchase
Package InfoHow you bought itPer receipt

The Item Master defines WHAT something is. The Receipt defines HOW you got it.

Sugar is sugar whether it comes in 50 KG bags or 100 KG bags. The packaging is a property of the transaction, not the product.

Quick Reference

"Should I name it '50 KG Sugar'?"

No. Name it "Sugar."

  • 50 KG is the package size → goes on receipt
  • Sugar is the product → goes on item master

"What if I buy different sizes?"

Each receipt records its own package info:

  • Receipt 1: 2 × 50 KG bags
  • Receipt 2: 1 × 100 KG bag
  • Same item master, different receipts

"What about dukas using quick add?"

Two options:

  1. Current: Enter calculated quantity and unit cost
  2. Enhanced: Enter packages and let system calculate

Both work - enhanced is more intuitive for shop owners who think in "bags" not "kilograms."


This architecture serves both the duka owner who just needs to add stock quickly, and the enterprise retailer who needs full procurement audit trails. Same data model, different interfaces.