From 6e6ef83d60d3f42b32b2d578889e8bd5f0f2e62b Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 15 May 2026 13:49:41 +0530 Subject: [PATCH 01/43] fix: incoming rate for legacy serial no (cherry picked from commit 2773b7c0022bbad0e872d05289fdfedeac0a79f7) # Conflicts: # erpnext/stock/deprecated_serial_batch.py # erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json # erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py --- erpnext/stock/deprecated_serial_batch.py | 19 +++++- .../stock_reposting_settings.json | 58 +++++++++++++++++++ .../stock_reposting_settings.py | 6 ++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 6c30b087de8..e1a64b3547b 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -32,9 +32,26 @@ class DeprecatedSerialNoValuation: # get rate from serial nos within same company incoming_values = 0.0 +<<<<<<< HEAD +======= + posting_datetime = self.sle.posting_datetime + + if not posting_datetime and self.sle.posting_date: + posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + + do_not_fetch_rate = frappe.db.get_single_value( + "Stock Reposting Settings", "do_not_fetch_incoming_rate_from_serial_no" + ) + +>>>>>>> 2773b7c002 (fix: incoming rate for legacy serial no) for serial_no in serial_nos: sn_details = frappe.db.get_value("Serial No", serial_no, ["purchase_rate", "company"], as_dict=1) - if sn_details and sn_details.purchase_rate and sn_details.company == self.sle.company: + if ( + sn_details + and sn_details.purchase_rate + and sn_details.company == self.sle.company + and (not frappe.flags.through_repost_item_valuation or not do_not_fetch_rate) + ): self.serial_no_incoming_rate[serial_no] += flt(sn_details.purchase_rate) incoming_values += self.serial_no_incoming_rate[serial_no] continue diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json index 3137bedfee4..de638582630 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "allow_rename": 1, "beta": 1, "creation": "2021-10-01 10:56:30.814787", @@ -13,6 +14,16 @@ "end_time", "limits_dont_apply_on", "item_based_reposting", +<<<<<<< HEAD +======= + "column_break_mavd", + "do_not_fetch_incoming_rate_from_serial_no", + "section_break_dxuf", + "enable_parallel_reposting", + "no_of_parallel_reposting", + "column_break_itvd", + "enable_separate_reposting_for_gl", +>>>>>>> 2773b7c002 (fix: incoming rate for legacy serial no) "errors_notification_section", "notify_reposting_error_to_role" ], @@ -65,12 +76,59 @@ "fieldname": "errors_notification_section", "fieldtype": "Section Break", "label": "Errors Notification" +<<<<<<< HEAD +======= + }, + { + "default": "0", + "depends_on": "eval: doc.item_based_reposting", + "fieldname": "enable_parallel_reposting", + "fieldtype": "Check", + "label": "Enable Parallel Reposting" + }, + { + "default": "4", + "depends_on": "eval: doc.item_based_reposting === 1 && doc.enable_parallel_reposting === 1", + "fieldname": "no_of_parallel_reposting", + "fieldtype": "Int", + "label": "No of Parallel Reposting (Per Item)" + }, + { + "fieldname": "section_break_dxuf", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_itvd", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "item_based_reposting", + "fieldname": "enable_separate_reposting_for_gl", + "fieldtype": "Check", + "label": "Enable Separate Reposting for GL" + }, + { + "fieldname": "column_break_mavd", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "For legacy serial nos, do not fetch incoming rate from serial no and calculate it based on the inward transaction", + "fieldname": "do_not_fetch_incoming_rate_from_serial_no", + "fieldtype": "Check", + "label": "Do not fetch incoming rate from Serial No" +>>>>>>> 2773b7c002 (fix: incoming rate for legacy serial no) } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], +<<<<<<< HEAD "modified": "2025-07-08 11:27:46.659056", +======= + "modified": "2026-05-15 12:59:34.392491", +>>>>>>> 2773b7c002 (fix: incoming rate for legacy serial no) "modified_by": "Administrator", "module": "Stock", "name": "Stock Reposting Settings", diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py index 8b47cd88df6..8b905c481d4 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py @@ -16,6 +16,12 @@ class StockRepostingSettings(Document): if TYPE_CHECKING: from frappe.types import DF +<<<<<<< HEAD +======= + do_not_fetch_incoming_rate_from_serial_no: DF.Check + enable_parallel_reposting: DF.Check + enable_separate_reposting_for_gl: DF.Check +>>>>>>> 2773b7c002 (fix: incoming rate for legacy serial no) end_time: DF.Time | None item_based_reposting: DF.Check limit_reposting_timeslot: DF.Check From bf27f2d869417789a1c4d74c30c6ff3bc5a3d06e Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 20 May 2026 10:40:15 +0530 Subject: [PATCH 02/43] fix: faster range calculation on process period closing voucher (cherry picked from commit ee33574a6d0a8d6b639a55f85dfc1504c086ff7a) --- .../process_period_closing_voucher.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index c63c5652cb3..af9faeb396f 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -69,8 +69,8 @@ class ProcessPeriodClosingVoucher(Document): pcv = frappe.get_doc("Period Closing Voucher", self.parent_pcv) if pcv.is_first_period_closing_voucher(): gl = qb.DocType("GL Entry") - min = qb.from_(gl).select(Min(gl.posting_date)).where(gl.company.eq(pcv.company)).run()[0][0] - max = qb.from_(gl).select(Max(gl.posting_date)).where(gl.company.eq(pcv.company)).run()[0][0] + min = qb.from_(gl).select(Min(gl.posting_date)).run()[0][0] + max = qb.from_(gl).select(Max(gl.posting_date)).run()[0][0] dates = self.get_dates(get_datetime(min), get_datetime(max)) for x in dates: From d81b6ab5dc0ba7b68ac015742f4d8b6c1cf8cd64 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 20 May 2026 11:19:00 +0530 Subject: [PATCH 03/43] refactor: ppcv select with for update and skip locked (cherry picked from commit eba58b28372721e2c8c7563a19e10afa7d8bc5ca) --- .../process_period_closing_voucher.py | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index af9faeb396f..2269c4e3194 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -90,12 +90,16 @@ class ProcessPeriodClosingVoucher(Document): def start_pcv_processing(docname: str): if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]: frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running") - if normal_balances := frappe.db.get_all( - "Process Period Closing Voucher Detail", - filters={"parent": docname, "status": "Queued"}, - fields=["processing_date", "report_type", "parentfield"], - order_by="parentfield, idx, processing_date", - limit=4, + + ppcvd = qb.DocType("Process Period Closing Voucher Detail") + if normal_balances := ( + qb.from_(ppcvd) + .select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield) + .where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued")) + .orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date) + .limit(4) + .for_update(skip_locked=True) + .run(as_dict=True) ): if not is_scheduler_inactive(): for x in normal_balances: @@ -235,12 +239,15 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions): @frappe.whitelist() def schedule_next_date(docname: str): - if to_process := frappe.db.get_all( - "Process Period Closing Voucher Detail", - filters={"parent": docname, "status": "Queued"}, - fields=["processing_date", "report_type", "parentfield"], - order_by="parentfield, idx, processing_date", - limit=1, + ppcvd = qb.DocType("Process Period Closing Voucher Detail") + if to_process := ( + qb.from_(ppcvd) + .select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield) + .where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued")) + .orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date) + .limit(1) + .for_update(skip_locked=True) + .run(as_dict=True) ): if not is_scheduler_inactive(): frappe.db.set_value( From 04e28f95564e1579e25b25f87214a5916a069b87 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 09:31:21 +0000 Subject: [PATCH 04/43] fix: incorrect error message string in sales order (backport #55090) (#55094) Co-authored-by: Shllokkk <140623894+Shllokkk@users.noreply.github.com> fix: incorrect error message string in sales order (#55090) --- erpnext/selling/doctype/sales_order/sales_order.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index a6babe5516c..856a75606bc 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -385,7 +385,7 @@ class SalesOrder(SellingController): and not cint(d.delivered_by_supplier) ): frappe.throw( - _("Delivery warehouse required for stock item {0}").format(d.item_code), WarehouseRequired + _("Source warehouse required for stock item {0}").format(d.item_code), WarehouseRequired ) def validate_with_previous_doc(self): From aa79247c390d0f8506764035434f75f0ffb2ee94 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 16:21:25 +0530 Subject: [PATCH 05/43] fix: set bin details when adding item using update items (backport #55096) (#55097) Co-authored-by: Mihir Kandoi fix: set bin details when adding item using update items (#55096) --- erpnext/controllers/accounts_controller.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index e903ab1ca30..d0d7d58f30d 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -69,6 +69,7 @@ from erpnext.stock.doctype.packed_item.packed_item import make_packing_list from erpnext.stock.get_item_details import ( _get_item_tax_template, _get_item_tax_template_from_item_group, + get_bin_details, get_conversion_factor, get_item_details, get_item_tax_map, @@ -3704,6 +3705,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child child_item.warehouse = get_item_warehouse(item, p_doc, overwrite_warehouse=True) conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor + child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company"))) if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]: # Initialized value will update in parent validation From 1c90c3bbc210a1280fbedc1cac0f2061b97f9428 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Thu, 21 May 2026 11:41:41 +0530 Subject: [PATCH 06/43] fix(stock): remove recalculate current qty function (#55121) --- .../stock_reconciliation.py | 80 ------------------- .../test_stock_reconciliation.py | 2 +- erpnext/stock/stock_ledger.py | 1 - 3 files changed, 1 insertion(+), 82 deletions(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index dfbbdc9254e..9426a2ca869 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -1035,86 +1035,6 @@ class StockReconciliation(StockController): else: self._cancel() - def recalculate_current_qty(self, voucher_detail_no, sle_creation, add_new_sle=False): - from erpnext.stock.stock_ledger import get_valuation_rate - - for row in self.items: - if voucher_detail_no != row.name: - continue - - if row.current_qty < 0: - return - - val_rate = 0.0 - current_qty = 0.0 - if row.current_serial_and_batch_bundle: - current_qty = self.get_current_qty_for_serial_or_batch(row, sle_creation) - elif row.serial_no: - item_dict = get_stock_balance_for( - row.item_code, - row.warehouse, - self.posting_date, - self.posting_time, - row=row, - company=self.company, - ) - - current_qty = item_dict.get("qty") - row.current_serial_no = item_dict.get("serial_nos") - row.current_valuation_rate = item_dict.get("rate") - val_rate = item_dict.get("rate") - elif row.batch_no: - current_qty = get_batch_qty_for_stock_reco( - row.item_code, - row.warehouse, - row.batch_no, - self.posting_date, - self.posting_time, - self.name, - sle_creation, - ) - - precesion = row.precision("current_qty") - if flt(current_qty, precesion) != flt(row.current_qty, precesion): - if not row.serial_no: - val_rate = get_incoming_rate( - frappe._dict( - { - "item_code": row.item_code, - "warehouse": row.warehouse, - "qty": current_qty * -1, - "serial_and_batch_bundle": row.current_serial_and_batch_bundle, - "batch_no": row.batch_no, - "voucher_type": self.doctype, - "voucher_no": self.name, - "company": self.company, - "posting_date": self.posting_date, - "posting_time": self.posting_time, - } - ) - ) - - row.current_valuation_rate = val_rate - row.current_qty = current_qty - row.db_set( - { - "current_qty": row.current_qty, - "current_valuation_rate": row.current_valuation_rate, - "current_amount": flt(row.current_qty * row.current_valuation_rate), - } - ) - - if add_new_sle and not frappe.db.get_value( - "Stock Ledger Entry", - {"voucher_detail_no": row.name, "actual_qty": ("<", 0), "is_cancelled": 0}, - "name", - ): - if not row.current_serial_and_batch_bundle: - self.set_current_serial_and_batch_bundle(voucher_detail_no, save=True) - row.reload() - - self.add_missing_stock_ledger_entry(row, voucher_detail_no, sle_creation) - def add_missing_stock_ledger_entry(self, row, voucher_detail_no, sle_creation): if row.current_qty == 0: return diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index d720ff260ae..795ea870cf1 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -1039,7 +1039,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): sr.reload() self.assertTrue(sr.items[0].serial_and_batch_bundle) - self.assertTrue(sr.items[0].current_serial_and_batch_bundle) + self.assertFalse(sr.items[0].current_serial_and_batch_bundle) def test_not_reconcile_all_batch(self): from erpnext.stock.doctype.batch.batch import get_batch_qty diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index ffdb97ef9a6..d3bd150873b 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1053,7 +1053,6 @@ class update_entries_after: def reset_actual_qty_for_stock_reco(self, sle): doc = frappe.get_doc("Stock Reconciliation", sle.voucher_no) - doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty > 0) if sle.actual_qty < 0: doc.reload() From 08466218d8bb5c71d511bbc402b67328fff31716 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 21 May 2026 14:28:29 +0530 Subject: [PATCH 07/43] chore: fix conflicts Removed legacy code for fetching incoming rates from serial numbers. --- erpnext/stock/deprecated_serial_batch.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index e1a64b3547b..6702067e5fc 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -32,18 +32,6 @@ class DeprecatedSerialNoValuation: # get rate from serial nos within same company incoming_values = 0.0 -<<<<<<< HEAD -======= - posting_datetime = self.sle.posting_datetime - - if not posting_datetime and self.sle.posting_date: - posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) - - do_not_fetch_rate = frappe.db.get_single_value( - "Stock Reposting Settings", "do_not_fetch_incoming_rate_from_serial_no" - ) - ->>>>>>> 2773b7c002 (fix: incoming rate for legacy serial no) for serial_no in serial_nos: sn_details = frappe.db.get_value("Serial No", serial_no, ["purchase_rate", "company"], as_dict=1) if ( From 5557e982bf68d755865ba1af2718e6ff88ce1775 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 21 May 2026 14:30:26 +0530 Subject: [PATCH 08/43] chore: fix conflicts Removed legacy fields related to incoming rate and parallel reposting. --- .../stock_reposting_settings/stock_reposting_settings.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py index 8b905c481d4..c2b3d81c5c7 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py @@ -16,12 +16,7 @@ class StockRepostingSettings(Document): if TYPE_CHECKING: from frappe.types import DF -<<<<<<< HEAD -======= do_not_fetch_incoming_rate_from_serial_no: DF.Check - enable_parallel_reposting: DF.Check - enable_separate_reposting_for_gl: DF.Check ->>>>>>> 2773b7c002 (fix: incoming rate for legacy serial no) end_time: DF.Time | None item_based_reposting: DF.Check limit_reposting_timeslot: DF.Check From 6981599103db1bfcabc513df81c74de156cdb507 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 21 May 2026 14:31:22 +0530 Subject: [PATCH 09/43] chore: fix conflicts Removed fields related to parallel reposting and column breaks, and updated the modified date. --- .../stock_reposting_settings.json | 49 ------------------- 1 file changed, 49 deletions(-) diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json index de638582630..81f4bda30e4 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json @@ -14,16 +14,7 @@ "end_time", "limits_dont_apply_on", "item_based_reposting", -<<<<<<< HEAD -======= - "column_break_mavd", "do_not_fetch_incoming_rate_from_serial_no", - "section_break_dxuf", - "enable_parallel_reposting", - "no_of_parallel_reposting", - "column_break_itvd", - "enable_separate_reposting_for_gl", ->>>>>>> 2773b7c002 (fix: incoming rate for legacy serial no) "errors_notification_section", "notify_reposting_error_to_role" ], @@ -76,41 +67,6 @@ "fieldname": "errors_notification_section", "fieldtype": "Section Break", "label": "Errors Notification" -<<<<<<< HEAD -======= - }, - { - "default": "0", - "depends_on": "eval: doc.item_based_reposting", - "fieldname": "enable_parallel_reposting", - "fieldtype": "Check", - "label": "Enable Parallel Reposting" - }, - { - "default": "4", - "depends_on": "eval: doc.item_based_reposting === 1 && doc.enable_parallel_reposting === 1", - "fieldname": "no_of_parallel_reposting", - "fieldtype": "Int", - "label": "No of Parallel Reposting (Per Item)" - }, - { - "fieldname": "section_break_dxuf", - "fieldtype": "Section Break" - }, - { - "fieldname": "column_break_itvd", - "fieldtype": "Column Break" - }, - { - "default": "0", - "depends_on": "item_based_reposting", - "fieldname": "enable_separate_reposting_for_gl", - "fieldtype": "Check", - "label": "Enable Separate Reposting for GL" - }, - { - "fieldname": "column_break_mavd", - "fieldtype": "Column Break" }, { "default": "0", @@ -118,17 +74,12 @@ "fieldname": "do_not_fetch_incoming_rate_from_serial_no", "fieldtype": "Check", "label": "Do not fetch incoming rate from Serial No" ->>>>>>> 2773b7c002 (fix: incoming rate for legacy serial no) } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], -<<<<<<< HEAD - "modified": "2025-07-08 11:27:46.659056", -======= "modified": "2026-05-15 12:59:34.392491", ->>>>>>> 2773b7c002 (fix: incoming rate for legacy serial no) "modified_by": "Administrator", "module": "Stock", "name": "Stock Reposting Settings", From da8d25d80ada32ccdf2dc903d78ec52d512afd26 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 21 May 2026 14:38:05 +0530 Subject: [PATCH 10/43] chore: fix linters issue Added a setting to control fetching incoming rates for serial numbers. --- erpnext/stock/deprecated_serial_batch.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 6702067e5fc..00a06e98d2e 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -30,6 +30,10 @@ class DeprecatedSerialNoValuation: def get_incoming_value_for_serial_nos(self, serial_nos): from erpnext.stock.utils import get_combine_datetime + do_not_fetch_rate = frappe.db.get_single_value( + "Stock Reposting Settings", "do_not_fetch_incoming_rate_from_serial_no" + ) + # get rate from serial nos within same company incoming_values = 0.0 for serial_no in serial_nos: From 259f499e2550dcaa71907ce2f32965a817a6f376 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 21 May 2026 14:56:35 +0530 Subject: [PATCH 11/43] fix: removed redundant code (cherry picked from commit 14b17cd8a6e1e2273f2688e4375b054916d5017f) --- erpnext/stock/stock_ledger.py | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index d3bd150873b..a50bc4f8325 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -866,15 +866,6 @@ class update_entries_after: if not self.args.get("sle_id"): self.get_dynamic_incoming_outgoing_rate(sle) - if ( - sle.voucher_type == "Stock Reconciliation" - and (sle.serial_and_batch_bundle) - and sle.voucher_detail_no - and not self.args.get("sle_id") - and sle.is_cancelled == 0 - ): - self.reset_actual_qty_for_stock_reco(sle) - if ( sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"] and sle.voucher_detail_no @@ -1051,30 +1042,6 @@ class update_entries_after: if not allow_zero_rate: self.wh_data.valuation_rate = self.get_fallback_rate(sle) - def reset_actual_qty_for_stock_reco(self, sle): - doc = frappe.get_doc("Stock Reconciliation", sle.voucher_no) - - if sle.actual_qty < 0: - doc.reload() - - sle.actual_qty = ( - flt(frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "current_qty")) - * -1 - ) - - if abs(sle.actual_qty) == 0.0: - sle.is_cancelled = 1 - - if sle.serial_and_batch_bundle: - for row in doc.items: - if row.name == sle.voucher_detail_no: - row.db_set("current_serial_and_batch_bundle", "") - - sabb_doc = frappe.get_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle) - sabb_doc.voucher_detail_no = None - sabb_doc.voucher_no = None - sabb_doc.cancel() - def calculate_valuation_for_serial_batch_bundle(self, sle): if not frappe.db.exists("Serial and Batch Bundle", sle.serial_and_batch_bundle): return From c125d1489c17e33321ce81609d36f5f5eb3005aa Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Thu, 21 May 2026 12:15:15 +0530 Subject: [PATCH 12/43] refactor: migrate get_parent_customer_groups to query builder Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 91a2a7b0a0e1015b9725df0bea4e9f4aa5a54a73) --- .../setup/doctype/customer_group/customer_group.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/erpnext/setup/doctype/customer_group/customer_group.py b/erpnext/setup/doctype/customer_group/customer_group.py index 5dd0fd02011..c4a7f8a3c35 100644 --- a/erpnext/setup/doctype/customer_group/customer_group.py +++ b/erpnext/setup/doctype/customer_group/customer_group.py @@ -77,13 +77,11 @@ class CustomerGroup(NestedSet): def get_parent_customer_groups(customer_group): lft, rgt = frappe.db.get_value("Customer Group", customer_group, ["lft", "rgt"]) - - return frappe.db.sql( - """select name from `tabCustomer Group` - where lft <= %s and rgt >= %s - order by lft asc""", - (lft, rgt), - as_dict=True, + return frappe.get_all( + "Customer Group", + filters=[["lft", "<=", lft], ["rgt", ">=", rgt]], + fields=["name"], + order_by="lft asc", ) From 6517ed72b4816b69f080259c6a7135bd82e30fe9 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Thu, 21 May 2026 12:15:26 +0530 Subject: [PATCH 13/43] feat: add get_parent_supplier_groups using query builder Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit cb610b79d2156e8b9c3d78a00ce0f63e4adfbbe1) --- erpnext/setup/doctype/supplier_group/supplier_group.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/setup/doctype/supplier_group/supplier_group.py b/erpnext/setup/doctype/supplier_group/supplier_group.py index 5e1cab40450..a5ff5b8b6b4 100644 --- a/erpnext/setup/doctype/supplier_group/supplier_group.py +++ b/erpnext/setup/doctype/supplier_group/supplier_group.py @@ -70,3 +70,13 @@ class SupplierGroup(NestedSet): def on_trash(self): NestedSet.validate_if_child_exists(self) frappe.utils.nestedset.update_nsm(self) + + +def get_parent_supplier_groups(supplier_group): + lft, rgt = frappe.db.get_value("Supplier Group", supplier_group, ["lft", "rgt"]) + return frappe.get_all( + "Supplier Group", + filters=[["lft", "<=", lft], ["rgt", ">=", rgt]], + fields=["name"], + order_by="lft asc", + ) From 2a91c7229a47ac6a5bb2d8227290b03415bf8baf Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Thu, 21 May 2026 12:16:01 +0530 Subject: [PATCH 14/43] refactor: rewrite get_tax_template using query builder Migrates from raw frappe.db.sql with string interpolation to frappe.qb. Adds hierarchical supplier_group matching (mirrors customer_group behaviour). Removes unused get_customer_group_condition helper. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit f98975f51a62d611f536368671c3dbaf81d61eb9) --- erpnext/accounts/doctype/tax_rule/tax_rule.py | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/erpnext/accounts/doctype/tax_rule/tax_rule.py b/erpnext/accounts/doctype/tax_rule/tax_rule.py index 1c0c0a3c1d6..50a26f2b706 100644 --- a/erpnext/accounts/doctype/tax_rule/tax_rule.py +++ b/erpnext/accounts/doctype/tax_rule/tax_rule.py @@ -8,10 +8,13 @@ import frappe from frappe import _ from frappe.contacts.doctype.address.address import get_default_address from frappe.model.document import Document +from frappe.query_builder import DocType +from frappe.query_builder.functions import IfNull from frappe.utils import cstr from frappe.utils.nestedset import get_root_of from erpnext.setup.doctype.customer_group.customer_group import get_parent_customer_groups +from erpnext.setup.doctype.supplier_group.supplier_group import get_parent_supplier_groups class IncorrectCustomerGroup(frappe.ValidationError): @@ -174,38 +177,44 @@ def get_party_details(party, party_type, args=None): def get_tax_template(posting_date, args): """Get matching tax rule""" args = frappe._dict(args) - conditions = [] + + TaxRule = DocType("Tax Rule") + query = frappe.qb.from_(TaxRule).select("*") if posting_date: - conditions.append( - f"""(from_date is null or from_date <= '{posting_date}') - and (to_date is null or to_date >= '{posting_date}')""" + query = query.where( + (TaxRule.from_date.isnull() | (TaxRule.from_date <= posting_date)) + & (TaxRule.to_date.isnull() | (TaxRule.to_date >= posting_date)) ) else: - conditions.append("(from_date is null) and (to_date is null)") + query = query.where(TaxRule.from_date.isnull() & TaxRule.to_date.isnull()) - conditions.append( - "ifnull(tax_category, '') = {}".format(frappe.db.escape(cstr(args.get("tax_category")), False)) - ) - if "tax_category" in args.keys(): - del args["tax_category"] + def get_group_ancestors(doctype, get_parents, value): + if not value: + value = get_root_of(doctype) + return [""] + [d.name for d in get_parents(value)] + + group_fields = { + "customer_group": ("Customer Group", get_parent_customer_groups), + "supplier_group": ("Supplier Group", get_parent_supplier_groups), + } + + args.setdefault("tax_category", "") for key, value in args.items(): if key == "use_for_shopping_cart": - conditions.append(f"use_for_shopping_cart = {1 if value else 0}") - elif key == "customer_group": - if not value: - value = get_root_of("Customer Group") - customer_group_condition = get_customer_group_condition(value) - conditions.append(f"ifnull({key}, '') in ('', {customer_group_condition})") + query = query.where(TaxRule.use_for_shopping_cart == value) + elif key == "tax_category": + query = query.where(IfNull(TaxRule.tax_category, "") == (value or "")) + elif key in group_fields: + doctype, get_parents = group_fields[key] + query = query.where( + IfNull(TaxRule[key], "").isin(get_group_ancestors(doctype, get_parents, value)) + ) else: - conditions.append(f"ifnull({key}, '') in ('', {frappe.db.escape(cstr(value))})") + query = query.where(IfNull(TaxRule[key], "").isin(["", value or ""])) - tax_rule = frappe.db.sql( - """select * from `tabTax Rule` - where {}""".format(" and ".join(conditions)), - as_dict=True, - ) + tax_rule = query.run(as_dict=True) if not tax_rule: return None @@ -234,11 +243,3 @@ def get_tax_template(posting_date, args): return None return tax_template - - -def get_customer_group_condition(customer_group): - condition = "" - customer_groups = ["%s" % (frappe.db.escape(d.name)) for d in get_parent_customer_groups(customer_group)] - if customer_groups: - condition = ",".join(["%s"] * len(customer_groups)) % (tuple(customer_groups)) - return condition From 960be3e081959f6dff9537d5cab58b466020a5da Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Thu, 21 May 2026 12:16:22 +0530 Subject: [PATCH 15/43] fix: default use_for_shopping_cart to 0 in set_taxes Ensures regular transactions only match tax rules where use_for_shopping_cart = 0, preventing webshop-specific rules from applying to standard documents. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 4d43c74f5f7ebf3cb23ae64f178ba74f26988581) --- erpnext/accounts/party.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index a27d3aab125..a5d940d7b7f 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -743,7 +743,7 @@ def set_taxes( args.update({"tax_type": "Purchase"}) if use_for_shopping_cart: - args.update({"use_for_shopping_cart": use_for_shopping_cart}) + args.update({"use_for_shopping_cart": cint(use_for_shopping_cart)}) return get_tax_template(posting_date, args) From eb96f0429f9251dd9af89f986339202ad066c33f Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Thu, 21 May 2026 12:27:39 +0530 Subject: [PATCH 16/43] test: add tests for supplier group hierarchy and use_for_shopping_cart filter Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 8c4311872569d38ee8c989eff46905825a5728fe) --- .../doctype/tax_rule/test_tax_rule.py | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py index 335b4835630..963199c2ff8 100644 --- a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py +++ b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py @@ -72,6 +72,117 @@ class TestTaxRule(unittest.TestCase): "_Test Sales Taxes and Charges Template - _TC", ) + def test_for_parent_supplier_group(self): + purchase_template = "_Test Purchase Taxes and Charges Template - _TC" + if not frappe.db.exists("Purchase Taxes and Charges Template", purchase_template): + frappe.get_doc( + { + "doctype": "Purchase Taxes and Charges Template", + "title": "_Test Purchase Taxes and Charges Template", + "company": "_Test Company", + "taxes": [ + { + "account_head": "_Test Account VAT - _TC", + "charge_type": "On Net Total", + "description": "VAT", + "doctype": "Purchase Taxes and Charges", + "cost_center": "Main - _TC", + "rate": 6, + } + ], + } + ).insert() + + make_tax_rule( + supplier_group="All Supplier Groups", + tax_type="Purchase", + purchase_tax_template=purchase_template, + priority=1, + use_for_shopping_cart=0, + from_date="2015-01-01", + save=1, + ) + + # "_Test Supplier Group" has "All Supplier Groups" as its parent — should match hierarchically + self.assertEqual( + get_tax_template( + "2015-01-01", + { + "supplier_group": "_Test Supplier Group", + "tax_type": "Purchase", + "use_for_shopping_cart": 0, + }, + ), + purchase_template, + ) + + def test_use_for_shopping_cart_filter(self): + city = "Test Cart City" + # higher priority ensures this rule wins when use_for_shopping_cart is not filtered + make_tax_rule( + customer="_Test Customer", + billing_city=city, + sales_tax_template="_Test Sales Taxes and Charges Template - _TC", + use_for_shopping_cart=0, + priority=2, + save=1, + ) + make_tax_rule( + customer="_Test Customer", + billing_city=city, + sales_tax_template="_Test Sales Taxes and Charges Template 1 - _TC", + use_for_shopping_cart=1, + priority=1, + save=1, + ) + + # Cart request (use_for_shopping_cart=1) filters to cart rules only + self.assertEqual( + get_tax_template( + "2015-01-01", + {"customer": "_Test Customer", "billing_city": city, "use_for_shopping_cart": 1}, + ), + "_Test Sales Taxes and Charges Template 1 - _TC", + ) + + # Non-cart request omits use_for_shopping_cart — no filter is applied, both rules + # are candidates; non-cart rule wins by higher priority + self.assertEqual( + get_tax_template( + "2015-01-01", + {"customer": "_Test Customer", "billing_city": city}, + ), + "_Test Sales Taxes and Charges Template - _TC", + ) + + def test_use_for_shopping_cart_default(self): + city = "Test Default Cart City" + # use_for_shopping_cart not set — Check field defaults to 0 + make_tax_rule( + customer="_Test Customer", + billing_city=city, + sales_tax_template="_Test Sales Taxes and Charges Template - _TC", + use_for_shopping_cart=0, # Default is set to 1. + save=1, + ) + + # Non-cart request (no use_for_shopping_cart in args) matches the rule + self.assertEqual( + get_tax_template( + "2015-01-01", + {"customer": "_Test Customer", "billing_city": city}, + ), + "_Test Sales Taxes and Charges Template - _TC", + ) + + # Cart request (use_for_shopping_cart=1) does not match — rule has default 0 + self.assertIsNone( + get_tax_template( + "2015-01-01", + {"customer": "_Test Customer", "billing_city": city, "use_for_shopping_cart": 1}, + ) + ) + def test_conflict_with_overlapping_dates(self): tax_rule1 = make_tax_rule( customer="_Test Customer", From 429e02e6f9a3ae9e4f03d198bae5ec18058a4a1f Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 20:41:36 +0000 Subject: [PATCH 17/43] chore: migrate Address/Contact custom fields from JSON fixtures to install (backport #55084) (#55087) fixtures to install (backport #55084) --- erpnext/accounts/custom/address.json | 126 ------------------ .../erpnext_integrations/custom/contact.json | 60 --------- erpnext/patches.txt | 1 + .../migrate_address_contact_custom_fields.py | 16 +++ erpnext/setup/install.py | 32 +++++ 5 files changed, 49 insertions(+), 186 deletions(-) delete mode 100644 erpnext/accounts/custom/address.json delete mode 100644 erpnext/erpnext_integrations/custom/contact.json create mode 100644 erpnext/patches/v16_0/migrate_address_contact_custom_fields.py diff --git a/erpnext/accounts/custom/address.json b/erpnext/accounts/custom/address.json deleted file mode 100644 index 5c921da9b7a..00000000000 --- a/erpnext/accounts/custom/address.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "custom_fields": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2018-12-28 22:29:21.828090", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Address", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "tax_category", - "fieldtype": "Link", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 15, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "fax", - "label": "Tax Category", - "length": 0, - "mandatory_depends_on": null, - "modified": "2018-12-28 22:29:21.828090", - "modified_by": "Administrator", - "name": "Address-tax_category", - "no_copy": 0, - "options": "Tax Category", - "owner": "Administrator", - "parent": null, - "parentfield": null, - "parenttype": null, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "translatable": 0, - "unique": 0, - "width": null - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2020-10-14 17:41:40.878179", - "default": "0", - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Address", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "is_your_company_address", - "fieldtype": "Check", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 20, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "linked_with", - "label": "Is Your Company Address", - "length": 0, - "mandatory_depends_on": null, - "modified": "2020-10-14 17:41:40.878179", - "modified_by": "Administrator", - "name": "Address-is_your_company_address", - "no_copy": 0, - "options": null, - "owner": "Administrator", - "parent": null, - "parentfield": null, - "parenttype": null, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "translatable": 0, - "unique": 0, - "width": null - } - ], - "custom_perms": [], - "doctype": "Address", - "property_setters": [], - "sync_on_migrate": 1 -} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/custom/contact.json b/erpnext/erpnext_integrations/custom/contact.json deleted file mode 100644 index 98a4bbc795b..00000000000 --- a/erpnext/erpnext_integrations/custom/contact.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "custom_fields": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2019-12-02 11:00:03.432994", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Contact", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "is_billing_contact", - "fieldtype": "Check", - "hidden": 0, - "idx": 27, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "insert_after": "is_primary_contact", - "label": "Is Billing Contact", - "length": 0, - "modified": "2019-12-02 11:00:03.432994", - "modified_by": "Administrator", - "name": "Contact-is_billing_contact", - "no_copy": 0, - "options": null, - "owner": "Administrator", - "parent": null, - "parentfield": null, - "parenttype": null, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "translatable": 0, - "unique": 0, - "width": null - } - ], - "custom_perms": [], - "doctype": "Contact", - "property_setters": [], - "sync_on_migrate": 1 -} \ No newline at end of file diff --git a/erpnext/patches.txt b/erpnext/patches.txt index acb500e940f..3e467b37b0f 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -434,3 +434,4 @@ erpnext.patches.v16_0.add_portal_redirects erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po erpnext.patches.v16_0.depends_on_inv_dimensions erpnext.patches.v16_0.clear_procedures_from_receivable_report +erpnext.patches.v16_0.migrate_address_contact_custom_fields diff --git a/erpnext/patches/v16_0/migrate_address_contact_custom_fields.py b/erpnext/patches/v16_0/migrate_address_contact_custom_fields.py new file mode 100644 index 00000000000..6edce540eff --- /dev/null +++ b/erpnext/patches/v16_0/migrate_address_contact_custom_fields.py @@ -0,0 +1,16 @@ +import frappe + +from erpnext.setup.install import create_address_and_contact_custom_fields + + +def execute(): + """Replace fixture-based custom fields on Address and Contact with programmatic ones.""" + for custom_field in ( + "Address-tax_category", + "Address-is_your_company_address", + "Contact-is_billing_contact", + ): + if frappe.db.exists("Custom Field", custom_field): + frappe.delete_doc("Custom Field", custom_field, ignore_missing=True, force=True) + + create_address_and_contact_custom_fields() diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index fffe9b9bdf0..8c288b783c1 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -24,6 +24,7 @@ def after_install(): set_single_defaults() create_print_setting_custom_fields() + create_address_and_contact_custom_fields() create_custom_company_links() add_all_roles_to("Administrator") create_default_success_action() @@ -132,6 +133,37 @@ def create_print_setting_custom_fields(): ) +def create_address_and_contact_custom_fields(): + create_custom_fields( + { + "Address": [ + { + "label": _("Tax Category"), + "fieldname": "tax_category", + "fieldtype": "Link", + "options": "Tax Category", + "insert_after": "fax", + }, + { + "label": _("Is Your Company Address"), + "fieldname": "is_your_company_address", + "fieldtype": "Check", + "default": "0", + "insert_after": "linked_with", + }, + ], + "Contact": [ + { + "label": _("Is Billing Contact"), + "fieldname": "is_billing_contact", + "fieldtype": "Check", + "insert_after": "is_primary_contact", + }, + ], + } + ) + + def create_custom_company_links(): """Add link fields to Company in Email Account and Communication. From 034e159ee4cb474d45fd76f44b7cfb95fea4460c Mon Sep 17 00:00:00 2001 From: "Nihantra C. Patel" <141945075+Nihantra-Patel@users.noreply.github.com> Date: Fri, 22 May 2026 12:32:53 +0530 Subject: [PATCH 18/43] perf: skip delink_original_entry during cancellation when Immutable Ledger is enabled (#55130) * perf: get payment ledger and remove update from delink when immutable ledger is enabled * revert: changes of get_payment_ledger_entries * perf: skip delink_original_entry during cancellation when Immutable Ledger is enabled * test: for immutable ledger * test: add posting_date in create_sales_invoice * fix: link validation err with immutable ledger on * test: update testcase of the immutable ledger * refactor(test): simpler test for immutable invariants --------- Co-authored-by: ruthra kumar (cherry picked from commit 9eeccecd303fa30cb975dcdfad14cfd66db5f215) # Conflicts: # erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py # erpnext/accounts/general_ledger.py --- .../test_payment_ledger_entry.py | 83 ++++++++++++++++++- erpnext/accounts/utils.py | 9 +- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py index dd3a936f220..ef3daf6bde0 100644 --- a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py +++ b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py @@ -3,8 +3,9 @@ import frappe from frappe import qb +from frappe.query_builder.functions import Count, Sum from frappe.tests.utils import FrappeTestCase, change_settings -from frappe.utils import nowdate +from frappe.utils import add_days, nowdate from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry @@ -94,6 +95,7 @@ class TestPaymentLedgerEntry(FrappeTestCase): posting_date = nowdate() sinv = create_sales_invoice( + posting_date=posting_date, qty=qty, rate=rate, company=self.company, @@ -535,3 +537,82 @@ class TestPaymentLedgerEntry(FrappeTestCase): # with references removed, deletion should be possible so.delete() self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, so.doctype, so.name) + + @change_settings( + "Accounts Settings", + {"enable_immutable_ledger": 1}, + ) + def test_reverse_entries_on_cancel_for_immutable_ledger(self): + invoice_posting_date = add_days(nowdate(), -5) + gle = qb.DocType("GL Entry") + ple = qb.DocType("Payment Ledger Entry") + + si = self.create_sales_invoice(qty=1, rate=100, posting_date=invoice_posting_date) + + gles_before = ( + qb.from_(gle) + .select( + Count(gle.name), + ) + .where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0)) + .run()[0][0] + ) + ples_before = ( + qb.from_(ple) + .select( + Count(ple.name), + ) + .where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0))) + .run()[0][0] + ) + + si.cancel() + + gles_after = ( + qb.from_(gle) + .select(Count(gle.account)) + .where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0)) + .run()[0][0] + ) + self.assertEqual(gles_after, gles_before * 2) + + ples_after = ( + qb.from_(ple) + .select( + Count(ple.name), + ) + .where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0))) + .run()[0][0] + ) + self.assertEqual(ples_after, ples_before * 2) + + # assert debit/credit are reversed + gl_entries = ( + qb.from_(gle) + .select(gle.account, Sum(gle.debit).as_("total_debit"), Sum(gle.credit).as_("total_credit")) + .where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0)) + .groupby(gle.account) + .run(as_dict=True) + ) + for gl in gl_entries: + with self.subTest(gl=gl): + self.assertEqual(gl.total_debit, gl.total_credit) + + # assert amounts are reversed + pl_entries = ( + qb.from_(ple) + .select(ple.account, Sum(ple.amount).as_("total_amount")) + .where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked == 0)) + .groupby(ple.account) + .run(as_dict=True) + ) + for pl in pl_entries: + with self.subTest(pl=pl): + self.assertEqual(pl.total_amount, 0) + + self.assertFalse( + frappe.db.exists( + "Payment Ledger Entry", + {"voucher_type": si.doctype, "voucher_no": si.name, "delinked": 1}, + ) + ) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 930f632fbf7..557afc865fd 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1934,8 +1934,9 @@ def create_payment_ledger_entry( ple = frappe.get_doc(entry) if cancel: - delink_original_entry(ple, partial_cancel=partial_cancel) - if is_immutable_ledger_enabled(): + if not is_immutable_ledger_enabled(): + delink_original_entry(ple, partial_cancel=partial_cancel) + else: ple.delinked = 0 ple.posting_date = frappe.form_dict.get("posting_date") or getdate() @@ -2027,6 +2028,7 @@ def delink_original_entry(pl_entry, partial_cancel=False): qb.update(ple) .set(ple.modified, now()) .set(ple.modified_by, frappe.session.user) + .set(ple.delinked, True) .where( (ple.company == pl_entry.company) & (ple.account_type == pl_entry.account_type) @@ -2043,9 +2045,6 @@ def delink_original_entry(pl_entry, partial_cancel=False): if partial_cancel: query = query.where(ple.voucher_detail_no == pl_entry.voucher_detail_no) - if not is_immutable_ledger_enabled(): - query = query.set(ple.delinked, True) - query.run() From 425e6c52f411c1bd69ea18fb3ce5e16cf04b6398 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 09:12:19 +0000 Subject: [PATCH 19/43] fix: edit stock uom qty for purchase documents (backport #55135) (#55178) Co-authored-by: Nishka Gosalia <58264710+nishkagosalia@users.noreply.github.com> fix: edit stock uom qty for purchase documents (#55135) --- erpnext/stock/doctype/stock_settings/stock_settings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index c6c6bd49488..f573c35925b 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -294,9 +294,8 @@ def clean_all_descriptions(): @frappe.whitelist() def get_enable_stock_uom_editing(): - return frappe.get_cached_value( + return frappe.get_single_value( "Stock Settings", - None, ["allow_to_edit_stock_uom_qty_for_sales", "allow_to_edit_stock_uom_qty_for_purchase"], as_dict=1, ) From 25739ae217bee2411dd3d8564fd431f1c80ce6b5 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 16:49:10 +0530 Subject: [PATCH 20/43] fix: invalid filter on item_group (backport #55186) (#55187) Co-authored-by: Mihir Kandoi fix: invalid filter on item_group (#55186) --- erpnext/stock/doctype/item/item.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index a1f0fe2aab8..5bf0d54ee26 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -226,13 +226,6 @@ frappe.ui.form.on("Item", { }); frm.set_df_property("is_fixed_asset", "read_only", frm.doc.__onload?.asset_exists ? 1 : 0); frm.toggle_reqd("customer", frm.doc.is_customer_provided_item ? 1 : 0); - frm.set_query("item_group", () => { - return { - filters: { - is_group: 0, - }, - }; - }); }, validate: function (frm) { @@ -411,12 +404,6 @@ $.extend(erpnext.item, { }; }; - frm.fields_dict["item_group"].get_query = function (doc, cdt, cdn) { - return { - filters: [["Item Group", "docstatus", "!=", 2]], - }; - }; - frm.fields_dict["item_defaults"].grid.get_field("deferred_revenue_account").get_query = function ( doc, cdt, From ff442cd8e7611d62d5f187376d3dc6160fd91992 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 11:34:43 +0000 Subject: [PATCH 21/43] fix(stock): apply posting datetime filters while fetching available batches (backport #54976) (#55184) fix(stock): apply posting datetime filters while fetching available batches Co-authored-by: Mihir Kandoi --- erpnext/controllers/queries.py | 15 +++++++++++++++ .../public/js/utils/serial_no_batch_selector.js | 2 ++ 2 files changed, 17 insertions(+) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index dbfa6e7f9fb..dd48a65e5e9 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -17,6 +17,7 @@ from pypika import Order import erpnext from erpnext.accounts.utils import build_qb_match_conditions from erpnext.stock.get_item_details import _get_item_tax_template +from erpnext.stock.utils import get_combine_datetime # searches for active employees @@ -476,6 +477,13 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p .limit(page_len) ) + if not filters.get("is_inward"): + if filters.get("posting_date") and filters.get("posting_time"): + query = query.where( + stock_ledger_entry.posting_datetime + <= get_combine_datetime(filters.posting_date, filters.posting_time) + ) + if not filters.get("include_expired_batches"): query = query.where((batch_table.expiry_date >= expiry_date) | (batch_table.expiry_date.isnull())) @@ -529,6 +537,13 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0 .limit(page_len) ) + if not filters.get("is_inward"): + if filters.get("posting_date") and filters.get("posting_time"): + bundle_query = bundle_query.where( + stock_ledger_entry.posting_datetime + <= get_combine_datetime(filters.posting_date, filters.posting_time) + ) + if not filters.get("include_expired_batches"): bundle_query = bundle_query.where( (batch_table.expiry_date >= expiry_date) | (batch_table.expiry_date.isnull()) diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 002842cad2d..91cda58718c 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -472,6 +472,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { warehouse: this.item.s_warehouse || this.item.t_warehouse || this.item.warehouse, is_inward: is_inward, + posting_date: this.frm.doc.posting_date, + posting_time: this.frm.doc.posting_time, include_expired_batches: include_expired_batches, }, }; From 914576040eb4d30b2f85aa4733268e8b9474315a Mon Sep 17 00:00:00 2001 From: nareshkannasln Date: Fri, 22 May 2026 16:47:46 +0530 Subject: [PATCH 22/43] fix(project): update customer and sales order as no copy (cherry picked from commit 9d8f3863f2e9f2016247477502e77f77dccb1106) # Conflicts: # erpnext/projects/doctype/project/project.json --- erpnext/projects/doctype/project/project.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json index a6b8513298c..c0db91c560b 100644 --- a/erpnext/projects/doctype/project/project.json +++ b/erpnext/projects/doctype/project/project.json @@ -176,6 +176,7 @@ "fieldtype": "Link", "in_global_search": 1, "label": "Customer", + "no_copy": 1, "oldfieldname": "customer", "oldfieldtype": "Link", "options": "Customer", @@ -190,6 +191,7 @@ "fieldname": "sales_order", "fieldtype": "Link", "label": "Sales Order", + "no_copy": 1, "options": "Sales Order" }, { @@ -462,7 +464,11 @@ "index_web_pages_for_search": 1, "links": [], "max_attachments": 4, +<<<<<<< HEAD "modified": "2025-08-21 17:57:58.314809", +======= + "modified": "2026-05-22 16:45:50.762759", +>>>>>>> 9d8f3863f2 (fix(project): update customer and sales order as no copy) "modified_by": "Administrator", "module": "Projects", "name": "Project", From 59e9f5192c56d4bff7c10290e01fb30f8c3f301e Mon Sep 17 00:00:00 2001 From: Nishka Gosalia Date: Sat, 23 May 2026 15:35:19 +0530 Subject: [PATCH 23/43] fix: merge conflicts --- erpnext/projects/doctype/project/project.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json index c0db91c560b..24841972ea4 100644 --- a/erpnext/projects/doctype/project/project.json +++ b/erpnext/projects/doctype/project/project.json @@ -464,11 +464,7 @@ "index_web_pages_for_search": 1, "links": [], "max_attachments": 4, -<<<<<<< HEAD - "modified": "2025-08-21 17:57:58.314809", -======= "modified": "2026-05-22 16:45:50.762759", ->>>>>>> 9d8f3863f2 (fix(project): update customer and sales order as no copy) "modified_by": "Administrator", "module": "Projects", "name": "Project", From 418a7fb3015553884ecb739126793dfd809e00b5 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 09:48:12 +0000 Subject: [PATCH 24/43] fix: consider batchwise valuation in stock ageing report (backport #54919) (#55229) Co-authored-by: Mihir Kandoi --- .../stock/report/stock_ageing/stock_ageing.py | 461 ++++++++++++--- .../report/stock_ageing/test_stock_ageing.py | 533 +++++++++++++++++- .../report/stock_balance/stock_balance.py | 7 +- ...rehouse_wise_item_balance_age_and_value.py | 7 +- 4 files changed, 913 insertions(+), 95 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 7bc96941b0e..79b5c848bf9 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -7,7 +7,7 @@ from operator import itemgetter import frappe from frappe import _ -from frappe.query_builder.functions import Count +from frappe.query_builder.functions import Abs, Count from frappe.utils import cint, date_diff, flt, get_datetime from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -31,7 +31,7 @@ def execute(filters: Filters = None) -> tuple: def format_report_data(filters: Filters, item_details: dict, to_date: str) -> list[dict]: "Returns ordered, formatted data with ranges." - _func = itemgetter(1) + _func = itemgetter(-2) data = [] precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True)) @@ -48,15 +48,13 @@ def format_report_data(filters: Filters, item_details: dict, to_date: str) -> li if not fifo_queue: continue + fifo_queue = normalize_fifo_queue(fifo_queue) + average_age = get_average_age(fifo_queue, to_date) earliest_age = date_diff(to_date, fifo_queue[0][1]) latest_age = date_diff(to_date, fifo_queue[-1][1]) range_values = get_range_age(filters, fifo_queue, to_date, item_dict) - check_and_replace_valuations_if_moving_average( - range_values, details.valuation_method, details.valuation_rate - ) - row = [details.name, details.item_name, details.description, details.item_group, details.brand] if filters.get("show_warehouse_wise_stock"): @@ -78,18 +76,14 @@ def format_report_data(filters: Filters, item_details: dict, to_date: str) -> li return data -def check_and_replace_valuations_if_moving_average(range_values, item_valuation_method, valuation_rate): - if item_valuation_method == "Moving Average" or ( - not item_valuation_method - and frappe.db.get_single_value("Stock Settings", "valuation_method") == "Moving Average" - ): - for i in range(0, len(range_values), 2): - range_values[i + 1] = range_values[i] * valuation_rate +def normalize_fifo_queue(fifo_queue: list) -> list: + """Convert batch valuation slots to the standard [qty, posting_date, value] shape.""" + return [slot[2:] if len(slot) == 5 else slot for slot in fifo_queue] def get_average_age(fifo_queue: list, to_date: str) -> float: batch_age = age_qty = total_qty = 0.0 - for batch in fifo_queue: + for batch in normalize_fifo_queue(fifo_queue): batch_age = date_diff(to_date, batch[1]) if isinstance(batch[0], int | float): @@ -235,7 +229,9 @@ class FIFOSlots: def __init__(self, filters: dict | None = None, sle: list | None = None): self.item_details = {} self.transferred_item_details = {} - self.serial_no_batch_purchase_details = {} + self.serial_no_details = {} + self.batch_no_details = {} + self.batchwise_valuation_by_batch = {} self.filters = filters self.sle = sle @@ -252,10 +248,13 @@ class FIFOSlots: from erpnext.stock.serial_batch_bundle import get_serial_nos_from_bundle stock_ledger_entries = self.sle + use_prefetched_bundle_data = stock_ledger_entries is None bundle_wise_serial_nos = frappe._dict({}) - if stock_ledger_entries is None: + bundle_wise_batch_nos = frappe._dict({}) + if use_prefetched_bundle_data: bundle_wise_serial_nos = self.__get_bundle_wise_serial_nos() + bundle_wise_batch_nos = self.__get_bundle_wise_batch_nos() # prepare single sle voucher detail lookup self.prepare_stock_reco_voucher_wise_count() @@ -287,17 +286,37 @@ class FIFOSlots: d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty) serial_nos = get_serial_nos(d.serial_no) if d.serial_no else [] - if d.serial_and_batch_bundle and d.has_serial_no: - if bundle_wise_serial_nos: - serial_nos = bundle_wise_serial_nos.get(d.serial_and_batch_bundle) or [] - else: - serial_nos = sorted(get_serial_nos_from_bundle(d.serial_and_batch_bundle)) or [] + batch_nos = ( + [ + [ + d.batch_no.upper(), + self.__get_batchwise_valuation(d.batch_no), + abs(d.actual_qty), + abs(d.stock_value_difference), + ] + ] + if d.batch_no + else [] + ) + if d.serial_and_batch_bundle: + if d.has_serial_no: + if use_prefetched_bundle_data: + serial_nos = bundle_wise_serial_nos.get(d.serial_and_batch_bundle) or [] + else: + serial_nos = sorted(get_serial_nos_from_bundle(d.serial_and_batch_bundle)) or [] + elif d.has_batch_no: + if use_prefetched_bundle_data: + batch_nos = bundle_wise_batch_nos.get(d.serial_and_batch_bundle) or [] + else: + batch_nos = self.__get_bundle_wise_batch_nos(d.serial_and_batch_bundle).get( + d.serial_and_batch_bundle, [] + ) serial_nos = self.uppercase_serial_nos(serial_nos) if d.actual_qty > 0: - self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos) + self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos, batch_nos) else: - self.__compute_outgoing_stock(d, fifo_queue, transferred_item_key, serial_nos) + self.__compute_outgoing_stock(d, fifo_queue, transferred_item_key, serial_nos, batch_nos) self.__update_balances(d, key) @@ -322,6 +341,14 @@ class FIFOSlots: "Convert serial nos to uppercase for uniformity." return [sn.upper() for sn in serial_nos] + def __get_batchwise_valuation(self, batch_no: str): + if batch_no not in self.batchwise_valuation_by_batch: + self.batchwise_valuation_by_batch[batch_no] = frappe.db.get_value( + "Batch", batch_no, "use_batchwise_valuation" + ) + + return self.batchwise_valuation_by_batch[batch_no] + def __init_key_stores(self, row: dict) -> tuple: "Initialise keys and FIFO Queue." @@ -334,102 +361,286 @@ class FIFOSlots: return key, fifo_queue, transferred_item_key - def __compute_incoming_stock(self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list): + def __compute_incoming_stock( + self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list + ): "Update FIFO Queue on inward stock." + def set_fifo_queue_for_serial_items(): + valuation = row.stock_value_difference / row.actual_qty + for serial_no in serial_nos: + if self.serial_no_details.get(serial_no): + fifo_queue.append([serial_no, self.serial_no_details.get(serial_no), valuation]) + else: + self.serial_no_details.setdefault(serial_no, row.posting_date) + fifo_queue.append([serial_no, row.posting_date, valuation]) + + def set_fifo_queue_for_batch_items(): + for batch_no, use_batchwise_valuation, qty, stock_value_difference in batch_nos: + qty, stock_value_difference = neutralize_negative_batch_stock( + batch_no, use_batchwise_valuation, qty, stock_value_difference + ) + + if not qty: + continue + + if self.batch_no_details.get(batch_no): + fifo_queue.append( + [ + batch_no, + use_batchwise_valuation, + qty, + self.batch_no_details.get(batch_no), + stock_value_difference, + ] + ) + else: + self.batch_no_details.setdefault(batch_no, row.posting_date) + fifo_queue.append( + [batch_no, use_batchwise_valuation, qty, row.posting_date, stock_value_difference] + ) + + def neutralize_negative_batch_stock(batch_no, use_batchwise_valuation, qty, stock_value_difference): + qty = flt(qty) + stock_value_difference = flt(stock_value_difference) + + if not qty: + return qty, stock_value_difference + + for slot in list(fifo_queue): + if ( + len(slot) != 5 + or slot[0] != batch_no + or slot[1] != use_batchwise_valuation + or flt(slot[2]) >= 0 + ): + continue + + qty_to_adjust = min(qty, abs(flt(slot[2]))) + value_to_adjust = ( + stock_value_difference + if qty_to_adjust == qty + else flt(stock_value_difference * (qty_to_adjust / qty)) + ) + + slot[2] = flt(slot[2]) + qty_to_adjust + slot[3] = row.posting_date + slot[4] = flt(slot[4]) + value_to_adjust + + qty = flt(qty - qty_to_adjust) + stock_value_difference = flt(stock_value_difference - value_to_adjust) + + if not flt(slot[2]) and not flt(slot[4]): + fifo_queue.remove(slot) + + if not qty: + break + + return qty, stock_value_difference + transfer_data = self.transferred_item_details.get(transfer_key) if transfer_data: # inward/outward from same voucher, item & warehouse # eg: Repack with same item, Stock reco for batch item # consume transfer data and add stock to fifo queue - self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row) + self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row, batch_nos) else: - if not serial_nos and not row.get("has_serial_no"): - if fifo_queue and flt(fifo_queue[0][0]) <= 0: - # neutralize 0/negative stock by adding positive stock - fifo_queue[0][0] += flt(row.actual_qty) - fifo_queue[0][1] = row.posting_date - fifo_queue[0][2] += flt(row.stock_value_difference) - else: - fifo_queue.append( - [flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)] - ) - return + if serial_nos and row.get("has_serial_no"): + set_fifo_queue_for_serial_items() + elif batch_nos and row.get("has_batch_no"): + set_fifo_queue_for_batch_items() + elif fifo_queue and flt(fifo_queue[0][0]) <= 0: + # neutralize 0/negative stock by adding positive stock + fifo_queue[0][0] += flt(row.actual_qty) + fifo_queue[0][1] = row.posting_date + fifo_queue[0][2] += flt(row.stock_value_difference) + else: + fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)]) - valuation = row.stock_value_difference / row.actual_qty - for serial_no in serial_nos: - if self.serial_no_batch_purchase_details.get(serial_no): - fifo_queue.append( - [serial_no, self.serial_no_batch_purchase_details.get(serial_no), valuation] - ) - else: - self.serial_no_batch_purchase_details.setdefault(serial_no, row.posting_date) - fifo_queue.append([serial_no, row.posting_date, valuation]) - - def __compute_outgoing_stock(self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list): + def __compute_outgoing_stock( + self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list + ): "Update FIFO Queue on outward stock." if serial_nos: fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_nos] - return + elif batch_nos: + for batch_no, use_batchwise_valuation, qty, stock_value_difference in batch_nos: + items_to_remove = [] - qty_to_pop = abs(row.actual_qty) - stock_value = abs(row.stock_value_difference) + for slot in fifo_queue: + slot_batch_no, slot_use_batchwise_valuation, slot_qty, _, slot_stock_value = slot - while qty_to_pop: - slot = fifo_queue[0] if fifo_queue else [0, None, 0] - if 0 < flt(slot[0]) <= qty_to_pop: - # qty to pop >= slot qty - # if +ve and not enough or exactly same balance in current slot, consume whole slot - qty_to_pop -= flt(slot[0]) - stock_value -= flt(slot[2]) - self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) - elif not fifo_queue: - # negative stock, no balance but qty yet to consume - fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)]) - self.transferred_item_details[transfer_key].append( - [qty_to_pop, row.posting_date, stock_value] - ) - qty_to_pop = 0 - stock_value = 0 - else: - # qty to pop < slot qty, ample balance - # consume actual_qty from first slot - slot[0] = flt(slot[0]) - qty_to_pop - slot[2] = flt(slot[2]) - stock_value - self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1], stock_value]) - qty_to_pop = 0 - stock_value = 0 + if flt(slot_qty) <= 0: + continue - def __adjust_incoming_transfer_qty(self, transfer_data: dict, fifo_queue: list, row: dict): + # Batchwise valuation: consume only from same batch + if use_batchwise_valuation: + if slot_batch_no != batch_no: + continue + # Non-batchwise valuation: consume from any non-batchwise batch + else: + if slot_use_batchwise_valuation: + continue + + if flt(slot_qty) <= qty: + qty -= flt(slot_qty) + stock_value_difference -= flt(slot_stock_value) + self.transferred_item_details[transfer_key].append( + [flt(slot_qty), slot[3], flt(slot_stock_value)] + ) + items_to_remove.append(slot) + else: + slot[2] = flt(slot_qty) - qty + # preserve ledger valuation (moving average / SLE value), not slot proportional value + slot[4] = flt(slot_stock_value) - stock_value_difference + self.transferred_item_details[transfer_key].append( + [qty, slot[3], stock_value_difference] + ) + qty = 0 + stock_value_difference = 0 + break + + for item in items_to_remove: + fifo_queue.remove(item) + + if qty: + fifo_queue.append( + [ + batch_no, + use_batchwise_valuation, + -(qty), + row.posting_date, + -(stock_value_difference), + ] + ) + self.transferred_item_details[transfer_key].append( + [qty, row.posting_date, stock_value_difference] + ) + else: + qty_to_pop = abs(row.actual_qty) + stock_value = abs(row.stock_value_difference) + + while qty_to_pop: + slot = fifo_queue[0] if fifo_queue else [0, None, 0] + if 0 < flt(slot[0]) <= qty_to_pop: + # qty to pop >= slot qty + # if +ve and not enough or exactly same balance in current slot, consume whole slot + qty_to_pop -= flt(slot[0]) + stock_value -= flt(slot[2]) + self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) + elif not fifo_queue: + # negative stock, no balance but qty yet to consume + fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)]) + self.transferred_item_details[transfer_key].append( + [qty_to_pop, row.posting_date, stock_value] + ) + qty_to_pop = 0 + stock_value = 0 + else: + # qty to pop < slot qty, ample balance + # consume actual_qty from first slot + slot[0] = flt(slot[0]) - qty_to_pop + slot[2] = flt(slot[2]) - stock_value + self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1], stock_value]) + qty_to_pop = 0 + stock_value = 0 + + def __adjust_incoming_transfer_qty( + self, transfer_data: dict, fifo_queue: list, row: dict, batch_nos: list | None = None + ): "Add previously removed stock back to FIFO Queue." transfer_qty_to_pop = flt(row.actual_qty) stock_value = flt(row.stock_value_difference) + batch_nos = [list(batch_no) for batch_no in batch_nos or []] + + def is_batch_slot(slot): + return len(slot) == 5 + + def get_incoming_slots(qty, posting_date, value): + if not batch_nos: + return [[qty, posting_date, value]] + + incoming_slots = [] + remaining_qty = flt(qty) + remaining_value = flt(value) + + while remaining_qty and batch_nos: + batch_no, use_batchwise_valuation, batch_qty, _ = batch_nos[0] + batch_qty = flt(batch_qty) + slot_qty = min(batch_qty, remaining_qty) + slot_value = ( + remaining_value + if slot_qty == remaining_qty + else flt(remaining_value * (slot_qty / remaining_qty)) + ) + + incoming_slots.append([batch_no, use_batchwise_valuation, slot_qty, posting_date, slot_value]) + + batch_nos[0][2] = flt(batch_qty - slot_qty) + if not batch_nos[0][2]: + batch_nos.pop(0) + + remaining_qty = flt(remaining_qty - slot_qty) + remaining_value = flt(remaining_value - slot_value) + + if remaining_qty: + incoming_slots.append([remaining_qty, posting_date, remaining_value]) + + return incoming_slots def add_to_fifo_queue(slot): - if fifo_queue and flt(fifo_queue[0][0]) <= 0: - # neutralize 0/negative stock by adding positive stock + matching_negative_batch_slot = next( + ( + existing_slot + for existing_slot in fifo_queue + if is_batch_slot(existing_slot) + and is_batch_slot(slot) + and flt(existing_slot[2]) <= 0 + and existing_slot[0] == slot[0] + and existing_slot[1] == slot[1] + ), + None, + ) + + if ( + fifo_queue + and not is_batch_slot(fifo_queue[0]) + and not is_batch_slot(slot) + and flt(fifo_queue[0][0]) <= 0 + ): fifo_queue[0][0] += flt(slot[0]) fifo_queue[0][1] = slot[1] fifo_queue[0][2] += flt(slot[2]) + elif matching_negative_batch_slot: + matching_negative_batch_slot[2] += flt(slot[2]) + matching_negative_batch_slot[3] = slot[3] + matching_negative_batch_slot[4] += flt(slot[4]) else: fifo_queue.append(slot) while transfer_qty_to_pop: - if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop: + if transfer_data and 0 < flt(transfer_data[0][0]) <= transfer_qty_to_pop: # bucket qty is not enough, consume whole - transfer_qty_to_pop -= transfer_data[0][0] - stock_value -= transfer_data[0][2] - add_to_fifo_queue(transfer_data.pop(0)) + transfer_qty = flt(transfer_data[0][0]) + transfer_date = transfer_data[0][1] + transfer_value = flt(transfer_data[0][2]) + transfer_qty_to_pop -= transfer_qty + stock_value -= transfer_value + for slot in get_incoming_slots(transfer_qty, transfer_date, transfer_value): + add_to_fifo_queue(slot) + transfer_data.pop(0) elif not transfer_data: # transfer bucket is empty, extra incoming qty - add_to_fifo_queue([transfer_qty_to_pop, row.posting_date, stock_value]) + for slot in get_incoming_slots(transfer_qty_to_pop, row.posting_date, stock_value): + add_to_fifo_queue(slot) transfer_qty_to_pop = 0 stock_value = 0 else: # ample bucket qty to consume transfer_data[0][0] -= transfer_qty_to_pop transfer_data[0][2] -= stock_value - add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1], stock_value]) + for slot in get_incoming_slots(transfer_qty_to_pop, transfer_data[0][1], stock_value): + add_to_fifo_queue(slot) transfer_qty_to_pop = 0 stock_value = 0 @@ -441,6 +652,7 @@ class FIFOSlots: self.item_details[key]["total_qty"] += row.actual_qty self.item_details[key]["has_serial_no"] = row.has_serial_no + self.item_details[key]["has_batch_no"] = row.has_batch_no self.item_details[key]["details"].valuation_rate = row.valuation_rate def __aggregate_details_by_item(self, wh_wise_data: dict) -> dict: @@ -464,6 +676,7 @@ class FIFOSlots: item_row["qty_after_transaction"] += flt(row["qty_after_transaction"]) item_row["total_qty"] += flt(row["total_qty"]) item_row["has_serial_no"] = row["has_serial_no"] + item_row["has_batch_no"] = row["has_batch_no"] return item_aggregated_data @@ -482,8 +695,8 @@ class FIFOSlots: item.brand, item.description, item.stock_uom, + item.has_batch_no, item.has_serial_no, - item.valuation_method, sle.actual_qty, sle.stock_value_difference, sle.valuation_rate, @@ -524,33 +737,97 @@ class FIFOSlots: def __get_bundle_wise_serial_nos(self) -> dict: bundle = frappe.qb.DocType("Serial and Batch Bundle") entry = frappe.qb.DocType("Serial and Batch Entry") + sle = frappe.qb.DocType("Stock Ledger Entry") query = ( frappe.qb.from_(bundle) .join(entry) .on(bundle.name == entry.parent) .select(bundle.name, entry.serial_no) + .where((bundle.docstatus == 1) & (entry.serial_no.isnotnull())) + ) + + to_date = get_datetime(self.filters.get("to_date") + " 23:59:59") + query = ( + query.join(sle) + .on(sle.serial_and_batch_bundle == bundle.name) .where( - (bundle.docstatus == 1) - & (entry.serial_no.isnotnull()) - & (bundle.company == self.filters.get("company")) - & (bundle.posting_date <= self.filters.get("to_date")) + (sle.company == self.filters.get("company")) + & (sle.posting_datetime <= to_date) + & (sle.is_cancelled != 1) ) ) for field in ["item_code"]: if self.filters.get(field): - query = query.where(bundle[field] == self.filters.get(field)) + query = query.where(sle[field] == self.filters.get(field)) if self.filters.get("warehouse"): - query = self.__get_warehouse_conditions(bundle, query) + query = self.__get_warehouse_conditions(sle, query) bundle_wise_serial_nos = frappe._dict({}) - for bundle_name, serial_no in query.run(): + for bundle_name, serial_no in query.distinct().run(): bundle_wise_serial_nos.setdefault(bundle_name, []).append(serial_no) return bundle_wise_serial_nos + def __get_bundle_wise_batch_nos(self, sabb_name=None) -> dict: + bundle = frappe.qb.DocType("Serial and Batch Bundle") + entry = frappe.qb.DocType("Serial and Batch Entry") + batch = frappe.qb.DocType("Batch") + sle = frappe.qb.DocType("Stock Ledger Entry") + + query = ( + frappe.qb.from_(bundle) + .join(entry) + .on(bundle.name == entry.parent) + .join(batch) + .on(entry.batch_no == batch.name) + .select( + bundle.name, + entry.batch_no, + batch.use_batchwise_valuation, + Abs(entry.qty).as_("qty"), + Abs(entry.stock_value_difference).as_("stock_value_difference"), + ) + .where((bundle.docstatus == 1) & (entry.batch_no.isnotnull())) + ) + + if sabb_name: + query = query.where(bundle.name == sabb_name) + else: + to_date = get_datetime(self.filters.get("to_date") + " 23:59:59") + query = ( + query.join(sle) + .on(sle.serial_and_batch_bundle == bundle.name) + .where( + (sle.company == self.filters.get("company")) + & (sle.posting_datetime <= to_date) + & (sle.is_cancelled != 1) + ) + ) + + for field in ["item_code"]: + if self.filters.get(field): + query = query.where(sle[field] == self.filters.get(field)) + + if self.filters.get("warehouse"): + query = self.__get_warehouse_conditions(sle, query) + + bundle_wise_batch_nos = frappe._dict({}) + for ( + bundle_name, + batch_no, + use_batchwise_valuation, + qty, + stock_value_difference, + ) in query.distinct().run(): + bundle_wise_batch_nos.setdefault(bundle_name, []).append( + [batch_no.upper(), use_batchwise_valuation, qty, stock_value_difference] + ) + + return bundle_wise_batch_nos + def __get_item_query(self) -> str: item_table = frappe.qb.DocType("Item") @@ -562,7 +839,7 @@ class FIFOSlots: "brand", "item_group", "has_serial_no", - "valuation_method", + "has_batch_no", ) if self.filters.get("item_code"): diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 61616f71d80..af000f1e9dd 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -4,7 +4,11 @@ import frappe from frappe.tests.utils import FrappeTestCase -from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data +from erpnext.stock.report.stock_ageing.stock_ageing import ( + FIFOSlots, + format_report_data, + get_average_age, +) class TestStockAgeing(FrappeTestCase): @@ -868,6 +872,533 @@ class TestStockAgeing(FrappeTestCase): range_valuations = range_values[1::2] self.assertEqual(range_valuations, [15, 7.5, 20, 5]) + def test_batch_item_report_formatting_preserves_mixed_fifo_slots(self): + item_details = { + "Batch Mixed Item": { + "details": frappe._dict( + name="Batch Mixed Item", + item_name="Batch Mixed Item", + description="Batch Mixed Item", + item_group=None, + brand=None, + has_batch_no=True, + stock_uom="Nos", + ), + "fifo_queue": [ + ["SA-BATCH-MIXED-SLOT", 1, 5.0, "2021-12-01", 50.0], + [3.0, "2021-12-02", 30.0], + ], + "has_serial_no": False, + "total_qty": 8.0, + } + } + + report_data = format_report_data(self.filters, item_details, self.filters["to_date"]) + + self.assertEqual(report_data[0][7:15], [8.0, 80.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + + def test_average_age_accepts_batchwise_valuation_slots(self): + fifo_queue = [["SA-BATCH-SLOT", 1, 5.0, "2021-12-01", 50.0]] + + self.assertEqual(get_average_age(fifo_queue, self.filters["to_date"]), 9.0) + + def test_batchwise_valuation(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Valuation", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + def make_batch(batch_id, use_batchwise_valuation): + if not frappe.db.exists("Batch", batch_id): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", use_batchwise_valuation) + + batchwise_above_90 = "SA-BATCHWISE-ABOVE-90" + non_batchwise_above_90 = "SA-NON-BATCHWISE-ABOVE-90" + batchwise_61_90 = "SA-BATCHWISE-61-90" + non_batchwise_61_90 = "SA-NON-BATCHWISE-61-90" + batchwise_31_60 = "SA-BATCHWISE-31-60" + non_batchwise_31_60 = "SA-NON-BATCHWISE-31-60" + batchwise_0_30 = "SA-BATCHWISE-0-30" + non_batchwise_0_30 = "SA-NON-BATCHWISE-0-30" + + for batch_id, use_batchwise_valuation in { + batchwise_above_90: 1, + non_batchwise_above_90: 0, + batchwise_61_90: 1, + non_batchwise_61_90: 0, + batchwise_31_60: 1, + non_batchwise_31_60: 0, + batchwise_0_30: 1, + non_batchwise_0_30: 0, + }.items(): + make_batch(batch_id, use_batchwise_valuation) + + qty_after_transaction = 0 + + def make_sle(posting_date, voucher_no, batch_no, actual_qty, stock_value_difference): + nonlocal qty_after_transaction + + qty_after_transaction += actual_qty + return frappe._dict( + name=item_code, + actual_qty=actual_qty, + qty_after_transaction=qty_after_transaction, + stock_value_difference=stock_value_difference, + warehouse="WH 1", + posting_date=posting_date, + voucher_type="Stock Entry", + voucher_no=voucher_no, + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ) + + sle = [ + make_sle("2021-08-01", "001", batchwise_above_90, 50, 500), + make_sle("2021-08-10", "002", non_batchwise_above_90, 60, 600), + make_sle("2021-08-20", "003", batchwise_above_90, -10, -100), + make_sle("2021-09-01", "004", non_batchwise_above_90, -15, -150), + make_sle("2021-09-20", "005", batchwise_61_90, 40, 400), + make_sle("2021-09-25", "006", non_batchwise_61_90, 50, 500), + make_sle("2021-09-30", "007", batchwise_61_90, -5, -50), + make_sle("2021-10-05", "008", non_batchwise_above_90, -20, -200), + make_sle("2021-10-20", "009", batchwise_31_60, 30, 300), + make_sle("2021-10-25", "010", non_batchwise_31_60, 40, 400), + make_sle("2021-10-30", "011", batchwise_31_60, -8, -80), + make_sle("2021-11-05", "012", non_batchwise_above_90, -25, -250), + make_sle("2021-11-20", "013", batchwise_0_30, 20, 200), + make_sle("2021-11-25", "014", non_batchwise_0_30, 30, 300), + make_sle("2021-11-30", "015", batchwise_0_30, -6, -60), + make_sle("2021-12-01", "016", non_batchwise_61_90, -10, -100), + ] + + slots = FIFOSlots(self.filters, sle).generate() + item_result = slots[item_code] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual(item_result["total_qty"], 221.0) + self.assertEqual( + item_result["fifo_queue"], + [ + [batchwise_above_90, 1, 40.0, "2021-08-01", 400.0], + [batchwise_61_90, 1, 35.0, "2021-09-20", 350.0], + [non_batchwise_61_90, 0, 40.0, "2021-09-25", 400.0], + [batchwise_31_60, 1, 22.0, "2021-10-20", 220.0], + [non_batchwise_31_60, 0, 40, "2021-10-25", 400], + [batchwise_0_30, 1, 14.0, "2021-11-20", 140.0], + [non_batchwise_0_30, 0, 30, "2021-11-25", 300], + ], + ) + + report_data = format_report_data(self.filters, slots, self.filters["to_date"]) + range_values = report_data[0][7:15] + self.assertEqual(range_values, [44.0, 440.0, 62.0, 620.0, 75.0, 750.0, 40.0, 400.0]) + + def test_batchwise_valuation_same_voucher_transfer(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Transfer", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + def make_batch(batch_id): + if not frappe.db.exists("Batch", batch_id): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", 1) + + source_batch = "SA-BATCHWISE-TRANSFER-SOURCE" + target_batch = "SA-BATCHWISE-TRANSFER-TARGET" + make_batch(source_batch) + make_batch(target_batch) + + sle = [ + frappe._dict( + name=item_code, + actual_qty=20, + qty_after_transaction=20, + stock_value_difference=200, + warehouse="WH 1", + posting_date="2021-09-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=source_batch, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=-15, + qty_after_transaction=5, + stock_value_difference=-150, + warehouse="WH 1", + posting_date="2021-10-01", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=source_batch, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=10, + qty_after_transaction=15, + stock_value_difference=100, + warehouse="WH 1", + posting_date="2021-10-01", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=target_batch, + valuation_rate=10, + ), + ] + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots[item_code] + + self.assertEqual(item_result["total_qty"], 15.0) + self.assertEqual( + item_result["fifo_queue"], + [ + [source_batch, 1, 5.0, "2021-09-01", 50.0], + [target_batch, 1, 10.0, "2021-09-01", 100.0], + ], + ) + self.assertEqual( + fifo_slots.transferred_item_details[("002", item_code, "WH 1")], + [[5.0, "2021-09-01", 50.0]], + ) + + def test_batchwise_valuation_negative_stock_same_voucher(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Negative Stock", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + batch_no = "SA-BATCHWISE-NEGATIVE-STOCK" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_no, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + sle = [ + frappe._dict( + name=item_code, + actual_qty=-10, + qty_after_transaction=-10, + stock_value_difference=-100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ) + ] + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots[item_code] + + self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -10, "2021-12-01", -100]]) + self.assertEqual( + fifo_slots.transferred_item_details[("001", item_code, "WH 1")], [[10, "2021-12-01", 100]] + ) + + sle.append( + frappe._dict( + name=item_code, + actual_qty=6, + qty_after_transaction=-4, + stock_value_difference=60, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ) + ) + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots[item_code] + + self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-12-01", -40.0]]) + self.assertEqual( + fifo_slots.transferred_item_details[("001", item_code, "WH 1")], + [[4.0, "2021-12-01", 40.0]], + ) + + def test_batchwise_valuation_neutralizes_non_head_negative_batch(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Negative Non Head", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + buffer_batch = "SA-BATCHWISE-NEGATIVE-BUFFER" + negative_batch = "SA-BATCHWISE-NEGATIVE-NON-HEAD" + for batch_no in [buffer_batch, negative_batch]: + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_no, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + sle = [ + frappe._dict( + name=item_code, + actual_qty=5, + qty_after_transaction=5, + stock_value_difference=50, + warehouse="WH 1", + posting_date="2021-11-30", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=buffer_batch, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=-10, + qty_after_transaction=-5, + stock_value_difference=-100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=negative_batch, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=6, + qty_after_transaction=1, + stock_value_difference=60, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=negative_batch, + valuation_rate=10, + ), + ] + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots[item_code] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual( + item_result["fifo_queue"], + [ + [buffer_batch, 1, 5, "2021-11-30", 50], + [negative_batch, 1, -4.0, "2021-12-01", -40.0], + ], + ) + self.assertEqual( + fifo_slots.transferred_item_details[("002", item_code, "WH 1")], + [[4.0, "2021-12-01", 40.0]], + ) + + def test_batchwise_valuation_negative_stock_later_voucher(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Negative Later Voucher", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + batch_no = "SA-BATCHWISE-NEGATIVE-LATER-VOUCHER" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_no, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + sle = [ + frappe._dict( + name=item_code, + actual_qty=-10, + qty_after_transaction=-10, + stock_value_difference=-100, + warehouse="WH 1", + posting_date="2021-11-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=6, + qty_after_transaction=-4, + stock_value_difference=60, + warehouse="WH 1", + posting_date="2021-11-10", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ), + ] + + slots = FIFOSlots(self.filters, sle).generate() + item_result = slots[item_code] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual(item_result["total_qty"], -4.0) + self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]]) + + def test_batchwise_valuation_stock_reconciliation_with_bundle(self): + from frappe.utils import add_days, getdate, nowdate + + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + get_batch_from_bundle, + ) + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_stock_reconciliation, + ) + + suffix = frappe.generate_hash(length=8).upper() + item_code = make_item( + f"Test Stock Ageing Batch Reco {suffix}", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": f"SA-RECO-{suffix}-.###", + "valuation_method": "FIFO", + }, + ).name + warehouse = "_Test Warehouse - _TC" + base_date = nowdate() + + opening_reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=12, + rate=10, + posting_date=add_days(base_date, -2), + posting_time="10:00:00", + ) + batch_no = get_batch_from_bundle(opening_reco.items[0].serial_and_batch_bundle) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=5, + rate=10, + batch_no=batch_no, + posting_date=add_days(base_date, -1), + posting_time="10:00:00", + ) + + filters = frappe._dict( + company="_Test Company", + to_date=base_date, + ranges=["30", "60", "90"], + item_code=item_code, + ) + slots = FIFOSlots(filters).generate() + item_result = slots[item_code] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual(item_result["total_qty"], 5.0) + self.assertEqual( + item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]] + ) + def generate_item_and_item_wh_wise_slots(filters, sle): "Return results with and without 'show_warehouse_wise_stock'" diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index f219cc23dce..0f09cc867c2 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -15,7 +15,11 @@ from frappe.utils.nestedset import get_descendants_of import erpnext from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter -from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age +from erpnext.stock.report.stock_ageing.stock_ageing import ( + FIFOSlots, + get_average_age, + normalize_fifo_queue, +) from erpnext.stock.utils import add_additional_uom_columns @@ -123,6 +127,7 @@ class StockBalanceReport: stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0} if opening_fifo_queue: fifo_queue = sorted(filter(_func, opening_fifo_queue), key=_func) + fifo_queue = normalize_fifo_queue(fifo_queue) if not fifo_queue: continue diff --git a/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py b/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py index 0401ba0d954..dc330e40825 100644 --- a/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py +++ b/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py @@ -10,7 +10,11 @@ from frappe import _ from frappe.query_builder.functions import Count from frappe.utils import cint, flt, getdate -from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age +from erpnext.stock.report.stock_ageing.stock_ageing import ( + FIFOSlots, + get_average_age, + normalize_fifo_queue, +) from erpnext.stock.report.stock_analytics.stock_analytics import ( get_item_details, get_items, @@ -68,6 +72,7 @@ def execute(filters=None): fifo_queue = item_ageing[item]["fifo_queue"] average_age = 0.00 if fifo_queue: + fifo_queue = normalize_fifo_queue(fifo_queue) average_age = get_average_age(fifo_queue, filters["to_date"]) row += [average_age] From 238f1685f1fcf33f6bef6a6a18b5c2b02ab27b11 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Sat, 23 May 2026 13:08:11 +0530 Subject: [PATCH 25/43] fix: fg valuation rate in repack entry when multiple FGs (cherry picked from commit a47e4c04f73cf1501b96a9d04cc346a70a86518a) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index a367dc2dc95..3e9e5b5c8a4 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1174,13 +1174,21 @@ class StockEntry(StockController): ) def get_basic_rate_for_repacked_items(self, finished_item_qty, outgoing_items_cost): - finished_items = [d.item_code for d in self.get("items") if d.is_finished_item] + finished_items = [ + d.item_code for d in self.get("items") if d.is_finished_item and not d.set_basic_rate_manually + ] if len(finished_items) == 1: return flt(outgoing_items_cost / finished_item_qty) else: unique_finished_items = set(finished_items) - if len(unique_finished_items) == 1: - total_fg_qty = sum([flt(d.transfer_qty) for d in self.items if d.is_finished_item]) + if unique_finished_items: + total_fg_qty = sum( + [ + flt(d.transfer_qty) + for d in self.items + if d.is_finished_item and not d.set_basic_rate_manually + ] + ) return flt(outgoing_items_cost / total_fg_qty) def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float: From af3e7f53acc3266ff0cc614dc1df59efd6b3fa39 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 12:44:55 +0530 Subject: [PATCH 26/43] refactor: stock ageing report (backport #55231) (#55236) Co-authored-by: Mihir Kandoi --- .../stock/report/stock_ageing/stock_ageing.py | 980 +++++++++++------- .../report/stock_ageing/test_stock_ageing.py | 27 + 2 files changed, 606 insertions(+), 401 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 79b5c848bf9..93fac84d60a 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -15,10 +15,25 @@ from erpnext.stock.valuation import round_off_if_near_zero Filters = frappe._dict +FIFO_POSTING_DATE_INDEX = -2 +FIFO_QTY_INDEX = 0 +FIFO_DATE_INDEX = 1 +FIFO_VALUE_INDEX = 2 + +BATCH_SLOT_SIZE = 5 +BATCH_SLOT_BATCH_INDEX = 0 +BATCH_SLOT_VALUATION_INDEX = 1 +BATCH_SLOT_QTY_INDEX = 2 +BATCH_SLOT_DATE_INDEX = 3 +BATCH_SLOT_VALUE_INDEX = 4 + +AVERAGE_AGE_COLUMN = 6 +MAX_CHART_ITEMS = 10 + def execute(filters: Filters = None) -> tuple: to_date = filters["to_date"] - filters.ranges = [num.strip() for num in filters.range.split(",") if num.strip().isdigit()] + filters.ranges = get_age_ranges(filters.range) columns = get_columns(filters) item_details = FIFOSlots(filters).generate() @@ -29,99 +44,130 @@ def execute(filters: Filters = None) -> tuple: return columns, data, None, chart_data -def format_report_data(filters: Filters, item_details: dict, to_date: str) -> list[dict]: +def get_age_ranges(age_range: str) -> list[str]: + return [num.strip() for num in age_range.split(",") if num.strip().isdigit()] + + +def get_float_precision() -> int: + return cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True)) + + +def format_report_data(filters: Filters, item_details: dict, to_date: str) -> list[list]: "Returns ordered, formatted data with ranges." - _func = itemgetter(-2) data = [] - precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True)) + precision = get_float_precision() for _item, item_dict in item_details.items(): if not flt(item_dict.get("total_qty"), precision): continue - earliest_age, latest_age = 0, 0 details = item_dict["details"] - - fifo_queue = sorted(filter(_func, item_dict["fifo_queue"]), key=_func) - + fifo_queue = get_report_fifo_queue(item_dict["fifo_queue"], details.has_batch_no) if not fifo_queue: continue - fifo_queue = normalize_fifo_queue(fifo_queue) - - average_age = get_average_age(fifo_queue, to_date) - earliest_age = date_diff(to_date, fifo_queue[0][1]) - latest_age = date_diff(to_date, fifo_queue[-1][1]) - range_values = get_range_age(filters, fifo_queue, to_date, item_dict) - - row = [details.name, details.item_name, details.description, details.item_group, details.brand] - - if filters.get("show_warehouse_wise_stock"): - row.append(details.warehouse) - - row.extend( - [ - flt(item_dict.get("total_qty"), precision), - average_age, - *range_values, - earliest_age, - latest_age, - details.stock_uom, - ] - ) - - data.append(row) + data.append(get_report_row(filters, item_dict, fifo_queue, to_date, precision)) return data def normalize_fifo_queue(fifo_queue: list) -> list: """Convert batch valuation slots to the standard [qty, posting_date, value] shape.""" - return [slot[2:] if len(slot) == 5 else slot for slot in fifo_queue] + return [get_batch_report_slot(slot) if is_batch_slot(slot) else slot for slot in fifo_queue] + + +def get_report_fifo_queue(fifo_queue: list, has_batch_no: bool) -> list: + get_posting_date = itemgetter(FIFO_POSTING_DATE_INDEX) + fifo_queue = sorted([slot for slot in fifo_queue if get_posting_date(slot)], key=get_posting_date) + + if has_batch_no: + return normalize_fifo_queue(fifo_queue) + + return fifo_queue + + +def get_batch_report_slot(slot: list) -> list: + if is_batch_slot(slot): + return slot[BATCH_SLOT_QTY_INDEX:] + + return slot + + +def get_report_row(filters: Filters, item_dict: dict, fifo_queue: list, to_date: str, precision: int) -> list: + details = item_dict["details"] + range_values = get_range_age(filters, fifo_queue, to_date, item_dict, precision) + row = [details.name, details.item_name, details.description, details.item_group, details.brand] + + if filters.get("show_warehouse_wise_stock"): + row.append(details.warehouse) + + row.extend( + [ + flt(item_dict.get("total_qty"), precision), + get_average_age(fifo_queue, to_date), + *range_values, + date_diff(to_date, fifo_queue[0][FIFO_DATE_INDEX]), + date_diff(to_date, fifo_queue[-1][FIFO_DATE_INDEX]), + details.stock_uom, + ] + ) + + return row def get_average_age(fifo_queue: list, to_date: str) -> float: - batch_age = age_qty = total_qty = 0.0 - for batch in normalize_fifo_queue(fifo_queue): - batch_age = date_diff(to_date, batch[1]) - - if isinstance(batch[0], int | float): - age_qty += batch_age * batch[0] - total_qty += batch[0] - else: - age_qty += batch_age * 1 - total_qty += 1 + age_qty = total_qty = 0.0 + for slot in normalize_fifo_queue(fifo_queue): + qty = get_slot_qty(slot) + age_qty += date_diff(to_date, slot[FIFO_DATE_INDEX]) * qty + total_qty += qty return flt(age_qty / total_qty, 2) if total_qty else 0.0 -def get_range_age(filters: Filters, fifo_queue: list, to_date: str, item_dict: dict) -> list: - precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True)) +def get_slot_qty(slot: list) -> float: + if is_qty_slot(slot): + return slot[FIFO_QTY_INDEX] + + return 1.0 + + +def get_range_age( + filters: Filters, fifo_queue: list, to_date: str, item_dict: dict, precision: int | None = None +) -> list: + precision = precision if precision is not None else get_float_precision() range_values = [0.0] * ((len(filters.ranges) * 2) + 2) - for item in fifo_queue: - age = flt(date_diff(to_date, item[1])) - qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0 - stock_value = flt(item[2]) - - for i, age_limit in enumerate(filters.ranges): - if age <= flt(age_limit): - i *= 2 - range_values[i] = flt(range_values[i] + qty, precision) - range_values[i + 1] = flt(range_values[i + 1] + stock_value, precision) - if range_values[i] == 0.0 and round_off_if_near_zero(range_values[i + 1], 2) == 0: - range_values[i + 1] = 0.0 - break - else: - range_values[-2] = flt(range_values[-2] + qty, precision) - range_values[-1] = flt(range_values[-1] + stock_value, precision) - if range_values[-2] == 0.0 and round_off_if_near_zero(range_values[-1], 2) == 0: - range_values[-1] = 0.0 + for slot in fifo_queue: + bucket_index = get_age_bucket_index(filters.ranges, slot, to_date) + qty = 1.0 if item_dict["has_serial_no"] else flt(slot[FIFO_QTY_INDEX]) + stock_value = flt(slot[FIFO_VALUE_INDEX]) + add_to_range_bucket(range_values, bucket_index, qty, stock_value, precision) return range_values +def get_age_bucket_index(age_ranges: list, slot: list, to_date: str) -> int: + age = flt(date_diff(to_date, slot[FIFO_DATE_INDEX])) + + for index, age_limit in enumerate(age_ranges): + if age <= flt(age_limit): + return index * 2 + + return len(age_ranges) * 2 + + +def add_to_range_bucket( + range_values: list, bucket_index: int, qty: float, stock_value: float, precision: int +) -> None: + range_values[bucket_index] = flt(range_values[bucket_index] + qty, precision) + range_values[bucket_index + 1] = flt(range_values[bucket_index + 1] + stock_value, precision) + + if range_values[bucket_index] == 0.0 and round_off_if_near_zero(range_values[bucket_index + 1], 2) == 0: + range_values[bucket_index + 1] = 0.0 + + def get_columns(filters: Filters) -> list[dict]: range_columns = [] setup_ageing_columns(filters, range_columns) @@ -189,14 +235,14 @@ def get_chart_data(data: list, filters: Filters) -> dict: if filters.get("show_warehouse_wise_stock"): return {} - data.sort(key=lambda row: row[6], reverse=True) + data.sort(key=lambda row: row[AVERAGE_AGE_COLUMN], reverse=True) - if len(data) > 10: - data = data[:10] + if len(data) > MAX_CHART_ITEMS: + data = data[:MAX_CHART_ITEMS] for row in data: labels.append(row[0]) - datapoints.append(row[6]) + datapoints.append(row[AVERAGE_AGE_COLUMN]) return { "data": {"labels": labels, "datasets": [{"name": _("Average Age"), "values": datapoints}]}, @@ -207,9 +253,9 @@ def get_chart_data(data: list, filters: Filters) -> dict: def setup_ageing_columns(filters: Filters, range_columns: list): prev_range_value = 0 ranges = [] - for range in filters.ranges: - ranges.append(f"{prev_range_value} - {range}") - prev_range_value = cint(range) + 1 + for age_range in filters.ranges: + ranges.append(f"{prev_range_value} - {age_range}") + prev_range_value = cint(age_range) + 1 ranges.append(f"{prev_range_value} - Above") @@ -223,6 +269,14 @@ def add_column(range_columns: list, label: str, fieldname: str, fieldtype: str = range_columns.append(dict(label=label, fieldname=fieldname, fieldtype=fieldtype, width=width)) +def is_batch_slot(slot: list) -> bool: + return len(slot) == BATCH_SLOT_SIZE + + +def is_qty_slot(slot: list) -> bool: + return isinstance(slot[FIFO_QTY_INDEX], int | float) + + class FIFOSlots: "Returns FIFO computed slots of inwarded stock as per date." @@ -237,7 +291,7 @@ class FIFOSlots: def generate(self) -> dict: """ - Returns dict of the foll.g structure: + Returns dict of the following structure: Key = Item A / (Item A, Warehouse A) Key: { 'details' -> Dict: ** item details **, @@ -245,103 +299,128 @@ class FIFOSlots: consumed/updated and maintained via FIFO. ** } """ - from erpnext.stock.serial_batch_bundle import get_serial_nos_from_bundle - stock_ledger_entries = self.sle - use_prefetched_bundle_data = stock_ledger_entries is None - - bundle_wise_serial_nos = frappe._dict({}) - bundle_wise_batch_nos = frappe._dict({}) - if use_prefetched_bundle_data: - bundle_wise_serial_nos = self.__get_bundle_wise_serial_nos() - bundle_wise_batch_nos = self.__get_bundle_wise_batch_nos() + bundle_wise_serial_nos, bundle_wise_batch_nos = self._get_bundle_wise_details(stock_ledger_entries) # prepare single sle voucher detail lookup self.prepare_stock_reco_voucher_wise_count() with frappe.db.unbuffered_cursor(): if stock_ledger_entries is None: - stock_ledger_entries = self.__get_stock_ledger_entries() + stock_ledger_entries = self._get_stock_ledger_entries() - for d in stock_ledger_entries: - key, fifo_queue, transferred_item_key = self.__init_key_stores(d) - prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0) - - if d.voucher_type == "Stock Reconciliation" and ( - not d.batch_no or d.serial_no or d.serial_and_batch_bundle - ): - if d.voucher_detail_no in self.stock_reco_voucher_wise_count: - # for legacy recon with single sle has qty_after_transaction and stock_value_difference without outward entry - # for exisitng handle emptying the existing queue and details. - d.stock_value_difference = flt(d.qty_after_transaction * d.valuation_rate) - d.actual_qty = d.qty_after_transaction - self.item_details[key]["qty_after_transaction"] = 0 - self.item_details[key]["total_qty"] = 0 - fifo_queue.clear() - else: - d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty) - - elif d.voucher_type == "Stock Reconciliation": - # get difference in qty shift as actual qty - d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty) - - serial_nos = get_serial_nos(d.serial_no) if d.serial_no else [] - batch_nos = ( - [ - [ - d.batch_no.upper(), - self.__get_batchwise_valuation(d.batch_no), - abs(d.actual_qty), - abs(d.stock_value_difference), - ] - ] - if d.batch_no - else [] - ) - if d.serial_and_batch_bundle: - if d.has_serial_no: - if use_prefetched_bundle_data: - serial_nos = bundle_wise_serial_nos.get(d.serial_and_batch_bundle) or [] - else: - serial_nos = sorted(get_serial_nos_from_bundle(d.serial_and_batch_bundle)) or [] - elif d.has_batch_no: - if use_prefetched_bundle_data: - batch_nos = bundle_wise_batch_nos.get(d.serial_and_batch_bundle) or [] - else: - batch_nos = self.__get_bundle_wise_batch_nos(d.serial_and_batch_bundle).get( - d.serial_and_batch_bundle, [] - ) - - serial_nos = self.uppercase_serial_nos(serial_nos) - if d.actual_qty > 0: - self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos, batch_nos) - else: - self.__compute_outgoing_stock(d, fifo_queue, transferred_item_key, serial_nos, batch_nos) - - self.__update_balances(d, key) - - # handle serial nos misconsumption - if d.has_serial_no: - qty_after = cint(self.item_details[key]["qty_after_transaction"]) - if qty_after <= 0: - fifo_queue.clear() - elif len(fifo_queue) > qty_after: - fifo_queue[:] = fifo_queue[:qty_after] + for row in stock_ledger_entries: + self._process_stock_ledger_entry(row, bundle_wise_serial_nos, bundle_wise_batch_nos) # Note that stock_ledger_entries is an iterator, you can not reuse it like a list del stock_ledger_entries if not self.filters.get("show_warehouse_wise_stock"): # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) - self.item_details = self.__aggregate_details_by_item(self.item_details) + self.item_details = self._aggregate_details_by_item(self.item_details) return self.item_details + def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]: + if stock_ledger_entries is not None: + return frappe._dict({}), frappe._dict({}) + + return self._get_bundle_wise_serial_nos(), self._get_bundle_wise_batch_nos() + + def _process_stock_ledger_entry( + self, row: dict, bundle_wise_serial_nos: dict, bundle_wise_batch_nos: dict + ) -> None: + key, fifo_queue, transferred_item_key = self._init_key_stores(row) + prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0) + + self._set_stock_reconciliation_actual_qty(row, key, fifo_queue, prev_balance_qty) + serial_nos, batch_nos = self._get_serial_and_batch_nos( + row, bundle_wise_serial_nos, bundle_wise_batch_nos + ) + + if row.actual_qty > 0: + self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos) + else: + self._compute_outgoing_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos) + + self._update_balances(row, key) + self._trim_serial_fifo_queue(row, key, fifo_queue) + + def _set_stock_reconciliation_actual_qty( + self, row: dict, key: tuple, fifo_queue: list, prev_balance_qty: float + ) -> None: + if row.voucher_type != "Stock Reconciliation": + return + + if not row.batch_no or row.serial_no or row.serial_and_batch_bundle: + if row.voucher_detail_no in self.stock_reco_voucher_wise_count: + # Legacy reconciliation with a single SLE has qty_after_transaction and + # stock_value_difference without an outward entry, so reset the queue first. + row.stock_value_difference = flt(row.qty_after_transaction * row.valuation_rate) + row.actual_qty = row.qty_after_transaction + self.item_details[key]["qty_after_transaction"] = 0 + self.item_details[key]["total_qty"] = 0 + fifo_queue.clear() + return + + # Stock reconciliation stores the final balance; FIFO needs the movement delta. + row.actual_qty = flt(row.qty_after_transaction) - flt(prev_balance_qty) + + def _get_serial_and_batch_nos( + self, row: dict, bundle_wise_serial_nos: dict, bundle_wise_batch_nos: dict + ) -> tuple[list, list]: + from erpnext.stock.serial_batch_bundle import get_serial_nos_from_bundle + + serial_nos = get_serial_nos(row.serial_no) if row.serial_no else [] + batch_nos = self._get_row_batch_nos(row) + + if row.serial_and_batch_bundle: + if row.has_serial_no: + if bundle_wise_serial_nos: + serial_nos = bundle_wise_serial_nos.get(row.serial_and_batch_bundle) or [] + else: + serial_nos = sorted(get_serial_nos_from_bundle(row.serial_and_batch_bundle)) or [] + elif row.has_batch_no: + if bundle_wise_batch_nos: + batch_nos = bundle_wise_batch_nos.get(row.serial_and_batch_bundle) or [] + else: + batch_nos = ( + self._get_bundle_wise_batch_nos(row.serial_and_batch_bundle).get( + row.serial_and_batch_bundle + ) + or [] + ) + + return self.uppercase_serial_nos(serial_nos), batch_nos + + def _get_row_batch_nos(self, row: dict) -> list: + if not row.batch_no: + return [] + + return [ + [ + row.batch_no.upper(), + self._get_batchwise_valuation(row.batch_no), + abs(row.actual_qty), + abs(row.stock_value_difference), + ] + ] + + def _trim_serial_fifo_queue(self, row: dict, key: tuple, fifo_queue: list) -> None: + if not row.has_serial_no: + return + + qty_after = cint(self.item_details[key]["qty_after_transaction"]) + if qty_after <= 0: + fifo_queue.clear() + elif len(fifo_queue) > qty_after: + fifo_queue[:] = fifo_queue[:qty_after] + def uppercase_serial_nos(self, serial_nos): "Convert serial nos to uppercase for uniformity." return [sn.upper() for sn in serial_nos] - def __get_batchwise_valuation(self, batch_no: str): + def _get_batchwise_valuation(self, batch_no: str): if batch_no not in self.batchwise_valuation_by_batch: self.batchwise_valuation_by_batch[batch_no] = frappe.db.get_value( "Batch", batch_no, "use_batchwise_valuation" @@ -349,7 +428,7 @@ class FIFOSlots: return self.batchwise_valuation_by_batch[batch_no] - def __init_key_stores(self, row: dict) -> tuple: + def _init_key_stores(self, row: dict) -> tuple: "Initialise keys and FIFO Queue." key = (row.name, row.warehouse) @@ -361,290 +440,389 @@ class FIFOSlots: return key, fifo_queue, transferred_item_key - def __compute_incoming_stock( + def _compute_incoming_stock( self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list ): "Update FIFO Queue on inward stock." - - def set_fifo_queue_for_serial_items(): - valuation = row.stock_value_difference / row.actual_qty - for serial_no in serial_nos: - if self.serial_no_details.get(serial_no): - fifo_queue.append([serial_no, self.serial_no_details.get(serial_no), valuation]) - else: - self.serial_no_details.setdefault(serial_no, row.posting_date) - fifo_queue.append([serial_no, row.posting_date, valuation]) - - def set_fifo_queue_for_batch_items(): - for batch_no, use_batchwise_valuation, qty, stock_value_difference in batch_nos: - qty, stock_value_difference = neutralize_negative_batch_stock( - batch_no, use_batchwise_valuation, qty, stock_value_difference - ) - - if not qty: - continue - - if self.batch_no_details.get(batch_no): - fifo_queue.append( - [ - batch_no, - use_batchwise_valuation, - qty, - self.batch_no_details.get(batch_no), - stock_value_difference, - ] - ) - else: - self.batch_no_details.setdefault(batch_no, row.posting_date) - fifo_queue.append( - [batch_no, use_batchwise_valuation, qty, row.posting_date, stock_value_difference] - ) - - def neutralize_negative_batch_stock(batch_no, use_batchwise_valuation, qty, stock_value_difference): - qty = flt(qty) - stock_value_difference = flt(stock_value_difference) - - if not qty: - return qty, stock_value_difference - - for slot in list(fifo_queue): - if ( - len(slot) != 5 - or slot[0] != batch_no - or slot[1] != use_batchwise_valuation - or flt(slot[2]) >= 0 - ): - continue - - qty_to_adjust = min(qty, abs(flt(slot[2]))) - value_to_adjust = ( - stock_value_difference - if qty_to_adjust == qty - else flt(stock_value_difference * (qty_to_adjust / qty)) - ) - - slot[2] = flt(slot[2]) + qty_to_adjust - slot[3] = row.posting_date - slot[4] = flt(slot[4]) + value_to_adjust - - qty = flt(qty - qty_to_adjust) - stock_value_difference = flt(stock_value_difference - value_to_adjust) - - if not flt(slot[2]) and not flt(slot[4]): - fifo_queue.remove(slot) - - if not qty: - break - - return qty, stock_value_difference - transfer_data = self.transferred_item_details.get(transfer_key) if transfer_data: # inward/outward from same voucher, item & warehouse # eg: Repack with same item, Stock reco for batch item # consume transfer data and add stock to fifo queue - self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row, batch_nos) + self._adjust_incoming_transfer_qty( + transfer_data, + fifo_queue, + row, + batch_nos, + serial_nos=serial_nos if row.get("has_serial_no") else None, + ) + elif serial_nos and row.get("has_serial_no"): + self._add_serial_fifo_slots(row, fifo_queue, serial_nos) + elif batch_nos and row.get("has_batch_no"): + self._add_batch_fifo_slots(row, fifo_queue, batch_nos) + elif fifo_queue and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0: + self._add_to_negative_fifo_head(row, fifo_queue) else: - if serial_nos and row.get("has_serial_no"): - set_fifo_queue_for_serial_items() - elif batch_nos and row.get("has_batch_no"): - set_fifo_queue_for_batch_items() - elif fifo_queue and flt(fifo_queue[0][0]) <= 0: - # neutralize 0/negative stock by adding positive stock - fifo_queue[0][0] += flt(row.actual_qty) - fifo_queue[0][1] = row.posting_date - fifo_queue[0][2] += flt(row.stock_value_difference) - else: - fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)]) + fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)]) - def __compute_outgoing_stock( + def _add_serial_fifo_slots(self, row: dict, fifo_queue: list, serial_nos: list) -> None: + valuation = row.stock_value_difference / row.actual_qty + for serial_no in serial_nos: + posting_date = self.serial_no_details.setdefault(serial_no, row.posting_date) + fifo_queue.append([serial_no, posting_date, valuation]) + + def _add_batch_fifo_slots(self, row: dict, fifo_queue: list, batch_nos: list) -> None: + for batch_no, use_batchwise_valuation, qty, stock_value_difference in batch_nos: + qty, stock_value_difference = self._neutralize_negative_batch_stock( + fifo_queue, row, batch_no, use_batchwise_valuation, qty, stock_value_difference + ) + + if not qty: + continue + + posting_date = self.batch_no_details.setdefault(batch_no, row.posting_date) + fifo_queue.append([batch_no, use_batchwise_valuation, qty, posting_date, stock_value_difference]) + + def _neutralize_negative_batch_stock( + self, + fifo_queue: list, + row: dict, + batch_no: str, + use_batchwise_valuation: bool, + qty: float, + stock_value_difference: float, + ) -> tuple[float, float]: + qty = flt(qty) + stock_value_difference = flt(stock_value_difference) + + if not qty: + return qty, stock_value_difference + + for slot in list(fifo_queue): + if not self._is_matching_negative_batch_slot(slot, batch_no, use_batchwise_valuation): + continue + + qty_to_adjust = min(qty, abs(flt(slot[BATCH_SLOT_QTY_INDEX]))) + value_to_adjust = ( + stock_value_difference + if qty_to_adjust == qty + else flt(stock_value_difference * (qty_to_adjust / qty)) + ) + + slot[BATCH_SLOT_QTY_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX]) + qty_to_adjust + slot[BATCH_SLOT_DATE_INDEX] = row.posting_date + slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_VALUE_INDEX]) + value_to_adjust + + qty = flt(qty - qty_to_adjust) + stock_value_difference = flt(stock_value_difference - value_to_adjust) + + if not flt(slot[BATCH_SLOT_QTY_INDEX]) and not flt(slot[BATCH_SLOT_VALUE_INDEX]): + fifo_queue.remove(slot) + + if not qty: + break + + return qty, stock_value_difference + + def _is_matching_negative_batch_slot( + self, slot: list, batch_no: str, use_batchwise_valuation: bool, include_zero_qty: bool = False + ) -> bool: + if not is_batch_slot(slot): + return False + + qty = flt(slot[BATCH_SLOT_QTY_INDEX]) + + return ( + slot[BATCH_SLOT_BATCH_INDEX] == batch_no + and slot[BATCH_SLOT_VALUATION_INDEX] == use_batchwise_valuation + and (qty <= 0 if include_zero_qty else qty < 0) + ) + + def _add_to_negative_fifo_head(self, row: dict, fifo_queue: list) -> None: + fifo_queue[0][FIFO_QTY_INDEX] += flt(row.actual_qty) + fifo_queue[0][FIFO_DATE_INDEX] = row.posting_date + fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference) + + def _compute_outgoing_stock( self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list ): "Update FIFO Queue on outward stock." if serial_nos: - fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_nos] + self._consume_serial_fifo_slots(fifo_queue, serial_nos) elif batch_nos: - for batch_no, use_batchwise_valuation, qty, stock_value_difference in batch_nos: - items_to_remove = [] - - for slot in fifo_queue: - slot_batch_no, slot_use_batchwise_valuation, slot_qty, _, slot_stock_value = slot - - if flt(slot_qty) <= 0: - continue - - # Batchwise valuation: consume only from same batch - if use_batchwise_valuation: - if slot_batch_no != batch_no: - continue - # Non-batchwise valuation: consume from any non-batchwise batch - else: - if slot_use_batchwise_valuation: - continue - - if flt(slot_qty) <= qty: - qty -= flt(slot_qty) - stock_value_difference -= flt(slot_stock_value) - self.transferred_item_details[transfer_key].append( - [flt(slot_qty), slot[3], flt(slot_stock_value)] - ) - items_to_remove.append(slot) - else: - slot[2] = flt(slot_qty) - qty - # preserve ledger valuation (moving average / SLE value), not slot proportional value - slot[4] = flt(slot_stock_value) - stock_value_difference - self.transferred_item_details[transfer_key].append( - [qty, slot[3], stock_value_difference] - ) - qty = 0 - stock_value_difference = 0 - break - - for item in items_to_remove: - fifo_queue.remove(item) - - if qty: - fifo_queue.append( - [ - batch_no, - use_batchwise_valuation, - -(qty), - row.posting_date, - -(stock_value_difference), - ] - ) - self.transferred_item_details[transfer_key].append( - [qty, row.posting_date, stock_value_difference] - ) + self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos) else: - qty_to_pop = abs(row.actual_qty) - stock_value = abs(row.stock_value_difference) + self._consume_fifo_slots(row, fifo_queue, transfer_key) - while qty_to_pop: - slot = fifo_queue[0] if fifo_queue else [0, None, 0] - if 0 < flt(slot[0]) <= qty_to_pop: - # qty to pop >= slot qty - # if +ve and not enough or exactly same balance in current slot, consume whole slot - qty_to_pop -= flt(slot[0]) - stock_value -= flt(slot[2]) - self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) - elif not fifo_queue: - # negative stock, no balance but qty yet to consume - fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)]) + def _consume_serial_fifo_slots(self, fifo_queue: list, serial_nos: list) -> None: + fifo_queue[:] = [slot for slot in fifo_queue if slot[FIFO_QTY_INDEX] not in serial_nos] + + def _consume_batch_fifo_slots( + self, row: dict, fifo_queue: list, transfer_key: tuple, batch_nos: list + ) -> None: + for batch_no, use_batchwise_valuation, qty, stock_value_difference in batch_nos: + items_to_remove = [] + + for slot in fifo_queue: + if not self._can_consume_batch_slot(slot, batch_no, use_batchwise_valuation): + continue + + slot_qty = flt(slot[BATCH_SLOT_QTY_INDEX]) + slot_stock_value = flt(slot[BATCH_SLOT_VALUE_INDEX]) + + if slot_qty <= qty: + qty -= slot_qty + stock_value_difference -= slot_stock_value self.transferred_item_details[transfer_key].append( - [qty_to_pop, row.posting_date, stock_value] + [slot_qty, slot[BATCH_SLOT_DATE_INDEX], slot_stock_value] ) - qty_to_pop = 0 - stock_value = 0 + items_to_remove.append(slot) else: - # qty to pop < slot qty, ample balance - # consume actual_qty from first slot - slot[0] = flt(slot[0]) - qty_to_pop - slot[2] = flt(slot[2]) - stock_value - self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1], stock_value]) - qty_to_pop = 0 - stock_value = 0 + slot[BATCH_SLOT_QTY_INDEX] = slot_qty - qty + # Preserve ledger valuation (moving average / SLE value), not slot proportional value. + slot[BATCH_SLOT_VALUE_INDEX] = slot_stock_value - stock_value_difference + self.transferred_item_details[transfer_key].append( + [qty, slot[BATCH_SLOT_DATE_INDEX], stock_value_difference] + ) + qty = 0 + stock_value_difference = 0 + break - def __adjust_incoming_transfer_qty( - self, transfer_data: dict, fifo_queue: list, row: dict, batch_nos: list | None = None + for item in items_to_remove: + fifo_queue.remove(item) + + if qty: + self._append_negative_batch_slot( + row, + fifo_queue, + transfer_key, + batch_no, + use_batchwise_valuation, + qty, + stock_value_difference, + ) + + def _can_consume_batch_slot(self, slot: list, batch_no: str, use_batchwise_valuation: bool) -> bool: + if not is_batch_slot(slot): + return False + + if flt(slot[BATCH_SLOT_QTY_INDEX]) <= 0: + return False + + if use_batchwise_valuation: + return slot[BATCH_SLOT_BATCH_INDEX] == batch_no + + return not slot[BATCH_SLOT_VALUATION_INDEX] + + def _append_negative_batch_slot( + self, + row: dict, + fifo_queue: list, + transfer_key: tuple, + batch_no: str, + use_batchwise_valuation: bool, + qty: float, + stock_value_difference: float, + ) -> None: + fifo_queue.append( + [batch_no, use_batchwise_valuation, -(qty), row.posting_date, -(stock_value_difference)] + ) + self.transferred_item_details[transfer_key].append([qty, row.posting_date, stock_value_difference]) + + def _consume_fifo_slots(self, row: dict, fifo_queue: list, transfer_key: tuple) -> None: + qty_to_pop = abs(row.actual_qty) + stock_value = abs(row.stock_value_difference) + + while qty_to_pop: + slot = fifo_queue[0] if fifo_queue else [0, None, 0] + slot_qty = flt(slot[FIFO_QTY_INDEX]) + slot_value = flt(slot[FIFO_VALUE_INDEX]) + + if 0 < slot_qty <= qty_to_pop: + qty_to_pop -= slot_qty + stock_value -= slot_value + self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) + elif not fifo_queue: + fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)]) + self.transferred_item_details[transfer_key].append( + [qty_to_pop, row.posting_date, stock_value] + ) + qty_to_pop = 0 + stock_value = 0 + else: + slot[FIFO_QTY_INDEX] = slot_qty - qty_to_pop + slot[FIFO_VALUE_INDEX] = slot_value - stock_value + self.transferred_item_details[transfer_key].append( + [qty_to_pop, slot[FIFO_DATE_INDEX], stock_value] + ) + qty_to_pop = 0 + stock_value = 0 + + def _adjust_incoming_transfer_qty( + self, + transfer_data: dict, + fifo_queue: list, + row: dict, + batch_nos: list | None = None, + serial_nos: list | None = None, ): "Add previously removed stock back to FIFO Queue." transfer_qty_to_pop = flt(row.actual_qty) stock_value = flt(row.stock_value_difference) batch_nos = [list(batch_no) for batch_no in batch_nos or []] - - def is_batch_slot(slot): - return len(slot) == 5 - - def get_incoming_slots(qty, posting_date, value): - if not batch_nos: - return [[qty, posting_date, value]] - - incoming_slots = [] - remaining_qty = flt(qty) - remaining_value = flt(value) - - while remaining_qty and batch_nos: - batch_no, use_batchwise_valuation, batch_qty, _ = batch_nos[0] - batch_qty = flt(batch_qty) - slot_qty = min(batch_qty, remaining_qty) - slot_value = ( - remaining_value - if slot_qty == remaining_qty - else flt(remaining_value * (slot_qty / remaining_qty)) - ) - - incoming_slots.append([batch_no, use_batchwise_valuation, slot_qty, posting_date, slot_value]) - - batch_nos[0][2] = flt(batch_qty - slot_qty) - if not batch_nos[0][2]: - batch_nos.pop(0) - - remaining_qty = flt(remaining_qty - slot_qty) - remaining_value = flt(remaining_value - slot_value) - - if remaining_qty: - incoming_slots.append([remaining_qty, posting_date, remaining_value]) - - return incoming_slots - - def add_to_fifo_queue(slot): - matching_negative_batch_slot = next( - ( - existing_slot - for existing_slot in fifo_queue - if is_batch_slot(existing_slot) - and is_batch_slot(slot) - and flt(existing_slot[2]) <= 0 - and existing_slot[0] == slot[0] - and existing_slot[1] == slot[1] - ), - None, - ) - - if ( - fifo_queue - and not is_batch_slot(fifo_queue[0]) - and not is_batch_slot(slot) - and flt(fifo_queue[0][0]) <= 0 - ): - fifo_queue[0][0] += flt(slot[0]) - fifo_queue[0][1] = slot[1] - fifo_queue[0][2] += flt(slot[2]) - elif matching_negative_batch_slot: - matching_negative_batch_slot[2] += flt(slot[2]) - matching_negative_batch_slot[3] = slot[3] - matching_negative_batch_slot[4] += flt(slot[4]) - else: - fifo_queue.append(slot) + serial_nos = list(serial_nos or []) while transfer_qty_to_pop: - if transfer_data and 0 < flt(transfer_data[0][0]) <= transfer_qty_to_pop: + if transfer_data and 0 < flt(transfer_data[0][FIFO_QTY_INDEX]) <= transfer_qty_to_pop: # bucket qty is not enough, consume whole - transfer_qty = flt(transfer_data[0][0]) - transfer_date = transfer_data[0][1] - transfer_value = flt(transfer_data[0][2]) + transfer_qty = flt(transfer_data[0][FIFO_QTY_INDEX]) + transfer_date = transfer_data[0][FIFO_DATE_INDEX] + transfer_value = flt(transfer_data[0][FIFO_VALUE_INDEX]) transfer_qty_to_pop -= transfer_qty stock_value -= transfer_value - for slot in get_incoming_slots(transfer_qty, transfer_date, transfer_value): - add_to_fifo_queue(slot) + self._add_incoming_transfer_slots( + fifo_queue, batch_nos, transfer_qty, transfer_date, transfer_value, serial_nos + ) transfer_data.pop(0) elif not transfer_data: # transfer bucket is empty, extra incoming qty - for slot in get_incoming_slots(transfer_qty_to_pop, row.posting_date, stock_value): - add_to_fifo_queue(slot) + self._add_incoming_transfer_slots( + fifo_queue, batch_nos, transfer_qty_to_pop, row.posting_date, stock_value, serial_nos + ) transfer_qty_to_pop = 0 stock_value = 0 else: # ample bucket qty to consume - transfer_data[0][0] -= transfer_qty_to_pop - transfer_data[0][2] -= stock_value - for slot in get_incoming_slots(transfer_qty_to_pop, transfer_data[0][1], stock_value): - add_to_fifo_queue(slot) + transfer_data[0][FIFO_QTY_INDEX] -= transfer_qty_to_pop + transfer_data[0][FIFO_VALUE_INDEX] -= stock_value + self._add_incoming_transfer_slots( + fifo_queue, + batch_nos, + transfer_qty_to_pop, + transfer_data[0][FIFO_DATE_INDEX], + stock_value, + serial_nos, + ) transfer_qty_to_pop = 0 stock_value = 0 - def __update_balances(self, row: dict, key: tuple | str): + def _add_incoming_transfer_slots( + self, + fifo_queue: list, + batch_nos: list, + qty: float, + posting_date: str, + value: float, + serial_nos: list | None = None, + ) -> None: + for slot in self._get_incoming_transfer_slots(batch_nos, qty, posting_date, value, serial_nos): + self._add_transfer_slot_to_fifo_queue(fifo_queue, slot) + + def _get_incoming_transfer_slots( + self, + batch_nos: list, + qty: float, + posting_date: str, + value: float, + serial_nos: list | None = None, + ) -> list: + if serial_nos: + return self._get_serial_incoming_transfer_slots(serial_nos, qty, posting_date, value) + + if not batch_nos: + return [[qty, posting_date, value]] + + incoming_slots = [] + remaining_qty = flt(qty) + remaining_value = flt(value) + + while remaining_qty and batch_nos: + batch_no, use_batchwise_valuation, batch_qty, _ = batch_nos[0] + batch_qty = flt(batch_qty) + slot_qty = min(batch_qty, remaining_qty) + slot_value = ( + remaining_value + if slot_qty == remaining_qty + else flt(remaining_value * (slot_qty / remaining_qty)) + ) + + incoming_slots.append([batch_no, use_batchwise_valuation, slot_qty, posting_date, slot_value]) + + batch_nos[0][2] = flt(batch_qty - slot_qty) + if not batch_nos[0][2]: + batch_nos.pop(0) + + remaining_qty = flt(remaining_qty - slot_qty) + remaining_value = flt(remaining_value - slot_value) + + if remaining_qty: + incoming_slots.append([remaining_qty, posting_date, remaining_value]) + + return incoming_slots + + def _get_serial_incoming_transfer_slots( + self, serial_nos: list, qty: float, posting_date: str, value: float + ) -> list: + incoming_slots = [] + remaining_value = flt(value) + serial_count = min(cint(qty), len(serial_nos)) + + for index in range(serial_count): + serial_no = serial_nos.pop(0) + serial_value = remaining_value if index == serial_count - 1 else flt(value / serial_count) + serial_posting_date = self.serial_no_details.setdefault(serial_no, posting_date) + + incoming_slots.append([serial_no, serial_posting_date, serial_value]) + remaining_value = flt(remaining_value - serial_value) + + return incoming_slots + + def _add_transfer_slot_to_fifo_queue(self, fifo_queue: list, slot: list) -> None: + matching_negative_batch_slot = self._get_matching_negative_batch_slot(fifo_queue, slot) + + if ( + fifo_queue + and is_qty_slot(fifo_queue[0]) + and is_qty_slot(slot) + and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0 + ): + fifo_queue[0][FIFO_QTY_INDEX] += flt(slot[FIFO_QTY_INDEX]) + fifo_queue[0][FIFO_DATE_INDEX] = slot[FIFO_DATE_INDEX] + fifo_queue[0][FIFO_VALUE_INDEX] += flt(slot[FIFO_VALUE_INDEX]) + elif matching_negative_batch_slot: + matching_negative_batch_slot[BATCH_SLOT_QTY_INDEX] += flt(slot[BATCH_SLOT_QTY_INDEX]) + matching_negative_batch_slot[BATCH_SLOT_DATE_INDEX] = slot[BATCH_SLOT_DATE_INDEX] + matching_negative_batch_slot[BATCH_SLOT_VALUE_INDEX] += flt(slot[BATCH_SLOT_VALUE_INDEX]) + if self._is_empty_batch_slot(matching_negative_batch_slot): + fifo_queue.remove(matching_negative_batch_slot) + else: + fifo_queue.append(slot) + + def _is_empty_batch_slot(self, slot: list) -> bool: + return ( + not flt(slot[BATCH_SLOT_QTY_INDEX]) + and round_off_if_near_zero(slot[BATCH_SLOT_VALUE_INDEX], 2) == 0 + ) + + def _get_matching_negative_batch_slot(self, fifo_queue: list, slot: list) -> list | None: + if not is_batch_slot(slot): + return None + + return next( + ( + existing_slot + for existing_slot in fifo_queue + if self._is_matching_negative_batch_slot( + existing_slot, + slot[BATCH_SLOT_BATCH_INDEX], + slot[BATCH_SLOT_VALUATION_INDEX], + include_zero_qty=True, + ) + ), + None, + ) + + def _update_balances(self, row: dict, key: tuple | str): self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction if "total_qty" not in self.item_details[key]: self.item_details[key]["total_qty"] = row.actual_qty @@ -655,7 +833,7 @@ class FIFOSlots: self.item_details[key]["has_batch_no"] = row.has_batch_no self.item_details[key]["details"].valuation_rate = row.valuation_rate - def __aggregate_details_by_item(self, wh_wise_data: dict) -> dict: + def _aggregate_details_by_item(self, wh_wise_data: dict) -> dict: "Aggregate Item-Wh wise data into single Item entry." item_aggregated_data = {} for key, row in wh_wise_data.items(): @@ -680,9 +858,9 @@ class FIFOSlots: return item_aggregated_data - def __get_stock_ledger_entries(self) -> Iterator[dict]: + def _get_stock_ledger_entries(self) -> Iterator[dict]: sle = frappe.qb.DocType("Stock Ledger Entry") - item = self.__get_item_query() # used as derived table in sle query + item = self._get_item_query() # used as derived table in sle query to_date = get_datetime(self.filters.get("to_date") + " 23:59:59") sle_query = ( @@ -719,7 +897,7 @@ class FIFOSlots: ) if self.filters.get("warehouse"): - sle_query = self.__get_warehouse_conditions(sle, sle_query) + sle_query = self._get_warehouse_conditions(sle, sle_query) elif self.filters.get("warehouse_type"): warehouses = frappe.get_all( "Warehouse", @@ -734,7 +912,7 @@ class FIFOSlots: return sle_query.run(as_dict=True, as_iterator=True) - def __get_bundle_wise_serial_nos(self) -> dict: + def _get_bundle_wise_serial_nos(self) -> dict: bundle = frappe.qb.DocType("Serial and Batch Bundle") entry = frappe.qb.DocType("Serial and Batch Entry") sle = frappe.qb.DocType("Stock Ledger Entry") @@ -763,7 +941,7 @@ class FIFOSlots: query = query.where(sle[field] == self.filters.get(field)) if self.filters.get("warehouse"): - query = self.__get_warehouse_conditions(sle, query) + query = self._get_warehouse_conditions(sle, query) bundle_wise_serial_nos = frappe._dict({}) for bundle_name, serial_no in query.distinct().run(): @@ -771,7 +949,7 @@ class FIFOSlots: return bundle_wise_serial_nos - def __get_bundle_wise_batch_nos(self, sabb_name=None) -> dict: + def _get_bundle_wise_batch_nos(self, sabb_name=None) -> dict: bundle = frappe.qb.DocType("Serial and Batch Bundle") entry = frappe.qb.DocType("Serial and Batch Entry") batch = frappe.qb.DocType("Batch") @@ -812,7 +990,7 @@ class FIFOSlots: query = query.where(sle[field] == self.filters.get(field)) if self.filters.get("warehouse"): - query = self.__get_warehouse_conditions(sle, query) + query = self._get_warehouse_conditions(sle, query) bundle_wise_batch_nos = frappe._dict({}) for ( @@ -828,7 +1006,7 @@ class FIFOSlots: return bundle_wise_batch_nos - def __get_item_query(self) -> str: + def _get_item_query(self) -> str: item_table = frappe.qb.DocType("Item") item = frappe.qb.from_("Item").select( @@ -850,7 +1028,7 @@ class FIFOSlots: return item - def __get_warehouse_conditions(self, sle, sle_query) -> str: + def _get_warehouse_conditions(self, sle, sle_query) -> str: warehouse = frappe.qb.DocType("Warehouse") lft, rgt = frappe.db.get_value("Warehouse", self.filters.get("warehouse"), ["lft", "rgt"]) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index af000f1e9dd..601524c74e9 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -902,6 +902,33 @@ class TestStockAgeing(FrappeTestCase): self.assertEqual(get_average_age(fifo_queue, self.filters["to_date"]), 9.0) + def test_serial_transfer_replay_preserves_serial_slots(self): + fifo_slots = FIFOSlots(self.filters, []) + transfer_key = ("001", "Serial Item", "WH 1") + fifo_slots.transferred_item_details[transfer_key] = [[2, "2021-12-01", 20]] + + row = frappe._dict( + name="Serial Item", + actual_qty=2, + stock_value_difference=20, + posting_date="2021-12-05", + has_serial_no=True, + ) + fifo_queue = [] + + fifo_slots._compute_incoming_stock(row, fifo_queue, transfer_key, ["SN-A", "SN-B"], []) + + self.assertEqual(fifo_queue, [["SN-A", "2021-12-01", 10.0], ["SN-B", "2021-12-01", 10.0]]) + self.assertFalse(fifo_slots.transferred_item_details[transfer_key]) + + def test_batch_transfer_replay_removes_zeroed_negative_slot(self): + fifo_slots = FIFOSlots(self.filters, []) + fifo_queue = [["SA-ZERO-BATCH", 1, -4, "2021-12-01", -40]] + + fifo_slots._add_transfer_slot_to_fifo_queue(fifo_queue, ["SA-ZERO-BATCH", 1, 4, "2021-12-02", 40]) + + self.assertEqual(fifo_queue, []) + def test_batchwise_valuation(self): from erpnext.stock.doctype.item.test_item import make_item From d31a051c747923d76559207e5642c02d706d7b28 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 23:21:43 +0530 Subject: [PATCH 27/43] fix(payment_entry): sync paid/received amounts for cross-currency entries (backport #55270) (#55271) Co-authored-by: diptanilsaha fix(payment_entry): sync paid/received amounts for cross-currency entries (#55270) --- .../doctype/payment_entry/payment_entry.js | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 80a8388eea4..f18025c0174 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -822,11 +822,14 @@ frappe.ui.form.on("Payment Entry", { frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate)); let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; if (!frm.doc.received_amount) { - if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { - frm.set_value("received_amount", frm.doc.paid_amount); - } else if (company_currency == frm.doc.paid_to_account_currency) { + frm.set_value("base_received_amount", frm.doc.base_paid_amount); + if (company_currency == frm.doc.paid_to_account_currency) { frm.set_value("received_amount", frm.doc.base_paid_amount); - frm.set_value("base_received_amount", frm.doc.base_paid_amount); + } else if (frm.doc.target_exchange_rate) { + frm.set_value( + "received_amount", + flt(frm.doc.base_paid_amount) / flt(frm.doc.target_exchange_rate) + ); } } frm.trigger("reset_received_amount"); @@ -843,15 +846,14 @@ frappe.ui.form.on("Payment Entry", { ); if (!frm.doc.paid_amount) { - if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { - frm.set_value("paid_amount", frm.doc.received_amount); - if (frm.doc.target_exchange_rate) { - frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate); - } - frm.set_value("base_paid_amount", frm.doc.base_received_amount); - } else if (company_currency == frm.doc.paid_from_account_currency) { + frm.set_value("base_paid_amount", frm.doc.base_received_amount); + if (company_currency == frm.doc.paid_from_account_currency) { frm.set_value("paid_amount", frm.doc.base_received_amount); - frm.set_value("base_paid_amount", frm.doc.base_received_amount); + } else if (frm.doc.source_exchange_rate) { + frm.set_value( + "paid_amount", + flt(frm.doc.base_received_amount) / flt(frm.doc.source_exchange_rate) + ); } } From a797ab3482a9603d40c3992495273aafb062af89 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 25 May 2026 14:11:46 +0530 Subject: [PATCH 28/43] refactor: summarize in background (cherry picked from commit 1c3a9f7dd9aab3ea0ea97ba478366edcaf0d9437) --- .../process_period_closing_voucher.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index 2269c4e3194..38f013829ff 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -285,7 +285,14 @@ def schedule_next_date(docname: str): ) # Ensure both normal and opening balances are processed for all dates if total_no_of_dates == completed: - summarize_and_post_ledger_entries(docname) + frappe.enqueue( + method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries", + queue="long", + timeout="3600", + is_async=True, + enqueue_after_commit=True, + docname=docname, + ) def make_dict_json_compliant(dimension_wise_balance) -> dict: From f28b948e1b36c9a1b54c77f28c53876c01e62a9c Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 25 May 2026 16:02:09 +0530 Subject: [PATCH 29/43] refactor: handle processes stuck in running state in process pcv (cherry picked from commit f414778486e7526e8d4b07eccc627fb858acd6e3) --- .../process_period_closing_voucher.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index 38f013829ff..471bc997837 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -134,9 +134,10 @@ def pause_pcv_processing(docname: str): ppcv = qb.DocType("Process Period Closing Voucher") qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run() + # If a date is stuck in 'Running' state, this will allow it to procced. if queued_dates := frappe.db.get_all( "Process Period Closing Voucher Detail", - filters={"parent": docname, "status": "Queued"}, + filters={"parent": docname, "status": ["in", ["Queued", "Running"]]}, pluck="name", ): ppcvd = qb.DocType("Process Period Closing Voucher Detail") @@ -170,6 +171,9 @@ def resume_pcv_processing(docname: str): ppcvd = qb.DocType("Process Period Closing Voucher Detail") qb.update(ppcvd).set(ppcvd.status, "Queued").where(ppcvd.name.isin(paused_dates)).run() start_pcv_processing(docname) + else: + # If a parent doc is stuck in 'Running' state, will allow it to proceed. + schedule_next_date(docname) def update_default_dimensions(dimension_fields, gl_entry, dimension_values): From b517f26085f67834e447a97a3fa16e6c08e4b291 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 26 May 2026 09:51:40 +0530 Subject: [PATCH 30/43] refactor: atomic summarization step for process pcv (cherry picked from commit 6cb7971342b6425894e4b3b040d4a5b2ed0faa08) --- .../process_period_closing_voucher.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index 471bc997837..e85ebd29e52 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -289,15 +289,22 @@ def schedule_next_date(docname: str): ) # Ensure both normal and opening balances are processed for all dates if total_no_of_dates == completed: - frappe.enqueue( - method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries", - queue="long", - timeout="3600", - is_async=True, - enqueue_after_commit=True, - docname=docname, + from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import ( + is_job_running, ) + job_name = f"summarize_{docname}" + if not is_job_running(job_name): + frappe.enqueue( + method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries", + queue="long", + timeout="3600", + is_async=True, + job_name=job_name, + enqueue_after_commit=True, + docname=docname, + ) + def make_dict_json_compliant(dimension_wise_balance) -> dict: """ From 8b241b45e2c6768ee33515eff4d537c5513158d1 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 10:29:27 +0530 Subject: [PATCH 31/43] fix(stock): remove precision for valuation rate while creating sle (backport #55249) (#55259) Co-authored-by: Sudharsanan11 --- .../stock_reconciliation/stock_reconciliation.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 9426a2ca869..085383f4abf 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -567,15 +567,18 @@ class StockReconciliation(StockController): def calculate_difference_amount(self, item, item_dict): qty_precision = item.precision("qty") - val_precision = item.precision("valuation_rate") + amount_precision = item.precision("amount") new_qty = flt(item.qty, qty_precision) - new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate"), val_precision) + new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate")) current_qty = flt(item_dict.get("qty"), qty_precision) - current_valuation_rate = flt(item_dict.get("rate"), val_precision) + current_valuation_rate = flt(item_dict.get("rate")) - self.difference_amount += (new_qty * new_valuation_rate) - (current_qty * current_valuation_rate) + new_amount = flt(new_qty * new_valuation_rate, amount_precision) + current_amount = flt(current_qty * current_valuation_rate, amount_precision) + + self.difference_amount += new_amount - current_amount def validate_data(self): def _get_msg(row_num, msg): @@ -875,7 +878,7 @@ class StockReconciliation(StockController): "company": self.company, "stock_uom": frappe.db.get_value("Item", row.item_code, "stock_uom"), "is_cancelled": 1 if self.docstatus == 2 else 0, - "valuation_rate": flt(row.valuation_rate, row.precision("valuation_rate")), + "valuation_rate": flt(row.valuation_rate), } ) From 4f89f3a85646061ef39730eb658b9904f7b3b0b3 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 05:11:11 +0000 Subject: [PATCH 32/43] fix: prevent AttributeError in batch query filters (backport #55257) (#55278) Co-authored-by: Pandiyan P fix: prevent AttributeError in batch query filters (#55257) --- erpnext/controllers/queries.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index dd48a65e5e9..b5bcadbdabc 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -481,7 +481,7 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p if filters.get("posting_date") and filters.get("posting_time"): query = query.where( stock_ledger_entry.posting_datetime - <= get_combine_datetime(filters.posting_date, filters.posting_time) + <= get_combine_datetime(filters.get("posting_date"), filters.get("posting_time")) ) if not filters.get("include_expired_batches"): @@ -541,7 +541,7 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0 if filters.get("posting_date") and filters.get("posting_time"): bundle_query = bundle_query.where( stock_ledger_entry.posting_datetime - <= get_combine_datetime(filters.posting_date, filters.posting_time) + <= get_combine_datetime(filters.get("posting_date"), filters.get("posting_time")) ) if not filters.get("include_expired_batches"): From 937eb87932e9fd6fdbacca33638e9012a4f15f77 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 08:25:21 +0000 Subject: [PATCH 33/43] fix: single variant creation error (backport #55286) (#55288) * fix: single variant creation error (cherry picked from commit bda75135c35bc99c7f3564de3a29be415225409d) * feat: allow creation of any number of variants in multiple item variant creation dialog (cherry picked from commit 090c25d848a57c10a3b1ee078832f4b6f43a2743) # Conflicts: # erpnext/controllers/item_variant.py * chore: resolve conflicts --------- Co-authored-by: Mihir Kandoi --- erpnext/controllers/item_variant.py | 14 +++++++++++++- .../controllers/tests/test_item_variant.py | 19 ++++++++++++++++++- erpnext/stock/doctype/item/item.js | 12 +++++++----- erpnext/stock/doctype/item/item.py | 9 +++++++-- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index 0ba090956ca..74b7dfb5c24 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -209,7 +209,9 @@ def create_variant(item, args, use_template_image=False): variant_attributes = [] for d in template.attributes: - variant_attributes.append({"attribute": d.attribute, "attribute_value": args.get(d.attribute)}) + attribute_value = args.get(_(d.attribute)) or args.get(d.attribute) + if attribute_value: + variant_attributes.append({"attribute": d.attribute, "attribute_value": attribute_value}) variant.set("attributes", variant_attributes) copy_attributes_to_variant(template, variant) @@ -228,6 +230,12 @@ def enqueue_multiple_variant_creation(item, args, use_template_image=False): # There can be innumerable attribute combinations, enqueue if isinstance(args, str): variants = json.loads(args) + else: + variants = args + variants = {key: values for key, values in variants.items() if values} + if not variants: + frappe.throw(_("Please select at least one attribute value")) + total_variants = 1 for key in variants: total_variants *= len(variants[key]) @@ -251,6 +259,7 @@ def create_multiple_variants(item, args, use_template_image=False): count = 0 if isinstance(args, str): args = json.loads(args) + args = {key: values for key, values in args.items() if values} template_item = frappe.get_doc("Item", item) args_set = generate_keyed_value_combinations(args) @@ -285,6 +294,9 @@ def generate_keyed_value_combinations(args): """ # Return empty list if empty + if not args: + return [] + args = {key: values for key, values in args.items() if values} if not args: return [] diff --git a/erpnext/controllers/tests/test_item_variant.py b/erpnext/controllers/tests/test_item_variant.py index 68c8d2cd2c3..eacefbb2806 100644 --- a/erpnext/controllers/tests/test_item_variant.py +++ b/erpnext/controllers/tests/test_item_variant.py @@ -3,7 +3,11 @@ import unittest import frappe -from erpnext.controllers.item_variant import copy_attributes_to_variant, make_variant_item_code +from erpnext.controllers.item_variant import ( + copy_attributes_to_variant, + generate_keyed_value_combinations, + make_variant_item_code, +) from erpnext.stock.doctype.item.test_item import set_item_variant_settings from erpnext.stock.doctype.quality_inspection.test_quality_inspection import ( create_quality_inspection_parameter, @@ -17,6 +21,19 @@ class TestItemVariant(unittest.TestCase): variant = make_item_variant() self.assertEqual(variant.get("quality_inspection_template"), "_Test QC Template") + def test_generate_keyed_value_combinations_ignores_empty_attributes(self): + combinations = generate_keyed_value_combinations( + {"Test Colour": ["Red", "Blue"], "Test Size": ["Small", "Large"], "Test Fit": []} + ) + + self.assertEqual(len(combinations), 4) + self.assertNotIn("Test Fit", combinations[0]) + + single_attribute_combinations = generate_keyed_value_combinations( + {"Test Colour": ["Red", "Blue"], "Test Size": []} + ) + self.assertEqual(single_attribute_combinations, [{"Test Colour": "Red"}, {"Test Colour": "Blue"}]) + def create_variant_with_tables(item, args): if isinstance(args, str): diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 5bf0d54ee26..19585b9dabc 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -581,11 +581,10 @@ $.extend(erpnext.item, { default: 0, onchange: function () { let selected_attributes = get_selected_attributes(); - let lengths = []; - Object.keys(selected_attributes).map((key) => { - lengths.push(selected_attributes[key].length); + let lengths = Object.keys(selected_attributes).map((key) => { + return selected_attributes[key].length; }); - if (lengths.includes(0)) { + if (!lengths.length) { me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants")); me.multiple_variant_dialog.disable_primary_action(); } else { @@ -622,7 +621,7 @@ $.extend(erpnext.item, { fieldtype: "HTML", fieldname: "help", options: ``, }, ] @@ -680,6 +679,9 @@ $.extend(erpnext.item, { selected_attributes[attribute_name].push($(opt).attr("data-fieldname")); } }); + if (!selected_attributes[attribute_name].length) { + delete selected_attributes[attribute_name]; + } }); return selected_attributes; diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index b011dcf8fcd..05e8a2f3779 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -855,8 +855,13 @@ class Item(Document): if disabled: frappe.throw(_("Attribute {0} is disabled.").format(frappe.bold(d.attribute))) - if not numeric_values and not frappe.db.exists( - "Item Attribute Value", {"parent": d.attribute, "attribute_value": d.attribute_value} + if ( + not numeric_values + and d.attribute_value + and not frappe.db.exists( + "Item Attribute Value", + {"parent": d.attribute, "attribute_value": d.attribute_value}, + ) ): frappe.throw( _("Attribute Value {0} is not valid for the selected attribute {1}.").format( From 4436585aa07a82ab3704d8091fd99482854aa854 Mon Sep 17 00:00:00 2001 From: Nihantra Patel Date: Mon, 25 May 2026 21:39:54 +0530 Subject: [PATCH 34/43] fix: use passed posting date in make_reverse_gl_entries (cherry picked from commit f040bdf1656545362d86bda44c8986274124622a) --- .../test_period_closing_voucher.py | 60 +++++++++++++++++++ erpnext/accounts/general_ledger.py | 7 ++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py index 2aa484d05a1..15f2025e669 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py @@ -10,6 +10,7 @@ from frappe.utils import today from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.general_ledger import make_reverse_gl_entries from erpnext.accounts.utils import get_fiscal_year @@ -351,6 +352,65 @@ class TestPeriodClosingVoucher(unittest.TestCase): return pcv + @ERPNextTestSuite.change_settings( + "Accounts Settings", + {"enable_immutable_ledger": 1}, + ) + def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self): + company = create_company() + cost_center = create_cost_center("Test Cost Center 1") + + jv = make_journal_entry( + posting_date="2021-03-15", + amount=400, + account1="Cash - TPC", + account2="Sales - TPC", + cost_center=cost_center, + company=company, + save=False, + ) + jv.company = company + jv.save() + jv.submit() + + self.make_period_closing_voucher(posting_date="2021-03-31") + + totals_before_cancel = frappe.db.sql( + """ + select sum(debit) as total_debit, sum(credit) as total_credit + from `tabGL Entry` + where voucher_type=%s and voucher_no=%s and is_cancelled=0 + """, + ("Journal Entry", jv.name), + as_dict=True, + )[0] + + # Passed posting_date is after PCV end date, so cancellation should not fail. + make_reverse_gl_entries( + voucher_type="Journal Entry", + voucher_no=jv.name, + posting_date="2022-01-01", + ) + + totals_after_cancel = frappe.db.sql( + """ + select sum(debit) as total_debit, sum(credit) as total_credit + from `tabGL Entry` + where voucher_type=%s and voucher_no=%s and is_cancelled=0 + """, + ("Journal Entry", jv.name), + as_dict=True, + )[0] + + self.assertEqual( + totals_after_cancel.total_debit, + totals_before_cancel.total_debit * 2, + ) + self.assertEqual( + totals_after_cancel.total_credit, + totals_before_cancel.total_credit * 2, + ) + def create_company(): company = frappe.get_doc( diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index ab86dcfd15c..599173c99f5 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -700,7 +700,12 @@ def make_reverse_gl_entries( check_freezing_date(gl_entries[0]["posting_date"], adv_adj) is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries) - validate_against_pcv(is_opening, gl_entries[0]["posting_date"], gl_entries[0]["company"]) + + # For reverse entries, use the posting_date parameter if provided and valid + # Otherwise fall back to original posting_date + validation_date = posting_date if posting_date else gl_entries[0]["posting_date"] + validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"]) + if partial_cancel: # Partial cancel is only used by `Advance` in separate account feature. # Only cancel GL entries for unlinked reference using `voucher_detail_no` From b8b2141e20eeadf4b06f29f944e0da7567e40acf Mon Sep 17 00:00:00 2001 From: Nihantra Patel Date: Tue, 26 May 2026 14:19:04 +0530 Subject: [PATCH 35/43] test: update testcase (cherry picked from commit 9c39b01f1c838e88cec373bb320fc53332e47df7) --- .../test_period_closing_voucher.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py index 15f2025e669..c5b20ea395f 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py @@ -375,16 +375,6 @@ class TestPeriodClosingVoucher(unittest.TestCase): self.make_period_closing_voucher(posting_date="2021-03-31") - totals_before_cancel = frappe.db.sql( - """ - select sum(debit) as total_debit, sum(credit) as total_credit - from `tabGL Entry` - where voucher_type=%s and voucher_no=%s and is_cancelled=0 - """, - ("Journal Entry", jv.name), - as_dict=True, - )[0] - # Passed posting_date is after PCV end date, so cancellation should not fail. make_reverse_gl_entries( voucher_type="Journal Entry", @@ -402,14 +392,7 @@ class TestPeriodClosingVoucher(unittest.TestCase): as_dict=True, )[0] - self.assertEqual( - totals_after_cancel.total_debit, - totals_before_cancel.total_debit * 2, - ) - self.assertEqual( - totals_after_cancel.total_credit, - totals_before_cancel.total_credit * 2, - ) + self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit) def create_company(): From 46d5395148747f8b4259a418fab98035bc4897b5 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 10:14:19 +0000 Subject: [PATCH 36/43] fix: consumed operation cost calculation (backport #54858) (#55132) Co-authored-by: Mihir Kandoi fix: consumed operation cost calculation (#54858) --- erpnext/controllers/status_updater.py | 4 +- erpnext/manufacturing/doctype/bom/bom.py | 71 +++- .../doctype/job_card/job_card.json | 4 +- .../doctype/job_card/test_job_card.py | 399 +++++++++++++++++- .../doctype/job_card_item/job_card_item.json | 6 +- .../doctype/work_order/work_order.js | 50 +-- .../doctype/work_order/work_order.json | 7 +- .../work_order_item/work_order_item.json | 4 +- .../landed_cost_taxes_and_charges.json | 34 +- .../landed_cost_taxes_and_charges.py | 3 + .../stock/doctype/stock_entry/stock_entry.py | 27 ++ 11 files changed, 555 insertions(+), 54 deletions(-) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index c28a8ff44fc..c3d3627dfe6 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -240,10 +240,10 @@ class StatusUpdater(Document): # get unique transactions to update for d in self.get_all_children(): - if hasattr(d, "qty") and d.qty < 0 and not self.get("is_return"): + if hasattr(d, "qty") and flt(d.qty) < 0 and not self.get("is_return"): frappe.throw(_("For an item {0}, quantity must be positive number").format(d.item_code)) - if hasattr(d, "qty") and d.qty > 0 and self.get("is_return"): + if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"): frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code)) if not frappe.db.get_single_value("Selling Settings", "allow_negative_rates_for_items"): diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 82bfa4cb5c1..1bbc23afeea 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1367,18 +1367,71 @@ def add_non_stock_items_cost(stock_entry, work_order, expense_account): def add_operations_cost(stock_entry, work_order=None, expense_account=None): - from erpnext.stock.doctype.stock_entry.stock_entry import get_operating_cost_per_unit + from erpnext.stock.doctype.stock_entry.stock_entry import ( + get_consumed_operating_cost, + get_operating_cost_per_unit, + ) - operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no) - - if operating_cost_per_unit: - stock_entry.append( - "additional_costs", - { + def append_operating_cost(amount, operation=None, qty=None): + if amount: + row = { "expense_account": expense_account, "description": _("Operating Cost as per Work Order / BOM"), - "amount": operating_cost_per_unit * flt(stock_entry.fg_completed_qty), - }, + "amount": flt( + amount, + frappe.get_precision("Landed Cost Taxes and Charges", "amount"), + ), + "has_operating_cost": 1, + } + if operation: + row["operation_id"] = operation.name + if qty is not None: + row["qty"] = qty + stock_entry.append( + "additional_costs", + row, + ) + + if ( + work_order + and stock_entry.bom_no + and frappe.db.get_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies") + and work_order.get("use_multi_level_bom") + ): + operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no) + append_operating_cost( + operating_cost_per_unit * flt(stock_entry.fg_completed_qty), + qty=flt(stock_entry.fg_completed_qty), + ) + elif work_order and work_order.get("operations"): + for operation in work_order.get("operations"): + qty = flt(stock_entry.fg_completed_qty) + amount = 0 + + if flt(operation.completed_qty): + consumed_cost = get_consumed_operating_cost( + work_order.name, stock_entry.bom_no, operation.name + ) + remaining_cost = flt( + flt(operation.actual_operating_cost) - flt(consumed_cost.get("consumed_cost")), + operation.precision("actual_operating_cost"), + ) + remaining_qty = flt(operation.completed_qty) - flt(consumed_cost.get("consumed_qty")) + + if remaining_cost <= 0 or remaining_qty <= 0: + continue + + qty = min(remaining_qty, flt(stock_entry.fg_completed_qty)) + amount = remaining_cost / remaining_qty * qty + elif work_order.qty: + amount = flt(operation.planned_operating_cost) / flt(work_order.qty) * qty + + append_operating_cost(amount, operation=operation, qty=qty) + else: + operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no) + append_operating_cost( + operating_cost_per_unit * flt(stock_entry.fg_completed_qty), + qty=flt(stock_entry.fg_completed_qty), ) if work_order and work_order.additional_operating_cost and work_order.qty: diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 7f4fbdaef06..ba680df99f9 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "autoname": "naming_series:", "creation": "2018-07-09 17:23:29.518745", "doctype": "DocType", @@ -135,6 +136,7 @@ "fieldname": "wip_warehouse", "fieldtype": "Link", "label": "WIP Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "options": "Warehouse", "reqd": 1 }, @@ -511,7 +513,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2025-08-04 15:47:54.514290", + "modified": "2026-05-12 12:17:17.750857", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index 12205a80a2b..f59eb057de3 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -7,7 +7,7 @@ from typing import Literal import frappe from frappe.test_runner import make_test_records from frappe.tests.utils import FrappeTestCase, change_settings -from frappe.utils import random_string +from frappe.utils import flt, random_string from frappe.utils.data import add_to_date, now, today from erpnext.manufacturing.doctype.job_card.job_card import ( @@ -697,6 +697,403 @@ class TestJobCard(FrappeTestCase): self.assertEqual(wo_doc.process_loss_qty, 2) self.assertEqual(wo_doc.status, "Completed") + def test_op_cost_calculation(self): + from erpnext.manufacturing.doctype.routing.test_routing import ( + create_routing, + setup_bom, + setup_operations, + ) + from erpnext.manufacturing.doctype.work_order.work_order import make_job_card + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_for_wo, + ) + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + suffix = random_string(5) + workstation = make_workstation( + workstation_name=f"Test Workstation Z {suffix}", hour_rate_rent=240, hour_rate_labour=0 + ) + workstation.update( + { + "hour_rate_rent": 240, + "hour_rate_labour": 0, + "hour_rate_electricity": 0, + "hour_rate_consumable": 0, + } + ) + workstation.save() + operations = [ + { + "operation": f"Test Operation A1 {suffix}", + "workstation": workstation.name, + "time_in_mins": 30, + }, + ] + + warehouse = create_warehouse(f"Test Warehouse 123 for Job Card {suffix}") + setup_operations(operations) + + item_code = f"Test Job Card Process Qty Item {suffix}" + for item in [item_code, item_code + "RM 1", item_code + "RM 2"]: + if not frappe.db.exists("Item", item): + make_item( + item, + { + "item_name": item, + "stock_uom": "Nos", + "is_stock_item": 1, + }, + ) + + routing_doc = create_routing(routing_name="Testing Route", operations=operations) + bom_doc = setup_bom( + item_code=item_code, + routing=routing_doc.name, + raw_materials=[item_code + "RM 1", item_code + "RM 2"], + source_warehouse=warehouse, + ) + + for row in bom_doc.items: + make_stock_entry( + item_code=row.item_code, + target=row.source_warehouse, + qty=10, + basic_rate=100, + ) + + wo_doc = make_wo_order_test_record( + production_item=item_code, + bom_no=bom_doc.name, + qty=10, + skip_transfer=1, + wip_warehouse=warehouse, + source_warehouse=warehouse, + ) + + first_job_card = frappe.get_all( + "Job Card", + filters={"work_order": wo_doc.name, "sequence_id": 1}, + fields=["name"], + order_by="sequence_id", + limit=1, + )[0].name + + jc = frappe.get_doc("Job Card", first_job_card) + from_time = "2025-01-01 09:00:00" + for _ in jc.scheduled_time_logs: + jc.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, minutes=1), + "completed_qty": 4, + }, + ) + jc.for_quantity = 4 + jc.save() + jc.submit() + + s1 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 4)) + s1.submit() + + wo_doc.reload() + precision = s1.additional_costs[0].precision("amount") + self.assertEqual( + flt(s1.additional_costs[0].amount, precision), + flt(wo_doc.operations[0].actual_operating_cost, precision), + ) + + make_job_card( + wo_doc.name, + [ + { + "name": wo_doc.operations[0].name, + "operation": operations[0]["operation"], + "workstation": wo_doc.operations[0].workstation, + "qty": 6, + "pending_qty": 6, + } + ], + ) + + job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name}) + from_time = "2025-01-01 10:00:00" + job_card.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, minutes=2), + "completed_qty": 6, + }, + ) + job_card.for_quantity = 6 + job_card.save() + job_card.submit() + + s2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6)) + wo_doc.reload() + precision = s2.additional_costs[0].precision("amount") + self.assertEqual( + flt(s2.additional_costs[0].amount, precision), + flt(wo_doc.operations[0].actual_operating_cost - s1.additional_costs[0].amount, precision), + ) + + @change_settings("Manufacturing Settings", {"overproduction_percentage_for_work_order": 100}) + def test_operating_cost_with_overproduction(self): + from erpnext.manufacturing.doctype.routing.test_routing import ( + create_routing, + setup_bom, + setup_operations, + ) + from erpnext.manufacturing.doctype.work_order.work_order import make_job_card + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_for_wo, + ) + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + suffix = random_string(5) + workstation = make_workstation( + workstation_name=f"Test Workstation for Overproduction {suffix}", + hour_rate_rent=10, + hour_rate_labour=10, + ) + workstation.update( + { + "hour_rate_rent": 10, + "hour_rate_labour": 10, + "hour_rate_electricity": 0, + "hour_rate_consumable": 0, + } + ) + workstation.save() + operations = [ + {"operation": f"Test Operation 1 {suffix}", "workstation": workstation.name, "time_in_mins": 30}, + {"operation": f"Test Operation 2 {suffix}", "workstation": workstation.name, "time_in_mins": 30}, + ] + warehouse = create_warehouse(f"Test Warehouse for Overproduction {suffix}") + setup_operations(operations) + + fg = make_item(f"Test FG for Overproduction {suffix}", {"stock_uom": "Nos", "is_stock_item": 1}) + rm = make_item(f"Test RM for Overproduction {suffix}", {"stock_uom": "Nos", "is_stock_item": 1}) + + routing_doc = create_routing(routing_name=f"Testing Route {suffix}", operations=operations) + bom_doc = setup_bom( + item_code=fg.name, + routing=routing_doc.name, + raw_materials=[rm.name], + source_warehouse=warehouse, + ) + + for row in bom_doc.items: + make_stock_entry( + item_code=row.item_code, + target=row.source_warehouse, + qty=100, + basic_rate=100, + ) + + wo_doc = make_wo_order_test_record( + production_item=fg.name, + bom_no=bom_doc.name, + qty=10, + skip_transfer=1, + source_warehouse=warehouse, + ) + + first_operation = frappe.get_all( + "Job Card", + filters={"work_order": wo_doc.name, "sequence_id": 1}, + fields=["name"], + order_by="sequence_id", + limit=1, + )[0].name + + jc = frappe.get_doc("Job Card", first_operation) + from_time = "2025-01-02 09:00:00" + for _ in jc.scheduled_time_logs: + jc.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, days=1), + "completed_qty": 4, + }, + ) + jc.for_quantity = 4 + jc.save() + jc.submit() + + second_operation = frappe.get_all( + "Job Card", + filters={"work_order": wo_doc.name, "sequence_id": 2}, + fields=["name"], + order_by="sequence_id", + limit=1, + )[0].name + + jc = frappe.get_doc("Job Card", second_operation) + from_time = "2025-01-05 09:00:00" + for _ in jc.scheduled_time_logs: + jc.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, days=2), + "completed_qty": 4, + }, + ) + jc.for_quantity = 4 + jc.save() + jc.submit() + + s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6)) # overproduction + s.submit() + + def assert_operating_costs(stock_entry, qty, previous_entries): + wo_doc.reload() + for idx, operation in enumerate(wo_doc.operations): + consumed_cost = sum( + entry.additional_costs[idx].amount for entry in previous_entries if entry.docstatus == 1 + ) + consumed_qty = sum( + entry.additional_costs[idx].qty for entry in previous_entries if entry.docstatus == 1 + ) + remaining_cost = operation.actual_operating_cost - consumed_cost + remaining_qty = operation.completed_qty - consumed_qty + precision = stock_entry.additional_costs[idx].precision("amount") + expected_cost = flt(remaining_cost / remaining_qty * min(remaining_qty, qty), precision) + + self.assertEqual(flt(stock_entry.additional_costs[idx].amount, precision), expected_cost) + + assert_operating_costs(s, 6, []) + + make_job_card( + wo_doc.name, + [ + { + "name": wo_doc.operations[0].name, + "operation": operations[0]["operation"], + "workstation": wo_doc.operations[0].workstation, + "qty": 2, + "pending_qty": 2, + } + ], + ) + + job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name}) + from_time = "2025-01-09 09:00:00" + job_card.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, days=1), + "completed_qty": 2, + }, + ) + job_card.for_quantity = 2 + job_card.save() + job_card.submit() + + make_job_card( + wo_doc.name, + [ + { + "name": wo_doc.operations[1].name, + "operation": operations[1]["operation"], + "workstation": wo_doc.operations[1].workstation, + "qty": 2, + "pending_qty": 2, + } + ], + ) + + job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name}) + from_time = "2025-01-12 09:00:00" + job_card.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, days=2), + "completed_qty": 2, + }, + ) + job_card.for_quantity = 2 + job_card.save() + job_card.submit() + + s2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 1)) + s2.submit() + + assert_operating_costs(s2, 1, [s]) + + make_job_card( + wo_doc.name, + [ + { + "name": wo_doc.operations[0].name, + "operation": operations[0]["operation"], + "workstation": wo_doc.operations[0].workstation, + "qty": 2, + "pending_qty": 2, + } + ], + ) + + job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name}) + from_time = "2025-01-16 09:00:00" + job_card.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, days=1), + "completed_qty": 2, + }, + ) + job_card.for_quantity = 2 + job_card.save() + job_card.submit() + + make_job_card( + wo_doc.name, + [ + { + "name": wo_doc.operations[1].name, + "operation": operations[1]["operation"], + "workstation": wo_doc.operations[1].workstation, + "qty": 2, + "pending_qty": 2, + } + ], + ) + + job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name}) + from_time = "2025-01-19 09:00:00" + job_card.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, days=2), + "completed_qty": 2, + }, + ) + job_card.for_quantity = 2 + job_card.save() + job_card.submit() + + s3 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 2)) + s3.submit() + + assert_operating_costs(s3, 2, [s, s2]) + + s2.cancel() + + s4 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 3)) + s4.submit() + + assert_operating_costs(s4, 3, [s, s3]) + def create_bom_with_multiple_operations(): "Create a BOM with multiple operations and Material Transfer against Job Card" diff --git a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json index d91530dd3b5..93a0b8960e5 100644 --- a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json +++ b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "creation": "2018-07-09 17:20:44.737289", "doctype": "DocType", "editable_grid": 1, @@ -33,6 +34,7 @@ "ignore_user_permissions": 1, "in_list_view": 1, "label": "Source Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "options": "Warehouse" }, { @@ -105,7 +107,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-04-22 18:50:00.003444", + "modified": "2026-05-12 12:22:18.506904", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Item", @@ -115,4 +117,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 6e20e789899..3b3448333d9 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -12,21 +12,10 @@ frappe.ui.form.on("Work Order", { frm.ignore_doctypes_on_cancel_all = ["Serial and Batch Bundle"]; // Set query for warehouses - frm.set_query("wip_warehouse", function () { - return { - filters: { - company: frm.doc.company, - }, - }; - }); - - frm.set_query("source_warehouse", function () { - return { - filters: { - company: frm.doc.company, - }, - }; - }); + frm.events.set_company_filters(frm, "wip_warehouse"); + frm.events.set_company_filters(frm, "source_warehouse"); + frm.events.set_company_filters(frm, "fg_warehouse"); + frm.events.set_company_filters(frm, "scrap_warehouse"); frm.set_query("source_warehouse", "required_items", function () { return { @@ -44,24 +33,6 @@ frappe.ui.form.on("Work Order", { }; }); - frm.set_query("fg_warehouse", function () { - return { - filters: { - company: frm.doc.company, - is_group: 0, - }, - }; - }); - - frm.set_query("scrap_warehouse", function () { - return { - filters: { - company: frm.doc.company, - is_group: 0, - }, - }; - }); - // Set query for BOM frm.set_query("bom_no", function () { if (frm.doc.production_item) { @@ -118,6 +89,16 @@ frappe.ui.form.on("Work Order", { }); }, + set_company_filters(frm, fieldname) { + frm.set_query(fieldname, () => { + return { + filters: { + company: frm.doc.company, + }, + }; + }); + }, + onload: function (frm) { if (!frm.doc.status) frm.doc.status = "Draft"; @@ -315,7 +296,7 @@ frappe.ui.form.on("Work Order", { { fieldtype: "Data", fieldname: "name", - label: __("Operation Id"), + label: __("Operation ID"), }, { fieldtype: "Float", @@ -385,6 +366,7 @@ frappe.ui.form.on("Work Order", { if (pending_qty) { dialog.fields_dict.operations.df.data.push({ + __checked: 1, name: data.name, operation: data.operation, workstation: data.workstation, diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index f1735ab64b5..54fe85f6c0c 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "allow_import": 1, "autoname": "naming_series:", "creation": "2025-04-09 12:09:40.634472", @@ -249,6 +250,7 @@ "fieldname": "wip_warehouse", "fieldtype": "Link", "label": "Work-in-Progress Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse", "options": "Warehouse" }, @@ -257,6 +259,7 @@ "fieldname": "fg_warehouse", "fieldtype": "Link", "label": "Target Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "options": "Warehouse", "reqd": 1 }, @@ -269,6 +272,7 @@ "fieldname": "scrap_warehouse", "fieldtype": "Link", "label": "Scrap Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "options": "Warehouse" }, { @@ -498,6 +502,7 @@ "fieldname": "source_warehouse", "fieldtype": "Link", "label": "Source Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "options": "Warehouse" }, { @@ -602,7 +607,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2025-06-21 00:55:45.916224", + "modified": "2026-05-19 12:20:38.102403", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json index 98ee0a63d53..35d2c61f9a0 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "creation": "2016-04-18 07:38:26.314642", "doctype": "DocType", "editable_grid": 1, @@ -46,6 +47,7 @@ "ignore_user_permissions": 1, "in_list_view": 1, "label": "Source Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "options": "Warehouse" }, { @@ -151,7 +153,7 @@ ], "istable": 1, "links": [], - "modified": "2025-12-02 11:16:05.081613", + "modified": "2026-05-12 12:05:16.687866", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Item", diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json index 898848ebf42..4743821d06a 100644 --- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json +++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "creation": "2014-07-11 11:51:00.453717", "doctype": "DocType", "editable_grid": 1, @@ -12,7 +13,10 @@ "col_break3", "amount", "base_amount", - "has_corrective_cost" + "has_corrective_cost", + "has_operating_cost", + "operation_id", + "qty" ], "fields": [ { @@ -70,12 +74,36 @@ "fieldtype": "Check", "label": "Has Corrective Cost", "read_only": 1 + }, + { + "default": "0", + "fieldname": "has_operating_cost", + "fieldtype": "Check", + "label": "Has Operating Cost", + "read_only": 1 + }, + { + "fieldname": "operation_id", + "fieldtype": "Data", + "hidden": 1, + "label": "Operation ID", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "qty", + "fieldtype": "Float", + "hidden": 1, + "label": "Qty", + "no_copy": 1, + "non_negative": 1, + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-01-20 12:22:03.455762", + "modified": "2026-05-19 12:21:07.953801", "modified_by": "Administrator", "module": "Stock", "name": "Landed Cost Taxes and Charges", @@ -83,4 +111,4 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC" -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py index a3f7f037d60..879b67014d4 100644 --- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py +++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py @@ -21,9 +21,12 @@ class LandedCostTaxesandCharges(Document): exchange_rate: DF.Float expense_account: DF.Link | None has_corrective_cost: DF.Check + has_operating_cost: DF.Check + operation_id: DF.Data | None parent: DF.Data parentfield: DF.Data parenttype: DF.Data + qty: DF.Float # end: auto-generated types pass diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 3e9e5b5c8a4..f0d7e10ad73 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3370,6 +3370,33 @@ def get_work_order_details(work_order, company): } +def get_consumed_operating_cost(work_order, bom_no, operation_id=None): + table = frappe.qb.DocType("Stock Entry") + child_table = frappe.qb.DocType("Landed Cost Taxes and Charges") + query = ( + frappe.qb.from_(child_table) + .join(table) + .on(child_table.parent == table.name) + .select( + Sum(child_table.amount).as_("consumed_cost"), + Sum(child_table.qty).as_("consumed_qty"), + ) + .where( + (table.docstatus == 1) + & (table.work_order == work_order) + & (table.purpose == "Manufacture") + & (table.bom_no == bom_no) + & (child_table.has_operating_cost == 1) + ) + ) + + if operation_id: + query = query.where(child_table.operation_id == operation_id) + + data = query.run(as_dict=True) + return data[0] if data else frappe._dict() + + def get_operating_cost_per_unit(work_order=None, bom_no=None): operating_cost_per_unit = 0 if work_order: From cba4c9f0ee64f882657ed4bbea27f2dc5475f61a Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 26 May 2026 13:59:25 +0530 Subject: [PATCH 37/43] fix: inclusive tax amount not considered while setting LCV from purchase invoice (cherry picked from commit 048ddfc265b2f7a031f8eada42fafc5870d19747) # Conflicts: # erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py --- .../purchase_receipt/purchase_receipt.py | 4 +- .../purchase_receipt/test_purchase_receipt.py | 295 ++++++++++++++++++ 2 files changed, 297 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 1e7e7e0bce8..c27e2e40f30 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -1250,7 +1250,7 @@ def get_billed_qty_amount_against_purchase_receipt(pr_doc): .on(parent_table.name == table.parent) .select( table.pr_detail, - fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"), + fn.Sum(table.base_net_amount).as_("amount"), fn.Sum(table.qty).as_("qty"), ) .where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1)) @@ -1296,7 +1296,7 @@ def get_billed_qty_amount_against_purchase_order(pr_doc): .select( table.po_detail, fn.Sum(table.qty).as_("qty"), - fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"), + fn.Sum(table.base_net_amount).as_("amount"), ) .where((table.po_detail.isin(po_names)) & (table.docstatus == 1) & (table.pr_detail.isnull())) .groupby(table.po_detail) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index c5527dbd43c..6d14d124154 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -5170,6 +5170,301 @@ class TestPurchaseReceipt(FrappeTestCase): "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value ) +<<<<<<< HEAD +======= + def test_purchase_receipt_gl_entries_for_asset_item(self): + from erpnext.assets.doctype.asset.test_asset import create_fixed_asset_item + + # Create a Company without Stock Accounts Linked. + company = frappe.get_doc( + { + "doctype": "Company", + "company_name": "Asset Company", + "country": "India", + "default_currency": "INR", + } + ).insert() + + stock_accounts = ( + company.default_inventory_account, + company.stock_adjustment_account, + company.stock_received_but_not_billed, + ) + + company.update( + {"stock_in_hand_account": "", "stock_adjustment_account": "", "stock_received_but_not_billed": ""} + ).save() + + for account in stock_accounts: + frappe.db.delete("Account", account) + + asset_category = create_asset_category_for_pr_test() + asset_item = create_fixed_asset_item( + item_code="Test Fixed Asset Item for PR GL Test", asset_category=asset_category.name + ) + arnb_account = frappe.db.get_value("Company", company.name, "asset_received_but_not_billed") + + # Purchase Receipt should be able to create even without any stock accounts linked to company + pr = make_purchase_receipt( + item_code=asset_item.name, warehouse="Stores - AC", qty=1, rate=10000, company=company.name + ) + + gl_entries = get_gl_entries("Purchase Receipt", pr.name) + + self.assertTrue(gl_entries) + gl_accounts = [d.account for d in gl_entries] + + # The fixed asset account set on the item row must be debited + asset_expense_account = pr.items[0].expense_account + self.assertIn(asset_expense_account, gl_accounts) + + # Asset Received But Not Billed must be credited + self.assertIn(arnb_account, gl_accounts) + + # No Stock-type account should appear — the inventory account map is not + # needed and must not be consulted for an asset-only receipt + for entry in gl_entries: + account_type = frappe.db.get_value("Account", entry.account, "account_type") + self.assertNotEqual(account_type, "Stock") + + pr.cancel() + + def test_purchase_receipt_gl_entries_with_mixed_asset_and_stock_items(self): + from erpnext.assets.doctype.asset.test_asset import create_fixed_asset_item + + company = frappe.get_doc( + { + "doctype": "Company", + "company_name": "Asset Company", + "country": "India", + "default_currency": "INR", + } + ).insert() + + asset_category = create_asset_category_for_pr_test() + asset_item = create_fixed_asset_item( + item_code="Test Fixed Asset Item for PR GL Test", asset_category=asset_category.name + ) + arnb_account = frappe.db.get_value("Company", company.name, "asset_received_but_not_billed") + + pr = make_purchase_receipt( + item_code=asset_item.name, + qty=1, + rate=10000, + warehouse="Stores - AC", + do_not_save=True, + company=company.name, + ) + pr.append( + "items", + { + "item_code": "_Test Item", + "warehouse": "Stores - AC", + "qty": 5, + "received_qty": 5, + "rejected_qty": 0, + "rate": 50, + "uom": "_Test UOM", + "stock_uom": "_Test UOM", + "conversion_factor": 1.0, + "cost_center": frappe.get_cached_value("Company", pr.company, "cost_center"), + }, + ) + pr.insert() + pr.submit() + + gl_entries = get_gl_entries("Purchase Receipt", pr.name) + self.assertTrue(gl_entries) + + gl_accounts = [d.account for d in gl_entries] + self.assertIn(arnb_account, gl_accounts) + + # The fixed asset account set on the item row must be debited + asset_expense_account = pr.items[0].expense_account + self.assertIn(asset_expense_account, gl_accounts) + + # Asset Received But Not Billed must be credited + self.assertIn(asset_category.accounts[0].fixed_asset_account, gl_accounts) + + # Stock Accounts should be used for Stock Items + self.assertIn(company.stock_received_but_not_billed, gl_accounts) + self.assertIn(company.default_inventory_account, gl_accounts) + pr.cancel() + + @ERPNextTestSuite.change_settings( + "Buying Settings", {"set_landed_cost_based_on_purchase_invoice_rate": 1, "maintain_same_rate": 0} + ) + def test_srbnb_with_inclusive_tax_and_rate_change_in_pi(self): + """ + When 'Set Landed Cost Based on PI Rate' is enabled and PI has an inclusive tax: + - PR: qty=2, rate=1000 INR → base_net_amount=2000 + - PI: rate changed to 2000, 5% tax included in basic rate + → PI base_net_amount = 2 * 2000 / 1.05 ≈ 3809.52 + + The system must use PI's base_net_amount (not amount=4000) so that + SRBNB credit on PR = 3809.52, not 4000. + """ + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + cost_center = "Main - TCP1" + + item_code = make_item( + "Test Item for SRBNB Inclusive Tax Rate Change", + {"is_stock_item": 1}, + ).name + + pr = make_purchase_receipt( + item_code=item_code, + qty=2, + rate=1000, + company=company, + warehouse=warehouse, + cost_center=cost_center, + ) + + pi = make_purchase_invoice(pr.name) + pi.items[0].rate = 2000 + pi.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - TCP1", + "category": "Total", + "add_deduct_tax": "Add", + "included_in_print_rate": 1, + "rate": 5, + "description": "Test Inclusive Tax", + "cost_center": cost_center, + }, + ) + pi.save() + pi.submit() + + pr.reload() + + # PI base_net_amount = qty * (rate / (1 + tax_rate/100)) = 2 * (2000 / 1.05) + pi_base_net_amount = flt(2 * 2000 / 1.05, 2) + pr_base_net_amount = flt(pr.items[0].amount, 2) # 2 * 1000 = 2000 + expected_diff = flt(pi_base_net_amount - pr_base_net_amount, 2) + + self.assertAlmostEqual(pr.items[0].amount_difference_with_purchase_invoice, expected_diff, places=2) + + # Total SRBNB credit = PR base_net_amount + amount_difference = PI base_net_amount + srbnb_account = "Stock Received But Not Billed - TCP1" + gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True) + srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account) + self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2) + + @ERPNextTestSuite.change_settings( + "Buying Settings", {"set_landed_cost_based_on_purchase_invoice_rate": 1, "maintain_same_rate": 0} + ) + def test_srbnb_with_inclusive_tax_and_exchange_rate_change_in_pi(self): + """ + When 'Set Landed Cost Based on PI Rate' is enabled, PI has an inclusive tax, and only + the exchange rate changes on the PI (rate stays the same): + - PR: qty=2, rate=100 USD, conversion_rate=70 → base_net_amount=14000 INR + - PI: same rate=100 USD, conversion_rate changed to 90, 5% tax included in basic rate + → PI base_net_amount = 2 * (100 / 1.05) * 90 ≈ 17142.86 INR + + The system must use PI's base_net_amount (not amount = 2*100*90 = 18000) so that + SRBNB credit on PR = 17142.86, not 18000. + """ + from erpnext.accounts.doctype.account.test_account import create_account + + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + cost_center = "Main - TCP1" + + party_account = create_account( + account_name="USD Payable For SRBNB Exchange Rate Test", + parent_account="Accounts Payable - TCP1", + account_type="Payable", + company=company, + account_currency="USD", + ) + + supplier = create_supplier( + supplier_name="_Test USD Supplier for SRBNB Exchange Rate", + default_currency="USD", + party_account=party_account, + ).name + + item_code = make_item( + "Test Item for SRBNB Inclusive Tax Exchange Rate Change", + {"is_stock_item": 1}, + ).name + + pr = make_purchase_receipt( + item_code=item_code, + qty=2, + rate=100, + currency="USD", + conversion_rate=70, + company=company, + warehouse=warehouse, + supplier=supplier, + ) + + pi = make_purchase_invoice(pr.name) + pi.conversion_rate = 90 + pi.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - TCP1", + "category": "Total", + "add_deduct_tax": "Add", + "included_in_print_rate": 1, + "rate": 5, + "description": "Test Inclusive Tax", + "cost_center": cost_center, + }, + ) + pi.save() + pi.submit() + + pr.reload() + + # PI base_net_amount = qty * (rate / (1 + tax_rate/100)) * new_conversion_rate + # = 2 * (100 / 1.05) * 90 ≈ 17142.86 INR + # PR base_net_amount = qty * rate * pr_conversion_rate = 2 * 100 * 70 = 14000 INR + tax_amount_pr = (200 - flt(200 / 1.05, 2)) * 90 + + pi_base_net_amount = flt(2 * 100 * 90) - flt(tax_amount_pr) + pr_base_net_amount = flt(2 * 100 * 70) + expected_diff = flt(pi_base_net_amount - pr_base_net_amount) + + self.assertAlmostEqual(pr.items[0].amount_difference_with_purchase_invoice, expected_diff, places=2) + + # Total SRBNB credit = PR base_net_amount + amount_difference = PI base_net_amount + srbnb_account = "Stock Received But Not Billed - TCP1" + gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True) + srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account) + self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2) + + +def create_asset_category_for_pr_test(): + category_name = "Test Asset Category for PR" + + asset_category = frappe.get_doc( + { + "doctype": "Asset Category", + "asset_category_name": category_name, + "enable_cwip_accounting": 0, + "depreciation_method": "Straight Line", + "total_number_of_depreciations": 12, + "frequency_of_depreciation": 1, + "accounts": [ + { + "company_name": "Asset Company", + "fixed_asset_account": "Electronic Equipment - AC", + } + ], + } + ).insert() + return asset_category + +>>>>>>> 048ddfc265 (fix: inclusive tax amount not considered while setting LCV from purchase invoice) def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier From 76078a7fb941ba2b820e1af9ac8c040f6a774654 Mon Sep 17 00:00:00 2001 From: "Nihantra C. Patel" <141945075+Nihantra-Patel@users.noreply.github.com> Date: Tue, 26 May 2026 15:48:41 +0530 Subject: [PATCH 38/43] fix: ERPNextTestSuite to change_settings --- .../period_closing_voucher/test_period_closing_voucher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py index c5b20ea395f..e5098b0bfbc 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py @@ -352,7 +352,7 @@ class TestPeriodClosingVoucher(unittest.TestCase): return pcv - @ERPNextTestSuite.change_settings( + @change_settings( "Accounts Settings", {"enable_immutable_ledger": 1}, ) From 9d211990c3a3782a938fb2ad7c24d2941783a73e Mon Sep 17 00:00:00 2001 From: "Nihantra C. Patel" <141945075+Nihantra-Patel@users.noreply.github.com> Date: Tue, 26 May 2026 15:56:38 +0530 Subject: [PATCH 39/43] fix: import change_settings --- .../period_closing_voucher/test_period_closing_voucher.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py index e5098b0bfbc..68c6364e88c 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py @@ -12,6 +12,7 @@ from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.general_ledger import make_reverse_gl_entries from erpnext.accounts.utils import get_fiscal_year +from erpnext.tests.utils import change_settings class TestPeriodClosingVoucher(unittest.TestCase): From 66267cf99ab99d3701a026a2a0dd0ef86fb5b2e1 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Tue, 26 May 2026 15:57:24 +0530 Subject: [PATCH 40/43] chore: fix conflicts --- .../purchase_receipt/test_purchase_receipt.py | 148 +----------------- 1 file changed, 2 insertions(+), 146 deletions(-) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 6d14d124154..ce23acf85e2 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -5170,128 +5170,7 @@ class TestPurchaseReceipt(FrappeTestCase): "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value ) -<<<<<<< HEAD -======= - def test_purchase_receipt_gl_entries_for_asset_item(self): - from erpnext.assets.doctype.asset.test_asset import create_fixed_asset_item - - # Create a Company without Stock Accounts Linked. - company = frappe.get_doc( - { - "doctype": "Company", - "company_name": "Asset Company", - "country": "India", - "default_currency": "INR", - } - ).insert() - - stock_accounts = ( - company.default_inventory_account, - company.stock_adjustment_account, - company.stock_received_but_not_billed, - ) - - company.update( - {"stock_in_hand_account": "", "stock_adjustment_account": "", "stock_received_but_not_billed": ""} - ).save() - - for account in stock_accounts: - frappe.db.delete("Account", account) - - asset_category = create_asset_category_for_pr_test() - asset_item = create_fixed_asset_item( - item_code="Test Fixed Asset Item for PR GL Test", asset_category=asset_category.name - ) - arnb_account = frappe.db.get_value("Company", company.name, "asset_received_but_not_billed") - - # Purchase Receipt should be able to create even without any stock accounts linked to company - pr = make_purchase_receipt( - item_code=asset_item.name, warehouse="Stores - AC", qty=1, rate=10000, company=company.name - ) - - gl_entries = get_gl_entries("Purchase Receipt", pr.name) - - self.assertTrue(gl_entries) - gl_accounts = [d.account for d in gl_entries] - - # The fixed asset account set on the item row must be debited - asset_expense_account = pr.items[0].expense_account - self.assertIn(asset_expense_account, gl_accounts) - - # Asset Received But Not Billed must be credited - self.assertIn(arnb_account, gl_accounts) - - # No Stock-type account should appear — the inventory account map is not - # needed and must not be consulted for an asset-only receipt - for entry in gl_entries: - account_type = frappe.db.get_value("Account", entry.account, "account_type") - self.assertNotEqual(account_type, "Stock") - - pr.cancel() - - def test_purchase_receipt_gl_entries_with_mixed_asset_and_stock_items(self): - from erpnext.assets.doctype.asset.test_asset import create_fixed_asset_item - - company = frappe.get_doc( - { - "doctype": "Company", - "company_name": "Asset Company", - "country": "India", - "default_currency": "INR", - } - ).insert() - - asset_category = create_asset_category_for_pr_test() - asset_item = create_fixed_asset_item( - item_code="Test Fixed Asset Item for PR GL Test", asset_category=asset_category.name - ) - arnb_account = frappe.db.get_value("Company", company.name, "asset_received_but_not_billed") - - pr = make_purchase_receipt( - item_code=asset_item.name, - qty=1, - rate=10000, - warehouse="Stores - AC", - do_not_save=True, - company=company.name, - ) - pr.append( - "items", - { - "item_code": "_Test Item", - "warehouse": "Stores - AC", - "qty": 5, - "received_qty": 5, - "rejected_qty": 0, - "rate": 50, - "uom": "_Test UOM", - "stock_uom": "_Test UOM", - "conversion_factor": 1.0, - "cost_center": frappe.get_cached_value("Company", pr.company, "cost_center"), - }, - ) - pr.insert() - pr.submit() - - gl_entries = get_gl_entries("Purchase Receipt", pr.name) - self.assertTrue(gl_entries) - - gl_accounts = [d.account for d in gl_entries] - self.assertIn(arnb_account, gl_accounts) - - # The fixed asset account set on the item row must be debited - asset_expense_account = pr.items[0].expense_account - self.assertIn(asset_expense_account, gl_accounts) - - # Asset Received But Not Billed must be credited - self.assertIn(asset_category.accounts[0].fixed_asset_account, gl_accounts) - - # Stock Accounts should be used for Stock Items - self.assertIn(company.stock_received_but_not_billed, gl_accounts) - self.assertIn(company.default_inventory_account, gl_accounts) - pr.cancel() - - @ERPNextTestSuite.change_settings( + @change_settings( "Buying Settings", {"set_landed_cost_based_on_purchase_invoice_rate": 1, "maintain_same_rate": 0} ) def test_srbnb_with_inclusive_tax_and_rate_change_in_pi(self): @@ -5355,7 +5234,7 @@ class TestPurchaseReceipt(FrappeTestCase): srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account) self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2) - @ERPNextTestSuite.change_settings( + @change_settings( "Buying Settings", {"set_landed_cost_based_on_purchase_invoice_rate": 1, "maintain_same_rate": 0} ) def test_srbnb_with_inclusive_tax_and_exchange_rate_change_in_pi(self): @@ -5443,29 +5322,6 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2) -def create_asset_category_for_pr_test(): - category_name = "Test Asset Category for PR" - - asset_category = frappe.get_doc( - { - "doctype": "Asset Category", - "asset_category_name": category_name, - "enable_cwip_accounting": 0, - "depreciation_method": "Straight Line", - "total_number_of_depreciations": 12, - "frequency_of_depreciation": 1, - "accounts": [ - { - "company_name": "Asset Company", - "fixed_asset_account": "Electronic Equipment - AC", - } - ], - } - ).insert() - return asset_category - ->>>>>>> 048ddfc265 (fix: inclusive tax amount not considered while setting LCV from purchase invoice) - def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier from erpnext.selling.doctype.customer.test_customer import create_internal_customer From 31c251d9561d8149bc8d79e0e2c4a41bec18e7f6 Mon Sep 17 00:00:00 2001 From: "Nihantra C. Patel" <141945075+Nihantra-Patel@users.noreply.github.com> Date: Tue, 26 May 2026 16:24:31 +0530 Subject: [PATCH 41/43] fix: update import --- .../period_closing_voucher/test_period_closing_voucher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py index 68c6364e88c..5b42bc0f210 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py @@ -5,6 +5,7 @@ import unittest import frappe +from frappe.tests.utils import change_settings from frappe.utils import today from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book @@ -12,7 +13,6 @@ from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.general_ledger import make_reverse_gl_entries from erpnext.accounts.utils import get_fiscal_year -from erpnext.tests.utils import change_settings class TestPeriodClosingVoucher(unittest.TestCase): From 8f164cff1d450470b05e0da6cbaaa2a9e92e3470 Mon Sep 17 00:00:00 2001 From: Nihantra Patel Date: Tue, 26 May 2026 22:53:14 +0530 Subject: [PATCH 42/43] test: immutable ledger reverse entry --- .../period_closing_voucher/test_period_closing_voucher.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py index 5b42bc0f210..e4e31a9adf4 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py @@ -358,6 +358,9 @@ class TestPeriodClosingVoucher(unittest.TestCase): {"enable_immutable_ledger": 1}, ) def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self): + frappe.db.sql("delete from `tabGL Entry` where company='Test PCV Company'") + frappe.db.sql("delete from `tabPeriod Closing Voucher` where company='Test PCV Company'") + company = create_company() cost_center = create_cost_center("Test Cost Center 1") From b972b7c307cf01f754d02ee872b6f949908e9a68 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 23:34:47 +0000 Subject: [PATCH 43/43] fix(general-ledger): show raw GL entries when categorize_by is empty (backport #54816) (#54829) fix(general-ledger): show raw GL entries when categorize_by is empty (#54816) (cherry picked from commit dfbe847307c22d6f37cf6a808ccfbb72db3038bd) # Conflicts: # erpnext/accounts/report/general_ledger/general_ledger.py Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> --- erpnext/accounts/report/general_ledger/general_ledger.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 3756fb8fc9f..597ab75385a 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -406,7 +406,13 @@ def get_data_with_opening_closing(filters, account_details, accounting_dimension # Opening for filtered account data.append(totals.opening) - if filters.get("categorize_by") != "Categorize by Voucher (Consolidated)": + if not filters.get("categorize_by"): + all_entries = [] + for acc_dict in gle_map.values(): + all_entries.extend(acc_dict.entries) + data += all_entries + + elif filters.get("categorize_by") != "Categorize by Voucher (Consolidated)": for _acc, acc_dict in gle_map.items(): # acc if acc_dict.entries: