Skip to content

Commit

Permalink
feat: Support for Alternative Items in Quotation (#33874)
Browse files Browse the repository at this point in the history
* feat: Filter out alternative item rows in taxes and totals for Quotation

- Added a Quotation Item field `is_alternative_item`
- Use filtered rows for taxes and totals computation

(cherry picked from commit 91982d1)

# Conflicts:
#	erpnext/selling/doctype/quotation_item/quotation_item.json

* feat: Consider filtered items table in JS for totals computation

- Set `_items` as filtered rows if quotation else the entire table. Set at entry point of JS API
- Use `_items` instead of `items` to compute taxes and charges. Exclude alternative item rows

(cherry picked from commit f19eada)

* feat: Dialog to select alternative item before creating Sales order

- Users can leave the row blank in the dialog if original item is to be used
- Else users can select an alternative item against an original item
- In the document, users must check `Is Alternative Item` if needed and also specify which item it is an altenrative to since there are no documented mappings

(cherry picked from commit cef7dfd)

# Conflicts:
#	erpnext/selling/doctype/quotation/quotation.js
#	erpnext/selling/doctype/quotation_item/quotation_item.json

* feat: Filter rows to be mapped on server side mapping function

- Pass dialog selections to `make_sales_order`
- Map either original item or its alternative depending on mapping
- Only qty check for simple rows (without alternatives and not an alternative itself)

(cherry picked from commit 94cacb6)

* chore: Validate 'alternative_to' field values, must be a valid non-alterntaive item from table

(cherry picked from commit fa9b327)

* fix: Iterate over list instead of map's output and formatting

(cherry picked from commit ece6358)

* fix: Consider only ordered alternative/original item for Quotation status

- The original and its alternatives make a set of items where one is chosen
- While setting order status of Quotation, check if the chosen item from the set is fully ordered or not
- Filter out unselected items from the set
- Create a map containing the set of items and if they were ordered or not for ease of grouping
- The simple items will work as it used to

(cherry picked from commit b3fe7c6)

* chore: Code simplification

- Map is not required, avoid filter multiple times, use single loop instead
- Better variable name
- Reduce LOC

(cherry picked from commit 03321f5)

* 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

(cherry picked from commit db2076d)

# Conflicts:
#	erpnext/selling/doctype/quotation_item/quotation_item.json

* test: Alternative items in Quotation

- Taxes and totals, mapping, back updation

(cherry picked from commit 74fab53)

* fix: Use block variable

Co-authored-by: Deepesh Garg <deepeshgarg6@gmail.com>
(cherry picked from commit 3c96791)

* fix: Handle `Get Items From` in Sales Order

- Map all non alternatives from Quotation to SO if no selected items
- Show disclaimer mentioning that Qtns with alternatives must be mapped to SO from the Qtn form

(cherry picked from commit 1945612)

* fix: Map only non alternative items from Quotation in Sales Invoice

- Since there's no item selection, only Quotation selection :/

(cherry picked from commit 6b789e2)

* fix: Merge conflicts

---------

Co-authored-by: marination <maricadsouza221197@gmail.com>
  • Loading branch information
mergify[bot] and marination committed Mar 16, 2023
1 parent 68f9863 commit 9f7da21
Show file tree
Hide file tree
Showing 7 changed files with 405 additions and 42 deletions.
32 changes: 20 additions & 12 deletions erpnext/controllers/taxes_and_totals.py
Expand Up @@ -24,11 +24,19 @@ class calculate_taxes_and_totals(object):
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
Expand Down Expand Up @@ -70,7 +78,7 @@ def calculate_tax_withholding_net_total(self):
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
Expand All @@ -79,7 +87,7 @@ def calculate_tax_withholding_net_total(self):
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 = {
Expand Down Expand Up @@ -137,7 +145,7 @@ def calculate_item_values(self):
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:
Expand Down Expand Up @@ -236,7 +244,7 @@ def determine_exclusive_rate(self):
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
Expand Down Expand Up @@ -317,7 +325,7 @@ def calculate_net_total(self):
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
Expand Down Expand Up @@ -354,7 +362,7 @@ def calculate_taxes(self):
]
)

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
Expand All @@ -363,7 +371,7 @@ def calculate_taxes(self):
# 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
Expand Down Expand Up @@ -391,7 +399,7 @@ def calculate_taxes(self):
)

# 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"])

Expand Down Expand Up @@ -570,7 +578,7 @@ def calculate_totals(self):
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

Expand Down Expand Up @@ -630,7 +638,7 @@ def apply_discount_amount(self):

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
)
Expand All @@ -643,7 +651,7 @@ def apply_discount_amount(self):
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")
)
Expand Down
30 changes: 19 additions & 11 deletions erpnext/public/js/controllers/taxes_and_totals.js
Expand Up @@ -91,6 +91,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}

_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();
Expand Down Expand Up @@ -122,7 +125,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
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;
Expand Down Expand Up @@ -206,7 +209,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
});
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;
Expand Down Expand Up @@ -277,7 +280,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
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;
Expand Down Expand Up @@ -330,7 +333,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}
});

$.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
Expand All @@ -339,7 +342,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
// 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];
}
}
Expand Down Expand Up @@ -376,7 +379,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}

// 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"]);
Expand Down Expand Up @@ -599,10 +602,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {

_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"];
});
}
Expand Down Expand Up @@ -655,15 +659,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
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));
net_total += item.net_amount;

// 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,
Expand Down Expand Up @@ -892,4 +896,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}

}

filtered_items() {
return this.frm.doc.items.filter(item => !item["is_alternative"]);
}
};
124 changes: 116 additions & 8 deletions erpnext/selling/doctype/quotation/quotation.js
Expand Up @@ -90,7 +90,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
|| frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) {
this.frm.add_custom_button(
__("Sales Order"),
this.frm.cscript["Make Sales Order"],
() => this.make_sales_order(),
__("Create")
);
}
Expand Down Expand Up @@ -145,6 +145,20 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.

}

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")
{
Expand Down Expand Up @@ -220,17 +234,111 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
}
})
}

show_alternative_items_dialog() {
let 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
})
Expand Down

0 comments on commit 9f7da21

Please sign in to comment.