Merge branch 'develop' into finance_book_read_only

This commit is contained in:
Deepesh Garg 2023-03-02 10:01:12 +05:30 committed by GitHub
commit 790f0aac12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 427 additions and 55 deletions

View File

@ -38,8 +38,11 @@
{% if(data[i].posting_date) { %} {% if(data[i].posting_date) { %}
<td>{%= frappe.datetime.str_to_user(data[i].posting_date) %}</td> <td>{%= frappe.datetime.str_to_user(data[i].posting_date) %}</td>
<td>{%= data[i].voucher_type %} <td>{%= data[i].voucher_type %}
<br>{%= data[i].voucher_no %}</td> <br>{%= data[i].voucher_no %}
<td> </td>
{% var longest_word = cstr(data[i].remarks).split(" ").reduce((longest, word) => word.length > longest.length ? word : longest, ""); %}
<td {% if longest_word.length > 45 %} class="overflow-wrap-anywhere" {% endif %}>
<span>
{% if(!(filters.party || filters.account)) { %} {% if(!(filters.party || filters.account)) { %}
{%= data[i].party || data[i].account %} {%= data[i].party || data[i].account %}
<br> <br>
@ -49,11 +52,14 @@
{% if(data[i].bill_no) { %} {% if(data[i].bill_no) { %}
<br>{%= __("Supplier Invoice No") %}: {%= data[i].bill_no %} <br>{%= __("Supplier Invoice No") %}: {%= data[i].bill_no %}
{% } %} {% } %}
</span>
</td> </td>
<td style="text-align: right"> <td style="text-align: right">
{%= format_currency(data[i].debit, filters.presentation_currency || data[i].account_currency) %}</td> {%= format_currency(data[i].debit, filters.presentation_currency) %}
</td>
<td style="text-align: right"> <td style="text-align: right">
{%= format_currency(data[i].credit, filters.presentation_currency || data[i].account_currency) %}</td> {%= format_currency(data[i].credit, filters.presentation_currency) %}
</td>
{% } else { %} {% } else { %}
<td></td> <td></td>
<td></td> <td></td>

View File

@ -24,11 +24,19 @@ class calculate_taxes_and_totals(object):
def __init__(self, doc: Document): def __init__(self, doc: Document):
self.doc = doc self.doc = doc
frappe.flags.round_off_applicable_accounts = [] 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) get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts)
self.calculate() 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): def calculate(self):
if not len(self.doc.get("items")): if not len(self._items):
return return
self.discount_amount_applied = False self.discount_amount_applied = False
@ -70,7 +78,7 @@ class calculate_taxes_and_totals(object):
if hasattr(self.doc, "tax_withholding_net_total"): if hasattr(self.doc, "tax_withholding_net_total"):
sum_net_amount = 0 sum_net_amount = 0
sum_base_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: if hasattr(item, "apply_tds") and item.apply_tds:
sum_net_amount += item.net_amount sum_net_amount += item.net_amount
sum_base_net_amount += item.base_net_amount sum_base_net_amount += item.base_net_amount
@ -79,7 +87,7 @@ class calculate_taxes_and_totals(object):
self.doc.base_tax_withholding_net_total = sum_base_net_amount self.doc.base_tax_withholding_net_total = sum_base_net_amount
def validate_item_tax_template(self): 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"): if item.item_code and item.get("item_tax_template"):
item_doc = frappe.get_cached_doc("Item", item.item_code) item_doc = frappe.get_cached_doc("Item", item.item_code)
args = { args = {
@ -137,7 +145,7 @@ class calculate_taxes_and_totals(object):
return return
if not self.discount_amount_applied: if not self.discount_amount_applied:
for item in self.doc.get("items"): for item in self._items:
self.doc.round_floats_in(item) self.doc.round_floats_in(item)
if item.discount_percentage == 100: if item.discount_percentage == 100:
@ -236,7 +244,7 @@ class calculate_taxes_and_totals(object):
if not any(cint(tax.included_in_print_rate) for tax in self.doc.get("taxes")): if not any(cint(tax.included_in_print_rate) for tax in self.doc.get("taxes")):
return 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) item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
cumulated_tax_fraction = 0 cumulated_tax_fraction = 0
total_inclusive_tax_amount_per_qty = 0 total_inclusive_tax_amount_per_qty = 0
@ -317,7 +325,7 @@ class calculate_taxes_and_totals(object):
self.doc.total self.doc.total
) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0 ) = 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 += item.amount
self.doc.total_qty += item.qty self.doc.total_qty += item.qty
self.doc.base_total += item.base_amount self.doc.base_total += item.base_amount
@ -354,7 +362,7 @@ class calculate_taxes_and_totals(object):
] ]
) )
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) item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
for i, tax in enumerate(self.doc.get("taxes")): for i, tax in enumerate(self.doc.get("taxes")):
# tax_amount represents the amount of tax for the current step # tax_amount represents the amount of tax for the current step
@ -363,7 +371,7 @@ class calculate_taxes_and_totals(object):
# Adjust divisional loss to the last item # Adjust divisional loss to the last item
if tax.charge_type == "Actual": if tax.charge_type == "Actual":
actual_tax_dict[tax.idx] -= current_tax_amount 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] current_tax_amount += actual_tax_dict[tax.idx]
# accumulate tax amount into tax.tax_amount # accumulate tax amount into tax.tax_amount
@ -391,7 +399,7 @@ class calculate_taxes_and_totals(object):
) )
# set precision in the last item iteration # 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.round_off_totals(tax)
self._set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"]) self._set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"])
@ -570,7 +578,7 @@ class calculate_taxes_and_totals(object):
def calculate_total_net_weight(self): def calculate_total_net_weight(self):
if self.doc.meta.get_field("total_net_weight"): if self.doc.meta.get_field("total_net_weight"):
self.doc.total_net_weight = 0.0 self.doc.total_net_weight = 0.0
for d in self.doc.items: for d in self._items:
if d.total_weight: if d.total_weight:
self.doc.total_net_weight += d.total_weight self.doc.total_net_weight += d.total_weight
@ -630,7 +638,7 @@ class calculate_taxes_and_totals(object):
if total_for_discount_amount: if total_for_discount_amount:
# calculate item amount after 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 = ( distributed_amount = (
flt(self.doc.discount_amount) * item.net_amount / total_for_discount_amount flt(self.doc.discount_amount) * item.net_amount / total_for_discount_amount
) )
@ -643,7 +651,7 @@ class calculate_taxes_and_totals(object):
self.doc.apply_discount_on == "Net Total" self.doc.apply_discount_on == "Net Total"
or not taxes or not taxes
or total_for_discount_amount == self.doc.net_total 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( discount_amount_loss = flt(
self.doc.net_total - net_total - self.doc.discount_amount, self.doc.precision("net_total") self.doc.net_total - net_total - self.doc.discount_amount, self.doc.precision("net_total")
) )

View File

@ -91,6 +91,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
} }
_calculate_taxes_and_totals() { _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.validate_conversion_rate();
this.calculate_item_values(); this.calculate_item_values();
this.initialize_taxes(); this.initialize_taxes();
@ -122,7 +125,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
calculate_item_values() { calculate_item_values() {
var me = this; var me = this;
if (!this.discount_amount_applied) { 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); frappe.model.round_floats_in(item);
item.net_rate = item.rate; item.net_rate = item.rate;
item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty; item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty;
@ -206,7 +209,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}); });
if(has_inclusive_tax==false) return; 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 item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
var cumulated_tax_fraction = 0.0; var cumulated_tax_fraction = 0.0;
var total_inclusive_tax_amount_per_qty = 0; var total_inclusive_tax_amount_per_qty = 0;
@ -277,7 +280,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
var me = this; 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; 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 += item.amount;
me.frm.doc.total_qty += item.qty; me.frm.doc.total_qty += item.qty;
me.frm.doc.base_total += item.base_amount; me.frm.doc.base_total += item.base_amount;
@ -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); var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
$.each(me.frm.doc["taxes"] || [], function(i, tax) { $.each(me.frm.doc["taxes"] || [], function(i, tax) {
// tax_amount represents the amount of tax for the current step // tax_amount represents the amount of tax for the current step
@ -339,7 +342,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
// Adjust divisional loss to the last item // Adjust divisional loss to the last item
if (tax.charge_type == "Actual") { if (tax.charge_type == "Actual") {
actual_tax_dict[tax.idx] -= current_tax_amount; 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]; current_tax_amount += actual_tax_dict[tax.idx];
} }
} }
@ -376,7 +379,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
} }
// set precision in the last item iteration // 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.round_off_totals(tax);
me.set_in_company_currency(tax, me.set_in_company_currency(tax,
["tax_amount", "tax_amount_after_discount_amount"]); ["tax_amount", "tax_amount_after_discount_amount"]);
@ -599,10 +602,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
_cleanup() { _cleanup() {
this.frm.doc.base_in_words = this.frm.doc.in_words = ""; 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(items && items.length) {
if(!frappe.meta.get_docfield(this.frm.doc["items"][0].doctype, "item_tax_amount", this.frm.doctype)) { if(!frappe.meta.get_docfield(items[0].doctype, "item_tax_amount", this.frm.doctype)) {
$.each(this.frm.doc["items"] || [], function(i, item) { $.each(items || [], function(i, item) {
delete item["item_tax_amount"]; delete item["item_tax_amount"];
}); });
} }
@ -655,7 +659,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
var net_total = 0; var net_total = 0;
// calculate item amount after Discount Amount // calculate item amount after Discount Amount
if (total_for_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; distributed_amount = flt(me.frm.doc.discount_amount) * item.net_amount / total_for_discount_amount;
item.net_amount = flt(item.net_amount - distributed_amount, item.net_amount = flt(item.net_amount - distributed_amount,
precision("base_amount", item)); precision("base_amount", item));
@ -663,7 +667,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
// discount amount rounding loss adjustment if no taxes // 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")) 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 var discount_amount_loss = flt(me.frm.doc.net_total - net_total
- me.frm.doc.discount_amount, precision("net_total")); - me.frm.doc.discount_amount, precision("net_total"));
item.net_amount = flt(item.net_amount + discount_amount_loss, item.net_amount = flt(item.net_amount + discount_amount_loss,
@ -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"]);
}
}; };

View File

@ -90,7 +90,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
|| frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) { || frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) {
this.frm.add_custom_button( this.frm.add_custom_button(
__("Sales Order"), __("Sales Order"),
this.frm.cscript["Make Sales Order"], () => this.make_sales_order(),
__("Create") __("Create")
); );
} }
@ -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(){ set_dynamic_field_label(){
if (this.frm.doc.quotation_to == "Customer") if (this.frm.doc.quotation_to == "Customer")
{ {
@ -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.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) { 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 // enable tax_amount field if Actual
}) })

View File

@ -35,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"))
@ -59,7 +62,18 @@ 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"
ordered_items = frappe._dict( ordered_items = frappe._dict(
frappe.db.get_all( frappe.db.get_all(
"Sales Order Item", "Sales Order Item",
@ -70,16 +84,40 @@ class Quotation(SellingController):
) )
) )
status = "Open" if not ordered_items:
if 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" status = "Ordered"
for item in self.get("items"):
if item.qty > ordered_items.get(item.item_code, 0.0):
status = "Partially Ordered"
return status 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): def is_fully_ordered(self):
return self.get_ordered_status() == "Ordered" return self.get_ordered_status() == "Ordered"
@ -176,6 +214,22 @@ 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_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): def get_list_context(context=None):
from erpnext.controllers.website_list_for_contact import get_list_context from erpnext.controllers.website_list_for_contact import get_list_context
@ -221,6 +275,8 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
) )
) )
selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]
def set_missing_values(source, target): def set_missing_values(source, target):
if customer: if customer:
target.customer = customer.name target.customer = customer.name
@ -244,6 +300,24 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
target.blanket_order = obj.blanket_order target.blanket_order = obj.blanket_order
target.blanket_order_rate = obj.blanket_order_rate target.blanket_order_rate = obj.blanket_order_rate
def can_map_row(item) -> bool:
"""
Row mapping from Quotation to Sales order:
1. If no selections, map all non-alternative rows (that sum up to the grand total)
2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
3. If selections: Simple row: Map if adequate qty
"""
has_qty = item.qty > 0
if not selected_rows:
return not item.is_alternative
if selected_rows and (item.is_alternative or item.has_alternative_item):
return (item.name in selected_rows) and has_qty
# Simple row
return has_qty
doclist = get_mapped_doc( doclist = get_mapped_doc(
"Quotation", "Quotation",
source_name, source_name,
@ -253,7 +327,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
"doctype": "Sales Order Item", "doctype": "Sales Order Item",
"field_map": {"parent": "prevdoc_docname", "name": "quotation_item"}, "field_map": {"parent": "prevdoc_docname", "name": "quotation_item"},
"postprocess": update_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 Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
@ -322,7 +396,11 @@ def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
source_name, source_name,
{ {
"Quotation": {"doctype": "Sales Invoice", "validation": {"docstatus": ["=", 1]}}, "Quotation": {"doctype": "Sales Invoice", "validation": {"docstatus": ["=", 1]}},
"Quotation Item": {"doctype": "Sales Invoice Item", "postprocess": update_item}, "Quotation Item": {
"doctype": "Sales Invoice Item",
"postprocess": update_item,
"condition": lambda row: not row.is_alternative,
},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True}, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
}, },

View File

@ -457,6 +457,139 @@ class TestQuotation(FrappeTestCase):
expected_index = id + 1 expected_index = id + 1
self.assertEqual(item.idx, expected_index) self.assertEqual(item.idx, expected_index)
def test_alternative_items_with_stock_items(self):
"""
Check if taxes & totals considers only non-alternative items with:
- One set of non-alternative & alternative items [first 3 rows]
- One simple stock item
"""
from erpnext.stock.doctype.item.test_item import make_item
item_list = []
stock_items = {
"_Test Simple Item 1": 100,
"_Test Alt 1": 120,
"_Test Alt 2": 110,
"_Test Simple Item 2": 200,
}
for item, rate in stock_items.items():
make_item(item, {"is_stock_item": 1})
item_list.append(
{
"item_code": item,
"qty": 1,
"rate": rate,
"is_alternative": bool("Alt" in item),
}
)
quotation = make_quotation(item_list=item_list, do_not_submit=1)
quotation.append(
"taxes",
{
"account_head": "_Test Account VAT - _TC",
"charge_type": "On Net Total",
"cost_center": "_Test Cost Center - _TC",
"description": "VAT",
"doctype": "Sales Taxes and Charges",
"rate": 10,
},
)
quotation.submit()
self.assertEqual(quotation.net_total, 300)
self.assertEqual(quotation.grand_total, 330)
def test_alternative_items_with_service_items(self):
"""
Check if taxes & totals considers only non-alternative items with:
- One set of non-alternative & alternative service items [first 3 rows]
- One simple non-alternative service item
All having the same item code and unique item name/description due to
dynamic services
"""
from erpnext.stock.doctype.item.test_item import make_item
item_list = []
service_items = {
"Tiling with Standard Tiles": 100,
"Alt Tiling with Durable Tiles": 150,
"Alt Tiling with Premium Tiles": 180,
"False Ceiling with Material #234": 190,
}
make_item("_Test Dynamic Service Item", {"is_stock_item": 0})
for name, rate in service_items.items():
item_list.append(
{
"item_code": "_Test Dynamic Service Item",
"item_name": name,
"description": name,
"qty": 1,
"rate": rate,
"is_alternative": bool("Alt" in name),
}
)
quotation = make_quotation(item_list=item_list, do_not_submit=1)
quotation.append(
"taxes",
{
"account_head": "_Test Account VAT - _TC",
"charge_type": "On Net Total",
"cost_center": "_Test Cost Center - _TC",
"description": "VAT",
"doctype": "Sales Taxes and Charges",
"rate": 10,
},
)
quotation.submit()
self.assertEqual(quotation.net_total, 290)
self.assertEqual(quotation.grand_total, 319)
def test_alternative_items_sales_order_mapping_with_stock_items(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
frappe.flags.args = frappe._dict()
item_list = []
stock_items = {
"_Test Simple Item 1": 100,
"_Test Alt 1": 120,
"_Test Alt 2": 110,
"_Test Simple Item 2": 200,
}
for item, rate in stock_items.items():
make_item(item, {"is_stock_item": 1})
item_list.append(
{
"item_code": item,
"qty": 1,
"rate": rate,
"is_alternative": bool("Alt" in item),
"warehouse": "_Test Warehouse - _TC",
}
)
quotation = make_quotation(item_list=item_list)
frappe.flags.args.selected_items = [quotation.items[2]]
sales_order = make_sales_order(quotation.name)
sales_order.delivery_date = add_days(sales_order.transaction_date, 10)
sales_order.save()
self.assertEqual(sales_order.items[0].item_code, "_Test Alt 2")
self.assertEqual(sales_order.items[1].item_code, "_Test Simple Item 2")
self.assertEqual(sales_order.net_total, 310)
sales_order.submit()
quotation.reload()
self.assertEqual(quotation.status, "Ordered")
test_records = frappe.get_test_records("Quotation") test_records = frappe.get_test_records("Quotation")

View File

@ -49,6 +49,8 @@
"pricing_rules", "pricing_rules",
"stock_uom_rate", "stock_uom_rate",
"is_free_item", "is_free_item",
"is_alternative",
"has_alternative_item",
"section_break_43", "section_break_43",
"valuation_rate", "valuation_rate",
"column_break_45", "column_break_45",
@ -643,12 +645,28 @@
"no_copy": 1, "no_copy": 1,
"options": "currency", "options": "currency",
"read_only": 1 "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, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-12-25 02:49:53.926625", "modified": "2023-02-06 11:00:07.042364",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Quotation Item", "name": "Quotation Item",
@ -656,5 +674,6 @@
"permissions": [], "permissions": [],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -275,7 +275,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
if (this.frm.doc.docstatus===0) { if (this.frm.doc.docstatus===0) {
this.frm.add_custom_button(__('Quotation'), this.frm.add_custom_button(__('Quotation'),
function() { function() {
erpnext.utils.map_current_doc({ let d = erpnext.utils.map_current_doc({
method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
source_doctype: "Quotation", source_doctype: "Quotation",
target: me.frm, target: me.frm,
@ -293,7 +293,16 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
docstatus: 1, docstatus: 1,
status: ["!=", "Lost"] status: ["!=", "Lost"]
} }
}) });
setTimeout(() => {
d.$parent.append(`
<span class='small text-muted'>
${__("Note: Please create Sales Orders from individual Quotations to select from among Alternative Items.")}
</span>
`);
}, 200);
}, __("Get Items From")); }, __("Get Items From"));
} }

View File

@ -191,14 +191,17 @@ class SubcontractingReceipt(SubcontractingController):
def validate_available_qty_for_consumption(self): def validate_available_qty_for_consumption(self):
for item in self.get("supplied_items"): for item in self.get("supplied_items"):
precision = item.precision("consumed_qty")
if ( if (
item.available_qty_for_consumption and item.available_qty_for_consumption < item.consumed_qty item.available_qty_for_consumption
and flt(item.available_qty_for_consumption, precision) - flt(item.consumed_qty, precision) < 0
): ):
frappe.throw( msg = f"""Row {item.idx}: Consumed Qty {flt(item.consumed_qty, precision)}
_( must be less than or equal to Available Qty For Consumption
"Row {0}: Consumed Qty must be less than or equal to Available Qty For Consumption in Consumed Items Table." {flt(item.available_qty_for_consumption, precision)}
).format(item.idx) in Consumed Items Table."""
)
frappe.throw(_(msg))
def validate_items_qty(self): def validate_items_qty(self):
for item in self.items: for item in self.items: