Here is an updated script.
frappe.ui.form.on(âQuotation Itemâ, {
item_code: function (frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (!row || !row.item_code) return;
// --- DUPLICATE-PROOFING ---
// 1) Block re-entry while we compute
if (row.__pricing_busy) return;
row.__pricing_busy = true;
// 2) Debounce rapid re-triggers (e.g., UI refresh)
clearTimeout(row.__pricing_timer);
row.__pricing_timer = setTimeout(() => runPricing(frm, cdt, cdn), 50);
}
});
function runPricing(frm, cdt, cdn) {
const row = locals[cdt][cdn];
const price_list_name = frm.doc.selling_price_list || âNot Specifiedâ;
const tx_date = frm.doc.transaction_date || null;
// If we already priced this exact item_code, don't repeat the whole pipeline
if (row.__priced_for === row.item_code) {
row.__pricing_busy = false;
return;
}
// Step 1: Get BOM unit cost (total_cost / quantity)
frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "BOM",
filters: { item: row.item_code, is_active: 1, is_default: 1 },
fields: ["name", "quantity", "total_cost"]
},
callback: function (bom_res) {
if (bom_res.message && bom_res.message.length > 0) {
const bom = bom_res.message[0];
const qty_batch = parseFloat(bom.quantity) || 1;
const total = parseFloat(bom.total_cost) || 0;
const unit_cost = qty_batch ? (total / qty_batch) : 0; // â
BOM unit cost
processPricing(unit_cost, bom.name);
} else {
// No BOM â fallback to Item valuation â else price list rate
frappe.call({
method: "frappe.client.get",
args: { doctype: "Item", name: row.item_code },
callback: function (item_res) {
let item = item_res.message;
let fallback_cost = parseFloat(item.valuation_rate) || 0;
let bom_name = "No BOM (Using Item Valuation)";
if (!fallback_cost || fallback_cost === 0) {
frappe.call({
method: "erpnext.stock.get_item_details.get_item_details",
args: {
args: {
item_code: row.item_code,
price_list: frm.doc.selling_price_list,
transaction_date: frm.doc.transaction_date,
company: frm.doc.company,
qty: row.qty || 1,
doctype: "Quotation",
currency: frm.doc.currency || "PHP",
conversion_rate: 1
}
},
callback: function (price_res) {
const rate = price_res.message?.price_list_rate;
const fallback = rate ? parseFloat(rate) : 0;
processPricing(fallback, "No BOM (Using Price List Rate)");
},
error: function () {
row.__pricing_busy = false;
}
});
} else {
processPricing(fallback_cost, bom_name);
}
},
error: function () {
row.__pricing_busy = false;
}
});
}
},
error: function () {
row.__pricing_busy = false;
}
});
// Step 2: Rule scan + compute
function processPricing(base_cost, bom_name) {
base_cost = parseFloat(base_cost) || 0;
// Pull item group/brand for rule matching
frappe.call({
method: "frappe.client.get",
args: { doctype: "Item", name: row.item_code },
callback: function (item_res) {
const item_group = item_res.message.item_group;
const brand = item_res.message.brand || null;
// Fetch only rule names (avoid forbidden fields), then filter client-side
frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "Pricing Rule",
fields: ["name", "priority"],
limit_page_length: 500
},
callback: function (list_res) {
const headers = (list_res.message || []).sort((a, b) => (b.priority || 0) - (a.priority || 0));
let matched_rule = null;
function checkRule(index) {
if (index >= headers.length) {
// Compute once at the end â ONE popup
computeAndDisplay(matched_rule, base_cost, bom_name);
return;
}
frappe.call({
method: "frappe.client.get",
args: { doctype: "Pricing Rule", name: headers[index].name },
callback: function (rule_detail) {
const doc = rule_detail.message;
// Safe guards (donât use these in get_list)
if (doc.hasOwnProperty("selling") && doc.selling !== 1) return checkRule(index + 1);
if (doc.company && frm.doc.company && doc.company !== frm.doc.company) return checkRule(index + 1);
if (doc.hasOwnProperty("disabled") && doc.disabled === 1) return checkRule(index + 1);
if (doc.valid_from && tx_date && tx_date < doc.valid_from) return checkRule(index + 1);
if (doc.valid_upto && tx_date && tx_date > doc.valid_upto) return checkRule(index + 1);
if (doc.min_qty && (row.qty || 1) < doc.min_qty) return checkRule(index + 1);
if (doc.price_list && frm.doc.selling_price_list && (doc.price_list !== frm.doc.selling_price_list))
return checkRule(index + 1);
if (doc.customer && frm.doc.customer && doc.customer !== frm.doc.customer) return checkRule(index + 1);
if (doc.customer_group && frm.doc.customer_group && doc.customer_group !== frm.doc.customer_group) return checkRule(index + 1);
if (doc.territory && frm.doc.territory && doc.territory !== frm.doc.territory) return checkRule(index + 1);
// Priority: Item Code > Item Group > Brand > Transaction
const matchItem = (doc.apply_on === "Item Code" && (doc.items || []).some(i => i.item_code === row.item_code));
const matchGroup = (doc.apply_on === "Item Group" && (doc.item_groups || []).some(g => g.item_group === item_group));
const matchBrand = (doc.apply_on === "Brand" && doc.brand === brand);
const matchTran = (doc.apply_on === "Transaction" && doc.transaction === "Quotation");
if (!matched_rule && (matchItem || matchGroup || matchBrand || matchTran)) {
matched_rule = doc;
}
checkRule(index + 1);
},
error: function () { checkRule(index + 1); }
});
}
checkRule(0);
},
error: function () {
row.__pricing_busy = false;
}
});
},
error: function () {
row.__pricing_busy = false;
}
});
}
function computeAndDisplay(rule, base_cost, bom_name) {
let markup_percentage = 0;
let markup_amount = 0; // for "Amount" rules
let rule_name = "â";
let rule_type = "";
let selling_price = base_cost; // default to BOM cost
let indicator = 'blue';
let extra_notice = '';
if (rule) {
rule_name = rule.name;
rule_type = rule.apply_on;
if (rule.margin_type === "Percentage") {
markup_percentage = parseFloat(rule.margin_rate_or_amount) || 0;
markup_amount = base_cost * (markup_percentage / 100.0);
selling_price = base_cost + markup_amount;
} else if (rule.margin_type === "Amount") {
markup_amount = parseFloat(rule.margin_rate_or_amount) || 0;
selling_price = base_cost + markup_amount;
}
} else {
indicator = 'orange';
extra_notice = `
<br><span style="color:#e67e22">
No matching Pricing Rule (Item Code / Item Group / Brand / Transaction) was found.<br>
Consider creating one in <b>Selling â Pricing Rule</b>.
</span>
`;
}
// ONE popup only
frappe.msgprint({
title: __('BOM & Price Computation'),
message: `
<b>Item:</b> ${frappe.utils.escape_html(row.item_code)}<br>
<b>BOM:</b> ${bom_name}<br>
<b>Base Cost:</b> â±${base_cost.toFixed(2)}<br>
<b>Markup:</b> ${
rule
? (rule.margin_type === "Percentage"
? (markup_percentage + "% = â±" + markup_amount.toFixed(2))
: ("+â±" + markup_amount.toFixed(2)))
: "â (no rule)"
}<br>
<b>Computed Selling Price:</b> â±${selling_price.toFixed(2)}<br><br>
<b>Applied Pricing Rule:</b> ${rule ? (rule_name + (rule_type ? " (" + rule_type + ")" : "")) : "â"}<br>
<b>Price List Used:</b> ${price_list_name}
${extra_notice}
`,
indicator: indicator
});
// Set both fields so UI stays consistent
frappe.model.set_value(cdt, cdn, "price_list_rate", selling_price);
frappe.model.set_value(cdt, cdn, "rate", selling_price);
// Mark as priced for this item_code to suppress repeats
row.__priced_for = row.item_code;
// release lock
row.__pricing_busy = false;
}
}
Hope this helps.