Merge branch 'develop' into alternative-items-quotation
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index 8c403aa..1edd7bf 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -24,11 +24,19 @@
def __init__(self, doc: Document):
self.doc = doc
frappe.flags.round_off_applicable_accounts = []
+
+ self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items")
+
get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts)
self.calculate()
+ def filter_rows(self):
+ """Exclude rows, that do not fulfill the filter criteria, from totals computation."""
+ items = list(filter(lambda item: not item.get("is_alternative"), self.doc.get("items")))
+ return items
+
def calculate(self):
- if not len(self.doc.get("items")):
+ if not len(self._items):
return
self.discount_amount_applied = False
@@ -70,7 +78,7 @@
if hasattr(self.doc, "tax_withholding_net_total"):
sum_net_amount = 0
sum_base_net_amount = 0
- for item in self.doc.get("items"):
+ for item in self._items:
if hasattr(item, "apply_tds") and item.apply_tds:
sum_net_amount += item.net_amount
sum_base_net_amount += item.base_net_amount
@@ -79,7 +87,7 @@
self.doc.base_tax_withholding_net_total = sum_base_net_amount
def validate_item_tax_template(self):
- for item in self.doc.get("items"):
+ for item in self._items:
if item.item_code and item.get("item_tax_template"):
item_doc = frappe.get_cached_doc("Item", item.item_code)
args = {
@@ -137,7 +145,7 @@
return
if not self.discount_amount_applied:
- for item in self.doc.get("items"):
+ for item in self._items:
self.doc.round_floats_in(item)
if item.discount_percentage == 100:
@@ -236,7 +244,7 @@
if not any(cint(tax.included_in_print_rate) for tax in self.doc.get("taxes")):
return
- for item in self.doc.get("items"):
+ for item in self._items:
item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
cumulated_tax_fraction = 0
total_inclusive_tax_amount_per_qty = 0
@@ -317,7 +325,7 @@
self.doc.total
) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0
- for item in self.doc.get("items"):
+ for item in self._items:
self.doc.total += item.amount
self.doc.total_qty += item.qty
self.doc.base_total += item.base_amount
@@ -354,7 +362,7 @@
]
)
- for n, item in enumerate(self.doc.get("items")):
+ for n, item in enumerate(self._items):
item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
for i, tax in enumerate(self.doc.get("taxes")):
# tax_amount represents the amount of tax for the current step
@@ -363,7 +371,7 @@
# Adjust divisional loss to the last item
if tax.charge_type == "Actual":
actual_tax_dict[tax.idx] -= current_tax_amount
- if n == len(self.doc.get("items")) - 1:
+ if n == len(self._items) - 1:
current_tax_amount += actual_tax_dict[tax.idx]
# accumulate tax amount into tax.tax_amount
@@ -391,7 +399,7 @@
)
# set precision in the last item iteration
- if n == len(self.doc.get("items")) - 1:
+ if n == len(self._items) - 1:
self.round_off_totals(tax)
self._set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"])
@@ -570,7 +578,7 @@
def calculate_total_net_weight(self):
if self.doc.meta.get_field("total_net_weight"):
self.doc.total_net_weight = 0.0
- for d in self.doc.items:
+ for d in self._items:
if d.total_weight:
self.doc.total_net_weight += d.total_weight
@@ -630,7 +638,7 @@
if total_for_discount_amount:
# calculate item amount after Discount Amount
- for i, item in enumerate(self.doc.get("items")):
+ for i, item in enumerate(self._items):
distributed_amount = (
flt(self.doc.discount_amount) * item.net_amount / total_for_discount_amount
)
@@ -643,7 +651,7 @@
self.doc.apply_discount_on == "Net Total"
or not taxes
or total_for_discount_amount == self.doc.net_total
- ) and i == len(self.doc.get("items")) - 1:
+ ) and i == len(self._items) - 1:
discount_amount_loss = flt(
self.doc.net_total - net_total - self.doc.discount_amount, self.doc.precision("net_total")
)
diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js
index 2ce0c7e..029d6c0 100644
--- a/erpnext/public/js/controllers/taxes_and_totals.js
+++ b/erpnext/public/js/controllers/taxes_and_totals.js
@@ -91,6 +91,9 @@
}
_calculate_taxes_and_totals() {
+ const is_quotation = this.frm.doc.doctype == "Quotation";
+ this.frm.doc._items = is_quotation ? this.filtered_items() : this.frm.doc.items;
+
this.validate_conversion_rate();
this.calculate_item_values();
this.initialize_taxes();
@@ -122,7 +125,7 @@
calculate_item_values() {
var me = this;
if (!this.discount_amount_applied) {
- for (const item of this.frm.doc.items || []) {
+ for (const item of this.frm.doc._items || []) {
frappe.model.round_floats_in(item);
item.net_rate = item.rate;
item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty;
@@ -197,7 +200,7 @@
});
if(has_inclusive_tax==false) return;
- $.each(me.frm.doc["items"] || [], function(n, item) {
+ $.each(me.frm.doc._items || [], function(n, item) {
var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
var cumulated_tax_fraction = 0.0;
var total_inclusive_tax_amount_per_qty = 0;
@@ -268,7 +271,7 @@
var me = this;
this.frm.doc.total_qty = this.frm.doc.total = this.frm.doc.base_total = this.frm.doc.net_total = this.frm.doc.base_net_total = 0.0;
- $.each(this.frm.doc["items"] || [], function(i, item) {
+ $.each(this.frm.doc._items || [], function(i, item) {
me.frm.doc.total += item.amount;
me.frm.doc.total_qty += item.qty;
me.frm.doc.base_total += item.base_amount;
@@ -321,7 +324,7 @@
}
});
- $.each(this.frm.doc["items"] || [], function(n, item) {
+ $.each(this.frm.doc._items || [], function(n, item) {
var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
$.each(me.frm.doc["taxes"] || [], function(i, tax) {
// tax_amount represents the amount of tax for the current step
@@ -330,7 +333,7 @@
// Adjust divisional loss to the last item
if (tax.charge_type == "Actual") {
actual_tax_dict[tax.idx] -= current_tax_amount;
- if (n == me.frm.doc["items"].length - 1) {
+ if (n == me.frm.doc._items.length - 1) {
current_tax_amount += actual_tax_dict[tax.idx];
}
}
@@ -367,7 +370,7 @@
}
// set precision in the last item iteration
- if (n == me.frm.doc["items"].length - 1) {
+ if (n == me.frm.doc._items.length - 1) {
me.round_off_totals(tax);
me.set_in_company_currency(tax,
["tax_amount", "tax_amount_after_discount_amount"]);
@@ -590,10 +593,11 @@
_cleanup() {
this.frm.doc.base_in_words = this.frm.doc.in_words = "";
+ let items = this.frm.doc._items;
- if(this.frm.doc["items"] && this.frm.doc["items"].length) {
- if(!frappe.meta.get_docfield(this.frm.doc["items"][0].doctype, "item_tax_amount", this.frm.doctype)) {
- $.each(this.frm.doc["items"] || [], function(i, item) {
+ if(items && items.length) {
+ if(!frappe.meta.get_docfield(items[0].doctype, "item_tax_amount", this.frm.doctype)) {
+ $.each(items || [], function(i, item) {
delete item["item_tax_amount"];
});
}
@@ -646,7 +650,7 @@
var net_total = 0;
// calculate item amount after Discount Amount
if (total_for_discount_amount) {
- $.each(this.frm.doc["items"] || [], function(i, item) {
+ $.each(this.frm.doc._items || [], function(i, item) {
distributed_amount = flt(me.frm.doc.discount_amount) * item.net_amount / total_for_discount_amount;
item.net_amount = flt(item.net_amount - distributed_amount,
precision("base_amount", item));
@@ -654,7 +658,7 @@
// discount amount rounding loss adjustment if no taxes
if ((!(me.frm.doc.taxes || []).length || total_for_discount_amount==me.frm.doc.net_total || (me.frm.doc.apply_discount_on == "Net Total"))
- && i == (me.frm.doc.items || []).length - 1) {
+ && i == (me.frm.doc._items || []).length - 1) {
var discount_amount_loss = flt(me.frm.doc.net_total - net_total
- me.frm.doc.discount_amount, precision("net_total"));
item.net_amount = flt(item.net_amount + discount_amount_loss,
@@ -883,4 +887,8 @@
}
}
+
+ filtered_items() {
+ return this.frm.doc.items.filter(item => !item["is_alternative"]);
+ }
};
diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js
index 6b42e4d..a67f9b0 100644
--- a/erpnext/selling/doctype/quotation/quotation.js
+++ b/erpnext/selling/doctype/quotation/quotation.js
@@ -87,7 +87,7 @@
if (doc.docstatus == 1 && !["Lost", "Ordered"].includes(doc.status)) {
this.frm.add_custom_button(
__("Sales Order"),
- this.frm.cscript["Make Sales Order"],
+ () => this.make_sales_order(),
__("Create")
);
@@ -141,6 +141,20 @@
}
+ make_sales_order() {
+ var me = this;
+
+ let has_alternative_item = this.frm.doc.items.some((item) => item.is_alternative);
+ if (has_alternative_item) {
+ this.show_alternative_items_dialog();
+ } else {
+ frappe.model.open_mapped_doc({
+ method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
+ frm: me.frm
+ });
+ }
+ }
+
set_dynamic_field_label(){
if (this.frm.doc.quotation_to == "Customer")
{
@@ -216,17 +230,111 @@
}
})
}
+
+ show_alternative_items_dialog() {
+ var me = this;
+
+ const table_fields = [
+ {
+ fieldtype:"Data",
+ fieldname:"name",
+ label: __("Name"),
+ read_only: 1,
+ },
+ {
+ fieldtype:"Link",
+ fieldname:"item_code",
+ options: "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:"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 = 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 Alternative Items for Sales Order"),
+ fields: [
+ {
+ fieldname: "info",
+ fieldtype: "HTML",
+ read_only: 1
+ },
+ {
+ fieldname: "alternative_items",
+ fieldtype: "Table",
+ cannot_add_rows: true,
+ in_place_edit: true,
+ reqd: 1,
+ data: this.data,
+ description: __("Select an item from each set to be used in the Sales Order."),
+ get_data: () => {
+ return this.data;
+ },
+ fields: table_fields
+ },
+ ],
+ primary_action: function() {
+ frappe.model.open_mapped_doc({
+ method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
+ frm: me.frm,
+ args: {
+ 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();
+ }
};
cur_frm.script_manager.make(erpnext.selling.QuotationController);
-cur_frm.cscript['Make Sales Order'] = function() {
- frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
- frm: cur_frm
- })
-}
-
frappe.ui.form.on("Quotation Item", "items_on_form_rendered", "packed_items_on_form_rendered", function(frm, cdt, cdn) {
// enable tax_amount field if Actual
})
diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py
index 6836d56..a4a5667 100644
--- a/erpnext/selling/doctype/quotation/quotation.py
+++ b/erpnext/selling/doctype/quotation/quotation.py
@@ -35,6 +35,9 @@
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"))
@@ -59,7 +62,18 @@
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(
frappe.db.get_all(
"Sales Order Item",
@@ -70,16 +84,40 @@
)
)
- status = "Open"
- if ordered_items:
+ if not ordered_items:
+ return status
+
+ has_alternatives = any(row.is_alternative for row in self.get("items"))
+ self._items = self.get_valid_items() if has_alternatives else self.get("items")
+
+ if any(row.qty > ordered_items.get(row.item_code, 0.0) for row in self._items):
+ status = "Partially Ordered"
+ else:
status = "Ordered"
- for item in self.get("items"):
- if item.qty > ordered_items.get(item.item_code, 0.0):
- status = "Partially Ordered"
-
return status
+ def get_valid_items(self):
+ """
+ Filters out items in an alternatives set that were not ordered.
+ """
+
+ def is_in_sales_order(row):
+ in_sales_order = bool(
+ frappe.db.exists(
+ "Sales Order Item", {"quotation_item": row.name, "item_code": row.item_code, "docstatus": 1}
+ )
+ )
+ return in_sales_order
+
+ def can_map(row) -> bool:
+ if row.is_alternative or row.has_alternative_item:
+ return is_in_sales_order(row)
+
+ return True
+
+ return list(filter(can_map, self.get("items")))
+
def is_fully_ordered(self):
return self.get_ordered_status() == "Ordered"
@@ -176,6 +214,22 @@
def on_recurring(self, reference_doc, auto_repeat_doc):
self.valid_till = None
+ 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):
from erpnext.controllers.website_list_for_contact import get_list_context
@@ -210,6 +264,8 @@
)
)
+ selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]
+
def set_missing_values(source, target):
if customer:
target.customer = customer.name
@@ -233,6 +289,20 @@
target.blanket_order = obj.blanket_order
target.blanket_order_rate = obj.blanket_order_rate
+ def can_map_row(item) -> bool:
+ """
+ Row mapping from Quotation to Sales order:
+ 1. Simple row: Map if adequate qty
+ 2. Has Alternative Item: Map if no 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
+ 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
+
+ return (item.name in selected_rows) and has_qty
+
doclist = get_mapped_doc(
"Quotation",
source_name,
@@ -242,7 +312,7 @@
"doctype": "Sales Order Item",
"field_map": {"parent": "prevdoc_docname", "name": "quotation_item"},
"postprocess": update_item,
- "condition": lambda doc: doc.qty > 0,
+ "condition": can_map_row,
},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json
index ca7dfd2..f2aabc5 100644
--- a/erpnext/selling/doctype/quotation_item/quotation_item.json
+++ b/erpnext/selling/doctype/quotation_item/quotation_item.json
@@ -49,6 +49,8 @@
"pricing_rules",
"stock_uom_rate",
"is_free_item",
+ "is_alternative",
+ "has_alternative_item",
"section_break_43",
"valuation_rate",
"column_break_45",
@@ -643,12 +645,28 @@
"no_copy": 1,
"options": "currency",
"read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "is_alternative",
+ "fieldtype": "Check",
+ "label": "Is Alternative",
+ "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": "2022-12-25 02:49:53.926625",
+ "modified": "2023-02-06 11:00:07.042364",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation Item",
@@ -656,5 +674,6 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file