From 8005fee6569abed607c57d31b26531925fd7e15b Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Tue, 22 Feb 2022 15:06:19 +0530 Subject: [PATCH 01/28] feat: update ordered qty for packed items --- .../doctype/purchase_order/purchase_order.py | 10 +++++++++ .../purchase_order_item.json | 13 ++++++++++- .../doctype/sales_order/sales_order.js | 22 ++++++++++++++++++- .../doctype/sales_order/sales_order.py | 5 +++++ .../doctype/packed_item/packed_item.json | 11 +++++++++- 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 1b5f35efbb..2e7d3063cc 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -316,6 +316,16 @@ class PurchaseOrder(BuyingController): 'target_ref_field': 'stock_qty', 'source_field': 'stock_qty' }) + self.status_updater.append({ + 'source_dt': 'Purchase Order Item', + 'target_dt': 'Packed Item', + 'target_field': 'ordered_qty', + 'target_parent_dt': 'Sales Order', + 'target_parent_field': '', + 'join_field': 'sales_order_packed_item', + 'target_ref_field': 'qty', + 'source_field': 'stock_qty' + }) def update_delivered_qty_in_sales_order(self): """Update delivered qty in Sales Order for drop ship""" diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 87cd57517e..c26d592e3e 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -63,6 +63,7 @@ "material_request_item", "sales_order", "sales_order_item", + "sales_order_packed_item", "supplier_quotation", "supplier_quotation_item", "col_break5", @@ -837,21 +838,31 @@ "label": "Product Bundle", "options": "Product Bundle", "read_only": 1 + }, + { + "fieldname": "sales_order_packed_item", + "fieldtype": "Data", + "label": "Sales Order Packed Item", + "no_copy": 1, + "print_hide": 1, + "search_index": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-08-30 20:06:26.712097", + "modified": "2022-02-02 13:10:18.398976", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", + "naming_rule": "Random", "owner": "Administrator", "permissions": [], "quick_entry": 1, "search_fields": "item_name", "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 eb98e6c0bf..f80eaf2757 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -562,6 +562,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex var me = this; var dialog = new frappe.ui.Dialog({ title: __("Select Items"), + size: "large", fields: [ { "fieldtype": "Check", @@ -663,7 +664,8 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } else { let po_items = []; me.frm.doc.items.forEach(d => { - let pending_qty = (flt(d.stock_qty) - flt(d.ordered_qty)) / flt(d.conversion_factor); + let ordered_qty = me.get_ordered_qty(d, me.frm.doc); + let pending_qty = (flt(d.stock_qty) - ordered_qty) / flt(d.conversion_factor); if (pending_qty > 0) { po_items.push({ "doctype": "Sales Order Item", @@ -689,6 +691,24 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex dialog.show(); } + get_ordered_qty(item, so) { + let ordered_qty = item.ordered_qty; + if (so.packed_items) { + // calculate ordered qty based on packed items in case of product bundle + let packed_items = so.packed_items.filter( + (pi) => pi.parent_detail_docname == item.name + ); + if (packed_items) { + ordered_qty = packed_items.reduce( + (sum, pi) => sum + flt(pi.ordered_qty), + 0 + ); + ordered_qty = ordered_qty / packed_items.length; + } + } + return ordered_qty; + } + hold_sales_order(){ var me = this; var d = new frappe.ui.Dialog({ diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 0f5b1e3b89..abbb3c9b90 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -877,6 +877,9 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): target.stock_qty = (flt(source.stock_qty) - flt(source.ordered_qty)) target.project = source_parent.project + def update_item_for_packed_item(source, target, source_parent): + target.qty = flt(source.qty) - flt(source.ordered_qty) + # po = frappe.get_list("Purchase Order", filters={"sales_order":source_name, "supplier":supplier, "docstatus": ("<", "2")}) doc = get_mapped_doc("Sales Order", source_name, { "Sales Order": { @@ -920,6 +923,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): "Packed Item": { "doctype": "Purchase Order Item", "field_map": [ + ["name", "sales_order_packed_item"], ["parent", "sales_order"], ["uom", "uom"], ["conversion_factor", "conversion_factor"], @@ -934,6 +938,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): "supplier", "pricing_rules" ], + "postprocess": update_item_for_packed_item, "condition": lambda doc: doc.parent_item in items_to_map } }, target_doc, set_missing_values) diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index d2d4789765..d6e2e9ce2d 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -26,6 +26,7 @@ "section_break_13", "actual_qty", "projected_qty", + "ordered_qty", "column_break_16", "incoming_rate", "page_break", @@ -224,13 +225,21 @@ "label": "Rate", "print_hide": 1, "read_only": 1 + }, + { + "allow_on_submit": 1, + "fieldname": "ordered_qty", + "fieldtype": "Float", + "label": "Ordered Qty", + "no_copy": 1, + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-01-28 16:03:30.780111", + "modified": "2022-02-22 12:57:45.325488", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", From 8e3f1e306d705109a51271ba262b46fe4798a793 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Tue, 22 Feb 2022 15:34:26 +0530 Subject: [PATCH 02/28] test: po updates packed item's ordered_qty --- .../doctype/sales_order/test_sales_order.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 73c5bd299a..e56e56cea1 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -959,6 +959,42 @@ class TestSalesOrder(ERPNextTestCase): self.assertEqual(purchase_order.items[0].item_code, "_Test Bundle Item 1") self.assertEqual(purchase_order.items[1].item_code, "_Test Bundle Item 2") + def test_purchase_order_updates_packed_item_ordered_qty(self): + """ + Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order + """ + from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order + + product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) + make_item("_Test Bundle Item 1", {"is_stock_item": 1}) + make_item("_Test Bundle Item 2", {"is_stock_item": 1}) + + make_product_bundle("_Test Product Bundle", + ["_Test Bundle Item 1", "_Test Bundle Item 2"]) + + so_items = [ + { + "item_code": product_bundle.item_code, + "warehouse": "", + "qty": 2, + "rate": 400, + "delivered_by_supplier": 1, + "supplier": '_Test Supplier' + } + ] + + so = make_sales_order(item_list=so_items) + + purchase_order = make_purchase_order(so.name, selected_items=so_items) + purchase_order.supplier = "_Test Supplier" + purchase_order.set_warehouse = "_Test Warehouse - _TC" + purchase_order.save() + purchase_order.submit() + + so.reload() + self.assertEqual(so.packed_items[0].ordered_qty, 2) + self.assertEqual(so.packed_items[1].ordered_qty, 2) + def test_reserved_qty_for_closing_so(self): bin = frappe.get_all("Bin", filters={"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC"}, fields=["reserved_qty"]) From 3a547cb0d965b8012136d06adc9d7c7b94700660 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 22 Feb 2022 16:25:32 +0530 Subject: [PATCH 03/28] fix: Item discounts for quotation --- erpnext/controllers/taxes_and_totals.py | 2 +- erpnext/selling/doctype/quotation/quotation.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 2776628227..52190765c9 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -116,7 +116,7 @@ class calculate_taxes_and_totals(object): if item.discount_percentage == 100: item.rate = 0.0 elif item.price_list_rate: - if not item.rate or (item.pricing_rules and item.discount_percentage > 0): + if item.pricing_rules or item.discount_percentage > 0: item.rate = flt(item.price_list_rate * (1.0 - (item.discount_percentage / 100.0)), item.precision("rate")) item.discount_amount = item.price_list_rate * (item.discount_percentage / 100.0) diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 0e1a915deb..34e9a52e11 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -40,7 +40,6 @@ frappe.ui.form.on('Quotation', { erpnext.selling.QuotationController = class QuotationController extends erpnext.selling.SellingController { onload(doc, dt, dn) { - var me = this; super.onload(doc, dt, dn); } party_name() { From 1e139cf9a115228b993c83be29415b1f6971b33e Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 24 Feb 2022 15:12:12 +0530 Subject: [PATCH 04/28] chore: remove unintentional search index --- .../buying/doctype/purchase_order_item/purchase_order_item.json | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index c26d592e3e..2c9fc36794 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -845,7 +845,6 @@ "label": "Sales Order Packed Item", "no_copy": 1, "print_hide": 1, - "search_index": 1 } ], "idx": 1, From 2f1709dfef55ed17db3e2ae885aef5588dd7a31a Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 24 Feb 2022 15:12:35 +0530 Subject: [PATCH 05/28] chore: remove unintentional search index --- .../buying/doctype/purchase_order_item/purchase_order_item.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 2c9fc36794..a18c527644 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -844,7 +844,7 @@ "fieldtype": "Data", "label": "Sales Order Packed Item", "no_copy": 1, - "print_hide": 1, + "print_hide": 1 } ], "idx": 1, From d3b0ca30c6ae0e979b7bdddbe67018941be8d59b Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 25 Feb 2022 12:10:11 +0530 Subject: [PATCH 06/28] fix: Get MRs that are yet to be received but fully ordered in Report - Remove incorrect query clause that only check if ordered qty < 100 - MR should be visible in report until fully received (cycle complete) --- .../requested_items_to_order_and_receive.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py index f98e5f12c2..2c597f29bf 100644 --- a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py +++ b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py @@ -18,10 +18,10 @@ def execute(filters=None): columns = get_columns(filters) conditions = get_conditions(filters) - #get queried data + # get queried data data = get_data(filters, conditions) - #prepare data for report and chart views + # prepare data for report and chart views data, chart_data = prepare_data(data, filters) return columns, data, None, chart_data @@ -74,10 +74,9 @@ def get_data(filters, conditions): and mr.material_request_type = "Purchase" and mr.docstatus = 1 and mr.status != "Stopped" + and mr.per_received < 100 {conditions} group by mr.name, mr_item.item_code - having - sum(ifnull(mr_item.ordered_qty, 0)) < sum(ifnull(mr_item.stock_qty, 0)) order by mr.transaction_date, mr.schedule_date""".format(conditions=conditions), as_dict=1) return data From bbc4710fa31357cad038f2b515ae07ed09bd2c5e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 1 Mar 2022 11:48:28 +0530 Subject: [PATCH 07/28] fix: apply margin on duplicated doc too --- erpnext/controllers/taxes_and_totals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 52190765c9..d2a913c74b 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -116,11 +116,11 @@ class calculate_taxes_and_totals(object): if item.discount_percentage == 100: item.rate = 0.0 elif item.price_list_rate: - if item.pricing_rules or item.discount_percentage > 0: + if item.pricing_rules or abs(item.discount_percentage) > 0: item.rate = flt(item.price_list_rate * (1.0 - (item.discount_percentage / 100.0)), item.precision("rate")) item.discount_amount = item.price_list_rate * (item.discount_percentage / 100.0) - elif item.discount_amount and item.pricing_rules: + elif item.discount_amount or item.pricing_rules: item.rate = item.price_list_rate - item.discount_amount if item.doctype in ['Quotation Item', 'Sales Order Item', 'Delivery Note Item', 'Sales Invoice Item', 'POS Invoice Item', 'Purchase Invoice Item', 'Purchase Order Item', 'Purchase Receipt Item']: From e6952cb7f993c37d4f71be4ba6779c94257656f6 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 1 Mar 2022 17:15:04 +0530 Subject: [PATCH 08/28] refactor: Convert to QB, added test file, removed white space - Converted mysql raw query to qb - Test file for Report Requested Items to Order and Receive - Removed white space and edited copyright year --- .../requested_items_to_order_and_receive.py | 100 ++++++++++-------- ...st_requested_items_to_order_and_receive.py | 68 ++++++++++++ .../material_request/test_material_request.py | 14 +-- .../report/stock_ageing/test_stock_ageing.py | 2 +- 4 files changed, 134 insertions(+), 50 deletions(-) create mode 100644 erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py diff --git a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py index 2c597f29bf..2923e5bb5f 100644 --- a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py +++ b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py @@ -6,6 +6,7 @@ import copy import frappe from frappe import _ +from frappe.query_builder.functions import Sum, Coalesce from frappe.utils import date_diff, flt, getdate @@ -16,10 +17,7 @@ def execute(filters=None): validate_filters(filters) columns = get_columns(filters) - conditions = get_conditions(filters) - - # get queried data - data = get_data(filters, conditions) + data = get_data(filters) # prepare data for report and chart views data, chart_data = prepare_data(data, filters) @@ -34,52 +32,70 @@ def validate_filters(filters): elif date_diff(to_date, from_date) < 0: frappe.throw(_("To Date cannot be before From Date.")) -def get_conditions(filters): - conditions = '' +def get_data(filters): + mr = frappe.qb.DocType("Material Request") + mr_item = frappe.qb.DocType("Material Request Item") + query = ( + frappe.qb.from_(mr) + .join(mr_item).on(mr_item.parent == mr.name) + .select( + mr.name.as_("material_request"), + mr.transaction_date.as_("date"), + mr_item.schedule_date.as_("required_date"), + mr_item.item_code.as_("item_code"), + Sum(Coalesce(mr_item.stock_qty, 0)).as_("qty"), + Coalesce(mr_item.stock_uom, '').as_("uom"), + Sum(Coalesce(mr_item.ordered_qty, 0)).as_("ordered_qty"), + Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"), + ( + Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.received_qty, 0)) + ).as_("qty_to_receive"), + Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"), + ( + Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.ordered_qty, 0)) + ).as_("qty_to_order"), + mr_item.item_name, + mr_item.description, + mr.company + ).where( + (mr.material_request_type == "Purchase") + & (mr.docstatus == 1) + & (mr.status != "Stopped") + & (mr.per_received < 100) + ) + ) + + query = get_conditions(filters, query, mr, mr_item) # add conditional conditions + + query = ( + query.groupby( + mr.name, mr_item.item_code + ).orderby( + mr.transaction_date, mr.schedule_date + ) + ) + data = query.run(as_dict=True) + return data + +def get_conditions(filters, query, mr, mr_item): if filters.get("from_date") and filters.get("to_date"): - conditions += " and mr.transaction_date between '{0}' and '{1}'".format(filters.get("from_date"),filters.get("to_date")) - + query = ( + query.where( + ( mr.transaction_date >= filters.get("from_date")) + & (mr.transaction_date <= filters.get("to_date")) + ) + ) if filters.get("company"): - conditions += " and mr.company = '{0}'".format(filters.get("company")) + query = query.where(mr.company == filters.get("company")) if filters.get("material_request"): - conditions += " and mr.name = '{0}'".format(filters.get("material_request")) + query = query.where(mr.name == filters.get("material_request")) if filters.get("item_code"): - conditions += " and mr_item.item_code = '{0}'".format(filters.get("item_code")) + query = query.where(mr_item.item_code == filters.get("item_code")) - return conditions - -def get_data(filters, conditions): - data = frappe.db.sql(""" - select - mr.name as material_request, - mr.transaction_date as date, - mr_item.schedule_date as required_date, - mr_item.item_code as item_code, - sum(ifnull(mr_item.stock_qty, 0)) as qty, - ifnull(mr_item.stock_uom, '') as uom, - sum(ifnull(mr_item.ordered_qty, 0)) as ordered_qty, - sum(ifnull(mr_item.received_qty, 0)) as received_qty, - (sum(ifnull(mr_item.stock_qty, 0)) - sum(ifnull(mr_item.received_qty, 0))) as qty_to_receive, - (sum(ifnull(mr_item.stock_qty, 0)) - sum(ifnull(mr_item.ordered_qty, 0))) as qty_to_order, - mr_item.item_name as item_name, - mr_item.description as "description", - mr.company as company - from - `tabMaterial Request` mr, `tabMaterial Request Item` mr_item - where - mr_item.parent = mr.name - and mr.material_request_type = "Purchase" - and mr.docstatus = 1 - and mr.status != "Stopped" - and mr.per_received < 100 - {conditions} - group by mr.name, mr_item.item_code - order by mr.transaction_date, mr.schedule_date""".format(conditions=conditions), as_dict=1) - - return data + return query def update_qty_columns(row_to_update, data_row): fields = ["qty", "ordered_qty", "received_qty", "qty_to_receive", "qty_to_order"] diff --git a/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py b/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py new file mode 100644 index 0000000000..be2419a7b3 --- /dev/null +++ b/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py @@ -0,0 +1,68 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import today, add_days + +from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt +from erpnext.buying.report.requested_items_to_order_and_receive.requested_items_to_order_and_receive import ( + get_data +) +from erpnext.stock.doctype.item.test_item import create_item +from erpnext.stock.doctype.material_request.material_request import make_purchase_order + +class TestRequestedItemsToOrderAndReceive(FrappeTestCase): + def setUp(self) -> None: + create_item("Test MR Report Item") + self.setup_material_request() # to order and receive + self.setup_material_request(order=True) # to receive (ordered) + self.setup_material_request(order=True, receive=True) # complete (ordered & received) + + self.filters = frappe._dict( + company="_Test Company", from_date=today(), to_date=add_days(today(), 30), + item_code="Test MR Report Item" + ) + + def tearDown(self) -> None: + frappe.db.rollback() + + def test_date_range(self): + data = get_data(self.filters) + self.assertEqual(len(data), 2) # MRs today should be fetched + + self.filters.from_date = add_days(today(), 1) + data = get_data(self.filters) + self.assertEqual(len(data), 0) # MRs today should not be fetched as from date is tomorrow + + def test_ordered_received_material_requests(self): + data = get_data(self.filters) + + # from the 3 MRs made, only 2 (to receive) should be fetched + self.assertEqual(len(data), 2) + self.assertEqual(data[0].ordered_qty, 0.0) + self.assertEqual(data[1].ordered_qty, 57.0) + + def setup_material_request(self, order=False, receive=False): + po = None + test_records = frappe.get_test_records('Material Request') + + mr = frappe.copy_doc(test_records[0]) + mr.transaction_date = today() + mr.schedule_date = add_days(today(), 1) + for row in mr.items: + row.item_code = "Test MR Report Item" + row.item_name = "Test MR Report Item" + row.description = "Test MR Report Item" + row.uom = "Nos" + row.schedule_date = add_days(today(), 1) + mr.submit() + + if order or receive: + po = make_purchase_order(mr.name) + po.supplier = "_Test Supplier" + po.submit() + if receive: + pr = make_purchase_receipt(po.name) + pr.submit() + diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 1cda781617..866f3ab2d5 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -626,13 +626,13 @@ class TestMaterialRequest(FrappeTestCase): mr.schedule_date = today() if not frappe.db.get_value('UOM Conversion Detail', - {'parent': item.item_code, 'uom': 'Kg'}): - item_doc = frappe.get_doc('Item', item.item_code) - item_doc.append('uoms', { - 'uom': 'Kg', - 'conversion_factor': 5 - }) - item_doc.save(ignore_permissions=True) + {'parent': item.item_code, 'uom': 'Kg'}): + item_doc = frappe.get_doc('Item', item.item_code) + item_doc.append('uoms', { + 'uom': 'Kg', + 'conversion_factor': 5 + }) + item_doc.save(ignore_permissions=True) item.uom = 'Kg' for item in mr.items: diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 2630805c62..ca963b7486 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -1,4 +1,4 @@ -# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt import frappe From ac425722e206465c34d4029b3e959ac726ebd0ef Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 1 Mar 2022 17:30:37 +0530 Subject: [PATCH 09/28] fix: Sider and Linter --- .../requested_items_to_order_and_receive.py | 2 +- .../test_requested_items_to_order_and_receive.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py index 2923e5bb5f..50fe78ba0f 100644 --- a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py +++ b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py @@ -82,7 +82,7 @@ def get_conditions(filters, query, mr, mr_item): if filters.get("from_date") and filters.get("to_date"): query = ( query.where( - ( mr.transaction_date >= filters.get("from_date")) + (mr.transaction_date >= filters.get("from_date")) & (mr.transaction_date <= filters.get("to_date")) ) ) diff --git a/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py b/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py index be2419a7b3..f3c751c5c3 100644 --- a/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py +++ b/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py @@ -3,15 +3,16 @@ import frappe from frappe.tests.utils import FrappeTestCase -from frappe.utils import today, add_days +from frappe.utils import add_days, today from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt from erpnext.buying.report.requested_items_to_order_and_receive.requested_items_to_order_and_receive import ( - get_data + get_data, ) from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.material_request.material_request import make_purchase_order + class TestRequestedItemsToOrderAndReceive(FrappeTestCase): def setUp(self) -> None: create_item("Test MR Report Item") From 2eb7921bfff61052a56f09b625d0115875a1a1ee Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 28 Feb 2022 21:07:08 +0530 Subject: [PATCH 10/28] fix: Exchange rate not getting set in payment entry (cherry picked from commit 86e6bdf2c9ffc0a656c14663b6f790071a7f3afd) --- .../doctype/payment_entry/payment_entry.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index cc32a6ccd9..b2b818a214 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -346,6 +346,8 @@ frappe.ui.form.on('Payment Entry', { } frm.set_party_account_based_on_party = true; + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; + return frappe.call({ method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details", args: { @@ -379,7 +381,11 @@ frappe.ui.form.on('Payment Entry', { if (r.message.bank_account) { frm.set_value("bank_account", r.message.bank_account); } - } + }, + () => frm.events.set_current_exchange_rate(frm, "source_exchange_rate", + frm.doc.paid_from_account_currency, company_currency), + () => frm.events.set_current_exchange_rate(frm, "target_exchange_rate", + frm.doc.paid_to_account_currency, company_currency) ]); } } @@ -483,14 +489,14 @@ frappe.ui.form.on('Payment Entry', { }, paid_from_account_currency: function(frm) { - if(!frm.doc.paid_from_account_currency) return; - var company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; + if(!frm.doc.paid_from_account_currency || !frm.doc.company) return; + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; if (frm.doc.paid_from_account_currency == company_currency) { frm.set_value("source_exchange_rate", 1); } else if (frm.doc.paid_from){ if (in_list(["Internal Transfer", "Pay"], frm.doc.payment_type)) { - var company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; frappe.call({ method: "erpnext.setup.utils.get_exchange_rate", args: { @@ -510,8 +516,8 @@ frappe.ui.form.on('Payment Entry', { }, paid_to_account_currency: function(frm) { - if(!frm.doc.paid_to_account_currency) return; - var company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; + if(!frm.doc.paid_to_account_currency || !frm.doc.company) return; + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; frm.events.set_current_exchange_rate(frm, "target_exchange_rate", frm.doc.paid_to_account_currency, company_currency); From d95f8934aa5cafdddd02568841786081f90c214a Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 1 Mar 2022 23:09:59 +0530 Subject: [PATCH 11/28] fix: Test cases with discount --- erpnext/controllers/taxes_and_totals.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index d2a913c74b..dc58e1181c 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -113,17 +113,24 @@ class calculate_taxes_and_totals(object): for item in self.doc.get("items"): self.doc.round_floats_in(item) + if not item.rate: + item.rate = item.price_list_rate + if item.discount_percentage == 100: item.rate = 0.0 elif item.price_list_rate: if item.pricing_rules or abs(item.discount_percentage) > 0: item.rate = flt(item.price_list_rate * (1.0 - (item.discount_percentage / 100.0)), item.precision("rate")) - item.discount_amount = item.price_list_rate * (item.discount_percentage / 100.0) + + if abs(item.discount_percentage) > 0: + item.discount_amount = item.price_list_rate * (item.discount_percentage / 100.0) + elif item.discount_amount or item.pricing_rules: item.rate = item.price_list_rate - item.discount_amount - if item.doctype in ['Quotation Item', 'Sales Order Item', 'Delivery Note Item', 'Sales Invoice Item', 'POS Invoice Item', 'Purchase Invoice Item', 'Purchase Order Item', 'Purchase Receipt Item']: + if item.doctype in ['Quotation Item', 'Sales Order Item', 'Delivery Note Item', 'Sales Invoice Item', + 'POS Invoice Item', 'Purchase Invoice Item', 'Purchase Order Item', 'Purchase Receipt Item']: item.rate_with_margin, item.base_rate_with_margin = self.calculate_margin(item) if flt(item.rate_with_margin) > 0: item.rate = flt(item.rate_with_margin * (1.0 - (item.discount_percentage / 100.0)), item.precision("rate")) From efc4b943f8f250de20c806a6dc923710b0f21885 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 2 Mar 2022 11:19:12 +0530 Subject: [PATCH 12/28] fix: ignore serial no during landed cost voucher --- .../test_landed_cost_voucher.py | 48 +++++++++++++++++++ erpnext/stock/stock_ledger.py | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index dbaefc1e11..6dc4fee569 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -11,6 +11,7 @@ from erpnext.accounts.doctype.account.test_account import create_account, get_in from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.utils import update_gl_entries_after from erpnext.assets.doctype.asset.test_asset import create_asset_category, create_fixed_asset_item +from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import ( get_gl_entries, make_purchase_receipt, @@ -177,6 +178,53 @@ class TestLandedCostVoucher(FrappeTestCase): self.assertEqual(serial_no.purchase_rate - serial_no_rate, 5.0) self.assertEqual(serial_no.warehouse, "Stores - TCP1") + def test_serialized_lcv_delivered(self): + """In some cases you'd want to deliver before you can know all the + landed costs, this should be allowed for serial nos too. + + Case: + - receipt a serial no @ X rate + - delivery the serial no @ X rate + - add LCV to receipt X + Y + - LCV should be successful + - delivery should reflect X+Y valuation. + """ + serial_no = "LCV_TEST_SR_NO" + item_code = "_Test Serialized Item" + warehouse = "Stores - TCP1" + + pr = make_purchase_receipt(company="_Test Company with perpetual inventory", + warehouse=warehouse, qty=1, rate=200, + item_code=item_code, serial_no=serial_no) + + serial_no_rate = frappe.db.get_value("Serial No", serial_no, "purchase_rate") + + # deliver it before creating LCV + dn = create_delivery_note(item_code=item_code, + company='_Test Company with perpetual inventory', warehouse='Stores - TCP1', + serial_no=serial_no, qty=1, rate=500, + cost_center = 'Main - TCP1', expense_account = "Cost of Goods Sold - TCP1") + + charges = 10 + create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company, charges=charges) + + new_purchase_rate = serial_no_rate + charges + + serial_no = frappe.db.get_value("Serial No", serial_no, + ["warehouse", "purchase_rate"], as_dict=1) + + self.assertEqual(serial_no.purchase_rate, new_purchase_rate) + + stock_value_difference = frappe.db.get_value("Stock Ledger Entry", + filters={ + "voucher_no": dn.name, + "voucher_type": dn.doctype, + "is_cancelled": 0 # LCV cancels with same name. + }, + fieldname="stock_value_difference") + + # reposting should update the purchase rate in future delivery + self.assertEqual(stock_value_difference, -new_purchase_rate) def test_landed_cost_voucher_for_odd_numbers (self): pr = make_purchase_receipt(company="_Test Company with perpetual inventory", warehouse = "Stores - TCP1", supplier_warehouse = "Work in Progress - TCP1", do_not_save=True) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 1b90086440..1a80d93c67 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -39,7 +39,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc future_sle_exists(args, sl_entries) for sle in sl_entries: - if sle.serial_no: + if sle.serial_no and not via_landed_cost_voucher: validate_serial_no(sle) if cancel: From eb8495a401ceb9f1118bdb50f80a836a07994b9b Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 2 Mar 2022 12:01:51 +0530 Subject: [PATCH 13/28] docs: explain make_sl_entries arguments --- erpnext/stock/stock_ledger.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 1a80d93c67..69755529e5 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -28,6 +28,16 @@ class SerialNoExistsInFutureTransaction(frappe.ValidationError): def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): + """ Create SL entries from SL entry dicts + + args: + - allow_negative_stock: disable negative stock valiations if true + - via_landed_cost_voucher: landed cost voucher cancels and reposts + entries of purchase document. This flag is used to identify if + cancellation and repost is happening via landed cost voucher, in + such cases certain validations need to be ignored (like negative + stock) + """ from erpnext.controllers.stock_controller import future_sle_exists if sl_entries: cancel = sl_entries[0].get("is_cancelled") From 5d85b35f41d792879e47d63ec2bf45696ee6553c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 2 Mar 2022 12:29:03 +0530 Subject: [PATCH 14/28] test: fix flaky bin value test --- .../doctype/purchase_receipt/test_purchase_receipt.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index a24acb1bd8..fa28f2252d 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -161,6 +161,15 @@ class TestPurchaseReceipt(FrappeTestCase): qty=abs(existing_bin_qty) ) + existing_bin_qty, existing_bin_stock_value = frappe.db.get_value( + "Bin", + { + "item_code": "_Test Item", + "warehouse": "_Test Warehouse - _TC" + }, + ["actual_qty", "stock_value"] + ) + pr = make_purchase_receipt() stock_value_difference = frappe.db.get_value( From 98f26e9cbb24c13657e357060f817f6ab3b58780 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Wed, 2 Mar 2022 12:28:22 +0530 Subject: [PATCH 15/28] fix: handle `ImportError` when installing country fixtures --- erpnext/setup/doctype/company/company.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 95b1e8b9c6..158952484c 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -3,7 +3,6 @@ import json -import os import frappe import frappe.defaults @@ -422,14 +421,14 @@ def get_name_with_abbr(name, company): return " - ".join(parts) def install_country_fixtures(company, country): - path = frappe.get_app_path('erpnext', 'regional', frappe.scrub(country)) - if os.path.exists(path.encode("utf-8")): - try: - module_name = "erpnext.regional.{0}.setup.setup".format(frappe.scrub(country)) - frappe.get_attr(module_name)(company, False) - except Exception as e: - frappe.log_error() - frappe.throw(_("Failed to setup defaults for country {0}. Please contact support.").format(frappe.bold(country))) + try: + module_name = "erpnext.regional.{0}.setup.setup".format(frappe.scrub(country)) + frappe.get_attr(module_name)(company, False) + except ImportError: + pass + except Exception: + frappe.log_error() + frappe.throw(_("Failed to setup defaults for country {0}. Please contact support.").format(frappe.bold(country))) def update_company_current_month_sales(company): From 89368ece4120411af5dd6cace9128e42cfd51f13 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Wed, 2 Mar 2022 12:40:25 +0530 Subject: [PATCH 16/28] style: use f-string --- erpnext/setup/doctype/company/company.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 158952484c..36ad8fec9f 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -422,7 +422,7 @@ def get_name_with_abbr(name, company): def install_country_fixtures(company, country): try: - module_name = "erpnext.regional.{0}.setup.setup".format(frappe.scrub(country)) + module_name = f"erpnext.regional.{frappe.scrub(country)}.setup.setup" frappe.get_attr(module_name)(company, False) except ImportError: pass From 54b3676f35579840c35fce5690f0202e590dd424 Mon Sep 17 00:00:00 2001 From: marination Date: Wed, 2 Mar 2022 13:09:34 +0530 Subject: [PATCH 17/28] fix: linter (imports alphabetical) --- .../requested_items_to_order_and_receive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py index 50fe78ba0f..60a8f92cc3 100644 --- a/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py +++ b/erpnext/buying/report/requested_items_to_order_and_receive/requested_items_to_order_and_receive.py @@ -6,7 +6,7 @@ import copy import frappe from frappe import _ -from frappe.query_builder.functions import Sum, Coalesce +from frappe.query_builder.functions import Coalesce, Sum from frappe.utils import date_diff, flt, getdate From b3b7cdfb49cdc67e2dc3688b472deb5c2294addc Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 1 Mar 2022 14:33:46 +0530 Subject: [PATCH 18/28] test: FIFO transfer for multi-batch transaction --- .../test_stock_ledger_entry.py | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 01d25b2e86..a182a6d7ef 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -389,10 +389,13 @@ class TestStockLedgerEntry(FrappeTestCase): ) - def assertSLEs(self, doc, expected_sles): + def assertSLEs(self, doc, expected_sles, sle_filters=None): """ Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line""" - sles = frappe.get_all("Stock Ledger Entry", fields=["*"], - filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled":0}, + + filters = {"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled":0} + if sle_filters: + filters.update(sle_filters) + sles = frappe.get_all("Stock Ledger Entry", fields=["*"], filters=filters, order_by="timestamp(posting_date, posting_time), creation") for exp_sle, act_sle in zip(expected_sles, sles): @@ -665,6 +668,42 @@ class TestStockLedgerEntry(FrappeTestCase): {"actual_qty": -10, "stock_value_difference": -10*40, "stock_queue": []}, ])) + def test_fifo_dependent_consumption(self): + item = make_item("_TestFifoTransferRates") + source = "_Test Warehouse - _TC" + target = "Stores - _TC" + + rates = [10 * i for i in range(1, 20)] + + receipt = make_stock_entry(item_code=item.name, target=source, qty=10, do_not_save=True, rate=10) + for rate in rates[1:]: + row = frappe.copy_doc(receipt.items[0], ignore_no_copy=False) + row.basic_rate = rate + receipt.append("items", row) + + receipt.save() + receipt.submit() + + expected_queues = [] + for idx, rate in enumerate(rates, start=1): + expected_queues.append( + {"stock_queue": [[10, 10 * i] for i in range(1, idx + 1)]} + ) + self.assertSLEs(receipt, expected_queues) + + transfer = make_stock_entry(item_code=item.name, source=source, target=target, qty=10, do_not_save=True, rate=10) + for rate in rates[1:]: + row = frappe.copy_doc(transfer.items[0], ignore_no_copy=False) + row.basic_rate = rate + transfer.append("items", row) + + transfer.save() + transfer.submit() + + # same exact queue should be transferred + self.assertSLEs(transfer, expected_queues, sle_filters={"warehouse": target}) + + def create_repack_entry(**args): args = frappe._dict(args) repack = frappe.new_doc("Stock Entry") From ccd2ce56b1d6e24005e5a24b11c72e78adb0a4e4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 1 Mar 2022 14:04:02 +0530 Subject: [PATCH 19/28] fix: FIFO valuation in case of multi-item entries --- erpnext/stock/stock_ledger.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 69755529e5..97d54f8984 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -448,8 +448,8 @@ class update_entries_after(object): return # Get dynamic incoming/outgoing rate - if not self.args.get("sle_id"): - self.get_dynamic_incoming_outgoing_rate(sle) + # XXX: performance regression + self.get_dynamic_incoming_outgoing_rate(sle) if get_serial_nos(sle.serial_no): self.get_serialized_values(sle) @@ -492,8 +492,8 @@ class update_entries_after(object): sle.doctype="Stock Ledger Entry" frappe.get_doc(sle).db_update() - if not self.args.get("sle_id"): - self.update_outgoing_rate_on_transaction(sle) + # XXX: performance regression + self.update_outgoing_rate_on_transaction(sle) def validate_negative_stock(self, sle): @@ -576,9 +576,8 @@ class update_entries_after(object): def update_rate_on_stock_entry(self, sle, outgoing_rate): frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate) - # Update outgoing item's rate, recalculate FG Item's rate and total incoming/outgoing amount - if not sle.dependant_sle_voucher_detail_no: - self.recalculate_amounts_in_stock_entry(sle.voucher_no) + # XXX: performance regression + self.recalculate_amounts_in_stock_entry(sle.voucher_no) def recalculate_amounts_in_stock_entry(self, voucher_no): stock_entry = frappe.get_doc("Stock Entry", voucher_no, for_update=True) From 2f71c5bccaad5924a3047912761240681698d0ee Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 1 Mar 2022 17:04:10 +0530 Subject: [PATCH 20/28] test: repack FIFO rates --- .../test_stock_ledger_entry.py | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index a182a6d7ef..684a8d4d7c 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -392,7 +392,7 @@ class TestStockLedgerEntry(FrappeTestCase): def assertSLEs(self, doc, expected_sles, sle_filters=None): """ Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line""" - filters = {"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled":0} + filters = {"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0} if sle_filters: filters.update(sle_filters) sles = frappe.get_all("Stock Ledger Entry", fields=["*"], filters=filters, @@ -694,7 +694,6 @@ class TestStockLedgerEntry(FrappeTestCase): transfer = make_stock_entry(item_code=item.name, source=source, target=target, qty=10, do_not_save=True, rate=10) for rate in rates[1:]: row = frappe.copy_doc(transfer.items[0], ignore_no_copy=False) - row.basic_rate = rate transfer.append("items", row) transfer.save() @@ -703,6 +702,43 @@ class TestStockLedgerEntry(FrappeTestCase): # same exact queue should be transferred self.assertSLEs(transfer, expected_queues, sle_filters={"warehouse": target}) + def test_fifo_multi_item_repack_consumption(self): + rm = make_item("_TestFifoRepackRM") + packed = make_item("_TestFifoRepackFinished") + warehouse = "_Test Warehouse - _TC" + + rates = [10 * i for i in range(1, 5)] + + receipt = make_stock_entry(item_code=rm.name, target=warehouse, qty=10, do_not_save=True, rate=10) + for rate in rates[1:]: + row = frappe.copy_doc(receipt.items[0], ignore_no_copy=False) + row.basic_rate = rate + receipt.append("items", row) + + receipt.save() + receipt.submit() + + repack = make_stock_entry(item_code=rm.name, source=warehouse, qty=10, + do_not_save=True, rate=10, purpose="Repack") + for rate in rates[1:]: + row = frappe.copy_doc(repack.items[0], ignore_no_copy=False) + repack.append("items", row) + + repack.append("items", { + "item_code": packed.name, + "t_warehouse": warehouse, + "qty": 1, + "transfer_qty": 1, + }) + + repack.save() + repack.submit() + + # same exact queue should be transferred + self.assertSLEs(repack, [ + {"incoming_rate": sum(rates) * 10} + ], sle_filters={"item_code": packed.name}) + def create_repack_entry(**args): args = frappe._dict(args) From 701878f60b4a9b60035295f23ae65973680b03e5 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 1 Mar 2022 18:08:29 +0530 Subject: [PATCH 21/28] revert "fix: FIFO valuation in case of multi-item entries" This reverts commit b8ee193d1a124668691b3d8181ce4e3bf612afe0. This is huge performance regression for large docs. --- erpnext/stock/stock_ledger.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 97d54f8984..69755529e5 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -448,8 +448,8 @@ class update_entries_after(object): return # Get dynamic incoming/outgoing rate - # XXX: performance regression - self.get_dynamic_incoming_outgoing_rate(sle) + if not self.args.get("sle_id"): + self.get_dynamic_incoming_outgoing_rate(sle) if get_serial_nos(sle.serial_no): self.get_serialized_values(sle) @@ -492,8 +492,8 @@ class update_entries_after(object): sle.doctype="Stock Ledger Entry" frappe.get_doc(sle).db_update() - # XXX: performance regression - self.update_outgoing_rate_on_transaction(sle) + if not self.args.get("sle_id"): + self.update_outgoing_rate_on_transaction(sle) def validate_negative_stock(self, sle): @@ -576,8 +576,9 @@ class update_entries_after(object): def update_rate_on_stock_entry(self, sle, outgoing_rate): frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate) - # XXX: performance regression - self.recalculate_amounts_in_stock_entry(sle.voucher_no) + # Update outgoing item's rate, recalculate FG Item's rate and total incoming/outgoing amount + if not sle.dependant_sle_voucher_detail_no: + self.recalculate_amounts_in_stock_entry(sle.voucher_no) def recalculate_amounts_in_stock_entry(self, voucher_no): stock_entry = frappe.get_doc("Stock Entry", voucher_no, for_update=True) From 3638fbf06bfc492515096e4b4a065a52a495420a Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 1 Mar 2022 18:17:14 +0530 Subject: [PATCH 22/28] fix: repost items with repeating item-warehouses --- erpnext/controllers/stock_controller.py | 30 ++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index c8e5eddfea..8972c32879 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -507,13 +507,41 @@ class StockController(AccountsController): "voucher_no": self.name, "company": self.company }) - if future_sle_exists(args): + + if future_sle_exists(args) or repost_required_for_queue(self): item_based_reposting = cint(frappe.db.get_single_value("Stock Reposting Settings", "item_based_reposting")) if item_based_reposting: create_item_wise_repost_entries(voucher_type=self.doctype, voucher_no=self.name) else: create_repost_item_valuation_entry(args) +def repost_required_for_queue(doc: StockController) -> bool: + """check if stock document contains repeated item-warehouse with queue based valuation. + + if queue exists for repeated items then SLEs need to reprocessed in background again. + """ + + consuming_sles = frappe.db.get_all("Stock Ledger Entry", + filters={ + "voucher_type": doc.doctype, + "voucher_no": doc.name, + "actual_qty": ("<", 0), + "is_cancelled": 0 + }, + fields=["item_code", "warehouse", "stock_queue"] + ) + item_warehouses = [(sle.item_code, sle.warehouse) for sle in consuming_sles] + + unique_item_warehouses = set(item_warehouses) + + if len(unique_item_warehouses) == len(item_warehouses): + return False + + for sle in consuming_sles: + if sle.stock_queue != "[]": # using FIFO/LIFO valuation + return True + return False + @frappe.whitelist() def make_quality_inspections(doctype, docname, items): From b988046082b97e41c8b07eeaae35cfc2829e2bf3 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 2 Mar 2022 14:15:22 +0530 Subject: [PATCH 23/28] fix(Timesheet): fetch exchange rate only if currency is set (#30057) --- erpnext/projects/doctype/timesheet/timesheet.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/projects/doctype/timesheet/timesheet.js b/erpnext/projects/doctype/timesheet/timesheet.js index f615f051f0..453d46c7c4 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.js +++ b/erpnext/projects/doctype/timesheet/timesheet.js @@ -116,7 +116,7 @@ frappe.ui.form.on("Timesheet", { currency: function(frm) { let base_currency = frappe.defaults.get_global_default('currency'); - if (base_currency != frm.doc.currency) { + if (frm.doc.currency && (base_currency != frm.doc.currency)) { frappe.call({ method: "erpnext.setup.utils.get_exchange_rate", args: { From 55a966ec4137a5d60ef4351ea401e0fcbc5dff91 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 2 Mar 2022 14:49:45 +0530 Subject: [PATCH 24/28] chore: get stock reco qty from SR instead of SLE (#30059) [skip ci] --- .../stock_ledger_invariant_check.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index 7826d34422..1ba2482935 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -21,6 +21,7 @@ SLE_FIELDS = ( "stock_value", "stock_value_difference", "valuation_rate", + "voucher_detail_no", ) @@ -66,7 +67,9 @@ def add_invariant_check_fields(sles): balance_qty += sle.actual_qty balance_stock_value += sle.stock_value_difference if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: - balance_qty = sle.qty_after_transaction + balance_qty = frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "qty") + if balance_qty is None: + balance_qty = sle.qty_after_transaction sle.fifo_queue_qty = fifo_qty sle.fifo_stock_value = fifo_value From f8ac4c082a8512349187cecf058c8945231e7d52 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 2 Mar 2022 16:25:18 +0530 Subject: [PATCH 25/28] fix: remove dead dashboard links --- .../manufacturing/doctype/operation/operation_dashboard.py | 2 +- .../doctype/workstation/workstation_dashboard.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/operation/operation_dashboard.py b/erpnext/manufacturing/doctype/operation/operation_dashboard.py index 4fbcf4954e..9f7efa2b38 100644 --- a/erpnext/manufacturing/doctype/operation/operation_dashboard.py +++ b/erpnext/manufacturing/doctype/operation/operation_dashboard.py @@ -7,7 +7,7 @@ def get_data(): 'transactions': [ { 'label': _('Manufacture'), - 'items': ['BOM', 'Work Order', 'Job Card', 'Timesheet'] + 'items': ['BOM', 'Work Order', 'Job Card'] } ] } diff --git a/erpnext/manufacturing/doctype/workstation/workstation_dashboard.py b/erpnext/manufacturing/doctype/workstation/workstation_dashboard.py index bc481ca192..1fa14940a4 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation_dashboard.py +++ b/erpnext/manufacturing/doctype/workstation/workstation_dashboard.py @@ -11,9 +11,9 @@ def get_data(): }, { 'label': _('Transaction'), - 'items': ['Work Order', 'Job Card', 'Timesheet'] + 'items': ['Work Order', 'Job Card',] } ], 'disable_create_buttons': ['BOM', 'Routing', 'Operation', - 'Work Order', 'Job Card', 'Timesheet'] + 'Work Order', 'Job Card',] } From 9ef35ef7735683041d412f25eed9d7b59e2e8dd7 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 2 Mar 2022 16:32:48 +0530 Subject: [PATCH 26/28] fix: dont hardcode precision in routing Co-Authored-By: Suraj Shetty --- erpnext/manufacturing/doctype/routing/routing.js | 2 +- erpnext/manufacturing/doctype/routing/routing.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/routing/routing.js b/erpnext/manufacturing/doctype/routing/routing.js index 33a313e32f..b480c70ad5 100644 --- a/erpnext/manufacturing/doctype/routing/routing.js +++ b/erpnext/manufacturing/doctype/routing/routing.js @@ -17,7 +17,7 @@ frappe.ui.form.on('Routing', { }, calculate_operating_cost: function(frm, child) { - const operating_cost = flt(flt(child.hour_rate) * flt(child.time_in_mins) / 60, 2); + const operating_cost = flt(flt(child.hour_rate) * flt(child.time_in_mins) / 60, precision("operating_cost", child)); frappe.model.set_value(child.doctype, child.name, "operating_cost", operating_cost); } }); diff --git a/erpnext/manufacturing/doctype/routing/routing.py b/erpnext/manufacturing/doctype/routing/routing.py index 1c76634646..b207906c5e 100644 --- a/erpnext/manufacturing/doctype/routing/routing.py +++ b/erpnext/manufacturing/doctype/routing/routing.py @@ -20,7 +20,8 @@ class Routing(Document): for operation in self.operations: if not operation.hour_rate: operation.hour_rate = frappe.db.get_value("Workstation", operation.workstation, 'hour_rate') - operation.operating_cost = flt(flt(operation.hour_rate) * flt(operation.time_in_mins) / 60, 2) + operation.operating_cost = flt(flt(operation.hour_rate) * flt(operation.time_in_mins) / 60, + operation.precision("operating_cost")) def set_routing_id(self): sequence_id = 0 From 031a0dd7035aced02c0a943ce3bff7dba49d3a7f Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 7 Mar 2022 11:40:01 +0530 Subject: [PATCH 27/28] fix(e-invoicing): remove batch no from e-invoices --- erpnext/regional/india/e_invoice/einv_item_template.json | 6 +----- erpnext/regional/india/e_invoice/utils.py | 2 -- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/erpnext/regional/india/e_invoice/einv_item_template.json b/erpnext/regional/india/e_invoice/einv_item_template.json index 78e56518df..2c04c6dcf4 100644 --- a/erpnext/regional/india/e_invoice/einv_item_template.json +++ b/erpnext/regional/india/e_invoice/einv_item_template.json @@ -23,9 +23,5 @@ "StateCesAmt": "{item.state_cess_amount}", "StateCesNonAdvlAmt": "{item.state_cess_nadv_amount}", "OthChrg": "{item.other_charges}", - "TotItemVal": "{item.total_value}", - "BchDtls": {{ - "Nm": "{item.batch_no}", - "ExpDt": "{item.batch_expiry_date}" - }} + "TotItemVal": "{item.total_value}" }} \ No newline at end of file diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index e3f7e90ff3..64c75c4a75 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -214,8 +214,6 @@ def get_item_list(invoice): item.taxable_value = abs(item.taxable_value) item.discount_amount = 0 - item.batch_expiry_date = frappe.db.get_value('Batch', d.batch_no, 'expiry_date') if d.batch_no else None - item.batch_expiry_date = format_date(item.batch_expiry_date, 'dd/mm/yyyy') if item.batch_expiry_date else None item.is_service_item = 'Y' if item.gst_hsn_code and item.gst_hsn_code[:2] == "99" else 'N' item.serial_no = "" From 748d5c4873c3e09a37c7466b70c3bc0e329f12c5 Mon Sep 17 00:00:00 2001 From: Dany Robert Date: Mon, 7 Mar 2022 19:57:57 +0530 Subject: [PATCH 28/28] fix: wrong payment days in salary slip for employees joining/leaving during mid payroll dates (#29082) Co-authored-by: Rucha Mahabal --- erpnext/hr/doctype/attendance/attendance.py | 18 ++- .../hr/doctype/attendance/test_attendance.py | 93 +++++++++++++- .../doctype/salary_slip/salary_slip.py | 55 ++++++-- .../doctype/salary_slip/test_salary_slip.py | 120 ++++++++++++++---- 4 files changed, 239 insertions(+), 47 deletions(-) diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py index b1eaaf8b58..b1e373e218 100644 --- a/erpnext/hr/doctype/attendance/attendance.py +++ b/erpnext/hr/doctype/attendance/attendance.py @@ -174,16 +174,22 @@ def get_month_map(): def get_unmarked_days(employee, month, exclude_holidays=0): import calendar month_map = get_month_map() - today = get_datetime() - dates_of_month = ['{}-{}-{}'.format(today.year, month_map[month], r) for r in range(1, calendar.monthrange(today.year, month_map[month])[1] + 1)] + joining_date, relieving_date = frappe.get_cached_value("Employee", employee, ["date_of_joining", "relieving_date"]) + start_day = 1 + end_day = calendar.monthrange(today.year, month_map[month])[1] + 1 - length = len(dates_of_month) - month_start, month_end = dates_of_month[0], dates_of_month[length-1] + if joining_date and joining_date.month == month_map[month]: + start_day = joining_date.day + if relieving_date and relieving_date.month == month_map[month]: + end_day = relieving_date.day + 1 - records = frappe.get_all("Attendance", fields = ['attendance_date', 'employee'] , filters = [ + dates_of_month = ['{}-{}-{}'.format(today.year, month_map[month], r) for r in range(start_day, end_day)] + month_start, month_end = dates_of_month[0], dates_of_month[-1] + + records = frappe.get_all("Attendance", fields=['attendance_date', 'employee'], filters=[ ["attendance_date", ">=", month_start], ["attendance_date", "<=", month_end], ["employee", "=", employee], @@ -200,7 +206,7 @@ def get_unmarked_days(employee, month, exclude_holidays=0): for date in dates_of_month: date_time = get_datetime(date) - if today.day == date_time.day and today.month == date_time.month: + if today.day <= date_time.day and today.month <= date_time.month: break if date_time not in marked_days: unmarked_days.append(date) diff --git a/erpnext/hr/doctype/attendance/test_attendance.py b/erpnext/hr/doctype/attendance/test_attendance.py index a770d70ffa..118cc987ef 100644 --- a/erpnext/hr/doctype/attendance/test_attendance.py +++ b/erpnext/hr/doctype/attendance/test_attendance.py @@ -4,17 +4,104 @@ import unittest import frappe -from frappe.utils import nowdate +from frappe.utils import add_days, get_first_day, getdate, nowdate + +from erpnext.hr.doctype.attendance.attendance import ( + get_month_map, + get_unmarked_days, + mark_attendance, +) +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday test_records = frappe.get_test_records('Attendance') class TestAttendance(unittest.TestCase): def test_mark_absent(self): - from erpnext.hr.doctype.employee.test_employee import make_employee employee = make_employee("test_mark_absent@example.com") date = nowdate() frappe.db.delete('Attendance', {'employee':employee, 'attendance_date':date}) - from erpnext.hr.doctype.attendance.attendance import mark_attendance attendance = mark_attendance(employee, date, 'Absent') fetch_attendance = frappe.get_value('Attendance', {'employee':employee, 'attendance_date':date, 'status':'Absent'}) self.assertEqual(attendance, fetch_attendance) + + def test_unmarked_days(self): + first_day = get_first_day(getdate()) + + employee = make_employee('test_unmarked_days@example.com', date_of_joining=add_days(first_day, -1)) + frappe.db.delete('Attendance', {'employee': employee}) + + from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list + holiday_list = make_holiday_list() + frappe.db.set_value('Employee', employee, 'holiday_list', holiday_list) + + first_sunday = get_first_sunday(holiday_list) + mark_attendance(employee, first_day, 'Present') + month_name = get_month_name(first_day) + + unmarked_days = get_unmarked_days(employee, month_name) + unmarked_days = [getdate(date) for date in unmarked_days] + + # attendance already marked for the day + self.assertNotIn(first_day, unmarked_days) + # attendance unmarked + self.assertIn(getdate(add_days(first_day, 1)), unmarked_days) + # holiday considered in unmarked days + self.assertIn(first_sunday, unmarked_days) + + def test_unmarked_days_excluding_holidays(self): + first_day = get_first_day(getdate()) + + employee = make_employee('test_unmarked_days@example.com', date_of_joining=add_days(first_day, -1)) + frappe.db.delete('Attendance', {'employee': employee}) + + from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list + holiday_list = make_holiday_list() + frappe.db.set_value('Employee', employee, 'holiday_list', holiday_list) + + first_sunday = get_first_sunday(holiday_list) + mark_attendance(employee, first_day, 'Present') + month_name = get_month_name(first_day) + + unmarked_days = get_unmarked_days(employee, month_name, exclude_holidays=True) + unmarked_days = [getdate(date) for date in unmarked_days] + + # attendance already marked for the day + self.assertNotIn(first_day, unmarked_days) + # attendance unmarked + self.assertIn(getdate(add_days(first_day, 1)), unmarked_days) + # holidays not considered in unmarked days + self.assertNotIn(first_sunday, unmarked_days) + + def test_unmarked_days_as_per_joining_and_relieving_dates(self): + first_day = get_first_day(getdate()) + + doj = add_days(first_day, 1) + relieving_date = add_days(first_day, 5) + employee = make_employee('test_unmarked_days_as_per_doj@example.com', date_of_joining=doj, + date_of_relieving=relieving_date) + frappe.db.delete('Attendance', {'employee': employee}) + + attendance_date = add_days(first_day, 2) + mark_attendance(employee, attendance_date, 'Present') + month_name = get_month_name(first_day) + + unmarked_days = get_unmarked_days(employee, month_name) + unmarked_days = [getdate(date) for date in unmarked_days] + + # attendance already marked for the day + self.assertNotIn(attendance_date, unmarked_days) + # date before doj not in unmarked days + self.assertNotIn(add_days(doj, -1), unmarked_days) + # date after relieving not in unmarked days + self.assertNotIn(add_days(relieving_date, 1), unmarked_days) + + def tearDown(self): + frappe.db.rollback() + + +def get_month_name(date): + month_number = date.month + for month, number in get_month_map().items(): + if number == month_number: + return month \ No newline at end of file diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py index d2a39989a6..b44dbb926d 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -307,28 +307,59 @@ class SalarySlip(TransactionBase): if payroll_based_on == "Attendance": self.payment_days -= flt(absent) - unmarked_days = self.get_unmarked_days() consider_unmarked_attendance_as = frappe.db.get_value("Payroll Settings", None, "consider_unmarked_attendance_as") or "Present" if payroll_based_on == "Attendance" and consider_unmarked_attendance_as =="Absent": + unmarked_days = self.get_unmarked_days(include_holidays_in_total_working_days) self.absent_days += unmarked_days #will be treated as absent self.payment_days -= unmarked_days - if include_holidays_in_total_working_days: - for holiday in holidays: - if not frappe.db.exists("Attendance", {"employee": self.employee, "attendance_date": holiday, "docstatus": 1 }): - self.payment_days += 1 else: self.payment_days = 0 - def get_unmarked_days(self): - marked_days = frappe.get_all("Attendance", filters = { - "attendance_date": ["between", [self.start_date, self.end_date]], - "employee": self.employee, - "docstatus": 1 - }, fields = ["COUNT(*) as marked_days"])[0].marked_days + def get_unmarked_days(self, include_holidays_in_total_working_days): + unmarked_days = self.total_working_days + joining_date, relieving_date = frappe.get_cached_value("Employee", self.employee, + ["date_of_joining", "relieving_date"]) + start_date = self.start_date + end_date = self.end_date - return self.total_working_days - marked_days + if joining_date and (getdate(self.start_date) < joining_date <= getdate(self.end_date)): + start_date = joining_date + unmarked_days = self.get_unmarked_days_based_on_doj_or_relieving(unmarked_days, + include_holidays_in_total_working_days, self.start_date, joining_date) + if relieving_date and (getdate(self.start_date) <= relieving_date < getdate(self.end_date)): + end_date = relieving_date + unmarked_days = self.get_unmarked_days_based_on_doj_or_relieving(unmarked_days, + include_holidays_in_total_working_days, relieving_date, self.end_date) + + # exclude days for which attendance has been marked + unmarked_days -= frappe.get_all("Attendance", filters = { + "attendance_date": ["between", [start_date, end_date]], + "employee": self.employee, + "docstatus": 1 + }, fields = ["COUNT(*) as marked_days"])[0].marked_days + + return unmarked_days + + def get_unmarked_days_based_on_doj_or_relieving(self, unmarked_days, + include_holidays_in_total_working_days, start_date, end_date): + """ + Exclude days before DOJ or after + Relieving Date from unmarked days + """ + from erpnext.hr.doctype.employee.employee import is_holiday + + if include_holidays_in_total_working_days: + unmarked_days -= date_diff(end_date, start_date) + else: + # exclude only if not holidays + for days in range(date_diff(end_date, start_date)): + date = add_days(end_date, -days) + if not is_holiday(self.employee, date): + unmarked_days -= 1 + + return unmarked_days def get_payment_days(self, joining_date, relieving_date, include_holidays_in_total_working_days): if not joining_date: diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 6a5debf998..fe15f2d3fa 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -7,10 +7,12 @@ import unittest import frappe from frappe.model.document import Document +from frappe.tests.utils import change_settings from frappe.utils import ( add_days, add_months, cstr, + date_diff, flt, get_first_day, get_last_day, @@ -21,6 +23,7 @@ from frappe.utils.make_random import get_random import erpnext from erpnext.accounts.utils import get_fiscal_year +from erpnext.hr.doctype.attendance.attendance import mark_attendance from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type @@ -37,17 +40,17 @@ class TestSalarySlip(unittest.TestCase): setup_test() def tearDown(self): + frappe.db.rollback() frappe.db.set_value("Payroll Settings", None, "include_holidays_in_total_working_days", 0) frappe.set_user("Administrator") + @change_settings("Payroll Settings", { + "payroll_based_on": "Attendance", + "daily_wages_fraction_for_half_day": 0.75 + }) def test_payment_days_based_on_attendance(self): - from erpnext.hr.doctype.attendance.attendance import mark_attendance no_of_days = self.get_no_of_days() - # Payroll based on attendance - frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance") - frappe.db.set_value("Payroll Settings", None, "daily_wages_fraction_for_half_day", 0.75) - emp_id = make_employee("test_payment_days_based_on_attendance@salary.com") frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"}) @@ -85,14 +88,78 @@ class TestSalarySlip(unittest.TestCase): self.assertEqual(ss.gross_pay, gross_pay) - frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave") + @change_settings("Payroll Settings", { + "payroll_based_on": "Attendance", + "consider_unmarked_attendance_as": "Absent", + "include_holidays_in_total_working_days": True + }) + def test_payment_days_for_mid_joinee_including_holidays(self): + from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday + no_of_days = self.get_no_of_days() + month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate()) + + new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com") + joining_date, relieving_date = add_days(month_start_date, 3), add_days(month_end_date, -5) + frappe.db.set_value("Employee", new_emp_id, { + "date_of_joining": joining_date, + "relieving_date": relieving_date, + "status": "Left" + }) + + holidays = 0 + + for days in range(date_diff(relieving_date, joining_date) + 1): + date = add_days(joining_date, days) + if not is_holiday("Salary Slip Test Holiday List", date): + mark_attendance(new_emp_id, date, 'Present', ignore_validate=True) + else: + holidays += 1 + + new_ss = make_employee_salary_slip("test_payment_days_based_on_joining_date@salary.com", "Monthly", "Test Payment Based On Attendence") + + self.assertEqual(new_ss.total_working_days, no_of_days[0]) + self.assertEqual(new_ss.payment_days, no_of_days[0] - holidays - 8) + + @change_settings("Payroll Settings", { + "payroll_based_on": "Attendance", + "consider_unmarked_attendance_as": "Absent", + "include_holidays_in_total_working_days": False + }) + def test_payment_days_for_mid_joinee_excluding_holidays(self): + from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday + + no_of_days = self.get_no_of_days() + month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate()) + + new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com") + joining_date, relieving_date = add_days(month_start_date, 3), add_days(month_end_date, -5) + frappe.db.set_value("Employee", new_emp_id, { + "date_of_joining": joining_date, + "relieving_date": relieving_date, + "status": "Left" + }) + + holidays = 0 + + for days in range(date_diff(relieving_date, joining_date) + 1): + date = add_days(joining_date, days) + if not is_holiday("Salary Slip Test Holiday List", date): + mark_attendance(new_emp_id, date, 'Present', ignore_validate=True) + else: + holidays += 1 + + new_ss = make_employee_salary_slip("test_payment_days_based_on_joining_date@salary.com", "Monthly", "Test Payment Based On Attendence") + + self.assertEqual(new_ss.total_working_days, no_of_days[0] - no_of_days[1]) + self.assertEqual(new_ss.payment_days, no_of_days[0] - holidays - 8) + + @change_settings("Payroll Settings", { + "payroll_based_on": "Leave" + }) def test_payment_days_based_on_leave_application(self): no_of_days = self.get_no_of_days() - # Payroll based on attendance - frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave") - emp_id = make_employee("test_payment_days_based_on_leave_application@salary.com") frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"}) @@ -133,8 +200,9 @@ class TestSalarySlip(unittest.TestCase): self.assertEqual(ss.payment_days, days_in_month - no_of_holidays - 4) - frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave") - + @change_settings("Payroll Settings", { + "payroll_based_on": "Attendance" + }) def test_payment_days_in_salary_slip_based_on_timesheet(self): from erpnext.hr.doctype.attendance.attendance import mark_attendance from erpnext.projects.doctype.timesheet.test_timesheet import ( @@ -145,9 +213,6 @@ class TestSalarySlip(unittest.TestCase): make_salary_slip as make_salary_slip_for_timesheet, ) - # Payroll based on attendance - frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance") - emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company", holiday_list="Salary Slip Test Holiday List") frappe.db.set_value("Employee", emp, {"relieving_date": None, "status": "Active"}) @@ -185,17 +250,15 @@ class TestSalarySlip(unittest.TestCase): self.assertEqual(salary_slip.gross_pay, flt(gross_pay, 2)) - frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave") - + @change_settings("Payroll Settings", { + "payroll_based_on": "Attendance" + }) def test_component_amount_dependent_on_another_payment_days_based_component(self): from erpnext.hr.doctype.attendance.attendance import mark_attendance from erpnext.payroll.doctype.salary_structure.test_salary_structure import ( create_salary_structure_assignment, ) - # Payroll based on attendance - frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance") - salary_structure = make_salary_structure_for_payment_days_based_component_dependency() employee = make_employee("test_payment_days_based_component@salary.com", company="_Test Company") @@ -238,11 +301,12 @@ class TestSalarySlip(unittest.TestCase): expected_amount = flt((flt(ss.gross_pay) - payment_days_based_comp_amount) * 0.12, precision) self.assertEqual(actual_amount, expected_amount) - frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave") + @change_settings("Payroll Settings", { + "include_holidays_in_total_working_days": 1 + }) def test_salary_slip_with_holidays_included(self): no_of_days = self.get_no_of_days() - frappe.db.set_value("Payroll Settings", None, "include_holidays_in_total_working_days", 1) make_employee("test_salary_slip_with_holidays_included@salary.com") frappe.db.set_value("Employee", frappe.get_value("Employee", {"employee_name":"test_salary_slip_with_holidays_included@salary.com"}, "name"), "relieving_date", None) @@ -256,9 +320,11 @@ class TestSalarySlip(unittest.TestCase): self.assertEqual(ss.earnings[1].amount, 3000) self.assertEqual(ss.gross_pay, 78000) + @change_settings("Payroll Settings", { + "include_holidays_in_total_working_days": 0 + }) def test_salary_slip_with_holidays_excluded(self): no_of_days = self.get_no_of_days() - frappe.db.set_value("Payroll Settings", None, "include_holidays_in_total_working_days", 0) make_employee("test_salary_slip_with_holidays_excluded@salary.com") frappe.db.set_value("Employee", frappe.get_value("Employee", {"employee_name":"test_salary_slip_with_holidays_excluded@salary.com"}, "name"), "relieving_date", None) @@ -273,14 +339,15 @@ class TestSalarySlip(unittest.TestCase): self.assertEqual(ss.earnings[1].amount, 3000) self.assertEqual(ss.gross_pay, 78000) + @change_settings("Payroll Settings", { + "include_holidays_in_total_working_days": 1 + }) def test_payment_days(self): from erpnext.payroll.doctype.salary_structure.test_salary_structure import ( create_salary_structure_assignment, ) no_of_days = self.get_no_of_days() - # Holidays not included in working days - frappe.db.set_value("Payroll Settings", None, "include_holidays_in_total_working_days", 1) # set joinng date in the same month employee = make_employee("test_payment_days@salary.com") @@ -338,11 +405,12 @@ class TestSalarySlip(unittest.TestCase): frappe.set_user("test_employee_salary_slip_read_permission@salary.com") self.assertTrue(salary_slip_test_employee.has_permission("read")) + @change_settings("Payroll Settings", { + "email_salary_slip_to_employee": 1 + }) def test_email_salary_slip(self): frappe.db.sql("delete from `tabEmail Queue`") - frappe.db.set_value("Payroll Settings", None, "email_salary_slip_to_employee", 1) - make_employee("test_email_salary_slip@salary.com") ss = make_employee_salary_slip("test_email_salary_slip@salary.com", "Monthly", "Test Salary Slip Email") ss.company = "_Test Company"