Skip to content

Commit

Permalink
refactor: Order based alternative items mapping
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
marination committed Feb 6, 2023
1 parent 03321f5 commit db2076d
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 80 deletions.
91 changes: 60 additions & 31 deletions erpnext/selling/doctype/quotation/quotation.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.

let has_alternative_item = this.frm.doc.items.some((item) => item.is_alternative);
if (has_alternative_item) {
this.show_alternative_item_dialog();
this.show_alternative_items_dialog();
} else {
frappe.model.open_mapped_doc({
method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
Expand Down Expand Up @@ -231,75 +231,104 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
})
}

show_alternative_item_dialog() {
show_alternative_items_dialog() {
var me = this;
let item_alt_map = {};

// Create a `{original item: [alternate items]}` map
this.frm.doc.items.filter(
(item) => item.is_alternative
).forEach((item) =>
(item_alt_map[item.alternative_to] ??= []).push(item.item_code)
)

const fields = [{
const table_fields = [
{
fieldtype:"Data",
fieldname:"name",
label: __("Name"),
read_only: 1,
},
{
fieldtype:"Link",
fieldname:"original_item",
fieldname:"item_code",
options: "Item",
label: __("Original Item"),
label: __("Item Code"),
read_only: 1,
in_list_view: 1,
columns: 2,
formatter: (value, df, options, doc) => {
return doc.is_alternative ? `<span class="indicator yellow">${value}</span>` : value;
}
},
{
fieldtype:"Link",
fieldname:"alternative_item",
options: "Item",
label: __("Alternative Item"),
fieldtype:"Data",
fieldname:"description",
label: __("Description"),
in_list_view: 1,
get_query: (row, cdt, cdn) => {
return {
filters: {
"item_code": ["in", item_alt_map[row.original_item]]
}
}
},
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({
title: __("Select Alternatives for Sales Order"),
title: __("Select Alternative Items for Sales Order"),
fields: [
{
fieldname: "info",
fieldtype: "HTML",
read_only: 1
},
{
fieldname: "alternative_items",
fieldtype: "Table",
label: "Items with Alternatives",
cannot_add_rows: true,
in_place_edit: true,
reqd: 1,
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: () => {
return this.data;
},
fields: fields
fields: table_fields
},
],
primary_action: function() {
frappe.model.open_mapped_doc({
method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
frm: me.frm,
args: {
mapping: dialog.get_value("alternative_items")
selected_items: dialog.fields_dict.alternative_items.grid.get_selected_children()
}
});
dialog.hide();
},
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();
}
};
Expand Down
72 changes: 32 additions & 40 deletions erpnext/selling/doctype/quotation/quotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,16 @@ def validate(self):
self.validate_valid_till()
self.validate_shopping_cart_items()
self.set_customer_name()
self.validate_alternative_items()
if self.items:
self.with_items = 1

from erpnext.stock.doctype.packed_item.packed_item import make_packing_list

make_packing_list(self)

def before_submit(self):
self.set_has_alternative_item()

def validate_valid_till(self):
if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date):
frappe.throw(_("Valid till date cannot be before transaction date"))
Expand All @@ -60,6 +62,16 @@ def validate_shopping_cart_items(self):
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):
status = "Open"
ordered_items = frappe._dict(
Expand Down Expand Up @@ -98,10 +110,8 @@ def is_in_sales_order(row):
)
return in_sales_order

items_with_alternatives = self.get_items_having_alternatives()

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 True
Expand All @@ -127,24 +137,6 @@ def set_customer_name(self):
)
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):
for opportunity in set(d.prevdoc_docname for d in self.get("items")):
if opportunity:
Expand Down Expand Up @@ -222,10 +214,21 @@ def print_other_charges(self, docname):
def on_recurring(self, reference_doc, auto_repeat_doc):
self.valid_till = None

def get_items_having_alternatives(self):
alternative_items = filter(lambda item: item.is_alternative, self.get("items"))
items_with_alternatives = set((map(lambda item: item.alternative_to, alternative_items)))
return items_with_alternatives
def get_rows_with_alternatives(self):
rows_with_alternatives = []
table_length = len(self.get("items"))

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):
Expand Down Expand Up @@ -261,10 +264,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
)
)

alternative_map = {
x.get("original_item"): x.get("alternative_item")
for x in frappe.flags.get("args", {}).get("mapping", [])
}
selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]

def set_missing_values(source, target):
if customer:
Expand Down Expand Up @@ -297,19 +297,11 @@ def can_map_row(item) -> bool:
3. Is Alternative Item: Map if alternative was selected against original item and #1
"""
has_qty = item.qty > 0

has_alternative = item.item_code in alternative_map
is_alternative = item.is_alternative

if not alternative_map or not (is_alternative or has_alternative):
if not (item.is_alternative or item.has_alternative_item):
# No alternative items in doc or current row is a simple item (without alternatives)
return has_qty

if is_alternative:
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
return (item.name in selected_rows) and has_qty

doclist = get_mapped_doc(
"Quotation",
Expand Down
18 changes: 9 additions & 9 deletions erpnext/selling/doctype/quotation_item/quotation_item.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"stock_uom_rate",
"is_free_item",
"is_alternative",
"alternative_to",
"has_alternative_item",
"section_break_43",
"valuation_rate",
"column_break_45",
Expand Down Expand Up @@ -654,19 +654,19 @@
"print_hide": 1
},
{
"depends_on": "is_alternative",
"fieldname": "alternative_to",
"fieldtype": "Link",
"label": "Alternative To",
"mandatory_depends_on": "is_alternative",
"options": "Item",
"print_hide": 1
"default": "0",
"fieldname": "has_alternative_item",
"fieldtype": "Check",
"hidden": 1,
"label": "Has Alternative Item",
"print_hide": 1,
"read_only": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
"modified": "2023-01-26 07:32:02.768197",
"modified": "2023-02-06 11:00:07.042364",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation Item",
Expand Down

0 comments on commit db2076d

Please sign in to comment.