From ba2fd71b6522b39d1f17fc00c497d1d59ea00e7e Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 15 Mar 2023 10:38:49 +0530 Subject: [PATCH 01/31] fix: use max function to get default company address (backport #34116) (#34452) * fix: use max function to get default company address (cherry picked from commit b93c18bd4a0320a22fb03b9f41b92675ee246f19) * test: add test for primary address sorting (cherry picked from commit e0042972c85e916e3081818570d79d2378f222a6) --------- Co-authored-by: Prateek <40106895+prateekkaramchandani@users.noreply.github.com> Co-authored-by: Ankush Menat --- erpnext/setup/doctype/company/company.py | 2 +- erpnext/setup/doctype/company/test_company.py | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 07ee2890c46..fcdf245659b 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -808,7 +808,7 @@ def get_default_company_address(name, sort_key="is_primary_address", existing_ad return existing_address if out: - return min(out, key=lambda x: x[1])[0] # find min by sort_key + return max(out, key=lambda x: x[1])[0] # find max by sort_key else: return None diff --git a/erpnext/setup/doctype/company/test_company.py b/erpnext/setup/doctype/company/test_company.py index 29e056e34f0..fd2fe300fac 100644 --- a/erpnext/setup/doctype/company/test_company.py +++ b/erpnext/setup/doctype/company/test_company.py @@ -11,6 +11,7 @@ from frappe.utils import random_string from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import ( get_charts_for_country, ) +from erpnext.setup.doctype.company.company import get_default_company_address test_ignore = ["Account", "Cost Center", "Payment Terms Template", "Salary Component", "Warehouse"] test_dependencies = ["Fiscal Year"] @@ -132,6 +133,38 @@ class TestCompany(unittest.TestCase): self.assertTrue(lft >= min_lft) self.assertTrue(rgt <= max_rgt) + def test_primary_address(self): + company = "_Test Company" + + secondary = frappe.get_doc( + { + "address_title": "Non Primary", + "doctype": "Address", + "address_type": "Billing", + "address_line1": "Something", + "city": "Mumbai", + "state": "Maharashtra", + "country": "India", + "is_primary_address": 1, + "pincode": "400098", + "links": [ + { + "link_doctype": "Company", + "link_name": company, + } + ], + } + ) + secondary.insert() + self.addCleanup(secondary.delete) + + primary = frappe.copy_doc(secondary) + primary.is_primary_address = 1 + primary.insert() + self.addCleanup(primary.delete) + + self.assertEqual(get_default_company_address(company), primary.name) + def get_no_of_children(self, company): def get_no_of_children(companies, no_of_children): children = [] From 68f9863ae5f0cde8d1343a05a4964780494836e0 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 15 Mar 2023 14:19:09 +0530 Subject: [PATCH 02/31] test: add timeout to all BOM related tests (backport #34446) (#34453) test: add timeout to all BOM related tests (#34446) * Revert "chore: remove failing test (#34444)" This reverts commit b89ecd482d4c8db69765f633bc22c95619f2f3f9. * test: add timeout to bom tests (cherry picked from commit f95ad039e4564d9516cd654816c0180d2c71a7a9) Co-authored-by: Ankush Menat --- erpnext/manufacturing/doctype/bom/test_bom.py | 29 ++++++++++++++++++- .../bom_update_tool/test_bom_update_tool.py | 4 ++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index d60feb2b391..01bf2e4315f 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -6,7 +6,7 @@ from collections import deque from functools import partial import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, timeout from frappe.utils import cstr, flt from erpnext.controllers.tests.test_subcontracting_controller import ( @@ -27,6 +27,7 @@ test_dependencies = ["Item", "Quality Inspection Template"] class TestBOM(FrappeTestCase): + @timeout def test_get_items(self): from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict @@ -37,6 +38,7 @@ class TestBOM(FrappeTestCase): self.assertTrue(test_records[2]["items"][1]["item_code"] in items_dict) self.assertEqual(len(items_dict.values()), 2) + @timeout def test_get_items_exploded(self): from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict @@ -49,11 +51,13 @@ class TestBOM(FrappeTestCase): self.assertTrue(test_records[0]["items"][1]["item_code"] in items_dict) self.assertEqual(len(items_dict.values()), 3) + @timeout def test_get_items_list(self): from erpnext.manufacturing.doctype.bom.bom import get_bom_items self.assertEqual(len(get_bom_items(bom=get_default_bom(), company="_Test Company")), 3) + @timeout def test_default_bom(self): def _get_default_bom_in_item(): return cstr(frappe.db.get_value("Item", "_Test FG Item 2", "default_bom")) @@ -71,6 +75,7 @@ class TestBOM(FrappeTestCase): self.assertTrue(_get_default_bom_in_item(), bom.name) + @timeout def test_update_bom_cost_in_all_boms(self): # get current rate for '_Test Item 2' bom_rates = frappe.db.get_values( @@ -99,6 +104,7 @@ class TestBOM(FrappeTestCase): ): self.assertEqual(d.base_rate, rm_base_rate + 10) + @timeout def test_bom_cost(self): bom = frappe.copy_doc(test_records[2]) bom.insert() @@ -127,6 +133,7 @@ class TestBOM(FrappeTestCase): self.assertAlmostEqual(bom.base_raw_material_cost, base_raw_material_cost) self.assertAlmostEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost) + @timeout def test_bom_cost_with_batch_size(self): bom = frappe.copy_doc(test_records[2]) bom.docstatus = 0 @@ -145,6 +152,7 @@ class TestBOM(FrappeTestCase): self.assertAlmostEqual(bom.operating_cost, op_cost / 2) bom.delete() + @timeout def test_bom_cost_multi_uom_multi_currency_based_on_price_list(self): frappe.db.set_value("Price List", "_Test Price List", "price_not_uom_dependent", 1) for item_code, rate in (("_Test Item", 3600), ("_Test Item Home Desktop Manufactured", 3000)): @@ -181,6 +189,7 @@ class TestBOM(FrappeTestCase): self.assertEqual(bom.base_raw_material_cost, 27000) self.assertEqual(bom.base_total_cost, 33000) + @timeout def test_bom_cost_multi_uom_based_on_valuation_rate(self): bom = frappe.copy_doc(test_records[2]) bom.set_rate_of_sub_assembly_item_based_on_bom = 0 @@ -202,6 +211,7 @@ class TestBOM(FrappeTestCase): self.assertEqual(bom.items[0].rate, 20) + @timeout def test_bom_cost_with_fg_based_operating_cost(self): bom = frappe.copy_doc(test_records[4]) bom.insert() @@ -229,6 +239,7 @@ class TestBOM(FrappeTestCase): self.assertAlmostEqual(bom.base_raw_material_cost, base_raw_material_cost) self.assertAlmostEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost) + @timeout def test_subcontractor_sourced_item(self): item_code = "_Test Subcontracted FG Item 1" set_backflush_based_on("Material Transferred for Subcontract") @@ -310,6 +321,7 @@ class TestBOM(FrappeTestCase): supplied_items = sorted([d.rm_item_code for d in sco.supplied_items]) self.assertEqual(bom_items, supplied_items) + @timeout def test_bom_tree_representation(self): bom_tree = { "Assembly": { @@ -335,6 +347,7 @@ class TestBOM(FrappeTestCase): for reqd_item, created_item in zip(reqd_order, created_order): self.assertEqual(reqd_item, created_item.item_code) + @timeout def test_generated_variant_bom(self): from erpnext.controllers.item_variant import create_variant @@ -375,6 +388,7 @@ class TestBOM(FrappeTestCase): self.assertEqual(reqd_item.qty, created_item.qty) self.assertEqual(reqd_item.exploded_qty, created_item.exploded_qty) + @timeout def test_bom_recursion_1st_level(self): """BOM should not allow BOM item again in child""" item_code = make_item(properties={"is_stock_item": 1}).name @@ -387,6 +401,7 @@ class TestBOM(FrappeTestCase): bom.items[0].bom_no = bom.name bom.save() + @timeout def test_bom_recursion_transitive(self): item1 = make_item(properties={"is_stock_item": 1}).name item2 = make_item(properties={"is_stock_item": 1}).name @@ -408,6 +423,7 @@ class TestBOM(FrappeTestCase): bom1.save() bom2.save() + @timeout def test_bom_with_process_loss_item(self): fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items() @@ -421,6 +437,7 @@ class TestBOM(FrappeTestCase): # Items with whole UOMs can't be PL Items self.assertRaises(frappe.ValidationError, bom_doc.submit) + @timeout def test_bom_item_query(self): query = partial( item_query, @@ -440,6 +457,7 @@ class TestBOM(FrappeTestCase): ) self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results") + @timeout def test_exclude_exploded_items_from_bom(self): bom_no = get_default_bom() new_bom = frappe.copy_doc(frappe.get_doc("BOM", bom_no)) @@ -458,6 +476,7 @@ class TestBOM(FrappeTestCase): new_bom.delete() + @timeout def test_valid_transfer_defaults(self): bom_with_op = frappe.db.get_value( "BOM", {"item": "_Test FG Item 2", "with_operations": 1, "is_active": 1} @@ -489,11 +508,13 @@ class TestBOM(FrappeTestCase): self.assertEqual(bom.transfer_material_against, "Work Order") bom.delete() + @timeout def test_bom_name_length(self): """test >140 char names""" bom_tree = {"x" * 140: {" ".join(["abc"] * 35): {}}} create_nested_bom(bom_tree, prefix="") + @timeout def test_version_index(self): bom = frappe.new_doc("BOM") @@ -515,6 +536,7 @@ class TestBOM(FrappeTestCase): msg=f"Incorrect index for {existing_boms}", ) + @timeout def test_bom_versioning(self): bom_tree = {frappe.generate_hash(length=10): {frappe.generate_hash(length=10): {}}} bom = create_nested_bom(bom_tree, prefix="") @@ -547,6 +569,7 @@ class TestBOM(FrappeTestCase): self.assertNotEqual(amendment.name, version.name) self.assertEqual(int(version.name.split("-")[-1]), 2) + @timeout def test_clear_inpection_quality(self): bom = frappe.copy_doc(test_records[2], ignore_no_copy=True) @@ -565,6 +588,7 @@ class TestBOM(FrappeTestCase): self.assertEqual(bom.quality_inspection_template, None) + @timeout def test_bom_pricing_based_on_lpp(self): from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -585,6 +609,7 @@ class TestBOM(FrappeTestCase): bom.submit() self.assertEqual(bom.items[0].rate, 42) + @timeout def test_set_default_bom_for_item_having_single_bom(self): from erpnext.stock.doctype.item.test_item import make_item @@ -621,6 +646,7 @@ class TestBOM(FrappeTestCase): bom.reload() self.assertEqual(frappe.get_value("Item", fg_item.item_code, "default_bom"), bom.name) + @timeout def test_exploded_items_rate(self): rm_item = make_item( properties={"is_stock_item": 1, "valuation_rate": 99, "last_purchase_rate": 89} @@ -649,6 +675,7 @@ class TestBOM(FrappeTestCase): bom.submit() self.assertEqual(bom.exploded_items[0].rate, bom.items[0].base_rate) + @timeout def test_bom_cost_update_flag(self): rm_item = make_item( properties={"is_stock_item": 1, "valuation_rate": 99, "last_purchase_rate": 89} diff --git a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py index 5dd557f8ab1..2026f629147 100644 --- a/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py +++ b/erpnext/manufacturing/doctype/bom_update_tool/test_bom_update_tool.py @@ -2,7 +2,7 @@ # License: GNU General Public License v3. See license.txt import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, timeout from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import ( update_cost_in_all_boms_in_test, @@ -20,6 +20,7 @@ class TestBOMUpdateTool(FrappeTestCase): def tearDown(self): frappe.db.rollback() + @timeout def test_replace_bom(self): current_bom = "BOM-_Test Item Home Desktop Manufactured-001" @@ -33,6 +34,7 @@ class TestBOMUpdateTool(FrappeTestCase): self.assertFalse(frappe.db.exists("BOM Item", {"bom_no": current_bom, "docstatus": 1})) self.assertTrue(frappe.db.exists("BOM Item", {"bom_no": bom_doc.name, "docstatus": 1})) + @timeout def test_bom_cost(self): for item in ["BOM Cost Test Item 1", "BOM Cost Test Item 2", "BOM Cost Test Item 3"]: item_doc = create_item(item, valuation_rate=100) From 9f7da21c9333e61bad2eb561ed3de96c8656ab7e Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 16 Mar 2023 11:50:19 +0530 Subject: [PATCH 03/31] feat: Support for Alternative Items in Quotation (#33874) * 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 91982d1e4f7012a6a28ed8116a65fdfd9b9f973f) # 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 f19eadab9ab1a7defd74cf3b7337012e4e111ac8) * 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 cef7dfd0b48ba6ebd6dfb9eabb722c78e4493ccb) # 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 94cacb60de00bda141537eb59d3d775004576a3d) * chore: Validate 'alternative_to' field values, must be a valid non-alterntaive item from table (cherry picked from commit fa9b327501a33850374f69f64dcf27ac5b2f2ae3) * fix: Iterate over list instead of map's output and formatting (cherry picked from commit ece6358e60f5c0bfae6129e3d9613de0d9a40402) * 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 b3fe7c6dad442c5000959dde8537bfcdf6b55390) * chore: Code simplification - Map is not required, avoid filter multiple times, use single loop instead - Better variable name - Reduce LOC (cherry picked from commit 03321f5f1396bb386b08d289db827962c9b6cbc3) * 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 db2076db693a54a8962588ba26a31682a1acc99f) # Conflicts: # erpnext/selling/doctype/quotation_item/quotation_item.json * test: Alternative items in Quotation - Taxes and totals, mapping, back updation (cherry picked from commit 74fab53e281b42c2eb3436ae3145d820108b1c13) * fix: Use block variable Co-authored-by: Deepesh Garg (cherry picked from commit 3c96791d52f06b611ea14a2814a651af2ecd7649) * 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 19456127cfde02bdf6873da5dff89ea519e5dddd) * fix: Map only non alternative items from Quotation in Sales Invoice - Since there's no item selection, only Quotation selection :/ (cherry picked from commit 6b789e2f0492c3e6932852507b746d1111412028) * fix: Merge conflicts --------- Co-authored-by: marination --- erpnext/controllers/taxes_and_totals.py | 32 +++-- .../public/js/controllers/taxes_and_totals.js | 30 ++-- .../selling/doctype/quotation/quotation.js | 124 ++++++++++++++-- .../selling/doctype/quotation/quotation.py | 94 +++++++++++-- .../doctype/quotation/test_quotation.py | 133 ++++++++++++++++++ .../quotation_item/quotation_item.json | 21 ++- .../doctype/sales_order/sales_order.js | 13 +- 7 files changed, 405 insertions(+), 42 deletions(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 8c403aa9bfe..1edd7bf85e1 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -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 @@ -70,7 +78,7 @@ class calculate_taxes_and_totals(object): 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 @@ class calculate_taxes_and_totals(object): 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 @@ class calculate_taxes_and_totals(object): 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 @@ class calculate_taxes_and_totals(object): 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 @@ class calculate_taxes_and_totals(object): 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 @@ 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) for i, tax in enumerate(self.doc.get("taxes")): # 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 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 @@ class calculate_taxes_and_totals(object): ) # 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 @@ class calculate_taxes_and_totals(object): 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 @@ class calculate_taxes_and_totals(object): 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 @@ class calculate_taxes_and_totals(object): 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 974b937fa26..d1a55e6f424 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -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(); @@ -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; @@ -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; @@ -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; @@ -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 @@ -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]; } } @@ -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"]); @@ -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"]; }); } @@ -655,7 +659,7 @@ 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)); @@ -663,7 +667,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { // 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, @@ -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"]); + } }; diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index b348bd35754..81ef44d53ed 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -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") ); } @@ -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") { @@ -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 ? `${value}` : 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( + `

+ + Alternative Items +

` + ) + 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 063813b2dc7..fc66db20d29 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -35,6 +35,9 @@ class Quotation(SellingController): 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 @@ class Quotation(SellingController): 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 @@ class Quotation(SellingController): ) ) - 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 @@ class Quotation(SellingController): 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 @@ -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): if customer: 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_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( "Quotation", source_name, @@ -253,7 +327,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): "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}, @@ -322,7 +396,11 @@ def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): source_name, { "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 Team": {"doctype": "Sales Team", "add_if_empty": True}, }, diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index cdf5f5d00c5..67f6518657e 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -457,6 +457,139 @@ class TestQuotation(FrappeTestCase): expected_index = id + 1 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") diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index 31a95896bc1..fb810318e93 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", @@ -644,12 +646,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": "2021-07-15 12:40:51.074820", + "modified": "2023-02-06 11:00:07.042364", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", @@ -657,5 +675,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index ee0752549da..449d461561a 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -275,7 +275,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex if (this.frm.doc.docstatus===0) { this.frm.add_custom_button(__('Quotation'), function() { - erpnext.utils.map_current_doc({ + let d = erpnext.utils.map_current_doc({ method: "erpnext.selling.doctype.quotation.quotation.make_sales_order", source_doctype: "Quotation", target: me.frm, @@ -293,7 +293,16 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex docstatus: 1, status: ["!=", "Lost"] } - }) + }); + + setTimeout(() => { + d.$parent.append(` + + ${__("Note: Please create Sales Orders from individual Quotations to select from among Alternative Items.")} + + `); + }, 200); + }, __("Get Items From")); } From befd1a0f918d8dc35ef071b8d00dd4ae95e167ab Mon Sep 17 00:00:00 2001 From: Ritwik Puri Date: Thu, 16 Mar 2023 16:04:21 +0530 Subject: [PATCH 04/31] ci: use version specific payments repo (#34468) ci: use version-14 branch of payments repo for v14 erpnext --- .github/helper/install.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 2bb950fcfcc..cb82d2ad733 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -8,8 +8,9 @@ sudo apt update && sudo apt install redis-server libcups2-dev pip install frappe-bench +githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}} frappeuser=${FRAPPE_USER:-"frappe"} -frappebranch=${FRAPPE_BRANCH:-${GITHUB_BASE_REF:-${GITHUB_REF##*/}}} +frappebranch=${FRAPPE_BRANCH:-$githubbranch} git clone "https://github.com/${frappeuser}/frappe" --branch "${frappebranch}" --depth 1 bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench @@ -56,7 +57,7 @@ sed -i 's/schedule:/# schedule:/g' Procfile sed -i 's/socketio:/# socketio:/g' Procfile sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile -bench get-app payments +bench get-app payments --branch ${githubbranch%"-hotfix"} bench get-app erpnext "${GITHUB_WORKSPACE}" if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi From 9b608eaa0fcebcb5a8627ec4c14ccfad1057421e Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Fri, 17 Mar 2023 04:31:29 +0100 Subject: [PATCH 05/31] feat: bank reconciliation and plaid changes (#33986) feat: bank reconciliation and plaid changes (#33986) fix: plaid link refresh: update account ids fix: plaid transactions for credit cards & add accounts on link refresh if they don't exist fix: bank reconciliation amount matching fix: bank reconciliation dialog usability feat: rewrite bank transaction reconciliation to allow multiple transactions to reconcile against vouchers before clearance fix: matching transaction amounts and race condition bug fix: ensure there is a reference number in plaid transactions and other tweaks feat: add references to Payroll Entry Bank Journal Entry feat: only clear Voucher once all Bank GLEs are allocated to Bank Transactions fix: strange type error feat: add payment method field to bank and plaid transactions and prepopulate relevant bank reconciliation new voucher fields feat: bank reconciliation - allow bank transactions to reconcile against themselves for when there are banking amendments fix: bank transaction self-reconcile bug and tidy fix: bank reconciliation datatable index update Co-authored-by: Richard Case <110036763+casesolved-co-uk@users.noreply.github.com> --- erpnext/accounts/doctype/bank/bank.js | 6 +- .../bank_reconciliation_tool.js | 4 +- .../bank_reconciliation_tool.py | 286 ++++++++------- .../bank_transaction/bank_transaction.js | 12 +- .../bank_transaction/bank_transaction.json | 18 +- .../bank_transaction/bank_transaction.py | 347 +++++++++++++----- .../doctype/plaid_settings/plaid_connector.py | 2 +- .../doctype/plaid_settings/plaid_settings.js | 2 +- .../doctype/plaid_settings/plaid_settings.py | 63 +++- .../plaid_settings/test_plaid_settings.py | 2 + .../data_table_manager.js | 3 + .../dialog_manager.js | 72 ++-- 12 files changed, 548 insertions(+), 269 deletions(-) diff --git a/erpnext/accounts/doctype/bank/bank.js b/erpnext/accounts/doctype/bank/bank.js index 059e1d31588..35d606ba3ae 100644 --- a/erpnext/accounts/doctype/bank/bank.js +++ b/erpnext/accounts/doctype/bank/bank.js @@ -118,6 +118,10 @@ erpnext.integrations.refreshPlaidLink = class refreshPlaidLink { } plaid_success(token, response) { - frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' }); + frappe.xcall('erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.update_bank_account_ids', { + response: response, + }).then(() => { + frappe.show_alert({ message: __('Plaid Link Updated'), indicator: 'green' }); + }); } }; diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js index c083189eb27..ae84154f2df 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js @@ -155,7 +155,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { } }, - render_chart: frappe.utils.debounce((frm) => { + render_chart(frm) { frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager( { $reconciliation_tool_cards: frm.get_field( @@ -167,7 +167,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", { currency: frm.currency, } ); - }, 500), + }, render(frm) { if (frm.doc.bank_account) { diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index 57bc351f414..c4a23a640c3 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -10,7 +10,7 @@ from frappe.model.document import Document from frappe.query_builder.custom import ConstantColumn from frappe.utils import cint, flt -from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_paid_amount +from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount from erpnext.accounts.report.bank_reconciliation_statement.bank_reconciliation_statement import ( get_amounts_not_reflected_in_system, get_entries, @@ -28,7 +28,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None): filters = [] filters.append(["bank_account", "=", bank_account]) filters.append(["docstatus", "=", 1]) - filters.append(["unallocated_amount", ">", 0]) + filters.append(["unallocated_amount", ">", 0.0]) if to_date: filters.append(["date", "<=", to_date]) if from_date: @@ -66,7 +66,7 @@ def get_account_balance(bank_account, till_date): balance_as_per_system = get_balance_on(filters["account"], filters["report_date"]) - total_debit, total_credit = 0, 0 + total_debit, total_credit = 0.0, 0.0 for d in data: total_debit += flt(d.debit) total_credit += flt(d.credit) @@ -145,10 +145,8 @@ def create_journal_entry_bts( accounts.append( { "account": second_account, - "credit_in_account_currency": bank_transaction.deposit if bank_transaction.deposit > 0 else 0, - "debit_in_account_currency": bank_transaction.withdrawal - if bank_transaction.withdrawal > 0 - else 0, + "credit_in_account_currency": bank_transaction.deposit, + "debit_in_account_currency": bank_transaction.withdrawal, "party_type": party_type, "party": party, } @@ -158,10 +156,8 @@ def create_journal_entry_bts( { "account": company_account, "bank_account": bank_transaction.bank_account, - "credit_in_account_currency": bank_transaction.withdrawal - if bank_transaction.withdrawal > 0 - else 0, - "debit_in_account_currency": bank_transaction.deposit if bank_transaction.deposit > 0 else 0, + "credit_in_account_currency": bank_transaction.withdrawal, + "debit_in_account_currency": bank_transaction.deposit, } ) @@ -185,16 +181,22 @@ def create_journal_entry_bts( journal_entry.insert() journal_entry.submit() - if bank_transaction.deposit > 0: + if bank_transaction.deposit > 0.0: paid_amount = bank_transaction.deposit else: paid_amount = bank_transaction.withdrawal vouchers = json.dumps( - [{"payment_doctype": "Journal Entry", "payment_name": journal_entry.name, "amount": paid_amount}] + [ + { + "payment_doctype": "Journal Entry", + "payment_name": journal_entry.name, + "amount": paid_amount, + } + ] ) - return reconcile_vouchers(bank_transaction.name, vouchers) + return reconcile_vouchers(bank_transaction_name, vouchers) @frappe.whitelist() @@ -218,7 +220,7 @@ def create_payment_entry_bts( as_dict=True, )[0] paid_amount = bank_transaction.unallocated_amount - payment_type = "Receive" if bank_transaction.deposit > 0 else "Pay" + payment_type = "Receive" if bank_transaction.deposit > 0.0 else "Pay" company_account = frappe.get_value("Bank Account", bank_transaction.bank_account, "account") company = frappe.get_value("Account", company_account, "company") @@ -257,9 +259,15 @@ def create_payment_entry_bts( payment_entry.submit() vouchers = json.dumps( - [{"payment_doctype": "Payment Entry", "payment_name": payment_entry.name, "amount": paid_amount}] + [ + { + "payment_doctype": "Payment Entry", + "payment_name": payment_entry.name, + "amount": paid_amount, + } + ] ) - return reconcile_vouchers(bank_transaction.name, vouchers) + return reconcile_vouchers(bank_transaction_name, vouchers) @frappe.whitelist() @@ -341,59 +349,7 @@ def reconcile_vouchers(bank_transaction_name, vouchers): # updated clear date of all the vouchers based on the bank transaction vouchers = json.loads(vouchers) transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) - company_account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") - - if transaction.unallocated_amount == 0: - frappe.throw(_("This bank transaction is already fully reconciled")) - total_amount = 0 - for voucher in vouchers: - voucher["payment_entry"] = frappe.get_doc(voucher["payment_doctype"], voucher["payment_name"]) - total_amount += get_paid_amount( - frappe._dict( - { - "payment_document": voucher["payment_doctype"], - "payment_entry": voucher["payment_name"], - } - ), - transaction.currency, - company_account, - ) - - if total_amount > transaction.unallocated_amount: - frappe.throw( - _( - "The sum total of amounts of all selected vouchers should be less than the unallocated amount of the bank transaction" - ) - ) - account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") - - for voucher in vouchers: - gl_entry = frappe.db.get_value( - "GL Entry", - dict( - account=account, voucher_type=voucher["payment_doctype"], voucher_no=voucher["payment_name"] - ), - ["credit_in_account_currency as credit", "debit_in_account_currency as debit"], - as_dict=1, - ) - gl_amount, transaction_amount = ( - (gl_entry.credit, transaction.deposit) - if gl_entry.credit > 0 - else (gl_entry.debit, transaction.withdrawal) - ) - allocated_amount = gl_amount if gl_amount >= transaction_amount else transaction_amount - - transaction.append( - "payment_entries", - { - "payment_document": voucher["payment_entry"].doctype, - "payment_entry": voucher["payment_entry"].name, - "allocated_amount": allocated_amount, - }, - ) - - transaction.save() - transaction.update_allocations() + transaction.add_payment_entries(vouchers) return frappe.get_doc("Bank Transaction", bank_transaction_name) @@ -412,9 +368,9 @@ def get_linked_payments( bank_account = frappe.db.get_values( "Bank Account", transaction.bank_account, ["account", "company"], as_dict=True )[0] - (account, company) = (bank_account.account, bank_account.company) + (gl_account, company) = (bank_account.account, bank_account.company) matching = check_matching( - account, + gl_account, company, transaction, document_types, @@ -424,7 +380,27 @@ def get_linked_payments( from_reference_date, to_reference_date, ) - return matching + return subtract_allocations(gl_account, matching) + + +def subtract_allocations(gl_account, vouchers): + "Look up & subtract any existing Bank Transaction allocations" + copied = [] + for voucher in vouchers: + rows = get_total_allocated_amount(voucher[1], voucher[2]) + amount = None + for row in rows: + if row["gl_account"] == gl_account: + amount = row["total"] + break + + if amount: + l = list(voucher) + l[3] -= amount + copied.append(tuple(l)) + else: + copied.append(voucher) + return copied def check_matching( @@ -438,6 +414,7 @@ def check_matching( from_reference_date, to_reference_date, ): + exact_match = True if "exact_match" in document_types else False # combine all types of vouchers subquery = get_queries( bank_account, @@ -449,10 +426,11 @@ def check_matching( filter_by_reference_date, from_reference_date, to_reference_date, + exact_match, ) filters = { "amount": transaction.unallocated_amount, - "payment_type": "Receive" if transaction.deposit > 0 else "Pay", + "payment_type": "Receive" if transaction.deposit > 0.0 else "Pay", "reference_no": transaction.reference_number, "party_type": transaction.party_type, "party": transaction.party, @@ -461,7 +439,9 @@ def check_matching( matching_vouchers = [] - matching_vouchers.extend(get_loan_vouchers(bank_account, transaction, document_types, filters)) + matching_vouchers.extend( + get_loan_vouchers(bank_account, transaction, document_types, filters, exact_match) + ) for query in subquery: matching_vouchers.extend( @@ -483,10 +463,10 @@ def get_queries( filter_by_reference_date, from_reference_date, to_reference_date, + exact_match, ): # get queries to get matching vouchers - amount_condition = "=" if "exact_match" in document_types else "<=" - account_from_to = "paid_to" if transaction.deposit > 0 else "paid_from" + account_from_to = "paid_to" if transaction.deposit > 0.0 else "paid_from" queries = [] # get matching queries from all the apps @@ -497,7 +477,7 @@ def get_queries( company, transaction, document_types, - amount_condition, + exact_match, account_from_to, from_date, to_date, @@ -516,7 +496,7 @@ def get_matching_queries( company, transaction, document_types, - amount_condition, + exact_match, account_from_to, from_date, to_date, @@ -526,8 +506,8 @@ def get_matching_queries( ): queries = [] if "payment_entry" in document_types: - pe_amount_matching = get_pe_matching_query( - amount_condition, + query = get_pe_matching_query( + exact_match, account_from_to, transaction, from_date, @@ -536,11 +516,11 @@ def get_matching_queries( from_reference_date, to_reference_date, ) - queries.extend([pe_amount_matching]) + queries.append(query) if "journal_entry" in document_types: - je_amount_matching = get_je_matching_query( - amount_condition, + query = get_je_matching_query( + exact_match, transaction, from_date, to_date, @@ -548,34 +528,70 @@ def get_matching_queries( from_reference_date, to_reference_date, ) - queries.extend([je_amount_matching]) + queries.append(query) - if transaction.deposit > 0 and "sales_invoice" in document_types: - si_amount_matching = get_si_matching_query(amount_condition) - queries.extend([si_amount_matching]) + if transaction.deposit > 0.0 and "sales_invoice" in document_types: + query = get_si_matching_query(exact_match) + queries.append(query) - if transaction.withdrawal > 0: + if transaction.withdrawal > 0.0: if "purchase_invoice" in document_types: - pi_amount_matching = get_pi_matching_query(amount_condition) - queries.extend([pi_amount_matching]) + query = get_pi_matching_query(exact_match) + queries.append(query) + + if "bank_transaction" in document_types: + query = get_bt_matching_query(exact_match, transaction) + queries.append(query) return queries -def get_loan_vouchers(bank_account, transaction, document_types, filters): +def get_loan_vouchers(bank_account, transaction, document_types, filters, exact_match): vouchers = [] - amount_condition = True if "exact_match" in document_types else False - if transaction.withdrawal > 0 and "loan_disbursement" in document_types: - vouchers.extend(get_ld_matching_query(bank_account, amount_condition, filters)) + if transaction.withdrawal > 0.0 and "loan_disbursement" in document_types: + vouchers.extend(get_ld_matching_query(bank_account, exact_match, filters)) - if transaction.deposit > 0 and "loan_repayment" in document_types: - vouchers.extend(get_lr_matching_query(bank_account, amount_condition, filters)) + if transaction.deposit > 0.0 and "loan_repayment" in document_types: + vouchers.extend(get_lr_matching_query(bank_account, exact_match, filters)) return vouchers -def get_ld_matching_query(bank_account, amount_condition, filters): +def get_bt_matching_query(exact_match, transaction): + # get matching bank transaction query + # find bank transactions in the same bank account with opposite sign + # same bank account must have same company and currency + field = "deposit" if transaction.withdrawal > 0.0 else "withdrawal" + + return f""" + + SELECT + (CASE WHEN reference_number = %(reference_no)s THEN 1 ELSE 0 END + + CASE WHEN {field} = %(amount)s THEN 1 ELSE 0 END + + CASE WHEN ( party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END + + CASE WHEN unallocated_amount = %(amount)s THEN 1 ELSE 0 END + + 1) AS rank, + 'Bank Transaction' AS doctype, + name, + unallocated_amount AS paid_amount, + reference_number AS reference_no, + date AS reference_date, + party, + party_type, + date AS posting_date, + currency + FROM + `tabBank Transaction` + WHERE + status != 'Reconciled' + AND name != '{transaction.name}' + AND bank_account = '{transaction.bank_account}' + AND {field} {'= %(amount)s' if exact_match else '> 0.0'} + """ + + +def get_ld_matching_query(bank_account, exact_match, filters): loan_disbursement = frappe.qb.DocType("Loan Disbursement") matching_reference = loan_disbursement.reference_number == filters.get("reference_number") matching_party = loan_disbursement.applicant_type == filters.get( @@ -603,17 +619,17 @@ def get_ld_matching_query(bank_account, amount_condition, filters): .where(loan_disbursement.disbursement_account == bank_account) ) - if amount_condition: + if exact_match: query.where(loan_disbursement.disbursed_amount == filters.get("amount")) else: - query.where(loan_disbursement.disbursed_amount <= filters.get("amount")) + query.where(loan_disbursement.disbursed_amount > 0.0) vouchers = query.run(as_list=True) return vouchers -def get_lr_matching_query(bank_account, amount_condition, filters): +def get_lr_matching_query(bank_account, exact_match, filters): loan_repayment = frappe.qb.DocType("Loan Repayment") matching_reference = loan_repayment.reference_number == filters.get("reference_number") matching_party = loan_repayment.applicant_type == filters.get( @@ -644,10 +660,10 @@ def get_lr_matching_query(bank_account, amount_condition, filters): if frappe.db.has_column("Loan Repayment", "repay_from_salary"): query = query.where((loan_repayment.repay_from_salary == 0)) - if amount_condition: + if exact_match: query.where(loan_repayment.amount_paid == filters.get("amount")) else: - query.where(loan_repayment.amount_paid <= filters.get("amount")) + query.where(loan_repayment.amount_paid > 0.0) vouchers = query.run() @@ -655,7 +671,7 @@ def get_lr_matching_query(bank_account, amount_condition, filters): def get_pe_matching_query( - amount_condition, + exact_match, account_from_to, transaction, from_date, @@ -665,7 +681,7 @@ def get_pe_matching_query( to_reference_date, ): # get matching payment entries query - if transaction.deposit > 0: + if transaction.deposit > 0.0: currency_field = "paid_to_account_currency as currency" else: currency_field = "paid_from_account_currency as currency" @@ -680,7 +696,8 @@ def get_pe_matching_query( return f""" SELECT (CASE WHEN reference_no=%(reference_no)s THEN 1 ELSE 0 END - + CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END + + CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END + + CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END + 1 ) AS rank, 'Payment Entry' as doctype, name, @@ -694,20 +711,19 @@ def get_pe_matching_query( FROM `tabPayment Entry` WHERE - paid_amount {amount_condition} %(amount)s - AND docstatus = 1 + docstatus = 1 AND payment_type IN (%(payment_type)s, 'Internal Transfer') AND ifnull(clearance_date, '') = "" AND {account_from_to} = %(bank_account)s + AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'} {filter_by_date} {filter_by_reference_no} order by{order_by} - """ def get_je_matching_query( - amount_condition, + exact_match, transaction, from_date, to_date, @@ -719,7 +735,7 @@ def get_je_matching_query( # We have mapping at the bank level # So one bank could have both types of bank accounts like asset and liability # So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type - cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit" + cr_or_dr = "credit" if transaction.withdrawal > 0.0 else "debit" filter_by_date = f"AND je.posting_date between '{from_date}' and '{to_date}'" order_by = " je.posting_date" filter_by_reference_no = "" @@ -731,26 +747,29 @@ def get_je_matching_query( return f""" SELECT (CASE WHEN je.cheque_no=%(reference_no)s THEN 1 ELSE 0 END + + CASE WHEN jea.{cr_or_dr}_in_account_currency = %(amount)s THEN 1 ELSE 0 END + 1) AS rank , - 'Journal Entry' as doctype, + 'Journal Entry' AS doctype, je.name, - jea.{cr_or_dr}_in_account_currency as paid_amount, - je.cheque_no as reference_no, - je.cheque_date as reference_date, - je.pay_to_recd_from as party, + jea.{cr_or_dr}_in_account_currency AS paid_amount, + je.cheque_no AS reference_no, + je.cheque_date AS reference_date, + je.pay_to_recd_from AS party, jea.party_type, je.posting_date, - jea.account_currency as currency + jea.account_currency AS currency FROM - `tabJournal Entry Account` as jea + `tabJournal Entry Account` AS jea JOIN - `tabJournal Entry` as je + `tabJournal Entry` AS je ON jea.parent = je.name WHERE - (je.clearance_date is null or je.clearance_date='0000-00-00') + je.docstatus = 1 + AND je.voucher_type NOT IN ('Opening Entry') + AND (je.clearance_date IS NULL OR je.clearance_date='0000-00-00') AND jea.account = %(bank_account)s - AND jea.{cr_or_dr}_in_account_currency {amount_condition} %(amount)s + AND jea.{cr_or_dr}_in_account_currency {'= %(amount)s' if exact_match else '> 0.0'} AND je.docstatus = 1 {filter_by_date} {filter_by_reference_no} @@ -758,11 +777,12 @@ def get_je_matching_query( """ -def get_si_matching_query(amount_condition): - # get matchin sales invoice query +def get_si_matching_query(exact_match): + # get matching sales invoice query return f""" SELECT - ( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END + ( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END + + CASE WHEN sip.amount = %(amount)s THEN 1 ELSE 0 END + 1 ) AS rank, 'Sales Invoice' as doctype, si.name, @@ -780,18 +800,20 @@ def get_si_matching_query(amount_condition): `tabSales Invoice` as si ON sip.parent = si.name - WHERE (sip.clearance_date is null or sip.clearance_date='0000-00-00') + WHERE + si.docstatus = 1 + AND (sip.clearance_date is null or sip.clearance_date='0000-00-00') AND sip.account = %(bank_account)s - AND sip.amount {amount_condition} %(amount)s - AND si.docstatus = 1 + AND sip.amount {'= %(amount)s' if exact_match else '> 0.0'} """ -def get_pi_matching_query(amount_condition): - # get matching purchase invoice query +def get_pi_matching_query(exact_match): + # get matching purchase invoice query when they are also used as payment entries (is_paid) return f""" SELECT ( CASE WHEN supplier = %(party)s THEN 1 ELSE 0 END + + CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END + 1 ) AS rank, 'Purchase Invoice' as doctype, name, @@ -805,9 +827,9 @@ def get_pi_matching_query(amount_condition): FROM `tabPurchase Invoice` WHERE - paid_amount {amount_condition} %(amount)s - AND docstatus = 1 + docstatus = 1 AND is_paid = 1 AND ifnull(clearance_date, '') = "" - AND cash_bank_account = %(bank_account)s + AND cash_bank_account = %(bank_account)s + AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'} """ diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js index 6f2900a6808..e548b4c7e9a 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js @@ -12,8 +12,13 @@ frappe.ui.form.on("Bank Transaction", { }; }); }, - - bank_account: function(frm) { + refresh(frm) { + frm.add_custom_button(__('Unreconcile Transaction'), () => { + frm.call('remove_payment_entries') + .then( () => frm.refresh() ); + }); + }, + bank_account: function (frm) { set_bank_statement_filter(frm); }, @@ -34,6 +39,7 @@ frappe.ui.form.on("Bank Transaction", { "Journal Entry", "Sales Invoice", "Purchase Invoice", + "Bank Transaction", ]; } }); @@ -49,7 +55,7 @@ const update_clearance_date = (frm, cdt, cdn) => { frappe .xcall( "erpnext.accounts.doctype.bank_transaction.bank_transaction.unclear_reference_payment", - { doctype: cdt, docname: cdn } + { doctype: cdt, docname: cdn, bt_name: frm.doc.name } ) .then((e) => { if (e == "success") { diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json index 2bdaa1049b7..768d2f0fa45 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json @@ -20,9 +20,11 @@ "currency", "section_break_10", "description", - "section_break_14", "reference_number", + "column_break_10", "transaction_id", + "transaction_type", + "section_break_14", "payment_entries", "section_break_18", "allocated_amount", @@ -190,11 +192,21 @@ "label": "Withdrawal", "oldfieldname": "credit", "options": "currency" + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, + { + "fieldname": "transaction_type", + "fieldtype": "Data", + "label": "Transaction Type", + "length": 50 } ], "is_submittable": 1, "links": [], - "modified": "2022-03-21 19:05:04.208222", + "modified": "2022-05-29 18:36:50.475964", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Transaction", @@ -248,4 +260,4 @@ "states": [], "title_field": "bank_account", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 9b36c93a0f3..15162376c15 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -1,9 +1,6 @@ # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - -from functools import reduce - import frappe from frappe.utils import flt @@ -18,72 +15,137 @@ class BankTransaction(StatusUpdater): self.clear_linked_payment_entries() self.set_status() + _saving_flag = False + + # nosemgrep: frappe-semgrep-rules.rules.frappe-modifying-but-not-comitting def on_update_after_submit(self): - self.update_allocations() - self.clear_linked_payment_entries() - self.set_status(update=True) + "Run on save(). Avoid recursion caused by multiple saves" + if not self._saving_flag: + self._saving_flag = True + self.clear_linked_payment_entries() + self.update_allocations() + self._saving_flag = False def on_cancel(self): self.clear_linked_payment_entries(for_cancel=True) self.set_status(update=True) def update_allocations(self): + "The doctype does not allow modifications after submission, so write to the db direct" if self.payment_entries: - allocated_amount = reduce( - lambda x, y: flt(x) + flt(y), [x.allocated_amount for x in self.payment_entries] - ) + allocated_amount = sum(p.allocated_amount for p in self.payment_entries) else: - allocated_amount = 0 + allocated_amount = 0.0 - if allocated_amount: - frappe.db.set_value(self.doctype, self.name, "allocated_amount", flt(allocated_amount)) - frappe.db.set_value( - self.doctype, - self.name, - "unallocated_amount", - abs(flt(self.withdrawal) - flt(self.deposit)) - flt(allocated_amount), - ) + amount = abs(flt(self.withdrawal) - flt(self.deposit)) + self.db_set("allocated_amount", flt(allocated_amount)) + self.db_set("unallocated_amount", amount - flt(allocated_amount)) + self.reload() + self.set_status(update=True) - else: - frappe.db.set_value(self.doctype, self.name, "allocated_amount", 0) - frappe.db.set_value( - self.doctype, self.name, "unallocated_amount", abs(flt(self.withdrawal) - flt(self.deposit)) - ) + def add_payment_entries(self, vouchers): + "Add the vouchers with zero allocation. Save() will perform the allocations and clearance" + if 0.0 >= self.unallocated_amount: + frappe.throw(frappe._(f"Bank Transaction {self.name} is already fully reconciled")) - amount = self.deposit or self.withdrawal - if amount == self.allocated_amount: - frappe.db.set_value(self.doctype, self.name, "status", "Reconciled") + added = False + for voucher in vouchers: + # Can't add same voucher twice + found = False + for pe in self.payment_entries: + if ( + pe.payment_document == voucher["payment_doctype"] + and pe.payment_entry == voucher["payment_name"] + ): + found = True + + if not found: + pe = { + "payment_document": voucher["payment_doctype"], + "payment_entry": voucher["payment_name"], + "allocated_amount": 0.0, # Temporary + } + child = self.append("payment_entries", pe) + added = True + + # runs on_update_after_submit + if added: + self.save() + + def allocate_payment_entries(self): + """Refactored from bank reconciliation tool. + Non-zero allocations must be amended/cleared manually + Get the bank transaction amount (b) and remove as we allocate + For each payment_entry if allocated_amount == 0: + - get the amount already allocated against all transactions (t), need latest date + - get the voucher amount (from gl) (v) + - allocate (a = v - t) + - a = 0: should already be cleared, so clear & remove payment_entry + - 0 < a <= u: allocate a & clear + - 0 < a, a > u: allocate u + - 0 > a: Error: already over-allocated + - clear means: set the latest transaction date as clearance date + """ + gl_bank_account = frappe.db.get_value("Bank Account", self.bank_account, "account") + remaining_amount = self.unallocated_amount + for payment_entry in self.payment_entries: + if payment_entry.allocated_amount == 0.0: + unallocated_amount, should_clear, latest_transaction = get_clearance_details( + self, payment_entry + ) + + if 0.0 == unallocated_amount: + if should_clear: + latest_transaction.clear_linked_payment_entry(payment_entry) + self.db_delete_payment_entry(payment_entry) + + elif remaining_amount <= 0.0: + self.db_delete_payment_entry(payment_entry) + + elif 0.0 < unallocated_amount and unallocated_amount <= remaining_amount: + payment_entry.db_set("allocated_amount", unallocated_amount) + remaining_amount -= unallocated_amount + if should_clear: + latest_transaction.clear_linked_payment_entry(payment_entry) + + elif 0.0 < unallocated_amount and unallocated_amount > remaining_amount: + payment_entry.db_set("allocated_amount", remaining_amount) + remaining_amount = 0.0 + + elif 0.0 > unallocated_amount: + self.db_delete_payment_entry(payment_entry) + frappe.throw( + frappe._(f"Voucher {payment_entry.payment_entry} is over-allocated by {unallocated_amount}") + ) self.reload() - def clear_linked_payment_entries(self, for_cancel=False): + def db_delete_payment_entry(self, payment_entry): + frappe.db.delete("Bank Transaction Payments", {"name": payment_entry.name}) + + @frappe.whitelist() + def remove_payment_entries(self): for payment_entry in self.payment_entries: - if payment_entry.payment_document == "Sales Invoice": - self.clear_sales_invoice(payment_entry, for_cancel=for_cancel) - elif payment_entry.payment_document in get_doctypes_for_bank_reconciliation(): - self.clear_simple_entry(payment_entry, for_cancel=for_cancel) + self.remove_payment_entry(payment_entry) + # runs on_update_after_submit + self.save() - def clear_simple_entry(self, payment_entry, for_cancel=False): - if payment_entry.payment_document == "Payment Entry": - if ( - frappe.db.get_value("Payment Entry", payment_entry.payment_entry, "payment_type") - == "Internal Transfer" - ): - if len(get_reconciled_bank_transactions(payment_entry)) < 2: - return + def remove_payment_entry(self, payment_entry): + "Clear payment entry and clearance" + self.clear_linked_payment_entry(payment_entry, for_cancel=True) + self.remove(payment_entry) - clearance_date = self.date if not for_cancel else None - frappe.db.set_value( - payment_entry.payment_document, payment_entry.payment_entry, "clearance_date", clearance_date - ) + def clear_linked_payment_entries(self, for_cancel=False): + if for_cancel: + for payment_entry in self.payment_entries: + self.clear_linked_payment_entry(payment_entry, for_cancel) + else: + self.allocate_payment_entries() - def clear_sales_invoice(self, payment_entry, for_cancel=False): - clearance_date = self.date if not for_cancel else None - frappe.db.set_value( - "Sales Invoice Payment", - dict(parenttype=payment_entry.payment_document, parent=payment_entry.payment_entry), - "clearance_date", - clearance_date, + def clear_linked_payment_entry(self, payment_entry, for_cancel=False): + clearance_date = None if for_cancel else self.date + set_voucher_clearance( + payment_entry.payment_document, payment_entry.payment_entry, clearance_date, self ) @@ -93,38 +155,112 @@ def get_doctypes_for_bank_reconciliation(): return frappe.get_hooks("bank_reconciliation_doctypes") -def get_reconciled_bank_transactions(payment_entry): - reconciled_bank_transactions = frappe.get_all( - "Bank Transaction Payments", - filters={"payment_entry": payment_entry.payment_entry}, - fields=["parent"], +def get_clearance_details(transaction, payment_entry): + """ + There should only be one bank gle for a voucher. + Could be none for a Bank Transaction. + But if a JE, could affect two banks. + Should only clear the voucher if all bank gles are allocated. + """ + gl_bank_account = frappe.db.get_value("Bank Account", transaction.bank_account, "account") + gles = get_related_bank_gl_entries(payment_entry.payment_document, payment_entry.payment_entry) + bt_allocations = get_total_allocated_amount( + payment_entry.payment_document, payment_entry.payment_entry ) - return reconciled_bank_transactions + unallocated_amount = min( + transaction.unallocated_amount, + get_paid_amount(payment_entry, transaction.currency, gl_bank_account), + ) + unmatched_gles = len(gles) + latest_transaction = transaction + for gle in gles: + if gle["gl_account"] == gl_bank_account: + if gle["amount"] <= 0.0: + frappe.throw( + frappe._(f"Voucher {payment_entry.payment_entry} value is broken: {gle['amount']}") + ) + + unmatched_gles -= 1 + unallocated_amount = gle["amount"] + for a in bt_allocations: + if a["gl_account"] == gle["gl_account"]: + unallocated_amount = gle["amount"] - a["total"] + if frappe.utils.getdate(transaction.date) < a["latest_date"]: + latest_transaction = frappe.get_doc("Bank Transaction", a["latest_name"]) + else: + # Must be a Journal Entry affecting more than one bank + for a in bt_allocations: + if a["gl_account"] == gle["gl_account"] and a["total"] == gle["amount"]: + unmatched_gles -= 1 + + return unallocated_amount, unmatched_gles == 0, latest_transaction -def get_total_allocated_amount(payment_entry): - return frappe.db.sql( +def get_related_bank_gl_entries(doctype, docname): + # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql + result = frappe.db.sql( """ SELECT - SUM(btp.allocated_amount) as allocated_amount, - bt.name + ABS(gle.credit_in_account_currency - gle.debit_in_account_currency) AS amount, + gle.account AS gl_account FROM - `tabBank Transaction Payments` as btp + `tabGL Entry` gle LEFT JOIN - `tabBank Transaction` bt ON bt.name=btp.parent + `tabAccount` ac ON ac.name=gle.account WHERE - btp.payment_document = %s - AND - btp.payment_entry = %s - AND - bt.docstatus = 1""", - (payment_entry.payment_document, payment_entry.payment_entry), + ac.account_type = 'Bank' + AND gle.voucher_type = %(doctype)s + AND gle.voucher_no = %(docname)s + AND is_cancelled = 0 + """, + dict(doctype=doctype, docname=docname), as_dict=True, ) + return result -def get_paid_amount(payment_entry, currency, bank_account): +def get_total_allocated_amount(doctype, docname): + """ + Gets the sum of allocations for a voucher on each bank GL account + along with the latest bank transaction name & date + NOTE: query may also include just saved vouchers/payments but with zero allocated_amount + """ + # nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql + result = frappe.db.sql( + """ + SELECT total, latest_name, latest_date, gl_account FROM ( + SELECT + ROW_NUMBER() OVER w AS rownum, + SUM(btp.allocated_amount) OVER(PARTITION BY ba.account) AS total, + FIRST_VALUE(bt.name) OVER w AS latest_name, + FIRST_VALUE(bt.date) OVER w AS latest_date, + ba.account AS gl_account + FROM + `tabBank Transaction Payments` btp + LEFT JOIN `tabBank Transaction` bt ON bt.name=btp.parent + LEFT JOIN `tabBank Account` ba ON ba.name=bt.bank_account + WHERE + btp.payment_document = %(doctype)s + AND btp.payment_entry = %(docname)s + AND bt.docstatus = 1 + WINDOW w AS (PARTITION BY ba.account ORDER BY bt.date desc) + ) temp + WHERE + rownum = 1 + """, + dict(doctype=doctype, docname=docname), + as_dict=True, + ) + for row in result: + # Why is this *sometimes* a byte string? + if isinstance(row["latest_name"], bytes): + row["latest_name"] = row["latest_name"].decode() + row["latest_date"] = frappe.utils.getdate(row["latest_date"]) + return result + + +def get_paid_amount(payment_entry, currency, gl_bank_account): if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]: paid_amount_field = "paid_amount" @@ -147,7 +283,7 @@ def get_paid_amount(payment_entry, currency, bank_account): elif payment_entry.payment_document == "Journal Entry": return frappe.db.get_value( "Journal Entry Account", - {"parent": payment_entry.payment_entry, "account": bank_account}, + {"parent": payment_entry.payment_entry, "account": gl_bank_account}, "sum(credit_in_account_currency)", ) @@ -166,6 +302,12 @@ def get_paid_amount(payment_entry, currency, bank_account): payment_entry.payment_document, payment_entry.payment_entry, "amount_paid" ) + elif payment_entry.payment_document == "Bank Transaction": + dep, wth = frappe.db.get_value( + "Bank Transaction", payment_entry.payment_entry, ("deposit", "withdrawal") + ) + return abs(flt(wth) - flt(dep)) + else: frappe.throw( "Please reconcile {0}: {1} manually".format( @@ -174,18 +316,55 @@ def get_paid_amount(payment_entry, currency, bank_account): ) -@frappe.whitelist() -def unclear_reference_payment(doctype, docname): - if frappe.db.exists(doctype, docname): - doc = frappe.get_doc(doctype, docname) - if doctype == "Sales Invoice": - frappe.db.set_value( - "Sales Invoice Payment", - dict(parenttype=doc.payment_document, parent=doc.payment_entry), - "clearance_date", - None, - ) - else: - frappe.db.set_value(doc.payment_document, doc.payment_entry, "clearance_date", None) +def set_voucher_clearance(doctype, docname, clearance_date, self): + if doctype in [ + "Payment Entry", + "Journal Entry", + "Purchase Invoice", + "Expense Claim", + "Loan Repayment", + "Loan Disbursement", + ]: + if ( + doctype == "Payment Entry" + and frappe.db.get_value("Payment Entry", docname, "payment_type") == "Internal Transfer" + and len(get_reconciled_bank_transactions(doctype, docname)) < 2 + ): + return + frappe.db.set_value(doctype, docname, "clearance_date", clearance_date) - return doc.payment_entry + elif doctype == "Sales Invoice": + frappe.db.set_value( + "Sales Invoice Payment", + dict(parenttype=doctype, parent=docname), + "clearance_date", + clearance_date, + ) + + elif doctype == "Bank Transaction": + # For when a second bank transaction has fixed another, e.g. refund + bt = frappe.get_doc(doctype, docname) + if clearance_date: + vouchers = [{"payment_doctype": "Bank Transaction", "payment_name": self.name}] + bt.add_payment_entries(vouchers) + else: + for pe in bt.payment_entries: + if pe.payment_document == self.doctype and pe.payment_entry == self.name: + bt.remove(pe) + bt.save() + break + + +def get_reconciled_bank_transactions(doctype, docname): + return frappe.get_all( + "Bank Transaction Payments", + filters={"payment_document": doctype, "payment_entry": docname}, + pluck="parent", + ) + + +@frappe.whitelist() +def unclear_reference_payment(doctype, docname, bt_name): + bt = frappe.get_doc("Bank Transaction", bt_name) + set_voucher_clearance(doctype, docname, None, bt) + return docname diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py index 38d69932f24..f44fad333cf 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py @@ -12,7 +12,7 @@ class PlaidConnector: def __init__(self, access_token=None): self.access_token = access_token self.settings = frappe.get_single("Plaid Settings") - self.products = ["auth", "transactions"] + self.products = ["transactions"] self.client_name = frappe.local.site self.client = plaid.Client( client_id=self.settings.plaid_client_id, diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js index 3740d049839..3ba6bb99873 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js @@ -47,7 +47,7 @@ erpnext.integrations.plaidLink = class plaidLink { } async init_config() { - this.product = ["auth", "transactions"]; + this.product = ["transactions"]; this.plaid_env = this.frm.doc.plaid_env; this.client_name = frappe.boot.sitename; this.token = await this.get_link_token(); diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py index 62ea85fc5d2..f3aa6a37935 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py @@ -70,7 +70,8 @@ def add_bank_accounts(response, bank, company): except TypeError: pass - bank = json.loads(bank) + if isinstance(bank, str): + bank = json.loads(bank) result = [] default_gl_account = get_default_bank_cash_account(company, "Bank") @@ -177,16 +178,15 @@ def sync_transactions(bank, bank_account): ) result = [] - for transaction in reversed(transactions): - result += new_bank_transaction(transaction) + if transactions: + for transaction in reversed(transactions): + result += new_bank_transaction(transaction) if result: last_transaction_date = frappe.db.get_value("Bank Transaction", result.pop(), "date") frappe.logger().info( - "Plaid added {} new Bank Transactions from '{}' between {} and {}".format( - len(result), bank_account, start_date, end_date - ) + f"Plaid added {len(result)} new Bank Transactions from '{bank_account}' between {start_date} and {end_date}" ) frappe.db.set_value( @@ -230,19 +230,20 @@ def new_bank_transaction(transaction): bank_account = frappe.db.get_value("Bank Account", dict(integration_id=transaction["account_id"])) - if float(transaction["amount"]) >= 0: - debit = 0 - credit = float(transaction["amount"]) + amount = float(transaction["amount"]) + if amount >= 0.0: + deposit = 0.0 + withdrawal = amount else: - debit = abs(float(transaction["amount"])) - credit = 0 + deposit = abs(amount) + withdrawal = 0.0 status = "Pending" if transaction["pending"] == "True" else "Settled" tags = [] try: tags += transaction["category"] - tags += ["Plaid Cat. {}".format(transaction["category_id"])] + tags += [f'Plaid Cat. {transaction["category_id"]}'] except KeyError: pass @@ -254,11 +255,18 @@ def new_bank_transaction(transaction): "date": getdate(transaction["date"]), "status": status, "bank_account": bank_account, - "deposit": debit, - "withdrawal": credit, + "deposit": deposit, + "withdrawal": withdrawal, "currency": transaction["iso_currency_code"], "transaction_id": transaction["transaction_id"], - "reference_number": transaction["payment_meta"]["reference_number"], + "transaction_type": ( + transaction["transaction_code"] or transaction["payment_meta"]["payment_method"] + ), + "reference_number": ( + transaction["check_number"] + or transaction["payment_meta"]["reference_number"] + or transaction["name"] + ), "description": transaction["name"], } ) @@ -271,7 +279,7 @@ def new_bank_transaction(transaction): result.append(new_transaction.name) except Exception: - frappe.throw(title=_("Bank transaction creation error")) + frappe.throw(_("Bank transaction creation error")) return result @@ -300,3 +308,26 @@ def enqueue_synchronization(): def get_link_token_for_update(access_token): plaid = PlaidConnector(access_token) return plaid.get_link_token(update_mode=True) + + +def get_company(bank_account_name): + from frappe.defaults import get_user_default + + company_names = frappe.db.get_all("Company", pluck="name") + if len(company_names) == 1: + return company_names[0] + if frappe.db.exists("Bank Account", bank_account_name): + return frappe.db.get_value("Bank Account", bank_account_name, "company") + company_default = get_user_default("Company") + if company_default: + return company_default + frappe.throw(_("Could not detect the Company for updating Bank Accounts")) + + +@frappe.whitelist() +def update_bank_account_ids(response): + data = json.loads(response) + institution_name = data["institution"]["name"] + bank = frappe.get_doc("Bank", institution_name).as_dict() + bank_account_name = f"{data['account']['name']} - {institution_name}" + return add_bank_accounts(response, bank, get_company(bank_account_name)) diff --git a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py index e8dc3e258f6..6d34a204cd2 100644 --- a/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py +++ b/erpnext/erpnext_integrations/doctype/plaid_settings/test_plaid_settings.py @@ -125,6 +125,8 @@ class TestPlaidSettings(unittest.TestCase): "unofficial_currency_code": None, "name": "INTRST PYMNT", "transaction_type": "place", + "transaction_code": "direct debit", + "check_number": "3456789", "amount": -4.22, "location": { "city": None, diff --git a/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js b/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js index f7c19a1b7ff..0cda93880fa 100644 --- a/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/data_table_manager.js @@ -182,6 +182,9 @@ erpnext.accounts.bank_reconciliation.DataTableManager = class DataTableManager { ); } else { this.transactions.splice(transaction_index, 1); + for (const [k, v] of Object.entries(this.transaction_dt_map)) { + if (v > transaction_index) this.transaction_dt_map[k] = v - 1; + } } this.datatable.refresh(this.transactions, this.columns); diff --git a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js index 911343d8b64..321b812de21 100644 --- a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js +++ b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js @@ -20,7 +20,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { doctype: "Bank Transaction", filters: { name: this.bank_transaction_name }, fieldname: [ - "date as reference_date", + "date", "deposit", "withdrawal", "currency", @@ -33,6 +33,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { "party", "unallocated_amount", "allocated_amount", + "transaction_type", ], }, callback: (r) => { @@ -41,11 +42,23 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { r.message.payment_entry = 1; r.message.journal_entry = 1; this.dialog.set_values(r.message); + this.copy_data_to_voucher(); this.dialog.show(); } }, }); } + + copy_data_to_voucher() { + let copied = { + reference_number: this.bank_transaction.reference_number || this.bank_transaction.description, + posting_date: this.bank_transaction.date, + reference_date: this.bank_transaction.date, + mode_of_payment: this.bank_transaction.transaction_type, + }; + this.dialog.set_values(copied); + } + get_linked_vouchers(document_types) { frappe.call({ method: @@ -75,10 +88,9 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { row[1], row[2], reference_date, - row[8], format_currency(row[3], row[9]), - row[6], row[4], + row[6], ]); }); this.get_dt_columns(); @@ -104,7 +116,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { { name: __("Document Name"), editable: false, - width: 150, + width: 1, }, { name: __("Reference Date"), @@ -112,25 +124,19 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { width: 120, }, { - name: "Posting Date", - editable: false, - width: 120, - }, - { - name: __("Amount"), + name: __("Remaining"), editable: false, width: 100, }, - { - name: __("Party"), - editable: false, - width: 120, - }, - { name: __("Reference Number"), editable: false, - width: 140, + width: 200, + }, + { + name: __("Party"), + editable: false, + width: 100, }, ]; } @@ -224,6 +230,16 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldname: "exact_match", onchange: () => this.update_options(), }, + { + fieldname: "column_break_5", + fieldtype: "Column Break", + }, + { + fieldtype: "Check", + label: "Bank Transaction", + fieldname: "bank_transaction", + onchange: () => this.update_options(), + }, { fieldtype: "Section Break", fieldname: "section_break_1", @@ -289,7 +305,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldtype: "Column Break", }, { - default: "Journal Entry Type", + default: "Bank Entry", fieldname: "journal_entry_type", fieldtype: "Select", label: "Journal Entry Type", @@ -364,7 +380,12 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { fieldtype: "Section Break", fieldname: "details_section", label: "Transaction Details", - collapsible: 1, + }, + { + fieldname: "date", + fieldtype: "Date", + label: "Date", + read_only: 1, }, { fieldname: "deposit", @@ -381,14 +402,14 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { read_only: 1, }, { - fieldname: "description", - fieldtype: "Small Text", - label: "Description", + fieldname: "column_break_17", + fieldtype: "Column Break", read_only: 1, }, { - fieldname: "column_break_17", - fieldtype: "Column Break", + fieldname: "description", + fieldtype: "Small Text", + label: "Description", read_only: 1, }, { @@ -398,7 +419,6 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { options: "Currency", read_only: 1, }, - { fieldname: "unallocated_amount", fieldtype: "Currency", @@ -593,4 +613,4 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager { } } -}; \ No newline at end of file +}; From daa1bb86e36f18f8a0d7fd9dcb696bc604ffdbb4 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 2 Mar 2023 16:40:37 +0530 Subject: [PATCH 06/31] fix: hide `+` button based on `Blanket Order Type` (cherry picked from commit abf9a28d6af8b3c9bfab1e892e56bf3adb18ee8e) --- .../manufacturing/doctype/blanket_order/blanket_order.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.js b/erpnext/manufacturing/doctype/blanket_order/blanket_order.js index d3bb33e86e0..7b26a14a57b 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.js +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.js @@ -7,6 +7,12 @@ frappe.ui.form.on('Blanket Order', { }, setup: function(frm) { + frm.custom_make_buttons = { + 'Purchase Order': 'Purchase Order', + 'Sales Order': 'Sales Order', + 'Quotation': 'Quotation', + }; + frm.add_fetch("customer", "customer_name", "customer_name"); frm.add_fetch("supplier", "supplier_name", "supplier_name"); }, From da915f15103d969c1c6ae2561ae517c68635cdd8 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 2 Mar 2023 17:04:09 +0530 Subject: [PATCH 07/31] feat: add field `Over Order Allowance (%)` in `Buying Settings` (cherry picked from commit f5937f46cb60f3521463f7a4c80c765f8a65e52b) --- .../doctype/buying_settings/buying_settings.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index 95857e4604d..8c73e56a99e 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -16,6 +16,7 @@ "transaction_settings_section", "po_required", "pr_required", + "over_order_allowance", "column_break_12", "maintain_same_rate", "set_landed_cost_based_on_purchase_invoice_rate", @@ -156,6 +157,13 @@ "fieldname": "set_landed_cost_based_on_purchase_invoice_rate", "fieldtype": "Check", "label": "Set Landed Cost Based on Purchase Invoice Rate" + }, + { + "default": "0", + "description": "Percentage you are allowed to order more against the Blanket Order Quantity. For example: If you have a Blanket Order of Quantity 100 units. and your Allowance is 10% then you are allowed to order 110 units.", + "fieldname": "over_order_allowance", + "fieldtype": "Float", + "label": "Over Order Allowance (%)" } ], "icon": "fa fa-cog", @@ -163,7 +171,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-02-28 15:41:32.686805", + "modified": "2023-03-02 17:02:14.404622", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", From 35297f6ac1ac6c5023700cac2c7e14367b737009 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 2 Mar 2023 17:18:46 +0530 Subject: [PATCH 08/31] refactor: rewrite `blanket_order.py` queries in `QB` (cherry picked from commit f3993783a3fc431a2909b445e9d09d9f584ff73e) --- .../doctype/blanket_order/blanket_order.py | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py index ff2140199de..3298f43ac36 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py @@ -6,6 +6,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc +from frappe.query_builder.functions import Sum from frappe.utils import flt, getdate from erpnext.stock.doctype.item.item import get_item_defaults @@ -29,21 +30,23 @@ class BlanketOrder(Document): def update_ordered_qty(self): ref_doctype = "Sales Order" if self.blanket_order_type == "Selling" else "Purchase Order" + + trans = frappe.qb.DocType(ref_doctype) + trans_item = frappe.qb.DocType(f"{ref_doctype} Item") + item_ordered_qty = frappe._dict( - frappe.db.sql( - """ - select trans_item.item_code, sum(trans_item.stock_qty) as qty - from `tab{0} Item` trans_item, `tab{0}` trans - where trans.name = trans_item.parent - and trans_item.blanket_order=%s - and trans.docstatus=1 - and trans.status not in ('Closed', 'Stopped') - group by trans_item.item_code - """.format( - ref_doctype - ), - self.name, - ) + ( + frappe.qb.from_(trans_item) + .from_(trans) + .select(trans_item.item_code, Sum(trans_item.stock_qty).as_("qty")) + .where( + (trans.name == trans_item.parent) + & (trans_item.blanket_order == self.name) + & (trans.docstatus == 1) + & (trans.status.notin(["Stopped", "Closed"])) + ) + .groupby(trans_item.item_code) + ).run() ) for d in self.items: From 7611a49db7a2785bada3f74aeb1bb330cb1e3c67 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 3 Mar 2023 10:49:25 +0530 Subject: [PATCH 09/31] fix: don't map item row having `0` qty (cherry picked from commit fc1088d9c4787b12bd9734597604492044eff4a0) --- erpnext/manufacturing/doctype/blanket_order/blanket_order.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py index 3298f43ac36..d03f019b084 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py @@ -82,6 +82,7 @@ def make_order(source_name): "doctype": doctype + " Item", "field_map": {"rate": "blanket_order_rate", "parent": "blanket_order"}, "postprocess": update_item, + "condition": lambda item: (flt(item.qty) - flt(item.ordered_qty)) > 0, }, }, ) From 932639b4df5747593667849695c01b953d02ef01 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 3 Mar 2023 11:11:33 +0530 Subject: [PATCH 10/31] feat: consider `over_order_allowance` while validating order qty (cherry picked from commit 8bcbc45add7767ac947fa7c9b3aaca99fc9dda9b) --- .../doctype/purchase_order/purchase_order.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 2415aec8cb9..d9ff9813225 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -69,6 +69,7 @@ class PurchaseOrder(BuyingController): self.validate_with_previous_doc() self.validate_for_subcontracting() self.validate_minimum_order_qty() + self.validate_against_blanket_order() if self.is_old_subcontracting_flow: self.validate_bom_for_subcontracting_items() @@ -197,6 +198,33 @@ class PurchaseOrder(BuyingController): ).format(item_code, qty, itemwise_min_order_qty.get(item_code)) ) + def validate_against_blanket_order(self): + po_data = {} + for item in self.get("items"): + if item.against_blanket_order and item.blanket_order: + if item.blanket_order in po_data: + if item.item_code in po_data[item.blanket_order]: + po_data[item.blanket_order][item.item_code] += item.qty + else: + po_data[item.blanket_order][item.item_code] = item.qty + else: + po_data[item.blanket_order] = {item.item_code: item.qty} + + if po_data: + allowance = flt(frappe.db.get_single_value("Buying Settings", "over_order_allowance")) + for bo_name, item_data in po_data.items(): + bo_doc = frappe.get_doc("Blanket Order", bo_name) + for item in bo_doc.get("items"): + if item.item_code in item_data: + remaining_qty = item.qty - item.ordered_qty + allowed_qty = remaining_qty + (remaining_qty * (allowance / 100)) + if allowed_qty < item_data[item.item_code]: + frappe.throw( + _( + f"Item {item.item_code} cannot be ordered more than {allowed_qty} against Blanket Order {bo_name}." + ) + ) + def validate_bom_for_subcontracting_items(self): for item in self.items: if not item.bom: From 46b5ba9c2aa9093115206cdf79831a86194447de Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 3 Mar 2023 11:19:28 +0530 Subject: [PATCH 11/31] feat: add field `Over Order Allowance (%)` in `Selling Settings` (cherry picked from commit d7da8928ac44df3a84f6099fc7bfbc9a9161be20) --- .../doctype/selling_settings/selling_settings.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index 6ea66a02378..45ad7d95a15 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -24,6 +24,7 @@ "so_required", "dn_required", "sales_update_frequency", + "over_order_allowance", "column_break_5", "allow_multiple_items", "allow_against_multiple_purchase_orders", @@ -179,6 +180,12 @@ "fieldname": "allow_sales_order_creation_for_expired_quotation", "fieldtype": "Check", "label": "Allow Sales Order Creation For Expired Quotation" + }, + { + "description": "Percentage you are allowed to order more against the Blanket Order Quantity. For example: If you have a Blanket Order of Quantity 100 units. and your Allowance is 10% then you are allowed to order 110 units.", + "fieldname": "over_order_allowance", + "fieldtype": "Float", + "label": "Over Order Allowance (%)" } ], "icon": "fa fa-cog", @@ -186,7 +193,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-02-04 12:37:53.380857", + "modified": "2023-03-03 11:16:54.333615", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", From 09b577a91fd32fdc3f974e8b7a3a85ad1f37d51d Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 3 Mar 2023 22:03:54 +0530 Subject: [PATCH 12/31] feat: consider `over_order_allowance` while validating sales order qty (cherry picked from commit 53701c37b18c7aecfaa00efabf4d3be768e59cb3) --- .../doctype/purchase_order/purchase_order.py | 32 +++-------------- .../doctype/blanket_order/blanket_order.py | 35 +++++++++++++++++++ .../doctype/sales_order/sales_order.py | 4 +++ 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index d9ff9813225..06b9d29e69c 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -21,6 +21,9 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category from erpnext.accounts.party import get_party_account, get_party_account_currency from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items from erpnext.controllers.buying_controller import BuyingController +from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( + validate_against_blanket_order, +) from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock.doctype.item.item import get_item_defaults, get_last_purchase_details from erpnext.stock.stock_balance import get_ordered_qty, update_bin_qty @@ -69,7 +72,7 @@ class PurchaseOrder(BuyingController): self.validate_with_previous_doc() self.validate_for_subcontracting() self.validate_minimum_order_qty() - self.validate_against_blanket_order() + validate_against_blanket_order(self) if self.is_old_subcontracting_flow: self.validate_bom_for_subcontracting_items() @@ -198,33 +201,6 @@ class PurchaseOrder(BuyingController): ).format(item_code, qty, itemwise_min_order_qty.get(item_code)) ) - def validate_against_blanket_order(self): - po_data = {} - for item in self.get("items"): - if item.against_blanket_order and item.blanket_order: - if item.blanket_order in po_data: - if item.item_code in po_data[item.blanket_order]: - po_data[item.blanket_order][item.item_code] += item.qty - else: - po_data[item.blanket_order][item.item_code] = item.qty - else: - po_data[item.blanket_order] = {item.item_code: item.qty} - - if po_data: - allowance = flt(frappe.db.get_single_value("Buying Settings", "over_order_allowance")) - for bo_name, item_data in po_data.items(): - bo_doc = frappe.get_doc("Blanket Order", bo_name) - for item in bo_doc.get("items"): - if item.item_code in item_data: - remaining_qty = item.qty - item.ordered_qty - allowed_qty = remaining_qty + (remaining_qty * (allowance / 100)) - if allowed_qty < item_data[item.item_code]: - frappe.throw( - _( - f"Item {item.item_code} cannot be ordered more than {allowed_qty} against Blanket Order {bo_name}." - ) - ) - def validate_bom_for_subcontracting_items(self): for item in self.items: if not item.bom: diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py index d03f019b084..32f1c365ade 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py @@ -87,3 +87,38 @@ def make_order(source_name): }, ) return target_doc + + +def validate_against_blanket_order(order_doc): + if order_doc.doctype in ("Sales Order", "Purchase Order"): + order_data = {} + + for item in order_doc.get("items"): + if item.against_blanket_order and item.blanket_order: + if item.blanket_order in order_data: + if item.item_code in order_data[item.blanket_order]: + order_data[item.blanket_order][item.item_code] += item.qty + else: + order_data[item.blanket_order][item.item_code] = item.qty + else: + order_data[item.blanket_order] = {item.item_code: item.qty} + + if order_data: + allowance = flt( + frappe.db.get_single_value( + "Selling Settings" if order_doc.doctype == "Sales Order" else "Buying Settings", + "over_order_allowance", + ) + ) + for bo_name, item_data in order_data.items(): + bo_doc = frappe.get_doc("Blanket Order", bo_name) + for item in bo_doc.get("items"): + if item.item_code in item_data: + remaining_qty = item.qty - item.ordered_qty + allowed_qty = remaining_qty + (remaining_qty * (allowance / 100)) + if allowed_qty < item_data[item.item_code]: + frappe.throw( + _("Item {0} cannot be ordered more than {1} against Blanket Order {2}.").format( + item.item_code, allowed_qty, bo_name + ) + ) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 385d0f3a585..ee9161bee48 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -21,6 +21,9 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( ) from erpnext.accounts.party import get_party_account from erpnext.controllers.selling_controller import SellingController +from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( + validate_against_blanket_order, +) from erpnext.manufacturing.doctype.production_plan.production_plan import ( get_items_for_material_requests, ) @@ -52,6 +55,7 @@ class SalesOrder(SellingController): self.validate_warehouse() self.validate_drop_ship() self.validate_serial_no_based_delivery() + validate_against_blanket_order(self) validate_inter_company_party( self.doctype, self.customer, self.company, self.inter_company_order_reference ) From c46e5a81d449c26a3d119a4656edabb2e28d644f Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 13 Mar 2023 17:21:07 +0530 Subject: [PATCH 13/31] test: add test cases for `Over Order Allowance` against `Blanket Order` (cherry picked from commit 66f650061dbae8c1093878f5b808e2a62f3a144a) --- .../blanket_order/test_blanket_order.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py index 2f1f3ae0f52..58f3c950598 100644 --- a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py @@ -63,6 +63,33 @@ class TestBlanketOrder(FrappeTestCase): po1.currency = get_company_currency(po1.company) self.assertEqual(po1.items[0].qty, (bo.items[0].qty - bo.items[0].ordered_qty)) + def test_over_order_allowance(self): + # Sales Order + bo = make_blanket_order(blanket_order_type="Selling", quantity=100) + + frappe.flags.args.doctype = "Sales Order" + so = make_order(bo.name) + so.currency = get_company_currency(so.company) + so.delivery_date = today() + so.items[0].qty = 110 + self.assertRaises(frappe.ValidationError, so.submit) + + frappe.db.set_single_value("Selling Settings", "over_order_allowance", 10) + so.submit() + + # Purchase Order + bo = make_blanket_order(blanket_order_type="Purchasing", quantity=100) + + frappe.flags.args.doctype = "Purchase Order" + po = make_order(bo.name) + po.currency = get_company_currency(po.company) + po.schedule_date = today() + po.items[0].qty = 110 + self.assertRaises(frappe.ValidationError, po.submit) + + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 10) + po.submit() + def make_blanket_order(**args): args = frappe._dict(args) From 9ab7bff0e0ed2ec100307ed7eb29b7c9b0984865 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 16 Mar 2023 09:54:06 +0530 Subject: [PATCH 14/31] fix: difference amount calculation for company currency accounts (cherry picked from commit 48fae0c1ce7b95bac8ba6e46516ac58a070e6003) --- .../payment_reconciliation.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index e3d9c26b2d1..c9e3998ac8a 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -221,12 +221,15 @@ class PaymentReconciliation(Document): def get_difference_amount(self, payment_entry, invoice, allocated_amount): difference_amount = 0 - if invoice.get("exchange_rate") and payment_entry.get("exchange_rate", 1) != invoice.get( - "exchange_rate", 1 - ): - allocated_amount_in_ref_rate = payment_entry.get("exchange_rate", 1) * allocated_amount - allocated_amount_in_inv_rate = invoice.get("exchange_rate", 1) * allocated_amount - difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate + if frappe.get_cached_value( + "Account", self.receivable_payable_account, "account_currency" + ) != frappe.get_cached_value("Company", self.company, "default_currency"): + if invoice.get("exchange_rate") and payment_entry.get("exchange_rate", 1) != invoice.get( + "exchange_rate", 1 + ): + allocated_amount_in_ref_rate = payment_entry.get("exchange_rate", 1) * allocated_amount + allocated_amount_in_inv_rate = invoice.get("exchange_rate", 1) * allocated_amount + difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate return difference_amount From e81ad864cf37c881c9f02fc5d8c528baa32e73c1 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 16 Mar 2023 10:35:06 +0530 Subject: [PATCH 15/31] test: difference amount should not be calculated for base currency (cherry picked from commit 861387f16447fc635e959a706090724a55840d4b) --- .../test_payment_reconciliation.py | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index f9dda0593b0..fca6caedd50 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -5,7 +5,7 @@ import unittest import frappe from frappe import qb -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, flt, nowdate from erpnext import get_default_cost_center @@ -824,6 +824,52 @@ class TestPaymentReconciliation(FrappeTestCase): payment_vouchers = [x.get("reference_name") for x in pr.get("payments")] self.assertCountEqual(payment_vouchers, [je2.name, pe2.name]) + @change_settings( + "Accounts Settings", + { + "allow_multi_currency_invoices_against_single_party_account": 1, + }, + ) + def test_no_difference_amount_for_base_currency_accounts(self): + # Make Sale Invoice + si = self.create_sales_invoice( + qty=1, rate=1, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + si.customer = self.customer + si.currency = "EUR" + si.conversion_rate = 85 + si.debit_to = self.debit_to + si.save().submit() + + # Make payment using Payment Entry + pe1 = create_payment_entry( + company=self.company, + payment_type="Receive", + party_type="Customer", + party=self.customer, + paid_from=self.debit_to, + paid_to=self.bank, + paid_amount=100, + ) + + pe1.save() + pe1.submit() + + pr = self.create_payment_reconciliation() + pr.party = self.customer + pr.receivable_payable_account = self.debit_to + pr.get_unreconciled_entries() + + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + + invoices = [x.as_dict() for x in pr.invoices] + payments = [pr.payments[0].as_dict()] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + self.assertEqual(pr.allocation[0].allocated_amount, 85) + self.assertEqual(pr.allocation[0].difference_amount, 0) + def make_customer(customer_name, currency=None): if not frappe.db.exists("Customer", customer_name): From c71b4ed6ec327c78186c082e307819145c57eefb Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 16 Mar 2023 11:12:44 +0530 Subject: [PATCH 16/31] refactor: difference amt validation for same currency accounts (cherry picked from commit ec075122b661b36763de0ecc6d5cb57895278871) --- .../test_payment_reconciliation.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index fca6caedd50..3be11ae31a7 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -349,6 +349,11 @@ class TestPaymentReconciliation(FrappeTestCase): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Difference amount should not be calculated for base currency accounts + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() si.reload() @@ -390,6 +395,11 @@ class TestPaymentReconciliation(FrappeTestCase): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Difference amount should not be calculated for base currency accounts + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() # check PR tool output @@ -414,6 +424,11 @@ class TestPaymentReconciliation(FrappeTestCase): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Difference amount should not be calculated for base currency accounts + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() # assert outstanding @@ -450,6 +465,11 @@ class TestPaymentReconciliation(FrappeTestCase): invoices = [x.as_dict() for x in pr.get("invoices")] payments = [x.as_dict() for x in pr.get("payments")] pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Difference amount should not be calculated for base currency accounts + for row in pr.allocation: + self.assertEqual(flt(row.get("difference_amount")), 0.0) + pr.reconcile() self.assertEqual(pr.get("invoices"), []) From 560407493518b810f1b39c4793b57cbba6dbf93f Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Tue, 14 Mar 2023 14:12:39 +0530 Subject: [PATCH 17/31] chore: `Allow Zero Valuation Rate` msg in SE (cherry picked from commit 22ad9a1903b8cd51829b68df02d05708054871cd) --- .../stock/doctype/stock_entry/stock_entry.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e263a278bef..7e39cb92f70 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -661,6 +661,7 @@ class StockEntry(StockController): ) finished_item_qty = sum(d.transfer_qty for d in self.items if d.is_finished_item) + items = [] # Set basic rate for incoming items for d in self.get("items"): if d.s_warehouse or d.set_basic_rate_manually: @@ -668,12 +669,7 @@ class StockEntry(StockController): if d.allow_zero_valuation_rate: d.basic_rate = 0.0 - frappe.msgprint( - _( - "Row {0}: Item rate has been updated to zero as Allow Zero Valuation Rate is checked for item {1}" - ).format(d.idx, d.item_code), - alert=1, - ) + items.append(d.item_code) elif d.is_finished_item: if self.purpose == "Manufacture": @@ -700,6 +696,20 @@ class StockEntry(StockController): d.basic_rate = flt(d.basic_rate) d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) + if items: + message = "" + + if len(items) > 1: + message = _( + "Items rate has been updated to zero as Allow Zero Valuation Rate is checked for the following items: {0}" + ).format(", ".join(frappe.bold(item) for item in items)) + else: + message = _( + "Item rate has been updated to zero as Allow Zero Valuation Rate is checked for item {0}" + ).format(frappe.bold(items[0])) + + frappe.msgprint(message, alert=True) + def set_rate_for_outgoing_items(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): outgoing_items_cost = 0.0 for d in self.get("items"): From 55d002c6364622b40a47d5c8e009d99f097174c7 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 19 Mar 2023 18:05:33 +0530 Subject: [PATCH 18/31] fix: Update account number from parent company (#34474) fix: Update account number from parent company (#34474) (cherry picked from commit d8ece86463084a750c1395297a9d1b48d70ee774) Co-authored-by: Deepesh Garg --- erpnext/accounts/doctype/account/account.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index 9dff1168fde..6635d3e733b 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -393,7 +393,13 @@ def update_account_number(name, account_name, account_number=None, from_descenda if ancestors and not allow_independent_account_creation: for ancestor in ancestors: - if frappe.db.get_value("Account", {"account_name": old_acc_name, "company": ancestor}, "name"): + old_name = frappe.db.get_value( + "Account", + {"account_number": old_acc_number, "account_name": old_acc_name, "company": ancestor}, + "name", + ) + + if old_name: # same account in parent company exists allow_child_account_creation = _("Allow Account Creation Against Child Company") From f146479362d3a2b46d720d635db71c37058e1a41 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 19 Mar 2023 18:06:00 +0530 Subject: [PATCH 19/31] fix: Multiple accounting dimension filtering in AR/AP reports (#34464) fix: Multiple accounting dimension filtering in AR/AP reports (#34464) Co-authored-by: Anand Baburajan (cherry picked from commit 7b630217bde9e7a54f4a26f44e1b549218aaf275) Co-authored-by: Deepesh Garg --- .../accounts/report/accounts_receivable/accounts_receivable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 94a1510f095..11de9a098dc 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -859,7 +859,7 @@ class ReceivablePayableReport(object): ) else: self.qb_selection_filter.append( - self.ple[dimension.fieldname] == self.filters[dimension.fieldname] + self.ple[dimension.fieldname].isin(self.filters[dimension.fieldname]) ) def is_invoice(self, ple): From de5fabc67a352b701bb6918efe1ec8a396074610 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 19 Mar 2023 18:06:45 +0530 Subject: [PATCH 20/31] chore: Update user manual link (#34478) * chore: Update user manual link (#34478) (cherry picked from commit be723bb9d483c615fa0b14b0115338e39e32a698) # Conflicts: # erpnext/patches.txt * chore: resolve conflicts --------- Co-authored-by: Deepesh Garg --- erpnext/patches.txt | 1 + erpnext/patches/v13_0/update_docs_link.py | 14 ++++++++++++++ erpnext/setup/install.py | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 erpnext/patches/v13_0/update_docs_link.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 3c0eb5107ce..5803f46dea3 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -325,5 +325,6 @@ erpnext.patches.v14_0.update_entry_type_for_journal_entry erpnext.patches.v14_0.change_autoname_for_tax_withheld_vouchers erpnext.patches.v14_0.update_asset_value_for_manual_depr_entries erpnext.patches.v14_0.set_pick_list_status +erpnext.patches.v13_0.update_docs_link # below migration patches should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger diff --git a/erpnext/patches/v13_0/update_docs_link.py b/erpnext/patches/v13_0/update_docs_link.py new file mode 100644 index 00000000000..4bc5c053d27 --- /dev/null +++ b/erpnext/patches/v13_0/update_docs_link.py @@ -0,0 +1,14 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + + +import frappe + + +def execute(): + navbar_settings = frappe.get_single("Navbar Settings") + for item in navbar_settings.help_dropdown: + if item.is_standard and item.route == "https://erpnext.com/docs/user/manual": + item.route = "https://docs.erpnext.com/docs/v14/user/manual/en/introduction" + + navbar_settings.save() diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 1f7dddfb95b..088958d1b26 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -155,7 +155,7 @@ def add_standard_navbar_items(): { "item_label": "Documentation", "item_type": "Route", - "route": "https://erpnext.com/docs/user/manual", + "route": "https://docs.erpnext.com/docs/v14/user/manual/en/introduction", "is_standard": 1, }, { From 4acde4468fa6574dfeb6ca786fe4b8796a5969eb Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 20 Mar 2023 14:27:34 +0530 Subject: [PATCH 21/31] fix: patch depends on Currency Exchange Settings (#34494) fix: patch depends on Currency Exchange Settings (#34494) (cherry picked from commit d791dc11a3eea4445e2e772125260923c43c3f9e) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- erpnext/patches/v14_0/update_opportunity_currency_fields.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/patches/v14_0/update_opportunity_currency_fields.py b/erpnext/patches/v14_0/update_opportunity_currency_fields.py index b803e9fa2dd..af736919d83 100644 --- a/erpnext/patches/v14_0/update_opportunity_currency_fields.py +++ b/erpnext/patches/v14_0/update_opportunity_currency_fields.py @@ -7,6 +7,9 @@ from erpnext.setup.utils import get_exchange_rate def execute(): + frappe.reload_doc( + "accounts", "doctype", "currency_exchange_settings" + ) # get_exchange_rate depends on Currency Exchange Settings frappe.reload_doctype("Opportunity") opportunities = frappe.db.get_list( "Opportunity", From baa789be347d14420b66498cd978c5a8380d6d2a Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 20 Mar 2023 14:48:46 +0530 Subject: [PATCH 22/31] perf: index against_sales_invoice field on DN items (backport #34509) (#34510) perf: index against_sales_invoice field on DN items (#34509) This is used on Sales invoice dashboard and takes a lot of time to load as db size increases. Results: Before: ~10-20 seconds to load dashboard After: few milliseconds because of index [skip ci] (cherry picked from commit 109a9f1390306740fa91f05ca458f5f1a968d6b8) Co-authored-by: Ankush Menat --- .../stock/doctype/delivery_note_item/delivery_note_item.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index 916ab2a05be..1763269193a 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -636,7 +636,8 @@ "no_copy": 1, "options": "Sales Invoice", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "so_detail", @@ -837,7 +838,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-11-09 12:17:50.850142", + "modified": "2023-03-20 14:24:10.406746", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", From 848e56bd4ca07ecbb1ea64dd226680c96a17a29c Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Sun, 5 Feb 2023 13:09:34 +0530 Subject: [PATCH 23/31] fix: Overallocation of 'qty' from Cr Notes to Parent Invoice Cr Notes 'qty' are overallocated to parent invoice, when there are mulitple instances of same item in Invoice. --- erpnext/accounts/report/gross_profit/gross_profit.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index e4b4f2260c6..a86c9c22b46 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -501,7 +501,14 @@ class GrossProfitGenerator(object): ): returned_item_rows = self.returned_invoices[row.parent][row.item_code] for returned_item_row in returned_item_rows: - row.qty += flt(returned_item_row.qty) + # returned_items 'qty' should be stateful + if returned_item_row.qty != 0: + if row.qty >= abs(returned_item_row.qty): + row.qty += returned_item_row.qty + returned_item_row.qty = 0 + else: + row.qty = 0 + returned_item_row.qty += row.qty row.base_amount += flt(returned_item_row.base_amount, self.currency_precision) row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision) if flt(row.qty) or row.base_amount: From e0e89b4209b558fee62e34868d3fe17e80e6724b Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 14 Mar 2023 16:22:49 +0530 Subject: [PATCH 24/31] refactor: Ignore linked Cr Notes in Report output --- erpnext/accounts/report/gross_profit/gross_profit.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index a86c9c22b46..41ba11adc6f 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -741,6 +741,8 @@ class GrossProfitGenerator(object): if self.filters.to_date: conditions += " and posting_date <= %(to_date)s" + conditions += " and (is_return = 0 or (is_return=1 and return_against is null))" + if self.filters.item_group: conditions += " and {0}".format(get_item_group_condition(self.filters.item_group)) From aead554d3124abc7ecf8a0bd2fe2f202f0635c98 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Sun, 5 Feb 2023 14:48:29 +0530 Subject: [PATCH 25/31] test: Gross Profit report output for Cr notes 2 New test cases added. 1. Standalone Cr notes will be reported as normal Invoices 2. Cr notes against an Invoice will not overallocate qty if there are multiple instances of same item --- .../report/gross_profit/test_gross_profit.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index 21681bef5b5..82fe1a0ba12 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -381,3 +381,82 @@ class TestGrossProfit(FrappeTestCase): } gp_entry = [x for x in data if x.parent_invoice == sinv.name] self.assertDictContainsSubset(expected_entry, gp_entry[0]) + + def test_crnote_against_invoice_with_multiple_instances_of_same_item(self): + """ + Item Qty for Sales Invoices with multiple instances of same item go in the -ve. Ideally, the credit noteshould cancel out the invoice items. + """ + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + + # Invoice with an item added twice + sinv = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate(), do_not_submit=True) + sinv.append("items", frappe.copy_doc(sinv.items[0], ignore_no_copy=False)) + sinv = sinv.save().submit() + + # Create Credit Note for Invoice + cr_note = make_sales_return(sinv.name) + cr_note = cr_note.save().submit() + + filters = frappe._dict( + company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice" + ) + + columns, data = execute(filters=filters) + expected_entry = { + "parent_invoice": sinv.name, + "currency": "INR", + "sales_invoice": self.item, + "customer": self.customer, + "posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()), + "item_code": self.item, + "item_name": self.item, + "warehouse": "Stores - _GP", + "qty": 0.0, + "avg._selling_rate": 0.0, + "valuation_rate": 0.0, + "selling_amount": -100.0, + "buying_amount": 0.0, + "gross_profit": -100.0, + "gross_profit_%": 100.0, + } + gp_entry = [x for x in data if x.parent_invoice == sinv.name] + # Both items of Invoice should have '0' qty + self.assertEqual(len(gp_entry), 2) + self.assertDictContainsSubset(expected_entry, gp_entry[0]) + self.assertDictContainsSubset(expected_entry, gp_entry[1]) + + def test_standalone_cr_notes(self): + """ + Standalone cr notes will be reported as usual + """ + # Make Cr Note + sinv = self.create_sales_invoice( + qty=-1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + sinv.is_return = 1 + sinv = sinv.save().submit() + + filters = frappe._dict( + company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice" + ) + + columns, data = execute(filters=filters) + expected_entry = { + "parent_invoice": sinv.name, + "currency": "INR", + "sales_invoice": self.item, + "customer": self.customer, + "posting_date": frappe.utils.datetime.date.fromisoformat(nowdate()), + "item_code": self.item, + "item_name": self.item, + "warehouse": "Stores - _GP", + "qty": -1.0, + "avg._selling_rate": 100.0, + "valuation_rate": 0.0, + "selling_amount": -100.0, + "buying_amount": 0.0, + "gross_profit": -100.0, + "gross_profit_%": 100.0, + } + gp_entry = [x for x in data if x.parent_invoice == sinv.name] + self.assertDictContainsSubset(expected_entry, gp_entry[0]) From 560df6330a1b43648a91e3f1514d8ff5dc382c15 Mon Sep 17 00:00:00 2001 From: Anand Baburajan Date: Tue, 21 Mar 2023 14:31:23 +0530 Subject: [PATCH 26/31] fix: incorrect depr schedules after asset repair [v14] (#34527) * fix: backport missing changes from #30838 * fix: incorrect schedule after repair --- erpnext/assets/doctype/asset/asset.py | 37 +++++--- .../doctype/asset_repair/asset_repair.py | 90 ++++++++++++------- 2 files changed, 83 insertions(+), 44 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 9db40658506..ee73729da0a 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -375,12 +375,19 @@ class Asset(AccountsController): value_after_depreciation -= flt(depreciation_amount, self.precision("gross_purchase_amount")) # Adjust depreciation amount in the last period based on the expected value after useful life - if finance_book.expected_value_after_useful_life and ( - ( - n == cint(number_of_pending_depreciations) - 1 - and value_after_depreciation != finance_book.expected_value_after_useful_life + if ( + finance_book.expected_value_after_useful_life + and ( + ( + n == cint(number_of_pending_depreciations) - 1 + and value_after_depreciation != finance_book.expected_value_after_useful_life + ) + or value_after_depreciation < finance_book.expected_value_after_useful_life + ) + and ( + not self.flags.increase_in_asset_value_due_to_repair + or not finance_book.depreciation_method in ("Written Down Value", "Double Declining Balance") ) - or value_after_depreciation < finance_book.expected_value_after_useful_life ): depreciation_amount += value_after_depreciation - finance_book.expected_value_after_useful_life skip_row = True @@ -1175,17 +1182,21 @@ def get_total_days(date, frequency): @erpnext.allow_regional def get_depreciation_amount(asset, depreciable_value, row): if row.depreciation_method in ("Straight Line", "Manual"): - # if the Depreciation Schedule is being prepared for the first time - if not asset.flags.increase_in_asset_life: - depreciation_amount = ( - flt(asset.gross_purchase_amount) - flt(row.expected_value_after_useful_life) - ) / flt(row.total_number_of_depreciations) - - # if the Depreciation Schedule is being modified after Asset Repair - else: + # if the Depreciation Schedule is being modified after Asset Repair due to increase in asset life and value + if asset.flags.increase_in_asset_life: depreciation_amount = ( flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life) ) / (date_diff(asset.to_date, asset.available_for_use_date) / 365) + # if the Depreciation Schedule is being modified after Asset Repair due to increase in asset value + elif asset.flags.increase_in_asset_value_due_to_repair: + depreciation_amount = ( + flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life) + ) / flt(row.total_number_of_depreciations) + # if the Depreciation Schedule is being prepared for the first time + else: + depreciation_amount = ( + flt(asset.gross_purchase_amount) - flt(row.expected_value_after_useful_life) + ) / flt(row.total_number_of_depreciations) else: depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100)) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 9ccf778a4bb..eec7ccba754 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -39,43 +39,51 @@ class AssetRepair(AccountsController): def before_submit(self): self.check_repair_status() - if self.get("stock_consumption") or self.get("capitalize_repair_cost"): - self.increase_asset_value() - if self.get("stock_consumption"): - self.check_for_stock_items_and_warehouse() - self.decrease_stock_quantity() - if self.get("capitalize_repair_cost"): - self.make_gl_entries() - if ( - frappe.db.get_value("Asset", self.asset, "calculate_depreciation") - and self.increase_in_asset_life - ): - self.modify_depreciation_schedule() + self.asset_doc.flags.increase_in_asset_value_due_to_repair = False - self.asset_doc.flags.ignore_validate_update_after_submit = True - self.asset_doc.prepare_depreciation_data() - self.asset_doc.save() + if self.get("stock_consumption") or self.get("capitalize_repair_cost"): + self.asset_doc.flags.increase_in_asset_value_due_to_repair = True + + self.increase_asset_value() + + if self.get("stock_consumption"): + self.check_for_stock_items_and_warehouse() + self.decrease_stock_quantity() + if self.get("capitalize_repair_cost"): + self.make_gl_entries() + if self.asset_doc.calculate_depreciation and self.increase_in_asset_life: + self.modify_depreciation_schedule() + + self.asset_doc.flags.ignore_validate_update_after_submit = True + self.asset_doc.prepare_depreciation_data() + if self.asset_doc.calculate_depreciation: + self.update_asset_expected_value_after_useful_life() + self.asset_doc.save() def before_cancel(self): self.asset_doc = frappe.get_doc("Asset", self.asset) - if self.get("stock_consumption") or self.get("capitalize_repair_cost"): - self.decrease_asset_value() - if self.get("stock_consumption"): - self.increase_stock_quantity() - if self.get("capitalize_repair_cost"): - self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") - self.make_gl_entries(cancel=True) - self.db_set("stock_entry", None) - if ( - frappe.db.get_value("Asset", self.asset, "calculate_depreciation") - and self.increase_in_asset_life - ): - self.revert_depreciation_schedule_on_cancellation() + self.asset_doc.flags.increase_in_asset_value_due_to_repair = False - self.asset_doc.flags.ignore_validate_update_after_submit = True - self.asset_doc.prepare_depreciation_data() - self.asset_doc.save() + if self.get("stock_consumption") or self.get("capitalize_repair_cost"): + self.asset_doc.flags.increase_in_asset_value_due_to_repair = True + + self.decrease_asset_value() + + if self.get("stock_consumption"): + self.increase_stock_quantity() + if self.get("capitalize_repair_cost"): + self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") + self.make_gl_entries(cancel=True) + self.db_set("stock_entry", None) + if self.asset_doc.calculate_depreciation and self.increase_in_asset_life: + self.revert_depreciation_schedule_on_cancellation() + + self.asset_doc.flags.ignore_validate_update_after_submit = True + self.asset_doc.prepare_depreciation_data() + if self.asset_doc.calculate_depreciation: + self.update_asset_expected_value_after_useful_life() + self.asset_doc.save() def after_delete(self): frappe.get_doc("Asset", self.asset).set_status() @@ -95,6 +103,26 @@ class AssetRepair(AccountsController): title=_("Missing Warehouse"), ) + def update_asset_expected_value_after_useful_life(self): + for row in self.asset_doc.get("finance_books"): + if row.depreciation_method in ("Written Down Value", "Double Declining Balance"): + accumulated_depreciation_after_full_schedule = [ + d.accumulated_depreciation_amount + for d in self.asset_doc.get("schedules") + if cint(d.finance_book_id) == row.idx + ] + + accumulated_depreciation_after_full_schedule = max( + accumulated_depreciation_after_full_schedule + ) + + asset_value_after_full_schedule = flt( + flt(row.value_after_depreciation) - flt(accumulated_depreciation_after_full_schedule), + row.precision("expected_value_after_useful_life"), + ) + + row.expected_value_after_useful_life = asset_value_after_full_schedule + def increase_asset_value(self): total_value_of_stock_consumed = self.get_total_value_of_stock_consumed() From 53c3fff2353c8c7363dd55421d7c43350ae3af07 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 21 Mar 2023 14:15:31 +0530 Subject: [PATCH 27/31] fix: E-commerce issue with Item Variants (cherry picked from commit aaa4d1eb5582028fcf1e46de0fa1a176311e5562) --- erpnext/e_commerce/variant_selector/utils.py | 24 ++++++++++++++++++- .../generators/item/item_configure.js | 22 ++++++++++++----- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/erpnext/e_commerce/variant_selector/utils.py b/erpnext/e_commerce/variant_selector/utils.py index df62c23aa48..1a3e7379281 100644 --- a/erpnext/e_commerce/variant_selector/utils.py +++ b/erpnext/e_commerce/variant_selector/utils.py @@ -1,5 +1,5 @@ import frappe -from frappe.utils import cint +from frappe.utils import cint, flt from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import ( get_shopping_cart_settings, @@ -166,6 +166,27 @@ def get_next_attribute_and_values(item_code, selected_attributes): else: product_info = None + product_id = "" + website_warehouse = "" + if exact_match or filtered_items: + if exact_match and len(exact_match) == 1: + product_id = exact_match[0] + elif filtered_items_count == 1: + product_id = list(filtered_items)[0] + + if product_id: + website_warehouse = frappe.get_cached_value( + "Website Item", {"item_code": product_id}, "website_warehouse" + ) + + available_qty = 0.0 + if website_warehouse: + available_qty = flt( + frappe.db.get_value( + "Bin", {"item_code": product_id, "warehouse": website_warehouse}, "actual_qty" + ) + ) + return { "next_attribute": next_attribute, "valid_options_for_attributes": valid_options_for_attributes, @@ -173,6 +194,7 @@ def get_next_attribute_and_values(item_code, selected_attributes): "filtered_items": filtered_items if filtered_items_count < 10 else [], "exact_match": exact_match, "product_info": product_info, + "available_qty": available_qty, } diff --git a/erpnext/templates/generators/item/item_configure.js b/erpnext/templates/generators/item/item_configure.js index 231ae0587ed..613c967e3d6 100644 --- a/erpnext/templates/generators/item/item_configure.js +++ b/erpnext/templates/generators/item/item_configure.js @@ -186,14 +186,14 @@ class ItemConfigure { this.dialog.$status_area.empty(); } - get_html_for_item_found({ filtered_items_count, filtered_items, exact_match, product_info }) { + get_html_for_item_found({ filtered_items_count, filtered_items, exact_match, product_info, available_qty, settings }) { const one_item = exact_match.length === 1 ? exact_match[0] : filtered_items_count === 1 ? filtered_items[0] : ''; - const item_add_to_cart = one_item ? ` + let item_add_to_cart = one_item ? `