diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py index 8cbb99e9252d..160e791978e8 100644 --- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py +++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py @@ -109,7 +109,7 @@ def get_api_endpoint(service_provider: str | None = None, use_http: bool = False if service_provider == "exchangerate.host": api = "api.exchangerate.host/convert" elif service_provider == "frankfurter.app": - api = "frankfurter.app/{transaction_date}" + api = "api.frankfurter.app/{transaction_date}" protocol = "https://" if use_http: diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index ad58761e1bfd..d63db3a09a11 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -210,19 +210,31 @@ def get_linked_dunnings_as_per_state(sales_invoice, state): @frappe.whitelist() -def get_dunning_letter_text(dunning_type, doc, language=None): +def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str | None = None) -> dict: + DOCTYPE = "Dunning Letter Text" + FIELDS = ["body_text", "closing_text", "language"] + if isinstance(doc, str): doc = json.loads(doc) + + if not language: + language = doc.get("language") + if language: - filters = {"parent": dunning_type, "language": language} - else: - filters = {"parent": dunning_type, "is_default_language": 1} - letter_text = frappe.db.get_value( - "Dunning Letter Text", filters, ["body_text", "closing_text", "language"], as_dict=1 - ) - if letter_text: - return { - "body_text": frappe.render_template(letter_text.body_text, doc), - "closing_text": frappe.render_template(letter_text.closing_text, doc), - "language": letter_text.language, - } + letter_text = frappe.db.get_value( + DOCTYPE, {"parent": dunning_type, "language": language}, FIELDS, as_dict=1 + ) + + if not letter_text: + letter_text = frappe.db.get_value( + DOCTYPE, {"parent": dunning_type, "is_default_language": 1}, FIELDS, as_dict=1 + ) + + if not letter_text: + return {} + + return { + "body_text": frappe.render_template(letter_text.body_text, doc), + "closing_text": frappe.render_template(letter_text.closing_text, doc), + "language": letter_text.language, + } diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 31143fb72b85..cf5bfedaebdd 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -2292,6 +2292,24 @@ def test_adjust_incoming_rate_from_pi_with_multi_currency(self): frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + def test_last_purchase_rate(self): + item = create_item("_Test Item For Last Purchase Rate from PI", is_stock_item=1) + pi1 = make_purchase_invoice(item_code=item.item_code, qty=10, rate=100) + item.reload() + self.assertEqual(item.last_purchase_rate, 100) + + pi2 = make_purchase_invoice(item_code=item.item_code, qty=10, rate=200) + item.reload() + self.assertEqual(item.last_purchase_rate, 200) + + pi2.cancel() + item.reload() + self.assertEqual(item.last_purchase_rate, 100) + + pi1.cancel() + item.reload() + self.assertEqual(item.last_purchase_rate, 0) + def set_advance_flag(company, flag, default_account): frappe.db.set_value( diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 44a9813c89eb..77f548180404 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3309,7 +3309,6 @@ def validate_fg_item_for_subcontracting(new_data, is_new): items_added_or_removed = False # updated to true if any new item is added or removed any_conversion_factor_changed = False - sales_doctypes = ["Sales Order", "Sales Invoice", "Delivery Note", "Quotation"] parent = frappe.get_doc(parent_doctype, parent_doctype_name) check_doc_permissions(parent, "write") @@ -3425,25 +3424,21 @@ def validate_fg_item_for_subcontracting(new_data, is_new): # if rate is greater than price_list_rate, set margin # or set discount child_item.discount_percentage = 0 - - if parent_doctype in sales_doctypes: - child_item.margin_type = "Amount" - child_item.margin_rate_or_amount = flt( - child_item.rate - child_item.price_list_rate, - child_item.precision("margin_rate_or_amount"), - ) - child_item.rate_with_margin = child_item.rate + child_item.margin_type = "Amount" + child_item.margin_rate_or_amount = flt( + child_item.rate - child_item.price_list_rate, + child_item.precision("margin_rate_or_amount"), + ) + child_item.rate_with_margin = child_item.rate else: child_item.discount_percentage = flt( (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, child_item.precision("discount_percentage"), ) child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) - - if parent_doctype in sales_doctypes: - child_item.margin_type = "" - child_item.margin_rate_or_amount = 0 - child_item.rate_with_margin = 0 + child_item.margin_type = "" + child_item.margin_rate_or_amount = 0 + child_item.rate_with_margin = 0 child_item.flags.ignore_validate_update_after_submit = True if new_child_flag: diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 31b6f391ba40..e9e7ef626702 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -702,9 +702,11 @@ def on_cancel(self): if self.get("is_return"): return - if self.doctype in ["Purchase Order", "Purchase Receipt"] and not frappe.db.get_single_value( - "Buying Settings", "disable_last_purchase_rate" - ): + if self.doctype in [ + "Purchase Order", + "Purchase Receipt", + "Purchase Invoice", + ] and not frappe.db.get_single_value("Buying Settings", "disable_last_purchase_rate"): update_last_purchase_rate(self, is_submit=0) if self.doctype in ["Purchase Receipt", "Purchase Invoice"]: diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 1620fa3bfffa..9fb0cc88cfb6 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -64,6 +64,18 @@ def validate(self): self.validate_internal_transfer() self.validate_putaway_capacity() + def validate_items_exist(self): + if not self.get("items"): + return + + items = [d.item_code for d in self.get("items")] + + exists_items = frappe.get_all("Item", filters={"name": ("in", items)}, pluck="name") + non_exists_items = set(items) - set(exists_items) + + if non_exists_items: + frappe.throw(_("Items {0} do not exist in the Item master.").format(", ".join(non_exists_items))) + def validate_duplicate_serial_and_batch_bundle(self, table_name): if not self.get(table_name): return diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 2d3b224b76f5..fc0ba56d1f4f 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -40,7 +40,7 @@ def filter_rows(self): return items def calculate(self): - if not len(self._items): + if not len(self.doc.items): return self.discount_amount_applied = False @@ -95,7 +95,7 @@ def validate_item_tax_template(self): if self.doc.get("is_return") and self.doc.get("return_against"): return - for item in self._items: + for item in self.doc.items: if item.item_code and item.get("item_tax_template"): item_doc = frappe.get_cached_doc("Item", item.item_code) args = { @@ -154,7 +154,7 @@ def calculate_item_values(self): return if not self.discount_amount_applied: - for item in self._items: + for item in self.doc.items: self.doc.round_floats_in(item) if item.discount_percentage == 100: @@ -258,7 +258,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._items: + for item in self.doc.items: item_tax_map = self._load_item_tax_rate(item.item_tax_rate) cumulated_tax_fraction = 0 total_inclusive_tax_amount_per_qty = 0 diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index ebc400979b64..6ee3f73eb084 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1531,6 +1531,197 @@ def test_backflushed_serial_no_batch_raw_materials_based_on_transferred(self): self.assertFalse(serial_nos) + def test_backflushed_batch_raw_materials_based_on_transferred_autosabb(self): + frappe.db.set_single_value( + "Manufacturing Settings", + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + + batch_item = "Test Batch MCC Keyboard" + fg_item = "Test FG Item with Batch Raw Materials" + + ste_doc = test_stock_entry.make_stock_entry( + item_code=batch_item, target="Stores - _TC", qty=8, basic_rate=100, do_not_save=True + ) + + # Inward raw materials in Stores warehouse + ste_doc.submit() + ste_doc.reload() + + batch_no = get_batch_from_bundle(ste_doc.items[0].serial_and_batch_bundle) + + wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) + # action taken upon Start button: + transferred_ste_doc = frappe.get_doc( + make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) + ) + + transferred_ste_doc.submit() + transferred_ste_doc.reload() + + self.assertTrue(transferred_ste_doc.items[0].serial_and_batch_bundle) + self.assertEqual( + get_batch_from_bundle(transferred_ste_doc.items[0].serial_and_batch_bundle), batch_no + ) + self.assertEqual(transferred_ste_doc.items[0].qty, 4.0) + + # Make additional consumption and link to WO + test_stock_entry.make_stock_entry( + item_code="Test Batch Battery Consumable", + target="Stores - _TC", + qty=8, + basic_rate=2.33, + ) + consume_use_doc = test_stock_entry.make_stock_entry( + item_code="Test Batch Battery Consumable", # consumable not linked to BOM + source="Stores - _TC", + qty=4, + purpose="Material Consumption for Manufacture", + do_not_save=True, + ) + consume_use_doc.work_order = wo_doc.name + consume_use_doc.fg_completed_qty = 4 + consume_use_doc.submit() + consume_use_doc.reload() + + manufacture_ste_doc = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 4)) + mfr_items = [i.as_dict() for i in manufacture_ste_doc.items] + manufacture_ste_doc.submit() + manufacture_ste_doc.reload() + + self.assertTrue(len(mfr_items), 2) + self.assertTrue(manufacture_ste_doc.items[0].serial_and_batch_bundle) + self.assertEqual( + get_batch_from_bundle(manufacture_ste_doc.items[0].serial_and_batch_bundle), batch_no + ) + self.assertEqual(manufacture_ste_doc.items[0].qty, 4.0) + + def test_backflushed_serial_no_raw_materials_based_on_transferred_autosabb(self): + frappe.db.set_single_value( + "Manufacturing Settings", + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + + sn_item = "Test Serial No BTT Headphone" + fg_item = "Test FG Item with Serial No Raw Materials" + + ste_doc = test_stock_entry.make_stock_entry( + item_code=sn_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True + ) + + # Inward raw materials in Stores warehouse + ste_doc.submit() + ste_doc.reload() + + serial_nos_list = sorted(get_serial_nos_from_bundle(ste_doc.items[0].serial_and_batch_bundle)) + + wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) + transferred_ste_doc = frappe.get_doc( + make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) + ) + + transferred_ste_doc.submit() + transferred_ste_doc.reload() + + self.assertTrue(transferred_ste_doc.items[0].serial_and_batch_bundle) + self.assertEqual( + sorted(get_serial_nos_from_bundle(transferred_ste_doc.items[0].serial_and_batch_bundle)), + serial_nos_list, + ) + self.assertEqual(transferred_ste_doc.items[0].qty, 4.0) + + # Make additional consumption and link to WO + test_stock_entry.make_stock_entry( + item_code="Test Serial Battery Consumable", + target="Stores - _TC", + qty=8, + basic_rate=3.33, + ) + consume_use_doc = test_stock_entry.make_stock_entry( + item_code="Test Serial Battery Consumable", # consumable not linked to BOM + source="Stores - _TC", + qty=4, + purpose="Material Consumption for Manufacture", + do_not_save=True, + ) + consume_use_doc.work_order = wo_doc.name + consume_use_doc.fg_completed_qty = 4 + consume_use_doc.submit() + consume_use_doc.reload() + + manufacture_ste_doc = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 4)) + mfr_items = [i.as_dict() for i in manufacture_ste_doc.items] + manufacture_ste_doc.submit() + manufacture_ste_doc.reload() + + self.assertTrue(len(mfr_items), 2) + self.assertTrue(manufacture_ste_doc.items[0].serial_and_batch_bundle) + self.assertEqual( + sorted(get_serial_nos_from_bundle(manufacture_ste_doc.items[0].serial_and_batch_bundle)), + serial_nos_list, + ) + self.assertEqual(manufacture_ste_doc.items[0].qty, 4.0) + + def test_backflushed_serial_no_batch_raw_materials_based_on_transferred_autosabb(self): + frappe.db.set_single_value( + "Manufacturing Settings", + "backflush_raw_materials_based_on", + "Material Transferred for Manufacture", + ) + + sn_batch_item = "Test Batch Serial No WebCam" + fg_item = "Test FG Item with Serial & Batch No Raw Materials" + + ste_doc = test_stock_entry.make_stock_entry( + item_code=sn_batch_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True + ) + + ste_doc.submit() + ste_doc.reload() + + serial_nos_list = sorted(get_serial_nos_from_bundle(ste_doc.items[0].serial_and_batch_bundle)) + batch_no = get_batch_from_bundle(ste_doc.items[0].serial_and_batch_bundle) + + wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) + transferred_ste_doc = frappe.get_doc( + make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) + ) + + transferred_ste_doc.submit() + transferred_ste_doc.reload() + + self.assertTrue(transferred_ste_doc.items[0].serial_and_batch_bundle) + self.assertEqual( + sorted(get_serial_nos_from_bundle(transferred_ste_doc.items[0].serial_and_batch_bundle)), + serial_nos_list, + ) + self.assertEqual( + get_batch_from_bundle(transferred_ste_doc.items[0].serial_and_batch_bundle), batch_no + ) + self.assertEqual(transferred_ste_doc.items[0].qty, 4.0) + + manufacture_ste_doc = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 4)) + manufacture_ste_doc.submit() + manufacture_ste_doc.reload() + + self.assertTrue(manufacture_ste_doc.items[0].serial_and_batch_bundle) + self.assertEqual( + sorted(get_serial_nos_from_bundle(manufacture_ste_doc.items[0].serial_and_batch_bundle)), + serial_nos_list, + ) + self.assertEqual( + get_batch_from_bundle(manufacture_ste_doc.items[0].serial_and_batch_bundle), batch_no + ) + self.assertEqual(manufacture_ste_doc.items[0].qty, 4.0) + + bundle = manufacture_ste_doc.items[0].serial_and_batch_bundle + bundle_doc = frappe.get_doc("Serial and Batch Bundle", bundle) + qty = sum(e.qty for e in bundle_doc.entries) + self.assertEqual(qty, -4.0) + + ### def test_non_consumed_material_return_against_work_order(self): frappe.db.set_single_value( "Manufacturing Settings", @@ -2335,6 +2526,29 @@ def prepare_data_for_backflush_based_on_materials_transferred(): make_bom(item=item.name, source_warehouse="Stores - _TC", raw_materials=[batch_item_doc.name]) + # Make additional items not attached to a BOM + make_item( + "Test Batch Battery Consumable", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBMK.#####", + "valuation_rate": 2.33, + "stock_uom": "Nos", + }, + ) + make_item( + "Test Serial Battery Consumable", + { + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "TSBH.#####", + "valuation_rate": 3.33, + "stock_uom": "Nos", + }, + ) + sn_item_doc = make_item( "Test Serial No BTT Headphone", { diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5b1455ef723f..333fca1d1da5 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -377,3 +377,4 @@ erpnext.patches.v15_0.drop_index_posting_datetime_from_sle erpnext.patches.v15_0.add_disassembly_order_stock_entry_type #1 erpnext.patches.v15_0.set_standard_stock_entry_type erpnext.patches.v15_0.link_purchase_item_to_asset_doc +erpnext.patches.v14_0.update_currency_exchange_settings_for_frankfurter diff --git a/erpnext/patches/v14_0/update_currency_exchange_settings_for_frankfurter.py b/erpnext/patches/v14_0/update_currency_exchange_settings_for_frankfurter.py new file mode 100644 index 000000000000..a67c5a262377 --- /dev/null +++ b/erpnext/patches/v14_0/update_currency_exchange_settings_for_frankfurter.py @@ -0,0 +1,11 @@ +import frappe + + +def execute(): + settings = frappe.get_doc("Currency Exchange Settings") + if settings.service_provider != "frankfurter.app": + return + + settings.set_parameters_and_result() + settings.flags.ignore_validate = True + settings.save() diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index b5a8b7577060..1d0680de861e 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -342,12 +342,15 @@ erpnext.buying = { add_serial_batch_bundle(doc, cdt, cdn) { let item = locals[cdt][cdn]; let me = this; + let fields = ["has_batch_no", "has_serial_no"]; - frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"]) + frappe.db.get_value("Item", item.item_code, fields) .then((r) => { if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) { - item.has_serial_no = r.message.has_serial_no; - item.has_batch_no = r.message.has_batch_no; + fields.forEach((field) => { + item[field] = r.message[field]; + }); + item.type_of_transaction = item.qty > 0 ? "Inward" : "Outward"; item.is_rejected = false; @@ -380,12 +383,15 @@ erpnext.buying = { add_serial_batch_for_rejected_qty(doc, cdt, cdn) { let item = locals[cdt][cdn]; let me = this; + let fields = ["has_batch_no", "has_serial_no"]; - frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"]) + frappe.db.get_value("Item", item.item_code, fields) .then((r) => { if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) { - item.has_serial_no = r.message.has_serial_no; - item.has_batch_no = r.message.has_batch_no; + fields.forEach((field) => { + item[field] = r.message[field]; + }); + item.type_of_transaction = item.rejected_qty > 0 ? "Inward" : "Outward"; item.is_rejected = true; diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index adf05ffb154d..dc7a5a8eef1c 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -128,7 +128,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._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; @@ -227,7 +227,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { }); if(has_inclusive_tax==false) return; - $.each(me.frm._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 cumulated_tax_fraction = 0.0; var total_inclusive_tax_amount_per_qty = 0; @@ -630,7 +630,7 @@ 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._items; + let items = this.frm.doc.items; if(items && items.length) { if(!frappe.meta.get_docfield(items[0].doctype, "item_tax_amount", this.frm.doctype)) { diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 67c61118952b..92e9e559ada8 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -573,6 +573,15 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe callback: function(r) { if(!r.exc) { frappe.run_serially([ + () => { + if (item.docstatus === 0 + && frappe.meta.has_field(item.doctype, "use_serial_batch_fields") + && !item.use_serial_batch_fields + && cint(frappe.user_defaults?.use_serial_batch_fields) === 1 + ) { + item["use_serial_batch_fields"] = 1; + } + }, () => { var d = locals[cdt][cdn]; me.add_taxes_from_item_tax_template(d.item_tax_rate); @@ -1102,7 +1111,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe apply_discount_on_item(doc, cdt, cdn, field) { var item = frappe.get_doc(cdt, cdn); - if(!item.price_list_rate) { + if(!item?.price_list_rate) { item[field] = 0.0; } else { this.price_list_rate(doc, cdt, cdn); @@ -1268,6 +1277,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe .filter(Boolean).length > 0; } else if (this.frm.doc?.items) { let first_row = this.frm.doc.items[0]; + if (!first_row) { + return false + }; + let mapped_rows = mappped_fields.filter(d => first_row[d]) return mapped_rows?.length > 0; diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 2ddfc24adb1c..46ff6366de8a 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -16,7 +16,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { let label = this.item?.has_serial_no ? __("Serial Nos") : __("Batch Nos"); let primary_label = this.bundle ? __("Update") : __("Add"); - if (this.item?.has_serial_no && this.item?.batch_no) { + if (this.item?.has_serial_no && this.item?.has_batch_no) { label = __("Serial Nos / Batch Nos"); } @@ -24,6 +24,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { this.dialog = new frappe.ui.Dialog({ title: this.item?.title || primary_label, + size: "large", fields: this.get_dialog_fields(), primary_action_label: primary_label, primary_action: () => this.update_bundle_entries(), @@ -164,12 +165,14 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { fields.push({ fieldtype: "Section Break", + depends_on: "eval:doc.enter_manually !== 1 || doc.entries?.length > 0", }); fields.push({ fieldname: "entries", fieldtype: "Table", allow_bulk_edit: true, + depends_on: "eval:doc.enter_manually !== 1 || doc.entries?.length > 0", data: [], fields: this.get_dialog_table_fields(), }); @@ -178,6 +181,7 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { } get_attach_field() { + let me = this; let label = this.item?.has_serial_no ? __("Serial Nos") : __("Batch Nos"); let primary_label = this.bundle ? __("Update") : __("Add"); @@ -185,86 +189,103 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { label = __("Serial Nos / Batch Nos"); } - let fields = [ + let fields = []; + if (this.item.has_serial_no) { + fields.push({ + fieldtype: "Check", + label: __("Enter Manually"), + fieldname: "enter_manually", + default: 1, + depends_on: "eval:doc.import_using_csv_file !== 1", + change() { + if (me.dialog.get_value("enter_manually")) { + me.dialog.set_value("import_using_csv_file", 0); + } + }, + }); + } + + fields = [ + ...fields, + { + fieldtype: "Check", + label: __("Import Using CSV file"), + fieldname: "import_using_csv_file", + depends_on: "eval:doc.enter_manually !== 1", + default: !this.item.has_serial_no ? 1 : 0, + change() { + if (me.dialog.get_value("import_using_csv_file")) { + me.dialog.set_value("enter_manually", 0); + } + }, + }, { fieldtype: "Section Break", + depends_on: "eval:doc.import_using_csv_file === 1", label: __("{0} {1} via CSV File", [primary_label, label]), }, + { + fieldtype: "Button", + fieldname: "download_csv", + label: __("Download CSV Template"), + click: () => this.download_csv_file(), + }, + { + fieldtype: "Column Break", + }, + { + fieldtype: "Attach", + fieldname: "attach_serial_batch_csv", + label: __("Attach CSV File"), + onchange: () => this.upload_csv_file(), + }, ]; if (this.item?.has_serial_no) { fields = [ ...fields, - { - fieldtype: "Check", - label: __("Import Using CSV file"), - fieldname: "import_using_csv_file", - default: 0, - }, { fieldtype: "Section Break", label: __("{0} {1} Manually", [primary_label, label]), - depends_on: "eval:doc.import_using_csv_file === 0", + depends_on: "eval:doc.enter_manually === 1", }, { fieldtype: "Data", - label: __("Enter Serial No Range"), + label: __("Serial No Range"), fieldname: "serial_no_range", - depends_on: "eval:doc.import_using_csv_file === 0", - description: __('Enter "ABC-001::100" for serial nos "ABC-001" to "ABC-100".'), + depends_on: "eval:doc.enter_manually === 1 && !doc.serial_no_series", + description: __('"SN-01::10" for "SN-01" to "SN-10"'), onchange: () => { this.set_serial_nos_from_range(); }, }, + ]; + } + + if (this.item?.has_serial_no) { + fields = [ + ...fields, + { + fieldtype: "Column Break", + depends_on: "eval:doc.enter_manually === 1", + }, { fieldtype: "Small Text", label: __("Enter Serial Nos"), fieldname: "upload_serial_nos", - depends_on: "eval:doc.import_using_csv_file === 0", + depends_on: "eval:doc.enter_manually === 1", description: __("Enter each serial no in a new line"), }, - { - fieldtype: "Column Break", - depends_on: "eval:doc.import_using_csv_file === 0", - }, - { - fieldtype: "Button", - fieldname: "make_serial_nos", - label: __("Create Serial Nos"), - depends_on: "eval:doc.import_using_csv_file === 0", - click: () => { - this.create_serial_nos(); - }, - }, - { - fieldtype: "Section Break", - depends_on: "eval:doc.import_using_csv_file === 1", - }, ]; } - fields = [ - ...fields, - { - fieldtype: "Button", - fieldname: "download_csv", - label: __("Download CSV Template"), - click: () => this.download_csv_file(), - }, - { - fieldtype: "Column Break", - }, - { - fieldtype: "Attach", - fieldname: "attach_serial_batch_csv", - label: __("Attach CSV File"), - onchange: () => this.upload_csv_file(), - }, - ]; - return fields; } + set_serial_nos_from_series() {} + + set_batch_nos_from_series() {} + set_serial_nos_from_range() { const serial_no_range = this.dialog.get_value("serial_no_range"); @@ -511,6 +532,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { scan_barcode_data() { const { scan_serial_no, scan_batch_no } = this.dialog.get_values(); + this.dialog.set_value("enter_manually", 0); + if (scan_serial_no || scan_batch_no) { frappe.call({ method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.is_serial_batch_no_exists", @@ -554,14 +577,13 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { serial_no: scan_serial_no, }, callback: (r) => { - if (r.message) { - this.dialog.fields_dict.entries.df.data.push({ - serial_no: scan_serial_no, - batch_no: r.message, - }); + this.dialog.fields_dict.entries.df.data.push({ + serial_no: scan_serial_no, + batch_no: r.message, + }); - this.dialog.fields_dict.scan_serial_no.set_value(""); - } + this.dialog.fields_dict.scan_serial_no.set_value(""); + this.dialog.fields_dict.entries.grid.refresh(); }, }); } @@ -590,6 +612,12 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { update_bundle_entries() { let entries = this.dialog.get_values().entries; let warehouse = this.dialog.get_value("warehouse"); + let upload_serial_nos = this.dialog.get_value("upload_serial_nos"); + + if (!entries?.length && upload_serial_nos) { + this.create_serial_nos(); + return; + } if ((entries && !entries.length) || !entries) { frappe.throw(__("Please add atleast one Serial No / Batch No")); @@ -610,9 +638,13 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { }, }) .then((r) => { - this.callback && this.callback(r.message); - this.frm.save(); - this.dialog.hide(); + frappe.run_serially([ + () => { + this.callback && this.callback(r.message); + }, + () => this.frm.save(), + () => this.dialog.hide(), + ]); }); } diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 7b66b7a251ad..9a31e335a05f 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -561,12 +561,50 @@ def test_alternative_items_with_service_items(self): "description": "VAT", "doctype": "Sales Taxes and Charges", "rate": 10, + "included_in_print_rate": 1, }, ) quotation.submit() - self.assertEqual(quotation.net_total, 290) - self.assertEqual(quotation.grand_total, 319) + self.assertEqual(round(quotation.items[1].net_rate, 2), 136.36) + self.assertEqual(round(quotation.items[1].amount, 2), 150) + + self.assertEqual(round(quotation.items[2].net_rate, 2), 163.64) + self.assertEqual(round(quotation.items[2].amount, 2), 180) + + self.assertEqual(round(quotation.net_total, 2), 263.64) + self.assertEqual(round(quotation.total_taxes_and_charges, 2), 26.36) + self.assertEqual(quotation.grand_total, 290) + + def test_amount_calculation_for_alternative_items(self): + """Make sure that the amount is calculated correctly for alternative items when the qty is changed.""" + from erpnext.stock.doctype.item.test_item import make_item + + item_list = [] + stock_items = { + "_Test Simple Item 1": 100, + "_Test Alt 1": 120, + } + + for item, rate in stock_items.items(): + make_item(item, {"is_stock_item": 0}) + item_list.append( + { + "item_code": item, + "qty": 1, + "rate": rate, + "is_alternative": "Alt" in item, + } + ) + + quotation = make_quotation(item_list=item_list, do_not_submit=1) + + self.assertEqual(quotation.items[1].amount, 120) + + quotation.items[1].qty = 2 + quotation.save() + + self.assertEqual(quotation.items[1].amount, 240) def test_alternative_items_sales_order_mapping_with_stock_items(self): from erpnext.selling.doctype.quotation.quotation import make_sales_order diff --git a/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py b/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py index 3249c93ef87c..d28b1e65b7bc 100644 --- a/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py +++ b/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py @@ -68,9 +68,9 @@ def json(self): if kwargs["params"].get("date") and kwargs["params"].get("from") and kwargs["params"].get("to"): if test_exchange_values.get(kwargs["params"]["date"]): return PatchResponse({"result": test_exchange_values[kwargs["params"]["date"]]}, 200) - elif args[0].startswith("https://frankfurter.app") and kwargs.get("params"): + elif args[0].startswith("https://api.frankfurter.app") and kwargs.get("params"): if kwargs["params"].get("base") and kwargs["params"].get("symbols"): - date = args[0].replace("https://frankfurter.app/", "") + date = args[0].replace("https://api.frankfurter.app/", "") if test_exchange_values.get(date): return PatchResponse( {"rates": {kwargs["params"].get("symbols"): test_exchange_values.get(date)}}, 200 diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index bba9e79a348d..97ec418d9557 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -98,7 +98,7 @@ def setup_currency_exchange(): ces.set("result_key", []) ces.set("req_params", []) - ces.api_endpoint = "https://frankfurter.app/{transaction_date}" + ces.api_endpoint = "https://api.frankfurter.app/{transaction_date}" ces.append("result_key", {"key": "rates"}) ces.append("result_key", {"key": "{to_currency}"}) ces.append("req_params", {"key": "base", "value": "{from_currency}"}) diff --git a/erpnext/stock/dashboard/item_dashboard.js b/erpnext/stock/dashboard/item_dashboard.js index 36a51056bb45..6fc9e6666a25 100644 --- a/erpnext/stock/dashboard/item_dashboard.js +++ b/erpnext/stock/dashboard/item_dashboard.js @@ -48,17 +48,18 @@ erpnext.stock.ItemDashboard = class ItemDashboard { let actual_qty = unescape(element.attr("data-actual_qty")); let disable_quick_entry = Number(unescape(element.attr("data-disable_quick_entry"))); let entry_type = action === "Move" ? "Material Transfer" : "Material Receipt"; + let stock_uom = unescape(element.attr("data-stock-uom")); if (disable_quick_entry) { open_stock_entry(item, warehouse, entry_type); } else { if (action === "Add") { let rate = unescape($(this).attr("data-rate")); - erpnext.stock.move_item(item, null, warehouse, actual_qty, rate, function () { + erpnext.stock.move_item(item, null, warehouse, actual_qty, rate, stock_uom, function () { me.refresh(); }); } else { - erpnext.stock.move_item(item, warehouse, null, actual_qty, null, function () { + erpnext.stock.move_item(item, warehouse, null, actual_qty, null, stock_uom, function () { me.refresh(); }); } @@ -207,7 +208,7 @@ erpnext.stock.ItemDashboard = class ItemDashboard { } }; -erpnext.stock.move_item = function (item, source, target, actual_qty, rate, callback) { +erpnext.stock.move_item = function (item, source, target, actual_qty, rate, stock_uom, callback) { var dialog = new frappe.ui.Dialog({ title: target ? __("Add Item") : __("Move Item"), fields: [ @@ -295,6 +296,8 @@ erpnext.stock.move_item = function (item, source, target, actual_qty, rate, call let row = frappe.model.add_child(doc, "items"); row.item_code = dialog.get_value("item_code"); row.s_warehouse = dialog.get_value("source"); + row.stock_uom = stock_uom; + row.uom = stock_uom; row.t_warehouse = dialog.get_value("target"); row.qty = dialog.get_value("qty"); row.conversion_factor = 1; diff --git a/erpnext/stock/dashboard/item_dashboard.py b/erpnext/stock/dashboard/item_dashboard.py index e6382688669b..3d7c21639e0f 100644 --- a/erpnext/stock/dashboard/item_dashboard.py +++ b/erpnext/stock/dashboard/item_dashboard.py @@ -71,6 +71,7 @@ def get_data( item.update( { "item_name": frappe.get_cached_value("Item", item.item_code, "item_name"), + "stock_uom": frappe.get_cached_value("Item", item.item_code, "stock_uom"), "disable_quick_entry": frappe.get_cached_value("Item", item.item_code, "has_batch_no") or frappe.get_cached_value("Item", item.item_code, "has_serial_no"), "projected_qty": flt(item.projected_qty, precision), diff --git a/erpnext/stock/dashboard/item_dashboard_list.html b/erpnext/stock/dashboard/item_dashboard_list.html index 3b2619133bf4..ae90ff80686a 100644 --- a/erpnext/stock/dashboard/item_dashboard_list.html +++ b/erpnext/stock/dashboard/item_dashboard_list.html @@ -49,12 +49,14 @@ data-disable_quick_entry="{{ d.disable_quick_entry }}" data-warehouse="{{ d.warehouse }}" data-actual_qty="{{ d.actual_qty }}" + data-stock-uom="{{ d.stock_uom }}" data-item="{{ escape(d.item_code) }}">{{ __("Move") }} {% endif %}