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.
Bill of Materials (BOM)
Section titled “Bill of Materials (BOM)”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.
flowchart LR BOM["BOM: Vanilla Ice Cream 500ml<br/>(ICE-VAN-500)"] BOM --> M1["Whole Milk · 0.3 L"] BOM --> M2["White Sugar · 0.05 Kg"] BOM --> M3["Vanilla Extract · 0.005 L"] BOM --> M4["Cream · 0.15 L"] BOM --> M5["Stabilizer · 0.002 Kg"] BOM --> M6["Ice Cream Tub 500ml · 1 Nos"] BOM --> M7["Tub Lid 500ml · 1 Nos"] BOM --> M8["Shrink Wrap Label · 1 Nos"]
Creating a BOM
Section titled “Creating a BOM”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.
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.nameBOM cost
Section titled “BOM cost”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:
| Component | Qty | UOM | Rate (INR) | Amount |
|---|---|---|---|---|
| Whole Milk | 0.3 | Litre | 40 | 12.00 |
| White Sugar | 0.05 | Kg | 45 | 2.25 |
| Vanilla Extract | 0.005 | Litre | 800 | 4.00 |
| Cream | 0.15 | Litre | 120 | 18.00 |
| Stabilizer | 0.002 | Kg | 500 | 1.00 |
| Tub 500ml | 1 | Nos | 3.50 | 3.50 |
| Lid 500ml | 1 | Nos | 1.20 | 1.20 |
| Label | 1 | Nos | 0.80 | 0.80 |
| Material Cost | 42.75 | |||
| Mixing (15 min) | 5.00 | |||
| Pasteurization (30 min) | 10.00 | |||
| Freezing (45 min) | 15.00 | |||
| Packaging (5 min) | 3.00 | |||
| Operating Cost | 33.00 | |||
| Total BOM Cost | 75.75 |
Multi-level BOM
Section titled “Multi-level BOM”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:
flowchart LR BOM["BOM: Vanilla Ice Cream 500ml<br/>(ICE-VAN-500)"] BOM --> SUB["Ice Cream Base Mix<br/>(SUB-ICE-BASE) · 0.5 L"] SUB --> S1["Whole Milk · 0.6 L"] SUB --> S2["Cream · 0.3 L"] SUB --> S3["Sugar · 0.1 Kg"] SUB --> S4["Stabilizer · 0.004 Kg"] BOM --> V["Vanilla Extract · 0.005 L"] BOM --> P1["Packaging (PKG-TUB-500) · 1 Nos"] BOM --> P2["Lid (PKG-LID-500) · 1 Nos"] BOM --> P3["Label (PKG-LABEL-VAN) · 1 Nos"]
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.
Phantom BOM (v16)
Section titled “Phantom BOM (v16)”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:
# In the BOM item rowbom.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})Work Order
Section titled “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.
stateDiagram-v2 [*] --> Draft Draft --> NotStarted: submit NotStarted --> InProcess: Stock Entry<br/>(Material Transfer for Manufacture) InProcess --> InProcess: Job Cards completed<br/>(one per operation) InProcess --> Completed: Stock Entry (Manufacture)<br/>finished goods received Completed --> [*]
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.
Creating a Work Order
Section titled “Creating a Work Order”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.
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.nameMaterial Transfer (start production)
Section titled “Material Transfer (start production)”The make_stock_entry helper builds a pre-populated Stock Entry from the Work
Order, so you don’t hand-assemble the material rows.
from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry
# Transfer raw materials from stores to WIPse_transfer = make_stock_entry( work_order_id="WO-00042", purpose="Material Transfer for Manufacture", qty=100)se_transfer.insert()se_transfer.submit()Manufacture (finish production)
Section titled “Manufacture (finish production)”# Record finished goodsse_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.
Production Plan and MRP
Section titled “Production Plan and MRP”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.
flowchart LR SO["Open Sales Orders"] --> PP["Production Plan<br/>(MRP)"] MR0["Material Requests"] --> PP PP -->|"make_work_order"| WO["Work Orders<br/>(finished goods + sub-assemblies)"] PP -->|"make_material_request"| MR["Material Requests<br/>(raw materials to purchase)"]
v16 MRP improvements
Section titled “v16 MRP improvements”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.
Creating a Production Plan
Section titled “Creating a Production Plan”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.
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.nameHow MRP calculates requirements
Section titled “How MRP calculates requirements”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)Job Card
Section titled “Job Card”When a Work Order has operations defined in its BOM, Job Cards are created for each operation. They track time, materials, and operator details.
# 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 = 100job_card.save()job_card.submit()Workstation
Section titled “Workstation”Workstations represent physical production equipment or areas with defined capacity. Their per-hour rates feed the operating cost on every operation that runs there.
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.
Subcontracting with BOM
Section titled “Subcontracting with BOM”When outsourcing production steps (like pasteurization or specialized freezing), v16 provides an end-to-end subcontracting flow:
flowchart LR SCO["Subcontracting Order"] --> SE["Stock Entry<br/>(Send to Supplier)"] SE --> SCR["Subcontracting Receipt<br/>(Receive finished goods)"] SCR --> LCV["Landed Cost Voucher<br/>(Add freight / duties)"]
v16 stock reservation for production
Section titled “v16 stock reservation for production”# Reserve stock for a specific Work Orderwo = frappe.get_doc("Work Order", "WO-00042")wo.reserve_stock = 1wo.save()
# Reserved items cannot be consumed by other Work Orders# until the reservation is releasedManufacturing Settings
Section titled “Manufacturing Settings”Navigate to Manufacturing Settings to configure module-wide defaults:
| Setting | Recommended Value | Purpose |
|---|---|---|
| Default WIP Warehouse | Work In Progress - SJ | Where materials go during production |
| Default FG Warehouse | Finished Goods - SJ | Where finished products go |
| Overproduction % for Work Order | 10 | Allow 10% overproduction |
| Capacity Planning For (Workstation) | Yes | Enable capacity checks |
| Disable Capacity Planning | No | Enforce workstation capacity |
| Allow Overtime | Yes | Allow scheduling beyond working hours |
| Update BOM Cost Automatically | Yes | Keep BOM costs current |
| Material Consumption | 0 | Track actual vs planned consumption |
Worked example: a full production run
Section titled “Worked example: a full production run”Putting it together — manufacturing 100 units of Vanilla Ice Cream from the BOM above, end to end.
-
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 = 100wo.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 = 1wo.planned_start_date = "2026-03-20 06:00:00"wo.insert()wo.submit() -
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_entryse = 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() -
Complete the Job Cards for each operation, in sequence: Mixing → Pasteurization → Freezing → Packaging.
scoopjoy/scoopjoy/manufacturing/run_vanilla.py # Each operation has a Job Cardjob_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() -
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”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.nameTracking WIP and finished goods
Section titled “Tracking WIP and finished goods”A status helper pulls the Work Order header, operation-wise progress from Job Cards, and the WIP stock value straight from the Stock Ledger.
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-00042Item: ICE-VAN-500Ordered Qty: 100Material Transferred: 100Produced Qty: 60Status: In Process
Operation Progress: Mixing: Completed (100%) Pasteurization: Completed (100%) Freezing: Work In Progress (60%) Packaging: Open (0%)
WIP Value: INR 4,575.00