refactor: Order based alternative items mapping

- Alternatives must be followed by a non-alternative item row
- On submit, store non-alternative rows in hidden checkbox to avoid recomputation
- Check for valid/mappable rows by row name
- UI: Select from table rows.Add single row for original/alternative item in dialog
- UI: Indicator for alternative items in dialog grid
- UI: Indicator legend and description of table
- DB: Added check field 'Has Alternative Item' not to be confused with 'Has Alternative' in Mfg
This commit is contained in:
marination 2023-02-06 16:25:38 +05:30
parent 03321f5f13
commit db2076db69
3 changed files with 102 additions and 81 deletions

View File

@ -146,7 +146,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
let has_alternative_item = this.frm.doc.items.some((item) => item.is_alternative); let has_alternative_item = this.frm.doc.items.some((item) => item.is_alternative);
if (has_alternative_item) { if (has_alternative_item) {
this.show_alternative_item_dialog(); this.show_alternative_items_dialog();
} else { } else {
frappe.model.open_mapped_doc({ frappe.model.open_mapped_doc({
method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
@ -231,60 +231,83 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
}) })
} }
show_alternative_item_dialog() { show_alternative_items_dialog() {
var me = this; var me = this;
let item_alt_map = {};
// Create a `{original item: [alternate items]}` map const table_fields = [
this.frm.doc.items.filter( {
(item) => item.is_alternative fieldtype:"Data",
).forEach((item) => fieldname:"name",
(item_alt_map[item.alternative_to] ??= []).push(item.item_code) label: __("Name"),
)
const fields = [{
fieldtype:"Link",
fieldname:"original_item",
options: "Item",
label: __("Original Item"),
read_only: 1, read_only: 1,
in_list_view: 1,
}, },
{ {
fieldtype:"Link", fieldtype:"Link",
fieldname:"alternative_item", fieldname:"item_code",
options: "Item", options: "Item",
label: __("Alternative Item"), label: __("Item Code"),
read_only: 1,
in_list_view: 1, in_list_view: 1,
get_query: (row, cdt, cdn) => { columns: 2,
return { formatter: (value, df, options, doc) => {
filters: { return doc.is_alternative ? `<span class="indicator yellow">${value}</span>` : value;
"item_code": ["in", item_alt_map[row.original_item]] }
} },
} {
}, fieldtype:"Data",
fieldname:"description",
label: __("Description"),
in_list_view: 1,
read_only: 1,
},
{
fieldtype:"Currency",
fieldname:"amount",
label: __("Amount"),
options: "currency",
in_list_view: 1,
read_only: 1,
},
{
fieldtype:"Check",
fieldname:"is_alternative",
label: __("Is Alternative"),
read_only: 1,
}]; }];
this.data = Object.keys(item_alt_map).map((item) => {
return {"original_item": item} this.data = this.frm.doc.items.filter(
(item) => item.is_alternative || item.has_alternative_item
).map((item) => {
return {
"name": item.name,
"item_code": item.item_code,
"description": item.description,
"amount": item.amount,
"is_alternative": item.is_alternative,
}
}); });
const dialog = new frappe.ui.Dialog({ const dialog = new frappe.ui.Dialog({
title: __("Select Alternatives for Sales Order"), title: __("Select Alternative Items for Sales Order"),
fields: [ fields: [
{
fieldname: "info",
fieldtype: "HTML",
read_only: 1
},
{ {
fieldname: "alternative_items", fieldname: "alternative_items",
fieldtype: "Table", fieldtype: "Table",
label: "Items with Alternatives",
cannot_add_rows: true, cannot_add_rows: true,
in_place_edit: true, in_place_edit: true,
reqd: 1, reqd: 1,
data: this.data, data: this.data,
description: __("Select an alternative to be used in the Sales Order or leave it blank to use the original item."), description: __("Select an item from each set to be used in the Sales Order."),
get_data: () => { get_data: () => {
return this.data; return this.data;
}, },
fields: fields fields: table_fields
}, },
], ],
primary_action: function() { primary_action: function() {
@ -292,7 +315,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
frm: me.frm, frm: me.frm,
args: { args: {
mapping: dialog.get_value("alternative_items") selected_items: dialog.fields_dict.alternative_items.grid.get_selected_children()
} }
}); });
dialog.hide(); dialog.hide();
@ -300,6 +323,12 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
primary_action_label: __('Continue') primary_action_label: __('Continue')
}); });
dialog.fields_dict.info.$wrapper.html(
`<p class="small text-muted">
<span class="indicator yellow"></span>
Alternative Items
</p>`
)
dialog.show(); dialog.show();
} }
}; };

View File

@ -28,7 +28,6 @@ class Quotation(SellingController):
self.validate_valid_till() self.validate_valid_till()
self.validate_shopping_cart_items() self.validate_shopping_cart_items()
self.set_customer_name() self.set_customer_name()
self.validate_alternative_items()
if self.items: if self.items:
self.with_items = 1 self.with_items = 1
@ -36,6 +35,9 @@ class Quotation(SellingController):
make_packing_list(self) make_packing_list(self)
def before_submit(self):
self.set_has_alternative_item()
def validate_valid_till(self): def validate_valid_till(self):
if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date): if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date):
frappe.throw(_("Valid till date cannot be before transaction date")) frappe.throw(_("Valid till date cannot be before transaction date"))
@ -60,6 +62,16 @@ class Quotation(SellingController):
title=_("Unpublished Item"), title=_("Unpublished Item"),
) )
def set_has_alternative_item(self):
"""Mark 'Has Alternative Item' for rows."""
if not any(row.is_alternative for row in self.get("items")):
return
items_with_alternatives = self.get_rows_with_alternatives()
for row in self.get("items"):
if not row.is_alternative and row.name in items_with_alternatives:
row.has_alternative_item = 1
def get_ordered_status(self): def get_ordered_status(self):
status = "Open" status = "Open"
ordered_items = frappe._dict( ordered_items = frappe._dict(
@ -98,10 +110,8 @@ class Quotation(SellingController):
) )
return in_sales_order return in_sales_order
items_with_alternatives = self.get_items_having_alternatives()
def can_map(row) -> bool: def can_map(row) -> bool:
if row.is_alternative or (row.item_code in items_with_alternatives): if row.is_alternative or row.has_alternative_item:
return is_in_sales_order(row) return is_in_sales_order(row)
return True return True
@ -127,24 +137,6 @@ class Quotation(SellingController):
) )
self.customer_name = company_name or lead_name self.customer_name = company_name or lead_name
def validate_alternative_items(self):
if not any(row.is_alternative for row in self.get("items")):
return
non_alternative_items = filter(lambda item: not item.is_alternative, self.get("items"))
non_alternative_items = list(map(lambda item: item.item_code, non_alternative_items))
alternative_items = filter(lambda item: item.is_alternative, self.get("items"))
for row in alternative_items:
if row.alternative_to not in non_alternative_items:
frappe.throw(
_("Row #{0}: {1} is not a valid non-alternative Item from the table").format(
row.idx, frappe.bold(row.alternative_to)
),
title=_("Invalid Item"),
)
def update_opportunity(self, status): def update_opportunity(self, status):
for opportunity in set(d.prevdoc_docname for d in self.get("items")): for opportunity in set(d.prevdoc_docname for d in self.get("items")):
if opportunity: if opportunity:
@ -222,10 +214,21 @@ class Quotation(SellingController):
def on_recurring(self, reference_doc, auto_repeat_doc): def on_recurring(self, reference_doc, auto_repeat_doc):
self.valid_till = None self.valid_till = None
def get_items_having_alternatives(self): def get_rows_with_alternatives(self):
alternative_items = filter(lambda item: item.is_alternative, self.get("items")) rows_with_alternatives = []
items_with_alternatives = set((map(lambda item: item.alternative_to, alternative_items))) table_length = len(self.get("items"))
return items_with_alternatives
for idx, row in enumerate(self.get("items")):
if row.is_alternative:
continue
if idx == (table_length - 1):
break
if self.get("items")[idx + 1].is_alternative:
rows_with_alternatives.append(row.name)
return rows_with_alternatives
def get_list_context(context=None): def get_list_context(context=None):
@ -261,10 +264,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
) )
) )
alternative_map = { selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]
x.get("original_item"): x.get("alternative_item")
for x in frappe.flags.get("args", {}).get("mapping", [])
}
def set_missing_values(source, target): def set_missing_values(source, target):
if customer: if customer:
@ -297,19 +297,11 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
3. Is Alternative Item: Map if alternative was selected against original item and #1 3. Is Alternative Item: Map if alternative was selected against original item and #1
""" """
has_qty = item.qty > 0 has_qty = item.qty > 0
if not (item.is_alternative or item.has_alternative_item):
has_alternative = item.item_code in alternative_map
is_alternative = item.is_alternative
if not alternative_map or not (is_alternative or has_alternative):
# No alternative items in doc or current row is a simple item (without alternatives) # No alternative items in doc or current row is a simple item (without alternatives)
return has_qty return has_qty
if is_alternative: return (item.name in selected_rows) and has_qty
is_selected = alternative_map.get(item.alternative_to) == item.item_code
else:
is_selected = alternative_map.get(item.item_code) is None
return is_selected and has_qty
doclist = get_mapped_doc( doclist = get_mapped_doc(
"Quotation", "Quotation",

View File

@ -50,7 +50,7 @@
"stock_uom_rate", "stock_uom_rate",
"is_free_item", "is_free_item",
"is_alternative", "is_alternative",
"alternative_to", "has_alternative_item",
"section_break_43", "section_break_43",
"valuation_rate", "valuation_rate",
"column_break_45", "column_break_45",
@ -654,19 +654,19 @@
"print_hide": 1 "print_hide": 1
}, },
{ {
"depends_on": "is_alternative", "default": "0",
"fieldname": "alternative_to", "fieldname": "has_alternative_item",
"fieldtype": "Link", "fieldtype": "Check",
"label": "Alternative To", "hidden": 1,
"mandatory_depends_on": "is_alternative", "label": "Has Alternative Item",
"options": "Item", "print_hide": 1,
"print_hide": 1 "read_only": 1
} }
], ],
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-01-26 07:32:02.768197", "modified": "2023-02-06 11:00:07.042364",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Quotation Item", "name": "Quotation Item",