Chapter 713 min read

Inventory & Product Management Architecture for Khoji Nepal

Inventory & Product Management Architecture for Khoji Nepal

A synthesized report combining research from ChatGPT, Gemini Deep Research, and independent Claude analysis — distilled into a clear, actionable architecture for our Laravel + Next.js hyperlocal marketplace.


1. Where All Three Sources Agree (The Universal Truths)

All three research reports converge on the same foundational principles. This is where we should place our confidence:

✅ Model by BEHAVIOR, not by CATEGORY

[!IMPORTANT] Don't think: "grocery, fashion, electronics, service, digital" Think: "how is this item sold, fulfilled, priced, tracked, and delivered?"

This is the single most important insight. The word "electronics" or "grocery" is just a human label. What actually matters to the database is:

  • Does this thing have physical stock? → affects checkout
  • Does it expire? → affects fulfillment ordering
  • Does each unit need individual tracking? → affects the database row structure
  • Is it a time-slot? → affects availability UI

✅ Template → Variant → Offer (The Universal Data Model)

Every system studied (Odoo, ERPNext, Saleor, Medusa, Shopify, Magento) uses this 3-layer pattern:

ProductTemplate (the concept: "Nike Air Max")
  └── ProductVariant (the purchasable thing: "Nike Air Max, Size 42, Black")
        └── VendorOffer (what a specific vendor sells: price, stock, delivery zone)

✅ Hybrid Relational + JSON for Attributes

All three reports reject pure EAV (Entity-Attribute-Value) at scale. The consensus:

  • Fixed columns for universal fields (name, price, status, vendor_id, category_id)
  • JSON/JSONB column for dynamic, category-specific attributes (expiry_date, warranty_months, screen_size)
  • Relational tables for inventory, variants, batches, and serials

2. The 5 Product Archetypes (What Can Be Sold)

These are the fundamental "types of things" your system needs to support. Every product in the world falls into one of these:

#ArchetypeStock ModelFulfillmentNepal Examples
1Storable PhysicalInteger count per locationShip/PickupClothing, utensils, stationery
2Perishable PhysicalBatch/Lot with expiry datesShip/Pickup (FEFO)Fruits, vegetables, dairy, meat, medicines
3Serialized PhysicalIndividual unit trackingShip/Pickup + WarrantyLaptops, phones, AC units, motorcycles
4ServiceTime-slot / CapacitySchedule/BookingHaircuts, plumbing, tutoring, photography
5DigitalInfinite (or license-counted)Download/AccessE-books, software, online courses

[!NOTE] Bundles (combo packs, gift sets) and Rentals are not separate archetypes — they are modifiers on top of the base archetypes. A "Computer Set" bundle is just a virtual product that, upon sale, decrements the stock of its child storable products.


3. The 4 Tracking Behaviors (How Stock Is Counted)

This is orthogonal to the archetype. It answers: "When a sale happens, what happens in the database?"

Level 0: none — No Tracking

Used for: Services, Digital products
What happens on sale: Nothing inventory-related. Just creates an order.
DB impact: No inventory tables touched.

Level 1: quantity — Simple Count

Used for: Grocery items, stationery, clothing, most physical goods
What happens on sale: stock_on_hand -= quantity_ordered
DB impact: UPDATE inventory_items SET quantity_on_hand = quantity_on_hand - 1
Alert: When stock falls below reorder_point

This is the answer to your "grocery store" question. Nobody tracks individual noodle packets. They track the SKU count:

  • Wai Wai Chicken 75g → Stock: 1,200 packets
  • Customer buys 3 → Stock: 1,197 packets
  • When stock hits 200 → Auto-alert: "Reorder 500 packets"

Level 2: batch — Lot/Batch Tracking

Used for: Perishable food, dairy, medicine, cosmetics
What happens on sale: System picks the OLDEST unexpired batch first (FEFO)
DB impact: UPDATE inventory_batches SET quantity = quantity - 1 WHERE expires_at > NOW() ORDER BY expires_at ASC

A fruit vendor doesn't track individual apples. They track batches:

  • Batch #2025-06-15: 5 crates of apples, expires June 18
  • Batch #2025-06-16: 3 crates of apples, expires June 19
  • System always sells from Batch #2025-06-15 first

Level 3: serial — Individual Unit Tracking

Used for: Laptops, phones, AC units, expensive electronics
What happens on sale: A specific serial number is assigned to the order
DB impact: UPDATE inventory_serials SET status = 'sold', sold_at = NOW() WHERE serial_number = 'DELL-XPS-2025-0042'

Each physical unit has a unique identity. You know exactly which laptop went to which customer. Critical for warranty claims.


4. The Vendor Catalog Question: Per-Vendor vs. Shared

This is a critical marketplace architecture decision.

Our Recommendation: Per-Vendor Catalogs (with future shared-catalog possibility)

For Khoji Nepal's hyperlocal context, vendors are selling vastly different, often unique goods (a local farm's organic vegetables are NOT the same product as another farm's). This is not Amazon where 50 vendors sell the exact same iPhone.

Start with per-vendor catalogs:

  • Each vendor creates and manages their own products
  • Products belong to a vendor via vendor_id on the product table
  • The public storefront aggregates all vendor products for search and discovery
  • Categories are shared and admin-controlled (vendors pick from a fixed taxonomy)

Why not shared catalog now?

  • Shared catalogs require complex deduplication logic (matching "the same product" across vendors)
  • Our vendors sell highly heterogeneous goods (fresh produce, handcrafts, services)
  • It adds significant complexity for marginal benefit at our scale

[!TIP] The schema we propose below is forward-compatible with a shared catalog. If you later want to add a master_products table and link multiple vendors to the same canonical product, the vendor_offers pattern slots right in.


5. The Recommended Database Schema for Khoji Nepal

This is the concrete, Laravel-ready schema that synthesizes all research:

Core Product Tables

┌─────────────────────────────────────────────────────────┐
│ product_families                                        │
│ (Admin-defined templates: "Grocery", "Electronics")     │
├─────────────────────────────────────────────────────────┤
│ id, name, slug                                          │
│ product_archetype: enum(storable, perishable,            │
│                        serialized, service, digital)     │
│ tracking_type: enum(none, quantity, batch, serial)        │
│ attribute_schema: JSON  ← defines what extra fields      │
│                           this family requires           │
│ variant_attributes: JSON ← which attrs create variants   │
│                           e.g., ["size", "color"]        │
│ requires_shipping: boolean                               │
│ is_active: boolean                                       │
└─────────────────┬───────────────────────────────────────┘
                  │ 1:N
┌─────────────────▼───────────────────────────────────────┐
│ products (the canonical identity)                        │
├─────────────────────────────────────────────────────────┤
│ id, vendor_id (FK), product_family_id (FK)               │
│ category_id (FK → categories)                            │
│ name, slug, description                                  │
│ base_price: decimal                                      │
│ compare_at_price: decimal (for showing "was Rs.X")       │
│ attributes: JSON  ← dynamic fields validated against     │
│                     product_family.attribute_schema       │
│ media: JSON (array of image/video URLs)                  │
│ tags: JSON                                               │
│ status: enum(draft, active, archived)                    │
│ is_featured: boolean                                     │
│ timestamps                                               │
└─────────────────┬───────────────────────────────────────┘
                  │ 1:N
┌─────────────────▼───────────────────────────────────────┐
│ product_variants (the purchasable unit)                   │
├─────────────────────────────────────────────────────────┤
│ id, product_id (FK)                                      │
│ sku: string (unique)                                     │
│ barcode: string (nullable, for POS scanning)             │
│ variant_attributes: JSON  ← {"size": "L", "color": "Red"}│
│ price_override: decimal (nullable, overrides base_price) │
│ weight_grams: integer (nullable)                         │
│ is_active: boolean                                       │
│ timestamps                                               │
└─────────────────┬───────────────────────────────────────┘
                  │ 1:1
                  ▼

Inventory Tables (only for tracked products)

┌─────────────────────────────────────────────────────────┐
│ inventory_items (stock summary per variant per location)  │
├─────────────────────────────────────────────────────────┤
│ id, product_variant_id (FK)                              │
│ location_id (FK → vendor_locations, for multi-store)     │
│ quantity_on_hand: integer (default 0)                     │
│ quantity_reserved: integer (default 0)  ← held for orders│
│ quantity_available: GENERATED (on_hand - reserved)        │
│ reorder_point: integer (nullable)                        │
│ reorder_quantity: integer (nullable)                      │
│ low_stock_alert_sent: boolean                            │
│ timestamps                                               │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ inventory_batches (ONLY for tracking_type = 'batch')     │
├─────────────────────────────────────────────────────────┤
│ id, inventory_item_id (FK)                               │
│ batch_number: string                                     │
│ quantity: integer                                        │
│ manufactured_at: date (nullable)                         │
│ expires_at: date                                         │
│ cost_price: decimal (nullable, for FIFO costing)         │
│ status: enum(active, expired, depleted)                  │
│ timestamps                                               │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ inventory_serials (ONLY for tracking_type = 'serial')    │
├─────────────────────────────────────────────────────────┤
│ id, inventory_item_id (FK)                               │
│ serial_number: string (unique)                           │
│ status: enum(in_stock, reserved, sold, returned,         │
│              defective, warranty_claim)                   │
│ sold_at: timestamp (nullable)                            │
│ order_id: FK (nullable, links to the sale)               │
│ notes: text (nullable)                                   │
│ timestamps                                               │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ inventory_movements (audit ledger — append-only)         │
├─────────────────────────────────────────────────────────┤
│ id, inventory_item_id (FK)                               │
│ type: enum(purchase, sale, return, adjustment,           │
│            transfer, damage, expired)                     │
│ quantity_change: integer (positive = in, negative = out) │
│ batch_id: FK (nullable)                                  │
│ serial_id: FK (nullable)                                 │
│ reference_type: string (e.g., "order", "manual")         │
│ reference_id: integer (nullable)                         │
│ notes: text (nullable)                                   │
│ performed_by: FK → users                                 │
│ timestamps                                               │
└─────────────────────────────────────────────────────────┘

Service-Specific Tables (for archetype = 'service')

┌─────────────────────────────────────────────────────────┐
│ service_slots (availability for services)                 │
├─────────────────────────────────────────────────────────┤
│ id, product_variant_id (FK)                              │
│ day_of_week: enum(mon, tue, wed, thu, fri, sat, sun)     │
│ start_time: time                                         │
│ end_time: time                                           │
│ max_bookings: integer                                    │
│ is_active: boolean                                       │
│ timestamps                                               │
└─────────────────────────────────────────────────────────┘

Supporting Tables

┌─────────────────────────────────────────────────────────┐
│ categories (admin-controlled taxonomy)                   │
├─────────────────────────────────────────────────────────┤
│ id, parent_id (self-referencing for tree structure)       │
│ product_family_id (FK — links category to a family)      │
│ name, slug, icon, image                                  │
│ description                                              │
│ sort_order: integer                                      │
│ is_active: boolean                                       │
│ timestamps                                               │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│ vendor_locations (for hyperlocal multi-store vendors)     │
├─────────────────────────────────────────────────────────┤
│ id, vendor_id (FK)                                       │
│ name (e.g., "Main Shop", "Warehouse")                    │
│ address, latitude, longitude                             │
│ is_default: boolean                                      │
│ is_active: boolean                                       │
│ timestamps                                               │
└─────────────────────────────────────────────────────────┘

6. How the Schema Handles Every Scenario

🥕 Scenario: Fruit & Vegetable Vendor

Product Family: "Fresh Produce" (archetype=perishable, tracking=batch)
Product: "Organic Fuji Apples"
Variant: "Organic Fuji Apples — 1kg" (no size/color variants)
Inventory Item: location=Main Shop, qty_on_hand=50 kg
Batch #1: batch="2025-06-15", qty=30kg, expires=June 18
Batch #2: batch="2025-06-16", qty=20kg, expires=June 19

On sale → system picks Batch #1 first (FEFO)
On June 18 → Batch #1 auto-marked "expired", remaining qty written off

🍜 Scenario: Grocery Store (Noodles, Biscuits)

Product Family: "Packaged Grocery" (archetype=storable, tracking=quantity)
Product: "Wai Wai Chicken Noodles 75g"
Variant: single variant (no size/color)
Inventory Item: location=Main Shop, qty_on_hand=1200, reorder_point=200

On sale of 3 → qty_on_hand becomes 1197
When qty hits 200 → vendor gets low-stock alert
Vendor restocks 500 → inventory_movement logged, qty becomes 697+500=1197

[!TIP] This is the answer to your grocery question. Nobody opens the carton. The vendor just says "I received 2 cartons = 48 packets" and the system adds 48 to stock. Barcode scanning at POS can automate the decrement later.

💻 Scenario: Electronics Shop

Product Family: "Consumer Electronics" (archetype=serialized, tracking=serial)
Product: "Dell XPS 15 Laptop"
Variants: "i7/16GB/512GB" and "i7/32GB/1TB"
Inventory Item: location=Showroom, qty_on_hand=5

Serial records:
  SN: DELL-XPS-2025-0042 → status=in_stock
  SN: DELL-XPS-2025-0043 → status=in_stock
  SN: DELL-XPS-2025-0044 → status=sold (order #1234)
  ...

On sale → specific serial assigned to order → warranty starts
On return → serial status → "returned", then "in_stock" after inspection

✂️ Scenario: Salon / Service Provider

Product Family: "Personal Services" (archetype=service, tracking=none)
Product: "Men's Haircut"
Variant: "Standard Cut" and "Premium Cut"
No inventory tables. Instead:

Service Slots:
  Mon-Sat: 10:00-18:00, max_bookings=3 per hour
  Sun: closed

Customer books → slot count decremented
No physical stock involved at all

📚 Scenario: Digital Product Seller

Product Family: "Digital Downloads" (archetype=digital, tracking=none)
Product: "Nepali Typography Font Pack"
Variant: single
Price: Rs. 500

On purchase → system generates a download link (or sends file via email)
No stock, no shipping, no location

7. The Product Family → Schema-Driven UI Flow

This is where everything clicks for the vendor dashboard (Inertia app):

Step 1: Vendor picks a Category
         "Fresh Produce → Fruits"

Step 2: System looks up the ProductFamily for that category
         ProductFamily: "Fresh Produce"
         archetype: perishable
         tracking: batch
         attribute_schema: {
           "origin": { "type": "text", "required": false },
           "storage_type": { "type": "select", "options": ["room_temp", "refrigerated", "frozen"] },
           "is_organic": { "type": "boolean", "default": false }
         }
         variant_attributes: ["weight_option"]

Step 3: Frontend renders ONLY relevant fields:
         ✅ Name, Description, Price, Images (universal)
         ✅ Origin, Storage Type, Is Organic (from attribute_schema)
         ✅ Weight Option variants (from variant_attributes)
         ✅ Batch entry form (from tracking=batch): batch#, qty, expiry
         ❌ Serial Number field → HIDDEN
         ❌ Warranty field → HIDDEN
         ❌ Download URL field → HIDDEN

Step 4: Vendor submits → backend validates JSON against schema → saved

The frontend never hardcodes "if category is grocery, show expiry field". It reads the schema and renders dynamically. New categories can be added by an admin without any code changes.


8. What We Should NOT Build (Pragmatic Scoping)

Given our current stage, here's what to defer:

FeatureStatusReason
Multi-warehouse per vendor❌ DeferMost Nepali vendors have 1 shop. Add later via vendor_locations.
FEFO auto-routing❌ DeferBuild batch tracking first, add auto-pick logic later.
Barcode/POS scanning❌ DeferNice-to-have; vendors can manually update stock initially.
Shared catalog / deduplication❌ DeferPer-vendor catalogs are fine for our heterogeneous vendor base.
Advanced costing (FIFO valuation)❌ DeferVendors set their own prices; we don't need cost accounting now.
Product Families & attribute schemas✅ BuildThis is the backbone. Without it, every future feature is painful.
Variant system✅ BuildCore to any e-commerce.
Quantity tracking✅ BuildThe most common tracking mode.
Batch tracking✅ BuildCritical for the perishable vendors that are our core market.
Serial tracking⏸️ StubCreate the table, but don't build the full UI yet.
Inventory movements ledger✅ BuildEssential for audit trail and debugging.
Service slots⏸️ StubCreate the table, build the UI when we add service vendors.

9. Implementation Roadmap (Laravel)

Phase 1: Foundation (Build Now)

  1. Create migrations for: product_families, categories, products, product_variants
  2. Create migrations for: inventory_items, inventory_batches, inventory_movements
  3. Build Eloquent models with proper relationships
  4. Seed product_families with initial families (Fresh Produce, Packaged Grocery, Electronics, Fashion, Services, Digital)
  5. Seed categories tree linked to families

Phase 2: Vendor Dashboard (Build Next)

  1. Product CRUD in Inertia (schema-driven forms)
  2. Inventory management UI (stock adjustments, batch entry)
  3. Low-stock alerts via Laravel Notifications

Phase 3: Public API (Build After)

  1. Product listing API for Next.js storefront
  2. Search & filter API (with category-aware attribute filtering)
  3. Stock availability checks during checkout

10. Key Disagreements Between Sources & Our Resolution

TopicChatGPT SaysGemini SaysMy Assessment
Schema strategyHybrid JSON + relationalHybrid JSONB + GIN indexes (PostgreSQL)Agree on hybrid. We use MySQL with Laravel, so JSON column type works. We can add Meilisearch/Typesense for search later.
Dropship archetypeInclude as core archetypeNot emphasizedDefer. Not relevant for hyperlocal Nepali vendors shipping from their own shops.
Frontend frameworkSchema-driven formsSuggests Astro.jsUse Inertia/React for vendor dashboard (schema-driven). Next.js for public. We already have this stack.
MicroservicesSuggests separate Catalog + Inventory servicesAgrees on modular separationMonolith first. Laravel modules (Services/Repositories), not separate microservices. We're not at that scale yet.
Rental supportMentions as special caseDetailed rental slot modelDefer. Very few rental vendors in initial launch. Can be added as a product family later.
    Inventory & Product Management Architecture for Khoji Nepal — Product management suggestions | Krishna Tiwari