From 372244e6b22058478512a0fe73a60435027d7ecb Mon Sep 17 00:00:00 2001 From: Afsal Syed <146159709+Afsalsyed@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:43:11 +0530 Subject: [PATCH 01/29] fix(stock): show actual outgoing rate in ledger preview (#58700) (cherry picked from commit 71f1edeb1c8cdc47d83d51784507ecabd0305e8d) --- erpnext/controllers/stock_controller.py | 32 +++++++++++++------ .../controllers/tests/test_ledger_preview.py | 23 +++++++++++++ erpnext/public/js/utils/ledger_preview.js | 2 +- 3 files changed, 46 insertions(+), 11 deletions(-) create mode 100644 erpnext/controllers/tests/test_ledger_preview.py diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index f5e344a4045..bb5e21d8c7b 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1533,7 +1533,7 @@ def show_accounting_ledger_preview(company: str, doctype: str, docname: str): @frappe.whitelist() def show_stock_ledger_preview(company: str, doctype: str, docname: str): - filters = frappe._dict(company=company) + filters = frappe._dict(company=company, valuation_field_type="Currency") doc = frappe.get_doc(doctype, docname) doc.check_permission("read") doc.run_method("before_sl_preview") @@ -1574,7 +1574,7 @@ def get_accounting_ledger_preview(doc, filters): columns = get_gl_columns(filters) gl_entries = get_gl_entries_for_preview(doc.doctype, doc.name, fields) - gl_columns = get_columns(columns, fields) + gl_columns = get_columns(columns, fields, erpnext.get_company_currency(filters.company)) gl_data = get_data(fields, gl_entries) return gl_columns, gl_data @@ -1616,7 +1616,7 @@ def get_stock_ledger_preview(doc, filters): columns = get_sl_columns(filters) sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields) - sl_columns = get_columns(columns, columns_fields) + sl_columns = get_columns(columns, columns_fields, erpnext.get_company_currency(filters.company)) sl_data = get_data(columns_fields, sl_entries) return sl_columns, sl_data @@ -1635,7 +1635,8 @@ def get_sl_entries_for_preview(doctype, docname, fields): entry["out_qty"] = abs(entry.actual_qty) entry["in_qty"] = 0 - entry["in_out_rate"] = entry["valuation_rate"] + if entry.actual_qty < 0: + entry["in_out_rate"] = entry.stock_value_difference / entry.actual_qty return sl_entries @@ -1644,12 +1645,23 @@ def get_gl_entries_for_preview(doctype, docname, fields): return frappe.get_all("GL Entry", filters={"voucher_type": doctype, "voucher_no": docname}, fields=fields) -def get_columns(raw_columns, fields): - return [ - {"name": d.get("label"), "editable": False, "width": 110, "fieldtype": d.get("fieldtype")} - for d in raw_columns - if not d.get("hidden") and d.get("fieldname") in fields - ] +def get_columns(raw_columns, fields, currency): + columns = [] + for source_column in raw_columns: + if source_column.get("hidden") or source_column.get("fieldname") not in fields: + continue + + column = { + "name": source_column.get("label"), + "editable": False, + "width": 110, + "fieldtype": source_column.get("fieldtype"), + } + if column["fieldtype"] == "Currency": + column["options"] = currency + columns.append(column) + + return columns def get_data(raw_columns, raw_data): diff --git a/erpnext/controllers/tests/test_ledger_preview.py b/erpnext/controllers/tests/test_ledger_preview.py new file mode 100644 index 00000000000..3aa5dd5ef53 --- /dev/null +++ b/erpnext/controllers/tests/test_ledger_preview.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +from unittest import TestCase +from unittest.mock import patch + +import frappe + +from erpnext.controllers.stock_controller import get_sl_entries_for_preview + + +class TestLedgerPreview(TestCase): + def test_in_out_rate_is_only_set_for_outgoing_entries(self): + stock_ledger_entries = [ + frappe._dict(actual_qty=5, stock_value_difference=10), + frappe._dict(actual_qty=-5, stock_value_difference=-15), + ] + + with patch("frappe.get_all", return_value=stock_ledger_entries): + entries = get_sl_entries_for_preview("Delivery Note", "DN-0001", []) + + self.assertIsNone(entries[0].get("in_out_rate")) + self.assertEqual(entries[1].in_out_rate, 3) diff --git a/erpnext/public/js/utils/ledger_preview.js b/erpnext/public/js/utils/ledger_preview.js index 9a5c8d3217f..9609a69d6d6 100644 --- a/erpnext/public/js/utils/ledger_preview.js +++ b/erpnext/public/js/utils/ledger_preview.js @@ -83,7 +83,7 @@ erpnext.accounts.ledger_preview = { columns.forEach((col) => { if (col.fieldtype === "Currency") { col.format = (value) => { - return format_currency(value); + return format_currency(value, col.options); }; } }); From 7ee54ba08bc50a25869c95aade7acc96be6b77c1 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Wed, 9 Sep 2026 07:42:40 +0530 Subject: [PATCH 02/29] fix(stock): guard inventory dimension lookup without reference document (#58889) Co-authored-by: Mihir Kandoi (cherry picked from commit afd93cf867db05aba14d2c4f092a11900e071cbe) --- .../inventory_dimension.js | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js index 1b5f4a5743f..2b9b08b4aef 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js @@ -73,24 +73,44 @@ frappe.ui.form.on("Inventory Dimension", { frm.trigger("set_parent_fields"); }, - set_parent_fields(frm) { - if (frm.doc.apply_to_all_doctypes) { - let options = ["\n", frm.doc.reference_document]; + istable(frm) { + frm.trigger("set_parent_fields"); + }, - frm.set_df_property("fetch_from_parent", "options", options); - } else if (frm.doc.document_type && frm.doc.istable) { + reference_document(frm) { + frm.trigger("set_parent_fields"); + }, + + apply_to_all_doctypes(frm) { + frm.trigger("set_parent_fields"); + }, + + set_parent_fields(frm) { + const { reference_document, document_type } = frm.doc; + if (!reference_document || (!frm.doc.apply_to_all_doctypes && (!document_type || !frm.doc.istable))) { + return set_parent_field_options(frm, []); + } + + if (frm.doc.apply_to_all_doctypes) { + return set_parent_field_options(frm, [{ value: reference_document, label: reference_document }]); + } else if (document_type && frm.doc.istable) { frappe.call({ method: "erpnext.stock.doctype.inventory_dimension.inventory_dimension.get_parent_fields", args: { - child_doctype: frm.doc.document_type, - dimension_name: frm.doc.reference_document, + child_doctype: document_type, + dimension_name: reference_document, }, callback: (r) => { - if (r.message && r.message.length) { - frm.set_df_property("fetch_from_parent", "options", ["\n"].concat(r.message)); - } else { - frm.set_df_property("fetch_from_parent", "hidden", 1); + if ( + frm.doc.reference_document !== reference_document || + frm.doc.document_type !== document_type || + frm.doc.apply_to_all_doctypes || + !frm.doc.istable + ) { + return; } + + return set_parent_field_options(frm, r.message || []); }, }); } @@ -115,3 +135,12 @@ frappe.ui.form.on("Inventory Dimension", { }); }, }); + +function set_parent_field_options(frm, fields) { + frm.set_df_property("fetch_from_parent", "options", ["", ...fields]); + frm.set_df_property("fetch_from_parent", "hidden", !fields.length); + + if (frm.doc.fetch_from_parent && !fields.some((field) => field.value === frm.doc.fetch_from_parent)) { + return frm.set_value("fetch_from_parent", ""); + } +} From 3bd194e986e3348a4087d1750f7e0b1a780b0a9c Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:20:51 +0000 Subject: [PATCH 03/29] fix: user not able to set valuation rate zero in stock reco (backport #58800) (#58821) * fix: user not able to set valuation rate zero in stock reco (#58800) * fix: user not able to set valuation rate zero in stock reco * fix: wrong difference amount when valuation rate is zero * fix: blank valuation rate should not be treated as a change (cherry picked from commit e85e300f8f90962c30710ff38a54b7da4abc7cdc) # Conflicts: # erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py * chore: fix conflicts Removed unused import statements and cleaned up the code. --------- Co-authored-by: rohitwaghchaure --- .../stock_reconciliation.py | 10 ++- .../test_stock_reconciliation.py | 86 ++++++++++++++++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 9470795f13f..f51b573aeb6 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -525,7 +525,10 @@ class StockReconciliation(StockController): rate_precision = item.precision("valuation_rate") rate = flt(item_dict.get("rate"), rate_precision) - valuation_rate = flt(item.valuation_rate, rate_precision) if item.valuation_rate else None + # an unset rate means "keep the current one", an explicit zero is a real revaluation + valuation_rate = ( + flt(item.valuation_rate, rate_precision) if item.valuation_rate not in ("", None) else None + ) if ( (item.qty is None or item.qty == item_dict.get("qty")) and (valuation_rate is None or valuation_rate == rate) @@ -568,7 +571,10 @@ class StockReconciliation(StockController): amount_precision = item.precision("amount") new_qty = flt(item.qty, qty_precision) - new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate")) + # an explicitly set zero rate is a real revaluation, don't fall back to the current rate + new_valuation_rate = flt( + item.valuation_rate if item.valuation_rate not in ("", None) else item_dict.get("rate") + ) current_qty = flt(item_dict.get("qty"), qty_precision) current_valuation_rate = flt(item_dict.get("rate")) diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 835f2a25f9f..1b9fe018312 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -25,7 +25,12 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after from erpnext.stock.tests.test_utils import StockTestMixin -from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method +from erpnext.stock.utils import ( + get_incoming_rate, + get_stock_balance, + get_stock_value_on, + get_valuation_method, +) class TestStockReconciliation(FrappeTestCase, StockTestMixin): @@ -1583,6 +1588,85 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): self.assertEqual(sr.difference_amount, 100 * -1) self.assertTrue(sr.items[0].qty == 0) + def test_difference_amount_for_zero_valuation_rate(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item("Test Item Stock Reco Zero Valuation Rate").name + warehouse = "_Test Warehouse - _TC" + + make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + + sr = create_stock_reconciliation( + item_code=item_code, warehouse=warehouse, qty=5, rate=0, do_not_save=1 + ) + sr.items[0].allow_zero_valuation_rate = 1 + sr.save() + + # qty is unchanged, the stock is revalued from 5 x 100 to 5 x 0 + self.assertEqual(sr.items[0].current_valuation_rate, 100) + self.assertEqual(sr.items[0].valuation_rate, 0) + self.assertEqual(sr.difference_amount, -500) + + sr.submit() + sr.reload() + + self.assertEqual(sr.difference_amount, -500) + self.assertEqual( + frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": sr.name, "is_cancelled": 0}, + "stock_value_difference", + ), + -500, + ) + + def test_no_change_row_removed_when_valuation_rate_is_blank(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item("Test Item Stock Reco Blank Valuation Rate").name + warehouse = "_Test Warehouse - _TC" + + make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + + sr = create_stock_reconciliation( + item_code=item_code, warehouse=warehouse, qty=5, rate=None, do_not_save=1 + ) + + # a blank rate means "keep the current rate", so nothing changed on this row + self.assertRaises(EmptyStockReconciliationItemsError, sr.save) + + def test_set_existing_stock_valuation_to_zero(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item("Test Item Stock Reco Set Valuation Zero").name + warehouse = "_Test Warehouse - _TC" + + make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=50) + + sr = create_stock_reconciliation( + item_code=item_code, warehouse=warehouse, qty=10, rate=0, do_not_save=1 + ) + sr.items[0].allow_zero_valuation_rate = 1 + + # only the rate changes, the row must not be dropped as "no change" + sr.save() + self.assertEqual(len(sr.items), 1) + + sr.submit() + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": sr.name, "is_cancelled": 0}, + ["qty_after_transaction", "valuation_rate", "stock_value"], + as_dict=True, + ) + + self.assertEqual(sle.qty_after_transaction, 10) + self.assertEqual(sle.valuation_rate, 0) + self.assertEqual(sle.stock_value, 0) + + self.assertEqual(get_stock_balance(item_code, warehouse, with_valuation_rate=True), (10, 0.0)) + def test_stock_reco_recalculate_qty_for_backdated_entry(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry From 47662b9af2e9e292c0e86f8cdab7917813dc2dbc Mon Sep 17 00:00:00 2001 From: pandiyan Date: Wed, 9 Sep 2026 06:57:48 +0530 Subject: [PATCH 04/29] fix(accounts): reject disabled price lists in buying and selling (#58891) (cherry picked from commit f8c2f3440b46a4fa7788e0bf077d094c7bda1f63) --- erpnext/controllers/accounts_controller.py | 24 +++++++++++++++++++ .../doctype/appointment/test_appointment.py | 8 +++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index ba4786cddaa..f73eb3b20db 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -253,6 +253,8 @@ class AccountsController(TransactionBase): if self.get("_action") and self._action != "update_after_submit": self.set_missing_values(for_validate=True) + self.validate_price_list() + if self.get("_action") == "submit": self.remove_bundle_for_non_stock_invoices() @@ -384,6 +386,28 @@ class AccountsController(TransactionBase): def is_drop_ship(items): return any(item.delivered_by_supplier for item in items) + def validate_price_list(self): + price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list" + price_list = self.get(price_list_field) + if not price_list or frappe.db.get_value("Price List", price_list, "enabled"): + return + + # Returns retain a submitted voucher's pricing even if its price list is now disabled. + if ( + self.get("is_return") + and self.get("return_against") + and price_list + == frappe.db.get_value( + self.doctype, {"name": self.return_against, "docstatus": 1}, price_list_field + ) + ): + return + + frappe.throw( + _("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)), + title=_("Disabled Price List"), + ) + def set_default_letter_head(self): if hasattr(self, "letter_head") and not self.letter_head: self.letter_head = frappe.db.get_value("Company", self.company, "default_letter_head") diff --git a/erpnext/crm/doctype/appointment/test_appointment.py b/erpnext/crm/doctype/appointment/test_appointment.py index 10813811664..d4236f2a566 100644 --- a/erpnext/crm/doctype/appointment/test_appointment.py +++ b/erpnext/crm/doctype/appointment/test_appointment.py @@ -53,7 +53,7 @@ def set_booking_setting(field, value): def slot_on(days_from_now, hour, minute=0): - day = datetime.date.today() + datetime.timedelta(days=days_from_now) + day = getdate() + datetime.timedelta(days=days_from_now) return datetime.datetime.combine(day, datetime.time(hour, minute)) @@ -136,7 +136,7 @@ class TestAppointment(FrappeTestCase): with self.set_user("Guest"), patch.object(Appointment, "send_confirmation_email") as mock_send: appointment = create_appointment( - date=str(datetime.date.today() + datetime.timedelta(days=days_from_now)), + date=str(getdate() + datetime.timedelta(days=days_from_now)), time=time, tz=get_system_timezone(), contact={"name": "Portal Visitor", "email": email, "number": "123", "skype": "", "notes": ""}, @@ -280,7 +280,7 @@ class TestAppointment(FrappeTestCase): with self.set_user("Guest"), self.assertRaises(frappe.Redirect): create_appointment( - date=str(datetime.date.today() + datetime.timedelta(days=3)), + date=str(getdate() + datetime.timedelta(days=3)), time="10:00:00", tz="UTC", contact={ @@ -295,7 +295,7 @@ class TestAppointment(FrappeTestCase): def test_booked_slot_unavailable_on_portal(self): self._configure_booking_settings() tz = get_system_timezone() - day = datetime.date.today() + datetime.timedelta(days=2) + day = getdate() + datetime.timedelta(days=2) def get_availability(): with self.set_user("Guest"): From 10987b11cc74b73a660c4d648808d555434b2146 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 8 Sep 2026 19:12:06 +0530 Subject: [PATCH 05/29] fix(stock): distribute additional costs when incoming items have no value (cherry picked from commit b6035810f062eacad9507208b77828377d12bf78) # Conflicts: # erpnext/stock/doctype/stock_entry/test_stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.py | 56 +++--- .../doctype/stock_entry/test_stock_entry.py | 186 +++++++++++++++++- 2 files changed, 210 insertions(+), 32 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index fb0fff2a601..b34d5799e8f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1534,22 +1534,28 @@ class StockEntry(StockController): self.total_additional_costs = sum(flt(t.base_amount) for t in self.get("additional_costs")) - if self.purpose in ("Repack", "Manufacture"): - incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item) - else: - incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse) - - if not incoming_items_cost: - return + incoming_items, basis, total_basis = self.get_additional_cost_allocation() for d in self.get("items"): - if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item: - d.additional_cost = 0 - continue - elif not d.t_warehouse: - d.additional_cost = 0 - continue - d.additional_cost = (flt(d.basic_amount) / incoming_items_cost) * self.total_additional_costs + d.additional_cost = 0 + + if not total_basis: + return + + for d in incoming_items: + d.additional_cost = (flt(d.get(basis)) / total_basis) * self.total_additional_costs + + def get_additional_cost_allocation(self): + if self.purpose in ("Repack", "Manufacture"): + incoming_items = [d for d in self.get("items") if d.is_finished_item] + else: + incoming_items = [d for d in self.get("items") if d.t_warehouse] + + total_basic_amount = sum(flt(d.basic_amount) for d in incoming_items) + if total_basic_amount: + return incoming_items, "basic_amount", total_basic_amount + + return incoming_items, "transfer_qty", sum(flt(d.transfer_qty) for d in incoming_items) def update_valuation_rate(self): for d in self.get("items"): @@ -2066,32 +2072,20 @@ class StockEntry(StockController): def get_gl_entries(self, warehouse_account): gl_entries = super().get_gl_entries(warehouse_account) - if self.purpose in ("Repack", "Manufacture"): - total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item) - else: - total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse) - - divide_based_on = total_basic_amount - - if self.get("additional_costs") and not total_basic_amount: - # if total_basic_amount is 0, distribute additional charges based on qty - divide_based_on = sum(item.qty for item in list(self.get("items"))) + incoming_items, basis, divide_based_on = self.get_additional_cost_allocation() item_account_wise_additional_cost = {} for t in self.get("additional_costs"): - for d in self.get("items"): - if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item: - continue - elif not d.t_warehouse: - continue - + if not divide_based_on: + continue + for d in incoming_items: item_account_wise_additional_cost.setdefault((d.item_code, d.name), {}) item_account_wise_additional_cost[(d.item_code, d.name)].setdefault( t.expense_account, {"amount": 0.0, "base_amount": 0.0} ) - multiply_based_on = d.basic_amount if total_basic_amount else d.qty + multiply_based_on = flt(d.get(basis)) item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]["amount"] += ( flt(t.amount * multiply_based_on) / divide_based_on diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index f9e1f415789..24c8be43b6f 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -3,8 +3,12 @@ from frappe.permissions import add_user_permission, remove_user_permission +<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, cstr, flt, get_time, getdate, nowtime, today +======= +from frappe.utils import add_days, cstr, flt, get_time, getdate, nowdate, nowtime, today +>>>>>>> b603581 (fix(stock): distribute additional costs when incoming items have no value) from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.controllers.accounts_controller import InvalidQtyError @@ -1614,10 +1618,12 @@ class TestStockEntry(FrappeTestCase): se.insert() se.submit() + self.assertEqual([33.33, 66.67], [flt(d.additional_cost, 2) for d in se.items]) + self.check_gl_entries( "Stock Entry", se.name, - sorted([["Stock Adjustment - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]), + sorted([["Stock In Hand - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]), ) def test_conversion_factor_change(self): @@ -1663,6 +1669,184 @@ class TestStockEntry(FrappeTestCase): distributed_costs = [d.additional_cost for d in se.items] self.assertEqual([0.0, 100.0, 0.0], distributed_costs) + def test_additional_cost_distribution_manufacture_zero_valued_items(self): + se = frappe.get_doc( + doctype="Stock Entry", + purpose="Manufacture", + additional_costs=[frappe._dict(base_amount=100)], + items=[ + frappe._dict(item_code="RM", basic_amount=0, transfer_qty=10), + frappe._dict( + item_code="FG", basic_amount=0, transfer_qty=5, t_warehouse="X", is_finished_item=1 + ), + frappe._dict(item_code="scrap", basic_amount=0, transfer_qty=2, t_warehouse="X"), + ], + ) + + se.distribute_additional_costs() + + distributed_costs = [d.additional_cost for d in se.items] + self.assertEqual([0.0, 100.0, 0.0], distributed_costs) + + def test_additional_cost_distribution_zero_valued_items(self): + se = frappe.get_doc( + doctype="Stock Entry", + purpose="Material Receipt", + additional_costs=[frappe._dict(base_amount=100)], + items=[ + frappe._dict(item_code="RECEIVED_1", basic_amount=0, transfer_qty=20, t_warehouse="X"), + frappe._dict(item_code="RECEIVED_2", basic_amount=0, transfer_qty=30, t_warehouse="X"), + ], + ) + + se.distribute_additional_costs() + + distributed_costs = [d.additional_cost for d in se.items] + self.assertEqual([40.0, 60.0], distributed_costs) + + def test_additional_cost_gl_for_zero_valued_manufacture(self): + company = "_Test Company with perpetual inventory" + rm = make_item("_Test Zero Rate RM", {"is_stock_item": 1}).name + fg = make_item("_Test Zero Rate FG", {"is_stock_item": 1}).name + + receipt = frappe.get_doc( + { + "doctype": "Stock Entry", + "purpose": "Material Receipt", + "stock_entry_type": "Material Receipt", + "posting_date": nowdate(), + "company": company, + "items": [ + { + "item_code": rm, + "qty": 5, + "basic_rate": 0, + "uom": "Nos", + "t_warehouse": "Stores - TCP1", + "allow_zero_valuation_rate": 1, + "cost_center": "Main - TCP1", + } + ], + } + ) + receipt.insert() + receipt.submit() + + se = frappe.get_doc( + { + "doctype": "Stock Entry", + "purpose": "Manufacture", + "stock_entry_type": "Manufacture", + "posting_date": nowdate(), + "company": company, + "items": [ + { + "item_code": rm, + "qty": 5, + "uom": "Nos", + "s_warehouse": "Stores - TCP1", + "cost_center": "Main - TCP1", + }, + { + "item_code": fg, + "qty": 5, + "uom": "Nos", + "t_warehouse": "Finished Goods - TCP1", + "is_finished_item": 1, + "cost_center": "Main - TCP1", + }, + ], + "additional_costs": [ + { + "expense_account": "Miscellaneous Expenses - TCP1", + "amount": 500, + "description": "freight", + } + ], + } + ) + se.insert() + se.submit() + + self.assertEqual(500.0, se.items[1].additional_cost) + self.check_gl_entries( + "Stock Entry", + se.name, + sorted([["Stock In Hand - TCP1", 500.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 500.0]]), + ) + + def test_additional_cost_gl_matches_valuation_split(self): + company = "_Test Company with perpetual inventory" + cost_center = "_Test Additional Cost CC - TCP1" + if not frappe.db.exists("Cost Center", cost_center): + frappe.get_doc( + { + "doctype": "Cost Center", + "cost_center_name": "_Test Additional Cost CC", + "company": company, + "is_group": 0, + "parent_cost_center": "_Test Company with perpetual inventory - TCP1", + } + ).insert() + + uoms = [{"uom": "Nos", "conversion_factor": 1}, {"uom": "Box", "conversion_factor": 2}] + item_a = make_item("_Test Addl Cost CF A", {"is_stock_item": 1, "uoms": uoms}).name + uoms[1]["conversion_factor"] = 3 + item_b = make_item("_Test Addl Cost CF B", {"is_stock_item": 1, "uoms": uoms}).name + + se = frappe.get_doc( + { + "doctype": "Stock Entry", + "purpose": "Material Receipt", + "stock_entry_type": "Material Receipt", + "posting_date": nowdate(), + "company": company, + "items": [ + { + "item_code": item_a, + "qty": 1, + "basic_rate": 0, + "uom": "Box", + "conversion_factor": 2, + "t_warehouse": "Stores - TCP1", + "allow_zero_valuation_rate": 1, + "cost_center": "Main - TCP1", + }, + { + "item_code": item_b, + "qty": 1, + "basic_rate": 0, + "uom": "Box", + "conversion_factor": 3, + "t_warehouse": "Stores - TCP1", + "allow_zero_valuation_rate": 1, + "cost_center": cost_center, + }, + ], + "additional_costs": [ + { + "expense_account": "Miscellaneous Expenses - TCP1", + "amount": 100, + "description": "misc", + } + ], + } + ) + se.insert() + se.submit() + + self.assertEqual([40.0, 60.0], [flt(d.additional_cost, 2) for d in se.items]) + + expense_by_cost_center = frappe.get_all( + "GL Entry", + filters={"voucher_no": se.name, "account": "Miscellaneous Expenses - TCP1"}, + fields=["cost_center", "credit"], + ) + self.assertEqual( + {"Main - TCP1": 40.0, cost_center: 60.0}, + {d.cost_center: d.credit for d in expense_by_cost_center}, + ) + def test_additional_cost_distribution_non_manufacture(self): se = frappe.get_doc( doctype="Stock Entry", From 00729a8d627ac9e1a7c2c61de946a4aae10f372e Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Wed, 9 Sep 2026 10:38:11 +0530 Subject: [PATCH 06/29] fix: resolve conflicts --- erpnext/stock/doctype/stock_entry/test_stock_entry.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 24c8be43b6f..4841e879eb9 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -3,12 +3,8 @@ from frappe.permissions import add_user_permission, remove_user_permission -<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase, change_settings -from frappe.utils import add_days, cstr, flt, get_time, getdate, nowtime, today -======= from frappe.utils import add_days, cstr, flt, get_time, getdate, nowdate, nowtime, today ->>>>>>> b603581 (fix(stock): distribute additional costs when incoming items have no value) from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.controllers.accounts_controller import InvalidQtyError From f981791012dfc1b29ba301dbb137ec9cbf70f1b6 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Wed, 9 Sep 2026 12:26:05 +0530 Subject: [PATCH 07/29] fix: reset price list fallback when changing parties (#58893) (cherry picked from commit 4c32acf300224b2a149f7469c6c74a9c607aa38c) --- erpnext/public/js/utils/party.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js index 66fd0eeec8e..767074b9104 100644 --- a/erpnext/public/js/utils/party.js +++ b/erpnext/public/js/utils/party.js @@ -24,14 +24,14 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) { args = { party: frm.doc.customer || frm.doc.party_name, party_type: party_type, - price_list: frm.doc.selling_price_list, + price_list: frappe.defaults.get_default("selling_price_list"), }; } else if (frm.doc.supplier) { args = { party: frm.doc.supplier, party_type: "Supplier", bill_date: frm.doc.bill_date, - price_list: frm.doc.buying_price_list, + price_list: frappe.defaults.get_default("buying_price_list"), }; } From 00e02f07acc90f87357d7df4491d52784994b410 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Wed, 9 Sep 2026 12:37:03 +0530 Subject: [PATCH 08/29] fix: validate shelf life for automatically created expiring batches (#58911) Co-authored-by: Ajish18 (cherry picked from commit b2bdeaa6725441089ae59cde4aa1c3b518ffb7c2) --- erpnext/stock/doctype/item/item.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index fb2a7ee95f8..cdeedae35bd 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -194,6 +194,7 @@ class Item(Document): self.validate_conversion_factor() self.validate_item_type() self.validate_naming_series() + self.validate_shelf_life() self.check_for_active_boms() self.fill_customer_code() self.check_item_tax() @@ -344,6 +345,19 @@ class Item(Document): ).format(self.item_code) ) + def validate_shelf_life(self): + if ( + self.has_batch_no + and self.has_expiry_date + and self.create_new_batch + and cint(self.shelf_life_in_days) <= 0 + ): + frappe.throw( + _("{0} must be greater than zero.").format( + self.get_label_from_fieldname("shelf_life_in_days") + ) + ) + def clear_retain_sample(self): if not self.has_batch_no: self.retain_sample = False From 32d8c359ed9d58619391c809f0409d811ec5dbc9 Mon Sep 17 00:00:00 2001 From: Shllokkk <140623894+Shllokkk@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:38:58 +0530 Subject: [PATCH 09/29] fix(stock): preserve rates for unsaved mapped rows (backport #58852) (#58898) * fix: keep each row's own rate under maintain same rate * test: cover maintain-same-rate rate lock for batch items --- erpnext/stock/get_item_details.py | 6 +- erpnext/stock/tests/test_get_item_details.py | 62 ++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index b68cbc08174..6e7c5cd1f77 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -192,7 +192,11 @@ def get_rate_locked_source_row(args, doc): if not source_fields or not doc or args.get("is_return") or not maintain_same_rate_enabled(args): return None - row = next((d for d in doc.get("items") or [] if d.get("name") == args.child_docname), None) + row = ( + next((d for d in doc.get("items") or [] if d.get("name") == args.child_docname), None) + if args.child_docname + else args + ) if not row: return None diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index 398d7e603b5..abdcaded81f 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -422,3 +422,65 @@ class TestGetItemDetail(FrappeTestCase): frappe.set_user("Administrator") frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) frappe.clear_cache(doctype="Buying Settings") + + def test_rate_lock_keeps_each_rows_rate_for_batch_items(self): + """Batch rows mapped PR->PI must each keep their own rate, not collapse onto the first.""" + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice + from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + get_batch_from_bundle, + ) + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.clear_cache(doctype="Buying Settings") + self.addCleanup(frappe.clear_cache, doctype="Buying Settings") + self.addCleanup(frappe.db.set_single_value, "Buying Settings", "maintain_same_rate", original) + + def batched_item(): + code = make_item( + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "FBQ-.#####", + } + ).name + se = make_stock_entry(item_code=code, target="_Test Warehouse - _TC", qty=5, basic_rate=100) + return code, get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + + item_a, batch_a = batched_item() + item_b, batch_b = batched_item() + + # one PO with both items at different rates + po = create_purchase_order(item_code=item_a, qty=5, rate=28, do_not_save=True) + po.append( + "items", + { + "item_code": item_b, + "qty": 5, + "rate": 275, + "warehouse": "_Test Warehouse - _TC", + "schedule_date": frappe.utils.nowdate(), + }, + ) + po.set_missing_values() + po.insert() + po.submit() + + # receive both against their own batches + pr = make_purchase_receipt(po.name) + for row in pr.items: + row.use_serial_batch_fields = 1 + pr.items[0].batch_no = batch_a + pr.items[1].batch_no = batch_b + pr.insert() + pr.submit() + + # the batch_no branch force-writes the fetched rate during mapping + pi = make_purchase_invoice(pr.name) + self.assertEqual(pi.items[0].rate, 28) + self.assertEqual(pi.items[1].rate, 275) # used to collapse onto the first row (28) From da53283659566c0c5bc1b8cd3e30d88ab5c27c13 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 17 Aug 2026 11:55:38 +0530 Subject: [PATCH 10/29] test: dunning must stay unpaid when only the invoiced sum is paid paying a dunned invoice without its interest and fee marks the dunning resolved, and the interest can no longer be collected. (cherry picked from commit fa89552d10ee84a9ca9835aeda026076bcf52a6b) --- .../accounts/doctype/dunning/test_dunning.py | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py index 4fe8e7bf9f8..44c26c64301 100644 --- a/erpnext/accounts/doctype/dunning/test_dunning.py +++ b/erpnext/accounts/doctype/dunning/test_dunning.py @@ -16,6 +16,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( create_dunning as create_dunning_from_sales_invoice, ) from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import ( + create_sales_invoice, create_sales_invoice_against_cost_center, ) @@ -71,6 +72,123 @@ class TestDunning(FrappeTestCase): dunning.reload() self.assertEqual(dunning.status, "Resolved") + def test_dunning_not_resolved_by_payment_of_invoiced_sum_only(self): + """ + Regression for #58220: paying the invoice without the interest and fee must not + resolve the dunning, the interest is still owed and has to stay claimable. + """ + dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC") + dunning.submit() + sales_invoice = dunning.overdue_payments[0].sales_invoice + + pe = get_payment_entry("Sales Invoice", sales_invoice) + pe.reference_no, pe.reference_date = "4", nowdate() + pe.insert() + pe.submit() + + self.assertEqual(frappe.get_value("Sales Invoice", sales_invoice, "outstanding_amount"), 0) + + dunning.reload() + self.assertEqual(dunning.status, "Unresolved") + self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41) + + # the interest and fee can still be collected on their own + pe = get_payment_entry("Dunning", dunning.name) + pe.reference_no, pe.reference_date = "5", nowdate() + self.assertEqual(pe.references, []) + self.assertEqual(round(pe.paid_amount, 2), 10.41) + pe.insert() + pe.submit() + + dunning.reload() + self.assertEqual(dunning.status, "Resolved") + self.assertEqual(dunning.get_unpaid_dunning_amount(), 0) + + # cancelling the interest payment makes the dunning claimable again + pe.cancel() + dunning.reload() + self.assertEqual(dunning.status, "Unresolved") + self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41) + + def test_dunning_can_be_cancelled_after_its_interest_was_paid(self): + """ + The payment collecting the interest links back to the dunning, which must not stand in + the way of cancelling it. + """ + dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC") + dunning.submit() + + pe = get_payment_entry("Dunning", dunning.name) + pe.reference_no, pe.reference_date = "6", nowdate() + pe.insert() + pe.submit() + + dunning.reload() + self.assertEqual(dunning.status, "Resolved") + + dunning.cancel() + self.assertEqual(dunning.docstatus, 2) + + def test_waived_interest_keeps_a_manually_resolved_dunning_resolved(self): + """ + Resolving a dunning by hand waives its interest, so a later payment of the invoice + must not reopen it. + """ + dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC") + dunning.submit() + sales_invoice = dunning.overdue_payments[0].sales_invoice + + # what the "Resolve" button does + dunning.reload() + dunning.status = "Resolved" + dunning.save() + + pe = get_payment_entry("Sales Invoice", sales_invoice) + pe.reference_no, pe.reference_date = "7", nowdate() + pe.insert() + pe.submit() + + dunning.reload() + self.assertEqual(dunning.status, "Resolved") + self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41) + + def test_unpaid_dunning_amount_is_tracked_in_company_currency(self): + """ + The interest and fee are collected as a Payment Entry deduction, a company currency + field, so what is left to collect has to be measured in the same currency. + """ + si = create_sales_invoice( + posting_date=add_days(today(), -15), + customer="_Test Customer USD", + currency="USD", + conversion_rate=50, + rate=100, + debit_to="_Test Receivable USD - _TC", + ) + + dunning = create_dunning_from_sales_invoice(si.name) + dunning_type = frappe.get_doc("Dunning Type", "Second Notice - _TC") + dunning.dunning_type = dunning_type.name + dunning.rate_of_interest = dunning_type.rate_of_interest + dunning.dunning_fee = dunning_type.dunning_fee + dunning.income_account = dunning_type.income_account + dunning.cost_center = dunning_type.cost_center + dunning.save() + + self.assertEqual(dunning.currency, "USD") + self.assertEqual(dunning.conversion_rate, 50) + self.assertEqual(round(dunning.dunning_amount, 2), 10.41) + self.assertEqual(round(dunning.base_dunning_amount, 2), 520.55) + + # nothing collected yet, in either currency + self.assertEqual(round(dunning.get_unpaid_base_dunning_amount(), 2), 520.55) + self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41) + + # the deduction booking the interest is in company currency + dunning.submit() + pe = get_payment_entry("Dunning", dunning.name) + self.assertEqual(round(pe.deductions[0].amount, 2), -520.55) + def test_fetch_overdue_payments(self): """ Create SI with overdue payment. Check if overdue payment is fetched in Dunning. From 11c6f0d02c9793cf63a956f255eccc46e47495ac Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 17 Aug 2026 11:55:38 +0530 Subject: [PATCH 11/29] fix: keep a dunning claimable until its interest is paid too a dunning was resolved as soon as the invoiced sum was settled, because the status was derived from the invoice outstanding alone. paying an invoice without the interest and fee therefore closed the dunning and lost the interest: a fresh dunning finds nothing overdue to charge it on. the dunning amount is never a receivable, it only reaches the ledger as a negative deduction on a payment entry made from the dunning. link that row to the dunning so what has been collected is known, and resolve a dunning only once the invoiced sum and the dunning amount are both paid. a dunning resolved by hand keeps its status, so waiving the interest stays possible. the deduction is a company currency field, so book and measure the dunning amount through base_dunning_amount instead of the transaction currency one. an interest-only payment leaves every invoice outstanding untouched, so update the linked dunnings from the payment entry itself instead of relying on the outstanding amount to change. such a payment also has to be built from what is left to collect, not from the totals the dunning was raised with, which are stale by then. (cherry picked from commit d5a9d158f92db71fbe0045e4e22dc3b0a766ac3a) --- erpnext/accounts/doctype/dunning/dunning.py | 99 ++++++++++++++++++- .../doctype/payment_entry/payment_entry.py | 60 ++++++----- .../payment_entry_deduction.json | 14 ++- .../payment_entry_deduction.py | 1 + 4 files changed, 143 insertions(+), 31 deletions(-) diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index c8e4adc5be4..55787ec1981 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -17,7 +17,8 @@ import json import frappe from frappe import _ from frappe.contacts.doctype.address.address import get_address_display -from frappe.utils import getdate +from frappe.query_builder.functions import Sum +from frappe.utils import flt, getdate from erpnext.controllers.accounts_controller import AccountsController @@ -140,6 +141,31 @@ class Dunning(AccountsController): ) row.dunning_level = len(past_dunnings) + 1 + def get_unpaid_base_dunning_amount(self): + """Interest and dunning fee that is still to be collected, in company currency.""" + if not self.base_dunning_amount: + return 0.0 + + return flt( + flt(self.base_dunning_amount) - get_paid_dunning_amount(self.name), + self.precision("base_dunning_amount"), + ) + + def get_unpaid_dunning_amount(self): + """Interest and dunning fee that is still to be collected, in the dunning currency.""" + return flt( + self.get_unpaid_base_dunning_amount() / (flt(self.conversion_rate) or 1), + self.precision("dunning_amount"), + ) + + def get_unpaid_overdue_payments(self): + """Overdue payments with their outstanding as of now, not as of dunning creation.""" + return [ + (row, outstanding) + for row in self.overdue_payments + if (outstanding := get_current_outstanding(row)) > 0 + ] + def on_cancel(self): super().on_cancel() self.ignore_linked_doctypes = [ @@ -154,6 +180,7 @@ class Dunning(AccountsController): "Unreconcile Payment Entries", "Payment Ledger Entry", "Serial and Batch Bundle", + "Payment Entry", ] @frappe.whitelist() @@ -252,11 +279,73 @@ def update_linked_dunnings(doc, previous_outstanding_amount): if has_outstanding: break - new_status = "Resolved" if not has_outstanding else "Unresolved" + set_dunning_status(dunning, has_outstanding, respect_manual_resolution=True) - if dunning.status != new_status: - dunning.status = new_status - dunning.save() + +def update_dunnings_linked_to_payment(payment_entry): + """Refresh dunnings whose interest and fee are settled by this payment.""" + dunnings = {row.dunning for row in payment_entry.get("deductions") if row.dunning} + + for name in dunnings: + dunning = frappe.get_doc("Dunning", name) + if dunning.docstatus != 1: + continue + + set_dunning_status(dunning, bool(dunning.get_unpaid_overdue_payments())) + + +def set_dunning_status(dunning, has_outstanding_payments: bool, respect_manual_resolution: bool = False): + """A dunning is only resolved once the invoiced sum *and* its interest and fee are paid.""" + has_unpaid_dunning_amount = dunning.get_unpaid_dunning_amount() > 0 + new_status = "Unresolved" if has_outstanding_payments or has_unpaid_dunning_amount else "Resolved" + + # resolving by hand waives the interest, only an invoice that is owed again reopens it + if respect_manual_resolution and dunning.status == "Resolved" and not has_outstanding_payments: + return + + if dunning.status != new_status: + dunning.db_set("status", new_status, notify=True) + + +def get_paid_dunning_amount(dunning: str) -> float: + """Interest and fee collected for this dunning, in company currency.""" + deduction = frappe.qb.DocType("Payment Entry Deduction") + payment_entry = frappe.qb.DocType("Payment Entry") + + paid = ( + frappe.qb.from_(deduction) + .join(payment_entry) + .on(payment_entry.name == deduction.parent) + .select(Sum(deduction.amount)) + .where((deduction.dunning == dunning) & (payment_entry.docstatus == 1)) + ).run() + + # the dunning amount is booked as a negative deduction, against the income account + return -flt(paid[0][0]) if paid else 0.0 + + +def get_current_outstanding(overdue_payment) -> float: + """Outstanding of an overdue payment as of now, in the invoice's transaction currency.""" + invoice = frappe.db.get_value( + "Sales Invoice", + overdue_payment.sales_invoice, + ["outstanding_amount", "currency", "party_account_currency"], + as_dict=True, + ) + schedule_outstanding = ( + flt(frappe.db.get_value("Payment Schedule", overdue_payment.payment_schedule, "outstanding")) + if overdue_payment.payment_schedule + else flt(overdue_payment.outstanding) + ) + + if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0: + return 0.0 + + outstanding = min(schedule_outstanding, flt(overdue_payment.outstanding)) + if invoice.currency == invoice.party_account_currency: + outstanding = min(outstanding, flt(invoice.outstanding_amount)) + + return outstanding def get_linked_dunnings_as_per_state(sales_invoice, state): diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 31abb641760..3ca9639a8b3 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -3,6 +3,7 @@ import json +from datetime import date from functools import reduce import frappe @@ -122,8 +123,14 @@ class PaymentEntry(AccountsController): self.update_payment_schedule() self.make_gl_entries() self.update_outstanding_amounts() + self.update_linked_dunnings() self.set_status() + def update_linked_dunnings(self): + from erpnext.accounts.doctype.dunning.dunning import update_dunnings_linked_to_payment + + update_dunnings_linked_to_payment(self) + def validate_for_repost(self): validate_docs_for_voucher_types(["Payment Entry"]) validate_docs_for_deferred_accounting([self.name], []) @@ -225,6 +232,7 @@ class PaymentEntry(AccountsController): self.update_payment_schedule(cancel=1) self.make_gl_entries(cancel=1) self.update_outstanding_amounts() + self.update_linked_dunnings() self.delink_advance_entry_references() self.set_status() @@ -2891,15 +2899,15 @@ def get_reference_details( @frappe.whitelist() def get_payment_entry( - dt, - dn, - party_amount=None, - bank_account=None, - bank_amount=None, - party_type=None, - payment_type=None, - reference_date=None, - created_from_payment_request=False, + dt: str, + dn: str, + party_amount: int | float | None = None, + bank_account: str | None = None, + bank_amount: int | float | None = None, + party_type: str | None = None, + payment_type: str | None = None, + reference_date: str | date | None = None, + created_from_payment_request: bool | None = False, ): frappe.has_permission("Payment Entry", ptype="create", throw=True) @@ -3000,7 +3008,7 @@ def get_payment_entry( pe.append("references", reference) else: if dt == "Dunning": - for overdue_payment in doc.overdue_payments: + for overdue_payment, outstanding in doc.get_unpaid_overdue_payments(): pe.append( "references", { @@ -3008,21 +3016,23 @@ def get_payment_entry( "reference_name": overdue_payment.sales_invoice, "payment_term": overdue_payment.payment_term, "due_date": overdue_payment.due_date, - "total_amount": overdue_payment.outstanding, - "outstanding_amount": overdue_payment.outstanding, - "allocated_amount": overdue_payment.outstanding, + "total_amount": outstanding, + "outstanding_amount": outstanding, + "allocated_amount": outstanding, }, ) - pe.append( - "deductions", - { - "account": doc.income_account, - "cost_center": doc.cost_center, - "amount": -1 * doc.dunning_amount, - "description": _("Interest and/or dunning fee"), - }, - ) + if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0: + pe.append( + "deductions", + { + "account": doc.income_account, + "cost_center": doc.cost_center, + "amount": -1 * unpaid_dunning_amount, + "description": _("Interest and/or dunning fee"), + "dunning": doc.name, + }, + ) else: pe.append( "references", @@ -3304,8 +3314,10 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre grand_total = doc.rounded_total or doc.grand_total outstanding_amount = doc.outstanding_amount elif dt == "Dunning": - grand_total = doc.grand_total - outstanding_amount = doc.grand_total + # only what is left to collect, the totals on the dunning are the ones it was raised with + grand_total = sum(outstanding for _row, outstanding in doc.get_unpaid_overdue_payments()) + grand_total += doc.get_unpaid_dunning_amount() + outstanding_amount = grand_total else: if party_account_currency == doc.company_currency: grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total")) diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json index 735d6b02857..2c3463914a5 100644 --- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json +++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json @@ -10,7 +10,8 @@ "amount", "column_break_2", "is_exchange_gain_loss", - "description" + "description", + "dunning" ], "fields": [ { @@ -55,12 +56,21 @@ "fieldtype": "Check", "label": "Is Exchange Gain / Loss?", "read_only": 1 + }, + { + "fieldname": "dunning", + "fieldtype": "Link", + "label": "Dunning", + "no_copy": 1, + "options": "Dunning", + "print_hide": 1, + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-03-11 14:26:11.312950", + "modified": "2026-08-17 11:20:35.482913", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry Deduction", diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py index ae4134fc27a..af29bcda4a8 100644 --- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py +++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py @@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document): amount: DF.Currency cost_center: DF.Link description: DF.SmallText | None + dunning: DF.Link | None is_exchange_gain_loss: DF.Check parent: DF.Data parentfield: DF.Data From 0099e7ec4f16c880807a4c544a468e90c45e2948 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Wed, 9 Sep 2026 12:54:10 +0530 Subject: [PATCH 12/29] fix(stock): remove empty delivery stops after mapping (#58896) Co-authored-by: Mihir Kandoi (cherry picked from commit 4b23cee2eafd8637724ec1adaca31816325daf6f) --- .../doctype/delivery_trip/delivery_trip.py | 7 ++++- .../delivery_trip/test_delivery_trip.py | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.py b/erpnext/stock/doctype/delivery_trip/delivery_trip.py index fbaafbe73fd..1e8c624f472 100644 --- a/erpnext/stock/doctype/delivery_trip/delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.py @@ -50,6 +50,11 @@ class DeliveryTrip(Document): "UOM Conversion Factor", {"from_uom": "Meter", "to_uom": self.default_distance_uom}, "value" ) + def after_mapping(self, source_doc): + for stop in self.delivery_stops[:]: + if not any(stop.get(df.fieldname) for df in stop.meta.fields): + self.remove(stop) + def validate(self): if self._action == "submit" and not self.driver: frappe.throw(_("A driver must be set to submit.")) @@ -69,7 +74,7 @@ class DeliveryTrip(Document): def validate_stop_addresses(self): for stop in self.delivery_stops: - if not stop.customer_address: + if stop.address and not stop.customer_address: stop.customer_address = get_address_display(frappe.get_doc("Address", stop.address).as_dict()) def update_status(self): diff --git a/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py b/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py index 09f5b2c2c89..c9075f30fdd 100644 --- a/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py @@ -7,6 +7,7 @@ from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, flt, now_datetime, nowdate import erpnext +from erpnext.stock.doctype.delivery_note.delivery_note import make_delivery_trip from erpnext.stock.doctype.delivery_trip.delivery_trip import ( get_contact_and_address, notify_customers, @@ -100,6 +101,32 @@ class TestDeliveryTrip(FrappeTestCase): self.delivery_trip.save() self.assertEqual(self.delivery_trip.status, "Completed") + def map_delivery_note_onto_trip(self, existing_stop): + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + delivery_note = create_delivery_note() + trip = frappe.new_doc("Delivery Trip") + trip.append("delivery_stops", existing_stop) + + return delivery_note, make_delivery_trip(delivery_note.name, trip) + + def test_mapping_drops_placeholder_stop(self): + delivery_note, trip = self.map_delivery_note_onto_trip({}) + + self.assertEqual(len(trip.delivery_stops), 1) + self.assertEqual(trip.delivery_stops[0].delivery_note, delivery_note.name) + + def test_mapping_keeps_partially_filled_stop(self): + _, trip = self.map_delivery_note_onto_trip({"customer": "_Test Customer"}) + + self.assertEqual(len(trip.delivery_stops), 2) + self.assertIsNone(trip.delivery_stops[0].delivery_note) + + def test_stop_without_address_throws_mandatory_error(self): + self.delivery_trip.append("delivery_stops", {"customer": "_Test Customer"}) + + self.assertRaises(frappe.MandatoryError, self.delivery_trip.save) + def create_address(driver): if not frappe.db.exists("Address", {"address_title": "_Test Address for Driver"}): From 228108ad4cb285329bacf8f2f72c650ebfc99ae1 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:20:26 +0000 Subject: [PATCH 13/29] fix(accounts): stop counting received qty on non-stock invoice returns (backport #58924) (#58928) Co-authored-by: Mihir Kandoi --- .../purchase_invoice/purchase_invoice.js | 30 +++++++++---------- .../purchase_invoice/test_purchase_invoice.py | 17 +++++++++++ .../controllers/sales_and_purchase_return.py | 7 ++++- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index cb7f9d6af76..fa254f5f356 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -78,7 +78,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. const me = this; super.refresh(); - hide_fields(this.frm.doc); + hide_fields(this.frm); // Show / Hide button this.show_general_ledger(); erpnext.accounts.ledger_preview.show_accounting_ledger_preview(this.frm); @@ -435,7 +435,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. } is_paid() { - hide_fields(this.frm.doc); + hide_fields(this.frm); if (cint(this.frm.doc.is_paid)) { this.frm.set_value("allocate_advances_automatically", 0); this.frm.set_value("payment_terms_template", ""); @@ -499,28 +499,26 @@ cur_frm.script_manager.make(erpnext.accounts.PurchaseInvoice); // Hide Fields // ------------ -function hide_fields(doc) { - var parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"]; +function hide_fields(frm) { + const doc = frm.doc; + const parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"]; if (cint(doc.is_paid) == 1) { - hide_field(parent_fields); + frm.toggle_display(parent_fields, false); } else { - for (var i in parent_fields) { - var docfield = frappe.meta.docfield_map[doc.doctype][parent_fields[i]]; - if (!docfield.hidden) unhide_field(parent_fields[i]); + for (const fieldname of parent_fields) { + const docfield = frappe.meta.docfield_map[doc.doctype][fieldname]; + if (!docfield.hidden) frm.toggle_display(fieldname, true); } } - var item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"]; + const item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"]; - if (cur_frm.fields_dict["items"]) { - cur_frm.fields_dict["items"].grid.set_column_disp( - item_fields_stock, - cint(doc.update_stock) == 1 || cint(doc.is_return) == 1 ? true : false - ); + if (frm.fields_dict["items"]) { + frm.fields_dict["items"].grid.set_column_disp(item_fields_stock, cint(doc.update_stock) == 1); } - cur_frm.refresh_fields(); + frm.refresh_fields(); } cur_frm.fields_dict.cash_bank_account.get_query = function (doc) { @@ -736,7 +734,7 @@ frappe.ui.form.on("Purchase Invoice", { }, update_stock: function (frm) { - hide_fields(frm.doc); + hide_fields(frm); frm.fields_dict.items.grid.toggle_reqd("item_code", frm.doc.update_stock ? true : false); }, diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 44d94499516..e918d84b0ca 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -3081,6 +3081,23 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): self.assertRaises(StockOverReturnError, return_doc.save) + def test_partial_returns_ignore_received_qty_without_update_stock(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + invoice = make_purchase_invoice(qty=10, received_qty=10) + + first_return = make_return_doc(invoice.doctype, invoice.name) + first_return.items[0].qty = -4 + first_return.save().submit() + + self.assertEqual(first_return.items[0].received_qty, -10) + + second_return = make_return_doc(invoice.doctype, invoice.name) + second_return.items[0].qty = -6 + second_return.save().submit() + + self.assertEqual(second_return.docstatus, 1) + def test_apply_discount_on_grand_total(self): """ To test if after applying discount on grand total, diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 4d348112446..5bd9c535919 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -190,7 +190,12 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items): if (doc.doctype == "Purchase Invoice" or doc.doctype == "Sales Invoice") and not doc.update_stock: fields = ["qty"] - if doc.doctype in ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"]: + tracks_accepted_rejected_split = doc.doctype in ( + "Purchase Receipt", + "Subcontracting Receipt", + ) or (doc.doctype == "Purchase Invoice" and doc.update_stock) + + if tracks_accepted_rejected_split: if not args.get("return_qty_from_rejected_warehouse"): fields.extend(["received_qty", "rejected_qty"]) else: From c31ae577e4843f81c1ac8ccaaec84507ae62922a Mon Sep 17 00:00:00 2001 From: Shllokkk <140623894+Shllokkk@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:03:59 +0530 Subject: [PATCH 14/29] fix: % Amount Billed exceeds 100% on fully rejected Purchase Receipts (#58897) * fix: include rejected qty in Purchase Receipt billing base * test: per billed stays 100% for fully rejected receipt (cherry picked from commit 79c8e4db7f768f2145642bfc868bb3555b5ad65c) --- .../purchase_receipt/purchase_receipt.py | 14 ++++---- .../purchase_receipt/test_purchase_receipt.py | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 00145ffa47c..681314dbd5f 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -1138,10 +1138,14 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate returned_qty = flt(item_wise_returned_qty.get(item.name)) returned_amount = flt(returned_qty) * flt(item.rate) pending_amount = flt(item.amount) - returned_amount - if buying_settings.bill_for_rejected_quantity_in_purchase_invoice: - pending_amount = flt(item.amount) - total_billable_amount = abs(flt(item.amount)) + # When rejected qty is billable, its value is part of the billable base too + rejected_amount = 0.0 + if buying_settings.bill_for_rejected_quantity_in_purchase_invoice: + rejected_amount = flt(item.rejected_qty * item.rate, item.precision("amount")) + pending_amount = flt(item.amount) + rejected_amount + + total_billable_amount = abs(flt(item.amount) + rejected_amount) if pending_amount > 0: total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt @@ -1151,9 +1155,7 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate if pr_doc.get("is_return") and not total_amount and total_billed_amount: total_amount = total_billed_amount - amount = item.amount - if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): - amount += flt(item.rejected_qty * item.rate, item.precision("amount")) + amount = flt(item.amount) + rejected_amount if adjust_incoming_rate: adjusted_amt = 0.0 diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index f30158b4f51..d8bb216a430 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -605,6 +605,42 @@ class TestPurchaseReceipt(FrappeTestCase): return_pr.cancel() pr.cancel() + def test_per_billed_for_fully_rejected_receipt(self): + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import update_billing_percentage + + bill_rejected = frappe.db.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) + frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1) + + try: + # Fully rejected receipt: accepted qty 0, whole qty in rejected warehouse + pr = make_purchase_receipt( + received_qty=10, + qty=0, + rejected_qty=10, + rate=9.5, + rejected_warehouse="_Test Warehouse 1 - _TC", + do_not_save=True, + ) + pr.items[0].warehouse = "" + pr.submit() + + # Bill the rejected qty (10 x 9.5) directly against the receipt item + pr.items[0].db_set("billed_amt", 95) + update_billing_percentage(pr) + + pr.load_from_db() + # Billing the rejected qty must not push per_billed above 100 + self.assertEqual(pr.per_billed, 100) + self.assertEqual(pr.status, "Completed") + + pr.cancel() + finally: + frappe.db.set_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", bill_rejected + ) + def test_purchase_receipt_for_rejected_gle_without_accepted_warehouse(self): from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse From e37ba3f8cf376ed4eb0c4b884954b183c5573be3 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:58:21 +0000 Subject: [PATCH 15/29] fix(selling): add email permission check on `send_emails` (backport #58935) (#58937) Co-authored-by: Diptanil Saha --- erpnext/selling/doctype/customer/customer.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 8245c75770a..fe06db02c55 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -609,11 +609,8 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False, # if the current user does not have permissions to override credit limit, # prompt them to send out an email to the controller users - frappe.msgprint( - message, - title=_("Credit Limit Crossed"), - raise_exception=1, - primary_action={ + primary_action = ( + { "label": "Send Email", "server_action": "erpnext.selling.doctype.customer.customer.send_emails", "hide_on_success": True, @@ -623,7 +620,16 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False, "credit_limit": credit_limit, "credit_controller_users_list": credit_controller_users, }, - }, + } + if frappe.has_permission("Customer", ptype="email", doc=customer) + else None + ) + + frappe.msgprint( + message, + title=_("Credit Limit Crossed"), + raise_exception=1, + primary_action=primary_action, ) @@ -631,6 +637,7 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False, def send_emails(args): args = json.loads(args) subject = _("Credit limit reached for customer {0}").format(args.get("customer")) + frappe.has_permission("Customer", ptype="email", doc=args.get("customer"), throw=True) message = _("Credit limit has been crossed for customer {0} ({1}/{2})").format( args.get("customer"), args.get("customer_outstanding"), args.get("credit_limit") ) From c72bc6925bb5f8a2ee598e723be2fc289d66284e Mon Sep 17 00:00:00 2001 From: pandiyan Date: Wed, 9 Sep 2026 18:05:03 +0530 Subject: [PATCH 16/29] fix(manufacturing): handle empty raw materials in workstation (#58927) (cherry picked from commit 3be0c7801afd0ed16fcd1a986c842437d2355c26) --- .../doctype/job_card/job_card.py | 5 +- .../doctype/workstation/test_workstation.py | 48 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index b1854088975..16a454a984b 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1182,7 +1182,7 @@ def make_material_request(source_name, target_doc=None): @frappe.whitelist() -def make_stock_entry(source_name, target_doc=None): +def make_stock_entry(source_name: str, target_doc: Document | str | None = None): def update_item(source, target, source_parent): target.t_warehouse = source_parent.wip_warehouse @@ -1194,6 +1194,9 @@ def make_stock_entry(source_name, target_doc=None): target.qty = pending_rm_qty def set_missing_values(source, target): + if not source.items: + frappe.throw(_("This Job Card has no raw materials to transfer.")) + target.purpose = "Material Transfer for Manufacture" target.from_bom = 1 diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py index 406d08f2e11..10d53049045 100644 --- a/erpnext/manufacturing/doctype/workstation/test_workstation.py +++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py @@ -4,20 +4,66 @@ import frappe from frappe.test_runner import make_test_records from frappe.tests.utils import FrappeTestCase +from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.manufacturing.doctype.routing.test_routing import create_routing, setup_bom from erpnext.manufacturing.doctype.workstation.workstation import ( NotInWorkingHoursError, WorkstationHolidayError, check_if_within_operating_hours, + get_raw_materials, ) -test_dependencies = ["Warehouse"] +test_dependencies = ["Warehouse", "Item"] test_records = frappe.get_test_records("Workstation") make_test_records("Workstation") class TestWorkstation(FrappeTestCase): + def test_get_raw_materials_without_items(self): + job_card = frappe.get_doc( + { + "doctype": "Job Card", + "company": "_Test Company", + "wip_warehouse": "_Test Warehouse 1 - _TC", + } + ).insert(ignore_mandatory=True) + + self.assertEqual(get_raw_materials([job_card.name]), {}) + with self.assertRaisesRegex(frappe.ValidationError, "This Job Card has no raw materials to transfer"): + make_stock_entry(job_card.name) + + job_card.reload() + self.assertFalse(job_card.items) + self.assertFalse(frappe.db.exists("Stock Entry", {"job_card": job_card.name})) + + def test_get_raw_materials_with_items(self): + job_card = frappe.get_doc( + { + "doctype": "Job Card", + "company": "_Test Company", + "wip_warehouse": "_Test Warehouse 1 - _TC", + "items": [ + { + "item_code": "_Test Item", + "source_warehouse": "_Test Warehouse - _TC", + "required_qty": 5, + "transferred_qty": 2, + } + ], + } + ).insert(ignore_mandatory=True) + + materials = get_raw_materials([job_card.name]) + + self.assertEqual(list(materials), [job_card.name]) + self.assertEqual(len(materials[job_card.name]), 1) + material = materials[job_card.name][0] + self.assertEqual(material.item_code, "_Test Item") + self.assertEqual(material.required_qty, 5) + self.assertEqual(material.transferred_qty, 2) + self.assertEqual(material.source_warehouse, "_Test Warehouse - _TC") + def test_validate_timings(self): check_if_within_operating_hours( "_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00" From f59a6ec88247b268728642aad70e34a5e73937ac Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:09:00 +0000 Subject: [PATCH 17/29] fix(stock): do not rebuild the Warehouse tree on an account map miss (backport #58956) (#58972) Co-authored-by: Mihir Kandoi --- erpnext/stock/__init__.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/erpnext/stock/__init__.py b/erpnext/stock/__init__.py index 19d8bcd21e3..4d7f54ff39e 100644 --- a/erpnext/stock/__init__.py +++ b/erpnext/stock/__init__.py @@ -68,12 +68,8 @@ def get_warehouse_account(warehouse, warehouse_account=None, *, raise_error=True account = warehouse.account if not account and warehouse.parent_warehouse: if warehouse_account: - if warehouse_account.get(warehouse.parent_warehouse): - account = warehouse_account.get(warehouse.parent_warehouse).account - else: - from frappe.utils.nestedset import rebuild_tree - - rebuild_tree("Warehouse", "parent_warehouse") + if parent := warehouse_account.get(warehouse.parent_warehouse): + account = parent.account else: account = frappe.db.sql( """ From 52ea7ed606858fc97f3d3bb938696def664708d2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 10 Sep 2026 20:51:03 +0530 Subject: [PATCH 18/29] fix(selling): exclude fully billed orders from the invoice picker (backport #58966) (#58968) --- .../doctype/sales_order/sales_order.py | 27 ++++++++++-- .../doctype/sales_order/test_sales_order.py | 42 +++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 7710bb8c434..d2349f4965a 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -14,7 +14,7 @@ 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 Case, Criterion -from frappe.query_builder.functions import Abs, Sum +from frappe.query_builder.functions import Abs, IfNull, Round, Sum from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, strip_html from pypika import Order @@ -1973,8 +1973,26 @@ def get_stock_reservation_status(): return frappe.db.get_single_value("Stock Settings", "enable_stock_reservation") +def get_pending_qty_criterion(sales_order_item): + """Mirror the mapper's pending quantity check.""" + invoice_item = qb.DocType("Sales Invoice Item") + billed_qty = ( + qb.from_(invoice_item) + .select(IfNull(Sum(invoice_item.qty), 0)) + .where((invoice_item.docstatus == 1) & (invoice_item.so_detail == sales_order_item.name)) + ) + + qty_precision = frappe.get_precision("Sales Order Item", "qty") + has_unbilled_ordered_qty = Round(sales_order_item.qty - billed_qty, qty_precision) > 0 + has_unbilled_delivered_qty = ( + Round(sales_order_item.qty - sales_order_item.returned_qty - billed_qty, qty_precision) > 0 + ) | (Round(sales_order_item.delivered_qty - billed_qty, qty_precision) > 0) + + return has_unbilled_ordered_qty & has_unbilled_delivered_qty + + def get_potentially_billable_item_criterion(sales_order, sales_order_item, item): - """Return the amount check for UI candidates. The mapper checks pending quantity.""" + """Return the row level checks the Sales Invoice mapper applies.""" global_allowance = flt(frappe.get_cached_value("Accounts Settings", None, "over_billing_allowance")) allowance = ( Case().when(item.over_billing_allowance != 0, item.over_billing_allowance).else_(global_allowance) @@ -1984,8 +2002,11 @@ def get_potentially_billable_item_criterion(sales_order, sales_order_item, item) Abs(sales_order_item.billed_amt) < Abs(sales_order_item.amount) * (1 + allowance / 100) ) is_unit_price_row = (sales_order.has_unit_price_items == 1) & (sales_order_item.qty == 0) + is_billable_row = ( + (sales_order_item.qty != 0) & has_amount_headroom & get_pending_qty_criterion(sales_order_item) + ) - return is_unit_price_row | ((sales_order_item.qty != 0) & has_amount_headroom) + return is_unit_price_row | is_billable_row def has_potentially_billable_items(sales_order: str) -> bool: diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 58df9766cc5..9605a92d43a 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -290,6 +290,48 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertEqual(len(make_sales_invoice(so.name).items), 0) + def test_fully_billed_order_is_not_offered_within_billing_allowance(self): + item = make_item( + "_Test Fully Billed Allowance Item", + {"is_stock_item": 1, "over_billing_allowance": 0}, + ).name + so = make_sales_order(item_code=item, qty=10, rate=100) + + si = make_sales_invoice(so.name) + si.insert() + si.submit() + + so.load_from_db() + self.assertEqual(flt(so.per_billed), 100) + + filters = {"docstatus": 1, "company": so.company, "customer": so.customer} + + with change_settings("Accounts Settings", {"over_billing_allowance": 100}): + self.assertFalse(has_potentially_billable_items(so.name)) + + rows = get_potentially_billable_sales_orders("Sales Order", "", "name", 0, 50, filters) + self.assertNotIn(so.name, [row.name for row in rows]) + + self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0) + + def test_order_with_sub_precision_pending_qty_is_not_offered(self): + item = make_item("_Test Sub Precision Qty Item", {"is_stock_item": 1}).name + so = make_sales_order(item_code=item, qty=10, rate=100) + + si = make_sales_invoice(so.name) + si.get("items")[0].rate = 90 + si.insert() + si.submit() + + qty_precision = frappe.get_precision("Sales Order Item", "qty") + billed_qty = 10 - 10 ** -(qty_precision + 1) + frappe.db.set_value( + "Sales Invoice Item", si.get("items")[0].name, "qty", billed_qty, update_modified=False + ) + + self.assertFalse(has_potentially_billable_items(so.name)) + self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0) + def test_make_sales_invoice_after_return_and_redelivery(self): from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return From 563615c77ec2076af91711d63578dadb755b6771 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:56:26 +0200 Subject: [PATCH 19/29] fix(stock): recalculate billing status of returned delivery notes (backport #58953) (#58986) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- erpnext/patches.txt | 3 +- ...e_returned_delivery_note_billing_status.py | 32 ++++++++++ .../delivery_note/test_delivery_note.py | 61 +++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 erpnext/patches/v16_0/recalculate_returned_delivery_note_billing_status.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 755d274227f..eec88dc76b5 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -449,4 +449,5 @@ erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.merge_seeded_item_group_root erpnext.patches.v16_0.remove_frappe_crm_custom_fields erpnext.patches.v16_0.append_fieldname_to_pos_search_fields -erpnext.patches.v16_0.add_transaction_roles_to_sms_settings \ No newline at end of file +erpnext.patches.v16_0.add_transaction_roles_to_sms_settings +erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status diff --git a/erpnext/patches/v16_0/recalculate_returned_delivery_note_billing_status.py b/erpnext/patches/v16_0/recalculate_returned_delivery_note_billing_status.py new file mode 100644 index 00000000000..c0ba3ac252d --- /dev/null +++ b/erpnext/patches/v16_0/recalculate_returned_delivery_note_billing_status.py @@ -0,0 +1,32 @@ +import frappe + + +def execute(): + """Recalculate billing status of Delivery Notes left open by a return. + + Returning the uninvoiced qty of a Delivery Note did not recalculate the original + Delivery Note, so it stayed "To Bill" / "Partially Billed" with nothing left to invoice. + """ + dn = frappe.qb.DocType("Delivery Note") + dn_item = frappe.qb.DocType("Delivery Note Item") + + delivery_notes = ( + frappe.qb.from_(dn) + .inner_join(dn_item) + .on(dn_item.parent == dn.name) + .select(dn.name) + .distinct() + .where( + (dn.docstatus == 1) + & (dn.is_return == 0) + & dn.status.isin(["To Bill", "Partially Billed"]) + & (dn_item.returned_qty > 0) + ) + .run(pluck=True) + ) + + for name in delivery_notes: + doc = frappe.get_doc("Delivery Note", name) + doc.update_billing_percentage(update_modified=False) + doc.load_from_db() + doc.set_status(update=True, update_modified=False) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 4ee8f3289af..458e22fb0bd 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -1099,6 +1099,67 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(dn.per_billed, 50) self.assertEqual(dn.status, "Partially Billed") + def test_billing_status_repair_patch(self): + """Returns submitted before #58869 left the original Delivery Note's per_billed stale. + + The repair patch recalculates such notes: a directly invoiced one whose remaining + qty was returned becomes Completed, an uninvoiced Sales Order linked one goes back + to To Bill. + """ + from erpnext.patches.v16_0 import recalculate_returned_delivery_note_billing_status as patch + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + + # Delivery Note invoiced for 2 of 5 qty, the remaining 3 returned -> fully billed + make_stock_entry(target="_Test Warehouse - _TC", qty=5, basic_rate=100) + dn = create_delivery_note(qty=5) + + si = make_sales_invoice(dn.name) + si.items[0].qty = 2 + si.insert() + si.submit() + + dn_return = make_sales_return(dn.name) + dn_return.items[0].qty = -3 + dn_return.insert() + # Mimic the submit request, which reconstructs the document from client data. + frappe.get_doc(dn_return.as_dict()).submit() + + dn.load_from_db() + self.assertEqual(dn.items[0].returned_qty, 3) + self.assertEqual(dn.per_billed, 100) + + # Sales Order linked Delivery Note, nothing invoiced, partly returned -> unbilled + so = make_sales_order(qty=10) + so_dn = create_dn_against_so(so.name, delivered_qty=5) + + so_dn_return = make_sales_return(so_dn.name) + so_dn_return.items[0].qty = -2 + so_dn_return.insert() + frappe.get_doc(so_dn_return.as_dict()).submit() + + so_dn.load_from_db() + self.assertEqual(so_dn.items[0].returned_qty, 2) + self.assertEqual(so_dn.per_billed, 0) + + # Mimic the state left behind by a return submitted before the fix + for name, per_billed in ((dn.name, 40), (so_dn.name, 50)): + frappe.db.set_value( + "Delivery Note", + name, + {"per_billed": per_billed, "status": "Partially Billed"}, + update_modified=False, + ) + + patch.execute() + + dn.load_from_db() + self.assertEqual(dn.per_billed, 100) + self.assertEqual(dn.status, "Completed") + + so_dn.load_from_db() + self.assertEqual(so_dn.per_billed, 0) + self.assertEqual(so_dn.status, "To Bill") + def test_dn_billing_status_case2(self): # SO -> SI and SO -> DN1, DN2 from erpnext.selling.doctype.sales_order.sales_order import ( From 85a152946e3ff1734a66c7315aff6ff093002d61 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:38:50 +0530 Subject: [PATCH 20/29] fix(accounts): add permission checks on `execute_repost_payment_ledger` (backport #58980) (#58981) Co-authored-by: Diptanil Saha --- .../doctype/repost_payment_ledger/repost_payment_ledger.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py index be896b6f143..53549cdd6c4 100644 --- a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py +++ b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py @@ -123,6 +123,8 @@ class RepostPaymentLedger(Document): def execute_repost_payment_ledger(docname): """Repost Payment Ledger Entries by background job.""" + frappe.has_permission("Repost Payment Ledger", ptype="submit", doc=docname, throw=True) + job_name = "payment_ledger_repost_" + docname frappe.enqueue( From 62b9f312979252e55d85eb72df91f9c37e37afd6 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:42:54 +0000 Subject: [PATCH 21/29] fix(accounts): add permission checks on `invoice_discounting.get_invoices` (backport #58975) (#58989) Co-authored-by: Diptanil Saha --- .../doctype/invoice_discounting/invoice_discounting.js | 1 + .../doctype/invoice_discounting/invoice_discounting.json | 6 +++--- .../doctype/invoice_discounting/invoice_discounting.py | 7 +++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js index 44aa2eac98d..cb5a83050b7 100644 --- a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js +++ b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js @@ -136,6 +136,7 @@ frappe.ui.form.on("Invoice Discounting", { ], primary_action: function () { var data = d.get_values(); + data.company = frm.doc.company; frappe.call({ method: "erpnext.accounts.doctype.invoice_discounting.invoice_discounting.get_invoices", diff --git a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.json b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.json index 3bfe2594326..63b24d2c636 100644 --- a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.json +++ b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.json @@ -168,7 +168,7 @@ } ], "is_submittable": 1, - "modified": "2019-05-30 19:08:21.199759", + "modified": "2026-09-09 17:04:59.512294", "modified_by": "Administrator", "module": "Accounts", "name": "Invoice Discounting", @@ -185,7 +185,7 @@ "print": 1, "read": 1, "report": 1, - "role": "System Manager", + "role": "Accounts Manager", "share": 1, "submit": 1, "write": 1 @@ -194,4 +194,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py index 5d3c2b987ba..a10ba7377f6 100644 --- a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py +++ b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py @@ -319,6 +319,13 @@ class InvoiceDiscounting(AccountsController): @frappe.whitelist() def get_invoices(filters): filters = frappe._dict(json.loads(filters)) + + if not filters.get("company"): + frappe.throw(_("Please set company on the Document before requesting for invoices.")) + + frappe.has_permission("Company", doc=filters.get("company"), throw=True) + frappe.has_permission("Invoice Discounting", throw=True) + cond = [] if filters.customer: cond.append("customer=%(customer)s") From f9433683ec73ef759f4d1551c4f37b2a335a51d9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:21:39 +0200 Subject: [PATCH 22/29] fix: Backfill `not_applicable` on Item Tax Template Details for German companies (backport #54682) (backport #54686) (#58952) * fix: Backfill `not_applicable` on Item Tax Template Details for German companies (backport #54682) (#54686) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> fix: Backfill `not_applicable` on Item Tax Template Details for German companies (#54682) (cherry picked from commit a22d773341faffdad2f3389e77e354e9e0a914f4) # Conflicts: # erpnext/patches.txt * chore: resolve conflict * fix: compare zero-rate row count, not just the identifier set * chore: correct comment on account name uniqueness * test: cover German `not_applicable` backfill patch Run the patch against a company seeded from the shipped German defaults: matching defaults are backfilled, customised templates and templates with a duplicate account name are left alone, and a rerun changes nothing. --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: barredterra <14891507+barredterra@users.noreply.github.com> --- .../test_item_tax_template.py | 136 +++++++++++ erpnext/patches.txt | 1 + ...applicable_on_german_item_tax_templates.py | 226 ++++++++++++++++++ 3 files changed, 363 insertions(+) create mode 100644 erpnext/patches/v16_0/set_not_applicable_on_german_item_tax_templates.py diff --git a/erpnext/accounts/doctype/item_tax_template/test_item_tax_template.py b/erpnext/accounts/doctype/item_tax_template/test_item_tax_template.py index e8638bb355d..231de8e42aa 100644 --- a/erpnext/accounts/doctype/item_tax_template/test_item_tax_template.py +++ b/erpnext/accounts/doctype/item_tax_template/test_item_tax_template.py @@ -3,6 +3,142 @@ import unittest +import frappe +from frappe.tests.utils import FrappeTestCase + +from erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates import ( + execute as backfill_not_applicable, +) + class TestItemTaxTemplate(unittest.TestCase): pass + + +class TestGermanNotApplicableBackfill(FrappeTestCase): + """Run the `not_applicable` backfill patch against a seeded German company. + + The company is created from the shipped German defaults, so the templates the + patch has to recognise are the ones a real site got. Each test resets the flag + to its pre-patch state (`not_applicable = 0`) and runs the patch. + """ + + TITLES = ("19 %", "7 %", "0%") + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = "_Test German Item Tax Templates" + if not frappe.db.exists("Company", cls.company): + frappe.get_doc( + { + "doctype": "Company", + "company_name": cls.company, + "abbr": "_TGITT", + "country": "Germany", + "default_currency": "EUR", + "create_chart_of_accounts_based_on": "Standard Template", + "chart_of_accounts": "Standard", + } + ).insert() + + cls.templates = { + title: frappe.db.get_value("Item Tax Template", {"company": cls.company, "title": title}, "name") + for title in cls.TITLES + } + assert all(cls.templates.values()), f"German defaults not seeded: {cls.templates}" + + def setUp(self): + frappe.db.savepoint("before_backfill_test") + self.addCleanup(frappe.db.rollback, save_point="before_backfill_test") + self.seeded_flags = self.flagged_rows() + # every default template ships not-applicable rows, otherwise the patch + # would be tested against effectively empty data + for title in self.TITLES: + self.assertTrue(self.seeded_flags[title], f"no not-applicable rows seeded in {title}") + + def flagged_rows(self, title=None) -> dict[str, set]: + """Detail rows currently marked as not applicable, per template title.""" + return { + t: { + d.name + for d in frappe.get_all( + "Item Tax Template Detail", + filters={"parent": name, "not_applicable": 1}, + fields=["name"], + ) + } + for t, name in self.templates.items() + if title in (None, t) + } + + def clear_flags(self): + """Restore the pre-patch state: zero rate, no flag.""" + for name in self.templates.values(): + frappe.db.set_value( + "Item Tax Template Detail", + {"parent": name}, + "not_applicable", + 0, + update_modified=False, + ) + self.assertEqual(self.flagged_rows(), {t: set() for t in self.TITLES}) + + def add_zero_rate_row(self, title, account_name, account_number): + """Add a user-defined zero-rate row, as a customised site would have.""" + like_account = frappe.db.get_value( + "Account", {"company": self.company, "account_name": "Umsatzsteuer 19 %"}, "name" + ) + account = frappe.get_doc( + { + "doctype": "Account", + "company": self.company, + "account_name": account_name, + "account_number": account_number, + "account_type": "Tax", + "parent_account": frappe.db.get_value("Account", like_account, "parent_account"), + } + ).insert() + + template = frappe.get_doc("Item Tax Template", self.templates[title]) + template.append("taxes", {"tax_type": account.name, "tax_rate": 0}) + template.save() + + def test_backfills_unmodified_defaults(self): + self.clear_flags() + backfill_not_applicable() + self.assertEqual(self.flagged_rows(), self.seeded_flags) + + def test_keeps_customised_template_untouched(self): + self.clear_flags() + self.add_zero_rate_row("19 %", "Sonstige Umsatzsteuer", "9998") + backfill_not_applicable() + + self.assertEqual(self.flagged_rows("19 %"), {"19 %": set()}) + self.assertEqual(self.flagged_rows("7 %"), {"7 %": self.seeded_flags["7 %"]}) + + def test_keeps_duplicate_account_name_untouched(self): + """A numbered account can share `account_name` with a default one. + + Its identifier collapses onto the default's, so only the row count tells + the customised template apart from an untouched one. + """ + self.clear_flags() + self.add_zero_rate_row("7 %", "Umsatzsteuer 19 %", "9999") + backfill_not_applicable() + + self.assertEqual(self.flagged_rows("7 %"), {"7 %": set()}) + self.assertEqual(self.flagged_rows("19 %"), {"19 %": self.seeded_flags["19 %"]}) + + def test_rerun_changes_nothing(self): + def snapshot(): + return frappe.get_all( + "Item Tax Template Detail", + filters={"parent": ("in", tuple(self.templates.values()))}, + fields=["name", "not_applicable", "tax_rate", "modified"], + order_by="name", + ) + + before = snapshot() + backfill_not_applicable() + self.assertEqual(snapshot(), before) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index eec88dc76b5..b0528ce7bfa 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -451,3 +451,4 @@ erpnext.patches.v16_0.remove_frappe_crm_custom_fields erpnext.patches.v16_0.append_fieldname_to_pos_search_fields erpnext.patches.v16_0.add_transaction_roles_to_sms_settings erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status +erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates diff --git a/erpnext/patches/v16_0/set_not_applicable_on_german_item_tax_templates.py b/erpnext/patches/v16_0/set_not_applicable_on_german_item_tax_templates.py new file mode 100644 index 00000000000..13c03edb8b7 --- /dev/null +++ b/erpnext/patches/v16_0/set_not_applicable_on_german_item_tax_templates.py @@ -0,0 +1,226 @@ +import frappe + +# Snapshot of the relevant German defaults when this migration was written. +# Migration patches must not read mutable setup data, otherwise future edits to +# country_wise_tax.json would change what this patch does on sites that have not +# run it yet. +# +# For numbered charts, compare account_number + root_type because Account.account_name +# is not unique within a company. +SKR04_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS = frozenset( + { + ("3801", "Liability"), + ("3802", "Liability"), + ("3835", "Liability"), + ("1401", "Asset"), + ("1402", "Asset"), + ("1541", "Asset"), + } +) + +SKR04_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS = frozenset( + { + ("3806", "Liability"), + ("3804", "Liability"), + ("3837", "Liability"), + ("1406", "Asset"), + ("1404", "Asset"), + ("1540", "Asset"), + } +) + +SKR03_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS = frozenset( + { + ("1771", "Liability"), + ("1772", "Liability"), + ("1785", "Liability"), + ("1571", "Asset"), + ("1572", "Asset"), + ("1541", "Asset"), + } +) + +SKR03_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS = frozenset( + { + ("1776", "Liability"), + ("1774", "Liability"), + ("1787", "Liability"), + ("1576", "Asset"), + ("1574", "Asset"), + ("1540", "Asset"), + } +) + +STANDARD_NOT_APPLICABLE_7_PERCENT_ACCOUNT_LABELS = frozenset( + { + ("Umsatzsteuer 7 %", "Liability"), + ("Umsatzsteuer aus innergemeinschaftlichem Erwerb", "Liability"), + ("Umsatzsteuer nach § 13b UStG", "Liability"), + ("Abziehbare Vorsteuer 7 %", "Asset"), + ("Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb", "Asset"), + ("Abziehbare Vorsteuer nach § 13b UStG", "Asset"), + } +) + +STANDARD_NOT_APPLICABLE_19_PERCENT_ACCOUNT_LABELS = frozenset( + { + ("Umsatzsteuer 19 %", "Liability"), + ("Umsatzsteuer aus innergemeinschaftlichem Erwerb 19 %", "Liability"), + ("Umsatzsteuer nach § 13b UStG 19 %", "Liability"), + ("Abziehbare Vorsteuer 19 %", "Asset"), + ("Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb 19 %", "Asset"), + ("Abziehbare Vorsteuer nach § 13b UStG 19 %", "Asset"), + } +) + +STANDARD_WITH_NUMBERS_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS = frozenset( + { + ("2321", "Liability"), + ("2331", "Liability"), + ("2341", "Liability"), + ("1521", "Asset"), + ("1531", "Asset"), + ("1541", "Asset"), + } +) + +STANDARD_WITH_NUMBERS_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS = frozenset( + { + ("2320", "Liability"), + ("2330", "Liability"), + ("2340", "Liability"), + ("1520", "Asset"), + ("1530", "Asset"), + ("1540", "Asset"), + } +) + +GERMAN_ITEM_TAX_TEMPLATE_NOT_APPLICABLE_ACCOUNTS = { + "SKR03 mit Kontonummern": { + "identifier_field": "account_number", + "templates": { + "19 %": SKR03_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS, + "7 %": SKR03_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS, + "0 %": SKR03_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS + | SKR03_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS + | frozenset({("1588", "Asset")}), + }, + }, + "SKR04 mit Kontonummern": { + "identifier_field": "account_number", + "templates": { + "19 %": SKR04_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS, + "7 %": SKR04_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS, + "0 %": SKR04_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS + | SKR04_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS + | frozenset({("1433", "Asset")}), + }, + }, + "Standard": { + "identifier_field": "account_name", + "templates": { + "19 %": STANDARD_NOT_APPLICABLE_7_PERCENT_ACCOUNT_LABELS, + "7 %": STANDARD_NOT_APPLICABLE_19_PERCENT_ACCOUNT_LABELS, + "0%": STANDARD_NOT_APPLICABLE_7_PERCENT_ACCOUNT_LABELS + | STANDARD_NOT_APPLICABLE_19_PERCENT_ACCOUNT_LABELS + | frozenset({("Entstandene Einfuhrumsatzsteuer", "Asset")}), + }, + }, + "Standard with Numbers": { + "identifier_field": "account_number", + "templates": { + "19%": STANDARD_WITH_NUMBERS_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS, + "7%": STANDARD_WITH_NUMBERS_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS, + "0 %": STANDARD_WITH_NUMBERS_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS + | STANDARD_WITH_NUMBERS_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS + | frozenset({("1550", "Asset")}), + }, + }, +} + + +def update_account_cache(accounts, account_cache): + missing_accounts = set(accounts) - set(account_cache) + if not missing_accounts: + return + + for account in frappe.get_all( + "Account", + filters={"name": ("in", tuple(sorted(missing_accounts)))}, + fields=["name", "account_name", "account_number", "root_type"], + ): + account_cache[account.name] = account + + +def get_account_identifier(account, identifier_field, account_cache): + cached_account = account_cache.get(account) + if not cached_account: + return None + + return cached_account.get(identifier_field), cached_account.root_type + + +def execute(): + """Backfill `not_applicable` on Item Tax Template Details for German companies. + + Before the `not_applicable` flag existed, German default templates used + `tax_rate: 0` to mean "this tax does not apply to the item" (as opposed to + an explicit 0% rate). For each German company, this patch looks up the + historical defaults for its Chart of Accounts and sets + `not_applicable = 1` on detail rows that still match those defaults + (same template title, same zero-rate tax account identifier set, flag still unset), + leaving any user-customised rows untouched. + """ + companies = frappe.get_all( + "Company", + filters={"country": "Germany"}, + fields=["name", "chart_of_accounts"], + ) + account_cache = {} + + for company in companies: + chart = GERMAN_ITEM_TAX_TEMPLATE_NOT_APPLICABLE_ACCOUNTS.get(company.chart_of_accounts) + if not chart: + continue + + identifier_field = chart["identifier_field"] + for template_title, target_accounts in chart["templates"].items(): + itt_names = frappe.get_all( + "Item Tax Template", + filters={"company": company.name, "title": template_title}, + pluck="name", + ) + for itt_name in itt_names: + zero_rate_details = frappe.get_all( + "Item Tax Template Detail", + filters={"parent": itt_name, "tax_rate": 0}, + fields=["name", "tax_type", "not_applicable"], + ) + update_account_cache((d.tax_type for d in zero_rate_details), account_cache) + zero_rate_accounts_by_detail = { + d.name: get_account_identifier(d.tax_type, identifier_field, account_cache) + for d in zero_rate_details + } + if any(identifier is None for identifier in zero_rate_accounts_by_detail.values()): + continue + + # Compare the row count as well. Account names are only implicitly unique + # among number-less accounts (`Account.name` is `[number - ]account_name - abbr`), + # so on a mixed chart a numbered account can share `account_name` with a + # default one. Without this, such a user-added zero-rate row collapses onto a + # default identifier and makes a customised template look untouched. + if len(zero_rate_accounts_by_detail) != len(target_accounts): + continue + + if set(zero_rate_accounts_by_detail.values()) != target_accounts: + continue + + for d in zero_rate_details: + if not d.not_applicable: + frappe.db.set_value( + "Item Tax Template Detail", + d.name, + "not_applicable", + 1, + update_modified=False, + ) From 7429a260f6a4da2f4189e90200e66020b8422f7e Mon Sep 17 00:00:00 2001 From: pandiyan Date: Fri, 11 Sep 2026 17:50:30 +0530 Subject: [PATCH 23/29] fix: prevent disabled items from being used in bom (#58997) Co-authored-by: Ajish18 (cherry picked from commit e6f431a8d6f883b02ca1a6246df7e0ac564170d5) --- erpnext/manufacturing/doctype/bom/bom.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 3a799a508dd..051554f3e0a 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -10,7 +10,7 @@ import frappe from frappe import _, bold from frappe.core.doctype.version.version import get_diff from frappe.model.mapper import get_mapped_doc -from frappe.utils import cint, cstr, flt, today +from frappe.utils import cint, cstr, flt, get_link_to_form, today from frappe.website.website_generator import WebsiteGenerator import erpnext @@ -653,6 +653,19 @@ class BOM(WebsiteGenerator): frappe.throw(_("Quantity required for Item {0} in row {1}").format(m.item_code, m.idx)) check_list.append(m) + bom_items = {self.item} + bom_items.update(d.item_code for d in self.get("items")) + bom_items.update(d.item_code for d in self.get("scrap_items")) + + if disabled_items := frappe.db.get_all( + "Item", filters={"item_code": ("in", list(bom_items)), "disabled": 1}, pluck="name" + ): + frappe.throw( + _("Disabled Item {0} cannot be used in BOMs.").format( + ", ".join(get_link_to_form("Item", item) for item in disabled_items) + ) + ) + def check_recursion(self, bom_list=None): """Check whether recursion occurs in any bom""" From 1150d8e9b0518bc3dce11a2faaf74787c2e5c61a Mon Sep 17 00:00:00 2001 From: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:58:56 +0530 Subject: [PATCH 24/29] fix(subcontracting): validate project across the subcontracting flow (#58965) (cherry picked from commit fe25746febc8731bbbc0880dcb18d528dcf08a63) # Conflicts: # erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py # erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py # erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py --- .../subcontracting_order.py | 37 ++++ .../test_subcontracting_order.py | 108 +++++++++++ .../doctype/subcontracting_receipt/mapper.py | 174 ++++++++++++++++++ .../subcontracting_receipt.py | 19 ++ .../test_subcontracting_receipt.py | 24 +++ 5 files changed, 362 insertions(+) create mode 100644 erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index 2bc39655a97..870ed518df2 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -98,6 +98,7 @@ class SubcontractingOrder(SubcontractingController): self.validate_service_items() self.validate_supplied_items() self.set_missing_values() + self.validate_with_previous_doc() self.reset_default_field_value("set_warehouse", "items", "warehouse") def on_submit(self): @@ -108,6 +109,18 @@ class SubcontractingOrder(SubcontractingController): self.update_status() self.update_subcontracted_quantity_in_po(cancel=True) + def validate_with_previous_doc(self): + super().validate_with_previous_doc( + { + "Purchase Order Item": { + "ref_dn_field": "purchase_order_item", + "compare_fields": [["project", "="]], + "is_child_table": True, + "allow_duplicate_prev_row_id": True, + }, + } + ) + def validate_purchase_order_for_subcontracting(self): if self.purchase_order: po = frappe.get_doc("Purchase Order", self.purchase_order) @@ -211,10 +224,29 @@ class SubcontractingOrder(SubcontractingController): if si.fg_item: item = frappe.get_doc("Item", si.fg_item) +<<<<<<< HEAD qty, subcontracted_quantity, fg_item_qty = frappe.db.get_value( "Purchase Order Item", si.purchase_order_item, ["qty", "subcontracted_quantity", "fg_item_qty"], +======= + ( + qty, + subcontracted_qty, + fg_item_qty, + production_plan_sub_assembly_item, + project, + ) = frappe.db.get_value( + "Purchase Order Item", + si.purchase_order_item, + [ + "qty", + "subcontracted_qty", + "fg_item_qty", + "production_plan_sub_assembly_item", + "project", + ], +>>>>>>> fe25746 (fix(subcontracting): validate project across the subcontracting flow (#58965)) ) available_qty = flt(qty) - flt(subcontracted_quantity) @@ -250,6 +282,11 @@ class SubcontractingOrder(SubcontractingController): "purchase_order_item": si.purchase_order_item, "material_request": si.material_request, "material_request_item": si.material_request_item, +<<<<<<< HEAD +======= + "production_plan_sub_assembly_item": production_plan_sub_assembly_item, + "project": project, +>>>>>>> fe25746 (fix(subcontracting): validate project across the subcontracting flow (#58965)) } ) else: diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py index 1a54343a1b3..b0c554ace7a 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py @@ -26,6 +26,7 @@ from erpnext.controllers.tests.test_subcontracting_controller import ( set_backflush_based_on, ) from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom +from erpnext.projects.doctype.project.test_project import make_project from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( @@ -112,6 +113,85 @@ class TestSubcontractingOrder(FrappeTestCase): sco.load_from_db() self.assertEqual(sco.status, "Partially Received") +<<<<<<< HEAD +======= + def test_project_is_carried_over_from_purchase_order(self): + project = make_project({"project_name": "_Test SCO Project"}).name + po = make_subcontracted_purchase_order(project) + + sco = get_mapped_subcontracting_order(source_name=po.name) + + self.assertEqual(sco.project, project) + self.assertEqual(sco.items[0].project, project) + + def test_project_cannot_differ_from_purchase_order(self): + project = make_project({"project_name": "_Test SCO Project"}).name + other_project = make_project({"project_name": "_Test SCO Project 2"}).name + po = make_subcontracted_purchase_order(project) + + sco = get_mapped_subcontracting_order(source_name=po.name) + sco.items[0].project = other_project + self.assertRaises(frappe.ValidationError, sco.save) + + def test_sco_requires_a_subcontracting_purchase_order(self): + sco = get_subcontracting_order(do_not_save=1) + sco.purchase_order = None + self.assertRaises(frappe.ValidationError, sco.validate_purchase_order_for_subcontracting) + + def test_service_item_must_be_non_stock(self): + sco = get_subcontracting_order(do_not_submit=1) + sco.service_items[0].item_code = "_Test Item" # a stock item + self.assertRaises(frappe.ValidationError, sco.validate_service_items) + + def test_reserve_warehouse_must_differ_from_supplier_warehouse(self): + sco = get_subcontracting_order(do_not_submit=1) + sco.supplied_items[0].reserve_warehouse = sco.supplier_warehouse + self.assertRaises(frappe.ValidationError, sco.validate_supplied_items) + + def test_subcontracting_receipt_applies_bom_process_loss(self): + sco = get_subcontracting_order() + frappe.db.set_value("BOM", sco.items[0].bom, "process_loss_percentage", 10) + + scr = make_subcontracting_receipt(sco.name) + + # 10% of the ordered 10 qty is lost in processing + self.assertEqual(scr.items[0].received_qty, 10) + self.assertEqual(scr.items[0].process_loss_qty, 1) + self.assertEqual(scr.items[0].qty, 9) + + def test_service_cost_is_matched_by_purchase_order_item(self): + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 7", + "qty": 10, + "rate": 100, + "fg_item": "Subcontracted Item SA7", + "fg_item_qty": 10, + }, + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 10, + "rate": 200, + "fg_item": "Subcontracted Item SA1", + "fg_item_qty": 10, + }, + ] + sco = get_subcontracting_order(service_items=service_items) + expected = {item.purchase_order_item: item.service_cost_per_qty for item in sco.items} + + # The two finished goods have distinct service costs, so a position-based pairing would swap them + self.assertEqual(len(set(expected.values())), 2) + + # Service costs must follow purchase_order_item, not list position + sco.service_items.reverse() + sco.calculate_service_costs() + + for item in sco.items: + self.assertEqual(item.service_cost_per_qty, expected[item.purchase_order_item]) + +>>>>>>> fe25746 (fix(subcontracting): validate project across the subcontracting flow (#58965)) def test_make_rm_stock_entry(self): sco = get_subcontracting_order() rm_items = get_rm_items(sco.supplied_items) @@ -873,3 +953,31 @@ def create_subcontracting_order(**args): sco.submit() return sco + + +def make_subcontracted_purchase_order(project): + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 7", + "qty": 10, + "rate": 100, + "fg_item": "Subcontracted Item SA7", + "fg_item_qty": 10, + }, + ] + po = create_purchase_order( + rm_items=service_items, + is_subcontracted=1, + supplier_warehouse="_Test Warehouse 1 - _TC", + do_not_submit=1, + ) + po.project = project + for item in po.items: + item.project = project + po.save() + po.submit() + + return po diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py b/erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py new file mode 100644 index 00000000000..ae1b7653d4f --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py @@ -0,0 +1,174 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.utils import flt, get_link_to_form + + +@frappe.whitelist() +def make_subcontract_return_against_rejected_warehouse(source_name: str): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + return make_return_doc("Subcontracting Receipt", source_name, return_against_rejected_qty=True) + + +@frappe.whitelist() +def make_subcontract_return(source_name: str, target_doc: str | dict | Document | None = None): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + return make_return_doc("Subcontracting Receipt", source_name, target_doc) + + +@frappe.whitelist(methods=["POST"]) +def make_purchase_receipt( + source_name: Document | str, + target_doc: str | dict | Document | None = None, + save: bool = False, + submit: bool = False, + notify: bool = False, +): + if isinstance(source_name, str): + source_doc = frappe.get_doc("Subcontracting Receipt", source_name) + else: + source_doc = source_name + + if source_doc.is_return: + return + + po_sr_item_dict = {} + po_name = None + for item in source_doc.items: + if not item.purchase_order: + continue + + if not po_name: + po_name = item.purchase_order + + po_sr_item_dict[item.purchase_order_item] = { + "qty": flt(item.qty), + "rejected_qty": flt(item.rejected_qty), + "warehouse": item.warehouse, + "rejected_warehouse": item.rejected_warehouse, + "subcontracting_receipt_item": item.name, + } + + if not po_name: + frappe.throw( + _("Purchase Order Item reference is missing in Subcontracting Receipt {0}").format( + source_doc.name + ) + ) + + def update_item(obj, target, source_parent): + sr_item_details = po_sr_item_dict.get(obj.name) + ratio = flt(obj.qty) / flt(obj.fg_item_qty) + + target.update( + { + "qty": ratio * sr_item_details["qty"], + "rejected_qty": ratio * sr_item_details["rejected_qty"], + "warehouse": sr_item_details["warehouse"], + "rejected_warehouse": sr_item_details["rejected_warehouse"], + "subcontracting_receipt_item": sr_item_details["subcontracting_receipt_item"], + } + ) + + def post_process(source, target): + target.set_missing_values() + target.update( + { + "posting_date": source_doc.posting_date, + "posting_time": source_doc.posting_time, + "subcontracting_receipt": source_doc.name, + "supplier_warehouse": source_doc.supplier_warehouse, + "is_subcontracted": 1, + "currency": frappe.get_cached_value("Company", target.company, "default_currency"), + } + ) + + target_doc = get_mapped_doc( + "Purchase Order", + po_name, + { + "Purchase Order": { + "doctype": "Purchase Receipt", + "field_map": {"supplier_warehouse": "supplier_warehouse"}, + "validation": { + "docstatus": ["=", 1], + }, + }, + "Purchase Order Item": { + "doctype": "Purchase Receipt Item", + "field_map": { + "name": "purchase_order_item", + "parent": "purchase_order", + "bom": "bom", + }, + "postprocess": update_item, + "condition": lambda doc: doc.name in po_sr_item_dict, + }, + "Purchase Taxes and Charges": { + "doctype": "Purchase Taxes and Charges", + "reset_value": True, + # for POs created in earlier version with tax_withholding_row + "condition": lambda doc: not doc.is_tax_withholding_account, + }, + }, + postprocess=post_process, + ) + + if not target_doc.get("items"): + add_po_items_to_pr(source_doc, target_doc) + + if (save or submit) and frappe.has_permission(target_doc.doctype, "create"): + target_doc.save() + + if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc): + frappe.db.savepoint("submit_subcontracting_receipt") + try: + target_doc.submit() + except Exception as e: + frappe.db.rollback(save_point="submit_subcontracting_receipt") + target_doc.add_comment("Comment", _("Submit Action Failed") + "

" + str(e)) + + if notify: + frappe.msgprint( + _("Purchase Receipt {0} created.").format( + get_link_to_form(target_doc.doctype, target_doc.name) + ), + indicator="green", + alert=True, + ) + + return target_doc + + +def add_po_items_to_pr(scr_doc, target_doc): + fg_items = {(item.item_code, item.purchase_order): item.qty for item in scr_doc.items} + + for (item_code, po_name), fg_qty in fg_items.items(): + po_doc = frappe.get_doc("Purchase Order", po_name) + for item in po_doc.items: + if item.fg_item != item_code: + continue + + qty = (item.stock_qty - item.received_qty) * fg_qty / item.fg_item_qty + if qty: + target_doc.append( + "items", + { + "item_code": item.item_code, + "item_name": item.item_name, + "description": item.description, + "qty": qty, + "rate": item.rate, + "warehouse": item.warehouse, + "purchase_order": item.parent, + "purchase_order_item": item.name, + "project": item.project, + "cost_center": item.cost_center, + }, + ) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 501fde5b3dd..8b88f5ed4bd 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -146,6 +146,7 @@ class SubcontractingReceipt(SubcontractingController): self.get_scrap_items() self.set_missing_values() + self.validate_with_previous_doc() if self.get("_action") == "submit": self.validate_scrap_items() @@ -159,6 +160,24 @@ class SubcontractingReceipt(SubcontractingController): self.set_supplied_items_expense_account() self.set_supplied_items_cost_center() + def validate_with_previous_doc(self): + super().validate_with_previous_doc( + { + "Subcontracting Order Item": { + "ref_dn_field": "subcontracting_order_item", + "compare_fields": [["project", "="]], + "is_child_table": True, + "allow_duplicate_prev_row_id": True, + }, + "Purchase Order Item": { + "ref_dn_field": "purchase_order_item", + "compare_fields": [["project", "="]], + "is_child_table": True, + "allow_duplicate_prev_row_id": True, + }, + } + ) + def on_submit(self): self.validate_closed_subcontracting_order() self.validate_bom_required_qty() diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index 26025169979..d79b6b1bfbc 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -25,6 +25,7 @@ from erpnext.controllers.tests.test_subcontracting_controller import ( set_backflush_based_on, ) from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom +from erpnext.projects.doctype.project.test_project import make_project from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( @@ -38,6 +39,9 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( make_subcontracting_receipt, ) +from erpnext.subcontracting.doctype.subcontracting_order.test_subcontracting_order import ( + make_subcontracted_purchase_order, +) from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import ( BOMQuantityError, ) @@ -50,6 +54,26 @@ class TestSubcontractingReceipt(FrappeTestCase): make_service_items() make_bom_for_subcontracted_items() + def test_project_is_carried_over_from_subcontracting_order(self): + project = make_project({"project_name": "_Test SCR Project"}).name + po = make_subcontracted_purchase_order(project) + sco = get_subcontracting_order(po_name=po.name) + + scr = make_subcontracting_receipt(sco.name) + + self.assertEqual(scr.project, project) + self.assertEqual(scr.items[0].project, project) + + def test_project_cannot_differ_from_subcontracting_order(self): + project = make_project({"project_name": "_Test SCR Project"}).name + other_project = make_project({"project_name": "_Test SCR Project 2"}).name + po = make_subcontracted_purchase_order(project) + sco = get_subcontracting_order(po_name=po.name) + + scr = make_subcontracting_receipt(sco.name) + scr.items[0].project = other_project + self.assertRaises(frappe.ValidationError, scr.save) + def test_subcontracting(self): set_backflush_based_on("BOM") make_stock_entry(item_code="_Test Item", qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100) From e84a94642dc210e817086258845f568778f4150b Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sun, 13 Sep 2026 12:12:16 +0530 Subject: [PATCH 25/29] fix: resolve conflicts --- .../subcontracting_order.py | 27 +-- .../test_subcontracting_order.py | 61 ------ .../doctype/subcontracting_receipt/mapper.py | 174 ------------------ .../subcontracting_receipt.py | 2 + 4 files changed, 4 insertions(+), 260 deletions(-) delete mode 100644 erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index 870ed518df2..2b8c01ceaca 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -224,29 +224,10 @@ class SubcontractingOrder(SubcontractingController): if si.fg_item: item = frappe.get_doc("Item", si.fg_item) -<<<<<<< HEAD - qty, subcontracted_quantity, fg_item_qty = frappe.db.get_value( + qty, subcontracted_quantity, fg_item_qty, project = frappe.db.get_value( "Purchase Order Item", si.purchase_order_item, - ["qty", "subcontracted_quantity", "fg_item_qty"], -======= - ( - qty, - subcontracted_qty, - fg_item_qty, - production_plan_sub_assembly_item, - project, - ) = frappe.db.get_value( - "Purchase Order Item", - si.purchase_order_item, - [ - "qty", - "subcontracted_qty", - "fg_item_qty", - "production_plan_sub_assembly_item", - "project", - ], ->>>>>>> fe25746 (fix(subcontracting): validate project across the subcontracting flow (#58965)) + ["qty", "subcontracted_quantity", "fg_item_qty", "project"], ) available_qty = flt(qty) - flt(subcontracted_quantity) @@ -282,11 +263,7 @@ class SubcontractingOrder(SubcontractingController): "purchase_order_item": si.purchase_order_item, "material_request": si.material_request, "material_request_item": si.material_request_item, -<<<<<<< HEAD -======= - "production_plan_sub_assembly_item": production_plan_sub_assembly_item, "project": project, ->>>>>>> fe25746 (fix(subcontracting): validate project across the subcontracting flow (#58965)) } ) else: diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py index b0c554ace7a..335f8f7c99e 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py @@ -113,8 +113,6 @@ class TestSubcontractingOrder(FrappeTestCase): sco.load_from_db() self.assertEqual(sco.status, "Partially Received") -<<<<<<< HEAD -======= def test_project_is_carried_over_from_purchase_order(self): project = make_project({"project_name": "_Test SCO Project"}).name po = make_subcontracted_purchase_order(project) @@ -133,65 +131,6 @@ class TestSubcontractingOrder(FrappeTestCase): sco.items[0].project = other_project self.assertRaises(frappe.ValidationError, sco.save) - def test_sco_requires_a_subcontracting_purchase_order(self): - sco = get_subcontracting_order(do_not_save=1) - sco.purchase_order = None - self.assertRaises(frappe.ValidationError, sco.validate_purchase_order_for_subcontracting) - - def test_service_item_must_be_non_stock(self): - sco = get_subcontracting_order(do_not_submit=1) - sco.service_items[0].item_code = "_Test Item" # a stock item - self.assertRaises(frappe.ValidationError, sco.validate_service_items) - - def test_reserve_warehouse_must_differ_from_supplier_warehouse(self): - sco = get_subcontracting_order(do_not_submit=1) - sco.supplied_items[0].reserve_warehouse = sco.supplier_warehouse - self.assertRaises(frappe.ValidationError, sco.validate_supplied_items) - - def test_subcontracting_receipt_applies_bom_process_loss(self): - sco = get_subcontracting_order() - frappe.db.set_value("BOM", sco.items[0].bom, "process_loss_percentage", 10) - - scr = make_subcontracting_receipt(sco.name) - - # 10% of the ordered 10 qty is lost in processing - self.assertEqual(scr.items[0].received_qty, 10) - self.assertEqual(scr.items[0].process_loss_qty, 1) - self.assertEqual(scr.items[0].qty, 9) - - def test_service_cost_is_matched_by_purchase_order_item(self): - service_items = [ - { - "warehouse": "_Test Warehouse - _TC", - "item_code": "Subcontracted Service Item 7", - "qty": 10, - "rate": 100, - "fg_item": "Subcontracted Item SA7", - "fg_item_qty": 10, - }, - { - "warehouse": "_Test Warehouse - _TC", - "item_code": "Subcontracted Service Item 1", - "qty": 10, - "rate": 200, - "fg_item": "Subcontracted Item SA1", - "fg_item_qty": 10, - }, - ] - sco = get_subcontracting_order(service_items=service_items) - expected = {item.purchase_order_item: item.service_cost_per_qty for item in sco.items} - - # The two finished goods have distinct service costs, so a position-based pairing would swap them - self.assertEqual(len(set(expected.values())), 2) - - # Service costs must follow purchase_order_item, not list position - sco.service_items.reverse() - sco.calculate_service_costs() - - for item in sco.items: - self.assertEqual(item.service_cost_per_qty, expected[item.purchase_order_item]) - ->>>>>>> fe25746 (fix(subcontracting): validate project across the subcontracting flow (#58965)) def test_make_rm_stock_entry(self): sco = get_subcontracting_order() rm_items = get_rm_items(sco.supplied_items) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py b/erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py deleted file mode 100644 index ae1b7653d4f..00000000000 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py +++ /dev/null @@ -1,174 +0,0 @@ -# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -import frappe -from frappe import _ -from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc -from frappe.utils import flt, get_link_to_form - - -@frappe.whitelist() -def make_subcontract_return_against_rejected_warehouse(source_name: str): - from erpnext.controllers.sales_and_purchase_return import make_return_doc - - return make_return_doc("Subcontracting Receipt", source_name, return_against_rejected_qty=True) - - -@frappe.whitelist() -def make_subcontract_return(source_name: str, target_doc: str | dict | Document | None = None): - from erpnext.controllers.sales_and_purchase_return import make_return_doc - - return make_return_doc("Subcontracting Receipt", source_name, target_doc) - - -@frappe.whitelist(methods=["POST"]) -def make_purchase_receipt( - source_name: Document | str, - target_doc: str | dict | Document | None = None, - save: bool = False, - submit: bool = False, - notify: bool = False, -): - if isinstance(source_name, str): - source_doc = frappe.get_doc("Subcontracting Receipt", source_name) - else: - source_doc = source_name - - if source_doc.is_return: - return - - po_sr_item_dict = {} - po_name = None - for item in source_doc.items: - if not item.purchase_order: - continue - - if not po_name: - po_name = item.purchase_order - - po_sr_item_dict[item.purchase_order_item] = { - "qty": flt(item.qty), - "rejected_qty": flt(item.rejected_qty), - "warehouse": item.warehouse, - "rejected_warehouse": item.rejected_warehouse, - "subcontracting_receipt_item": item.name, - } - - if not po_name: - frappe.throw( - _("Purchase Order Item reference is missing in Subcontracting Receipt {0}").format( - source_doc.name - ) - ) - - def update_item(obj, target, source_parent): - sr_item_details = po_sr_item_dict.get(obj.name) - ratio = flt(obj.qty) / flt(obj.fg_item_qty) - - target.update( - { - "qty": ratio * sr_item_details["qty"], - "rejected_qty": ratio * sr_item_details["rejected_qty"], - "warehouse": sr_item_details["warehouse"], - "rejected_warehouse": sr_item_details["rejected_warehouse"], - "subcontracting_receipt_item": sr_item_details["subcontracting_receipt_item"], - } - ) - - def post_process(source, target): - target.set_missing_values() - target.update( - { - "posting_date": source_doc.posting_date, - "posting_time": source_doc.posting_time, - "subcontracting_receipt": source_doc.name, - "supplier_warehouse": source_doc.supplier_warehouse, - "is_subcontracted": 1, - "currency": frappe.get_cached_value("Company", target.company, "default_currency"), - } - ) - - target_doc = get_mapped_doc( - "Purchase Order", - po_name, - { - "Purchase Order": { - "doctype": "Purchase Receipt", - "field_map": {"supplier_warehouse": "supplier_warehouse"}, - "validation": { - "docstatus": ["=", 1], - }, - }, - "Purchase Order Item": { - "doctype": "Purchase Receipt Item", - "field_map": { - "name": "purchase_order_item", - "parent": "purchase_order", - "bom": "bom", - }, - "postprocess": update_item, - "condition": lambda doc: doc.name in po_sr_item_dict, - }, - "Purchase Taxes and Charges": { - "doctype": "Purchase Taxes and Charges", - "reset_value": True, - # for POs created in earlier version with tax_withholding_row - "condition": lambda doc: not doc.is_tax_withholding_account, - }, - }, - postprocess=post_process, - ) - - if not target_doc.get("items"): - add_po_items_to_pr(source_doc, target_doc) - - if (save or submit) and frappe.has_permission(target_doc.doctype, "create"): - target_doc.save() - - if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc): - frappe.db.savepoint("submit_subcontracting_receipt") - try: - target_doc.submit() - except Exception as e: - frappe.db.rollback(save_point="submit_subcontracting_receipt") - target_doc.add_comment("Comment", _("Submit Action Failed") + "

" + str(e)) - - if notify: - frappe.msgprint( - _("Purchase Receipt {0} created.").format( - get_link_to_form(target_doc.doctype, target_doc.name) - ), - indicator="green", - alert=True, - ) - - return target_doc - - -def add_po_items_to_pr(scr_doc, target_doc): - fg_items = {(item.item_code, item.purchase_order): item.qty for item in scr_doc.items} - - for (item_code, po_name), fg_qty in fg_items.items(): - po_doc = frappe.get_doc("Purchase Order", po_name) - for item in po_doc.items: - if item.fg_item != item_code: - continue - - qty = (item.stock_qty - item.received_qty) * fg_qty / item.fg_item_qty - if qty: - target_doc.append( - "items", - { - "item_code": item.item_code, - "item_name": item.item_name, - "description": item.description, - "qty": qty, - "rate": item.rate, - "warehouse": item.warehouse, - "purchase_order": item.parent, - "purchase_order_item": item.name, - "project": item.project, - "cost_center": item.cost_center, - }, - ) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 8b88f5ed4bd..89be4fcb344 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -981,5 +981,7 @@ def add_po_items_to_pr(scr_doc, target_doc): "warehouse": item.warehouse, "purchase_order": item.parent, "purchase_order_item": item.name, + "project": item.project, + "cost_center": item.cost_center, }, ) From 9ba215972000c9fe599fb6fc92985aa9fe35849e Mon Sep 17 00:00:00 2001 From: Afsal Syed <146159709+Afsalsyed@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:06:56 +0530 Subject: [PATCH 26/29] fix(stock): set item_name to fetch if empty in packing slip item (#58925) (cherry picked from commit f130c645302af1a1a38519e9af6cd44dd7ce04ed) # Conflicts: # erpnext/stock/doctype/packing_slip_item/packing_slip_item.json --- erpnext/stock/doctype/packing_slip_item/packing_slip_item.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json index 4bd90355acb..11b81f67bde 100644 --- a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json +++ b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json @@ -41,6 +41,7 @@ }, { "fetch_from": "item_code.item_name", + "fetch_if_empty": 1, "fieldname": "item_name", "fieldtype": "Data", "in_list_view": 1, @@ -149,4 +150,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} From 618864a051cf54d0ecf44e1c96c59debf83bcad1 Mon Sep 17 00:00:00 2001 From: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:47:47 +0530 Subject: [PATCH 27/29] Revert "fix(stock): set item_name to fetch if empty in packing slip item (backport #58925)" --- erpnext/stock/doctype/packing_slip_item/packing_slip_item.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json index 11b81f67bde..4bd90355acb 100644 --- a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json +++ b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json @@ -41,7 +41,6 @@ }, { "fetch_from": "item_code.item_name", - "fetch_if_empty": 1, "fieldname": "item_name", "fieldtype": "Data", "in_list_view": 1, @@ -150,4 +149,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} +} \ No newline at end of file From 99afc2e6eba75bdafc517f945e1792f264f4c590 Mon Sep 17 00:00:00 2001 From: Afsal Syed <146159709+Afsalsyed@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:06:56 +0530 Subject: [PATCH 28/29] fix(stock): set item_name to fetch if empty in packing slip item (#58925) (cherry picked from commit f130c645302af1a1a38519e9af6cd44dd7ce04ed) # Conflicts: # erpnext/stock/doctype/packing_slip_item/packing_slip_item.json --- .../stock/doctype/packing_slip_item/packing_slip_item.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json index 4bd90355acb..d00c4d914a3 100644 --- a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json +++ b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json @@ -41,6 +41,7 @@ }, { "fetch_from": "item_code.item_name", + "fetch_if_empty": 1, "fieldname": "item_name", "fieldtype": "Data", "in_list_view": 1, @@ -138,7 +139,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2023-04-28 15:00:14.079306", + "modified": "2026-09-09 13:04:53.623636", "modified_by": "Administrator", "module": "Stock", "name": "Packing Slip Item", @@ -149,4 +150,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} From 112350dd6553ba139b839092fb3590828e3c5a25 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:40:22 +0530 Subject: [PATCH 29/29] fix(regional)!: remove `irs_1099_print` whitelisted method (backport #58976) (#58991) Co-authored-by: Diptanil Saha --- erpnext/regional/report/irs_1099/irs_1099.js | 16 -------- erpnext/regional/report/irs_1099/irs_1099.py | 40 -------------------- 2 files changed, 56 deletions(-) diff --git a/erpnext/regional/report/irs_1099/irs_1099.js b/erpnext/regional/report/irs_1099/irs_1099.js index 385468b58aa..17002420cfc 100644 --- a/erpnext/regional/report/irs_1099/irs_1099.js +++ b/erpnext/regional/report/irs_1099/irs_1099.js @@ -31,20 +31,4 @@ frappe.query_reports["IRS 1099"] = { width: 80, }, ], - - onload: function (query_report) { - query_report.page.add_inner_button(__("Print IRS 1099 Forms"), () => { - build_1099_print(query_report); - }); - }, }; - -function build_1099_print(query_report) { - let filters = JSON.stringify(query_report.get_values()); - let w = window.open( - "/api/method/erpnext.regional.report.irs_1099.irs_1099.irs_1099_print?" + - "&filters=" + - encodeURIComponent(filters) - ); - // w.print(); -} diff --git a/erpnext/regional/report/irs_1099/irs_1099.py b/erpnext/regional/report/irs_1099/irs_1099.py index 41e3761f9f1..27e55c6d732 100644 --- a/erpnext/regional/report/irs_1099/irs_1099.py +++ b/erpnext/regional/report/irs_1099/irs_1099.py @@ -84,46 +84,6 @@ def get_columns(): ] -@frappe.whitelist() -def irs_1099_print(filters): - if not filters: - frappe._dict( - { - "company": frappe.db.get_default("Company"), - "fiscal_year": frappe.db.get_default("Fiscal Year"), - } - ) - else: - filters = frappe._dict(json.loads(filters)) - - fiscal_year_doc = get_fiscal_year(fiscal_year=filters.fiscal_year, as_dict=True) - fiscal_year = cstr(fiscal_year_doc.year_start_date.year) - - company_address = get_payer_address_html(filters.company) - company_tin = frappe.db.get_value("Company", filters.company, "tax_id") - - columns, data = execute(filters) - template = frappe.get_doc("Print Format", "IRS 1099 Form").html - output = PdfWriter() - - for row in data: - row["fiscal_year"] = fiscal_year - row["company"] = filters.company - row["company_tin"] = company_tin - row["payer_street_address"] = company_address - row["recipient_street_address"], row["recipient_city_state"] = get_street_address_html( - "Supplier", row.supplier - ) - row["payments"] = fmt_money(row["payments"], precision=0, currency="USD") - get_pdf(render_template(template, row), output=output if output else None) - - frappe.local.response.filename = ( - f"{filters.fiscal_year} {filters.company} IRS 1099 Forms{IRS_1099_FORMS_FILE_EXTENSION}" - ) - frappe.local.response.filecontent = read_multi_pdf(output) - frappe.local.response.type = "download" - - def get_payer_address_html(company): address_list = frappe.db.sql( """