From 2ed7c84d0af613faf42699b029804efd1e051005 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Thu, 18 Jun 2026 15:42:28 +0530 Subject: [PATCH 01/94] fix(journal entry): validate opening entry against pcv on save (cherry picked from commit f8aa4c730c6b3eb4b02108251b8dab836aaefbef) # Conflicts: # erpnext/accounts/doctype/journal_entry/journal_entry.py # erpnext/accounts/general_ledger.py --- .../doctype/journal_entry/journal_entry.py | 4 ++++ erpnext/accounts/general_ledger.py | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index aa048a71ff2..762585601e5 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -21,6 +21,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( get_party_tax_withholding_details, ) +from erpnext.accounts.general_ledger import validate_opening_entry_against_pcv from erpnext.accounts.party import get_party_account from erpnext.accounts.utils import ( cancel_exchange_gain_loss_journal, @@ -123,6 +124,9 @@ class JournalEntry(AccountsController): if not self.is_opening: self.is_opening = "No" + if self.is_opening == "Yes": + validate_opening_entry_against_pcv(self.company) + self.clearance_date = None self.validate_party() diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 599173c99f5..8d01a9e2843 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -802,13 +802,24 @@ def check_freezing_date(posting_date, adv_adj=False): ) -def validate_against_pcv(is_opening, posting_date, company): - if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}): +def validate_opening_entry_against_pcv(company): + if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}): frappe.throw( - _("Opening Entry can not be created after Period Closing Voucher is created."), + _( + "A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more." + ).format( + '' + + _("Read the docs") + + "" + ), title=_("Invalid Opening Entry"), ) + +def validate_against_pcv(is_opening, posting_date, company): + if is_opening: + validate_opening_entry_against_pcv(company) + # Local import so you don't have to touch file-level imports from frappe.query_builder.functions import Max From 0f9429fd3dca54021f184c400b6397d92f830724 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Fri, 26 Jun 2026 15:00:54 +0530 Subject: [PATCH 02/94] fix: remove dead bundle helper call from purchase receipt print format (cherry picked from commit b9f5a77fa7ae7e151a667a38fc5569b4e2cecedd) --- .../purchase_receipt_serial_and_batch_bundle_print.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json b/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json index a8ab8f6ac7d..9b5a71aae02 100644 --- a/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json +++ b/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json @@ -8,7 +8,7 @@ "docstatus": 0, "doctype": "Print Format", "font_size": 14, - "format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"
\\t\\t\\t\\t

Purchase Receipt

{{ doc.name }}\\t\\t\\t\\t

\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"supplier_name\", \"print_hide\": 0, \"label\": \"Supplier Name\"}, {\"fieldname\": \"supplier_delivery_note\", \"print_hide\": 0, \"label\": \"Supplier Delivery Note\"}, {\"fieldname\": \"rack\", \"print_hide\": 0, \"label\": \"Rack\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"posting_date\", \"print_hide\": 0, \"label\": \"Date\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"apply_putaway_rule\", \"print_hide\": 0, \"label\": \"Apply Putaway Rule\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Accounting Dimensions\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"region\", \"print_hide\": 0, \"label\": \"Region\"}, {\"fieldname\": \"function\", \"print_hide\": 0, \"label\": \"Function\"}, {\"fieldname\": \"depot\", \"print_hide\": 0, \"label\": \"Depot\"}, {\"fieldname\": \"cost_center\", \"print_hide\": 0, \"label\": \"Cost Center\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"location\", \"print_hide\": 0, \"label\": \"Location\"}, {\"fieldname\": \"country\", \"print_hide\": 0, \"label\": \"Country\"}, {\"fieldname\": \"project\", \"print_hide\": 0, \"label\": \"Project\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Items\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"scan_barcode\", \"print_hide\": 0, \"label\": \"Scan Barcode\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"set_from_warehouse\", \"print_hide\": 0, \"label\": \"Set From Warehouse\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"\\n\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- for row in doc.items -%}\\n\\t\\t\\n\\t\\t {% set bundle_data = get_serial_or_batch_nos(row.serial_and_batch_bundle) %}\\n\\t\\t {% set serial_nos = [] %}\\n {% set batches = {} %}\\n\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- endfor -%}\\n\\t\\n
SrItem NameDescriptionQtyRateAmount
{{ row.idx }}\\n\\t\\t\\t\\t{{ row.item_name }}\\n\\t\\t\\t\\t{% if row.item_code != row.item_name -%}\\n\\t\\t\\t\\t
Item Code: {{ row.item_code}}\\n\\t\\t\\t\\t{%- endif %}\\n\\t\\t\\t
\\n\\t\\t\\t\\t
{{ row.description }}
{{ row.qty }} {{ row.uom or row.stock_uom }}{{\\n\\t\\t\\t\\trow.get_formatted(\\\"rate\\\", doc) }}{{\\n\\t\\t\\t\\trow.get_formatted(\\\"amount\\\", doc) }}
\\n\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total_qty\", \"print_hide\": 0, \"label\": \"Total Quantity\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total\", \"print_hide\": 0, \"label\": \"Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"taxes\", \"print_hide\": 0, \"label\": \"Purchase Taxes and Charges\", \"visible_columns\": [{\"fieldname\": \"category\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"add_deduct_tax\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"charge_type\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"row_id\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"included_in_print_rate\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"included_in_paid_amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"account_head\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"description\", \"print_width\": \"300px\", \"print_hide\": 0}, {\"fieldname\": \"rate\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"region\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"function\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"location\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"cost_center\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"depot\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"country\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"account_currency\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"tax_amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"total\", \"print_width\": \"\", \"print_hide\": 0}]}, {\"fieldtype\": \"Section Break\", \"label\": \"Totals\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"grand_total\", \"print_hide\": 0, \"label\": \"Grand Total\"}, {\"fieldname\": \"rounded_total\", \"print_hide\": 0, \"label\": \"Rounded Total\"}, {\"fieldname\": \"in_words\", \"print_hide\": 0, \"label\": \"In Words\"}, {\"fieldname\": \"disable_rounded_total\", \"print_hide\": 0, \"label\": \"Disable Rounded Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Supplier Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"address_display\", \"print_hide\": 0, \"label\": \"Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"contact_display\", \"print_hide\": 0, \"label\": \"Contact\"}, {\"fieldname\": \"contact_mobile\", \"print_hide\": 0, \"label\": \"Mobile No\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Company Billing Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"billing_address\", \"print_hide\": 0, \"label\": \"Billing Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"billing_address_display\", \"print_hide\": 0, \"label\": \"Billing Address\"}, {\"fieldname\": \"terms\", \"print_hide\": 0, \"label\": \"Terms and Conditions\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"\\n\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- for row in doc.items -%}\\n\\t\\t\\n\\t\\t {% set bundle_data = frappe.get_all(\\\"Serial and Batch Entry\\\", \\n\\t\\t fields=[\\\"serial_no\\\", \\\"batch_no\\\", \\\"qty\\\"], \\n\\t\\t filters={\\\"parent\\\": row.serial_and_batch_bundle}) %}\\n\\t\\t {% set serial_nos = [] %}\\n {% set batches = {} %}\\n \\n {% if bundle_data %}\\n\\t\\t\\t {% for data in bundle_data %}\\n\\t\\t\\t {% if data.serial_no %}\\n\\t\\t\\t {{ serial_nos.append(data.serial_no) or \\\"\\\" }}\\n\\t\\t\\t {% endif %}\\n\\t\\t\\t \\n\\t\\t\\t {% if data.batch_no %}\\n\\t\\t\\t {{ batches.update({data.batch_no: data.qty}) or \\\"\\\" }}\\n\\t\\t\\t {% endif %}\\n\\t\\t\\t {% endfor %}\\n\\t\\t\\t{% endif %}\\n\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- endfor -%}\\n\\t\\n
SrItem NameQtySerial NosBatch Nos (Qty)
{{ row.idx }}\\n\\t\\t\\t\\t{{ row.item_name }}\\n\\t\\t\\t\\t{% if row.item_code != row.item_name -%}\\n\\t\\t\\t\\t
Item Code: {{ row.item_code}}\\n\\t\\t\\t\\t{%- endif %}\\n\\t\\t\\t
{{ row.qty }} {{ row.uom or row.stock_uom }}{{ serial_nos|join(',') }}\\n\\t\\t\\t {% if batches %}\\n {% for batch_no, qty in batches.items() %}\\n

{{batch_no}} : {{qty}} {{ row.uom or row.stock_uom }}

\\n {% endfor %}\\n {% endif %}\\n\\t\\t\\t
\\n\"}]", + "format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"
\\t\\t\\t\\t

Purchase Receipt

{{ doc.name }}\\t\\t\\t\\t

\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"supplier_name\", \"print_hide\": 0, \"label\": \"Supplier Name\"}, {\"fieldname\": \"supplier_delivery_note\", \"print_hide\": 0, \"label\": \"Supplier Delivery Note\"}, {\"fieldname\": \"rack\", \"print_hide\": 0, \"label\": \"Rack\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"posting_date\", \"print_hide\": 0, \"label\": \"Date\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"apply_putaway_rule\", \"print_hide\": 0, \"label\": \"Apply Putaway Rule\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Accounting Dimensions\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"region\", \"print_hide\": 0, \"label\": \"Region\"}, {\"fieldname\": \"function\", \"print_hide\": 0, \"label\": \"Function\"}, {\"fieldname\": \"depot\", \"print_hide\": 0, \"label\": \"Depot\"}, {\"fieldname\": \"cost_center\", \"print_hide\": 0, \"label\": \"Cost Center\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"location\", \"print_hide\": 0, \"label\": \"Location\"}, {\"fieldname\": \"country\", \"print_hide\": 0, \"label\": \"Country\"}, {\"fieldname\": \"project\", \"print_hide\": 0, \"label\": \"Project\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Items\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"scan_barcode\", \"print_hide\": 0, \"label\": \"Scan Barcode\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"set_from_warehouse\", \"print_hide\": 0, \"label\": \"Set From Warehouse\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"\\n\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- for row in doc.items -%}\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- endfor -%}\\n\\t\\n
SrItem NameDescriptionQtyRateAmount
{{ row.idx }}\\n\\t\\t\\t\\t{{ row.item_name }}\\n\\t\\t\\t\\t{% if row.item_code != row.item_name -%}\\n\\t\\t\\t\\t
Item Code: {{ row.item_code}}\\n\\t\\t\\t\\t{%- endif %}\\n\\t\\t\\t
\\n\\t\\t\\t\\t
{{ row.description }}
{{ row.qty }} {{ row.uom or row.stock_uom }}{{\\n\\t\\t\\t\\trow.get_formatted(\\\"rate\\\", doc) }}{{\\n\\t\\t\\t\\trow.get_formatted(\\\"amount\\\", doc) }}
\\n\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total_qty\", \"print_hide\": 0, \"label\": \"Total Quantity\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total\", \"print_hide\": 0, \"label\": \"Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"taxes\", \"print_hide\": 0, \"label\": \"Purchase Taxes and Charges\", \"visible_columns\": [{\"fieldname\": \"category\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"add_deduct_tax\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"charge_type\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"row_id\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"included_in_print_rate\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"included_in_paid_amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"account_head\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"description\", \"print_width\": \"300px\", \"print_hide\": 0}, {\"fieldname\": \"rate\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"region\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"function\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"location\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"cost_center\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"depot\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"country\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"account_currency\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"tax_amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"total\", \"print_width\": \"\", \"print_hide\": 0}]}, {\"fieldtype\": \"Section Break\", \"label\": \"Totals\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"grand_total\", \"print_hide\": 0, \"label\": \"Grand Total\"}, {\"fieldname\": \"rounded_total\", \"print_hide\": 0, \"label\": \"Rounded Total\"}, {\"fieldname\": \"in_words\", \"print_hide\": 0, \"label\": \"In Words\"}, {\"fieldname\": \"disable_rounded_total\", \"print_hide\": 0, \"label\": \"Disable Rounded Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Supplier Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"address_display\", \"print_hide\": 0, \"label\": \"Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"contact_display\", \"print_hide\": 0, \"label\": \"Contact\"}, {\"fieldname\": \"contact_mobile\", \"print_hide\": 0, \"label\": \"Mobile No\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Company Billing Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"billing_address\", \"print_hide\": 0, \"label\": \"Billing Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"billing_address_display\", \"print_hide\": 0, \"label\": \"Billing Address\"}, {\"fieldname\": \"terms\", \"print_hide\": 0, \"label\": \"Terms and Conditions\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"\\n\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- for row in doc.items -%}\\n\\t\\t\\n\\t\\t {% set bundle_data = frappe.get_all(\\\"Serial and Batch Entry\\\", \\n\\t\\t fields=[\\\"serial_no\\\", \\\"batch_no\\\", \\\"qty\\\"], \\n\\t\\t filters={\\\"parent\\\": row.serial_and_batch_bundle}) %}\\n\\t\\t {% set serial_nos = [] %}\\n {% set batches = {} %}\\n \\n {% if bundle_data %}\\n\\t\\t\\t {% for data in bundle_data %}\\n\\t\\t\\t {% if data.serial_no %}\\n\\t\\t\\t {{ serial_nos.append(data.serial_no) or \\\"\\\" }}\\n\\t\\t\\t {% endif %}\\n\\t\\t\\t \\n\\t\\t\\t {% if data.batch_no %}\\n\\t\\t\\t {{ batches.update({data.batch_no: data.qty}) or \\\"\\\" }}\\n\\t\\t\\t {% endif %}\\n\\t\\t\\t {% endfor %}\\n\\t\\t\\t{% endif %}\\n\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- endfor -%}\\n\\t\\n
SrItem NameQtySerial NosBatch Nos (Qty)
{{ row.idx }}\\n\\t\\t\\t\\t{{ row.item_name }}\\n\\t\\t\\t\\t{% if row.item_code != row.item_name -%}\\n\\t\\t\\t\\t
Item Code: {{ row.item_code}}\\n\\t\\t\\t\\t{%- endif %}\\n\\t\\t\\t
{{ row.qty }} {{ row.uom or row.stock_uom }}{{ serial_nos|join(',') }}\\n\\t\\t\\t {% if batches %}\\n {% for batch_no, qty in batches.items() %}\\n

{{batch_no}} : {{qty}} {{ row.uom or row.stock_uom }}

\\n {% endfor %}\\n {% endif %}\\n\\t\\t\\t
\\n\"}]", "idx": 0, "line_breaks": 0, "margin_bottom": 15.0, @@ -27,4 +27,4 @@ "raw_printing": 0, "show_section_headings": 0, "standard": "Yes" -} \ No newline at end of file +} From 4aedf98c7ccff4ead45947219e2e5825f5432e8f Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:06:34 +0530 Subject: [PATCH 03/94] ci: bump pre-commit actions to v3.0.1 (backport #56562) (#56566) Co-authored-by: Diptanil Saha --- .github/workflows/linters.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 94b76b12ce7..f55d6bf844e 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -18,7 +18,7 @@ jobs: cache: pip - name: Install and Run Pre-commit - uses: pre-commit/action@v3.0.0 + uses: pre-commit/action@v3.0.1 - name: Download Semgrep rules run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules From 5ed946b3b96c888dc8d66b5595b6b75523abe279 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:42:28 +0530 Subject: [PATCH 04/94] fix: ignored posting time 00:00:00 in RIV (backport #56571) (#56572) fix: ignored posting time 00:00:00 in RIV (#56571) (cherry picked from commit 31f89b72b48110a2245791b85994b491c6fda2e5) Co-authored-by: rohitwaghchaure --- .../doctype/repost_item_valuation/repost_item_valuation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index 98a2720e6d2..f7cc4b90c36 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -86,7 +86,7 @@ class RepostItemValuation(Document): self.validate_recreate_stock_ledgers() def set_default_posting_time(self): - if not self.posting_time: + if self.posting_time is None: self.posting_time = nowtime() if not self.posting_date: From af05b8a30dba71ca964f90167db24015bd1ca6d5 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:31:22 +0530 Subject: [PATCH 05/94] chore: removing `controllers` from pre-commit eslint hooks exclude list (backport #56575) (#56578) Co-authored-by: Diptanil Saha --- .pre-commit-config.yaml | 1 - erpnext/public/js/controllers/accounts.js | 14 ++++++----- .../public/js/controllers/taxes_and_totals.js | 12 +++++---- erpnext/public/js/controllers/transaction.js | 25 +++++++++---------- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 13cbf66a5af..c09e5cdedb9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,7 +50,6 @@ repos: cypress/.*| .*node_modules.*| .*boilerplate.*| - erpnext/public/js/controllers/.*| erpnext/templates/pages/order.js| erpnext/templates/includes/.* )$ diff --git a/erpnext/public/js/controllers/accounts.js b/erpnext/public/js/controllers/accounts.js index dec0f1c024d..1b1c45e38ea 100644 --- a/erpnext/public/js/controllers/accounts.js +++ b/erpnext/public/js/controllers/accounts.js @@ -16,13 +16,15 @@ erpnext.accounts.taxes = { } }); }, - onload: function(frm) { - if(frm.get_field("taxes")) { - frm.set_query("account_head", "taxes", function(doc) { - if(frm.cscript.tax_table == "Sales Taxes and Charges") { - var account_type = ["Tax", "Chargeable", "Expense Account"]; + onload: function (frm) { + if (frm.get_field("taxes")) { + frm.set_query("account_head", "taxes", function (doc) { + let account_type = ["Tax", "Chargeable"]; + + if (frm.cscript.tax_table == "Sales Taxes and Charges") { + account_type.push("Expense Account"); } else { - var account_type = ["Tax", "Chargeable", "Income Account", "Expenses Included In Valuation"]; + account_type.push("Income Account", "Expenses Included In Valuation"); } return { diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 0df4cabfb4f..1f091f3934d 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -498,7 +498,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } else if(tax.charge_type == "On Net Total") { if (tax.account_head in item_tax_map) { current_net_amount = item.net_amount - }; + } current_tax_amount = (tax_rate / 100.0) * item.net_amount; } else if(tax.charge_type == "On Previous Row Amount") { current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item @@ -862,12 +862,13 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if(["Sales Invoice", "POS Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) { let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total; + let total_amount_to_pay; if(this.frm.doc.party_account_currency == this.frm.doc.currency) { - var total_amount_to_pay = flt((grand_total - this.frm.doc.total_advance + total_amount_to_pay = flt((grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount), precision("grand_total")); } else { - var total_amount_to_pay = flt( + total_amount_to_pay = flt( (flt(base_grand_total, precision("base_grand_total")) - this.frm.doc.total_advance - this.frm.doc.base_write_off_amount), precision("base_grand_total") @@ -901,14 +902,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { async set_total_amount_to_default_mop() { let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total; + let total_amount_to_pay; if (this.frm.doc.party_account_currency == this.frm.doc.currency) { - var total_amount_to_pay = flt( + total_amount_to_pay = flt( grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount, precision("grand_total") ); } else { - var total_amount_to_pay = flt( + total_amount_to_pay = flt( ( flt( base_grand_total, diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 5fce7783c38..88dc01d5845 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1007,13 +1007,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe var set_party_account = function(set_pricing) { if (["Sales Invoice", "Purchase Invoice"].includes(me.frm.doc.doctype)) { - if(me.frm.doc.doctype=="Sales Invoice") { - var party_type = "Customer"; - var party_account_field = 'debit_to'; - } else { - var party_type = "Supplier"; - var party_account_field = 'credit_to'; - } + let party_type = me.frm.doc.doctype == "Sales Invoice" ? "Customer" : "Supplier"; + let party_account_field = me.frm.doc.doctype == "Sales Invoice" ? "debit_to" : "credit_to"; var party = me.frm.doc[frappe.model.scrub(party_type)]; if(party && me.frm.doc.company && (!me.frm.doc.__onload?.load_after_mapping || !me.frm.doc[party_account_field])) { @@ -1427,7 +1422,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe let first_row = this.frm.doc.items[0]; if (!first_row) { return false - }; + } let mapped_rows = mappped_fields.filter(d => first_row[d]) @@ -1599,7 +1594,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe this.frm.set_currency_labels(["operating_cost", "hour_rate"], this.frm.doc.currency, "operations"); this.frm.set_currency_labels(["base_operating_cost", "base_hour_rate"], company_currency, "operations"); - var item_grid = this.frm.fields_dict["operations"].grid; + let item_grid = this.frm.fields_dict["operations"].grid; $.each(["base_operating_cost", "base_hour_rate"], function(i, fname) { if(frappe.meta.get_docfield(item_grid.doctype, fname)) item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency); @@ -1610,7 +1605,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "scrap_items"); this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "scrap_items"); - var item_grid = this.frm.fields_dict["scrap_items"].grid; + let item_grid = this.frm.fields_dict["scrap_items"].grid; $.each(["base_rate", "base_amount"], function(i, fname) { if(frappe.meta.get_docfield(item_grid.doctype, fname)) item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency); @@ -2005,7 +2000,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe row_to_modify[key] = pr_row[key]; } - if (this.frm.doc.hasOwnProperty("is_pos") && this.frm.doc.is_pos) { + if (Object.prototype.hasOwnProperty.call(this.frm.doc, "is_pos") && this.frm.doc.is_pos) { let r = await frappe.db.get_value("POS Profile", this.frm.doc.pos_profile, "cost_center"); if (r.message.cost_center) { row_to_modify["cost_center"] = r.message.cost_center; @@ -2237,8 +2232,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }, callback: function(r) { if (!r.exc) { - $.each(me.frm.doc.items || [], function(i, item) { - if (item.name && r.message.hasOwnProperty(item.name) && r.message[item.name].item_tax_template) { + $.each(me.frm.doc.items || [], function (i, item) { + if ( + item.name && + Object.prototype.hasOwnProperty.call(r.message, item.name) && + r.message[item.name].item_tax_template + ) { item.item_tax_template = r.message[item.name].item_tax_template; item.item_tax_rate = r.message[item.name].item_tax_rate; me.add_taxes_from_item_tax_template(item.item_tax_rate); From 257b4225ecfe531cc88c5b4a0c41c17d9045f482 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Sat, 27 Jun 2026 02:34:24 +0530 Subject: [PATCH 06/94] ci: separate job for semgrep (#56580) --- .github/workflows/linters.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index f55d6bf844e..3b9d953db1c 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -20,6 +20,18 @@ jobs: - name: Install and Run Pre-commit uses: pre-commit/action@v3.0.1 + semgrep: + name: semgrep + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: '3.10' + cache: pip + - name: Download Semgrep rules run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules From afddf709053183030a3e26bd27e00531b3f0939e Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:37:22 +0530 Subject: [PATCH 07/94] fix(lead): added missing read permission check on `get_lead_details` (backport #56272) (#56273) Co-authored-by: Diptanil Saha fix(lead): added missing read permission check on `get_lead_details` (#56272) --- erpnext/crm/doctype/lead/lead.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 94e99a612e8..42b82395719 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -438,6 +438,7 @@ def get_lead_details(lead, posting_date=None, company=None, doctype=None): out = frappe._dict() lead_doc = frappe.get_doc("Lead", lead) + lead_doc.check_permission() lead = lead_doc out.update( From 54fcd09995469f89e114402eebbbe95455f0468b Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:04:38 +0530 Subject: [PATCH 08/94] fix: do not allow closing the accounting period for future dates (backport #56551) (#56576) fix: do not allow closing the accounting period for future dates (#56551) (cherry picked from commit 5e60e4faa7a7a2e5238746275995fc1bb7625a48) Co-authored-by: rohitwaghchaure --- .../doctype/accounting_period/accounting_period.py | 13 +++++++++++++ .../accounting_period/test_accounting_period.py | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/accounting_period/accounting_period.py b/erpnext/accounts/doctype/accounting_period/accounting_period.py index 300d216618e..426a4d57064 100644 --- a/erpnext/accounts/doctype/accounting_period/accounting_period.py +++ b/erpnext/accounts/doctype/accounting_period/accounting_period.py @@ -5,6 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document +from frappe.utils import getdate, nowdate class OverlapError(frappe.ValidationError): @@ -34,8 +35,20 @@ class AccountingPeriod(Document): # end: auto-generated types def validate(self): + self.validate_dates() self.validate_overlap() + def validate_dates(self): + if getdate(self.start_date) > getdate(self.end_date): + frappe.throw(_("Start Date cannot be after End Date")) + + if getdate(self.end_date) > getdate(nowdate()): + frappe.throw( + _( + "Accounting Period cannot be created for a future date. End Date {0} is after today." + ).format(frappe.bold(frappe.format(self.end_date, "Date"))) + ) + def before_insert(self): self.bootstrap_doctypes_for_closing() diff --git a/erpnext/accounts/doctype/accounting_period/test_accounting_period.py b/erpnext/accounts/doctype/accounting_period/test_accounting_period.py index 16cae9683f9..671a28e3956 100644 --- a/erpnext/accounts/doctype/accounting_period/test_accounting_period.py +++ b/erpnext/accounts/doctype/accounting_period/test_accounting_period.py @@ -4,7 +4,7 @@ import unittest import frappe -from frappe.utils import add_months, nowdate +from frappe.utils import nowdate from erpnext.accounts.doctype.accounting_period.accounting_period import ( ClosedAccountingPeriod, @@ -47,7 +47,7 @@ def create_accounting_period(**args): accounting_period = frappe.new_doc("Accounting Period") accounting_period.start_date = args.start_date or nowdate() - accounting_period.end_date = args.end_date or add_months(nowdate(), 1) + accounting_period.end_date = args.end_date or nowdate() accounting_period.company = args.company or "_Test Company" accounting_period.period_name = args.period_name or "_Test_Period_Name_1" accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1}) From d992f6f5c8ca5676d7fc9365729a195fa50d22ac Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:13:07 +0000 Subject: [PATCH 09/94] chore(crm_settings): remove unused `delete_custom_fields` import (backport #56558) (#56582) Co-authored-by: Diptanil Saha --- erpnext/crm/doctype/crm_settings/crm_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py index 04e5a402add..7ca341adb77 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.py +++ b/erpnext/crm/doctype/crm_settings/crm_settings.py @@ -3,7 +3,7 @@ import frappe from frappe import _ -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields, delete_custom_fields +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.model.document import Document From d3df0bf387c452d831e2d2cd7acf7074380307a3 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sat, 27 Jun 2026 23:20:11 +0530 Subject: [PATCH 10/94] fix: reserve projected stock for production plan based on BOM qty --- .../doctype/production_plan/production_plan.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 67323d42d40..cb8f24fc9f1 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -9,6 +9,7 @@ from collections import defaultdict import frappe from frappe import _, msgprint from frappe.model.document import Document +from frappe.query_builder import Case from frappe.query_builder.functions import IfNull, Sum from frappe.utils import ( add_days, @@ -1375,7 +1376,7 @@ def get_material_request_items( get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0 ) - if required_qty > 0: + if flt(row.get("qty")) > 0: return { "item_code": row.item_code, "item_name": row.item_name, @@ -1880,7 +1881,12 @@ def get_reserved_qty_for_production_plan(item_code, warehouse): frappe.qb.from_(table) .inner_join(child) .on(table.name == child.parent) - .select(Sum(child.quantity * child.conversion_factor)) + .select( + Sum( + (Case().when(child.quantity == 0, child.required_bom_qty).else_(child.quantity)) + * child.conversion_factor + ) + ) .where( (table.docstatus == 1) & (child.item_code == item_code) From b39024e0e3380c67e9c2a48d4b77dbeb3c6adc32 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sat, 27 Jun 2026 23:20:11 +0530 Subject: [PATCH 11/94] test: update cascading test to assert zero-qty reservation for stock-covered items --- .../production_plan/test_production_plan.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 5af1fdb36b5..e64a8c16b84 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -212,13 +212,15 @@ class TestProductionPlan(FrappeTestCase): quantities = [d["quantity"] for d in mr_items] rm_qty = sum(quantities) - # Only 2 MR item created - the first SO's requirement is fully covered by stock (v15 behaviour) - self.assertEqual(len(mr_items), 2) - self.assertEqual(rm_qty, 2, "Cascading failed: total MR qty should be 2 (3 needed - 1 in stock)") + # 3 MR items: SO1's requirement is covered by stock (qty=0 but reserved), SO2 and SO3 need 1 each + self.assertEqual(len(mr_items), 3) + self.assertEqual( + rm_qty, 2, "Cascading failed: total purchase qty should be 2 (3 needed - 1 in stock)" + ) self.assertEqual( quantities, - [1, 1], - "Cascading failed: only second and third SO should need procurement (qty=1) since first SO consumed stock", + [0, 1, 1], + "SO1 stock-covered item should appear with qty=0 for reservation; SO2 and SO3 need qty=1", ) sr.cancel() @@ -251,11 +253,13 @@ class TestProductionPlan(FrappeTestCase): pln = create_production_plan( item_code="Test Production Item 1", use_multi_level_bom=0, ignore_existing_ordered_qty=0 ) - self.assertFalse(len(pln.mr_items)) + items_needing_purchase = [row.item_code for row in pln.mr_items if row.quantity > 0] + self.assertFalse(len(items_needing_purchase)) + + pln.cancel() sr1.cancel() sr2.cancel() - pln.cancel() def test_production_plan_sales_orders(self): "Test if previously fulfilled SO (with WO) is pulled into Prod Plan." From bba7fe98313c44c1478d5dca72aadc9ba555aa7e Mon Sep 17 00:00:00 2001 From: pandiyan Date: Wed, 24 Jun 2026 18:43:33 +0530 Subject: [PATCH 12/94] fix: skip qty over-allowance check for non-stock items only --- erpnext/controllers/status_updater.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 21e6d3ea8b9..50d272d7efb 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -275,6 +275,12 @@ class StatusUpdater(Document): item["idx"] = d.idx item["target_ref_field"] = args["target_ref_field"].replace("_", " ") + # skip qty over-allowance check for non-stock items + if "qty" in args.get("target_ref_field", "") and not frappe.get_cached_value( + "Item", item["item_code"], "is_stock_item" + ): + continue + # if not item[args['target_ref_field']]: # msgprint(_("Note: System will not check over-delivery and over-booking for Item {0} as quantity or amount is 0").format(item.item_code)) if args.get("no_allowance"): From 3f00a0ffa9c03e891643f17b86a9ca9182c6e7f6 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Wed, 24 Jun 2026 18:43:39 +0530 Subject: [PATCH 13/94] test: add tests for non stock item over billing against so/po --- .../purchase_invoice/test_purchase_invoice.py | 18 ++++++++ .../sales_invoice/test_sales_invoice.py | 45 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index bccd29c822a..5aa2faed1a1 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -2924,6 +2924,24 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): # Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail self.assertRaises(frappe.ValidationError, pi.submit) + @change_settings("Accounts Settings", {"over_billing_allowance": 0}) + def test_non_stock_item_over_billing_against_po_is_blocked(self): + service_item = create_item( + "_Test Service Item Non Stock PI", + is_stock_item=0, + is_purchase_item=1, + ).name + + po = create_purchase_order(item_code=service_item, qty=5, rate=100, do_not_save=False) + po.submit() + + pi = make_pi_from_po(po.name) + pi.items[0].qty = 10 # overbill by 100 % + pi.save() + + with self.assertRaises(frappe.ValidationError): + pi.submit() + def test_discount_percentage_not_set_when_amount_is_manually_set(self): pi = make_purchase_invoice(do_not_save=True) discount_amount = 7 diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 6f51e27f532..0b1f1e922bf 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -3700,6 +3700,51 @@ class TestSalesInvoice(FrappeTestCase): self.assertTrue("cannot overbill" in str(err.exception).lower()) dn.cancel() + @change_settings("Accounts Settings", {"over_billing_allowance": 0}) + def test_non_stock_item_over_billing_against_so_is_blocked(self): + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + service_item = create_item( + "_Test Service Item Non Stock SI", + is_stock_item=0, + ).name + + so = make_sales_order(item_code=service_item, qty=5, rate=100) + so.submit() + + si = make_si_from_so(so.name) + si.items[0].qty = 10 # overbill by 100 % + si.save() + + with self.assertRaises(frappe.ValidationError): + si.submit() + + @change_settings("Accounts Settings", {"over_billing_allowance": 0}) + def test_non_stock_item_over_billing_against_so_from_quotation_is_blocked(self): + from erpnext.selling.doctype.quotation.quotation import make_sales_order as make_so_from_quotation + from erpnext.selling.doctype.quotation.test_quotation import make_quotation + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so + + service_item = create_item( + "_Test Service Item Non Stock SI Quot", + is_stock_item=0, + ).name + + quotation = make_quotation(item_code=service_item, qty=5, rate=100) + + so = make_so_from_quotation(quotation.name) + so.delivery_date = frappe.utils.add_days(frappe.utils.today(), 7) + so.insert() + so.submit() + + si = make_si_from_so(so.name) + si.items[0].qty = 10 # overbill by 100 % + si.save() + + with self.assertRaises(frappe.ValidationError): + si.submit() + @change_settings( "Accounts Settings", { From 562563553c3dafc5f5e97c3995db0ac22dcd1fab Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:16:40 +0530 Subject: [PATCH 14/94] fix: sync Stock Reconciliation difference amount with GL after reposting (backport #56574) (#56584) * fix: sync Stock Reconciliation difference amount with GL after reposting (#56574) * fix: sync Stock Reconciliation difference amount with GL after reposting * fix: placement of recalculate differece amount function (cherry picked from commit c7ef42ef98b9e1da549f67e73291fb25431aa46e) # Conflicts: # erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py # erpnext/stock/stock_ledger.py * chore: fix conflicts Removed unused imports and fixed import structure. * chore: fix conflicts Refactor update_rate_on_stock_reconciliation method to use recalculation from ledger instead of manual updates for stock reconciliation items. * chore: fix conflicts --------- Co-authored-by: rohitwaghchaure --- .../stock_reconciliation.py | 113 ++++++++++++ .../test_stock_reconciliation.py | 166 ++++++++++++++++++ erpnext/stock/stock_ledger.py | 45 ++--- 3 files changed, 291 insertions(+), 33 deletions(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 67f9b57c172..ab1358e8293 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -993,6 +993,102 @@ class StockReconciliation(StockController): d.quantity_difference = flt(d.qty) - flt(d.current_qty) d.amount_difference = flt(d.amount) - flt(d.current_amount) + def recalculate_difference_amount_from_ledger(self): + """Sync the displayed current qty/rate and difference amount with the (reposted) ledger. + + Submitted reconciliations freeze ``difference_amount`` and the per-row current values at + submit time, but reposting/backdated transactions recompute the reconciliation's Stock Ledger + Entries and rebuild the GL from them. Without this sync the document keeps showing stale figures + that no longer match the GL entries. Anchoring ``amount_difference`` to the row's summed + ``stock_value_difference`` keeps the document and the GL consistent by construction. + """ + difference_amount = 0.0 + + for row in self.items: + stock_value_difference = flt(get_row_stock_value_difference(self.doctype, self.name, row.name)) + + amount = flt(flt(row.qty) * flt(row.valuation_rate), row.precision("amount")) + amount_difference = flt(stock_value_difference, row.precision("amount_difference")) + current_amount = flt(amount - amount_difference, row.precision("current_amount")) + + current_qty = self.get_current_qty_from_ledger(row) + current_valuation_rate = ( + flt(current_amount / current_qty, row.precision("current_valuation_rate")) + if current_qty + else 0.0 + ) + + row.db_set( + { + "amount": amount, + "current_qty": current_qty, + "current_valuation_rate": current_valuation_rate, + "current_amount": current_amount, + "quantity_difference": flt(row.qty) - current_qty, + "amount_difference": amount_difference, + }, + update_modified=False, + ) + + difference_amount += amount_difference + + self.db_set( + "difference_amount", + flt(difference_amount, self.precision("difference_amount")), + update_modified=False, + ) + + def get_current_qty_from_ledger(self, row): + """Current (pre-reconciliation) qty for a row, recomputed from the ledger after reposting. + + Serial/batch rows cannot have backdated qty changes inserted before a future reconciliation + (blocked by ``check_future_entries_exists``), so their current qty is frozen and read straight + from the current bundle. Non-serial rows can float, so read the ledger balance just before the + reconciliation, excluding the reconciliation's own entries. + """ + if row.current_serial_and_batch_bundle: + total_qty = frappe.db.get_value( + "Serial and Batch Bundle", row.current_serial_and_batch_bundle, "total_qty" + ) + return abs(flt(total_qty, row.precision("current_qty"))) + + reco_sle = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": self.doctype, + "voucher_no": self.name, + "voucher_detail_no": row.name, + "is_cancelled": 0, + }, + ["posting_datetime", "creation"], + as_dict=True, + ) + if not reco_sle: + return flt(row.current_qty, row.precision("current_qty")) + + sle = frappe.qb.DocType("Stock Ledger Entry") + previous_sle = ( + frappe.qb.from_(sle) + .select(sle.qty_after_transaction) + .where( + (sle.item_code == row.item_code) + & (sle.warehouse == row.warehouse) + & (sle.is_cancelled == 0) + & ( + (sle.posting_datetime < reco_sle.posting_datetime) + | ( + (sle.posting_datetime == reco_sle.posting_datetime) + & (sle.creation < reco_sle.creation) + ) + ) + ) + .orderby(sle.posting_datetime, order=frappe.qb.desc) + .orderby(sle.creation, order=frappe.qb.desc) + .limit(1) + ).run() + + return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0 + def submit(self): if len(self.items) > 100: msgprint( @@ -1179,6 +1275,23 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None): return itemwise_batch_data +def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_detail_no: str): + """Net stock value change posted to the GL by a reconciliation row (sum of its SLEs).""" + sle = frappe.qb.DocType("Stock Ledger Entry") + result = ( + frappe.qb.from_(sle) + .select(Sum(sle.stock_value_difference)) + .where( + (sle.voucher_type == voucher_type) + & (sle.voucher_no == voucher_no) + & (sle.voucher_detail_no == voucher_detail_no) + & (sle.is_cancelled == 0) + ) + ).run() + + return flt(result[0][0]) if result and result[0][0] else 0.0 + + @frappe.whitelist() def get_stock_balance_for( item_code: str, diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 795ea870cf1..11d7850913e 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -782,6 +782,172 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): sr1.load_from_db() self.assertEqual(sr1.difference_amount, 10000) + def assert_reco_difference_matches_gl(self, reco_name): + """The displayed Difference Amount (doc and per-row) must equal the reposted GL impact, + i.e. the sum of the reconciliation's Stock Ledger Entry ``stock_value_difference``.""" + from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( + get_row_stock_value_difference, + ) + + reco = frappe.get_doc("Stock Reconciliation", reco_name) + total_difference = 0.0 + + for row in reco.items: + row_difference = flt( + get_row_stock_value_difference("Stock Reconciliation", reco_name, row.name), + row.precision("amount_difference"), + ) + + self.assertEqual(flt(row.amount_difference), row_difference) + total_difference += row_difference + + self.assertEqual( + flt(reco.difference_amount, reco.precision("difference_amount")), + flt(total_difference, reco.precision("difference_amount")), + ) + + def test_difference_amount_synced_with_gl_after_repost_non_serialized(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item().name + warehouse = "_Test Warehouse - _TC" + + # Opening stock => 100 * 100 = 10000 + make_stock_entry( + item_code=item_code, + target=warehouse, + qty=100, + basic_rate=100, + posting_date=add_days(nowdate(), -5), + posting_time="10:00:00", + ) + + # Reconcile to 100 @ 200 => difference 20000 - 10000 = 10000 + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=100, + rate=200, + posting_date=add_days(nowdate(), -2), + ) + self.assertEqual(reco.difference_amount, 10000) + self.assert_reco_difference_matches_gl(reco.name) + + # Backdated reconciliation lowers the pre-reco stock value to 50 * 50 = 2500 + create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=50, + rate=50, + posting_date=add_days(nowdate(), -3), + ) + + reco.load_from_db() + # Current is now 2500 => difference 20000 - 2500 = 17500 + self.assertEqual(reco.difference_amount, 17500) + self.assert_reco_difference_matches_gl(reco.name) + + def test_difference_amount_synced_with_gl_after_repost_batched(self): + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + make_landed_cost_voucher, + ) + + item_code = self.make_item( + "Test Batch Item Reco Difference Sync", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TEST-BATCH-DIFFSYNC-.###", + }, + ).name + warehouse = "_Test Warehouse - _TC" + + # Receive 10 @ 100 (batch value 1000) + pr = make_purchase_receipt( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=100, + posting_date=add_days(nowdate(), -5), + ) + batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle) + + # Reconcile the batch to 10 @ 500 => difference 5000 - 1000 = 4000 + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=500, + batch_no=batch_no, + use_serial_batch_fields=1, + posting_date=add_days(nowdate(), -2), + ) + difference_on_submit = reco.difference_amount + self.assert_reco_difference_matches_gl(reco.name) + + # Landed cost retroactively raises the receipt (and batch) valuation, reposting the reco + make_landed_cost_voucher( + receipt_document_type="Purchase Receipt", + receipt_document=pr.name, + charges=1000, + company="_Test Company", + ) + + reco.load_from_db() + self.assertNotEqual(reco.difference_amount, difference_on_submit) + self.assert_reco_difference_matches_gl(reco.name) + + def test_difference_amount_synced_with_gl_after_repost_serialized(self): + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + make_landed_cost_voucher, + ) + + item_code = self.make_item( + "Test Serial Item Reco Difference Sync", + { + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "TSIRDS.####", + }, + ).name + warehouse = "_Test Warehouse - _TC" + + # Receive 5 serial nos @ 100 (value 500) + pr = make_purchase_receipt( + item_code=item_code, + warehouse=warehouse, + qty=5, + rate=100, + posting_date=add_days(nowdate(), -5), + ) + serial_nos = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle) + + # Reconcile the serial nos to 5 @ 500 => difference 2500 - 500 = 2000 + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=5, + rate=500, + serial_no="\n".join(serial_nos), + use_serial_batch_fields=1, + posting_date=add_days(nowdate(), -2), + ) + difference_on_submit = reco.difference_amount + self.assert_reco_difference_matches_gl(reco.name) + + # Landed cost retroactively raises the receipt (and serial) valuation, reposting the reco + make_landed_cost_voucher( + receipt_document_type="Purchase Receipt", + receipt_document=pr.name, + charges=1000, + company="_Test Company", + ) + + reco.load_from_db() + self.assertNotEqual(reco.difference_amount, difference_on_submit) + self.assert_reco_difference_matches_gl(reco.name) + def test_make_stock_zero_for_serial_batch_item(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 9ee458ce9c7..26dcb5f024b 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1329,6 +1329,11 @@ class update_entries_after: Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return In case of Stock Entry, also calculate FG Item rate and total incoming/outgoing amount """ + if sle.voucher_type == "Stock Reconciliation": + if flt(sle.actual_qty) <= 0 and not self.args.get("sle_id"): + self.update_rate_on_stock_reconciliation(sle) + return + if sle.actual_qty and sle.voucher_detail_no: outgoing_rate = abs(flt(sle.stock_value_difference)) / abs(sle.actual_qty) @@ -1340,8 +1345,6 @@ class update_entries_after: self.update_rate_on_purchase_receipt(sle, outgoing_rate) elif flt(sle.actual_qty) < 0 and sle.voucher_type == "Subcontracting Receipt": self.update_rate_on_subcontracting_receipt(sle, outgoing_rate) - elif sle.voucher_type == "Stock Reconciliation": - self.update_rate_on_stock_reconciliation(sle) def update_rate_on_stock_entry(self, sle, outgoing_rate): frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate) @@ -1435,37 +1438,13 @@ class update_entries_after: d.db_update() def update_rate_on_stock_reconciliation(self, sle): - if not sle.serial_no and not sle.batch_no: - sr = frappe.get_doc("Stock Reconciliation", sle.voucher_no, for_update=True) - - for item in sr.items: - # Skip for Serial and Batch Items - if item.name != sle.voucher_detail_no or item.serial_no or item.batch_no: - continue - - previous_sle = get_previous_sle( - { - "item_code": item.item_code, - "warehouse": item.warehouse, - "posting_date": sr.posting_date, - "posting_time": sr.posting_time, - "sle": sle.name, - } - ) - - item.current_qty = previous_sle.get("qty_after_transaction") or 0.0 - item.current_valuation_rate = previous_sle.get("valuation_rate") or 0.0 - item.current_amount = flt(item.current_qty) * flt(item.current_valuation_rate) - - item.amount = flt(item.qty) * flt(item.valuation_rate) - item.quantity_difference = item.qty - item.current_qty - item.amount_difference = item.amount - item.current_amount - else: - sr.difference_amount = sum([item.amount_difference for item in sr.items]) - sr.db_update() - - for item in sr.items: - item.db_update() + # Refresh the reconciliation's difference amount and per-row current qty/rate from the reposted + # ledger so the document keeps matching the GL entries. Handles serialized, batched and + # non-serialized items uniformly (the document method reads the current bundle for serial/batch + # rows and the pre-reconciliation ledger balance for non-serial rows). + frappe.get_lazy_doc( + "Stock Reconciliation", sle.voucher_no, for_update=True + ).recalculate_difference_amount_from_ledger() def get_incoming_value_for_serial_nos(self, sle, serial_nos): # get rate from serial nos within same company From 8abc27863ab0653a3af9c02dd034c2049b084d39 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 15:06:21 +0530 Subject: [PATCH 15/94] fix: extract stock ageing data helper in stock balance (backport #55774) The v15 backport (#55776) of #55774 dropped the stock_balance.py changes. A separate backport left an inline version that sorts/filters the FIFO queue by itemgetter(1) *before* normalizing, so for batchwise valuation slots itemgetter(1) reads the count flag instead of the posting date. The queue is then ordered wrong and earliest_age/latest_age come out swapped. Extract get_stock_ageing_data, which normalizes before sorting/filtering so itemgetter(1) is always the posting date, and add the regression test. Co-Authored-By: Claude Opus 4.8 --- .../report/stock_balance/stock_balance.py | 28 +++++++++++-------- .../stock_balance/test_stock_balance.py | 18 +++++++++++- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 6bceb0483f8..2c25b2c5b2b 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -100,8 +100,6 @@ class StockBalanceReport: self.filters["show_warehouse_wise_stock"] = True item_wise_fifo_queue = FIFOSlots(self.filters, self.sle_entries).generate() - _func = itemgetter(1) - del self.sle_entries sre_details = self.get_sre_reserved_qty_details() @@ -126,16 +124,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 - - to_date = self.to_date - stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date) - stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1]) - stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1]) - stock_ageing_data["fifo_queue"] = fifo_queue + stock_ageing_data.update(get_stock_ageing_data(opening_fifo_queue, self.to_date)) report_data.update(stock_ageing_data) @@ -694,6 +683,21 @@ class StockBalanceReport: return opening_fifo_queue +def get_stock_ageing_data(fifo_queue: list, to_date: str) -> dict: + stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0} + fifo_queue = sorted(filter(itemgetter(1), normalize_fifo_queue(fifo_queue)), key=itemgetter(1)) + + if not fifo_queue: + return stock_ageing_data + + stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date) + stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1]) + stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1]) + stock_ageing_data["fifo_queue"] = fifo_queue + + return stock_ageing_data + + def filter_items_with_no_transactions( iwb_map, float_precision: float, inventory_dimensions: list | None = None ): diff --git a/erpnext/stock/report/stock_balance/test_stock_balance.py b/erpnext/stock/report/stock_balance/test_stock_balance.py index 0985e4783c3..347c14c7eb0 100644 --- a/erpnext/stock/report/stock_balance/test_stock_balance.py +++ b/erpnext/stock/report/stock_balance/test_stock_balance.py @@ -7,7 +7,7 @@ from frappe.utils import today from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.stock.report.stock_balance.stock_balance import execute +from erpnext.stock.report.stock_balance.stock_balance import execute, get_stock_ageing_data def stock_balance(filters): @@ -168,3 +168,19 @@ class TestStockBalance(FrappeTestCase): rows = stock_balance(self.filters.update({"show_variant_attributes": 1, "item_code": [variant.name]})) self.assertPartialDictEq(attributes, rows[0]) self.assertInvariants(rows) + + def test_stock_ageing_data_accepts_batchwise_valuation_slots(self): + fifo_queue = [ + ["SA-BATCH-NEWER", 1, 2.0, "2021-12-05", 20.0], + ["SA-BATCH-OLDER", 1, 3.0, "2021-12-01", 30.0], + ] + + stock_ageing_data = get_stock_ageing_data(fifo_queue, "2021-12-10") + + self.assertEqual(stock_ageing_data["average_age"], 7.4) + self.assertEqual(stock_ageing_data["earliest_age"], 9) + self.assertEqual(stock_ageing_data["latest_age"], 5) + self.assertEqual( + stock_ageing_data["fifo_queue"], + [[3.0, "2021-12-01", 30.0], [2.0, "2021-12-05", 20.0]], + ) From ab98d19f26f82f34f89a2dcddfa285e8c55cff74 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 29 Jun 2026 17:20:12 +0530 Subject: [PATCH 16/94] fix: negative stock issue (#56616) --- erpnext/stock/stock_ledger.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 26dcb5f024b..06159345577 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -134,6 +134,7 @@ def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_vou "sle_id": args.get("name"), "creation": args.get("creation"), "reserved_stock": args.get("reserved_stock"), + "cancelled": args.get("is_cancelled"), }, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher, From f50e529f8ad6bcc267925fd550ab3f5baa9a4134 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 29 Jun 2026 17:21:17 +0530 Subject: [PATCH 17/94] fix: update_qty_in_future_sle skips SLEs with same posting datetime (#56615) --- .../test_stock_ledger_entry.py | 101 ++++++++++++++++++ erpnext/stock/stock_ledger.py | 80 ++++++++------ 2 files changed, 149 insertions(+), 32 deletions(-) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 917aba9803e..ac3d4a01d11 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1257,6 +1257,107 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): self.assertEqual(sle[0].qty_after_transaction, 105) self.assertEqual(sle[0].actual_qty, 100) + def test_update_qty_in_future_sle_shifts_same_timestamp_later_entry(self): + # update_qty_in_future_sle treats "future" as strictly after the current entry in the + # (posting_datetime, creation) order. An entry sharing the exact posting timestamp but created + # later must still have its running balance shifted; comparing posting_datetime alone would skip + # it. The current entry itself (same timestamp, same creation) must not be shifted. + from erpnext.stock.stock_ledger import update_qty_in_future_sle + + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + receipt1 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=10, + rate=10, + posting_date="2021-01-01", + posting_time="02:00:00", + ) + time.sleep(1) + receipt2 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=20, + rate=10, + posting_date="2021-01-01", + posting_time="02:00:00", # identical timestamp, later creation + ) + + def sle(voucher): + return frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": voucher.name, "is_cancelled": 0}, + ["name", "posting_date", "posting_time", "creation", "qty_after_transaction"], + as_dict=True, + ) + + sle1, sle2 = sle(receipt1), sle(receipt2) + self.assertEqual(sle1.qty_after_transaction, 10) + self.assertEqual(sle2.qty_after_transaction, 30) + + # Simulate a +5 qty shift originating at receipt1's ledger position. + args = frappe._dict( + { + "item_code": item, + "warehouse": warehouse, + "voucher_type": "Purchase Receipt", + "voucher_no": receipt1.name, + "posting_date": sle1.posting_date, + "posting_time": sle1.posting_time, + "creation": sle1.creation, + "actual_qty": 5, + } + ) + update_qty_in_future_sle(args, allow_negative_stock=True) + + # receipt2 (same timestamp, later creation) is shifted; receipt1 (the current entry) is not. + self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle2.name, "qty_after_transaction"), 35) + self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle1.name, "qty_after_transaction"), 10) + + def test_get_next_stock_reco_respects_creation_order(self): + # A stock reco sharing the exact posting timestamp of the current entry must only count as the + # "next" reco when it was created after that entry. A reco created before it actually precedes + # the entry and must not bound (truncate) the qty-shift range. + from erpnext.stock.stock_ledger import get_next_stock_reco + + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + reco = create_stock_reconciliation( + item_code=item, + warehouse=warehouse, + qty=10, + rate=100, + posting_date="2021-01-01", + posting_time="02:00:00", + ) + reco_sle = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": reco.name, "is_cancelled": 0}, + ["posting_date", "posting_time", "creation"], + as_dict=True, + ) + + base_kwargs = { + "item_code": item, + "warehouse": warehouse, + "voucher_no": "SOME-OTHER-VOUCHER", + "posting_date": reco_sle.posting_date, + "posting_time": reco_sle.posting_time, + } + + # Current entry created AFTER the reco at the same timestamp -> reco precedes it -> not returned. + after = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=5)} + self.assertFalse(get_next_stock_reco(after)) + + # Current entry created BEFORE the reco at the same timestamp -> reco follows it -> returned. + before = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=-5)} + result = get_next_stock_reco(before) + self.assertTrue(result) + self.assertEqual(result[0].voucher_no, reco.name) + @change_settings("System Settings", {"float_precision": 3, "currency_precision": 2}) def test_transfer_invariants(self): """Extact stock value should be transferred.""" diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 06159345577..0d83119a0fc 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -2042,36 +2042,44 @@ def get_valuation_rate( def update_qty_in_future_sle(args, allow_negative_stock=False): """Recalculate Qty after Transaction in future SLEs based on current SLE.""" - datetime_limit_condition = "" qty_shift = args.actual_qty - args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"]) + posting_datetime = get_combine_datetime(args["posting_date"], args["posting_time"]) + args["posting_datetime"] = posting_datetime # find difference/shift in qty caused by stock reconciliation if args.voucher_type == "Stock Reconciliation": qty_shift = get_stock_reco_qty_shift(args) + sle = frappe.qb.DocType("Stock Ledger Entry") + + # SLEs are ordered by (posting_datetime, creation). "Future" therefore means strictly after the + # current entry in that tuple order: a later posting_datetime, or the same posting_datetime with a + # later creation. Comparing posting_datetime alone would skip same-timestamp entries created after + # this one (e.g. the same item repeated in a voucher, or another voucher posted in the same second). + future_condition = sle.posting_datetime > posting_datetime + if args.get("creation"): + future_condition = future_condition | ( + (sle.posting_datetime == posting_datetime) & (sle.creation > args.get("creation")) + ) + + query = ( + frappe.qb.update(sle) + .set(sle.qty_after_transaction, sle.qty_after_transaction + qty_shift) + .where( + (sle.item_code == args.get("item_code")) + & (sle.warehouse == args.get("warehouse")) + & (sle.is_cancelled == 0) + & future_condition + ) + ) + # find the next nearest stock reco so that we only recalculate SLEs till that point next_stock_reco_detail = get_next_stock_reco(args) if next_stock_reco_detail: - detail = next_stock_reco_detail[0] - datetime_limit_condition = get_datetime_limit_condition(detail) + query = query.where(get_datetime_limit_condition(sle, next_stock_reco_detail[0])) - frappe.db.sql( # nosemgrep - f""" - update `tabStock Ledger Entry` - set qty_after_transaction = qty_after_transaction + {qty_shift} - where - item_code = %(item_code)s - and warehouse = %(warehouse)s - and is_cancelled = 0 - and ( - posting_datetime > %(posting_datetime)s - ) - {datetime_limit_condition} - """, - args, - ) + query.run() validate_negative_qty_in_future_sle(args, allow_negative_stock) @@ -2106,6 +2114,22 @@ def get_stock_reco_qty_shift(args): return stock_reco_qty_shift +def get_next_reco_datetime_condition(sle, kwargs): + """Match stock recos that come strictly after the current entry in (posting_datetime, creation) + order. Using posting_datetime alone (>=) could pick a reco sharing this exact timestamp but created + earlier — i.e. one that actually precedes this entry — and wrongly truncate the qty-shift range.""" + current_datetime = get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) + + creation = kwargs.get("creation") + if not creation: + # No creation tiebreaker available; fall back to the posting_datetime-only bound. + return sle.posting_datetime >= current_datetime + + return (sle.posting_datetime > current_datetime) | ( + (sle.posting_datetime == current_datetime) & (sle.creation > creation) + ) + + def get_next_stock_reco(kwargs): """Returns next nearest stock reconciliaton's details.""" @@ -2131,10 +2155,7 @@ def get_next_stock_reco(kwargs): & (sle.voucher_type == "Stock Reconciliation") & (sle.voucher_no != kwargs.get("voucher_no")) & (sle.is_cancelled == 0) - & ( - sle.posting_datetime - >= get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) - ) + & get_next_reco_datetime_condition(sle, kwargs) ) .orderby(sle.posting_datetime) .orderby(sle.creation) @@ -2147,17 +2168,12 @@ def get_next_stock_reco(kwargs): return query.run(as_dict=True) -def get_datetime_limit_condition(detail): +def get_datetime_limit_condition(sle, detail): posting_datetime = get_combine_datetime(detail.posting_date, detail.posting_time) - return f""" - and - (posting_datetime < '{posting_datetime}' - or ( - posting_datetime = '{posting_datetime}' - and creation < '{detail.creation}' - ) - )""" + return (sle.posting_datetime < posting_datetime) | ( + (sle.posting_datetime == posting_datetime) & (sle.creation < detail.creation) + ) def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): From 52d04ad8341fa5653de593feb7c0dbb4f1ecf6b5 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:04:44 +0530 Subject: [PATCH 18/94] fix: Use correct doctype name for PCV perm-check (backport #56606) (#56610) fix: Use correct doctype name for PCV perm-check (#56606) closes https://github.com/frappe/erpnext/issues/56593 (cherry picked from commit 747374e767a1b790d1cd2d83cab8ae3bdb3580ae) Co-authored-by: Ankush Menat --- .../process_period_closing_voucher.py | 2 +- 1 file changed, 1 insertion(+), 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 096b085cf0e..b5ca3331b71 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 @@ -89,7 +89,7 @@ class ProcessPeriodClosingVoucher(Document): @frappe.whitelist() def start_pcv_processing(docname: str): if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]: - frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True) + frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True) frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running") timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 From 83e8d1eb2f74c34e555b149c61f2e660387601a9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:12:20 +0530 Subject: [PATCH 19/94] fix: carry item-level project to Purchase Receipt GL entries (backport #56568) (#56618) fix: carry item-level project to Purchase Receipt GL entries (#56568) Purchase Receipt stock and asset GL lines used the item row's cost center but always fell back to the document-level project, unlike Purchase Invoice which uses the item-level project. add_gl_entry accepted a project argument but never wrote it to the GL dict, so the inward, Stock Received But Not Billed, landed cost, divisional loss, sub-contracting and exchange rate lines dropped the row's project. Write project into the GL dict and pass project=item.project on the entries that were missing it, so project behaves like cost center and matches Purchase Invoice. Ticket: 72523 (cherry picked from commit 6f97c7199c4f65e5e2fda31d99905077f2d2a2bc) Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> --- erpnext/controllers/stock_controller.py | 3 +++ erpnext/stock/doctype/purchase_receipt/purchase_receipt.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 32968952fc6..f58c831922a 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1490,6 +1490,9 @@ class StockController(AccountsController): "remarks": remarks, } + if project: + gl_entry.update({"project": project}) + if voucher_detail_no: gl_entry.update({"voucher_detail_no": voucher_detail_no}) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index c27e2e40f30..10099631a75 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -493,6 +493,7 @@ class PurchaseReceipt(BuyingController): remarks=remarks, against_account=stock_asset_rbnb, account_currency=account_currency, + project=item.project, item=item, ) @@ -535,6 +536,7 @@ class PurchaseReceipt(BuyingController): against_account=stock_asset_account_name, debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")), account_currency=account_currency, + project=item.project, item=item, ) @@ -559,6 +561,7 @@ class PurchaseReceipt(BuyingController): against_account=self.supplier, debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, account_currency=account_currency, + project=item.project, item=item, ) @@ -572,6 +575,7 @@ class PurchaseReceipt(BuyingController): against_account=self.supplier, debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, account_currency=account_currency, + project=item.project, item=item, ) @@ -634,6 +638,7 @@ class PurchaseReceipt(BuyingController): remarks=remarks, against_account=stock_asset_account_name, account_currency=supplier_warehouse_account_currency, + project=item.project, item=item, ) From 9125ab6c7734fb2a97060a717851e3a7c9051284 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 10:14:42 +0530 Subject: [PATCH 20/94] fix(controllers): fix supplier-RFQ portal list query (wrong column + Postgres DISTINCT) rfq_transaction_list had two defects introduced when it was converted to the query builder: 1. `party.supplier == party[0]` compared supplier to a column literally named "0" (a stray index on the DocType, not the intended `parties[0]` value). This renders as `supplier = \`0\`` / `supplier = "0"` and errors on BOTH engines (MariaDB: Unknown column '0'; Postgres: column "0" does not exist), so the supplier portal RFQ list was completely broken. 2. SELECT DISTINCT ordered by `creation`, which is not in the select list. Postgres rejects this ("for SELECT DISTINCT, ORDER BY expressions must appear in select list"). Compare against `parties[0]` and add `creation` to the select list. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit a7d9078bf450141a4e10fcb2f406f6ba272fe803) # Conflicts: # erpnext/controllers/tests/test_website_list_for_contact.py --- .../tests/test_website_list_for_contact.py | 36 +++++++++++++++++++ .../controllers/website_list_for_contact.py | 5 +-- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 erpnext/controllers/tests/test_website_list_for_contact.py diff --git a/erpnext/controllers/tests/test_website_list_for_contact.py b/erpnext/controllers/tests/test_website_list_for_contact.py new file mode 100644 index 00000000000..d62254d8a0c --- /dev/null +++ b/erpnext/controllers/tests/test_website_list_for_contact.py @@ -0,0 +1,36 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import json + +from frappe.tests.utils import FrappeTestCase + + +class TestWebsiteListForContact(FrappeTestCase): + def test_get_list_context_currency_symbols(self): + # get_list_context builds the enabled-currency symbol map via frappe.get_all (converted from + # raw SQL). Exercises that query and asserts a known enabled currency is present. + from erpnext.controllers.website_list_for_contact import get_list_context + + context = get_list_context() + + symbols = json.loads(context["currency_symbols"]) + self.assertIsInstance(symbols, dict) + self.assertIn("USD", symbols) + + def test_rfq_transaction_list_returns_supplier_rfq(self): + # rfq_transaction_list filters RFQs by the supplier (parties[0]) and uses SELECT DISTINCT with + # ORDER BY creation -- both must be valid on Postgres, and the supplier filter must compare to the + # party value (not a stray `party[0]` column reference). + from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation import ( + make_request_for_quotation, + ) + from erpnext.controllers.website_list_for_contact import rfq_transaction_list + + rfq = make_request_for_quotation() + supplier = rfq.suppliers[0].supplier + + rows = rfq_transaction_list( + "Request for Quotation Supplier", "Request for Quotation", [supplier], 0, 20 + ) + self.assertIn(rfq.name, [row.name for row in rows]) diff --git a/erpnext/controllers/website_list_for_contact.py b/erpnext/controllers/website_list_for_contact.py index ea7b47bd487..a62fccc752c 100644 --- a/erpnext/controllers/website_list_for_contact.py +++ b/erpnext/controllers/website_list_for_contact.py @@ -181,9 +181,10 @@ def rfq_transaction_list(parties_doctype, doctype, parties, limit_start, limit_p party = frappe.qb.DocType(parties_doctype) data = ( frappe.qb.from_(party) - .select(party.parent.as_("name"), party.supplier) + # creation must be selected: Postgres requires SELECT DISTINCT order-by exprs in the select list + .select(party.parent.as_("name"), party.supplier, party.creation) .distinct() - .where((party.supplier == party[0]) & (party.docstatus == 1)) + .where((party.supplier == parties[0]) & (party.docstatus == 1)) .orderby(party.creation, order=frappe.qb.desc) .limit(limit_page_length) .offset(limit_start) From 25764520c2981460ffa713c8ff5770246667b695 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:47:33 +0530 Subject: [PATCH 21/94] fix: update qty in future SLEs when cancelling documents (backport #56638) (#56644) fix: update qty in future SLEs when cancelling documents (#56638) (cherry picked from commit 01374db8dab4979309b4d1fa446c4af0a008ddfc) Co-authored-by: rohitwaghchaure --- .../test_stock_ledger_entry.py | 41 +++++++++++++++++++ erpnext/stock/stock_ledger.py | 5 ++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index ac3d4a01d11..6aea0ef5337 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1316,6 +1316,47 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle2.name, "qty_after_transaction"), 35) self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle1.name, "qty_after_transaction"), 10) + def test_cancel_first_of_two_same_timestamp_entries(self): + # Two receipts of the same item+warehouse at the exact same posting timestamp: balances 10 -> 20. + # Cancelling the first must leave the second standing alone on a zero base (qty 10), not + # double-decremented. The same-timestamp sibling is corrected by the cancellation reprocessing, + # so update_qty_in_future_sle must not shift it again. + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + receipt1 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=10, + rate=10, + posting_date="2026-06-01", + posting_time="10:00:00", + ) + time.sleep(1) + receipt2 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=10, + rate=10, + posting_date="2026-06-01", + posting_time="10:00:00", # identical timestamp, later creation + ) + + def qty_after(voucher): + return frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": voucher.name, "is_cancelled": 0}, + "qty_after_transaction", + ) + + self.assertEqual(qty_after(receipt1), 10) + self.assertEqual(qty_after(receipt2), 20) + + receipt1.cancel() + + # receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error). + self.assertEqual(qty_after(receipt2), 10) + def test_get_next_stock_reco_respects_creation_order(self): # A stock reco sharing the exact posting timestamp of the current entry must only count as the # "next" reco when it was created after that entry. A reco created before it actually precedes diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 0d83119a0fc..93ee2eaa651 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -2057,8 +2057,11 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): # current entry in that tuple order: a later posting_datetime, or the same posting_datetime with a # later creation. Comparing posting_datetime alone would skip same-timestamp entries created after # this one (e.g. the same item repeated in a voucher, or another voucher posted in the same second). + # On cancellation `args` is a freshly inserted reversal entry, so its `creation` is the cancel time + # (not the original entry's position) and same-timestamp siblings are already recomputed by the + # cancelled path in update_entries_after; applying the tiebreaker here would double-shift them. future_condition = sle.posting_datetime > posting_datetime - if args.get("creation"): + if args.get("creation") and not args.get("is_cancelled"): future_condition = future_condition | ( (sle.posting_datetime == posting_datetime) & (sle.creation > args.get("creation")) ) From 3479d65bd4fe0b5efcebad0e0c6eb0bf94033f00 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:33:00 +0530 Subject: [PATCH 22/94] fix(stock): value batch/serial return from ledger when original receipt has no bundle (backport #56631) (#56645) fix(stock): value batch/serial return from ledger when original receipt has no bundle (#56631) * fix(stock): value batch/serial return from ledger when original receipt has no bundle * test(stock): add test to validate the valuation of serial/batch for return when original receipt has no bundle (cherry picked from commit 6184c057dbb4547f6cbb30f08a16a811fe811b0c) Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> --- .../serial_and_batch_bundle.py | 52 +++++++++++- .../test_serial_and_batch_bundle.py | 85 +++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index f8facea5f78..4fa630fb8a8 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -513,10 +513,12 @@ class SerialandBatchBundle(Document): ] # Added to handle rejected warehouse case + return_warehouse = None if self.voucher_type in ["Purchase Receipt", "Purchase Invoice"]: warehouses = get_warehouses_for_return(self.voucher_type, return_against_voucher_detail_no) if self.warehouse in warehouses: - filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse]) + return_warehouse = self.warehouse + filters.append(["Serial and Batch Entry", "warehouse", "=", return_warehouse]) bundle_data = frappe.get_all( "Serial and Batch Bundle", @@ -529,6 +531,11 @@ class SerialandBatchBundle(Document): order_by="`tabSerial and Batch Bundle`.`creation`, `tabSerial and Batch Entry`.`idx`", ) + if not bundle_data: + bundle_data = self.get_legacy_valuation_rate_for_return_entry( + return_against, return_against_voucher_detail_no, return_warehouse + ) + if not bundle_data: return {} @@ -540,6 +547,49 @@ class SerialandBatchBundle(Document): return valuation_details + def get_legacy_valuation_rate_for_return_entry( + self, return_against, return_against_voucher_detail_no, return_warehouse=None + ): + """Return the original line's incoming rate per serial no / batch from the SLE, for legacy receipts with no bundle.""" + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + if not (self.has_serial_no or self.has_batch_no): + return [] + + sle = frappe.qb.DocType("Stock Ledger Entry") + query = ( + frappe.qb.from_(sle) + .select(sle.serial_no, sle.batch_no, sle.incoming_rate) + .where( + (sle.voucher_no == return_against) + & (sle.voucher_detail_no == return_against_voucher_detail_no) + & (sle.item_code == self.item_code) + & (sle.is_cancelled == 0) + & (sle.serial_and_batch_bundle.isnull()) + ) + ) + + if return_warehouse: + query = query.where(sle.warehouse == return_warehouse) + + data = [] + for d in query.run(as_dict=True): + if d.serial_no: + for serial_no in get_serial_nos(d.serial_no): + data.append( + frappe._dict( + {"serial_no": serial_no, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate} + ) + ) + elif d.batch_no: + data.append( + frappe._dict( + {"serial_no": None, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate} + ) + ) + + return data + def calculate_total_qty(self, save=True): self.total_qty = 0.0 for d in self.entries: diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 2cee2bb6f0a..100e62deafb 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -1246,6 +1246,91 @@ class TestSerialandBatchBundle(FrappeTestCase): self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se1.name) + def _assert_legacy_return_valuation(self, item_code, props, batch_no=None): + """Return against a legacy serial/batch receipt (no Serial and Batch Bundle) must value outgoing stock from the original ledger rate.""" + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + make_item(item_code, props) + if batch_no and not frappe.db.exists("Batch", batch_no): + frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert() + + pr = make_purchase_receipt( + item_code=item_code, qty=10, rate=100, batch_no=batch_no, use_serial_batch_fields=True + ) + + # Simulate a receipt migrated from an older version: serial nos / batch tracked via the + # deprecated fields on the Stock Ledger Entry, with no Serial and Batch Bundle. + serial_nos = [] + for row in pr.items: + if row.serial_and_batch_bundle: + serial_nos = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": row.serial_and_batch_bundle}, + pluck="serial_no", + ) + frappe.db.delete("Serial and Batch Bundle", {"name": row.serial_and_batch_bundle}) + frappe.db.set_value("Purchase Receipt Item", row.name, "serial_and_batch_bundle", None) + + serial_nos = [sn for sn in serial_nos if sn] + legacy = {"serial_and_batch_bundle": None} + if batch_no: + legacy["batch_no"] = batch_no + if serial_nos: + legacy["serial_no"] = "\n".join(serial_nos) + for sle in frappe.get_all("Stock Ledger Entry", filters={"voucher_no": pr.name}, pluck="name"): + frappe.db.set_value("Stock Ledger Entry", sle, legacy) + + rt = make_return_doc("Purchase Receipt", pr.name) + rt.items[0].qty = -4 + rt.items[0].received_qty = -4 + rt.items[0].use_serial_batch_fields = 1 + if batch_no: + rt.items[0].batch_no = batch_no + if serial_nos: + rt.items[0].serial_no = "\n".join(serial_nos[:4]) + rt.submit() + + difference_in_stock_value = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": rt.name, "is_cancelled": 0, "voucher_type": "Purchase Receipt"}, + "stock_value_difference", + ) + # 4 units returned at the original ledger rate of 100 -> -400 (must not be zero) + self.assertEqual(flt(difference_in_stock_value, 2), -400.0) + + def test_return_valuation_for_legacy_batch_without_bundle(self): + self._assert_legacy_return_valuation( + "Test Legacy Batch Return Valuation", + { + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "LBRV-.#####", + "is_stock_item": 1, + }, + batch_no="LBRV-BATCH-0001", + ) + + def test_return_valuation_for_legacy_serial_without_bundle(self): + self._assert_legacy_return_valuation( + "Test Legacy Serial Return Valuation", + {"has_serial_no": 1, "serial_no_series": "LSRV-.#####", "is_stock_item": 1}, + ) + + def test_return_valuation_for_legacy_serial_and_batch_without_bundle(self): + self._assert_legacy_return_valuation( + "Test Legacy Serial Batch Return Valuation", + { + "has_serial_no": 1, + "serial_no_series": "LSBRV-.#####", + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "LSBRVB-.#####", + "is_stock_item": 1, + }, + batch_no="LSBRV-BATCH-0001", + ) + def get_batch_from_bundle(bundle): from erpnext.stock.serial_batch_bundle import get_batch_nos From 53bdccee5f79df7cf2112a70e2ec0472b6102fbc Mon Sep 17 00:00:00 2001 From: pandiyan Date: Tue, 30 Jun 2026 16:09:00 +0530 Subject: [PATCH 23/94] fix: set mr status to received when per_received is 100 even if per_ordered < 100 --- erpnext/controllers/status_updater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 50d272d7efb..c695d17e80f 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -135,7 +135,7 @@ status_map = { ], [ "Partially Ordered", - "eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type != 'Material Transfer'", + "eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.per_received < 100 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']", ], ], "POS Opening Entry": [ From c2b77184554c2d6bf959cb59f7807377f372c06b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 30 Jun 2026 20:23:14 +0530 Subject: [PATCH 24/94] fix: use correct variable to fetch valuation method (cherry picked from commit 1492c9fbc3fb7c0e8b2434d4ffeb7320838a70c6) # Conflicts: # erpnext/controllers/sales_and_purchase_return.py --- erpnext/controllers/sales_and_purchase_return.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index e1e3ba3e84e..afa4f5c6d00 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -143,7 +143,11 @@ def validate_returned_items(doc): ref.rate and flt(d.rate) > ref.rate and doc.doctype in ("Delivery Note", "Sales Invoice") +<<<<<<< HEAD and get_valuation_method(ref.item_code) != "Moving Average" +======= + and get_valuation_method(d.item_code, doc.company) != "Moving Average" +>>>>>>> 1492c9fbc3 (fix: use correct variable to fetch valuation method) ): frappe.throw( _("Row # {0}: Rate cannot be greater than the rate used in {1} {2}").format( From 04a6831645f511edb574b140d0cf91dd0aba972d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 30 Jun 2026 20:35:44 +0530 Subject: [PATCH 25/94] chore: resolve conflicts --- erpnext/controllers/sales_and_purchase_return.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index afa4f5c6d00..c58580739e3 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -143,11 +143,7 @@ def validate_returned_items(doc): ref.rate and flt(d.rate) > ref.rate and doc.doctype in ("Delivery Note", "Sales Invoice") -<<<<<<< HEAD - and get_valuation_method(ref.item_code) != "Moving Average" -======= - and get_valuation_method(d.item_code, doc.company) != "Moving Average" ->>>>>>> 1492c9fbc3 (fix: use correct variable to fetch valuation method) + and get_valuation_method(d.item_code) != "Moving Average" ): frappe.throw( _("Row # {0}: Rate cannot be greater than the rate used in {1} {2}").format( From b5735531c15521c411d441e72f8ca08e1e23e344 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Tue, 30 Jun 2026 21:46:03 +0530 Subject: [PATCH 26/94] refactor: port bulk_transaction_log existence check to the query builder (#56667) Co-authored-by: Mihir Kandoi Co-authored-by: Claude Opus 4.8 (1M context) --- .../doctype/bulk_transaction_log/bulk_transaction_log.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py index fbe9d7fcf7d..6ffb23659c8 100644 --- a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py @@ -30,10 +30,7 @@ class BulkTransactionLog(Document): def load_from_db(self): log_detail = qb.DocType("Bulk Transaction Log Detail") - has_records = frappe.db.sql( - "select exists (select * from `tabBulk Transaction Log Detail` where date = %s);", - (self.name,), - )[0][0] + has_records = frappe.db.exists("Bulk Transaction Log Detail", {"date": self.name}) if not has_records: raise frappe.DoesNotExistError From 345587754f8b1b4906fe47884ee3b47a5468b97b Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:43:26 +0000 Subject: [PATCH 27/94] fix: add permission checks in whitelisted functions (backport #53103) (#56668) * fix: add permission checks in whitelisted functions (#53103) (cherry picked from commit a6e78c2eea71df011fbb59ca1cafeda15f96bd78) # Conflicts: # erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py * chore: resolve conflict --------- Co-authored-by: Priyal Rawal <135015851+Priyal208@users.noreply.github.com> Co-authored-by: Diptanil Saha --- .../bank_reconciliation_tool.py | 3 +- .../payment_request/payment_request.py | 1 + .../process_statement_of_accounts.py | 3 ++ .../stock_qty_vs_batch_qty.js | 44 ++++++++++--------- .../stock_qty_vs_batch_qty.py | 1 + 5 files changed, 30 insertions(+), 22 deletions(-) diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index 9ea87ef0ae7..f249cf9c19d 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -57,7 +57,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None): filters.append(["date", "<=", to_date]) if from_date: filters.append(["date", ">=", from_date]) - transactions = frappe.get_all( + transactions = frappe.get_list( "Bank Transaction", fields=[ "date", @@ -82,6 +82,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None): @frappe.whitelist() def get_account_balance(bank_account, till_date, company): # returns account balance till the specified date + frappe.has_permission("Bank Account", "read", bank_account, throw=True) account = frappe.db.get_value("Bank Account", bank_account, "account") filters = frappe._dict( { diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index e341490ed7f..f13569e0d9b 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -834,6 +834,7 @@ def resend_payment_email(docname): @frappe.whitelist() def make_payment_entry(docname): doc = frappe.get_doc("Payment Request", docname) + doc.check_permission("read") return doc.create_payment_entry(submit=False).as_dict() diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index 48349a4bd99..9f0680de3ee 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -439,6 +439,8 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr when Is Billing Contact checked and Primary email- email with Is Primary checked""" + frappe.has_permission("Customer", "read", customer_name, throw=True) + billing_email = frappe.db.sql( """ SELECT @@ -482,6 +484,7 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr @frappe.whitelist() def download_statements(document_name): doc = frappe.get_doc("Process Statement Of Accounts", document_name) + doc.check_permission("read") report = get_report_pdf(doc) if report: frappe.local.response.filename = doc.name + ".pdf" diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js index f80126bcb0a..40ad8843871 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js @@ -28,28 +28,30 @@ frappe.query_reports["Stock Qty vs Batch Qty"] = { }, ], onload: function (report) { - report.page.add_inner_button(__("Update Batch Qty"), function () { - let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows(); - let selected_rows = indexes - .map((i) => frappe.query_report.data[i]) - .filter((row) => row.difference != 0); + if (frappe.model.can_write("Batch")) { + report.page.add_inner_button(__("Update Batch Qty"), function () { + let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows(); + let selected_rows = indexes + .map((i) => frappe.query_report.data[i]) + .filter((row) => row.difference != 0); - if (selected_rows.length) { - frappe.call({ - method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty", - args: { - selected_batches: selected_rows, - }, - callback: function (r) { - if (!r.exc) { - report.refresh(); - } - }, - }); - } else { - frappe.msgprint(__("Please select at least one row with difference value")); - } - }); + if (selected_rows.length) { + frappe.call({ + method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty", + args: { + selected_batches: selected_rows, + }, + callback: function (r) { + if (!r.exc) { + report.refresh(); + } + }, + }); + } else { + frappe.msgprint(__("Please select at least one row with difference value")); + } + }); + } }, formatter: function (value, row, column, data, default_formatter) { diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py index 87c5e1419cc..e9ccde483ae 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py @@ -101,6 +101,7 @@ def get_data(filters=None): @frappe.whitelist() def update_batch_qty(selected_batches=None): + frappe.has_permission("Batch", "write", throw=True, ignore_share_permissions=True) if not selected_batches: return From 17733a564159ddf220fc58ba91ac53e48d6a0841 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Wed, 1 Jul 2026 08:02:45 +0530 Subject: [PATCH 28/94] fix: gross profit calculation with rate adjustment entries (cherry picked from commit b9f330a1589baa03acc60ba08fbda9874e5bc9b5) --- erpnext/accounts/report/gross_profit/gross_profit.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 21f999197c2..8ecfe51d244 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -562,7 +562,12 @@ class GrossProfitGenerator: row.base_amount = packed_item.base_amount # get buying amount - if row.item_code in product_bundles: + if row.is_debit_note: + # Rate adjustment debit notes have no stock movement, so buying amount is zero + if not grouped_by_invoice: + row.qty = 0 + row.buying_amount = 0 + elif row.item_code in product_bundles: row.buying_amount = flt( self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]), self.currency_precision, @@ -925,6 +930,7 @@ class GrossProfitGenerator: SalesInvoice.customer_group, SalesInvoice.customer_name, SalesInvoice.territory, + SalesInvoice.is_debit_note, SalesInvoiceItem.item_code, SalesInvoice.base_net_total.as_("invoice_base_net_total"), SalesInvoiceItem.item_name, @@ -1104,6 +1110,7 @@ class GrossProfitGenerator: "posting_time": row.posting_time, "project": row.project, "update_stock": row.update_stock, + "is_debit_note": row.is_debit_note, "customer": row.customer, "customer_group": row.customer_group, "customer_name": row.customer_name, @@ -1142,6 +1149,7 @@ class GrossProfitGenerator: "description": item.description, "warehouse": item.warehouse or row.warehouse, "update_stock": row.update_stock, + "is_debit_note": row.is_debit_note, "item_group": "", "brand": "", "dn_detail": row.dn_detail, From fa82da509ba3a9572d31a0b68cbd005cc06d9ca2 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Wed, 1 Jul 2026 08:17:42 +0530 Subject: [PATCH 29/94] test(gross_profit): added test cases for rate adjustment entry (cherry picked from commit 17ef5d6034b5dd2191813b3e57201e02fd65b0dd) --- .../report/gross_profit/test_gross_profit.py | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index 9a0a9cc5174..d24d472710d 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -727,6 +727,160 @@ class TestGrossProfit(FrappeTestCase): self.assertEqual(total[7], 1000.0) self.assertEqual(total[8], 100.0) + def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None): + """Create a rate adjustment debit note with no stock movement.""" + dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True) + if item_code: + dn.items[0].item_code = item_code + dn.items[0].item_name = item_code + dn.is_debit_note = 1 + dn.return_against = against_invoice.name + dn.items[0].allow_zero_valuation_rate = 1 + return dn.save().submit() + + def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self): + """ + Rate adjustment debit note (is_debit_note=1) should show buying_amount=0 + since there is no stock movement. Gross profit equals the adjustment amount + and gross profit % equals 100%. + """ + make_stock_entry( + company=self.company, + item_code=self.item, + target=self.warehouse, + qty=1, + basic_rate=100, + ) + + sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True) + sinv.update_stock = 1 + sinv = sinv.save().submit() + + debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20) + + filters = frappe._dict( + company=self.company, + from_date=nowdate(), + to_date=nowdate(), + group_by="Invoice", + ) + + columns, data = execute(filters=filters) + + dn_item_rows = [ + x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0 + ] + self.assertEqual(len(dn_item_rows), 1) + + dn_row = dn_item_rows[0] + self.assertEqual(dn_row.buying_amount, 0.0) + self.assertEqual(dn_row.selling_amount, 20.0) + self.assertEqual(dn_row.gross_profit, 20.0) + self.assertEqual(dn_row["gross_profit_%"], 100.0) + + def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self): + """ + The original invoice's GP should be derived solely from its own selling + amount and COGS — the rate adjustment debit note must not alter it. + """ + make_stock_entry( + company=self.company, + item_code=self.item, + target=self.warehouse, + qty=1, + basic_rate=100, + ) + + sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True) + sinv.update_stock = 1 + sinv = sinv.save().submit() + + self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20) + + filters = frappe._dict( + company=self.company, + from_date=nowdate(), + to_date=nowdate(), + group_by="Invoice", + ) + + columns, data = execute(filters=filters) + + sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0] + self.assertEqual(len(sinv_item_rows), 1) + + sinv_row = sinv_item_rows[0] + self.assertEqual(sinv_row.selling_amount, 200.0) + self.assertEqual(sinv_row.buying_amount, 100.0) + self.assertEqual(sinv_row.gross_profit, 100.0) + self.assertEqual(sinv_row["gross_profit_%"], 50.0) + + def test_debit_note_qty_not_inflated_in_grouped_report(self): + """ + When grouped by Item Code, the debit note (qty=0) must not inflate + the group's qty or buying_amount. The selling amount and average + selling rate correctly reflect the rate adjustment. + """ + item = create_item("_Test Rate Adjustment Debit Note Item") + + make_stock_entry( + company=self.company, + item_code=item.item_code, + target=self.warehouse, + qty=1, + basic_rate=100, + ) + + sinv = create_sales_invoice( + qty=1, + rate=200, + company=self.company, + customer=self.customer, + item_code=item.item_code, + item_name=item.item_code, + cost_center=self.cost_center, + warehouse=self.warehouse, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + update_stock=1, + currency="INR", + income_account=self.income_account, + expense_account=self.expense_account, + ) + + self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code) + + filters = frappe._dict( + company=self.company, + from_date=nowdate(), + to_date=nowdate(), + group_by="Item Code", + ) + + columns, data = execute(filters=filters) + + # group_by="Item Code" column order: + # [item_code, item_name, brand, description, qty, base_rate, + # buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency] + item_row = next((row for row in data if row[0] == item.item_code), None) + self.assertIsNotNone(item_row) + + qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = ( + item_row[4], + item_row[5], + item_row[8], + item_row[7], + item_row[9], + item_row[10], + ) + + self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated + self.assertEqual(buying_amount, 100.0) # only original invoice COGS + self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment) + self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1 + self.assertEqual(gross_profit, 120.0) # 220 - 100 + self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100 + def make_sales_person(sales_person_name="_Test Sales Person"): if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}): From 63e85c655182982d35012e50c4b8a9d4ab5f002c Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 30 Jun 2026 22:22:17 +0530 Subject: [PATCH 30/94] fix(selling): update sales order per billed on credit note submission (cherry picked from commit 224cf19f3331976178e89990fe92e12ad0db633f) --- erpnext/stock/doctype/delivery_note/delivery_note.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 0ad8bc781a5..1f119e9aa50 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -9,6 +9,7 @@ from frappe import _ from frappe.contacts.doctype.address.address import get_company_address from frappe.contacts.doctype.contact.contact import get_default_contact from frappe.desk.notifications import clear_doctype_notifications +from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values from frappe.query_builder import DocType @@ -910,7 +911,9 @@ def get_returned_qty_map(delivery_note): @frappe.whitelist() -def make_sales_invoice(source_name, target_doc=None, args=None): +def make_sales_invoice( + source_name: str, target_doc: Document | str | None = None, args: dict | str | None = None +): if args is None: args = {} if isinstance(args, str): @@ -1015,7 +1018,12 @@ def make_sales_invoice(source_name, target_doc=None, args=None): frappe.db.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") ) - if not doc.is_return: + if doc.is_return: + # A credit note made from a return Delivery Note should roll back the billed + # amount on the linked Sales Order too, so that per_billed stays consistent with + # per_delivered (which the return already reset). + doc.update_billed_amount_in_sales_order = True + else: so, doctype, fieldname = doc.get_order_details() if ( doc.linked_order_has_payment_terms(so, fieldname, doctype) From a6d790f321245574ef9369c246219f2f28f5c11a Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 30 Jun 2026 22:23:43 +0530 Subject: [PATCH 31/94] test(selling): add test to validate the per billed after credit note submission (cherry picked from commit 710e0216382f4c596da2c074c103ec8c4d591f1e) --- .../delivery_note/test_delivery_note.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 90bae7f68f5..e77940b1661 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -2599,6 +2599,92 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(dn.per_returned, 100) self.assertEqual(returned.status, "Return") + def _assert_credit_note_from_return_dn_resets_per_billed(self, so, dn): + """Given a fully billed Sales Order and a submitted Delivery Note that delivers it, + a credit note made from the return of that Delivery Note must reset per_billed to 0 + while leaving the delivery quantities exactly as the return already set them.""" + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + + so.load_from_db() + self.assertEqual(so.per_delivered, 100) + self.assertEqual(so.per_billed, 100) + + return_dn = make_sales_return(dn.name) + return_dn.insert() + return_dn.submit() + + # the return reverses the delivery quantities + so.load_from_db() + self.assertEqual(so.per_delivered, 0) + self.assertEqual(so.items[0].delivered_qty, 0) + + credit_note = make_sales_invoice(return_dn.name) + self.assertTrue(credit_note.is_return) + self.assertTrue(credit_note.update_billed_amount_in_sales_order) + # A Delivery Note-linked invoice can't update stock (validate_delivery_note), so the + # credit note only rolls back billing and never re-reverses the delivery quantities. + self.assertFalse(credit_note.update_stock) + credit_note.insert() + credit_note.submit() + + # per_billed is reset, and the delivery state stays exactly as the return left it + so.load_from_db() + self.assertEqual(so.per_billed, 0) + self.assertEqual(so.per_delivered, 0) + self.assertEqual(so.items[0].delivered_qty, 0) + self.assertEqual(so.items[0].returned_qty, 0) + + # Cancelling the credit note should restore the billed amount on the Sales Order. + credit_note.cancel() + so.load_from_db() + self.assertEqual(so.per_billed, 100) + + def test_sales_order_per_billed_after_credit_note_from_return_dn(self): + # Reported flow: SO -> SI (from SO) -> DN (from SI) -> return DN -> credit note. + # The DN carries si_detail in this path. + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so + + make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100) + + so = make_sales_order(qty=2) + + si = make_si_from_so(so.name) + si.insert() + si.submit() + + dn = make_delivery_note(si.name) + dn.insert() + dn.submit() + + self._assert_credit_note_from_return_dn_resets_per_billed(so, dn) + + def test_sales_order_per_billed_after_credit_note_from_so_derived_dn(self): + # SO billed and delivered separately (SO -> SI, SO -> DN), then return DN -> credit note. + # SO per_billed rolls back via the status_updater in update_prevdoc_status. + from erpnext.selling.doctype.sales_order.sales_order import ( + make_delivery_note as make_dn_from_so, + ) + from erpnext.selling.doctype.sales_order.sales_order import ( + make_sales_invoice as make_si_from_so, + ) + + make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100) + + so = make_sales_order(qty=2) + + si = make_si_from_so(so.name) + si.insert() + si.submit() + + dn = make_dn_from_so(so.name) + dn.insert() + dn.submit() + + self.assertIsNone(dn.items[0].si_detail) + + self._assert_credit_note_from_return_dn_resets_per_billed(so, dn) + def test_sales_return_for_product_bundle(self): from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return From 38b7042481bf6ad654e1ca416a90b58b04952694 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Wed, 3 Jun 2026 13:10:26 +0530 Subject: [PATCH 32/94] fix(stock): set stock received but not billed account for purchase (#55149) (cherry picked from commit c4d28a261271c69853efb51ecd481b9aa94fd961) --- erpnext/stock/get_item_details.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 8c205a10874..e342396d631 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -12,6 +12,7 @@ from frappe.model.utils import get_fetch_values from frappe.query_builder.functions import IfNull, Sum from frappe.utils import add_days, add_months, cint, cstr, flt, get_link_to_form, getdate, parse_json +import erpnext from erpnext import get_company_currency from erpnext.accounts.doctype.pricing_rule.pricing_rule import ( get_pricing_rule_for_item, @@ -518,10 +519,21 @@ def get_basic_details(args, item, overwrite_warehouse=True): args.name, args.conversion_rate, item.name, out.conversion_factor ) + expense_account_field = "default_expense_account" + if ( + item.is_stock_item + and erpnext.is_perpetual_inventory_enabled(args.company) + and ( + args.doctype == "Purchase Receipt" + or (args.doctype == "Purchase Invoice" and args.get("update_stock")) + ) + ): + expense_account_field = "stock_received_but_not_billed" + # if default specified in item is for another company, fetch from company for d in [ ["Account", "income_account", "default_income_account"], - ["Account", "expense_account", "default_expense_account"], + ["Account", "expense_account", expense_account_field], ["Cost Center", "cost_center", "cost_center"], ["Warehouse", "warehouse", ""], ]: From f13946685136736fc154263ad44a551669861a6e Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 2 Jul 2026 11:54:27 +0530 Subject: [PATCH 33/94] refactor: update title for process statement of accounts (cherry picked from commit 04468c3c33b37201810e742ee1ae8cdec2a24c74) --- .../process_statement_of_accounts.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html index cd1e357e3bc..c60de4c29c7 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html @@ -13,7 +13,7 @@ {% endif %} -

{{ _("GENERAL LEDGER") }}

+

{{ _("STATEMENT OF ACCOUNTS") }}

{% if filters.party[0] == filters.party_name[0] %}
{{ _("Customer: ") }} {{ filters.party_name[0] }}
From 71a2d6e43dbd957f41e242a021831bb9e0bff26f Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:27:20 +0530 Subject: [PATCH 34/94] fix: validate reverse GL entries on current date under immutable ledger (backport #56709) (#56751) * fix: validate reverse GL entries on current date under immutable ledger (#56709) * fix: validate reverse GL entries on current date under immutable ledger When Immutable Ledger is enabled, the reverse GL entry is posted on the current date, but the closed-period checks in make_reverse_gl_entries still validate against the original (backdated) posting date. This blocks cancelling a backdated voucher, such as a suspense Journal Entry for a migrated NPA loan, with a books-closed error even though the reverse entry lands in an open period. Validate both check_freezing_date and validate_against_pcv against the current date when Immutable Ledger is enabled. When it is disabled, behaviour is unchanged. Follow-up to #55268. * test: reset frozen till date after reverse entry test The freeze date set on the company was not reset, so it leaked into the next test which posts entries in that period. Reset it in a finally block. * fix: prefer explicit posting_date under immutable ledger Prefer the posting_date argument before frappe.form_dict and getdate, at both the validation and the GL entry site, so an explicit date passed by the caller is honoured and validation still matches the posted date. (cherry picked from commit cab1b129c064bdbef8e01763f0f1201495d0d233) # Conflicts: # erpnext/accounts/general_ledger.py * fix: resolved conflicts Removed outdated check for freezing date in general ledger validation. * fix: Update check_freezing_date function call parameters * fix: accounts_frozen_till_date to acc_frozen_upto * test: acc_frozen_upto setting in test_period_closing_voucher --------- Co-authored-by: Nihantra C. Patel <141945075+Nihantra-Patel@users.noreply.github.com> --- .../test_period_closing_voucher.py | 15 +++++++++------ erpnext/accounts/general_ledger.py | 12 +++++++----- 2 files changed, 16 insertions(+), 11 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 e4e31a9adf4..e9bad6d7494 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 @@ -379,12 +379,15 @@ class TestPeriodClosingVoucher(unittest.TestCase): self.make_period_closing_voucher(posting_date="2021-03-31") - # 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", - ) + frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", "2021-12-31") + + try: + make_reverse_gl_entries( + voucher_type="Journal Entry", + voucher_no=jv.name, + ) + finally: + frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None) totals_after_cancel = frappe.db.sql( """ diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 599173c99f5..38242a57eae 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -697,13 +697,15 @@ def make_reverse_gl_entries( partial_cancel=partial_cancel, ) validate_accounting_period(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) - # 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"] + if immutable_ledger_enabled: + validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate() + else: + validation_date = posting_date if posting_date else gl_entries[0]["posting_date"] + + check_freezing_date(validation_date, adv_adj) validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"]) if partial_cancel: @@ -770,7 +772,7 @@ def make_reverse_gl_entries( if immutable_ledger_enabled: new_gle["is_cancelled"] = 0 - new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate() + new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate() elif posting_date: new_gle["posting_date"] = posting_date From a9330e8900c6384558bc2ff833ed8c5300eb525a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 2 Jul 2026 12:25:52 +0530 Subject: [PATCH 35/94] fix: don't treat batch slot at FIFO queue head as qty slot An incoming SLE without resolvable serial/batch details hit the negative-head branch in _compute_incoming_stock even when the head was a batch slot, because flt() on the batch number string returns 0.0. _add_to_negative_fifo_head then crashed with "TypeError: can only concatenate str (not 'float') to str". Guard the branch with is_qty_slot, mirroring the existing check in _add_transfer_slot_to_fifo_queue. Co-Authored-By: Claude Fable 5 (cherry picked from commit c47a95a4d259863baa911f6161b5128df95e42d4) --- .../stock/report/stock_ageing/stock_ageing.py | 2 +- .../report/stock_ageing/test_stock_ageing.py | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 2e11fa1664b..b9ec3e7204f 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -492,7 +492,7 @@ class FIFOSlots: 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: + elif fifo_queue and is_qty_slot(fifo_queue[0]) and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0: self._add_to_negative_fifo_head(row, fifo_queue) else: fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)]) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 003d1a51d93..b21a89572c4 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -1438,6 +1438,47 @@ class TestStockAgeing(FrappeTestCase): 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_untagged_receipt_with_negative_batch_head(self): + """An incoming SLE without batch details must not treat a negative + batch slot at the queue head as a qty slot (TypeError: str += float).""" + sle = [ + frappe._dict( + name="Enclosure Item", + 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="QI-06448", + ), + frappe._dict( + name="Enclosure Item", + actual_qty=45, + qty_after_transaction=35, + stock_value_difference=1051.65, + warehouse="WH 1", + posting_date="2021-12-05", + voucher_type="Purchase Receipt", + voucher_no="002", + has_serial_no=False, + serial_no=None, + batch_no=None, + serial_and_batch_bundle="SABB-00001294", + ), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots["Enclosure Item"]["fifo_queue"] + + self.assertEqual(slots["Enclosure Item"]["total_qty"], 35.0) + self.assertEqual(queue[0][:3], ["QI-06448", None, -10.0]) + self.assertEqual(queue[1], [45.0, "2021-12-05", 1051.65]) + def test_batchwise_valuation_stock_reconciliation_with_bundle(self): from frappe.utils import add_days, getdate, nowdate From 4a6587b48eea1dcb55f323dd2d4606c5bce9c875 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 2 Jul 2026 12:31:24 +0530 Subject: [PATCH 36/94] test: assert full negative batch slot in ageing regression test Co-Authored-By: Claude Fable 5 (cherry picked from commit 8928b42d5d7952053aa191cfe9321015b8a102a1) --- erpnext/stock/report/stock_ageing/test_stock_ageing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index b21a89572c4..54f9ef9241e 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -1476,7 +1476,7 @@ class TestStockAgeing(FrappeTestCase): queue = slots["Enclosure Item"]["fifo_queue"] self.assertEqual(slots["Enclosure Item"]["total_qty"], 35.0) - self.assertEqual(queue[0][:3], ["QI-06448", None, -10.0]) + self.assertEqual(queue[0], ["QI-06448", None, -10.0, "2021-12-01", -100.0]) self.assertEqual(queue[1], [45.0, "2021-12-05", 1051.65]) def test_batchwise_valuation_stock_reconciliation_with_bundle(self): From 5f14f2ccd6b57c2c3d682ac3fe6efee75893a757 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 2 Jul 2026 12:54:27 +0530 Subject: [PATCH 37/94] fix(accounts): clear clearance date when amending reconciled voucher (backport #55947) When a reconciled voucher (Payment Entry / Journal Entry / Purchase Invoice / Sales Invoice) is cancelled, amended and resubmitted, the stale clearance date was carried into the amended draft because the framework ignores `no_copy` while amending. Add a shared `before_insert` hook on AccountsController that clears `clearance_date` (top-level field and Sales Invoice `payments` rows) on amendment. Reconciliation still sets the value post-submit via `frappe.db.set_value`, which bypasses this path. Manual backport of #55947 to version-15-hotfix (Mergify's auto-backport #55972 committed conflict markers; this replaces it). --- .../bank_transaction/test_bank_transaction.py | 30 +++++++++++++++++++ erpnext/controllers/accounts_controller.py | 20 +++++++++++++ 2 files changed, 50 insertions(+) diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py index 4294c4462b1..05a9c055078 100644 --- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py @@ -115,6 +115,36 @@ class TestBankTransaction(FrappeTestCase): self.assertEqual(bank_transaction.unallocated_amount, 1700) self.assertEqual(bank_transaction.payment_entries, []) + # Amending a reconciled payment entry must not carry over its clearance date + def test_clearance_date_cleared_on_amend(self): + bank_transaction = frappe.get_doc( + "Bank Transaction", + dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"), + ) + payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700)) + vouchers = json.dumps( + [ + { + "payment_doctype": "Payment Entry", + "payment_name": payment.name, + "amount": bank_transaction.unallocated_amount, + } + ] + ) + reconcile_vouchers(bank_transaction.name, vouchers) + + self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date")) + + payment.reload() + payment.cancel() + + amended = frappe.copy_doc(payment) + amended.amended_from = payment.name + amended.docstatus = 0 + amended.insert() + + self.assertFalse(amended.clearance_date) + # Check if ERPNext can correctly filter a linked payments based on the debit/credit amount def test_debit_credit_output(self): bank_transaction = frappe.get_doc( diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 0d3f13dde8c..365e481890f 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -141,6 +141,26 @@ class AccountsController(TransactionBase): if self.doctype in relevant_docs: self.set_payment_schedule() + def before_insert(self): + self.clear_clearance_date_on_amend() + + def clear_clearance_date_on_amend(self): + """Drop the bank reconciliation clearance date copied over while amending. + + The framework copies `no_copy` fields when amending, so a reconciled + voucher would carry a stale clearance date into its amendment even though + the linked bank transaction gets unreconciled on cancellation. + """ + if not self.get("amended_from"): + return + + if self.meta.has_field("clearance_date"): + self.clearance_date = None + + for payment in self.get("payments") or []: + if payment.meta.has_field("clearance_date"): + payment.clearance_date = None + def remove_bundle_for_non_stock_invoices(self): has_sabb = False if self.doctype in ("Sales Invoice", "Purchase Invoice") and not self.update_stock: From ac2e69fbfd628abcb8f47bd14c5fb9b1771abcc5 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 8 May 2026 12:27:32 +0530 Subject: [PATCH 38/94] fix: disallow editing on reversal journals (cherry picked from commit 26ca7445eb8c122351833105f7d4dcf57cb245c2) --- erpnext/accounts/doctype/journal_entry/journal_entry.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index ae3ee00e535..232c33d4def 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -40,6 +40,10 @@ frappe.ui.form.on("Journal Entry", { }, refresh: function (frm) { + if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) { + frm.set_read_only(); + } + erpnext.toggle_naming_series(); if (frm.doc.docstatus > 0) { From 44a7013ab45c4567da37c8126f1a050e877e88f7 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:26:04 +0000 Subject: [PATCH 39/94] fix: block serialized to non-serialized item change when SABB exists (backport #56773) (#56774) * fix: block serialized to non-serialized item change when SABB exists (#56773) (cherry picked from commit 0e8ae7548d12dbee6b50a02730d962f30b89765e) # Conflicts: # erpnext/stock/doctype/item/item.py # erpnext/stock/doctype/item/test_item.py * chore: fix conflicts * chore: fix conflicts Remove test for variant UOM mismatch and related logic. --------- Co-authored-by: rohitwaghchaure --- erpnext/stock/doctype/item/item.py | 20 ++++++++++++ erpnext/stock/doctype/item/test_item.py | 41 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 05e8a2f3779..7f077cfd4dd 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -217,6 +217,7 @@ class Item(Document): self.validate_item_defaults() self.validate_auto_reorder_enabled_in_stock_settings() self.cant_change() + self.validate_serialized_change_with_bundle() self.validate_item_tax_net_rate_range() if not self.is_new(): @@ -1074,6 +1075,25 @@ class Item(Document): frappe.throw(msg, title=_("Linked with submitted documents")) + def validate_serialized_change_with_bundle(self): + """Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists + for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first.""" + if self.is_new() or self.has_serial_no or not self._doc_before_save: + return + + # Only relevant when the item was serialized before and is now being unset. + if not self._doc_before_save.has_serial_no: + return + + # Draft (docstatus 0) or submitted (docstatus 1) bundles block the change; cancelled ones don't. + if frappe.db.count("Serial and Batch Bundle", {"item_code": self.name, "docstatus": ("<", 2)}): + frappe.throw( + _( + "Cannot change Item {0} from serialized to non-serialized because a Serial and Batch Bundle exists for it. Please delete or cancel the Serial and Batch Bundle first." + ).format(frappe.bold(self.name)), + title=_("Serial and Batch Bundle Exists"), + ) + def _get_linked_submitted_documents(self, changed_fields: list[str]) -> dict[str, str] | None: linked_doctypes = [ "Delivery Note Item", diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 8072437a173..a2319ca9488 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -966,6 +966,47 @@ class TestItem(FrappeTestCase): self.assertRaises(frappe.ValidationError, item_doc.save) + def test_cannot_unset_serialized_while_bundle_exists(self): + from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + make_serial_batch_bundle, + ) + + item = make_item( + properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TSN-UNSET-.####"} + ).name + + serial_no = f"{item}-SN-01" + frappe.get_doc( + {"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"} + ).insert() + + # A draft (unsubmitted) Serial and Batch Bundle for the item must block the change. + bundle = make_serial_batch_bundle( + { + "item_code": item, + "warehouse": "_Test Warehouse - _TC", + "company": "_Test Company", + "qty": 1, + "rate": 100, + "voucher_type": "Stock Entry", + "serial_nos": [serial_no], + "type_of_transaction": "Inward", + "do_not_submit": True, + "ignore_sabb_validation": True, + } + ) + + doc = frappe.get_doc("Item", item) + doc.has_serial_no = 0 + self.assertRaises(frappe.ValidationError, doc.save) + + # Once the bundle is removed, the item can be made non-serialized. + frappe.delete_doc("Serial and Batch Bundle", bundle.name, force=True) + doc = frappe.get_doc("Item", item) + doc.has_serial_no = 0 + doc.save() + self.assertEqual(frappe.db.get_value("Item", item, "has_serial_no"), 0) + def set_item_variant_settings(fields): doc = frappe.get_doc("Item Variant Settings") From b7e0331d56bc6aaa7bbc4df9aa73137ce5c9a7b5 Mon Sep 17 00:00:00 2001 From: jatin3128 Date: Thu, 2 Jul 2026 16:38:02 +0530 Subject: [PATCH 40/94] fix: fetch asset category expense account on purchase receipt Backport of #41024. Fixed-asset items now resolve expense_account from the asset category (CWIP account when CWIP accounting is enabled, otherwise the fixed asset account) for Purchase Receipt, Purchase Order and Material Request, matching v16 behavior. v15 previously did this only for Purchase Invoice. --- erpnext/stock/get_item_details.py | 24 +++++++--- erpnext/stock/tests/test_get_item_details.py | 46 ++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 312b8e129f8..35fd63ddcd2 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -410,12 +410,26 @@ def get_basic_details(args, item, overwrite_warehouse=True): expense_account = None - if args.get("doctype") == "Purchase Invoice" and item.is_fixed_asset: - from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account + if item.is_fixed_asset: + from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled - expense_account = get_asset_category_account( - fieldname="fixed_asset_account", item=args.item_code, company=args.company - ) + if is_cwip_accounting_enabled(item.asset_category): + expense_account = get_asset_account( + "capital_work_in_progress_account", + asset_category=item.asset_category, + company=args.company, + ) + elif args.get("doctype") in ( + "Purchase Invoice", + "Purchase Receipt", + "Purchase Order", + "Material Request", + ): + from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account + + expense_account = get_asset_category_account( + fieldname="fixed_asset_account", item=args.item_code, company=args.company + ) # Set the UOM to the Default Sales UOM or Default Purchase UOM if configured in the Item Master if not args.get("uom"): diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index fc19bac0a44..af98aa43980 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -35,6 +35,52 @@ class TestGetItemDetail(FrappeTestCase): details = get_item_details(args) self.assertEqual(details.get("price_list_rate"), 100) + def test_fetch_asset_category_expense_account_on_purchase_receipt(self): + from erpnext.stock.doctype.item.test_item import make_item + + asset_category = "Test Expense Account Asset Category" + if not frappe.db.exists("Asset Category", asset_category): + frappe.get_doc( + { + "doctype": "Asset Category", + "asset_category_name": asset_category, + "enable_cwip_accounting": 0, + "depreciation_method": "Straight Line", + "total_number_of_depreciations": 12, + "frequency_of_depreciation": 1, + "accounts": [ + { + "company_name": "_Test Company", + "fixed_asset_account": "_Test Fixed Asset - _TC", + "accumulated_depreciation_account": "_Test Accumulated Depreciations - _TC", + "depreciation_expense_account": "_Test Depreciations - _TC", + } + ], + } + ).insert() + + asset_item = make_item( + "Test Expense Account Asset Item", + {"is_stock_item": 0, "is_fixed_asset": 1, "asset_category": asset_category}, + ).item_code + + args = frappe._dict( + { + "item_code": asset_item, + "company": "_Test Company", + "conversion_rate": 1.0, + "price_list_currency": "USD", + "plc_conversion_rate": 1.0, + "doctype": "Purchase Receipt", + "supplier": "_Test Supplier", + "price_list": "_Test Buying Price List", + "ignore_pricing_rule": 1, + "qty": 1, + } + ) + details = get_item_details(args) + self.assertEqual(details.get("expense_account"), "_Test Fixed Asset - _TC") + # making this test in get_item_details test file as feat/fix is present in that method def test_fetch_price_from_list_rate_on_doc_save(self): # create item From 5666fd13343ad2bc4682b9938e8f5957487d94e8 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:02:09 +0000 Subject: [PATCH 41/94] fix(company): ignore user permissions for link fields having link to `Account` and `Cost Center` (backport #56748) (#56794) Co-authored-by: Diptanil Saha --- erpnext/setup/doctype/company/company.json | 45 +++++++++++++++++++++- erpnext/setup/doctype/company/company.py | 1 + 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json index fc6533a1e89..380320f0399 100644 --- a/erpnext/setup/doctype/company/company.json +++ b/erpnext/setup/doctype/company/company.json @@ -330,33 +330,48 @@ "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "round_off_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Round Off Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "round_off_cost_center", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Round Off Cost Center", + "no_copy": 1, "options": "Cost Center" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "write_off_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Write Off Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "exchange_gain_loss_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Exchange Gain / Loss Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "unrealized_exchange_gain_loss_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Unrealized Exchange Gain/Loss Account", + "no_copy": 1, "options": "Account" }, { @@ -482,6 +497,7 @@ "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "expenses_included_in_valuation", "fieldtype": "Link", "ignore_user_permissions": 1, @@ -490,15 +506,19 @@ "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "accumulated_depreciation_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Accumulated Depreciation Account", "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "depreciation_expense_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Depreciation Expense Account", "no_copy": 1, "options": "Account" @@ -519,29 +539,39 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "disposal_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Gain/Loss Account on Asset Disposal", "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "depreciation_cost_center", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Asset Depreciation Cost Center", "no_copy": 1, "options": "Cost Center" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "capital_work_in_progress_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Capital Work In Progress Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "asset_received_but_not_billed", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Asset Received But Not Billed", + "no_copy": 1, "options": "Account" }, { @@ -673,15 +703,21 @@ "options": "Warehouse" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "unrealized_profit_loss_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Unrealized Profit / Loss Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "default_discount_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Payment Discount Account", + "no_copy": 1, "options": "Account" }, { @@ -723,8 +759,10 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account", "fieldname": "default_advance_received_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Advance Received Account", "mandatory_depends_on": "book_advance_payments_as_liability", + "no_copy": 1, "options": "Account" }, { @@ -733,8 +771,10 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account", "fieldname": "default_advance_paid_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Advance Paid Account", "mandatory_depends_on": "book_advance_payments_as_liability", + "no_copy": 1, "options": "Account" }, { @@ -814,9 +854,12 @@ "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "round_off_for_opening", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Round Off for Opening", + "no_copy": 1, "options": "Account" }, { @@ -865,7 +908,7 @@ "image_field": "company_logo", "is_tree": 1, "links": [], - "modified": "2025-11-16 16:51:27.624096", + "modified": "2026-07-02 07:21:21.794533", "modified_by": "Administrator", "module": "Setup", "name": "Company", diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 299ae82cb69..c53dfa5fb40 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -74,6 +74,7 @@ class Company(NestedSet): default_operating_cost_account: DF.Link | None default_payable_account: DF.Link | None default_provisional_account: DF.Link | None + default_purchase_price_variance_account: DF.Link | None default_receivable_account: DF.Link | None default_sales_contact: DF.Link | None default_selling_terms: DF.Link | None From b0446b6a096d912baf37043e53500a8786eace75 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:35:17 +0530 Subject: [PATCH 42/94] fix: don't attempt to create SABB for non-serialized / non-batch items (backport #56627) (#56802) * fix: don't attempt to create SABB for non-serialized / non-batch items (#56627) * fix: don't attempt to create SABB for non-serialized / non-batch items * fix(stock): skip serial batch lookup for rows without item code (cherry picked from commit 5b738b7b0d21289569c92f0f3a85c7a57f3a2981) # Conflicts: # erpnext/stock/services/serial_batch_bundle_service.py * chore: resolve conflicts --------- Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- erpnext/controllers/stock_controller.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index f58c831922a..c48eb2bd620 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -263,6 +263,10 @@ class StockController(AccountsController): parent_details = self.get_parent_details_for_packed_items() for row in self.get(table_name): + item_code = row.get("rm_item_code") or row.get("item_code") + if not item_code or not self.is_serial_batch_item(item_code): + continue + if ( not via_landed_cost_voucher and row.serial_and_batch_bundle From 1cf94f55017eda065b745735a25792486b415aae Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:37:11 +0530 Subject: [PATCH 43/94] fix: replay immutable SLE qty for serial/batch bundle valuation (backport #56814) (#56834) fix: replay immutable SLE qty for serial/batch bundle valuation (#56814) (cherry picked from commit ecc8ec672bab513619ae35cdb7dde49b264cc722) Co-authored-by: rohitwaghchaure --- erpnext/stock/stock_ledger.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 93ee2eaa651..53d2153c44d 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1080,7 +1080,11 @@ class update_entries_after: self.wh_data.stock_queue = json.loads(stock_queue[0]) if stock_queue else [] self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + doc.total_amount) - self.wh_data.qty_after_transaction += flt(doc.total_qty, self.flt_precision) + # Replay the immutable qty recorded on the SLE at submission, not the bundle's recomputed + # total_qty. A valuation repost must never rewrite physical quantities; if the bundle's child + # rows were edited after submission, doc.total_qty would silently corrupt qty_after_transaction + # (and every downstream balance). sle.actual_qty is the frozen movement for this entry. + self.wh_data.qty_after_transaction += flt(sle.actual_qty, self.flt_precision) if flt(self.wh_data.qty_after_transaction, self.flt_precision): self.wh_data.valuation_rate = flt(self.wh_data.stock_value, self.flt_precision) / flt( self.wh_data.qty_after_transaction, self.flt_precision From 19fc8b8e6ffacdb9acb13fdd5369122192e46610 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 3 Jul 2026 11:29:44 +0530 Subject: [PATCH 44/94] fix: FIFO queue checks and incorrect entries filter in stock ledger reports - 'Show Incorrect Entries' always returned an empty result (regression from #43619); now returns entries from one row before the first incorrect one - FIFO queue columns were computed for serialized/batched SLEs that don't maintain a stock queue, showing false differences; left empty for such rows - compare value/valuation differences at currency precision, qty at float precision (cherry picked from commit 94ab09e4a3c6af85e086c6393518a432f11a86c7) # Conflicts: # erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py --- .../stock_ledger_invariant_check.py | 90 ++++++++++--------- .../test_stock_ledger_invariant_check.py | 76 ++++++++++++++++ .../stock_ledger_variance.py | 32 ++++--- 3 files changed, 147 insertions(+), 51 deletions(-) create mode 100644 erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index 954acf998d8..421529c90e6 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -20,6 +20,7 @@ SLE_FIELDS = ( "outgoing_rate", "stock_queue", "batch_no", + "serial_no", "stock_value", "stock_value_difference", "valuation_rate", @@ -52,16 +53,16 @@ def add_invariant_check_fields(sles, filters): balance_qty = 0.0 balance_stock_value = 0.0 - incorrect_idx = 0 - precision = frappe.get_precision("Stock Ledger Entry", "actual_qty") + incorrect_idx = None + float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3 + currency_precision = ( + cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision + ) for idx, sle in enumerate(sles): - queue = json.loads(sle.stock_queue) if sle.stock_queue else [] - - fifo_qty = 0.0 - fifo_value = 0.0 - for qty, rate in queue: - fifo_qty += qty - fifo_value += qty * rate + if sle.batch_no: + sle.use_batchwise_valuation = frappe.db.get_value( + "Batch", sle.batch_no, "use_batchwise_valuation", cache=True + ) if sle.actual_qty < 0: sle.consumption_rate = sle.stock_value_difference / sle.actual_qty @@ -77,57 +78,66 @@ def add_invariant_check_fields(sles, filters): if balance_qty is None: balance_qty = sle.qty_after_transaction - sle.fifo_queue_qty = fifo_qty - sle.fifo_stock_value = fifo_value - sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None sle.balance_value_by_qty = ( sle.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None ) sle.expected_qty_after_transaction = balance_qty sle.stock_value_from_diff = balance_stock_value - # set difference fields sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction - sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty - sle.fifo_value_diff = sle.stock_value - fifo_value - sle.fifo_valuation_diff = ( - sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None - ) sle.valuation_diff = ( sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None ) sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value - if not incorrect_idx and filters.get("show_incorrect_entries"): - if is_sle_has_correct_data(sle, precision): - continue - else: - incorrect_idx = idx + if maintains_fifo_queue(sle): + add_fifo_fields(sle, sles[idx - 1] if idx else None) - if idx > 0: - sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value - sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference - - if sle.batch_no: - sle.use_batchwise_valuation = frappe.db.get_value( - "Batch", sle.batch_no, "use_batchwise_valuation", cache=True - ) + if incorrect_idx is None and not is_sle_has_correct_data(sle, float_precision, currency_precision): + incorrect_idx = idx if filters.get("show_incorrect_entries"): - if incorrect_idx > 0: - sles = sles[cint(incorrect_idx) - 1 :] - - return [] + if incorrect_idx is None: + return [] + return sles[max(incorrect_idx - 1, 0) :] return sles -def is_sle_has_correct_data(sle, precision): - if flt(sle.difference_in_qty, precision) != 0.0 or flt(sle.diff_value_diff, precision) != 0: - print(flt(sle.difference_in_qty, precision), flt(sle.diff_value_diff, precision)) - return False +def maintains_fifo_queue(sle): + # no queue is maintained for serialized/batchwise-valued stock + return not ( + sle.serial_and_batch_bundle or sle.serial_no or (sle.batch_no and sle.use_batchwise_valuation) + ) - return True + +def add_fifo_fields(sle, prev_sle): + queue = json.loads(sle.stock_queue) if sle.stock_queue else [] + + fifo_qty = 0.0 + fifo_value = 0.0 + for qty, rate in queue: + fifo_qty += qty + fifo_value += qty * rate + + sle.fifo_queue_qty = fifo_qty + sle.fifo_stock_value = fifo_value + sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None + sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty + sle.fifo_value_diff = sle.stock_value - fifo_value + sle.fifo_valuation_diff = ( + sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None + ) + if prev_sle and prev_sle.fifo_stock_value is not None: + sle.fifo_stock_diff = sle.fifo_stock_value - prev_sle.fifo_stock_value + sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference + + +def is_sle_has_correct_data(sle, float_precision, currency_precision): + return ( + flt(sle.difference_in_qty, float_precision) == 0.0 + and flt(sle.diff_value_diff, currency_precision) == 0.0 + ) def get_columns(): diff --git a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py new file mode 100644 index 00000000000..0f71a8834b2 --- /dev/null +++ b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import execute +from erpnext.tests.utils import ERPNextTestSuite + +WAREHOUSE = "Stores - _TC" +COMPANY = "_Test Company" +ITEM = "_Test Item" + + +class TestStockLedgerInvariantCheck(ERPNextTestSuite): + def run_report(self, **extra): + filters = frappe._dict({"company": COMPANY, "warehouse": WAREHOUSE}) + filters.update(extra) + return execute(filters)[1] + + def make_movements(self) -> str: + frappe.db.set_value("Item", ITEM, "valuation_method", "FIFO") + make_stock_entry(item_code=ITEM, to_warehouse=WAREHOUSE, qty=10, rate=100, posting_date="2026-06-01") + make_stock_entry(item_code=ITEM, to_warehouse=WAREHOUSE, qty=5, rate=120, posting_date="2026-06-02") + make_stock_entry(item_code=ITEM, from_warehouse=WAREHOUSE, qty=4, rate=0, posting_date="2026-06-03") + return ITEM + + def test_diagnostic_rows_have_no_discrepancy(self): + item = self.make_movements() + + data = self.run_report(item_code=item) + + self.assertEqual(len(data), 3) + for row in data: + self.assertLess(abs(row.difference_in_qty), 0.01) + self.assertLess(abs(row.fifo_qty_diff), 0.01) + self.assertLess(abs(row.diff_value_diff), 0.01) + + def test_running_balance_matches(self): + item = self.make_movements() + + data = self.run_report(item_code=item) + + self.assertEqual(data[-1].qty_after_transaction, 11) + + def test_show_incorrect_entries(self): + item = self.make_movements() + + self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), []) + + sle = frappe.get_last_doc( + "Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0} + ) + frappe.db.set_value( + "Stock Ledger Entry", sle.name, "qty_after_transaction", sle.qty_after_transaction + 5 + ) + + data = self.run_report(item_code=item, show_incorrect_entries=1) + self.assertEqual(len(data), 2) # incorrect entry + one before it for context + self.assertEqual(data[-1].name, sle.name) + + def test_batch_item_skips_fifo_queue_checks(self): + from erpnext.stock.doctype.item.test_item import make_item + + item = make_item( + properties={"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "SLIC-BAT-.####"} + ).name + make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100) + + data = self.run_report(item_code=item) + self.assertTrue(data) + for row in data: + self.assertIsNone(row.fifo_qty_diff) + self.assertIsNone(row.fifo_value_diff) + + self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), []) diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py index 327f158e3f6..f02f630d716 100644 --- a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py +++ b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py @@ -205,7 +205,10 @@ def get_data(filters=None): data = [] if item_warehouse_map: - precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) + float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3 + currency_precision = ( + cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision + ) for item_warehouse in item_warehouse_map: report_data = stock_ledger_invariant_check(item_warehouse) @@ -215,7 +218,11 @@ def get_data(filters=None): for row in report_data: if has_difference( - row, precision, filters.difference_in, item_warehouse.valuation_method or valuation_method + row, + float_precision, + currency_precision, + filters.difference_in, + item_warehouse.valuation_method or valuation_method, ): row.update( { @@ -261,23 +268,26 @@ def get_item_warehouse_combinations(filters: dict | None = None) -> dict: return query.run(as_dict=1) -def has_difference(row, precision, difference_in, valuation_method): +def has_difference(row, float_precision, currency_precision, difference_in, valuation_method): if valuation_method == "Moving Average": - qty_diff = flt(row.difference_in_qty, precision) - value_diff = flt(row.diff_value_diff, precision) - valuation_diff = flt(row.valuation_diff, precision) + qty_diff = flt(row.difference_in_qty, float_precision) + value_diff = flt(row.diff_value_diff, currency_precision) + valuation_diff = flt(row.valuation_diff, currency_precision) else: - qty_diff = flt(row.difference_in_qty, precision) - value_diff = flt(row.diff_value_diff, precision) + qty_diff = flt(row.difference_in_qty, float_precision) + value_diff = flt(row.diff_value_diff, currency_precision) if row.stock_queue and json.loads(row.stock_queue): value_diff = value_diff or ( - flt(row.fifo_value_diff, precision) or flt(row.fifo_difference_diff, precision) + flt(row.fifo_value_diff, currency_precision) + or flt(row.fifo_difference_diff, currency_precision) ) - qty_diff = qty_diff or flt(row.fifo_qty_diff, precision) + qty_diff = qty_diff or flt(row.fifo_qty_diff, float_precision) - valuation_diff = flt(row.valuation_diff, precision) or flt(row.fifo_valuation_diff, precision) + valuation_diff = flt(row.valuation_diff, currency_precision) or flt( + row.fifo_valuation_diff, currency_precision + ) if difference_in == "Qty" and qty_diff: return True From 3c47adb0215bb027540b081f5a6bbebfe8553a2e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 3 Jul 2026 11:39:51 +0530 Subject: [PATCH 45/94] fix: address review comments - restore mutated SLE after test via addCleanup - explicit return False in has_difference - comment the fifo_stock_diff guard for non-queue predecessors (cherry picked from commit ef5f47fafdc7e81122f0e335be7d2faff8a730a9) --- .../stock_ledger_invariant_check.py | 1 + .../test_stock_ledger_invariant_check.py | 7 +++++++ .../report/stock_ledger_variance/stock_ledger_variance.py | 2 ++ 3 files changed, 10 insertions(+) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index 421529c90e6..aef9fec6414 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -128,6 +128,7 @@ def add_fifo_fields(sle, prev_sle): sle.fifo_valuation_diff = ( sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None ) + # prev row may not maintain a queue; H and H - F stay blank across the gap if prev_sle and prev_sle.fifo_stock_value is not None: sle.fifo_stock_diff = sle.fifo_stock_value - prev_sle.fifo_stock_value sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference diff --git a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py index 0f71a8834b2..49504b31207 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py @@ -51,6 +51,13 @@ class TestStockLedgerInvariantCheck(ERPNextTestSuite): sle = frappe.get_last_doc( "Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0} ) + self.addCleanup( + frappe.db.set_value, + "Stock Ledger Entry", + sle.name, + "qty_after_transaction", + sle.qty_after_transaction, + ) frappe.db.set_value( "Stock Ledger Entry", sle.name, "qty_after_transaction", sle.qty_after_transaction + 5 ) diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py index f02f630d716..04b888d85fe 100644 --- a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py +++ b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py @@ -297,3 +297,5 @@ def has_difference(row, float_precision, currency_precision, difference_in, valu return True elif difference_in not in ["Qty", "Value", "Valuation"] and (qty_diff or value_diff or valuation_diff): return True + + return False From f3dd90bb0afe3ef695149a497278e1c5528aaf92 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 3 Jul 2026 12:12:46 +0530 Subject: [PATCH 46/94] test: drop redundant cleanup, db rolls back after each test (cherry picked from commit 3b1e57966e14160ab620d3b60d99ad25e95db5b9) --- .../test_stock_ledger_invariant_check.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py index 49504b31207..0f71a8834b2 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py @@ -51,13 +51,6 @@ class TestStockLedgerInvariantCheck(ERPNextTestSuite): sle = frappe.get_last_doc( "Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0} ) - self.addCleanup( - frappe.db.set_value, - "Stock Ledger Entry", - sle.name, - "qty_after_transaction", - sle.qty_after_transaction, - ) frappe.db.set_value( "Stock Ledger Entry", sle.name, "qty_after_transaction", sle.qty_after_transaction + 5 ) From db4136b258a5a45a19eebc786a65427eeb0fcafa Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 3 Jul 2026 13:54:07 +0530 Subject: [PATCH 47/94] fix: use correct import --- .../test_stock_ledger_invariant_check.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py index 0f71a8834b2..ca5ba6acec1 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py @@ -5,14 +5,14 @@ import frappe from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import execute -from erpnext.tests.utils import ERPNextTestSuite +from erpnext.tests.utils import FrappeTestCase WAREHOUSE = "Stores - _TC" COMPANY = "_Test Company" ITEM = "_Test Item" -class TestStockLedgerInvariantCheck(ERPNextTestSuite): +class TestStockLedgerInvariantCheck(FrappeTestCase): def run_report(self, **extra): filters = frappe._dict({"company": COMPANY, "warehouse": WAREHOUSE}) filters.update(extra) From b170913880d4010b792a79633c360c387dce4619 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 3 Jul 2026 12:29:25 +0530 Subject: [PATCH 48/94] fix: remove company default on cost center in stock entry detail the ":company" default pre-filled every row before set_default_cost_center() ran, so its "if not row.cost_center" guard was always false and the project/item group/brand priority chain in get_default_cost_center() never ran. (cherry picked from commit edfa0a7a1d599a9f20406b3b114f8c666148e4f0) # Conflicts: # erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json --- .../stock/doctype/stock_entry_detail/stock_entry_detail.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index 4e422e320b9..2a63a37ffa7 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -337,7 +337,6 @@ "print_hide": 1 }, { - "default": ":Company", "depends_on": "eval:cint(erpnext.is_perpetual_inventory_enabled(parent.company))", "fieldname": "cost_center", "fieldtype": "Link", @@ -616,7 +615,11 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], +<<<<<<< HEAD "modified": "2026-04-27 11:40:38.294196", +======= + "modified": "2026-07-03 12:11:53.714931", +>>>>>>> edfa0a7a1d (fix: remove company default on cost center in stock entry detail) "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", From 1a52c586661149d851f88d783dac4fcc3e778cdd Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 3 Jul 2026 13:12:35 +0530 Subject: [PATCH 49/94] test: cover cost center fallback to item group default in manufacture entry the existing test_cost_center_for_manufacture only checks a raw material row against an item-level override, which is set independently of the ":company" default guard and never exercised the bug. (cherry picked from commit a168bb7ea49f669597aadcf898c35e1fd0cb2dbd) --- .../doctype/work_order/test_work_order.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 2679d6e29fe..629a8e717f5 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -687,6 +687,28 @@ class TestWorkOrder(FrappeTestCase): ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty) self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC") + @ERPNextTestSuite.change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0}) + def test_cost_center_for_manufacture_falls_back_to_item_group_default(self): + # "_Test Item Group" is master data with buying_cost_center already set to + # "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its + # BOM need to be created, since no existing item in that group has one. + fg_item = make_item( + "_Test FG Item For Item Group Cost Center", + {"is_stock_item": 1, "item_group": "_Test Item Group", "include_item_in_manufacturing": 1}, + ) + + if not frappe.db.exists("BOM", {"item": fg_item.name, "is_active": 1, "is_default": 1}): + make_bom(item=fg_item.name, raw_materials=["_Test Item"]) + + wo_order = make_wo_order_test_record( + production_item=fg_item.name, skip_transfer=1, source_warehouse="_Test Warehouse - _TC" + ) + ste = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", wo_order.qty)) + ste.insert() + + fg_row = next(d for d in ste.items if d.is_finished_item) + self.assertEqual(fg_row.cost_center, "_Test Cost Center 2 - _TC") + def test_operation_time_with_batch_size(self): fg_item = "Test Batch Size Item For BOM" rm1 = "Test Batch Size Item RM 1 For BOM" From cecfcf82e0aa6b03c4dfce504a4a28e60339f692 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 3 Jul 2026 21:14:43 +0530 Subject: [PATCH 50/94] fix: incorrect import path for FrappeTestCase --- .../test_stock_ledger_invariant_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py index ca5ba6acec1..372f91aaaa0 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py @@ -5,7 +5,7 @@ import frappe from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import execute -from erpnext.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase WAREHOUSE = "Stores - _TC" COMPANY = "_Test Company" From 0ab94a929afbd449d1a4092f89c4860e097350e5 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 3 Jul 2026 21:20:32 +0530 Subject: [PATCH 51/94] chore: formatting --- .../test_stock_ledger_invariant_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py index 372f91aaaa0..0ac0ce79216 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py @@ -2,10 +2,10 @@ # See license.txt import frappe +from frappe.tests.utils import FrappeTestCase from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import execute -from frappe.tests.utils import FrappeTestCase WAREHOUSE = "Stores - _TC" COMPANY = "_Test Company" From 93f33cc5cf2d195f64257e0bd36acb08781b1681 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 3 Jul 2026 21:42:58 +0530 Subject: [PATCH 52/94] test: fresh item per test, v15 only rolls back at class teardown --- .../test_stock_ledger_invariant_check.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py index 0ac0ce79216..b82e341c84a 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py @@ -4,12 +4,12 @@ import frappe from frappe.tests.utils import FrappeTestCase +from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import execute WAREHOUSE = "Stores - _TC" COMPANY = "_Test Company" -ITEM = "_Test Item" class TestStockLedgerInvariantCheck(FrappeTestCase): @@ -19,11 +19,12 @@ class TestStockLedgerInvariantCheck(FrappeTestCase): return execute(filters)[1] def make_movements(self) -> str: - frappe.db.set_value("Item", ITEM, "valuation_method", "FIFO") - make_stock_entry(item_code=ITEM, to_warehouse=WAREHOUSE, qty=10, rate=100, posting_date="2026-06-01") - make_stock_entry(item_code=ITEM, to_warehouse=WAREHOUSE, qty=5, rate=120, posting_date="2026-06-02") - make_stock_entry(item_code=ITEM, from_warehouse=WAREHOUSE, qty=4, rate=0, posting_date="2026-06-03") - return ITEM + # fresh item per test: db is only rolled back at class teardown on v15 + item = make_item(properties={"valuation_method": "FIFO"}).name + make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100, posting_date="2026-06-01") + make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=5, rate=120, posting_date="2026-06-02") + make_stock_entry(item_code=item, from_warehouse=WAREHOUSE, qty=4, rate=0, posting_date="2026-06-03") + return item def test_diagnostic_rows_have_no_discrepancy(self): item = self.make_movements() @@ -60,8 +61,6 @@ class TestStockLedgerInvariantCheck(FrappeTestCase): self.assertEqual(data[-1].name, sle.name) def test_batch_item_skips_fifo_queue_checks(self): - from erpnext.stock.doctype.item.test_item import make_item - item = make_item( properties={"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "SLIC-BAT-.####"} ).name From ece1c9872fad55bdebeec36405a60ed7a106dc22 Mon Sep 17 00:00:00 2001 From: Shllokkk <140623894+Shllokkk@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:49:33 +0530 Subject: [PATCH 53/94] fix: use live source warehouse valuation for internal transfer purchse receipts (#56812) fix: use live source warehouse valuation for internal transfer purchase receipts --- .../purchase_receipt/test_purchase_receipt.py | 87 +++++++++++++++++++ erpnext/stock/stock_ledger.py | 21 ++++- 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 3799e773a7d..edde28a04e6 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1662,6 +1662,93 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(query[0].value, 0) + def test_internal_transfer_pr_incoming_sle_anchored_to_dn_rate(self): + """Internal-transfer PR's inward SLE must use DN.incoming_rate even when + PR.item.valuation_rate was wrong at submit, so divisional_loss does not + leak to COGS.""" + from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.stock_ledger import update_entries_after + + prepare_data_for_internal_transfer() + customer = "_Test Internal Customer 2" + company = "_Test Company with perpetual inventory" + + from_warehouse = create_warehouse("_Test Drift From", company=company) + transit_warehouse = create_warehouse("_Test Drift Transit", company=company) + to_warehouse = create_warehouse("_Test Drift Receiver", company=company) + item_doc = create_item("Test Internal Drift Item") + + make_purchase_receipt( + item_code=item_doc.name, + company=company, + posting_date=add_days(today(), -1), + warehouse=from_warehouse, + qty=10, + rate=100, + ) + + dn = create_delivery_note( + item_code=item_doc.name, + company=company, + customer=customer, + cost_center="Main - TCP1", + expense_account="Cost of Goods Sold - TCP1", + qty=1, + rate=100, + warehouse=from_warehouse, + target_warehouse=transit_warehouse, + ) + self.assertEqual(flt(dn.items[0].incoming_rate), 100.0) + + pr = make_inter_company_purchase_receipt(dn.name) + pr.items[0].warehouse = to_warehouse + pr.submit() + + inward_sle = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Purchase Receipt", + "voucher_no": pr.name, + "warehouse": to_warehouse, + "is_cancelled": 0, + }, + ["name", "item_code", "warehouse", "posting_date", "posting_time", "creation", "incoming_rate"], + as_dict=True, + ) + self.assertEqual(flt(inward_sle.incoming_rate), 100.0) + + frappe.db.set_value( + "Purchase Receipt Item", + pr.items[0].name, + {"sales_incoming_rate": 0, "valuation_rate": 80}, + ) + frappe.db.set_value( + "Stock Ledger Entry", + inward_sle.name, + {"incoming_rate": 80, "stock_value_difference": 80}, + ) + + update_entries_after( + { + "item_code": inward_sle.item_code, + "warehouse": inward_sle.warehouse, + "posting_date": inward_sle.posting_date, + "posting_time": inward_sle.posting_time, + "sle_id": inward_sle.name, + "creation": inward_sle.creation, + } + ) + + refreshed = frappe.db.get_value( + "Stock Ledger Entry", + inward_sle.name, + ["incoming_rate", "stock_value_difference"], + as_dict=True, + ) + self.assertEqual(flt(refreshed.incoming_rate), 100.0) + self.assertEqual(flt(refreshed.stock_value_difference), 100.0) + def test_backdated_transaction_for_internal_transfer_in_trasit_warehouse_for_purchase_invoice( self, ): diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 53d2153c44d..ba578f69814 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -870,10 +870,16 @@ class update_entries_after: if ( sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"] and sle.voucher_detail_no - and sle.actual_qty < 0 and is_internal_transfer(sle) ): - sle.outgoing_rate = get_incoming_rate_for_inter_company_transfer(sle) + # Anchor both legs of an internal-transfer PR/PI to the DN/SI incoming_rate; + # otherwise an inward SLE that inherits a stale PR.valuation_rate leaks the + # gap to COGS via divisional_loss. + rate = get_incoming_rate_for_inter_company_transfer(sle) + if sle.actual_qty < 0: + sle.outgoing_rate = rate + elif rate: + sle.incoming_rate = rate dimensions = get_inventory_dimensions() has_dimensions = False @@ -2430,7 +2436,16 @@ def get_incoming_rate_for_inter_company_transfer(sle) -> float: if lcv_amount: lcv_rate = flt(lcv_amount / abs(sle.actual_qty)) - return rate + lcv_rate + charges_rate = 0.0 + if flt(sle.actual_qty) > 0: + charge_fields = ["item_tax_amount", "rm_supp_cost"] + charges = frappe.db.get_value( + f"{sle.voucher_type} Item", sle.voucher_detail_no, charge_fields, as_dict=True + ) + if charges: + charges_rate = flt(sum(flt(charges.get(f)) for f in charge_fields)) / abs(sle.actual_qty) + + return rate + lcv_rate + charges_rate def is_internal_transfer(sle): From da623eb124bd453cbfabebe95807ffcfa8395d2b Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Sat, 4 Jul 2026 12:45:09 +0530 Subject: [PATCH 54/94] fix: resolve conflicts --- .../stock/doctype/stock_entry_detail/stock_entry_detail.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index 2a63a37ffa7..ec73d0c6fee 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -615,11 +615,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], -<<<<<<< HEAD - "modified": "2026-04-27 11:40:38.294196", -======= "modified": "2026-07-03 12:11:53.714931", ->>>>>>> edfa0a7a1d (fix: remove company default on cost center in stock entry detail) "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", From 5cedc50c55bcd00a4ba033fbfdf1c16a49634fc1 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sat, 4 Jul 2026 12:53:57 +0530 Subject: [PATCH 55/94] fix: use change_settings decorator matching version-15-hotfix test utils ERPNextTestSuite doesn't exist on this branch; the cherry-picked test used the develop-branch decorator style, breaking ruff (F821) in CI. --- erpnext/manufacturing/doctype/work_order/test_work_order.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 629a8e717f5..3d599e311bb 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -687,7 +687,7 @@ class TestWorkOrder(FrappeTestCase): ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty) self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC") - @ERPNextTestSuite.change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0}) + @change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0}) def test_cost_center_for_manufacture_falls_back_to_item_group_default(self): # "_Test Item Group" is master data with buying_cost_center already set to # "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its From ae43f47c39c240d422692f681ea8ac278f4d66cc Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 3 Jul 2026 11:29:18 +0530 Subject: [PATCH 56/94] fix: race condition and repeatable read in process pcv - Update using child table name to avoid scanning whole table, which eventually leads to mariadb 1020 (REPEATABLE READ). - Avoid race condition in final summarization (cherry picked from commit ff6881764b843a3046934fddbbbfd8a907637ef8) --- .../process_period_closing_voucher.py | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 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 b5ca3331b71..43813fb98af 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 @@ -97,7 +97,7 @@ def start_pcv_processing(docname: str): ppcvd = qb.DocType("Process Period Closing Voucher Detail") if normal_balances := ( qb.from_(ppcvd) - .select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield) + .select(ppcvd.name, 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) @@ -108,12 +108,7 @@ def start_pcv_processing(docname: str): for x in normal_balances: frappe.db.set_value( "Process Period Closing Voucher Detail", - { - "processing_date": x.processing_date, - "parent": docname, - "report_type": x.report_type, - "parentfield": x.parentfield, - }, + x.name, "status", "Running", ) @@ -124,10 +119,12 @@ def start_pcv_processing(docname: str): is_async=True, enqueue_after_commit=True, docname=docname, + row_name=x.name, date=x.processing_date, report_type=x.report_type, parentfield=x.parentfield, ) + frappe.db.commit() else: frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed") @@ -251,7 +248,7 @@ def schedule_next_date(docname: str): ppcvd = qb.DocType("Process Period Closing Voucher Detail") if to_process := ( qb.from_(ppcvd) - .select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield) + .select(ppcvd.name, 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) @@ -261,15 +258,11 @@ def schedule_next_date(docname: str): if not is_scheduler_inactive(): frappe.db.set_value( "Process Period Closing Voucher Detail", - { - "processing_date": to_process[0].processing_date, - "parent": docname, - "report_type": to_process[0].report_type, - "parentfield": to_process[0].parentfield, - }, + to_process[0].name, "status", "Running", ) + frappe.db.commit() frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", queue="long", @@ -277,6 +270,7 @@ def schedule_next_date(docname: str): is_async=True, enqueue_after_commit=True, docname=docname, + row_name=to_process[0].name, date=to_process[0].processing_date, report_type=to_process[0].report_type, parentfield=to_process[0].parentfield, @@ -441,6 +435,8 @@ def summarize_and_post_ledger_entries(docname): make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date) + frappe.db.commit() + frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed") frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed") @@ -526,10 +522,10 @@ def build_dimension_wise_balance_dict(gl_entries): return dimension_balances -def process_individual_date(docname: str, date, report_type, parentfield): +def process_individual_date(docname: str, row_name, date, report_type, parentfield): current_date_status = frappe.db.get_value( "Process Period Closing Voucher Detail", - {"processing_date": date, "report_type": report_type, "parentfield": parentfield}, + row_name, "status", ) if current_date_status != "Running": @@ -576,17 +572,18 @@ def process_individual_date(docname: str, date, report_type, parentfield): # save results frappe.db.set_value( "Process Period Closing Voucher Detail", - {"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield}, + row_name, "closing_balance", frappe.json.dumps(res), ) frappe.db.set_value( "Process Period Closing Voucher Detail", - {"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield}, + row_name, "status", "Completed", ) + frappe.db.commit() # chain call schedule_next_date(docname) From 1ee8904a94b21f60d46ee925de02cf0321ce5193 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 3 Jul 2026 13:00:00 +0530 Subject: [PATCH 57/94] fix: prevent repeatable read related concurrency errors Process Period Closing Voucher and Process Period Closing Voucher Details are trackers how the jobs are processed. Keep transactions on them very short. (cherry picked from commit 7e4045e8282714928989453529d679ff3bf4b6eb) --- .../process_period_closing_voucher.py | 99 +++++++++++-------- 1 file changed, 58 insertions(+), 41 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 43813fb98af..a0e59d96be4 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 @@ -86,47 +86,55 @@ class ProcessPeriodClosingVoucher(Document): cancel_pcv_processing(self.name) +def initialize_parallel_threads(docname: str): + threads = 4 + timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 + ppcvd = qb.DocType("Process Period Closing Voucher Detail") + + frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running") + + if normal_balances := ( + qb.from_(ppcvd) + .select(ppcvd.name, 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(threads) + .for_update(skip_locked=True) + .run(as_dict=True) + ): + if not is_scheduler_inactive(): + for x in normal_balances: + frappe.db.set_value( + "Process Period Closing Voucher Detail", + x.name, + "status", + "Running", + ) + frappe.enqueue( + method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", + queue="long", + timeout=timeout, + is_async=True, + enqueue_after_commit=True, + docname=docname, + row_name=x.name, + date=x.processing_date, + report_type=x.report_type, + parentfield=x.parentfield, + ) + # keep transaction on PPCV and PPCVD short + # prevents concurrency errors - REPEATABLE READ + if not frappe.in_test: + frappe.db.commit() + else: + frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed") + + @frappe.whitelist() def start_pcv_processing(docname: str): if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]: frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True) - frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running") - - timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 - - ppcvd = qb.DocType("Process Period Closing Voucher Detail") - if normal_balances := ( - qb.from_(ppcvd) - .select(ppcvd.name, 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: - frappe.db.set_value( - "Process Period Closing Voucher Detail", - x.name, - "status", - "Running", - ) - frappe.enqueue( - method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", - queue="long", - timeout=timeout, - is_async=True, - enqueue_after_commit=True, - docname=docname, - row_name=x.name, - date=x.processing_date, - report_type=x.report_type, - parentfield=x.parentfield, - ) - frappe.db.commit() - else: - frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed") + initialize_parallel_threads(docname) @frappe.whitelist() @@ -244,8 +252,8 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions): @frappe.whitelist() def schedule_next_date(docname: str): timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 - ppcvd = qb.DocType("Process Period Closing Voucher Detail") + if to_process := ( qb.from_(ppcvd) .select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield) @@ -262,7 +270,11 @@ def schedule_next_date(docname: str): "status", "Running", ) - frappe.db.commit() + # keep transaction on PPCV and PPCVD short + # prevents concurrency errors - REPEATABLE READ + if not frappe.in_test: + frappe.db.commit() + frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", queue="long", @@ -435,7 +447,10 @@ def summarize_and_post_ledger_entries(docname): make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date) - frappe.db.commit() + # keep transaction on PPCV and PPCVD short + # prevents concurrency errors - REPEATABLE READ + if not frappe.in_test: + frappe.db.commit() frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed") frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed") @@ -583,7 +598,9 @@ def process_individual_date(docname: str, row_name, date, report_type, parentfie "status", "Completed", ) - frappe.db.commit() + # commit heavy computation before touching PPCV or PPCVD + if not frappe.in_test: + frappe.db.commit() # chain call schedule_next_date(docname) From 6945e1292b58348210280b4519f1fc3f37d5cea0 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 3 Jul 2026 15:51:18 +0530 Subject: [PATCH 58/94] refactor: prevent whole table scan while scheduling next date - helps in concurrency isolation (cherry picked from commit 21f4603144d3ffbe5e1b871dbaa02631e61919c2) --- .../process_period_closing_voucher_detail.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/process_period_closing_voucher_detail/process_period_closing_voucher_detail.py b/erpnext/accounts/doctype/process_period_closing_voucher_detail/process_period_closing_voucher_detail.py index f3a8302ac5b..0e0b905c96a 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher_detail/process_period_closing_voucher_detail.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher_detail/process_period_closing_voucher_detail.py @@ -1,7 +1,7 @@ # Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document @@ -24,3 +24,10 @@ class ProcessPeriodClosingVoucherDetail(Document): # end: auto-generated types pass + + +def on_doctype_update(): + frappe.db.add_index( + "Process Period Closing Voucher Detail", + ["parent", "status", "parentfield", "idx", "processing_date"], + ) From dcea09ea8f2a30f621cb364482899432bd96ab2d Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 3 Jul 2026 17:04:51 +0530 Subject: [PATCH 59/94] chore: linter fix (cherry picked from commit a9ffdac8062de9b2e2e68a9c457e9e96e8ced37b) --- .../process_period_closing_voucher.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 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 a0e59d96be4..6315560b89f 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 @@ -125,7 +125,7 @@ def initialize_parallel_threads(docname: str): # keep transaction on PPCV and PPCVD short # prevents concurrency errors - REPEATABLE READ if not frappe.in_test: - frappe.db.commit() + frappe.db.commit() # nosemgrep else: frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed") @@ -273,7 +273,7 @@ def schedule_next_date(docname: str): # keep transaction on PPCV and PPCVD short # prevents concurrency errors - REPEATABLE READ if not frappe.in_test: - frappe.db.commit() + frappe.db.commit() # nosemgrep frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", @@ -450,7 +450,7 @@ def summarize_and_post_ledger_entries(docname): # keep transaction on PPCV and PPCVD short # prevents concurrency errors - REPEATABLE READ if not frappe.in_test: - frappe.db.commit() + frappe.db.commit() # nosemgrep frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed") frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed") @@ -600,7 +600,7 @@ def process_individual_date(docname: str, row_name, date, report_type, parentfie ) # commit heavy computation before touching PPCV or PPCVD if not frappe.in_test: - frappe.db.commit() + frappe.db.commit() # nosemgrep # chain call schedule_next_date(docname) From 1fe9d569d6682c6d8b8f1c73cb1fc3172f839b10 Mon Sep 17 00:00:00 2001 From: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:21:12 +0530 Subject: [PATCH 60/94] =?UTF-8?q?Revert=20"refactor(sales=5Fperson=5Fwise?= =?UTF-8?q?=5Ftransaction=5Fsummary):=20Replace=20SQL=20with=20que?= =?UTF-8?q?=E2=80=A6=20(backport=20#55191)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sales_person_wise_transaction_summary.py | 94 +++++++++---------- 1 file changed, 42 insertions(+), 52 deletions(-) diff --git a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py index 23ed83cca84..405159215cd 100644 --- a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py +++ b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py @@ -4,7 +4,7 @@ import frappe from frappe import _, msgprint, qb -from frappe.query_builder import Case, Criterion +from frappe.query_builder import Criterion from erpnext import get_company_currency @@ -155,60 +155,50 @@ def get_columns(filters): def get_entries(filters): - doc_type = filters["doc_type"] + date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date" + if filters["doc_type"] == "Sales Order": + qty_field = "delivered_qty" + else: + qty_field = "qty" + conditions, values = get_conditions(filters, date_field) - date_field = "transaction_date" if doc_type == "Sales Order" else "posting_date" - qty_field = "delivered_qty" if doc_type == "Sales Order" else "qty" - - dt = frappe.qb.DocType(doc_type) - dt_item = frappe.qb.DocType(f"{doc_type} Item") - st = frappe.qb.DocType("Sales Team") - - calc_qty = dt_item[qty_field] * dt_item.conversion_factor - calc_net_amount = dt_item.base_net_rate * calc_qty - - stock_qty_case = Case().when(dt.status == "Closed", calc_qty).else_(dt_item.stock_qty).as_("stock_qty") - - base_net_amount_case = ( - Case() - .when(dt.status == "Closed", calc_net_amount) - .else_(dt_item.base_net_amount) - .as_("base_net_amount") + entries = frappe.db.sql( + """ + SELECT + dt.name, dt.customer, dt.territory, dt.{} as posting_date, dt_item.item_code, + st.sales_person, st.allocated_percentage, dt_item.warehouse, + CASE + WHEN dt.status = "Closed" THEN dt_item.{} * dt_item.conversion_factor + ELSE dt_item.stock_qty + END as stock_qty, + CASE + WHEN dt.status = "Closed" THEN (dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) + ELSE dt_item.base_net_amount + END as base_net_amount, + CASE + WHEN dt.status = "Closed" THEN ((dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) * st.allocated_percentage/100) + ELSE dt_item.base_net_amount * st.allocated_percentage/100 + END as contribution_amt + FROM + `tab{}` dt, `tab{} Item` dt_item, `tabSales Team` st + WHERE + st.parent = dt.name and dt.name = dt_item.parent and st.parenttype = {} + and dt.docstatus = 1 {} order by st.sales_person, dt.name desc + """.format( + date_field, + qty_field, + qty_field, + qty_field, + filters["doc_type"], + filters["doc_type"], + "%s", + conditions, + ), + tuple([filters["doc_type"], *values]), + as_dict=1, ) - contribution_amt_case = ( - Case() - .when(dt.status == "Closed", (calc_net_amount * st.allocated_percentage / 100)) - .else_(dt_item.base_net_amount * st.allocated_percentage / 100) - .as_("contribution_amt") - ) - - query = ( - frappe.get_query(dt, filters=filters, ignore_permissions=False) - .join(dt_item) - .on(dt.name == dt_item.parent) - .join(st) - .on(dt.name == st.parent) - .select( - dt.name, - dt.customer, - dt.territory, - dt[date_field].as_("posting_date"), - dt_item.item_code, - st.sales_person, - st.allocated_percentage, - dt_item.warehouse, - stock_qty_case, - base_net_amount_case, - contribution_amt_case, - ) - .where(st.parenttype == doc_type) - .where(dt.docstatus == 1) - ) - - query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc) - - return query.run(as_dict=True) + return entries def get_conditions(filters, date_field): From d62831d8852f63b6e1ad59a3f035ea680932a850 Mon Sep 17 00:00:00 2001 From: Dany Robert Date: Tue, 7 Jul 2026 19:44:35 +0530 Subject: [PATCH 61/94] fix: precision issue causing reconciliation error (#54043) * fix: precision issue causing reconciliation error * chore: code styling changes * test: precision causing reconciliation failure * fix: enhance payment reconciliation tests for floating-point precision * fix(test): incorrect assertion on status --------- Co-authored-by: ruthra kumar (cherry picked from commit be10c8ced9d1993dd660e5dfae681cfe22d7d1fd) --- .../payment_reconciliation.py | 11 ++- .../test_payment_reconciliation.py | 73 ++++++++++++++++++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 9ec4e0a073a..d286c6513f4 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -796,10 +796,17 @@ class PaymentReconciliation(Document): def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None): + allocated_amount_precision = get_field_precision( + frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount") + ) for inv in dr_cr_notes: if ( - abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount")) - < inv.allocated_amount + flt( + abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount")) + - inv.allocated_amount, + allocated_amount_precision, + ) + < 0 ): frappe.throw( _("{0} has been modified after you pulled it. Please pull it again.").format(inv.voucher_type) diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index b7d8fb44853..a727e4ab894 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -155,6 +155,7 @@ class TestPaymentReconciliation(FrappeTestCase): sinv = create_sales_invoice( qty=qty, rate=rate, + posting_date=posting_date, company=self.company, customer=self.customer, item_code=self.item, @@ -2146,7 +2147,7 @@ class TestPaymentReconciliation(FrappeTestCase): pr.reconcile() si.reload() - self.assertEqual(si.status, "Partly Paid") + self.assertEqual(si.status, "Overdue") # check PR tool output post reconciliation self.assertEqual(len(pr.get("invoices")), 1) self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 120) @@ -2540,6 +2541,76 @@ class TestPaymentReconciliation(FrappeTestCase): self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0) pr.reconcile() + def test_cr_note_split_across_invoices_floating_point_precision(self): + """Regression: when a credit note is split across multiple invoices, floating-point + arithmetic (150 - 8.45 - 90.72 = 50.83000000000001) must not cause reconcile() to fail. + + The test environment rounds INR totals to whole rupees (smallest_currency_fraction_value=0), + so the invoices are created with round-number totals (100, 200, 100) and then partially paid + down to the decimal outstanding amounts (8.45, 90.72, 72.57) via payment entries. + """ + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + + # Create invoices on different posting dates to control sort-order in Payment Reconciliation + # (invoices are sorted by posting_date ascending, so si_a is processed first). + # Processing order 8.45 → 90.72 → 72.57 produces the float chain: + # 150 - 8.45 = 141.55 → 141.55 - 90.72 = 50.83000000000001 + # The last allocation row will therefore carry allocated_amount = 50.83000000000001. + si_a = self.create_sales_invoice(qty=1, rate=100, posting_date=add_days(nowdate(), -2)) + si_b = self.create_sales_invoice(qty=1, rate=200, posting_date=add_days(nowdate(), -1)) + si_c = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate()) + + # Partially pay each invoice so the remaining outstanding is a clean decimal value. + # INR rounds the invoice total to a whole rupee, so we achieve decimal outstandings + # by subtracting a decimal-valued payment from the integer total: + # 100 - 91.55 = 8.45 + # 200 - 109.28 = 90.72 + # 100 - 27.43 = 72.57 + for si, partial_paid in ((si_a, 91.55), (si_b, 109.28), (si_c, 27.43)): + pe = get_payment_entry(si.doctype, si.name) + pe.paid_amount = partial_paid + pe.received_amount = partial_paid + pe.references[0].allocated_amount = partial_paid + pe.save().submit() + + cr_note = self.create_sales_invoice( + qty=-1, rate=150, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + cr_note.is_return = 1 + cr_note = cr_note.save().submit() + + pr = self.create_payment_reconciliation() + # Widen date range so all three invoices (oldest is -2 days) are fetched + pr.from_invoice_date = add_days(nowdate(), -2) + pr.to_invoice_date = nowdate() + pr.from_payment_date = nowdate() + pr.to_payment_date = nowdate() + + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 3) + self.assertEqual(len(pr.payments), 1) + + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Credit note (150) covers all of si_a (8.45) and si_b (90.72), then partially si_c + self.assertEqual(len(pr.allocation), 3) + last_row = pr.allocation[-1] + # Last allocated amount should be ~50.83 (possibly 50.83000000000001 due to float arithmetic) + self.assertAlmostEqual(flt(last_row.allocated_amount), 50.83, places=2) + + # reconcile() must not raise "has been modified after you pulled it" due to float imprecision + pr.reconcile() + + si_a.reload() + si_b.reload() + si_c.reload() + self.assertEqual(si_a.outstanding_amount, 0) + self.assertEqual(si_b.outstanding_amount, 0) + # si_c is only partially settled: 72.57 - 50.83 = 21.74 + self.assertAlmostEqual(si_c.outstanding_amount, 21.74, places=2) + def make_customer(customer_name, currency=None): if not frappe.db.exists("Customer", customer_name): From 3df52c7fc610cd9e39194dc1401e349232bf645b Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Tue, 7 Jul 2026 13:50:06 +0530 Subject: [PATCH 62/94] fix: validate template and its variant in the same Pricing Rule (cherry picked from commit a88048b37875ea462aee6ee7c235610b96d582bd) --- .../doctype/pricing_rule/pricing_rule.py | 18 +++++++++++++ .../doctype/pricing_rule/test_pricing_rule.py | 25 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index cc090df9270..4fab7fc1121 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -156,6 +156,24 @@ class PricingRule(Document): if len(values) != len(set(values)): frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on)) + if self.apply_on == "Item Code": + self.validate_template_with_variant(values) + + def validate_template_with_variant(self, item_codes): + # throws if a template and its variant both exist in one rule + variants = frappe.get_all( + "Item", + filters={"name": ("in", item_codes), "variant_of": ("in", item_codes)}, + fields=["name", "variant_of"], + ) + if variants: + variant = variants[0] + frappe.throw( + _("Variant {0} and its template {1} cannot both be added to the same Pricing Rule").format( + frappe.bold(variant.name), frappe.bold(variant.variant_of) + ) + ) + def validate_mandatory(self): if self.has_priority and not self.priority: throw(_("Priority is mandatory"), frappe.MandatoryError, _("Please Set Priority")) diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index 123c17f9b75..b5b464b05d9 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -336,6 +336,31 @@ class TestPricingRule(FrappeTestCase): details = get_item_details(args) self.assertEqual(details.get("discount_percentage"), 17.5) + def test_pricing_rule_with_template_and_its_variant(self): + if not frappe.db.exists("Item", "Test Variant PRT"): + variant = frappe.new_doc("Item") + variant.item_code = "Test Variant PRT" + variant.item_name = "Test Variant PRT" + variant.item_group = "_Test Item Group" + variant.is_stock_item = 1 + variant.variant_of = "_Test Variant Item" + variant.stock_uom = "_Test UOM" + variant.append("attributes", {"attribute": "Test Size", "attribute_value": "Medium"}) + variant.insert() + + rule = frappe.new_doc("Pricing Rule") + rule.title = "_Test Pricing Rule Template Variant" + rule.apply_on = "Item Code" + rule.currency = "USD" + rule.selling = 1 + rule.rate_or_discount = "Discount Percentage" + rule.discount_percentage = 10 + rule.company = "_Test Company" + rule.append("items", {"item_code": "_Test Variant Item"}) + rule.append("items", {"item_code": "Test Variant PRT"}) + + self.assertRaises(frappe.ValidationError, rule.insert) + def test_pricing_rule_for_stock_qty(self): test_record = { "doctype": "Pricing Rule", From 51669501ab3a59dfd75b3b0cb58c0bf0731d7ac7 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:29:42 +0200 Subject: [PATCH 63/94] feat(sla): filter service level agreement link by document type (backport #56954) (#56955) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- erpnext/patches.txt | 2 + ...ckfill_sla_link_filters_on_custom_field.py | 21 +++++ .../backfill_sla_link_filters_on_docfield.py | 20 +++++ .../service_level_agreement.py | 12 ++- .../test_service_level_agreement.py | 82 ++++++++++++++++--- 5 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 erpnext/patches/v15_0/backfill_sla_link_filters_on_custom_field.py create mode 100644 erpnext/patches/v15_0/backfill_sla_link_filters_on_docfield.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index b48f16a7550..d352abd445d 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -438,3 +438,5 @@ erpnext.patches.v16_0.migrate_address_contact_custom_fields erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600) +erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field +erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield diff --git a/erpnext/patches/v15_0/backfill_sla_link_filters_on_custom_field.py b/erpnext/patches/v15_0/backfill_sla_link_filters_on_custom_field.py new file mode 100644 index 00000000000..65996f258d8 --- /dev/null +++ b/erpnext/patches/v15_0/backfill_sla_link_filters_on_custom_field.py @@ -0,0 +1,21 @@ +import frappe + + +def execute(): + for custom_field in frappe.get_all( + "Custom Field", + filters={ + "fieldname": "service_level_agreement", + "fieldtype": "Link", + "options": "Service Level Agreement", + "link_filters": ("is", "not set"), + }, + fields=["name", "dt"], + ): + link_filters = frappe.as_json( + [["Service Level Agreement", "document_type", "=", custom_field.dt]], indent=None + ) + frappe.db.set_value( + "Custom Field", custom_field.name, "link_filters", link_filters, update_modified=False + ) + frappe.clear_cache(doctype=custom_field.dt) diff --git a/erpnext/patches/v15_0/backfill_sla_link_filters_on_docfield.py b/erpnext/patches/v15_0/backfill_sla_link_filters_on_docfield.py new file mode 100644 index 00000000000..22110afc9ff --- /dev/null +++ b/erpnext/patches/v15_0/backfill_sla_link_filters_on_docfield.py @@ -0,0 +1,20 @@ +import frappe + + +def execute(): + for docfield in frappe.get_all( + "DocField", + filters={ + "parenttype": "DocType", + "fieldname": "service_level_agreement", + "fieldtype": "Link", + "options": "Service Level Agreement", + "link_filters": ("is", "not set"), + }, + fields=["name", "parent"], + ): + link_filters = frappe.as_json( + [["Service Level Agreement", "document_type", "=", docfield.parent]], indent=None + ) + frappe.db.set_value("DocField", docfield.name, "link_filters", link_filters, update_modified=False) + frappe.clear_cache(doctype=docfield.parent) diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 6f7c943ddad..531c6371591 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -232,7 +232,7 @@ class ServiceLevelAgreement(Document): if self.document_type == "Issue": return - service_level_agreement_fields = get_service_level_agreement_fields() + service_level_agreement_fields = get_service_level_agreement_fields(self.document_type) meta = frappe.get_meta(self.document_type, cached=False) if meta.custom: @@ -276,6 +276,7 @@ class ServiceLevelAgreement(Document): "hidden": field.get("hidden"), "description": field.get("description"), "default": field.get("default"), + "link_filters": field.get("link_filters"), } ).insert(ignore_permissions=True) else: @@ -302,6 +303,7 @@ class ServiceLevelAgreement(Document): "hidden": field.get("hidden"), "description": field.get("description"), "default": field.get("default"), + "link_filters": field.get("link_filters"), } ).insert(ignore_permissions=True) else: @@ -309,7 +311,7 @@ class ServiceLevelAgreement(Document): self.reset_field_properties(existing_field, "Custom Field", field) def reset_field_properties(self, field, field_dt, sla_field): - field = frappe.get_doc(field_dt, {"fieldname": field.fieldname}) + field = frappe.get_doc(field_dt, field.name) field.label = sla_field.get("label") field.fieldname = sla_field.get("fieldname") field.fieldtype = sla_field.get("fieldtype") @@ -320,6 +322,7 @@ class ServiceLevelAgreement(Document): field.hidden = sla_field.get("hidden") field.description = sla_field.get("description") field.default = sla_field.get("default") + field.link_filters = sla_field.get("link_filters") field.save(ignore_permissions=True) @@ -909,7 +912,7 @@ def record_assigned_users_on_failure(doc): doc.add_comment(comment_type="Assigned", text=message) -def get_service_level_agreement_fields(): +def get_service_level_agreement_fields(doctype: str): return [ { "collapsible": 1, @@ -922,6 +925,9 @@ def get_service_level_agreement_fields(): "fieldtype": "Link", "label": "Service Level Agreement", "options": "Service Level Agreement", + "link_filters": frappe.as_json( + [["Service Level Agreement", "document_type", "=", doctype]], indent=None + ), }, {"fieldname": "priority", "fieldtype": "Link", "label": "Priority", "options": "Issue Priority"}, {"fieldname": "response_by", "fieldtype": "Datetime", "label": "Response By", "read_only": 1}, diff --git a/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py index cabd38f6427..7d579786f51 100644 --- a/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py @@ -2,6 +2,7 @@ # See license.txt import datetime +import json import unittest import frappe @@ -176,11 +177,14 @@ class TestServiceLevelAgreement(unittest.TestCase): self.assertEqual(lead_sla.name, default_sla.name) # check SLA custom fields created for leads - sla_fields = get_service_level_agreement_fields() + sla_fields = get_service_level_agreement_fields(doctype) for field in sla_fields: - self.assertTrue( - frappe.db.exists("Custom Field", {"dt": doctype, "fieldname": field.get("fieldname")}) + filters = {"dt": doctype, "fieldname": field.get("fieldname")} + self.assertTrue(frappe.db.exists("Custom Field", filters)) + self.assertEqual( + get_link_filters("Custom Field", filters), + json.loads(field["link_filters"]) if field.get("link_filters") else None, ) def test_docfield_creation_for_sla_on_custom_dt(self): @@ -200,13 +204,66 @@ class TestServiceLevelAgreement(unittest.TestCase): self.assertEqual(sla.name, default_sla.name) # check SLA docfields created - sla_fields = get_service_level_agreement_fields() + sla_fields = get_service_level_agreement_fields(doctype.name) for field in sla_fields: - self.assertTrue( - frappe.db.exists("DocField", {"fieldname": field.get("fieldname"), "parent": doctype.name}) + filters = {"fieldname": field.get("fieldname"), "parent": doctype.name} + self.assertTrue(frappe.db.exists("DocField", filters)) + self.assertEqual( + get_link_filters("DocField", filters), + json.loads(field["link_filters"]) if field.get("link_filters") else None, ) + def test_reset_field_properties_does_not_clobber_other_doctypes_field(self): + """Two doctypes each get their own "service_level_agreement" custom field + (same fieldname, different owning doctype). Updating the field on one of + them must not clobber the other's, even though both share the fieldname + (regression test for the fix in reset_field_properties, see PR #56954).""" + doctype_a = create_custom_doctype("Test SLA Dt A") + doctype_b = create_custom_doctype("Test SLA Dt B") + + for doctype in (doctype_a.name, doctype_b.name): + create_service_level_agreement( + default_service_level_agreement=1, + holiday_list="__Test Holiday List", + entity_type=None, + entity=None, + response_time=14400, + resolution_time=21600, + doctype=doctype, + ) + + def get_sla_field_link_filters(doctype): + return get_link_filters("DocField", {"parent": doctype, "fieldname": "service_level_agreement"}) + + self.assertEqual( + get_sla_field_link_filters(doctype_a.name), + [["Service Level Agreement", "document_type", "=", doctype_a.name]], + ) + + # The field on doctype_b already exists, so creating another, entity-specific + # SLA for doctype_b takes the "update existing field" branch (reset_field_properties) + # instead of creating a new field. + customer = create_customer() + create_service_level_agreement( + default_service_level_agreement=0, + holiday_list="__Test Holiday List", + entity_type="Customer", + entity=customer, + response_time=7200, + resolution_time=10800, + doctype=doctype_b.name, + ) + + self.assertEqual( + get_sla_field_link_filters(doctype_a.name), + [["Service Level Agreement", "document_type", "=", doctype_a.name]], + ) + self.assertEqual( + get_sla_field_link_filters(doctype_b.name), + [["Service Level Agreement", "document_type", "=", doctype_b.name]], + ) + def test_sla_application(self): # Default Service Level Agreement doctype = "Lead" @@ -362,6 +419,11 @@ class TestServiceLevelAgreement(unittest.TestCase): frappe.delete_doc("Service Level Agreement", d.name, force=1) +def get_link_filters(field_doctype, filters): + value = frappe.db.get_value(field_doctype, filters, "link_filters") + return json.loads(value) if value else None + + def get_service_level_agreement( default_service_level_agreement=None, entity_type=None, entity=None, doctype="Issue" ): @@ -602,8 +664,8 @@ def make_holiday_list(): ).insert() -def create_custom_doctype(): - if not frappe.db.exists("DocType", "Test SLA on Custom Dt"): +def create_custom_doctype(name="Test SLA on Custom Dt"): + if not frappe.db.exists("DocType", name): doc = frappe.get_doc( { "doctype": "DocType", @@ -626,13 +688,13 @@ def create_custom_doctype(): }, ], "permissions": [{"role": "System Manager", "read": 1, "write": 1}], - "name": "Test SLA on Custom Dt", + "name": name, } ) doc.insert() return doc else: - return frappe.get_doc("DocType", "Test SLA on Custom Dt") + return frappe.get_doc("DocType", name) def make_lead(creation=None, index=0, company=None): From d0f29405d02a5b7991feb09b9d019db16332ac53 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Thu, 9 Jul 2026 13:43:08 +0530 Subject: [PATCH 64/94] fix(patch): moved create_company_custom_fields from `pre_model_sync` to `post_model_sync` (#56962) --- erpnext/patches.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d352abd445d..fc9313f4bf7 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -262,7 +262,6 @@ execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Deta erpnext.patches.v14_0.update_proprietorship_to_individual erpnext.patches.v15_0.rename_subcontracting_fields erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage -erpnext.patches.v16_0.create_company_custom_fields [post_model_sync] erpnext.patches.v15_0.create_asset_depreciation_schedules_from_assets @@ -422,6 +421,7 @@ execute:frappe.db.set_single_value("Accounts Settings", "fetch_valuation_rate_fo erpnext.patches.v15_0.add_company_payment_gateway_account erpnext.patches.v15_0.update_uae_zero_rated_fetch erpnext.patches.v15_0.update_fieldname_in_accounting_dimension_filter +erpnext.patches.v16_0.create_company_custom_fields erpnext.patches.v15_0.set_asset_status_if_not_already_set erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing execute:frappe.db.set_single_value("Accounts Settings", "show_party_balance", 1) From 95b3bd4e1d3fc47d537898c6c3e9c84cf5e2a31d Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Wed, 1 Jul 2026 02:12:21 +0530 Subject: [PATCH 65/94] fix(crm_settings): skip allowed users check when frappe crm is installed locally (cherry picked from commit 41badb3d740cd9ef5192a9756022afe6d0e72750) --- .../crm/doctype/crm_settings/crm_settings.js | 33 +++++++++++++++++-- .../doctype/crm_settings/crm_settings.json | 4 +-- .../crm/doctype/crm_settings/crm_settings.py | 7 +++- erpnext/crm/frappe_crm_api.py | 12 ++++++- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.js b/erpnext/crm/doctype/crm_settings/crm_settings.js index 0fb695a3da4..ef71437be49 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.js +++ b/erpnext/crm/doctype/crm_settings/crm_settings.js @@ -2,6 +2,35 @@ // For license information, please see license.txt frappe.ui.form.on("CRM Settings", { - // refresh: function(frm) { - // } + refresh: function (frm) { + const flag = frm.events.calculate_visiblity_flag(frm); + + frm.set_df_property("allowed_users", "hidden", !flag); + frm.set_df_property("allowed_users", "reqd", flag); + }, + + enable_frappe_crm_data_synchronization: function (frm) { + const flag = frm.events.calculate_visiblity_flag(frm); + + if (flag) { + frappe.show_alert( + __("Allowed Users is required for data synchronization from remote Frappe CRM site.") + ); + } + + /* + make allowed_users field visible and mandatory if enable_frappe_crm_data_synchronization + is set and crm app is not installed. + */ + + frm.set_df_property("allowed_users", "hidden", !flag); + frm.set_df_property("allowed_users", "reqd", flag); + }, + + calculate_visiblity_flag: function (frm) { + const crm_sync_enabled = frm.doc.enable_frappe_crm_data_synchronization; + const is_crm_installed = cint(frappe.utils.get_installed_apps().includes("crm")); + + return crm_sync_enabled && !is_crm_installed; + }, }); diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.json b/erpnext/crm/doctype/crm_settings/crm_settings.json index 8822dd7ea02..3539da5b7cb 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.json +++ b/erpnext/crm/doctype/crm_settings/crm_settings.json @@ -120,9 +120,9 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;", "fieldname": "allowed_users", "fieldtype": "Table MultiSelect", + "hidden": 1, "label": "Allowed Users", "options": "Frappe CRM Allowed User", "permlevel": 1 @@ -139,7 +139,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-06-22 01:26:13.474915", + "modified": "2026-07-01 01:09:16.461470", "modified_by": "Administrator", "module": "CRM", "name": "CRM Settings", diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py index 7ca341adb77..379c55ae5b3 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.py +++ b/erpnext/crm/doctype/crm_settings/crm_settings.py @@ -6,6 +6,8 @@ from frappe import _ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.model.document import Document +from erpnext.crm.frappe_crm_api import is_crm_installed + class CRMSettings(Document): # begin: auto-generated types @@ -46,13 +48,16 @@ class CRMSettings(Document): ) def validate_allowed_users(self): - if self.enable_frappe_crm_data_synchronization and not self.allowed_users: + if self.enable_frappe_crm_data_synchronization and not (is_crm_installed() or self.allowed_users): frappe.throw( _( "Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site." ) ) + if self.enable_frappe_crm_data_synchronization and is_crm_installed() and self.allowed_users: + frappe.throw(_("Allowed Users is not required as Frappe CRM is already installed on the site.")) + def before_save(self): self.clear_allowed_users() diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index 5db9b7dc652..ba2d7331a3d 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -150,7 +150,9 @@ def create_customer(customer_data=None): for field in CUSTOMER_ALLOWED_FIELDS: if customer_data.get(field) is not None: customer.set(field, customer_data.get(field)) - customer.insert(ignore_permissions=True) + + # If CRM is installed on the site, User Permission cannot be ignored while saving Customer Records. + customer.insert(ignore_permissions=not is_crm_installed()) customer_name = customer.name contacts = json.loads(customer_data.get("contacts")) @@ -169,6 +171,10 @@ def validate_frappe_crm_sync(): _("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.") ) + # Skip allowed_users validation if CRM is installed on the site. + if is_crm_installed(): + return + allowed_users = [d.user for d in CRMSettings.allowed_users] if frappe.session.user not in allowed_users: @@ -178,3 +184,7 @@ def validate_frappe_crm_sync(): ), exc=frappe.PermissionError, ) + + +def is_crm_installed(): + return "crm" in frappe.get_installed_apps() From 0a5daeed57af3614b5fbd067297275832861ddb1 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Wed, 1 Jul 2026 03:52:08 +0530 Subject: [PATCH 66/94] feat(crm_settings): auto-update crm sync settings on frappe crm install and uninstall (cherry picked from commit c86aa2d6fe96b7087bb78d201131f2933201e7d4) --- erpnext/crm/frappe_crm_api.py | 23 +++++++++++++++++++++++ erpnext/hooks.py | 3 +++ erpnext/setup/install.py | 16 ++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index ba2d7331a3d..5b52b83040a 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -1,5 +1,6 @@ import json +import click import frappe from frappe import _ @@ -188,3 +189,25 @@ def validate_frappe_crm_sync(): def is_crm_installed(): return "crm" in frappe.get_installed_apps() + + +def remove_allowed_users_on_crm_install(): + CRMSettings = frappe.get_single("CRM Settings") + + if not CRMSettings.enable_frappe_crm_data_synchronization: + return + + CRMSettings.allowed_users = [] + CRMSettings.save() + click.secho("Removed Allowed Users from CRM Settings.") + + +def disable_frappe_crm_data_synchronization_on_crm_uninstall(): + CRMSettings = frappe.get_single("CRM Settings") + + if not CRMSettings.enable_frappe_crm_data_synchronization: + return + + CRMSettings.enable_frappe_crm_data_synchronization = 0 + CRMSettings.save() + click.secho("Enable Frappe CRM Data Synchronization on CRM Settings has been disabled.") diff --git a/erpnext/hooks.py b/erpnext/hooks.py index a1c64b60377..118f047f19c 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -61,6 +61,9 @@ before_install = [ ] after_install = "erpnext.setup.install.after_install" +after_app_install = "erpnext.setup.install.after_app_install" +after_app_uninstall = "erpnext.setup.install.after_app_uninstall" + boot_session = "erpnext.startup.boot.boot_session" notification_config = "erpnext.startup.notifications.get_notification_config" get_help_messages = "erpnext.utilities.activation.get_help_messages" diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 03fc31b253e..89e2a4c89ee 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -367,3 +367,19 @@ DEFAULT_ROLE_PROFILES = { "Purchase Manager", ], } + + +def after_app_install(app_name=None): + if app_name == "crm": + from erpnext.crm.frappe_crm_api import remove_allowed_users_on_crm_install + + remove_allowed_users_on_crm_install() + + +def after_app_uninstall(app_name=None): + if app_name == "crm": + from erpnext.crm.frappe_crm_api import disable_frappe_crm_data_synchronization_on_crm_uninstall + + disable_frappe_crm_data_synchronization_on_crm_uninstall() + + frappe.db.commit() # nosemgrep From 983ec5660c2d632d7ddaeb2ead2afc7a3f7879f7 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Thu, 9 Jul 2026 12:23:19 +0530 Subject: [PATCH 67/94] fix(`frappe_crm_api`): handle failure for `after_app_install` and `after_app_uninstall` (cherry picked from commit 2de423e225e0331eed2a60a4ad2f34b4de1283e8) --- erpnext/crm/frappe_crm_api.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index 5b52b83040a..ddd974663dc 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -192,22 +192,28 @@ def is_crm_installed(): def remove_allowed_users_on_crm_install(): - CRMSettings = frappe.get_single("CRM Settings") + try: + CRMSettings = frappe.get_single("CRM Settings") - if not CRMSettings.enable_frappe_crm_data_synchronization: - return + if not CRMSettings.enable_frappe_crm_data_synchronization: + return - CRMSettings.allowed_users = [] - CRMSettings.save() - click.secho("Removed Allowed Users from CRM Settings.") + CRMSettings.allowed_users = [] + CRMSettings.save() + click.secho("Removed 'Allowed Users' from CRM Settings.") + except Exception: + click.secho("'Allowed Users' from CRM Settings couldn't be cleared.") def disable_frappe_crm_data_synchronization_on_crm_uninstall(): - CRMSettings = frappe.get_single("CRM Settings") + try: + CRMSettings = frappe.get_single("CRM Settings") - if not CRMSettings.enable_frappe_crm_data_synchronization: - return + if not CRMSettings.enable_frappe_crm_data_synchronization: + return - CRMSettings.enable_frappe_crm_data_synchronization = 0 - CRMSettings.save() - click.secho("Enable Frappe CRM Data Synchronization on CRM Settings has been disabled.") + CRMSettings.enable_frappe_crm_data_synchronization = 0 + CRMSettings.save() + click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings has been disabled.") + except Exception: + click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings could not be disabled.") From 09247738560777e7c84e86783292d95528d02a29 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Thu, 9 Jul 2026 12:24:40 +0530 Subject: [PATCH 68/94] chore: patch to clear out allowed users on `crm_settings` if frappe crm is installed on the site (cherry picked from commit 0f987d7135979f8671bd9490921b766a332ef333) --- erpnext/patches.txt | 1 + ...crm_settings_handle_allowed_users_for_frappe_crm.py | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 erpnext/patches/v16_0/crm_settings_handle_allowed_users_for_frappe_crm.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index fc9313f4bf7..f28e3e1840f 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -440,3 +440,4 @@ erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600) erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield +erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm \ No newline at end of file diff --git a/erpnext/patches/v16_0/crm_settings_handle_allowed_users_for_frappe_crm.py b/erpnext/patches/v16_0/crm_settings_handle_allowed_users_for_frappe_crm.py new file mode 100644 index 00000000000..166cd5c66f8 --- /dev/null +++ b/erpnext/patches/v16_0/crm_settings_handle_allowed_users_for_frappe_crm.py @@ -0,0 +1,10 @@ +import frappe + + +def execute(): + from erpnext.crm.frappe_crm_api import is_crm_installed, remove_allowed_users_on_crm_install + + if not is_crm_installed(): + return + + remove_allowed_users_on_crm_install() From 55e0d106c9380f7477ffc54a693a17c7c377130c Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:00:19 +0530 Subject: [PATCH 69/94] fix: added permission checks on various whitelisted functions (backport #56745) (#56945) Co-authored-by: Diptanil Saha --- .../doctype/bank_account/bank_account.py | 10 ++- .../doctype/payment_entry/payment_entry.py | 5 +- erpnext/accounts/party.py | 78 +++++++++++-------- 3 files changed, 60 insertions(+), 33 deletions(-) diff --git a/erpnext/accounts/doctype/bank_account/bank_account.py b/erpnext/accounts/doctype/bank_account/bank_account.py index aced4258526..30dbb013be8 100644 --- a/erpnext/accounts/doctype/bank_account/bank_account.py +++ b/erpnext/accounts/doctype/bank_account/bank_account.py @@ -115,7 +115,7 @@ def get_party_bank_account(party_type, party): ) -def get_default_company_bank_account(company, party_type, party): +def get_default_company_bank_account(company, party_type, party, ignore_permissions=True): default_company_bank_account = frappe.db.get_value(party_type, party, "default_bank_account") if default_company_bank_account: if company != frappe.get_cached_value("Bank Account", default_company_bank_account, "company"): @@ -126,6 +126,14 @@ def get_default_company_bank_account(company, party_type, party): "Bank Account", {"company": company, "is_company_account": 1, "is_default": 1} ) + if not ignore_permissions: + default_company_bank_account = ( + default_company_bank_account + if default_company_bank_account + and frappe.get_cached_doc("Bank Account", default_company_bank_account).has_permission("select") + else None + ) + return default_company_bank_account diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 148dd4edcc5..815bd0fd6f2 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2691,6 +2691,9 @@ def get_party_details(company, party_type, party, date, cost_center=None): if not frappe.db.exists(party_type, party): frappe.throw(_("{0} {1} does not exist").format(_(party_type), party)) + ptype = "select" if frappe.only_has_select_perm(party_type) else "read" + frappe.has_permission(party_type, ptype, party, throw=True) + party_account = get_party_account(party_type, party, company) account_currency = get_account_currency(party_account) account_balance = ( @@ -2707,7 +2710,7 @@ def get_party_details(company, party_type, party, date, cost_center=None): ) if party_type in ["Customer", "Supplier"]: party_bank_account = get_party_bank_account(party_type, party) - bank_account = get_default_company_bank_account(company, party_type, party) + bank_account = get_default_company_bank_account(company, party_type, party, ignore_permissions=False) return { "party_account": party_account, diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index b39c5a7dc62..2edba2e5c5d 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -431,6 +431,17 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals Will first search in party (Customer / Supplier) record, if not found, will search in group (Customer Group / Supplier Group), finally will return default.""" + + def account_perm_check(account): + ptype = "select" if frappe.only_has_select_perm("Account") else "read" + if frappe.has_permission("Account", ptype, account): + return + + # Using custom message to prevent data leak in case of `apply_strict_permission` is enabled. + frappe.throw( + _("User don't have permissions to select/read this account."), exc=frappe.PermissionError + ) + if not party_type: frappe.throw(_("Party Type is mandatory")) if not company: @@ -441,46 +452,51 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals "default_receivable_account" if party_type == "Customer" else "default_payable_account" ) - return frappe.get_cached_value("Company", company, default_account_name) - - account = frappe.db.get_value( - "Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account" - ) - - if not account and party_type in ["Customer", "Supplier"]: - party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group" - group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype)) + account = frappe.get_cached_value("Company", company, default_account_name) + else: account = frappe.db.get_value( - "Party Account", - {"parenttype": party_group_doctype, "parent": group, "company": company}, - "account", + "Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account" ) - if not account and party_type in ["Customer", "Supplier"]: - default_account_name = ( - "default_receivable_account" if party_type == "Customer" else "default_payable_account" - ) - account = frappe.get_cached_value("Company", company, default_account_name) + if not account and party_type in ["Customer", "Supplier"]: + party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group" + group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype)) + account = frappe.db.get_value( + "Party Account", + {"parenttype": party_group_doctype, "parent": group, "company": company}, + "account", + ) - existing_gle_currency = get_party_gle_currency(party_type, party, company) - if existing_gle_currency: - if account: - account_currency = frappe.get_cached_value("Account", account, "account_currency") - if (account and account_currency != existing_gle_currency) or not account: - account = get_party_gle_account(party_type, party, company) + if not account and party_type in ["Customer", "Supplier"]: + default_account_name = ( + "default_receivable_account" if party_type == "Customer" else "default_payable_account" + ) + account = frappe.get_cached_value("Company", company, default_account_name) - # get default account on the basis of party type - if not account: - account_type = frappe.get_cached_value("Party Type", party_type, "account_type") - default_account_name = "default_" + account_type.lower() + "_account" - account = frappe.get_cached_value("Company", company, default_account_name) + existing_gle_currency = get_party_gle_currency(party_type, party, company) + if existing_gle_currency: + if account: + account_currency = frappe.get_cached_value("Account", account, "account_currency") + if (account and account_currency != existing_gle_currency) or not account: + account = get_party_gle_account(party_type, party, company) - if include_advance and party_type in ["Customer", "Supplier", "Student"]: + # get default account on the basis of party type + if not account: + account_type = frappe.get_cached_value("Party Type", party_type, "account_type") + default_account_name = "default_" + account_type.lower() + "_account" + account = frappe.get_cached_value("Company", company, default_account_name) + + if account: + account_perm_check(account) + + if include_advance and party and party_type in ["Customer", "Supplier", "Student"]: advance_account = get_party_advance_account(party_type, party, company) + if advance_account: + account_perm_check(advance_account) return [account, advance_account] - else: - return [account] + + return [account] return account From 99bdef6bafca36b456eab98334dbcd5da63a2ae3 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 6 Jul 2026 14:15:29 +0530 Subject: [PATCH 70/94] fix: rename variant item_code/item_name when attribute abbreviation changes Item Attribute abbreviations only got baked into a variant's item_code and item_name at creation time (make_variant_item_code returns early once item_code is set). Renaming an abbreviation afterwards left every existing variant stuck with the stale code, silently out of sync with its own attribute. Detect abbreviation renames on Item Attribute save, find every variant using the affected value, and rebuild+rename its item_code via frappe.rename_doc so linked records follow along. item_name is rebuilt in lockstep from the template's item_name, even if it had since been customized, since both fields are meant to be derived from the same abbreviation. (cherry picked from commit c0cfe5f363fa04da18c5efcbe57d7c4e50a5a9a5) --- erpnext/controllers/item_variant.py | 62 +++++++++++++++++++ .../doctype/item_attribute/item_attribute.py | 2 + 2 files changed, 64 insertions(+) diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index c2c620950af..a05ff7f3b7c 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -177,6 +177,68 @@ def update_variant_attribute_values(item_attribute): frappe.flags.attribute_values = None +def get_attribute_abbr_renames(item_attribute): + """Return the set of (current) attribute values whose abbreviation was renamed.""" + if item_attribute.numeric_values: + return set() + + db_value = item_attribute.get_doc_before_save() + if not db_value: + return set() + + old_abbrs = {d.name: d.abbr for d in db_value.item_attribute_values} + changed_values = set() + + for row in item_attribute.item_attribute_values: + if row.name in old_abbrs and old_abbrs[row.name] != row.abbr: + changed_values.add(row.attribute_value) + + return changed_values + + +def update_variant_item_codes_for_abbr_renames(item_attribute): + """Rebuild item_code/item_name of variant Items affected by a renamed Item Attribute abbreviation.""" + changed_values = get_attribute_abbr_renames(item_attribute) + if not changed_values: + return + + item_variant_table = frappe.qb.DocType("Item Variant Attribute") + variant_names = ( + frappe.qb.from_(item_variant_table) + .select(item_variant_table.parent) + .where(item_variant_table.attribute == item_attribute.name) + .where(item_variant_table.attribute_value.isin(list(changed_values))) + .distinct() + .run(pluck=True) + ) + + for variant_name in variant_names: + rename_variant_item_code(variant_name) + + +def rename_variant_item_code(variant_name): + """Recompute a variant's item_code/item_name from its template and current attribute abbreviations, + renaming the Item if it has changed.""" + variant = frappe.get_doc("Item", variant_name) + if not variant.variant_of: + return + + template = frappe.get_cached_doc("Item", variant.variant_of) + + new_code = frappe._dict({"item_code": None, "item_name": None, "attributes": variant.attributes}) + make_variant_item_code(template.item_code, template.item_name, new_code) + + if not new_code.item_code or new_code.item_code == variant.item_code: + return + + frappe.rename_doc("Item", variant.item_code, new_code.item_code) + + # Keep item_name in lockstep with item_code: both are derived from the same abbreviation, so + # item_name is always rebuilt here too, even if it had since been customized away from that pattern. + if new_code.item_name and new_code.item_name != variant.item_name: + frappe.db.set_value("Item", new_code.item_code, "item_name", new_code.item_name) + + def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True): allow_rename_attribute_value = frappe.db.get_single_value( "Item Variant Settings", "allow_rename_attribute_value" diff --git a/erpnext/stock/doctype/item_attribute/item_attribute.py b/erpnext/stock/doctype/item_attribute/item_attribute.py index 14d2c6a4f12..09e1d56ffdd 100644 --- a/erpnext/stock/doctype/item_attribute/item_attribute.py +++ b/erpnext/stock/doctype/item_attribute/item_attribute.py @@ -10,6 +10,7 @@ from frappe.utils import flt from erpnext.controllers.item_variant import ( InvalidItemAttributeValueError, update_variant_attribute_values, + update_variant_item_codes_for_abbr_renames, validate_is_incremental, validate_item_attribute_value, ) @@ -49,6 +50,7 @@ class ItemAttribute(Document): def on_update(self): update_variant_attribute_values(self) + update_variant_item_codes_for_abbr_renames(self) self.validate_exising_items() self.set_enabled_disabled_in_items() From f119080eb384a4e8aa49fb6beb7a97c97a0c900b Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 6 Jul 2026 14:15:40 +0530 Subject: [PATCH 71/94] test: cover variant item_code/item_name rename on abbreviation change Add regression coverage for the new abbreviation-rename propagation: a simple item_code rename, item_name derived from a template whose item_name differs from its item_code, and a manually customized item_name getting rebuilt rather than left stale. (cherry picked from commit e718a70b2603a19fc6c9f2213be34f313ef68be2) --- erpnext/stock/doctype/item/test_item.py | 94 +++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index a2319ca9488..073c8c8be93 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -443,6 +443,100 @@ class TestItem(FrappeTestCase): "Large", ) + def test_rename_attribute_abbr_updates_variant_item_code(self): + frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1) + + variant = create_variant("_Test Variant Item", {"Test Size": "Large"}) + variant.save() + + attribute = frappe.get_doc("Item Attribute", "Test Size") + for row in attribute.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "LRG" + break + + def restore_test_size_abbr(): + doc = frappe.get_doc("Item Attribute", "Test Size") + for row in doc.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "L" + break + frappe.flags.attribute_values = None + doc.save() + + self.addCleanup(restore_test_size_abbr) + self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1)) + + frappe.flags.attribute_values = None + attribute.save() + + self.assertFalse(frappe.db.exists("Item", "_Test Variant Item-L")) + self.assertTrue(frappe.db.exists("Item", "_Test Variant Item-LRG")) + self.assertEqual( + frappe.db.get_value("Item", "_Test Variant Item-LRG", "item_name"), + "_Test Variant Item-LRG", + ) + + def test_rename_attribute_abbr_updates_variant_item_name_from_template_name(self): + # item_name can be derived from the template's item_name, which may differ from its + # item_code (e.g. a friendly display name vs. a SKU-style code). The variant's item_name + # must follow the abbreviation rename the same way item_code does. + frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-L", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1) + + template = frappe.get_doc("Item", "_Test Variant Item").as_dict() + template = frappe.get_doc( + { + "doctype": "Item", + "item_code": "_Test Variant Item Diff", + "item_name": "Test Variant Friendly Name", + "item_group": template.item_group, + "stock_uom": template.stock_uom, + "has_variants": 1, + "attributes": [{"attribute": "Test Size"}], + } + ) + template.insert() + self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1)) + + variant = create_variant("_Test Variant Item Diff", {"Test Size": "Large"}) + variant.save() + self.assertEqual(variant.item_code, "_Test Variant Item Diff-L") + self.assertEqual(variant.item_name, "Test Variant Friendly Name-L") + + # even a manually customized item_name (unrelated to the auto-generated pattern) must be + # rebuilt on abbreviation rename, since item_code and item_name are meant to stay in lockstep. + frappe.db.set_value("Item", variant.name, "item_name", "Custom Friendly Large Shirt Name") + + attribute = frappe.get_doc("Item Attribute", "Test Size") + for row in attribute.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "LRG" + break + + def restore_test_size_abbr(): + doc = frappe.get_doc("Item Attribute", "Test Size") + for row in doc.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "L" + break + frappe.flags.attribute_values = None + doc.save() + + self.addCleanup(restore_test_size_abbr) + self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1)) + + frappe.flags.attribute_values = None + attribute.save() + + self.assertFalse(frappe.db.exists("Item", "_Test Variant Item Diff-L")) + self.assertEqual( + frappe.db.get_value("Item", "_Test Variant Item Diff-LRG", "item_name"), + "Test Variant Friendly Name-LRG", + ) + def test_make_item_variant(self): frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) From 989a6b507d1a51f598469c4f512b65eed2dbb15e Mon Sep 17 00:00:00 2001 From: pandiyan Date: Tue, 7 Jul 2026 12:28:21 +0530 Subject: [PATCH 72/94] perf: batch bin lookups in delivery note stock update update_current_stock() in delivery_note.py used to call frappe.db.get_value("Bin", ...) separately for every row in items and every row in packed_items - so a delivery note with 200 items and 200 packed items made 400 separate database calls on every save. now it groups item codes by warehouse and fetches bin data with one query per distinct warehouse, then assigns actual_qty/projected_qty to each row from that result - same values as before, far fewer database calls, and no cross-product over-fetch across warehouses. (cherry picked from commit 5da878d25f21c4e9d240d511cd9ca178ff67c45e) --- .../doctype/delivery_note/delivery_note.py | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 1f119e9aa50..49aa06ddd79 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -442,22 +442,34 @@ class DeliveryNote(SellingController): frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"])) def update_current_stock(self): - if self.get("_action") and self._action != "update_after_submit": - for d in self.get("items"): - d.actual_qty = frappe.db.get_value( - "Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty" - ) + if not (self.get("_action") and self._action != "update_after_submit"): + return - for d in self.get("packed_items"): - bin_qty = frappe.db.get_value( - "Bin", - {"item_code": d.item_code, "warehouse": d.warehouse}, - ["actual_qty", "projected_qty"], - as_dict=True, - ) - if bin_qty: - d.actual_qty = flt(bin_qty.actual_qty) - d.projected_qty = flt(bin_qty.projected_qty) + warehouse_item_codes = {} + for d in self.get("items") + self.get("packed_items"): + warehouse_item_codes.setdefault(d.warehouse, set()).add(d.item_code) + + if not warehouse_item_codes: + return + + bin_map = {} + for warehouse, item_codes in warehouse_item_codes.items(): + for b in frappe.get_all( + "Bin", + filters={"item_code": ["in", item_codes], "warehouse": warehouse}, + fields=["item_code", "actual_qty", "projected_qty"], + ): + bin_map[(b.item_code, warehouse)] = b + + for d in self.get("items"): + bin_data = bin_map.get((d.item_code, d.warehouse)) + d.actual_qty = bin_data.actual_qty if bin_data else None + + for d in self.get("packed_items"): + bin_data = bin_map.get((d.item_code, d.warehouse)) + if bin_data: + d.actual_qty = flt(bin_data.actual_qty) + d.projected_qty = flt(bin_data.projected_qty) def on_submit(self): self.validate_packed_qty() From de0c75bcbadbe8b97036b257e47ec21d2bed686d Mon Sep 17 00:00:00 2001 From: Mohd Haris Date: Thu, 9 Jul 2026 18:01:17 +0530 Subject: [PATCH 73/94] fix: update BOM operations when routing is changed The routing field handler only fetched operations from the routing when the operations table was empty. When a new BOM version is created (via "New Version"), operations are copied from the source BOM, so selecting a different routing left the old operations in place - both in the form and after saving. Drop the `!frm.doc.operations.length` guard from the routing handler so that (re)selecting a routing always refetches the operations from that routing via the existing get_routing method, which clears and repopulates the operations table. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 758a837de4b7e40653e33fdf8110b988e71187c3) --- erpnext/manufacturing/doctype/bom/bom.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index e83f7e232ad..88a6c3bad66 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -441,7 +441,11 @@ frappe.ui.form.on("BOM", { }, routing(frm) { - if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) { + // Refetch operations whenever the routing is (re)selected, so that + // changing the routing - e.g. on a new BOM version copied from another + // BOM - replaces the operations with those of the newly selected routing + // instead of keeping the old ones. + if (frm.doc.routing && frm.doc.with_operations) { frappe.call({ doc: frm.doc, method: "get_routing", From fde7cd534503255ac228641d56ff411eb8508d15 Mon Sep 17 00:00:00 2001 From: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:10:43 +0530 Subject: [PATCH 74/94] fix: show only template items in Variant Of filter (cherry picked from commit 243312985030dc515e6fedf7b253af08f3c55b06) --- erpnext/stock/doctype/item/item.json | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 10c771d1415..cb5a5ac829f 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -145,6 +145,7 @@ "ignore_user_permissions": 1, "in_standard_filter": 1, "label": "Variant Of", + "link_filters": "[[\"Item\",\"has_variants\",\"=\",1]]", "options": "Item", "read_only": 1, "search_index": 1, From 902a3c5c551c4dd55d23bba687fb4c700c53ab3e Mon Sep 17 00:00:00 2001 From: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:26:06 +0530 Subject: [PATCH 75/94] fix: update modified timestamp in item.json (cherry picked from commit 54da9fc27a4df6a0511fa52cc908cd818a4a4958) # Conflicts: # erpnext/stock/doctype/item/item.json --- erpnext/stock/doctype/item/item.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index cb5a5ac829f..fbaa17a76df 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -898,7 +898,11 @@ "image_field": "image", "links": [], "make_attachments_public": 1, +<<<<<<< HEAD "modified": "2026-03-17 20:39:05.218344", +======= + "modified": "2026-07-05 23:24:45.734144", +>>>>>>> 54da9fc27a (fix: update modified timestamp in item.json) "modified_by": "Administrator", "module": "Stock", "name": "Item", From cef69b05a9c54bc5c90bab7c76aa96ca88bb90e8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 9 Jul 2026 20:44:57 +0530 Subject: [PATCH 76/94] chore: resolve conflicts --- erpnext/stock/doctype/item/item.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index fbaa17a76df..e8280bacf4a 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -898,11 +898,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, -<<<<<<< HEAD - "modified": "2026-03-17 20:39:05.218344", -======= "modified": "2026-07-05 23:24:45.734144", ->>>>>>> 54da9fc27a (fix: update modified timestamp in item.json) "modified_by": "Administrator", "module": "Stock", "name": "Item", From afa72884bb9dde6ce8aaa1078add3053ef36f09d Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 9 Jul 2026 21:33:01 +0530 Subject: [PATCH 77/94] fix(manufacturing): update work order status on partial pick-list transfer (backport #56800) Backport of frappe/erpnext#56800 (via #56836/#57001). When a Pick List transfers only some of the required raw materials, the min-fraction rule keeps material_transferred_for_manufacturing at 0, leaving the Work Order stuck at "Not Started" even though material is already in WIP. Treat any submitted pick-list-linked Material Transfer stock entry as evidence the order has started. --- .../doctype/work_order/test_work_order.py | 32 +++++++++++++++++++ .../doctype/work_order/work_order.py | 26 ++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 3d599e311bb..0d421ed4a63 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1545,6 +1545,38 @@ class TestWorkOrder(FrappeTestCase): work_order.reload() self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0) + def test_status_in_process_when_only_one_required_item_transferred(self): + """Stock Entry created from a Pick List that picked only one of the required items: + min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must + still move to In Process because material is already in WIP.""" + from erpnext.manufacturing.doctype.work_order.work_order import create_pick_list + from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry + + work_order = make_wo_order_test_record( + planned_start_date=now(), qty=2, source_warehouse="Stores - _TC" + ) + test_stock_entry.make_stock_entry( + item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0 + ) + test_stock_entry.make_stock_entry( + item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0 + ) + + pick_list = create_pick_list(work_order.name, for_qty=work_order.qty) + # pick only _Test Item; the other required item is left out of this pick list + pick_list.pick_manually = 1 + pick_list.locations = [loc for loc in pick_list.locations if loc.item_code == "_Test Item"] + pick_list.save() + pick_list.submit() + + stock_entry = frappe.get_doc(create_stock_entry(frappe.as_json(pick_list.as_dict()))) + self.assertEqual(stock_entry.fg_completed_qty, 0.0) + stock_entry.submit() + + work_order.reload() + self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0) + self.assertEqual(work_order.status, "In Process") + def test_backflushed_batch_raw_materials_based_on_transferred(self): frappe.db.set_single_value( "Manufacturing Settings", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index d6764005a80..d5f7b1dd976 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -406,7 +406,11 @@ class WorkOrder(Document): elif self.docstatus == 1: if status not in ["Closed", "Stopped"]: status = "Not Started" - if flt(self.material_transferred_for_manufacturing) > 0 or self.skip_transfer: + if ( + flt(self.material_transferred_for_manufacturing) > 0 + or self.skip_transfer + or self.has_transferred_material() + ): status = "In Process" precision = frappe.get_precision("Work Order", "produced_qty") @@ -425,6 +429,26 @@ class WorkOrder(Document): return status + def has_transferred_material(self): + """True if any raw material was transferred against this work order via a pick list + (these leave material_transferred_for_manufacturing at 0 via the min-fraction rule).""" + ste = frappe.qb.DocType("Stock Entry") + ste_child = frappe.qb.DocType("Stock Entry Detail") + qty = ( + frappe.qb.from_(ste) + .inner_join(ste_child) + .on(ste_child.parent == ste.name) + .select(Sum(ste_child.transfer_qty)) + .where( + (ste.work_order == self.name) + & (ste.docstatus == 1) + & (ste.purpose == "Material Transfer for Manufacture") + & (ste.is_return == 0) + & (ste.pick_list.isnotnull()) + ) + ).run()[0][0] + return flt(qty) > 0 + def update_work_order_qty(self): """Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order based on Stock Entry""" From 53af4d53ef1e3013bc2aa5453e7fd346cf3fa712 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 9 Jul 2026 21:47:10 +0530 Subject: [PATCH 78/94] fix: partial delivery note against pick list (#56985) --- erpnext/stock/doctype/pick_list/pick_list.py | 3 ++ .../stock/doctype/pick_list/test_pick_list.py | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 6d42f51a8d6..9cadca6e137 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1415,6 +1415,9 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): if location.sales_order != sales_order or location.product_bundle_item: continue + if flt(location.picked_qty) - flt(location.delivered_qty) <= 0: + continue + if location.sales_order_item: sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item) else: diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 83d1827794c..114da89007e 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -1016,6 +1016,45 @@ class TestPickList(FrappeTestCase): pl.reload() self.assertEqual(pl.status, "Cancelled") + def test_create_second_delivery_note_with_fully_delivered_location(self): + # When one pick list item is fully delivered by the first Delivery Note + # and another item is still pending, creating a second Delivery Note from + # the Pick List must not create a zero-qty row for the delivered item. + warehouse = "_Test Warehouse - _TC" + item_a = make_item(properties={"is_stock_item": 1}).name + item_b = make_item(properties={"is_stock_item": 1}).name + make_stock_entry(item=item_a, to_warehouse=warehouse, qty=20) + make_stock_entry(item=item_b, to_warehouse=warehouse, qty=20) + + so = make_sales_order( + item_list=[ + {"item_code": item_a, "warehouse": warehouse, "qty": 10, "rate": 100}, + {"item_code": item_b, "warehouse": warehouse, "qty": 5, "rate": 100}, + ] + ) + + pl = create_pick_list(so.name) + pl.save().submit() + + # First Delivery Note: fully deliver item_a, drop item_b. + dn1 = create_delivery_note(pl.name) + for row in list(dn1.items): + if row.item_code == item_b: + dn1.remove(row) + dn1.save().submit() + + pl.reload() + delivered = {loc.item_code: loc.delivered_qty for loc in pl.locations} + self.assertEqual(delivered[item_a], 10) + self.assertEqual(delivered[item_b], 0) + + # Second Delivery Note for the remaining item must succeed and must not + # include a zero-qty row for the already delivered item_a. + dn2 = create_delivery_note(pl.name) + self.assertEqual(len(dn2.items), 1) + self.assertEqual(dn2.items[0].item_code, item_b) + self.assertEqual(dn2.items[0].qty, 5) + def test_pick_list_validation(self): warehouse = "_Test Warehouse - _TC" item = make_item("Test Non Serialized Pick List Item", properties={"is_stock_item": 1}).name From de6a1577733e3f74200732f6164dbc44149db3e8 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 10 Jul 2026 11:36:05 +0530 Subject: [PATCH 79/94] fix: validate planned end date is not before planned start date in work order Work Order allowed saving with a Planned End Date earlier than the Planned Start Date without any validation. Add a validate_dates check that throws when the planned end date precedes the planned start date. (cherry picked from commit 2ec780cb353b74de25802b9062dca8c8c6956ed) --- erpnext/manufacturing/doctype/work_order/work_order.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index d5f7b1dd976..dd5c07c2945 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -157,6 +157,7 @@ class WorkOrder(Document): self.check_wip_warehouse_skip() self.calculate_operating_cost() self.validate_qty() + self.validate_dates() self.validate_transfer_against() self.validate_operations() self.status = self.get_status() @@ -175,6 +176,11 @@ class WorkOrder(Document): self.validate_operations_sequence() + def validate_dates(self): + if self.planned_start_date and self.planned_end_date: + if get_datetime(self.planned_end_date) < get_datetime(self.planned_start_date): + frappe.throw(_("Planned End Date cannot be before Planned Start Date")) + def validate_operations_sequence(self): if all([not op.sequence_id for op in self.operations]): for op in self.operations: From c10b86d538fcca1ad52f2f9d23986312815587ee Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 10 Jul 2026 11:41:43 +0530 Subject: [PATCH 80/94] perf: avoid per-row Warehouse doc fetches in auto reorder job get_item_warehouse_projected_qty() called frappe.get_doc("Warehouse", ...) inside the per-bin loop to walk up the warehouse hierarchy, re-fetching the same parent warehouses over and over on sites with nested warehouses. Preload the warehouse-to-parent mapping with a single query and walk it in-memory instead, cutting the DB round-trips from O(bins * hierarchy depth) to one query. (cherry picked from commit 6beb3d2509b7370e2cadc037dfc85a68490b6684) --- erpnext/stock/reorder_item.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py index 1f527e7071a..e3d60f0d0d3 100644 --- a/erpnext/stock/reorder_item.py +++ b/erpnext/stock/reorder_item.py @@ -186,6 +186,10 @@ def get_item_warehouse_projected_qty(items_to_consider): item_warehouse_projected_qty = {} items_to_consider = list(items_to_consider.keys()) + warehouse_parent_map = frappe._dict( + frappe.get_all("Warehouse", fields=["name", "parent_warehouse"], as_list=True) + ) + for item_code, warehouse, projected_qty in frappe.db.sql( """select item_code, warehouse, projected_qty from tabBin where item_code in ({}) @@ -200,16 +204,14 @@ def get_item_warehouse_projected_qty(items_to_consider): if warehouse not in item_warehouse_projected_qty.get(item_code): item_warehouse_projected_qty[item_code][warehouse] = flt(projected_qty) - warehouse_doc = frappe.get_doc("Warehouse", warehouse) + parent_warehouse = warehouse_parent_map.get(warehouse) - while warehouse_doc.parent_warehouse: - if not item_warehouse_projected_qty.get(item_code, {}).get(warehouse_doc.parent_warehouse): - item_warehouse_projected_qty.setdefault(item_code, {})[warehouse_doc.parent_warehouse] = flt( - projected_qty - ) + while parent_warehouse: + if not item_warehouse_projected_qty.get(item_code, {}).get(parent_warehouse): + item_warehouse_projected_qty.setdefault(item_code, {})[parent_warehouse] = flt(projected_qty) else: - item_warehouse_projected_qty[item_code][warehouse_doc.parent_warehouse] += flt(projected_qty) - warehouse_doc = frappe.get_doc("Warehouse", warehouse_doc.parent_warehouse) + item_warehouse_projected_qty[item_code][parent_warehouse] += flt(projected_qty) + parent_warehouse = warehouse_parent_map.get(parent_warehouse) return item_warehouse_projected_qty From 4c8a51d54d1e7a51104b59e5d155d8ac0d7cf6dd Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 2 Jul 2026 15:08:04 +0530 Subject: [PATCH 81/94] refactor: add payment ledger to ignore link (cherry picked from commit 6a4c5b60626ef78ab89d81a10ab2a8f460529c05) --- .../exchange_rate_revaluation/exchange_rate_revaluation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index 41249662624..de2a9eb7bc2 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -90,7 +90,7 @@ class ExchangeRateRevaluation(Document): ) def on_cancel(self): - self.ignore_linked_doctypes = "GL Entry" + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] @frappe.whitelist() def check_journal_entry_condition(self): From d239273ce5d7eacbb3344355bc0320f70c4cdc89 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 2 Jul 2026 17:52:03 +0530 Subject: [PATCH 82/94] refactor: reversal capability on exchange rate revaluation (cherry picked from commit a0b14c0607e466be920edbfa8987ec1d9d051161) # Conflicts: # erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py # erpnext/accounts/doctype/journal_entry/journal_entry_list.js --- .../exchange_rate_revaluation.js | 34 ++++++-- .../exchange_rate_revaluation.py | 81 ++++++++++++++++--- .../test_exchange_rate_revaluation.py | 4 +- .../journal_entry/journal_entry_list.js | 10 +++ 4 files changed, 110 insertions(+), 19 deletions(-) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js index eeda531c4d6..a2e5e54a776 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js @@ -22,17 +22,27 @@ frappe.ui.form.on("Exchange Rate Revaluation", { refresh: function (frm) { if (frm.doc.docstatus == 1) { frappe.call({ - method: "check_journal_entry_condition", + method: "check_journal_and_reversal", doc: frm.doc, callback: function (r) { if (r.message) { - frm.add_custom_button( - __("Journal Entries"), - function () { - return frm.events.make_jv(frm); - }, - __("Create") - ); + if (!r.message.journals_posted) { + frm.add_custom_button( + __("Journal Entries"), + function () { + return frm.events.make_jv(frm); + }, + __("Create") + ); + } else if (!r.message.reversals_posted) { + frm.add_custom_button( + __("Reversal Journal Entries"), + function () { + return frm.events.make_reverse_journal(frm); + }, + __("Create") + ); + } } }, }); @@ -100,6 +110,14 @@ frappe.ui.form.on("Exchange Rate Revaluation", { }, }); }, + make_reverse_journal: function (frm) { + frappe.call({ + method: "make_reverse_journal", + doc: frm.doc, + freeze: true, + freeze_message: __("Reversing Journals..."), + }); + }, }); frappe.ui.form.on("Exchange Rate Revaluation Account", { diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index de2a9eb7bc2..e993106c17b 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -7,8 +7,13 @@ from frappe import _, qb from frappe.model.document import Document from frappe.model.meta import get_field_precision from frappe.query_builder import Criterion, Order +<<<<<<< HEAD from frappe.query_builder.functions import NullIf, Sum from frappe.utils import flt, get_link_to_form +======= +from frappe.query_builder.functions import Max, NullIf, Sum +from frappe.utils import flt, get_link_to_form, nowdate +>>>>>>> a0b14c0607 (refactor: reversal capability on exchange rate revaluation) import erpnext from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on @@ -93,22 +98,28 @@ class ExchangeRateRevaluation(Document): self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] @frappe.whitelist() - def check_journal_entry_condition(self): + def check_journal_and_reversal(self): exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account() + journals_posted = False + reversals_posted = False + + je = qb.DocType("Journal Entry") jea = qb.DocType("Journal Entry Account") journals = ( - qb.from_(jea) - .select(jea.parent) + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) .distinct() .where( (jea.reference_type == "Exchange Rate Revaluation") & (jea.reference_name == self.name) & (jea.docstatus == 1) + & (je.reversal_of.isnull()) # omit journals that have reversals ) - .run() + .run(pluck="name") ) - if journals: gle = qb.DocType("GL Entry") total_amt = ( @@ -123,12 +134,31 @@ class ExchangeRateRevaluation(Document): .run() ) - if total_amt and total_amt[0][0] != self.total_gain_loss: - return True + if total_amt and total_amt[0][0] == self.total_gain_loss: + journals_posted = True else: - return False + journals_posted = False - return True + # reverse journals + reverse_journals = ( + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) + .where( + (jea.reference_type == "Exchange Rate Revaluation") + & (jea.reference_name == self.name) + & (jea.docstatus == 1) + & (je.reversal_of.notnull()) + ) + .run(pluck="name") + ) + if reverse_journals: + reversals_posted = True + else: + reversals_posted = False + + return {"journals_posted": journals_posted, "reversals_posted": reversals_posted} def fetch_and_calculate_accounts_data(self): accounts = self.get_accounts_data() @@ -342,6 +372,7 @@ class ExchangeRateRevaluation(Document): @frappe.whitelist() def make_jv_entries(self): + frappe.has_permission("Journal Entry", "write", throw=True) zero_balance_jv = self.make_jv_for_zero_balance() if zero_balance_jv: frappe.msgprint( @@ -571,6 +602,38 @@ class ExchangeRateRevaluation(Document): journal_entry.save() return journal_entry + @frappe.whitelist() + def make_reverse_journal(self): + frappe.has_permission("Journal Entry", "write", throw=True) + je = qb.DocType("Journal Entry") + jea = qb.DocType("Journal Entry Account") + journals = ( + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) + .distinct() + .where( + (jea.reference_type == "Exchange Rate Revaluation") + & (jea.reference_name == self.name) + & (jea.docstatus == 1) + & (je.reversal_of.isnull()) # omit journals that have reversals + ) + .run(pluck="name") + ) + if journals: + from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry + + for x in journals: + reversal = make_reverse_journal_entry(x) + reversal.posting_date = nowdate() + reversal.submit() + frappe.msgprint( + _("Revaluation journal for {0} has been created: {1}").format( + frappe.bold(x), get_link_to_form("Journal Entry", reversal.name) + ) + ) + def calculate_exchange_rate_using_last_gle(company, account, party_type, party): """ diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py index 3eef6ab3832..15d030b45d5 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py @@ -130,7 +130,7 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase): err = err.save().submit() # Create JV for ERR - self.assertTrue(err.check_journal_entry_condition()) + self.assertTrue(err.check_journal_and_reversal()) err_journals = err.make_jv_entries() je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) je = je.submit() @@ -213,7 +213,7 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase): err = err.save().submit() # Create JV for ERR - self.assertTrue(err.check_journal_entry_condition()) + self.assertTrue(err.check_journal_and_reversal()) err_journals = err.make_jv_entries() je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) je = je.submit() diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js index 9d6e87392e5..d444bb7d3d1 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js @@ -1,4 +1,5 @@ frappe.listview_settings["Journal Entry"] = { +<<<<<<< HEAD add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark"], get_indicator: function (doc) { if (doc.docstatus == 0) { @@ -7,6 +8,15 @@ frappe.listview_settings["Journal Entry"] = { return [__("Cancelled", "grey", "docstatus,=,2")]; } else { return [__(doc.voucher_type), "blue", "voucher_type,=," + doc.voucher_type]; +======= + add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark", "reversal_of"], + get_indicator: function (doc) { + if (doc.docstatus === 1) { + if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") { + return [__("Reversal Of Exchange Rate Revaluation"), "blue"]; + } + return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`]; +>>>>>>> a0b14c0607 (refactor: reversal capability on exchange rate revaluation) } }, }; From 3c18b172a49d0e8241a5c9e1615bb25a559e0a7d Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 9 Jul 2026 13:09:29 +0530 Subject: [PATCH 83/94] refactor: handle reverse ERR journals in AR / AP report (cherry picked from commit 68382420637e4492be2bb52c156cd1222b34fa80) --- .../report/accounts_receivable/accounts_receivable.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index e83311647b2..42b3991194b 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -263,10 +263,12 @@ class ReceivablePayableReport: # Build and use a separate row for Employee Advances. # This allows Payments or Journals made against Emp Advance to be processed. - if ( - not row - and ple.against_voucher_type == "Employee Advance" - and self.filters.handle_employee_advances + if not row and ( + (ple.against_voucher_type == "Employee Advance" and self.filters.handle_employee_advances) + or ( + ple.against_voucher_type == "Exchange Rate Revaluation" + and self.filters.for_revaluation_journals + ) ): _d = self.build_voucher_dict(ple) _d.voucher_type = ple.against_voucher_type From 6b022a5a7e8a8a08bab7c89f66a9ff6a5d64e764 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:20:32 +0000 Subject: [PATCH 84/94] fix: for purchases do voucher based reposting (backport #56601) (#56607) * fix: for purchases do voucher based reposting (#56601) (cherry picked from commit 5523c15ab8bfc54d92832f93b191fd69680297a9) * chore: fix type hints * chore: fix test case * chore: fix linters issue --------- Co-authored-by: rohitwaghchaure --- .../stock_and_account_value_comparison.py | 46 ++++++++++++++- ...test_stock_and_account_value_comparison.py | 57 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py diff --git a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py index b83e46012cc..ec02318ce71 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py @@ -171,14 +171,20 @@ def get_columns(filters): @frappe.whitelist() -def create_reposting_entries(rows, company): +def create_reposting_entries(rows: str | list, company: str): if isinstance(rows, str): rows = parse_json(rows) entries = [] item_wh = frappe._dict() - vouchers = [row.get("voucher_no") for row in rows] + vouchers = [ + row.get("voucher_no") + for row in rows + if row.get("voucher_type") not in ["Purchase Receipt", "Purchase Invoice"] + ] + repost_based_on_transaction(rows, company, entries) + sles = get_stock_ledgers(vouchers) for sle in sles: key = (sle.item_code, sle.warehouse) @@ -211,3 +217,39 @@ def create_reposting_entries(rows, company): if entries: entries = ", ".join(entries) frappe.msgprint(_("Reposting entries created: {0}").format(entries)) + + +def repost_based_on_transaction(rows, company=None, entries=None): + if entries is None: + entries = [] + + duplicate_vouchers = set() + for row in rows: + if ( + row.get("voucher_type") == "Purchase Invoice" + and frappe.get_cached_value("Purchase Invoice", row.get("voucher_no"), "update_stock") == 0 + ): + continue + + if row.get("voucher_type") in ["Purchase Receipt", "Purchase Invoice"]: + voucher_key = (row.get("voucher_type"), row.get("voucher_no")) + if voucher_key in duplicate_vouchers: + continue + + duplicate_vouchers.add(voucher_key) + doc = frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "based_on": "Transaction", + "status": "Queued", + "voucher_type": row.get("voucher_type"), + "voucher_no": row.get("voucher_no"), + "posting_date": row.get("posting_date"), + "posting_time": row.get("posting_time"), + "company": company, + "allow_nagative_stock": 1, + "recalculate_valuation_rate": 1, + } + ).submit() + + entries.append(get_link_to_form("Repost Item Valuation", doc.name)) diff --git a/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py new file mode 100644 index 00000000000..66120a56b79 --- /dev/null +++ b/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py @@ -0,0 +1,57 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import today + +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt +from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import ( + create_reposting_entries, + execute, +) + +PI_COMPANY = "_Test Company with perpetual inventory" +PI_STORES = "Stores - TCP1" + + +class TestStockAndAccountValueComparison(FrappeTestCase): + def test_purchase_voucher_reposted_transaction_based(self): + # A Purchase Receipt whose GL entries are missing must surface in the report and, when reposted + # from it, be reposted Transaction-based (so its own GL is regenerated) rather than the slower + # Item-and-Warehouse based reposting. + item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name + + pr = make_purchase_receipt(item_code=item, company=PI_COMPANY, warehouse=PI_STORES, qty=5, rate=100) + + # Simulate the out-of-sync state: stock ledger exists but the accounting ledger does not. + frappe.db.delete("GL Entry", {"voucher_type": "Purchase Receipt", "voucher_no": pr.name}) + + # The receipt now shows up in the comparison report (stock value 500 vs account value 0). + filters = frappe._dict(company=PI_COMPANY, as_on_date=today()) + _columns, data = execute(filters) + + row = next((d for d in data if d.get("voucher_no") == pr.name), None) + self.assertIsNotNone(row, "Out-of-sync Purchase Receipt should appear in the report") + self.assertEqual(row.get("voucher_type"), "Purchase Receipt") + + # Repost from the report. + create_reposting_entries([row], PI_COMPANY) + + # A Transaction-based Repost Item Valuation must have been created for this voucher... + transaction_rivs = frappe.get_all( + "Repost Item Valuation", + filters={"voucher_no": pr.name, "voucher_type": "Purchase Receipt"}, + fields=["name", "based_on"], + ) + + self.assertTrue(transaction_rivs, "Expected a Repost Item Valuation for the Purchase Receipt") + self.assertTrue(all(riv.based_on == "Transaction" for riv in transaction_rivs)) + + # ...and no Item-and-Warehouse based reposting should have been created for this item. + item_wh_rivs = frappe.get_all( + "Repost Item Valuation", + filters={"based_on": "Item and Warehouse", "item_code": item}, + ) + self.assertFalse(item_wh_rivs, "Purchase vouchers must not be reposted Item-and-Warehouse based") From 0f688a184199a5703d8aca0f37f1f708e4a19640 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 10 Jul 2026 10:55:39 +0530 Subject: [PATCH 85/94] refactor(test): for reverse journals as well (cherry picked from commit 65775e59a1bc5fcb114db57a278bd8cc86c071c5) # Conflicts: # erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py --- .../test_exchange_rate_revaluation.py | 151 +++++++++++++++++- 1 file changed, 149 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py index 15d030b45d5..a132ff30cf4 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py @@ -130,7 +130,8 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase): err = err.save().submit() # Create JV for ERR - self.assertTrue(err.check_journal_and_reversal()) + ret = err.check_journal_and_reversal() + self.assertFalse(ret.get("journals_posted")) err_journals = err.make_jv_entries() je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) je = je.submit() @@ -213,7 +214,8 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase): err = err.save().submit() # Create JV for ERR - self.assertTrue(err.check_journal_and_reversal()) + ret = err.check_journal_and_reversal() + self.assertFalse(ret.get("journals_posted")) err_journals = err.make_jv_entries() je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) je = je.submit() @@ -287,3 +289,148 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase): for key, _val in expected_data.items(): self.assertEqual(expected_data.get(key), account_details.get(key)) +<<<<<<< HEAD +======= + + @ERPNextTestSuite.change_settings( + "Accounts Settings", + {"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0}, + ) + def test_05_revaluation_journal_reversal(self): + """ + Test reversing of revaluation journals + """ + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debtors_usd, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + price_list_rate=100, + do_not_submit=1, + ) + si.currency = "USD" + si.conversion_rate = 80 + si.save().submit() + + err = frappe.new_doc("Exchange Rate Revaluation") + err.company = self.company + err.posting_date = today() + err.fetch_and_calculate_accounts_data() + self.assertEqual(len(err.accounts), 1) + err.save().submit() + + gain_loss_account = err.get_for_unrealized_gain_loss_account() + usd_account = err.accounts[0].account + old_balance = err.accounts[0].balance_in_base_currency + new_balance = err.accounts[0].new_balance_in_base_currency + total_gain_loss = err.total_gain_loss + + # Create JV for ERR + ret = err.check_journal_and_reversal() + self.assertFalse(ret.get("journals_posted")) + err_journals = err.make_jv_entries() + je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv")) + je = je.submit() + + je.reload() + self.assertEqual(je.voucher_type, "Exchange Rate Revaluation") + self.assertEqual(len(je.accounts), 3) + expected = [ + (usd_account, new_balance, 0.0, 100.0, 0.0), + (usd_account, 0.0, old_balance, 0.0, 100.0), + (gain_loss_account, 0.0, total_gain_loss, 0.0, total_gain_loss), + ] + actual = [] + for acc in je.accounts: + actual.append( + ( + acc.account, + acc.debit, + acc.credit, + acc.debit_in_account_currency, + acc.credit_in_account_currency, + ) + ) + self.assertEqual(expected, actual) + + # Assert reversals are not posted + ret = err.check_journal_and_reversal() + self.assertTrue(ret.get("journals_posted")) + self.assertFalse(ret.get("reversals_posted")) + + err.make_reverse_journal() + ret = err.check_journal_and_reversal() + self.assertTrue(ret.get("journals_posted")) + self.assertTrue(ret.get("reversals_posted")) + + reverse_jv = frappe.db.get_all( + "Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name" + ) + self.assertIsNotNone(reverse_jv) + + +class TestExchangeRateRevaluationValidation(ERPNextTestSuite): + """Validation and gain/loss calculation paths, exercised on the document directly + so they don't need the multi-currency GL setup the integration tests above build.""" + + def setUp(self): + frappe.set_user("Administrator") + self.company = "_Test Company" + + def _revaluation_with_rows(self, rows, rounding_loss_allowance=0.05): + doc = frappe.new_doc("Exchange Rate Revaluation") + doc.company = self.company + doc.posting_date = today() + doc.rounding_loss_allowance = rounding_loss_allowance + for row in rows: + doc.append("accounts", row) + return doc + + def test_rounding_loss_allowance_must_be_between_0_and_1(self): + for bad in (-0.1, 1, 1.5): + doc = self._revaluation_with_rows([], rounding_loss_allowance=bad) + self.assertRaises(frappe.ValidationError, doc.validate) + # values inside [0, 1) are accepted, at the lower bound and mid-range + for good in (0.0, 0.5): + self._revaluation_with_rows([], rounding_loss_allowance=good).validate() + + def test_gain_loss_computed_and_split_by_zero_balance(self): + doc = self._revaluation_with_rows( + [ + # open (unbooked) row: base balance moved 1000 -> 1100, a 100 gain + {"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100}, + # already-settled (zero_balance) row carries a booked loss of 40 + {"zero_balance": 1, "gain_loss": -40}, + ] + ) + doc.validate() + + # gain_loss is derived only for open rows; the zero-balance row keeps its value + self.assertEqual(doc.accounts[0].gain_loss, 100) + self.assertEqual(doc.gain_loss_unbooked, 100) + self.assertEqual(doc.gain_loss_booked, -40) + self.assertEqual(doc.total_gain_loss, 60) + + def test_before_submit_drops_rows_without_gain_loss(self): + doc = self._revaluation_with_rows( + [ + {"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100}, + {"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500}, + ] + ) + doc.validate() # second row nets to a 0 gain_loss + doc.remove_accounts_without_gain_loss() + self.assertEqual(len(doc.accounts), 1) + self.assertEqual(doc.accounts[0].gain_loss, 100) + + def test_before_submit_requires_at_least_one_gain_loss_row(self): + doc = self._revaluation_with_rows( + [{"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500}] + ) + doc.validate() + self.assertRaises(frappe.ValidationError, doc.remove_accounts_without_gain_loss) +>>>>>>> 65775e59a1 (refactor(test): for reverse journals as well) From 40911889085158d30b11e6cbec640afd312eb660 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Fri, 10 Jul 2026 11:51:03 +0530 Subject: [PATCH 86/94] fix(stock): pick list serial batch posting date (#56957) (#57014) Pick List has no posting_date/posting_time fields, so creating or updating a Serial and Batch Bundle from a Pick List row crashed with "TypeError: combine() argument 1 must be datetime.date, not None". Fall back to today/now when the parent voucher doesn't carry its own posting date. Fixes #56951 The whitelisted add_serial_batch_ledgers only converted child_row into an attribute-accessible frappe._dict when it arrived as a JSON string, and doc's type hint only allowed Document | str. Frappe's JSON API delivers both as plain dicts (see frappe.app.make_form_dict, which parses the request body with orjson and only wraps the top-level dict, not nested values), so every real request was rejected before the handler body ever ran: first with a FrappeTypeError on doc, and once that's fixed, with an AttributeError on child_row.serial_and_batch_bundle. parse_json already wraps a plain dict in frappe._dict (and leaves a real Document instance untouched), so routing child_row through it unconditionally fixes both. (cherry picked from commit 8b3caeb5787fdfd3e32d52111ca1eda66698be00) --- .../serial_and_batch_bundle.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 4fa630fb8a8..e3428c98add 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -26,6 +26,7 @@ from frappe.utils import ( ) from frappe.utils.csvutils import build_csv_response +from erpnext.stock.doctype.purchase_receipt_item.purchase_receipt_item import PurchaseReceiptItem from erpnext.stock.serial_batch_bundle import ( BatchNoValuation, SerialNoValuation, @@ -2092,9 +2093,14 @@ def get_reference_serial_and_batch_bundle(child_row): @frappe.whitelist() -def add_serial_batch_ledgers(entries, child_row, doc, warehouse, do_not_save=False) -> object: - if isinstance(child_row, str): - child_row = frappe._dict(parse_json(child_row)) +def add_serial_batch_ledgers( + entries: list | str, + child_row: PurchaseReceiptItem | dict | str, + doc: Document | dict | str, + warehouse: str | None = None, + do_not_save: bool = False, +): + child_row = parse_json(child_row) if isinstance(entries, str): entries = parse_json(entries) @@ -2126,7 +2132,9 @@ def create_serial_batch_no_ledgers( if parent_doc.get("doctype") == "Stock Entry": warehouse = warehouse or child_row.s_warehouse or child_row.t_warehouse - posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time")) + posting_datetime = combine_datetime( + parent_doc.get("posting_date") or today(), parent_doc.get("posting_time") or nowtime() + ) doc = frappe.get_doc( { @@ -2243,7 +2251,9 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh ) doc.voucher_detail_no = child_row.name - doc.posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time")) + doc.posting_datetime = combine_datetime( + parent_doc.get("posting_date") or today(), parent_doc.get("posting_time") or nowtime() + ) doc.warehouse = warehouse or doc.warehouse doc.set("entries", []) From d2d5fae033c9d335ffa137f0c49848a0a53903cc Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 10 Jul 2026 12:01:56 +0530 Subject: [PATCH 87/94] fix: make trend report based-on and group-by column labels translatable based_wise_columns_query() and group_wise_column() built column labels as raw strings, so headers like Item, Item Name, Customer, Supplier, and Territory never went through the _() translation function and stayed in English regardless of the user's language, while period and total columns translated fine. Build these as column dicts with an explicit _()-wrapped label instead, so they're translated the same way as the rest of the report. (cherry picked from commit 015fa68fc04ff198d63cf549b6cd2be316d23134) --- erpnext/controllers/trends.py | 143 +++++++++++++++++++++++++++++----- 1 file changed, 125 insertions(+), 18 deletions(-) diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index f8e152f5299..28ff84c83fd 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -361,13 +361,24 @@ def based_wise_columns_query(based_on, trans): # based_on_cols, based_on_select, based_on_group_by, addl_tables if based_on == "Item": - based_on_details["based_on_cols"] = ["Item:Link/Item:120", "Item Name:Data:120"] + based_on_details["based_on_cols"] = [ + {"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"}, + {"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"}, + ] based_on_details["based_on_select"] = "t2.item_code, t2.item_name," based_on_details["based_on_group_by"] = "t2.item_code" based_on_details["addl_tables"] = "" elif based_on == "Item Group": - based_on_details["based_on_cols"] = ["Item Group:Link/Item Group:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Item Group"), + "fieldtype": "Link", + "options": "Item Group", + "width": 120, + "fieldname": "item_group", + } + ] based_on_details["based_on_select"] = "t2.item_group," based_on_details["based_on_group_by"] = "t2.item_group" based_on_details["addl_tables"] = "" @@ -375,32 +386,80 @@ def based_wise_columns_query(based_on, trans): elif based_on == "Customer": if trans == "Quotation": based_on_details["based_on_cols"] = [ - "Party:Link/Customer:120", - "Party Name:Data:120", - "Territory:Link/Territory:120", + { + "label": _("Party"), + "fieldtype": "Link", + "options": "Customer", + "width": 120, + "fieldname": "party", + }, + {"label": _("Party Name"), "fieldtype": "Data", "width": 120, "fieldname": "party_name"}, + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + }, ] based_on_details["based_on_select"] = "t1.party_name, t1.customer_name, t1.territory," else: based_on_details["based_on_cols"] = [ - "Customer:Link/Customer:120", - "Customer Name:Data:120", - "Territory:Link/Territory:120", + { + "label": _("Customer"), + "fieldtype": "Link", + "options": "Customer", + "width": 120, + "fieldname": "customer", + }, + { + "label": _("Customer Name"), + "fieldtype": "Data", + "width": 120, + "fieldname": "customer_name", + }, + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + }, ] based_on_details["based_on_select"] = "t1.customer, t1.customer_name, t1.territory," based_on_details["based_on_group_by"] = "t1.party_name" if trans == "Quotation" else "t1.customer" based_on_details["addl_tables"] = "" elif based_on == "Customer Group": - based_on_details["based_on_cols"] = ["Customer Group:Link/Customer Group"] + based_on_details["based_on_cols"] = [ + { + "label": _("Customer Group"), + "fieldtype": "Link", + "options": "Customer Group", + "fieldname": "customer_group", + } + ] based_on_details["based_on_select"] = "t1.customer_group," based_on_details["based_on_group_by"] = "t1.customer_group" based_on_details["addl_tables"] = "" elif based_on == "Supplier": based_on_details["based_on_cols"] = [ - "Supplier:Link/Supplier:120", - "Supplier Name:Data:120", - "Supplier Group:Link/Supplier Group:140", + { + "label": _("Supplier"), + "fieldtype": "Link", + "options": "Supplier", + "width": 120, + "fieldname": "supplier", + }, + {"label": _("Supplier Name"), "fieldtype": "Data", "width": 120, "fieldname": "supplier_name"}, + { + "label": _("Supplier Group"), + "fieldtype": "Link", + "options": "Supplier Group", + "width": 140, + "fieldname": "supplier_group", + }, ] based_on_details["based_on_select"] = "t1.supplier, t1.supplier_name, t3.supplier_group," based_on_details["based_on_group_by"] = "t1.supplier" @@ -408,26 +467,58 @@ def based_wise_columns_query(based_on, trans): based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name" elif based_on == "Supplier Group": - based_on_details["based_on_cols"] = ["Supplier Group:Link/Supplier Group:140"] + based_on_details["based_on_cols"] = [ + { + "label": _("Supplier Group"), + "fieldtype": "Link", + "options": "Supplier Group", + "width": 140, + "fieldname": "supplier_group", + } + ] based_on_details["based_on_select"] = "t3.supplier_group," based_on_details["based_on_group_by"] = "t3.supplier_group" based_on_details["addl_tables"] = ",`tabSupplier` t3" based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name" elif based_on == "Territory": - based_on_details["based_on_cols"] = ["Territory:Link/Territory:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + } + ] based_on_details["based_on_select"] = "t1.territory," based_on_details["based_on_group_by"] = "t1.territory" based_on_details["addl_tables"] = "" elif based_on == "Project": if trans in ["Sales Invoice", "Delivery Note", "Sales Order"]: - based_on_details["based_on_cols"] = ["Project:Link/Project:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Project"), + "fieldtype": "Link", + "options": "Project", + "width": 120, + "fieldname": "project", + } + ] based_on_details["based_on_select"] = "t1.project," based_on_details["based_on_group_by"] = "t1.project" based_on_details["addl_tables"] = "" elif trans in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]: - based_on_details["based_on_cols"] = ["Project:Link/Project:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Project"), + "fieldtype": "Link", + "options": "Project", + "width": 120, + "fieldname": "project", + } + ] based_on_details["based_on_select"] = "t2.project," based_on_details["based_on_group_by"] = "t2.project" based_on_details["addl_tables"] = "" @@ -435,7 +526,15 @@ def based_wise_columns_query(based_on, trans): frappe.throw(_("Project-wise data is not available for Quotation")) based_on_details["based_on_select"] += "t4.default_currency as currency," - based_on_details["based_on_cols"].append("Currency:Link/Currency:120") + based_on_details["based_on_cols"].append( + { + "label": _("Currency"), + "fieldtype": "Link", + "options": "Currency", + "width": 120, + "fieldname": "currency", + } + ) based_on_details["addl_tables"] += ", `tabCompany` t4" based_on_details["addl_tables_relational_cond"] = ( based_on_details.get("addl_tables_relational_cond", "") + " and t1.company = t4.name" @@ -446,6 +545,14 @@ def based_wise_columns_query(based_on, trans): def group_wise_column(group_by): if group_by: - return [group_by + ":Link/" + group_by + ":120"] + return [ + { + "label": _(group_by), + "fieldtype": "Link", + "options": group_by, + "width": 120, + "fieldname": frappe.scrub(group_by), + } + ] else: return [] From c89f9720bca79de8079a470c7c28ce0ba9b4115b Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Fri, 10 Jul 2026 11:53:46 +0530 Subject: [PATCH 88/94] chore: resolve conflicts --- .../exchange_rate_revaluation.py | 7 +- .../test_exchange_rate_revaluation.py | 67 +------------------ .../journal_entry/journal_entry_list.js | 11 +-- 3 files changed, 4 insertions(+), 81 deletions(-) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index e993106c17b..b87e8e00951 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -7,13 +7,8 @@ from frappe import _, qb from frappe.model.document import Document from frappe.model.meta import get_field_precision from frappe.query_builder import Criterion, Order -<<<<<<< HEAD from frappe.query_builder.functions import NullIf, Sum -from frappe.utils import flt, get_link_to_form -======= -from frappe.query_builder.functions import Max, NullIf, Sum from frappe.utils import flt, get_link_to_form, nowdate ->>>>>>> a0b14c0607 (refactor: reversal capability on exchange rate revaluation) import erpnext from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on @@ -622,7 +617,7 @@ class ExchangeRateRevaluation(Document): .run(pluck="name") ) if journals: - from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry + from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry for x in journals: reversal = make_reverse_journal_entry(x) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py index a132ff30cf4..4329b6078ec 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py @@ -289,10 +289,8 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase): for key, _val in expected_data.items(): self.assertEqual(expected_data.get(key), account_details.get(key)) -<<<<<<< HEAD -======= - @ERPNextTestSuite.change_settings( + @change_settings( "Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0}, ) @@ -371,66 +369,3 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase): "Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name" ) self.assertIsNotNone(reverse_jv) - - -class TestExchangeRateRevaluationValidation(ERPNextTestSuite): - """Validation and gain/loss calculation paths, exercised on the document directly - so they don't need the multi-currency GL setup the integration tests above build.""" - - def setUp(self): - frappe.set_user("Administrator") - self.company = "_Test Company" - - def _revaluation_with_rows(self, rows, rounding_loss_allowance=0.05): - doc = frappe.new_doc("Exchange Rate Revaluation") - doc.company = self.company - doc.posting_date = today() - doc.rounding_loss_allowance = rounding_loss_allowance - for row in rows: - doc.append("accounts", row) - return doc - - def test_rounding_loss_allowance_must_be_between_0_and_1(self): - for bad in (-0.1, 1, 1.5): - doc = self._revaluation_with_rows([], rounding_loss_allowance=bad) - self.assertRaises(frappe.ValidationError, doc.validate) - # values inside [0, 1) are accepted, at the lower bound and mid-range - for good in (0.0, 0.5): - self._revaluation_with_rows([], rounding_loss_allowance=good).validate() - - def test_gain_loss_computed_and_split_by_zero_balance(self): - doc = self._revaluation_with_rows( - [ - # open (unbooked) row: base balance moved 1000 -> 1100, a 100 gain - {"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100}, - # already-settled (zero_balance) row carries a booked loss of 40 - {"zero_balance": 1, "gain_loss": -40}, - ] - ) - doc.validate() - - # gain_loss is derived only for open rows; the zero-balance row keeps its value - self.assertEqual(doc.accounts[0].gain_loss, 100) - self.assertEqual(doc.gain_loss_unbooked, 100) - self.assertEqual(doc.gain_loss_booked, -40) - self.assertEqual(doc.total_gain_loss, 60) - - def test_before_submit_drops_rows_without_gain_loss(self): - doc = self._revaluation_with_rows( - [ - {"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100}, - {"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500}, - ] - ) - doc.validate() # second row nets to a 0 gain_loss - doc.remove_accounts_without_gain_loss() - self.assertEqual(len(doc.accounts), 1) - self.assertEqual(doc.accounts[0].gain_loss, 100) - - def test_before_submit_requires_at_least_one_gain_loss_row(self): - doc = self._revaluation_with_rows( - [{"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500}] - ) - doc.validate() - self.assertRaises(frappe.ValidationError, doc.remove_accounts_without_gain_loss) ->>>>>>> 65775e59a1 (refactor(test): for reverse journals as well) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js index d444bb7d3d1..45d29d08477 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js @@ -1,22 +1,15 @@ frappe.listview_settings["Journal Entry"] = { -<<<<<<< HEAD - add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark"], + add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark", "reversal_of"], get_indicator: function (doc) { if (doc.docstatus == 0) { return [__("Draft", "red", "docstatus,=,0")]; } else if (doc.docstatus == 2) { return [__("Cancelled", "grey", "docstatus,=,2")]; - } else { - return [__(doc.voucher_type), "blue", "voucher_type,=," + doc.voucher_type]; -======= - add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark", "reversal_of"], - get_indicator: function (doc) { - if (doc.docstatus === 1) { + } else if (doc.docstatus === 1) { if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") { return [__("Reversal Of Exchange Rate Revaluation"), "blue"]; } return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`]; ->>>>>>> a0b14c0607 (refactor: reversal capability on exchange rate revaluation) } }, }; From 9d654b78901c26b98a256bdff9ed211d2755ddfc Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:44:39 +0530 Subject: [PATCH 89/94] fix(stock): correct stock ageing value for moving average and lifo items (backport #56693) (#57016) fix(stock): correct stock ageing value for moving average and lifo items (#56693) * fix(stock): recompute moving average item slots * test(stock): add test to validate the stock value of moving average items * fix(stock): support lifo valuation in stock ageing report lifo items were aged as fifo (oldest consumed first), so the report kept the newest lots on hand and reported the wrong stock value and average age. prefetch each item's valuation method (it can't be resolved mid-stream without breaking the unbuffered cursor) and consume from the tail for lifo items. also reuse that shared lookup in the moving average revaluation pass. scoped to plain items; batch, serial and same-voucher repack legs stay on fifo. * test(stock): add test for lifo consumption in stock ageing report (cherry picked from commit 9cb6610b9e380a296df7802c857462bc59c4ed07) # Conflicts: # erpnext/stock/report/stock_ageing/stock_ageing.py Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> --- .../stock/report/stock_ageing/stock_ageing.py | 84 ++++++++++-- .../report/stock_ageing/test_stock_ageing.py | 127 ++++++++++++++++++ 2 files changed, 203 insertions(+), 8 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index b9ec3e7204f..e0106f7ddb0 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -287,6 +287,7 @@ class FIFOSlots: self.serial_no_details = {} self.batch_no_details = {} self.batchwise_valuation_by_batch = {} + self.valuation_method_by_item = {} self.filters = filters self.sle = sle @@ -307,9 +308,10 @@ class FIFOSlots: self.prepare_stock_reco_voucher_wise_count() if stock_ledger_entries is None: - # nested queries invalidate the streaming cursor below, - # so batchwise valuation flags must be resolved beforehand + # streaming path: nested queries invalidate the streaming cursor below, + # so batchwise valuation flags and item valuation methods must be resolved beforehand self._prefetch_batchwise_valuations() + self._prefetch_valuation_methods() with frappe.db.unbuffered_cursor(): if stock_ledger_entries is None: @@ -321,12 +323,28 @@ class FIFOSlots: # Note that stock_ledger_entries is an iterator, you can not reuse it like a list del stock_ledger_entries + self._recompute_moving_average_slots() + 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) return self.item_details + def _recompute_moving_average_slots(self) -> None: + for item_dict in self.item_details.values(): + if item_dict.get("has_serial_no") or item_dict.get("has_batch_no"): + continue + + details = item_dict["details"] + if self._get_item_valuation_method(details.name) != "Moving Average": + continue + + rate = flt(details.valuation_rate) + for slot in item_dict["fifo_queue"]: + if is_qty_slot(slot): + slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate) + 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({}) @@ -347,7 +365,10 @@ class FIFOSlots: 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) + from_end = self._get_item_valuation_method(row.name) == "LIFO" + self._compute_outgoing_stock( + row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end + ) self._update_balances(row, key) self._trim_serial_fifo_queue(row, key, fifo_queue) @@ -460,6 +481,43 @@ class FIFOSlots: for batch_no, use_batchwise_valuation in query.run(): self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation + def _get_item_valuation_method(self, item_code: str) -> str: + from erpnext.stock.utils import get_valuation_method + + if item_code not in self.valuation_method_by_item: + # only reachable when stock ledger entries are passed in directly; + # the streaming path prefetches all methods before iteration + self.valuation_method_by_item[item_code] = get_valuation_method(item_code) + + return self.valuation_method_by_item[item_code] + + def _prefetch_valuation_methods(self) -> None: + from erpnext.stock.utils import get_valuation_method + + company = self.filters.get("company") + sle = frappe.qb.DocType("Stock Ledger Entry") + item = frappe.qb.DocType("Item") + to_date = get_datetime(self.filters.get("to_date") + " 23:59:59") + + query = ( + frappe.qb.from_(sle) + .inner_join(item) + .on(sle.item_code == item.name) + .select(item.name, item.valuation_method) + .distinct() + .where((sle.company == company) & (sle.posting_datetime <= to_date) & (sle.is_cancelled != 1)) + ) + query = self._apply_filter(query, sle, "item_code") + + # items with no item-level method share the company/settings default; resolve it once + default_method = None + for item_code, valuation_method in query.run(): + if not valuation_method: + if default_method is None: + default_method = get_valuation_method(item_code) + valuation_method = default_method + self.valuation_method_by_item[item_code] = valuation_method + def _init_key_stores(self, row: dict) -> tuple: "Initialise keys and FIFO Queue." @@ -576,7 +634,13 @@ class FIFOSlots: 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 + self, + row: dict, + fifo_queue: list, + transfer_key: tuple, + serial_nos: list, + batch_nos: list, + from_end: bool = False, ): "Update FIFO Queue on outward stock." if serial_nos: @@ -584,7 +648,7 @@ class FIFOSlots: elif batch_nos: self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos) else: - self._consume_fifo_slots(row, fifo_queue, transfer_key) + self._consume_fifo_slots(row, fifo_queue, transfer_key, from_end) 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] @@ -661,19 +725,23 @@ class FIFOSlots: ) 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: + def _consume_fifo_slots( + self, row: dict, fifo_queue: list, transfer_key: tuple, from_end: bool = False + ) -> None: + # LIFO consumes the most recent inward first, so pop from the tail instead of the head. + index = -1 if from_end else 0 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 = fifo_queue[index] 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)) + self.transferred_item_details[transfer_key].append(fifo_queue.pop(index)) elif not fifo_queue: fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)]) self.transferred_item_details[transfer_key].append( diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 54f9ef9241e..2f74e1e3327 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -1,6 +1,8 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +from unittest.mock import patch + import frappe from frappe.tests.utils import FrappeTestCase @@ -67,6 +69,131 @@ class TestStockAgeing(FrappeTestCase): data = format_report_data(self.filters, slots, self.filters["to_date"]) self.assertEqual(data[0][8], 40.0) # valuating for stock value between age 0-30 + def test_moving_average_value_ties_to_stock_balance(self): + """For Moving Average items the queue value is re-derived as qty * rate so the + report's stock value ties to Stock Balance, instead of stranding a residual + from FIFO-by-qty consumption vs blended outgoing value.""" + sle = [ + frappe._dict( + name="MA Item", + actual_qty=10, + qty_after_transaction=10, + stock_value_difference=1000, + valuation_rate=100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=10, + qty_after_transaction=20, + stock_value_difference=2000, + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=(-10), + qty_after_transaction=10, + stock_value_difference=(-1500), + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-03", + voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=(-5), + qty_after_transaction=5, + stock_value_difference=(-750), + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-04", + voucher_type="Stock Entry", + voucher_no="004", + has_serial_no=False, + serial_no=None, + ), + ] + + with patch("erpnext.stock.utils.get_valuation_method", return_value="Moving Average"): + slots = FIFOSlots(self.filters, sle).generate() + + queue = slots["MA Item"]["fifo_queue"] + total_value = sum(slot[2] for slot in queue) + + # Stock Balance bal_val = qty_after_transaction * valuation_rate = 5 * 150 + self.assertEqual(total_value, 750.0) + + def test_lifo_consumes_newest_first(self): + """LIFO items consume the most recent inward first, so the oldest lot stays on + hand. The remaining queue, stock value and average age must reflect the older + stock, unlike the default FIFO which retains the newest lots.""" + sle = [ + frappe._dict( + name="LIFO Item", + actual_qty=30, + qty_after_transaction=30, + stock_value_difference=30, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="LIFO Item", + actual_qty=20, + qty_after_transaction=50, + stock_value_difference=20, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="LIFO Item", + actual_qty=(-10), + qty_after_transaction=40, + stock_value_difference=(-10), + warehouse="WH 1", + posting_date="2021-12-03", + voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, + serial_no=None, + ), + ] + + with patch("erpnext.stock.utils.get_valuation_method", return_value="LIFO"): + slots = FIFOSlots(self.filters, sle).generate() + + queue = slots["LIFO Item"]["fifo_queue"] + + # newest lot (day 2) is consumed first: oldest 30 stays, newest drops 20 -> 10 + self.assertEqual(queue[0][0], 30.0) + self.assertEqual(queue[-1][0], 10.0) + self.assertEqual(sum(slot[0] for slot in queue), 40.0) + self.assertEqual(sum(slot[2] for slot in queue), 40.0) + + # average age skews older than the FIFO result (8.5) because the old lot is retained + self.assertEqual(get_average_age(queue, self.filters["to_date"]), 8.75) + def test_insufficient_balance(self): "Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)" sle = [ From 8688f1f51a7868757aa6d14ef4568e099d3b1337 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:25:47 +0000 Subject: [PATCH 90/94] fix: display outstanding amount using company default currency (backport #56785) (#57008) Co-authored-by: S Sakthivel Murugan Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> Co-authored-by: Diptanil Saha --- .../opening_invoice_creation_tool_item.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json b/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json index ed8ff7c0f7a..d1d65dd9185 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json @@ -79,6 +79,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Outstanding Amount", + "options": "Company:company:default_currency", "reqd": 1 }, { @@ -115,7 +116,7 @@ ], "istable": 1, "links": [], - "modified": "2022-03-21 19:31:45.382656", + "modified": "2026-07-02 15:17:11.938499", "modified_by": "Administrator", "module": "Accounts", "name": "Opening Invoice Creation Tool Item", @@ -126,4 +127,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} From 0f6b2b68fa002733b44331444bba565cff501ab1 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:50:22 +0000 Subject: [PATCH 91/94] fix: fetch payment entry reference amounts from invoice (backport #56928) (#57041) Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> --- .../doctype/payment_request/payment_request.py | 1 + .../payment_request/test_payment_request.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index f13569e0d9b..01de1e34e21 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -367,6 +367,7 @@ class PaymentRequest(Document): bank_amount=bank_amount, created_from_payment_request=True, ) + payment_entry.set_missing_ref_details(force=True) payment_entry.update( { diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index df28b623488..9f92e9f4f09 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -618,6 +618,22 @@ class TestPaymentRequest(FrappeTestCase): pi.load_from_db() self.assertEqual(pr_2.grand_total, pi.outstanding_amount) + def test_payment_entry_reference_details_fetched_from_invoice(self): + pi = make_purchase_invoice(currency="INR", qty=1, rate=94500) + pi.submit() + + pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1, submit_doc=0, return_doc=1) + pr.grand_total = 94000 + pr.submit() + + pe = pr.create_payment_entry(submit=False) + + self.assertEqual(pe.references[0].reference_name, pi.name) + self.assertEqual(pe.references[0].total_amount, pi.grand_total) + self.assertEqual(pe.references[0].outstanding_amount, pi.outstanding_amount) + self.assertEqual(pe.references[0].allocated_amount, 94000) + self.assertEqual(pe.paid_amount, 94000) + def test_consider_journal_entry_and_return_invoice(self): from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry From 3e2a734414c1dc42ff0ab8de8b11eb9367ed2548 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:13:42 +0000 Subject: [PATCH 92/94] fix: map stock_qty in apply_price_list_on_item (backport #56869) (#57051) * fix: map stock_qty in apply_price_list_on_item (#56869) (cherry picked from commit 199eeff22c3301040806edcec35344d7c575ba31) # Conflicts: # erpnext/stock/get_item_details.py * chore: resolve conflicts --------- Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> Co-authored-by: diptanilsaha --- erpnext/stock/get_item_details.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 13ccb29cc7a..fe41802fdf0 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -1518,6 +1518,11 @@ def apply_price_list(args, as_doc=False, doc=None): def apply_price_list_on_item(args, doc=None): item_doc = frappe.db.get_value("Item", args.item_code, ["name", "variant_of"], as_dict=1) item_details = get_price_list_rate(args, item_doc) + + args.conversion_factor = flt(args.conversion_factor) or get_conversion_factor( + args.item_code, args.uom + ).get("conversion_factor", 1) + args.stock_qty = flt(args.qty) * flt(args.conversion_factor) item_details.update(get_pricing_rule_for_item(args, doc=doc)) return item_details From 1cb430757873993e7c2b00c4defb049065816052 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:45:22 +0530 Subject: [PATCH 93/94] fix: update events order by date asc (backport #56963) (#57055) Co-authored-by: Pandiyan P Co-authored-by: nareshkannasln --- erpnext/crm/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py index 8e6574bde4d..06d97b5173f 100644 --- a/erpnext/crm/utils.py +++ b/erpnext/crm/utils.py @@ -166,6 +166,7 @@ def get_open_todos(ref_doctype, ref_docname): "allocated_to", "date", ], + order_by="date asc", ) @@ -190,6 +191,7 @@ def get_open_events(ref_doctype, ref_docname): & (event_link.reference_docname == ref_docname) & (event.status == "Open") ) + .orderby(event.starts_on) ) data = query.run(as_dict=True) From bd6269918fcf093acdc9714a764c4cb4e3387df0 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:58:53 +0000 Subject: [PATCH 94/94] fix: remove incorrect Payable account_type from Customer Deposits in Philippines CoA (backport #57018) (#57060) Co-authored-by: Raghav Ruia <168326921+raghavisruia@users.noreply.github.com> --- .../account/chart_of_accounts/verified/philippines.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json index ea3977711a7..1cad6a97d0c 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json @@ -406,8 +406,7 @@ "Customer Deposits": { "account_number": "2500", "is_group": 0, - "root_type": "Liability", - "account_type": "Payable" + "root_type": "Liability" } }, "Non Current Liabilities": {