Skip to content

Manufacturing

ERPNext’s Manufacturing module handles the complete production lifecycle — from defining what goes into a product (BOM) to planning production, tracking work on the shop floor, and receiving finished goods. For ScoopJoy, this means managing the production of ice cream from raw ingredients through mixing, freezing, and packaging.

A BOM defines the recipe for manufacturing a product. It lists all raw materials, sub-assemblies, quantities, and operations required. If you’ve modeled a recipe as a row in a recipes table with a join table for ingredients, a BOM is that — except it also carries operations, costing, and a submit lifecycle.

BOM: Vanilla Ice Cream 500ml
Rendering diagram…

A BOM has two child tables: items (the materials) and operations (the steps). Set with_operations = 1 to cost the labour and machine time alongside the materials.

scoopjoy/scoopjoy/manufacturing/bom_setup.py
def create_vanilla_ice_cream_bom():
"""BOM for Vanilla Ice Cream 500ml."""
bom = frappe.new_doc("BOM")
bom.item = "ICE-VAN-500"
bom.item_name = "Vanilla Ice Cream 500ml"
bom.quantity = 1
bom.company = "ScoopJoy Ice Creams Pvt Ltd"
bom.is_active = 1
bom.is_default = 1
bom.with_operations = 1
# Raw materials
materials = [
("RM-MILK-WHOLE", "Whole Milk", 0.3, "Litre", 40),
("RM-SUGAR-WHITE", "White Sugar", 0.05, "Kg", 45),
("RM-VANILLA-EXTRACT", "Vanilla Extract", 0.005, "Litre", 800),
("RM-CREAM", "Cream", 0.15, "Litre", 120),
("RM-STABILIZER", "Stabilizer", 0.002, "Kg", 500),
("PKG-TUB-500", "Ice Cream Tub 500ml", 1, "Nos", 3.50),
("PKG-LID-500", "Tub Lid 500ml", 1, "Nos", 1.20),
("PKG-LABEL-VAN", "Vanilla Label", 1, "Nos", 0.80),
]
for code, name, qty, uom, rate in materials:
bom.append("items", {
"item_code": code,
"item_name": name,
"qty": qty,
"uom": uom,
"rate": rate,
"source_warehouse": "Central Warehouse - SJ"
})
# Operations
bom.append("operations", {
"operation": "Mixing",
"workstation": "Mixing Station",
"time_in_mins": 15,
"operating_cost": 5
})
bom.append("operations", {
"operation": "Pasteurization",
"workstation": "Pasteurizer",
"time_in_mins": 30,
"operating_cost": 10
})
bom.append("operations", {
"operation": "Freezing",
"workstation": "Batch Freezer",
"time_in_mins": 45,
"operating_cost": 15
})
bom.append("operations", {
"operation": "Packaging",
"workstation": "Packaging Line",
"time_in_mins": 5,
"operating_cost": 3
})
bom.insert()
bom.submit()
return bom.name

ERPNext calculates BOM cost automatically. Material cost is the sum of qty * rate for each item; operating cost is the sum of operating_cost across operations; the total is the two added together. For the Vanilla Ice Cream 500ml BOM that works out to:

ComponentQtyUOMRate (INR)Amount
Whole Milk0.3Litre4012.00
White Sugar0.05Kg452.25
Vanilla Extract0.005Litre8004.00
Cream0.15Litre12018.00
Stabilizer0.002Kg5001.00
Tub 500ml1Nos3.503.50
Lid 500ml1Nos1.201.20
Label1Nos0.800.80
Material Cost42.75
Mixing (15 min)5.00
Pasteurization (30 min)10.00
Freezing (45 min)15.00
Packaging (5 min)3.00
Operating Cost33.00
Total BOM Cost75.75

For complex products, a BOM can reference sub-assembly BOMs. ScoopJoy’s “Ice Cream Base Mix” might be a sub-assembly used across multiple flavors:

Multi-level BOM with a sub-assembly
Rendering diagram…

When creating a Work Order, you choose how deep to explode the BOM:

  • Use Multi-Level BOM = Yes — explodes all levels; raw materials are issued directly.
  • Use Multi-Level BOM = No — only the top level is used; sub-assemblies must be manufactured separately.

In v16, Phantom BOMs explode correctly in Production Plans and Work Orders without creating separate manufacturing steps. Mark a sub-assembly as “phantom” when it does not need to be stocked independently:

scoopjoy/scoopjoy/manufacturing/bom_setup.py
# In the BOM item row
bom.append("items", {
"item_code": "SUB-ICE-BASE",
"qty": 0.5,
"uom": "Litre",
"bom_no": "BOM-SUB-ICE-BASE-001",
# Phantom items are exploded into their raw materials
# without requiring a separate work order
})

A Work Order is the instruction to the production floor to manufacture a specific quantity of an item. Its status walks through a fixed lifecycle, driven by the Stock Entries you create against it.

Work Order lifecycle
Rendering diagram…

The Material Transfer step moves raw materials from stores into the WIP warehouse; the Manufacture step consumes them and receives finished goods into the FG warehouse.

Set skip_transfer = 0 to require the material transfer step before production starts — leave it as the default to enforce a clean WIP hand-off.

scoopjoy/scoopjoy/manufacturing/work_order.py
def create_work_order_vanilla(qty=100):
"""Create a Work Order for 100 units of Vanilla Ice Cream."""
wo = frappe.new_doc("Work Order")
wo.production_item = "ICE-VAN-500"
wo.bom_no = "BOM-ICE-VAN-500-001"
wo.qty = qty
wo.company = "ScoopJoy Ice Creams Pvt Ltd"
wo.wip_warehouse = "Work In Progress - SJ"
wo.fg_warehouse = "Finished Goods - SJ"
wo.source_warehouse = "Central Warehouse - SJ"
wo.use_multi_level_bom = 1
wo.skip_transfer = 0 # Require material transfer step
wo.planned_start_date = frappe.utils.now()
wo.insert()
wo.submit()
return wo.name

The make_stock_entry helper builds a pre-populated Stock Entry from the Work Order, so you don’t hand-assemble the material rows.

scoopjoy/scoopjoy/manufacturing/work_order.py
from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry
# Transfer raw materials from stores to WIP
se_transfer = make_stock_entry(
work_order_id="WO-00042",
purpose="Material Transfer for Manufacture",
qty=100
)
se_transfer.insert()
se_transfer.submit()
scoopjoy/scoopjoy/manufacturing/work_order.py
# Record finished goods
se_manufacture = make_stock_entry(
work_order_id="WO-00042",
purpose="Manufacture",
qty=100
)
se_manufacture.insert()
se_manufacture.submit()

After submission, 100 units of ICE-VAN-500 appear in the Finished Goods warehouse, and the raw materials are consumed from the WIP warehouse.

The Production Plan is ERPNext’s Material Requirements Planning (MRP) tool. It aggregates demand from Sales Orders and/or Material Requests, then calculates what needs to be manufactured and what raw materials need to be purchased.

Production Plan → Work Orders → Material Requests
Rendering diagram…

ERPNext v16 adds a dedicated MRP workflow that combines forecasts, delivery schedules, and lead times. Key enhancements:

  • Master Production Schedule (MPS) views — a consolidated planner that calculates future requirements.
  • Better lead time consideration — smarter procurement suggestions.
  • Phantom BOM support — correct explosion without unnecessary work orders.
  • Stock reservation — reserve materials for specific production jobs.

A Production Plan can pull items from open Sales Orders (get_open_sales_orders), or you can append them manually to the po_items child table. Once submitted, the make_work_order and make_material_request methods fan out into the documents that actually drive production and purchasing.

scoopjoy/scoopjoy/manufacturing/production_plan.py
def create_weekly_production_plan():
"""Weekly production plan across all products for all outlets."""
pp = frappe.new_doc("Production Plan")
pp.company = "ScoopJoy Ice Creams Pvt Ltd"
pp.posting_date = frappe.utils.today()
pp.get_items_from = "Sales Order"
# Fetch planned items from open Sales Orders
pp.run_method("get_open_sales_orders")
# Or manually add items
pp.append("po_items", {
"item_code": "ICE-VAN-500",
"bom_no": "BOM-ICE-VAN-500-001",
"planned_qty": 500,
"warehouse": "Finished Goods - SJ",
"planned_start_date": frappe.utils.today()
})
pp.append("po_items", {
"item_code": "ICE-CHOC-500",
"bom_no": "BOM-ICE-CHOC-500-001",
"planned_qty": 400,
"warehouse": "Finished Goods - SJ",
"planned_start_date": frappe.utils.today()
})
pp.append("po_items", {
"item_code": "ICE-MAN-500",
"bom_no": "BOM-ICE-MAN-500-001",
"planned_qty": 300,
"warehouse": "Finished Goods - SJ",
"planned_start_date": frappe.utils.today()
})
pp.insert()
pp.submit()
# Generate Work Orders
pp.run_method("make_work_order")
# Generate Material Requests for raw materials
pp.run_method("make_material_request")
return pp.name

For each planned item, ERPNext nets demand against what’s already on hand, then explodes the BOM to derive raw-material requirements:

For each planned item:
Required Qty = Planned Qty - Available Qty (in warehouse)
If "Skip Available Sub-Assembly Items" is checked:
Available sub-assemblies are subtracted from requirements
For raw materials (BOM explosion):
Required Qty = (Planned Qty * BOM qty per unit) - Projected Qty
Material Requests are generated for:
- Raw materials to be purchased
- Sub-assemblies to be manufactured (separate Work Orders)

When a Work Order has operations defined in its BOM, Job Cards are created for each operation. They track time, materials, and operator details.

scoopjoy/scoopjoy/manufacturing/job_card.py
# Job Cards are auto-created when a Work Order is submitted.
# Each operation in the BOM gets its own Job Card.
# To manually start a Job Card:
job_card = frappe.get_doc("Job Card", "JC-00101")
job_card.append("time_logs", {
"from_time": frappe.utils.now(),
"completed_qty": 0,
"employee": "HR-EMP-0005"
})
job_card.save()
# To complete the operation:
job_card.time_logs[0].to_time = frappe.utils.now()
job_card.time_logs[0].completed_qty = 100
job_card.save()
job_card.submit()

Workstations represent physical production equipment or areas with defined capacity. Their per-hour rates feed the operating cost on every operation that runs there.

scoopjoy/scoopjoy/manufacturing/workstation_setup.py
def setup_workstations():
"""Define ScoopJoy production workstations."""
workstations = [
{
"name": "Mixing Station",
"production_capacity": 200, # litres per hour
"hour_rate_electricity": 15,
"hour_rate_labour": 50,
"hour_rate_consumable": 5,
"working_hours": [
{"start_time": "06:00:00", "end_time": "14:00:00"},
{"start_time": "14:00:00", "end_time": "22:00:00"}
]
},
{
"name": "Batch Freezer",
"production_capacity": 100, # litres per hour
"hour_rate_electricity": 40,
"hour_rate_labour": 50,
"hour_rate_consumable": 10,
"working_hours": [
{"start_time": "00:00:00", "end_time": "23:59:59"} # 24/7
]
},
{
"name": "Packaging Line",
"production_capacity": 500, # units per hour
"hour_rate_electricity": 10,
"hour_rate_labour": 40,
"hour_rate_consumable": 3,
"working_hours": [
{"start_time": "06:00:00", "end_time": "22:00:00"}
]
}
]
for ws_data in workstations:
ws = frappe.new_doc("Workstation")
ws.workstation_name = ws_data["name"]
ws.production_capacity = ws_data["production_capacity"]
ws.hour_rate_electricity = ws_data["hour_rate_electricity"]
ws.hour_rate_labour = ws_data["hour_rate_labour"]
ws.hour_rate_consumable = ws_data["hour_rate_consumable"]
for wh in ws_data["working_hours"]:
ws.append("working_hours", wh)
ws.insert()
frappe.db.commit()

The total operating cost for a workstation per hour is hour_rate_electricity + hour_rate_labour + hour_rate_consumable.

When outsourcing production steps (like pasteurization or specialized freezing), v16 provides an end-to-end subcontracting flow:

Subcontracting flow
Rendering diagram…
scoopjoy/scoopjoy/manufacturing/work_order.py
# Reserve stock for a specific Work Order
wo = frappe.get_doc("Work Order", "WO-00042")
wo.reserve_stock = 1
wo.save()
# Reserved items cannot be consumed by other Work Orders
# until the reservation is released

Navigate to Manufacturing Settings to configure module-wide defaults:

SettingRecommended ValuePurpose
Default WIP WarehouseWork In Progress - SJWhere materials go during production
Default FG WarehouseFinished Goods - SJWhere finished products go
Overproduction % for Work Order10Allow 10% overproduction
Capacity Planning For (Workstation)YesEnable capacity checks
Disable Capacity PlanningNoEnforce workstation capacity
Allow OvertimeYesAllow scheduling beyond working hours
Update BOM Cost AutomaticallyYesKeep BOM costs current
Material Consumption0Track actual vs planned consumption

Putting it together — manufacturing 100 units of Vanilla Ice Cream from the BOM above, end to end.

  1. Create and submit the Work Order.

    scoopjoy/scoopjoy/manufacturing/run_vanilla.py
    wo = frappe.new_doc("Work Order")
    wo.production_item = "ICE-VAN-500"
    wo.bom_no = "BOM-ICE-VAN-500-001"
    wo.qty = 100
    wo.company = "ScoopJoy Ice Creams Pvt Ltd"
    wo.wip_warehouse = "Work In Progress - SJ"
    wo.fg_warehouse = "Finished Goods - SJ"
    wo.source_warehouse = "Central Warehouse - SJ"
    wo.use_multi_level_bom = 1
    wo.planned_start_date = "2026-03-20 06:00:00"
    wo.insert()
    wo.submit()
  2. Transfer materials to WIP. Quantities scale by 100 * BOM qty — 30 L milk, 5 Kg sugar, 0.5 L vanilla extract, 15 L cream, and so on.

    scoopjoy/scoopjoy/manufacturing/run_vanilla.py
    from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry
    se = make_stock_entry(wo.name, "Material Transfer for Manufacture", qty=100)
    # Verify material quantities (100 * BOM qty):
    # Milk: 100 * 0.3 = 30 Litres
    # Sugar: 100 * 0.05 = 5 Kg
    # Vanilla Extract: 100 * 0.005 = 0.5 Litre
    # Cream: 100 * 0.15 = 15 Litres
    # etc.
    se.insert()
    se.submit()
  3. Complete the Job Cards for each operation, in sequence: Mixing → Pasteurization → Freezing → Packaging.

    scoopjoy/scoopjoy/manufacturing/run_vanilla.py
    # Each operation has a Job Card
    job_cards = frappe.get_all("Job Card", filters={
    "work_order": wo.name,
    "docstatus": 0
    }, order_by="sequence_id asc")
    for jc_name in job_cards:
    jc = frappe.get_doc("Job Card", jc_name)
    jc.append("time_logs", {
    "from_time": frappe.utils.now(),
    "to_time": frappe.utils.add_to_date(frappe.utils.now(), minutes=30),
    "completed_qty": 100,
    "employee": "HR-EMP-0005"
    })
    jc.save()
    jc.submit()
  4. Record manufacture to receive finished goods.

    scoopjoy/scoopjoy/manufacturing/run_vanilla.py
    se_finish = make_stock_entry(wo.name, "Manufacture", qty=100)
    se_finish.insert()
    se_finish.submit()
    # Result: 100 units of ICE-VAN-500 now in Finished Goods - SJ

Planning a week of production across all products

Section titled “Planning a week of production across all products”
scoopjoy/scoopjoy/manufacturing/production_plan.py
def weekly_production_plan():
"""
Plan production for the coming week based on
open Sales Orders and current stock levels.
"""
pp = frappe.new_doc("Production Plan")
pp.company = "ScoopJoy Ice Creams Pvt Ltd"
pp.posting_date = frappe.utils.today()
pp.get_items_from = "Sales Order"
# Set date range for Sales Orders to consider
pp.from_date = frappe.utils.today()
pp.to_date = frappe.utils.add_days(frappe.utils.today(), 7)
# Fetch items from Sales Orders
pp.run_method("get_open_sales_orders")
# After fetching, review the items
for item in pp.po_items:
print(f"{item.item_code}: Plan {item.planned_qty} units")
# Adjust quantities if needed
# item.planned_qty = adjusted_qty
pp.insert()
# Get sub-assembly items (if multi-level BOM)
pp.run_method("get_sub_assembly_items")
pp.submit()
# Create Work Orders for all planned items
pp.run_method("make_work_order")
# Create Material Requests for raw materials
pp.run_method("make_material_request")
# Summary
work_orders = frappe.get_all("Work Order", filters={
"production_plan": pp.name
}, fields=["name", "production_item", "qty"])
for wo in work_orders:
print(f" WO {wo.name}: {wo.production_item} x {wo.qty}")
return pp.name

A status helper pulls the Work Order header, operation-wise progress from Job Cards, and the WIP stock value straight from the Stock Ledger.

scoopjoy/scoopjoy/manufacturing/status.py
def get_production_status(work_order_name):
"""Get current status of a work order with WIP tracking."""
wo = frappe.get_doc("Work Order", work_order_name)
# Material status
print(f"Work Order: {wo.name}")
print(f"Item: {wo.production_item}")
print(f"Ordered Qty: {wo.qty}")
print(f"Material Transferred: {wo.material_transferred_for_manufacturing}")
print(f"Produced Qty: {wo.produced_qty}")
print(f"Status: {wo.status}")
# Operation-wise progress from Job Cards
job_cards = frappe.get_all("Job Card", filters={
"work_order": wo.name
}, fields=["operation", "status", "total_completed_qty"])
print("\nOperation Progress:")
for jc in job_cards:
progress = (jc.total_completed_qty / wo.qty) * 100
print(f" {jc.operation}: {jc.status} ({progress:.0f}%)")
# WIP stock value
wip_value = frappe.db.sql("""
SELECT SUM(stock_value_difference)
FROM `tabStock Ledger Entry`
WHERE voucher_no IN (
SELECT name FROM `tabStock Entry`
WHERE work_order = %s AND purpose = 'Material Transfer for Manufacture'
)
AND warehouse = %s
""", (wo.name, wo.wip_warehouse))
print(f"\nWIP Value: INR {wip_value[0][0] or 0:,.2f}")
return {
"status": wo.status,
"ordered": wo.qty,
"produced": wo.produced_qty,
"remaining": wo.qty - wo.produced_qty
}

A run mid-production prints something like:

Work Order: WO-00042
Item: ICE-VAN-500
Ordered Qty: 100
Material Transferred: 100
Produced Qty: 60
Status: In Process
Operation Progress:
Mixing: Completed (100%)
Pasteurization: Completed (100%)
Freezing: Work In Progress (60%)
Packaging: Open (0%)
WIP Value: INR 4,575.00