feat: BOM Comparison Tool
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index a716293..22c2f69 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -9,6 +9,7 @@
from frappe.website.website_generator import WebsiteGenerator
from erpnext.stock.get_item_details import get_conversion_factor
from erpnext.stock.get_item_details import get_price_list_rate
+from frappe.core.doctype.version.version import get_diff
import functools
@@ -763,3 +764,52 @@
'description': name[0],
'amount': items.get(name[0])
})
+
+@frappe.whitelist()
+def get_bom_diff(bom1, bom2):
+ from frappe.model import table_fields
+
+ doc1 = frappe.get_doc('BOM', bom1)
+ doc2 = frappe.get_doc('BOM', bom2)
+
+ out = get_diff(doc1, doc2)
+ out.row_changed = []
+ out.added = []
+ out.removed = []
+
+ meta = doc1.meta
+
+ identifiers = {
+ 'operations': 'operation',
+ 'items': 'item_code',
+ 'scrap_items': 'item_code',
+ 'exploded_items': 'item_code'
+ }
+
+ for df in meta.fields:
+ old_value, new_value = doc1.get(df.fieldname), doc2.get(df.fieldname)
+
+ if df.fieldtype in table_fields:
+ identifier = identifiers[df.fieldname]
+ # make maps
+ old_row_by_identifier, new_row_by_identifier = {}, {}
+ for d in old_value:
+ old_row_by_identifier[d.get(identifier)] = d
+ for d in new_value:
+ new_row_by_identifier[d.get(identifier)] = d
+
+ # check rows for additions, changes
+ for i, d in enumerate(new_value):
+ if d.get(identifier) in old_row_by_identifier:
+ diff = get_diff(old_row_by_identifier[d.get(identifier)], d, for_child=True)
+ if diff and diff.changed:
+ out.row_changed.append((df.fieldname, i, d.get(identifier), diff.changed))
+ else:
+ out.added.append([df.fieldname, d.as_dict()])
+
+ # check for deletions
+ for d in old_value:
+ if not d.get(identifier) in new_row_by_identifier:
+ out.removed.append([df.fieldname, d.as_dict()])
+
+ return out
diff --git a/erpnext/manufacturing/page/bom_comparison_tool/__init__.py b/erpnext/manufacturing/page/bom_comparison_tool/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/manufacturing/page/bom_comparison_tool/__init__.py
diff --git a/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.js b/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.js
new file mode 100644
index 0000000..eeecdf6
--- /dev/null
+++ b/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.js
@@ -0,0 +1,218 @@
+frappe.pages['bom-comparison-tool'].on_page_load = function(wrapper) {
+ var page = frappe.ui.make_app_page({
+ parent: wrapper,
+ title: __('BOM Comparison Tool'),
+ single_column: true
+ });
+
+ new erpnext.BOMComparisonTool(page);
+}
+
+erpnext.BOMComparisonTool = class BOMComparisonTool {
+ constructor(page) {
+ this.page = page;
+ this.make_form();
+ }
+
+ make_form() {
+ this.form = new frappe.ui.FieldGroup({
+ fields: [
+ {
+ label: __('BOM 1'),
+ fieldname: 'name1',
+ fieldtype: 'Link',
+ options: 'BOM',
+ change: () => this.fetch_and_render()
+ },
+ {
+ fieldtype: 'Column Break'
+ },
+ {
+ label: __('BOM 2'),
+ fieldname: 'name2',
+ fieldtype: 'Link',
+ options: 'BOM',
+ change: () => this.fetch_and_render()
+ },
+ {
+ fieldtype: 'Section Break'
+ },
+ {
+ fieldtype: 'HTML',
+ fieldname: 'preview'
+ }
+ ],
+ body: this.page.body
+ });
+ this.form.make();
+ }
+
+ fetch_and_render() {
+ let { name1, name2 } = this.form.get_values();
+ if (!(name1 && name2)) {
+ this.form.get_field('preview').html('');
+ return;
+ }
+
+ // set working state
+ this.form.get_field('preview').html(`
+ <div class="text-muted margin-top">
+ ${__("Fetching...")}
+ </div>
+ `);
+
+ frappe.call('erpnext.manufacturing.doctype.bom.bom.get_bom_diff', {
+ bom1: name1,
+ bom2: name2
+ }).then(r => {
+ let diff = r.message;
+ frappe.model.with_doctype('BOM', () => {
+ this.render('BOM', name1, name2, diff);
+ });
+ });
+ }
+
+ render(doctype, name1, name2, diff) {
+
+ let change_html = (title, doctype, changed) => {
+ let values_changed = this.get_changed_values(doctype, changed)
+ .map(change => {
+ let [fieldname, value1, value2] = change;
+ return `
+ <tr>
+ <td>${frappe.meta.get_label(doctype, fieldname)}</td>
+ <td>${value1}</td>
+ <td>${value2}</td>
+ </tr>
+ `;
+ })
+ .join('');
+
+ return `
+ <h4 class="margin-top">${title}</h4>
+ <div>
+ <table class="table table-bordered">
+ <tr>
+ <th width="33%">${__('Field')}</th>
+ <th width="33%">${name1}</th>
+ <th width="33%">${name2}</th>
+ </tr>
+ ${values_changed}
+ </table>
+ </div>
+ `;
+ }
+
+ let value_changes = change_html(__('Values Changed'), doctype, diff.changed);
+
+ let row_changes_by_fieldname = group_items(diff.row_changed, change => change[0]);
+
+ let table_changes = Object.keys(row_changes_by_fieldname).map(fieldname => {
+ let changes = row_changes_by_fieldname[fieldname];
+ let df = frappe.meta.get_docfield(doctype, fieldname);
+
+ let html = changes.map(change => {
+ let [fieldname, idx, item_code, changes] = change;
+ let df = frappe.meta.get_docfield(doctype, fieldname);
+ let child_doctype = df.options;
+ let values_changed = this.get_changed_values(child_doctype, changes);
+
+ return values_changed.map((change, i) => {
+ let [fieldname, value1, value2] = change;
+ return `
+ <tr>
+ ${i === 0
+ ? `<th rowspan="${values_changed.length}">${item_code}</th>`
+ : ''}
+ <td>${frappe.meta.get_label(child_doctype, fieldname)}</td>
+ <td>${value1}</td>
+ <td>${value2}</td>
+ </tr>
+ `;
+ }).join('');
+ }).join('');
+
+ return `
+ <h4 class="margin-top">${__('Changes in {0}', [df.label])}</h4>
+ <table class="table table-bordered">
+ <tr>
+ <th width="25%">${__('Item Code')}</th>
+ <th width="25%">${__('Field')}</th>
+ <th width="25%">${name1}</th>
+ <th width="25%">${name2}</th>
+ </tr>
+ ${html}
+ </table>
+ `;
+ }).join('');
+
+ let get_added_removed_html = (title, grouped_items) => {
+ return Object.keys(grouped_items).map(fieldname => {
+ let rows = grouped_items[fieldname];
+ let df = frappe.meta.get_docfield(doctype, fieldname);
+ let fields = frappe.meta.get_docfields(df.options)
+ .filter(df => df.in_list_view);
+
+ let html = rows.map(row => {
+ let [, doc] = row;
+ return `
+ <tr>
+ <tr>
+ ${fields.map(df => {
+ return `<td>${doc[df.fieldname]}</td>`
+ }).join('')}
+ </tr>
+ `;
+ }).join('');
+
+ return `
+ <h4 class="margin-top">${$.format(title, [df.label])}</h4>
+ <table class="table table-bordered">
+ ${fields.map(df => {
+ return `<th>${df.label}</th>`
+ }).join('')}
+ </tr>
+ ${html}
+ </table>
+ `;
+ }).join('');
+ }
+
+ let added_by_fieldname = group_items(diff.added, change => change[0]);
+ let removed_by_fieldname = group_items(diff.removed, change => change[0]);
+
+ let added_html = get_added_removed_html(__('Rows Added in {0}'), added_by_fieldname);
+ let removed_html = get_added_removed_html(__('Rows Removed in {0}'), removed_by_fieldname);
+
+ let html = `
+ ${value_changes}
+ ${table_changes}
+ ${added_html}
+ ${removed_html}
+ `;
+
+ this.form.get_field('preview').html(html);
+ }
+
+ get_changed_values(doctype, changed) {
+ return changed.filter(change => {
+ let [fieldname, value1, value2] = change;
+ if (!value1) value1 = '';
+ if (!value2) value2 = '';
+ if (value1 === value2) return false;
+ let df = frappe.meta.get_docfield(doctype, fieldname);
+ if (!df) return false;
+ if (df.hidden) return false;
+ return true;
+ });
+ }
+}
+
+function group_items(array, fn) {
+ return array.reduce((acc, item) => {
+ let key = fn(item);
+ acc[key] = acc[key] || [];
+ acc[key].push(item);
+ return acc;
+ }, {});
+}
diff --git a/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.json b/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.json
new file mode 100644
index 0000000..067a106
--- /dev/null
+++ b/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.json
@@ -0,0 +1,30 @@
+{
+ "content": null,
+ "creation": "2019-07-29 13:24:38.201981",
+ "docstatus": 0,
+ "doctype": "Page",
+ "idx": 0,
+ "modified": "2019-07-29 13:24:38.201981",
+ "modified_by": "Administrator",
+ "module": "Manufacturing",
+ "name": "bom-comparison-tool",
+ "owner": "Administrator",
+ "page_name": "BOM Comparison Tool",
+ "restrict_to_domain": "Manufacturing",
+ "roles": [
+ {
+ "role": "System Manager"
+ },
+ {
+ "role": "Manufacturing User"
+ },
+ {
+ "role": "Manufacturing Manager"
+ }
+ ],
+ "script": null,
+ "standard": "Yes",
+ "style": null,
+ "system_page": 0,
+ "title": "BOM Comparison Tool"
+}
\ No newline at end of file