Multiple Finished Goods

Hello Community,

I am working on ERPNext Manufacturing implementation for a Tuna processing industry, and I faced a challenge related to cost allocation and production outputs.

Installed Apps

ERPNext: v15.81.1 (version-15)

Frappe Framework: v15.84.0 (version-15)

Business Process (Real Example)

  • Input: Raw Fish (whole tuna).
  • Outputs:
    1. Main Finished Product: Tuna Slices.
    2. By-Product: Shredded Tuna.

This is a common case in food industries, where one raw material produces multiple outputs (main products + by-products).

What I tried so far

  1. BOM + Work Order

    • ERPNext allows only one finished product in a Work Order.
    • The Scrap table lets me define by-products, but:
      • I have to manually enter the rate of the by-product when creating the BOM.
      • In reality, I don’t know the exact rate/value of shredded tuna at BOM creation.
    • So cost distribution is not automatic.
  2. Stock Entry → Repack

    • Works well when converting raw fish → single finished product.
    • But when converting raw fish → multiple finished products, the costing is not automatically distributed across outputs.
    • It seems ERPNext handles one-to-one or many-to-one, but not one-to-many (or many-to-many) production very well.

Example (One to One)
The total value of outgoing and incoming is set correctly, with no differences.

Example (One to Many)
The total value of outgoing and incoming is not set correctly, and the item rate is fetched from the Item Master and cannot be edited. However, I need it to be set according to the quantity percentage between the finished goods.

Business Requirement

  • I need to consume 1 raw input (whole fish) and generate multiple outputs (slices + shredded).
  • The system should:
    • Automatically distribute cost between products (e.g., based on percentage, weight, or predefined ratio).
    • Not require me to manually set rates for by-products each time.
  • No need for automatic transfer between warehouses — only proper cost allocation and stock updates.

Questions to the Community

  • Has anyone faced a similar scenario in food or process manufacturing (like Tuna, Dairy, Meat processing)?
  • What approach did you take to solve the multiple outputs per Work Order issue?
  • Is there a recommended way in ERPNext to handle this case, or should I customize the Work Order / Stock Entry logic?
  • Would it be better to extend BOM to support multiple finished goods?

Here’s a clear flowchart showing how one raw fish (whole tuna) can be processed into two outputs:

Any advice, ideas, or shared experiences would be highly appreciated :folded_hands:

Thank you in advance!

2 Likes

“To Whom It May Concern, I have made some customizations in Stock Entry with the type ‘Repack’:”

“I added a Client Script and a Server Script for the ‘Before Save’ event.”

“Here it is.”

“Server Script.”

# Server Script: Stock Entry (Before Save)

if doc.purpose == "Repack":

    # 1) Cost - (Raw)
    total_raw_cost = 0
    for row in doc.items:
        if not row.is_finished_item and not row.get("is_scrap_item", 0):
            total_raw_cost = total_raw_cost + (row.qty or 0) * (row.valuation_rate or 0)

    # 2) Total qt without scraped
    total_finished_qty = 0
    for row in doc.items:
        if row.is_finished_item and not row.get("is_scrap_item", 0):
            total_finished_qty = total_finished_qty + (row.qty or 0)

    if total_finished_qty == 0:
        frappe.throw(
            f"Stock Entry {doc.name}: Cannot complete Repack because no finished goods quantity found."
        )

    # 3) allocated_cost
    for row in doc.items:
        if row.is_finished_item and not row.get("is_scrap_item", 0):
            share = (row.qty or 0) / total_finished_qty

            allocated_cost = total_raw_cost * share
            basic_rate = allocated_cost / (row.qty or 1)

            extra_cost = (doc.total_additional_costs or 0) * share
            extra_rate = extra_cost / (row.qty or 1)

            # update value
            row.basic_rate = basic_rate
            row.additional_cost = extra_cost
            row.valuation_rate = basic_rate + extra_rate
            row.basic_amount = (basic_rate * (row.qty or 0)) + extra_cost
            row.amount = (basic_rate * (row.qty or 0)) + extra_cost

        elif row.get("is_scrap_item", 0):
            # Scrap = zero
            row.basic_rate = 0
            row.valuation_rate = 0
            row.is_finished_item = 0
            row.basic_amount = 0
            row.amount = 0
            row.additional_cost = 0
            row.allow_zero_valuation_rate = 1
        else:
            row.additional_cost = 0

    # 4)  Totals 
    total_incoming = 0
    total_outgoing = 0
    for row in doc.items:
        if row.t_warehouse:
            total_incoming = total_incoming + row.basic_amount or 0
        if row.s_warehouse:
            total_outgoing = total_outgoing + row.basic_amount or 0

    doc.total_incoming_value = total_incoming
    doc.total_outgoing_value = total_outgoing
    doc.total_amount = total_incoming   
    doc.value_difference = (doc.total_incoming_value or 0) - (doc.total_outgoing_value or 0)

“Client Script.”

// Custom Script For Repack Transaction:

function highlight_scrap_rows(frm) {
    if (frm.doc.purpose !== "Repack") return;

    // rest css
    $(frm.fields_dict["items"].grid.wrapper)
        .find(".grid-row")
        .css("background-color", "");

    // if item scrap → set background-color in grey  
    (frm.doc.items || []).forEach(row => {
        if (row.is_scrap_item) {
            let $row = $(frm.fields_dict["items"].grid.wrapper)
                .find(`[data-name='${row.name}']`);
            $row.css("background-color", "#e0e0e0"); // grey color
        }
    });
}


function recalc_cost(frm) {
    if (frm.doc.purpose !== "Repack") return;

    let total_raw_cost = 0;
    let total_finished_qty = 0;

    // 1) raw material cost
    (frm.doc.items || []).forEach(row => {
        if (!row.is_finished_item && !row.is_scrap_item) {
            total_raw_cost += (row.qty || 0) * (row.valuation_rate || 0);
        }
    });

    // 2) total qt of FG
    (frm.doc.items || []).forEach(row => {
        if (row.is_finished_item && !row.is_scrap_item) {
            total_finished_qty += (row.qty || 0);
        }
    });

    // 3) cost allocation for FG
    if (total_finished_qty > 0) {
        (frm.doc.items || []).forEach(row => {
            if (row.is_finished_item && !row.is_scrap_item) {
                let share = (row.qty || 0) / total_finished_qty;

                // Rw Cost
                let allocated_cost = total_raw_cost * share;
                let basic_rate = allocated_cost / (row.qty || 1);

                // FG Cost from Addational Cost
                let extra_cost = (frm.doc.total_additional_costs || 0) * share;
                let extra_rate = extra_cost / (row.qty || 1);

                // Update Fields Value
                frappe.model.set_value(row.doctype, row.name, "basic_rate", basic_rate);
                frappe.model.set_value(row.doctype, row.name, "additional_cost", extra_cost);

                frappe.model.set_value(row.doctype, row.name, "valuation_rate", basic_rate + extra_rate);

                frappe.model.set_value(row.doctype, row.name, "basic_amount", (basic_rate * row.qty) + extra_cost);
                frappe.model.set_value(row.doctype, row.name, "amount", (basic_rate * row.qty) + extra_cost);

            }

            //   (Scrap)
            if (row.is_scrap_item) {
                frappe.model.set_value(row.doctype, row.name, "basic_rate", 0);
                frappe.model.set_value(row.doctype, row.name, "valuation_rate", 0);
                frappe.model.set_value(row.doctype, row.name, "is_finished_item", 0);
                frappe.model.set_value(row.doctype, row.name, "basic_amount", 0);
                frappe.model.set_value(row.doctype, row.name, "amount", 0);
                frappe.model.set_value(row.doctype, row.name, "allow_zero_valuation_rate", 1);
                frappe.model.set_value(row.doctype, row.name, "additional_cost", 0);
            }
        });
    }

    // 4) Totals 
    let total_incoming = 0, total_outgoing = 0;
    (frm.doc.items || []).forEach(row => {
        if (row.t_warehouse) total_incoming += (row.basic_amount || 0);
        if (row.s_warehouse) total_outgoing += (row.basic_amount || 0);
    });

    frm.set_value("total_incoming_value", total_incoming);
    frm.set_value("total_outgoing_value", total_outgoing);
    frm.set_value("total_amount", total_incoming);
    frm.set_value("value_difference", (total_incoming || 0) - (total_outgoing || 0));
}

// Events
frappe.ui.form.on("Stock Entry", {
    refresh(frm) { highlight_scrap_rows(frm); },
    after_save(frm) { highlight_scrap_rows(frm); },
    purpose(frm) { recalc_cost(frm); },
    items_add(frm) { recalc_cost(frm); },
    items_remove(frm) { recalc_cost(frm); },
    items_on_form_rendered(frm) { recalc_cost(frm); },
    total_additional_costs(frm) { recalc_cost(frm); }   
});

frappe.ui.form.on("Stock Entry Detail", {
    qty(frm, cdt, cdn) { recalc_cost(frm); },
    is_finished_item(frm, cdt, cdn) { recalc_cost(frm); },
    is_scrap_item(frm, cdt, cdn) { recalc_cost(frm); highlight_scrap_rows(frm); },
    valuation_rate(frm, cdt, cdn) { recalc_cost(frm); }
});

The Final Output is:

** I also cover the addational cost:**

1 Like

this is what I planned to try myself, the same can be applied to Manufacture.

1 Like

Yes, each script has a condition (if doc.purpose), so you do this.

However, I do not prefer to use the BOM, as it has many configurations.