diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json index 599a0604755..99622314532 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.json +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.json @@ -38,7 +38,10 @@ "column_break_3czf", "bank_party_name", "bank_party_account_number", - "bank_party_iban" + "bank_party_iban", + "extended_bank_statement_section", + "included_fee", + "excluded_fee" ], "fields": [ { @@ -233,12 +236,32 @@ { "fieldname": "column_break_oufv", "fieldtype": "Column Break" + }, + { + "fieldname": "extended_bank_statement_section", + "fieldtype": "Section Break", + "label": "Extended Bank Statement" + }, + { + "fieldname": "included_fee", + "fieldtype": "Currency", + "label": "Included Fee", + "non_negative": 1, + "options": "currency" + }, + { + "description": "On save, the Excluded Fee will be converted to an Included Fee.", + "fieldname": "excluded_fee", + "fieldtype": "Currency", + "label": "Excluded Fee", + "non_negative": 1, + "options": "currency" } ], "grid_page_length": 50, "is_submittable": 1, "links": [], - "modified": "2025-10-23 17:32:58.514807", + "modified": "2025-12-07 20:49:18.600757", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Transaction", diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 16c84ac2a60..f9f1b54406b 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -32,6 +32,8 @@ class BankTransaction(Document): date: DF.Date | None deposit: DF.Currency description: DF.SmallText | None + excluded_fee: DF.Currency + included_fee: DF.Currency naming_series: DF.Literal["ACC-BTN-.YYYY.-"] party: DF.DynamicLink | None party_type: DF.Link | None @@ -45,9 +47,11 @@ class BankTransaction(Document): # end: auto-generated types def before_validate(self): + self.handle_excluded_fee() self.update_allocated_amount() def validate(self): + self.validate_included_fee() self.validate_duplicate_references() self.validate_currency() @@ -307,6 +311,40 @@ class BankTransaction(Document): self.party_type, self.party = result + def validate_included_fee(self): + """ + The included_fee is only handled for withdrawals. An included_fee for a deposit, is not credited to the account and is + therefore outside of the deposit value and can be larger than the deposit itself. + """ + + if self.included_fee and self.withdrawal: + if self.included_fee > self.withdrawal: + frappe.throw(_("Included fee is bigger than the withdrawal itself.")) + + def handle_excluded_fee(self): + # Include the excluded fee on validate to handle all further processing the same + excluded_fee = flt(self.excluded_fee) + if excluded_fee <= 0: + return + + # Suppress a negative deposit (aka withdrawal), likely not intendend + if flt(self.deposit) > 0 and (flt(self.deposit) - excluded_fee) < 0: + frappe.throw(_("The Excluded Fee is bigger than the Deposit it is deducted from.")) + + # Enforce directionality + if flt(self.deposit) > 0 and flt(self.withdrawal) > 0: + frappe.throw( + _("Only one of Deposit or Withdrawal should be non-zero when applying an Excluded Fee.") + ) + + if flt(self.deposit) > 0: + self.deposit = flt(self.deposit) - excluded_fee + # A fee applied to deposit and withdrawal equal 0 become a withdrawal + elif flt(self.withdrawal) >= 0: + self.withdrawal = flt(self.withdrawal) + excluded_fee + self.included_fee = flt(self.included_fee) + excluded_fee + self.excluded_fee = 0 + @frappe.whitelist() def get_doctypes_for_bank_reconciliation(): diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction_fees.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction_fees.py new file mode 100644 index 00000000000..13f4f9cfd79 --- /dev/null +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction_fees.py @@ -0,0 +1,133 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestBankTransactionFees(FrappeTestCase): + def test_included_fee_throws(self): + """A fee that's part of a withdrawal cannot be bigger than the + withdrawal itself.""" + bt = frappe.new_doc("Bank Transaction") + bt.withdrawal = 100 + bt.included_fee = 101 + + self.assertRaises(frappe.ValidationError, bt.validate_included_fee) + + def test_included_fee_allows_equal(self): + """A fee that's part of a withdrawal may be equal to the withdrawal + amount (only the fee was deducted from the account).""" + bt = frappe.new_doc("Bank Transaction") + bt.withdrawal = 100 + bt.included_fee = 100 + + bt.validate_included_fee() + + def test_included_fee_allows_for_deposit(self): + """For deposits, a fee may be recorded separately without limiting the + received amount.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 10 + bt.included_fee = 999 + + bt.validate_included_fee() + + def test_excluded_fee_noop_when_zero(self): + """When there is no excluded fee to apply, the amounts should remain + unchanged.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 100 + bt.withdrawal = 0 + bt.included_fee = 5 + bt.excluded_fee = 0 + + bt.handle_excluded_fee() + + self.assertEqual(bt.deposit, 100) + self.assertEqual(bt.withdrawal, 0) + self.assertEqual(bt.included_fee, 5) + self.assertEqual(bt.excluded_fee, 0) + + def test_excluded_fee_throws_when_exceeds_deposit(self): + """A fee deducted from an incoming payment must not exceed the incoming + amount (else it would be a withdrawal, a conversion we don't support).""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 10 + bt.excluded_fee = 11 + + self.assertRaises(frappe.ValidationError, bt.handle_excluded_fee) + + def test_excluded_fee_throws_when_both_deposit_and_withdrawal_are_set(self): + """A transaction must be either incoming or outgoing when applying a + fee, not both.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 10 + bt.withdrawal = 10 + bt.excluded_fee = 1 + + self.assertRaises(frappe.ValidationError, bt.handle_excluded_fee) + + def test_excluded_fee_deducts_from_deposit(self): + """When a fee is deducted from an incoming payment, the net received + amount decreases and the fee is tracked as included.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 100 + bt.withdrawal = 0 + bt.included_fee = 2 + bt.excluded_fee = 5 + + bt.handle_excluded_fee() + + self.assertEqual(bt.deposit, 95) + self.assertEqual(bt.withdrawal, 0) + self.assertEqual(bt.included_fee, 7) + self.assertEqual(bt.excluded_fee, 0) + + def test_excluded_fee_can_reduce_an_incoming_payment_to_zero(self): + """A separately-deducted fee may reduce an incoming payment to zero, + while still tracking the fee.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 5 + bt.withdrawal = 0 + bt.included_fee = 0 + bt.excluded_fee = 5 + + bt.handle_excluded_fee() + + self.assertEqual(bt.deposit, 0) + self.assertEqual(bt.withdrawal, 0) + self.assertEqual(bt.included_fee, 5) + self.assertEqual(bt.excluded_fee, 0) + + def test_excluded_fee_increases_outgoing_payment(self): + """When a separately-deducted fee is provided for an outgoing payment, + the total money leaving increases and the fee is tracked.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 0 + bt.withdrawal = 100 + bt.included_fee = 2 + bt.excluded_fee = 5 + + bt.handle_excluded_fee() + + self.assertEqual(bt.deposit, 0) + self.assertEqual(bt.withdrawal, 105) + self.assertEqual(bt.included_fee, 7) + self.assertEqual(bt.excluded_fee, 0) + + def test_excluded_fee_turns_zero_amount_into_withdrawal(self): + """If only an excluded fee is provided, it should be treated as an + outgoing payment and the fee is then tracked as included.""" + bt = frappe.new_doc("Bank Transaction") + bt.deposit = 0 + bt.withdrawal = 0 + bt.included_fee = 0 + bt.excluded_fee = 5 + + bt.handle_excluded_fee() + + self.assertEqual(bt.deposit, 0) + self.assertEqual(bt.withdrawal, 5) + self.assertEqual(bt.included_fee, 5) + self.assertEqual(bt.excluded_fee, 0) diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js index c3531420ce1..40f0938ee1c 100644 --- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js +++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js @@ -19,7 +19,7 @@ frappe.ui.form.on("Currency Exchange Settings", { to: "{to_currency}", }; add_param(frm, r.message, params, result); - } else if (frm.doc.service_provider == "frankfurter.dev") { + } else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) { let result = ["rates", "{to_currency}"]; let params = { base: "{from_currency}", diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py index 3a12ab30403..28e4158cc8d 100644 --- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py +++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py @@ -60,7 +60,7 @@ class CurrencyExchangeSettings(Document): self.append("req_params", {"key": "date", "value": "{transaction_date}"}) self.append("req_params", {"key": "from", "value": "{from_currency}"}) self.append("req_params", {"key": "to", "value": "{to_currency}"}) - elif self.service_provider == "frankfurter.dev": + elif self.service_provider in ("frankfurter.dev", "frankfurter.app"): self.set("result_key", []) self.set("req_params", []) @@ -105,9 +105,11 @@ class CurrencyExchangeSettings(Document): @frappe.whitelist() def get_api_endpoint(service_provider: str | None = None, use_http: bool = False): - if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev"]: + if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]: if service_provider == "exchangerate.host": api = "api.exchangerate.host/convert" + elif service_provider == "frankfurter.app": + api = "api.frankfurter.app/{transaction_date}" elif service_provider == "frankfurter.dev": api = "api.frankfurter.dev/v1/{transaction_date}" diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index f38d5cf7c4a..96272c0d901 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -486,6 +486,9 @@ class ExchangeRateRevaluation(Document): journal_entry.posting_date = self.posting_date journal_entry.multi_currency = 1 + # Prevent JE from overriding user-entered exchange rates (e.g., rate of 1) + journal_entry.flags.ignore_exchange_rate = True + journal_entry_accounts = [] for d in accounts: if not flt(d.get("balance_in_account_currency"), d.precision("balance_in_account_currency")): diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 7ca374e6575..31f0235d147 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -1302,15 +1302,14 @@ frappe.ui.form.on("Payment Entry", { let row = (frm.doc.deductions || []).find((t) => t.is_exchange_gain_loss); if (!row) { - const response = await get_company_defaults(frm.doc.company); - + const company_defaults = frappe.get_doc(":Company", frm.doc.company); const account = - response.message?.[account_fieldname] || + company_defaults?.[account_fieldname] || (await prompt_for_missing_account(frm, account_fieldname)); row = frm.add_child("deductions"); row.account = account; - row.cost_center = response.message?.cost_center; + row.cost_center = company_defaults?.cost_center; row.is_exchange_gain_loss = 1; } diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 5737956fe86..752085e5f99 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -545,6 +545,9 @@ def make_payment_request(**args): if args.dt not in ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST: frappe.throw(_("Payment Requests cannot be created against: {0}").format(frappe.bold(args.dt))) + if args.dn and not isinstance(args.dn, str): + frappe.throw(_("Invalid parameter. 'dn' should be of type str")) + ref_doc = args.ref_doc or frappe.get_doc(args.dt, args.dn) if not args.get("company"): args.company = ref_doc.company diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 2e7e0314aba..52f68180267 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -575,17 +575,6 @@ cur_frm.fields_dict["items"].grid.get_field("cost_center").get_query = function }; }; -cur_frm.cscript.cost_center = function (doc, cdt, cdn) { - var d = locals[cdt][cdn]; - if (d.cost_center) { - var cl = doc.items || []; - for (var i = 0; i < cl.length; i++) { - if (!cl[i].cost_center) cl[i].cost_center = d.cost_center; - } - } - refresh_field("items"); -}; - cur_frm.fields_dict["items"].grid.get_field("project").get_query = function (doc, cdt, cdn) { return { filters: [["Project", "status", "not in", "Completed, Cancelled"]], diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index abb5d76b9d8..9711c4637bc 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -648,10 +648,6 @@ cur_frm.cscript.expense_account = function (doc, cdt, cdn) { erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "expense_account"); }; -cur_frm.cscript.cost_center = function (doc, cdt, cdn) { - erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "cost_center"); -}; - cur_frm.set_query("debit_to", function (doc) { return { filters: { diff --git a/erpnext/accounts/doctype/share_balance/share_balance.json b/erpnext/accounts/doctype/share_balance/share_balance.json index 04d7bb75bf8..4cbe2deffe5 100644 --- a/erpnext/accounts/doctype/share_balance/share_balance.json +++ b/erpnext/accounts/doctype/share_balance/share_balance.json @@ -80,7 +80,7 @@ "collapsible": 0, "columns": 0, "fieldname": "rate", - "fieldtype": "Int", + "fieldtype": "Currency", "hidden": 0, "ignore_user_permissions": 0, "ignore_xss_filter": 0, @@ -102,7 +102,7 @@ "search_index": 0, "set_only_once": 0, "unique": 0 - }, + }, { "allow_bulk_edit": 0, "allow_on_submit": 0, @@ -199,7 +199,7 @@ "collapsible": 0, "columns": 0, "fieldname": "amount", - "fieldtype": "Int", + "fieldtype": "Currency", "hidden": 0, "ignore_user_permissions": 0, "ignore_xss_filter": 0, @@ -221,7 +221,7 @@ "search_index": 0, "set_only_once": 0, "unique": 0 - }, + }, { "allow_bulk_edit": 0, "allow_on_submit": 0, @@ -324,7 +324,7 @@ "issingle": 0, "istable": 1, "max_attachments": 0, - "modified": "2018-01-10 18:32:36.201124", + "modified": "2025-12-10 08:06:40.611761", "modified_by": "Administrator", "module": "Accounts", "name": "Share Balance", @@ -339,4 +339,4 @@ "sort_order": "DESC", "track_changes": 1, "track_seen": 0 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/share_balance/share_balance.py b/erpnext/accounts/doctype/share_balance/share_balance.py index 7a77ce9b871..2eff8a9a9be 100644 --- a/erpnext/accounts/doctype/share_balance/share_balance.py +++ b/erpnext/accounts/doctype/share_balance/share_balance.py @@ -14,7 +14,7 @@ class ShareBalance(Document): if TYPE_CHECKING: from frappe.types import DF - amount: DF.Int + amount: DF.Currency current_state: DF.Literal["", "Issued", "Purchased"] from_no: DF.Int is_company: DF.Check @@ -22,7 +22,7 @@ class ShareBalance(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data - rate: DF.Int + rate: DF.Currency share_type: DF.Link to_no: DF.Int # end: auto-generated types diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index f965da928ac..13201f4bcbc 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -199,19 +199,20 @@ def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None, from_r for d in gl_map: cost_center = d.get("cost_center") + cost_center_allocation = get_cost_center_allocation_data( + gl_map[0]["company"], gl_map[0]["posting_date"], cost_center + ) + + if not cost_center_allocation: + new_gl_map.append(d) + continue + # Validate budget against main cost center if not from_repost: validate_expense_against_budget( d, expense_amount=flt(d.debit, precision) - flt(d.credit, precision) ) - cost_center_allocation = get_cost_center_allocation_data( - gl_map[0]["company"], gl_map[0]["posting_date"], cost_center - ) - if not cost_center_allocation: - new_gl_map.append(d) - continue - if d.account == round_off_account: d.cost_center = cost_center_allocation[0][0] new_gl_map.append(d) @@ -414,7 +415,11 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False): gle.flags.notify_update = False gle.submit() - if not from_repost and gle.voucher_type != "Period Closing Voucher": + if ( + not from_repost + and gle.voucher_type != "Period Closing Voucher" + and (gle.is_cancelled == 0 or gle.voucher_type == "Journal Entry") + ): validate_expense_against_budget(args) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 100dcd46c85..d28311d8876 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -482,7 +482,7 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot immutable_ledger = frappe.db.get_single_value("Accounts Settings", "enable_immutable_ledger") - def update_value_in_dict(data, key, gle): + def update_value_in_dict(data, key, gle, show_net_values=False): data[key].debit += gle.debit data[key].credit += gle.credit @@ -493,10 +493,14 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot data[key].debit_in_transaction_currency += gle.debit_in_transaction_currency data[key].credit_in_transaction_currency += gle.credit_in_transaction_currency - if filters.get("show_net_values_in_party_account") and account_type_map.get(data[key].account) in ( - "Receivable", - "Payable", - ): + if ( + filters.get("show_net_values_in_party_account") + and account_type_map.get(data[key].account) + in ( + "Receivable", + "Payable", + ) + ) or show_net_values: net_value = data[key].debit - data[key].credit net_value_in_account_currency = ( data[key].debit_in_account_currency - data[key].credit_in_account_currency @@ -526,11 +530,11 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot if gle.posting_date < from_date or (cstr(gle.is_opening) == "Yes" and not show_opening_entries): if not group_by_voucher_consolidated: - update_value_in_dict(gle_map[group_by_value].totals, "opening", gle) - update_value_in_dict(gle_map[group_by_value].totals, "closing", gle) + update_value_in_dict(gle_map[group_by_value].totals, "opening", gle, True) + update_value_in_dict(gle_map[group_by_value].totals, "closing", gle, True) - update_value_in_dict(totals, "opening", gle) - update_value_in_dict(totals, "closing", gle) + update_value_in_dict(totals, "opening", gle, True) + update_value_in_dict(totals, "closing", gle, True) elif gle.posting_date <= to_date or (cstr(gle.is_opening) == "Yes" and show_opening_entries): if not group_by_voucher_consolidated: diff --git a/erpnext/accounts/report/trial_balance/trial_balance.py b/erpnext/accounts/report/trial_balance/trial_balance.py index 2ea76c82975..13bf6f531d1 100644 --- a/erpnext/accounts/report/trial_balance/trial_balance.py +++ b/erpnext/accounts/report/trial_balance/trial_balance.py @@ -399,7 +399,7 @@ def prepare_data(accounts, filters, parent_children_map, company_currency): } for key in value_fields: - row[key] = flt(d.get(key, 0.0), 3) + row[key] = flt(d.get(key, 0.0)) if abs(row[key]) >= get_zero_cutoff(company_currency): # ignore zero values diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index d6c4b99588c..571f9ad4b14 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -378,6 +378,9 @@ class calculate_taxes_and_totals: self._calculate() def calculate_taxes(self): + # reset value from earlier calculations + self.grand_total_diff = 0 + doc = self.doc if not doc.get("taxes"): return @@ -587,7 +590,7 @@ class calculate_taxes_and_totals: self.grand_total_diff = 0 def calculate_totals(self): - grand_total_diff = getattr(self, "grand_total_diff", 0) + grand_total_diff = self.grand_total_diff if self.doc.get("taxes"): self.doc.grand_total = flt(self.doc.get("taxes")[-1].total) + grand_total_diff @@ -850,12 +853,11 @@ class calculate_taxes_and_totals: ) ) - if self.doc.docstatus.is_draft(): - if self.doc.get("write_off_outstanding_amount_automatically"): - self.doc.write_off_amount = 0 + if self.doc.get("write_off_outstanding_amount_automatically"): + self.doc.write_off_amount = 0 - self.calculate_outstanding_amount() - self.calculate_write_off_amount() + self.calculate_outstanding_amount() + self.calculate_write_off_amount() def is_internal_invoice(self): """ diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 4514249824b..4451fc9a601 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -412,29 +412,29 @@ scheduler_events = { "0/15 * * * *": [ "erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs", ], - "0/30 * * * *": [ - "erpnext.utilities.doctype.video.video.update_youtube_data", - ], + "0/30 * * * *": [], # Hourly but offset by 30 minutes "30 * * * *": [ "erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs", ], # Daily but offset by 45 minutes - "45 0 * * *": [ - "erpnext.stock.reorder_item.reorder_item", - ], + "45 0 * * *": [], }, "hourly": [ - "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization", - "erpnext.projects.doctype.project.project.project_status_update_reminder", "erpnext.projects.doctype.project.project.hourly_reminder", - "erpnext.projects.doctype.project.project.collect_project_status", ], - "hourly_long": [ + "hourly_long": [], + "hourly_maintenance": [ "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries", "erpnext.utilities.bulk_transaction.retry", + "erpnext.projects.doctype.project.project.collect_project_status", + "erpnext.projects.doctype.project.project.project_status_update_reminder", + "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization", + "erpnext.utilities.doctype.video.video.update_youtube_data", ], - "daily": [ + "daily": [], + "daily_long": [], + "daily_maintenance": [ "erpnext.support.doctype.issue.issue.auto_close_tickets", "erpnext.crm.doctype.opportunity.opportunity.auto_close_opportunity", "erpnext.controllers.accounts_controller.update_invoice_status", @@ -458,17 +458,16 @@ scheduler_events = { "erpnext.accounts.utils.auto_create_exchange_rate_revaluation_daily", "erpnext.accounts.utils.run_ledger_health_checks", "erpnext.assets.doctype.asset_maintenance_log.asset_maintenance_log.update_asset_maintenance_log_status", - ], - "weekly": [ - "erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly", - ], - "daily_long": [ + "erpnext.stock.reorder_item.reorder_item", "erpnext.accounts.doctype.process_subscription.process_subscription.create_subscription_process", "erpnext.setup.doctype.email_digest.email_digest.send", "erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.auto_update_latest_price_in_all_boms", "erpnext.crm.utils.open_leads_opportunities_based_on_todays_event", "erpnext.assets.doctype.asset.depreciation.post_depreciation_entries", ], + "weekly": [ + "erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly", + ], "monthly_long": [ "erpnext.accounts.deferred_revenue.process_deferred_accounting", "erpnext.accounts.utils.auto_create_exchange_rate_revaluation_monthly", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 5fe7c607b6c..9e0679a7cd1 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -403,6 +403,10 @@ frappe.ui.form.on("Work Order", { erpnext.work_order .show_prompt_for_qty_input(frm, "Disassemble") .then((data) => { + if (flt(data.qty) <= 0) { + frappe.msgprint(__("Disassemble Qty cannot be less than or equal to 0.")); + return; + } return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", { work_order_id: frm.doc.name, purpose: "Disassemble", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 5e4dcf22431..20f8a4ab553 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -979,14 +979,14 @@ class WorkOrder(Document): for d in self.get("operations"): precision = d.precision("completed_qty") - qty = flt(d.completed_qty, precision) + flt(d.process_loss_qty, precision) + qty = flt(flt(d.completed_qty, precision) + flt(d.process_loss_qty, precision), precision) if not qty: d.status = "Pending" - elif flt(qty) < flt(self.qty): + elif qty < flt(self.qty, precision): d.status = "Work in Progress" - elif flt(qty) == flt(self.qty): + elif qty == flt(self.qty, precision): d.status = "Completed" - elif flt(qty) <= max_allowed_qty_for_wo: + elif qty <= flt(max_allowed_qty_for_wo, precision): d.status = "Completed" else: frappe.throw(_("Completed Qty cannot be greater than 'Qty to Manufacture'")) @@ -1509,7 +1509,7 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None): stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse stock_entry.set_stock_entry_type() - stock_entry.get_items(qty, work_order.production_item) + stock_entry.get_items() if purpose != "Disassemble": stock_entry.set_serial_no_batch_for_finished_good() diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 7a79332e316..1574c540100 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -426,4 +426,4 @@ erpnext.patches.v15_0.set_asset_status_if_not_already_set erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing execute:frappe.db.set_single_value("Accounts Settings", "show_party_balance", 1) execute:frappe.db.set_single_value("Accounts Settings", "show_account_balance", 1) -erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter +erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter #2025-12-11 diff --git a/erpnext/patches/v16_0/update_currency_exchange_settings_for_frankfurter.py b/erpnext/patches/v16_0/update_currency_exchange_settings_for_frankfurter.py index 68157b1a4ad..9d3f78cc09b 100644 --- a/erpnext/patches/v16_0/update_currency_exchange_settings_for_frankfurter.py +++ b/erpnext/patches/v16_0/update_currency_exchange_settings_for_frankfurter.py @@ -2,8 +2,13 @@ import frappe def execute(): + settings_meta = frappe.get_meta("Currency Exchange Settings") settings = frappe.get_doc("Currency Exchange Settings") - if settings.service_provider != "frankfurter.app": + + if ( + "frankfurter.dev" not in settings_meta.get_options("service_provider").split("\n") + or settings.service_provider != "frankfurter.app" + ): return settings.service_provider = "frankfurter.dev" diff --git a/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py index fe47cf7541e..b93a43239b4 100644 --- a/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py +++ b/erpnext/projects/report/delayed_tasks_summary/delayed_tasks_summary.py @@ -75,13 +75,27 @@ def get_chart_data(data): delay = delay + 1 else: on_track = on_track + 1 + + labels = [] + datasets = [] + colors = [] + + if on_track: + labels.append(_("On Track")) + datasets.append(on_track) + colors.append("#84D5BA") + if delay: + labels.append(_("Delayed")) + datasets.append(delay) + colors.append("#CB4B5F") + charts = { "data": { - "labels": [_("On Track"), _("Delayed")], - "datasets": [{"name": _("Delayed"), "values": [on_track, delay]}], + "labels": labels, + "datasets": [{"name": _("Delayed"), "values": datasets}], }, "type": "percentage", - "colors": ["#84D5BA", "#CB4B5F"], + "colors": colors, } return charts diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index d8c81b726ee..de0a7ab51bc 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -343,6 +343,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } calculate_taxes() { + // reset value from earlier calculations + this.grand_total_diff = 0; + const doc = this.frm.doc; if (!doc.taxes?.length) return; @@ -578,6 +581,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if ( diff && Math.abs(diff) <= (5.0 / Math.pow(10, precision("tax_amount", last_tax))) ) { me.grand_total_diff = diff; + } else { + me.grand_total_diff = 0; } } } @@ -587,7 +592,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { // Changing sequence can cause rounding_adjustmentng issue and on-screen discrepency const me = this; const tax_count = this.frm.doc.taxes?.length; - const grand_total_diff = this.grand_total_diff || 0; + const grand_total_diff = this.grand_total_diff; this.frm.doc.grand_total = flt(tax_count ? this.frm.doc["taxes"][tax_count - 1].total + grand_total_diff diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index f61de798390..1b23042769e 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1093,12 +1093,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe this.frm.refresh_field("payment_schedule"); } - cost_center(doc) { - this.frm.doc.items.forEach((item) => { - item.cost_center = doc.cost_center; - }); - - this.frm.refresh_field("items"); + cost_center(doc, cdt, cdn) { + erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "cost_center"); } due_date(doc, cdt, cdn) { diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index a6f1cf4b666..8be426fa1a5 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1408,7 +1408,6 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t { "Sales Order": { "doctype": "Purchase Order", - "field_map": {"dispatch_address_name": "dispatch_address"}, "field_no_map": [ "address_display", "contact_display", @@ -1417,6 +1416,7 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t "contact_person", "taxes_and_charges", "shipping_address", + "dispatch_address", ], "validation": {"docstatus": ["=", 1]}, }, @@ -1549,7 +1549,6 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): { "Sales Order": { "doctype": "Purchase Order", - "field_map": {"dispatch_address_name": "dispatch_address"}, "field_no_map": [ "address_display", "contact_display", @@ -1558,6 +1557,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): "contact_person", "taxes_and_charges", "shipping_address", + "dispatch_address", ], "validation": {"docstatus": ["=", 1]}, }, diff --git a/erpnext/setup/doctype/employee/employee.js b/erpnext/setup/doctype/employee/employee.js index 2e45d5511b3..ee6a9d31201 100755 --- a/erpnext/setup/doctype/employee/employee.js +++ b/erpnext/setup/doctype/employee/employee.js @@ -11,7 +11,13 @@ erpnext.setup.EmployeeController = class EmployeeController extends frappe.ui.fo }; }; this.frm.fields_dict.reports_to.get_query = function (doc, cdt, cdn) { - return { query: "erpnext.controllers.queries.employee_query" }; + return { + query: "erpnext.controllers.queries.employee_query", + filters: [ + ["status", "=", "Active"], + ["name", "!=", doc.name], + ], + }; }; } diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py index ae4b1db1232..9fa32c9d5ec 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py @@ -347,8 +347,9 @@ class TransactionDeletionRecord(Document): self.db_set("error_log", None) def get_doctypes_to_be_ignored_list(self): - singles = frappe.get_all("DocType", filters={"issingle": 1}, pluck="name") - doctypes_to_be_ignored_list = singles + doctypes_to_be_ignored_list = frappe.get_all( + "DocType", or_filters=[["issingle", "=", 1], ["is_virtual", "=", 1]], pluck="name" + ) for doctype in self.doctypes_to_be_ignored: doctypes_to_be_ignored_list.append(doctype.doctype_name) diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 0bcab1ccb59..ccae5136a9e 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -4,14 +4,13 @@ import click import frappe -from frappe import _ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to -from frappe.utils import cint import erpnext from erpnext.setup.default_energy_point_rules import get_default_energy_point_rules from erpnext.setup.doctype.incoterm.incoterm import create_incoterms +from erpnext.setup.utils import identity as _ from .default_success_action import get_default_success_action @@ -193,28 +192,27 @@ def add_company_to_session_defaults(): def add_standard_navbar_items(): navbar_settings = frappe.get_single("Navbar Settings") - erpnext_navbar_items = [ { - "item_label": "Documentation", + "item_label": _("Documentation"), "item_type": "Route", "route": "https://docs.erpnext.com/", "is_standard": 1, }, { - "item_label": "User Forum", + "item_label": _("User Forum"), "item_type": "Route", "route": "https://discuss.frappe.io", "is_standard": 1, }, { - "item_label": "Frappe School", + "item_label": _("Frappe School"), "item_type": "Route", "route": "https://frappe.io/school?utm_source=in_app", "is_standard": 1, }, { - "item_label": "Report an Issue", + "item_label": _("Report an Issue"), "item_type": "Route", "route": "https://github.com/frappe/erpnext/issues", "is_standard": 1, diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py index eae5c9f6c74..b7436a140f8 100644 --- a/erpnext/setup/utils.py +++ b/erpnext/setup/utils.py @@ -234,3 +234,15 @@ def welcome_email(): site_name = get_default_company() or "ERPNext" title = _("Welcome to {0}").format(site_name) return title + + +def identity(x, *args, **kwargs): + """Used for redefining the translation function to return the string as is. + + We want to create english records but still mark the strings as translatable. + E.g. when the respective DocTypes have 'Translate Link Fields' enabled or + we're creating custom fields. + + Use like this: `from erpnext.setup.utils import identity as _` + """ + return x diff --git a/erpnext/startup/boot.py b/erpnext/startup/boot.py index b17b6b49b6b..83816f6cc09 100644 --- a/erpnext/startup/boot.py +++ b/erpnext/startup/boot.py @@ -50,7 +50,7 @@ def boot_session(bootinfo): bootinfo.docs += frappe.db.sql( """select name, default_currency, cost_center, default_selling_terms, default_buying_terms, - default_letter_head, default_bank_account, enable_perpetual_inventory, country from `tabCompany`""", + default_letter_head, default_bank_account, enable_perpetual_inventory, country, exchange_gain_loss_account from `tabCompany`""", as_dict=1, update={"doctype": ":Company"}, ) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index b9c98bbb8ba..cdb219180ad 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -130,10 +130,6 @@ frappe.ui.form.on("Delivery Note Item", { var d = locals[dt][dn]; frm.update_in_all_rows("items", "expense_account", d.expense_account); }, - cost_center: function (frm, dt, dn) { - var d = locals[dt][dn]; - frm.update_in_all_rows("items", "cost_center", d.cost_center); - }, }); erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index 0136bcfcb9d..bfa1d8821ad 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -504,6 +504,61 @@ class TestInventoryDimension(FrappeTestCase): self.assertEqual(site_name, "Site 1") + def test_validate_negative_stock_with_multiple_dimension(self): + frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 0) + item_code = "Test Negative Multi Inventory Dimension Item" + create_item(item_code) + + inv_dimension_1 = create_inventory_dimension( + apply_to_all_doctypes=1, + dimension_name="Inv Site", + reference_document="Inv Site", + document_type="Inv Site", + validate_negative_stock=1, + ) + inv_dimension_1.db_set("validate_negative_stock", 1) + + inv_dimension_2 = create_inventory_dimension( + apply_to_all_doctypes=1, + dimension_name="Rack", + reference_document="Rack", + document_type="Rack", + validate_negative_stock=1, + ) + inv_dimension_2.db_set("validate_negative_stock", 1) + frappe.local.inventory_dimensions = {} + frappe.local.document_wise_inventory_dimensions = {} + + pr_doc = make_purchase_receipt(item_code=item_code, qty=30, do_not_submit=True) + pr_doc.items[0].inv_site = "Site 1" + pr_doc.items[0].rack = "Rack 1" + pr_doc.save() + pr_doc.submit() + + pr_doc = make_purchase_receipt(item_code=item_code, qty=15, do_not_submit=True) + pr_doc.items[0].inv_site = "Site 1" + pr_doc.items[0].rack = "Rack 2" + pr_doc.save() + pr_doc.submit() + + pr_doc = make_purchase_receipt(item_code=item_code, qty=30, do_not_submit=True) + pr_doc.items[0].inv_site = "Site 2" + pr_doc.items[0].rack = "Rack 1" + pr_doc.save() + pr_doc.submit() + + pr_doc = make_purchase_receipt(item_code=item_code, qty=25, do_not_submit=True) + pr_doc.items[0].inv_site = "Site 2" + pr_doc.items[0].rack = "Rack 2" + pr_doc.save() + pr_doc.submit() + + dn_doc = create_delivery_note(item_code=item_code, qty=35, do_not_submit=True) + dn_doc.items[0].inv_site = "Site 2" + dn_doc.items[0].rack = "Rack 1" + dn_doc.save() + self.assertRaises(InventoryDimensionNegativeStockError, dn_doc.submit) + def get_voucher_sl_entries(voucher_no, fields): return frappe.get_all( @@ -593,7 +648,7 @@ def prepare_test_data(): } ).insert(ignore_permissions=True) - for rack in ["Rack 1"]: + for rack in ["Rack 1", "Rack 2"]: if not frappe.db.exists("Rack", rack): frappe.get_doc({"doctype": "Rack", "rack_name": rack}).insert(ignore_permissions=True) diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 1b9ddd13f2c..ecd11a4fcd6 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -167,6 +167,7 @@ "in_preview": 1, "in_standard_filter": 1, "label": "Item Group", + "link_filters": "[[\"Item Group\",\"is_group\",\"=\",0]]", "oldfieldname": "item_group", "oldfieldtype": "Link", "options": "Item Group", @@ -894,7 +895,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2025-12-04 09:11:56.029567", + "modified": "2025-12-15 20:08:35.634046", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 7bcab314e1a..ce5affdfdbe 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -19,6 +19,7 @@ from erpnext.stock.doctype.material_request.material_request import ( make_supplier_quotation, raise_work_orders, ) +from erpnext.stock.doctype.stock_entry.stock_entry import make_stock_in_entry from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse @@ -926,6 +927,49 @@ class TestMaterialRequest(FrappeTestCase): pl_for_pending = create_pick_list(mr.name) self.assertEqual(pl_for_pending.locations[0].qty, 5) + def test_mr_status_with_partial_and_excess_end_transit(self): + material_request = make_material_request( + material_request_type="Material Transfer", + item_code="_Test Item Home Desktop 100", + ) + + in_transit_wh = get_in_transit_warehouse(material_request.company) + + # Make sure stock is available in source warehouse + self._insert_stock_entry(20.0, 20.0) + + # Stock Entry (Transfer to In-Transit) + stock_entry_1 = make_in_transit_stock_entry(material_request.name, in_transit_wh) + stock_entry_1.items[0].update( + { + "qty": 5, + "s_warehouse": "_Test Warehouse 1 - _TC", + } + ) + stock_entry_1.save().submit() + + stock_entry_2 = make_in_transit_stock_entry(material_request.name, in_transit_wh) + stock_entry_2.items[0].update( + { + "qty": 5, + "s_warehouse": "_Test Warehouse 1 - _TC", + } + ) + stock_entry_2.save().submit() + + end_transit_1 = make_stock_in_entry(stock_entry_1.name) + end_transit_1.save().submit() + + # Material Request Transfer Status should still be In Transit + material_request.load_from_db() + self.assertEqual(material_request.transfer_status, "In Transit") + + end_transit_2 = make_stock_in_entry(stock_entry_2.name) + end_transit_2.items[0].update({"qty": 6}) # More than transferred + end_transit_2.save() + + self.assertRaises(frappe.ValidationError, end_transit_2.submit) + def get_in_transit_warehouse(company): if not frappe.db.exists("Warehouse Type", "Transit"): diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 7a4101e9dfb..fc3df50de80 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -8,7 +8,7 @@ import frappe from frappe import _, throw from frappe.desk.notifications import clear_doctype_notifications from frappe.model.mapper import get_mapped_doc -from frappe.query_builder.functions import CombineDatetime +from frappe.query_builder.functions import Abs, CombineDatetime, Sum from frappe.utils import cint, flt, get_datetime, getdate, nowdate from pypika import functions as fn @@ -1372,21 +1372,26 @@ def get_invoiced_qty_map(purchase_receipt): def get_returned_qty_map(purchase_receipt): - """returns a map: {so_detail: returned_qty}""" - returned_qty_map = frappe._dict( - frappe.db.sql( - """select pr_item.purchase_receipt_item, abs(pr_item.qty) as qty - from `tabPurchase Receipt Item` pr_item, `tabPurchase Receipt` pr - where pr.name = pr_item.parent - and pr.docstatus = 1 - and pr.is_return = 1 - and pr.return_against = %s - """, - purchase_receipt, - ) - ) + """returns a map: {pr_detail: returned_qty}""" - return returned_qty_map + pr = frappe.qb.DocType("Purchase Receipt") + pr_item = frappe.qb.DocType("Purchase Receipt Item") + + query = ( + frappe.qb.from_(pr) + .inner_join(pr_item) + .on(pr.name == pr_item.parent) + .select(pr_item.purchase_receipt_item, Sum(Abs(pr_item.qty)).as_("qty")) + .where( + (pr.docstatus == 1) + & (pr.is_return == 1) + & (pr.return_against == purchase_receipt) + & (pr_item.purchase_receipt_item.isnotnull()) + ) + .groupby(pr_item.purchase_receipt_item) + ).run(as_list=1) + + return frappe._dict(query) if query else frappe._dict() @frappe.whitelist() diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index d53382b7047..e20363cec3a 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -2008,6 +2008,8 @@ def get_available_serial_nos(kwargs): filters["name"] = ("in", time_based_serial_nos) elif ignore_serial_nos: filters["name"] = ("not in", ignore_serial_nos) + elif kwargs.get("serial_nos"): + filters["name"] = ("in", kwargs.get("serial_nos")) if kwargs.get("batches"): batches = get_non_expired_batches(kwargs.get("batches")) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 942a9f8133c..a441664ab9c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -8,6 +8,7 @@ from collections import defaultdict import frappe from frappe import _, bold from frappe.model.mapper import get_mapped_doc +from frappe.query_builder import DocType from frappe.query_builder.functions import Sum from frappe.utils import ( cint, @@ -1905,7 +1906,7 @@ class StockEntry(StockController): }, ) - def get_items_for_disassembly(self, disassemble_qty, production_item): + def get_items_for_disassembly(self): """Get items for Disassembly Order""" if not self.work_order: @@ -1918,7 +1919,7 @@ class StockEntry(StockController): items_dict = get_bom_items_as_dict( self.bom_no, self.company, - disassemble_qty, + self.fg_completed_qty, fetch_exploded=self.use_multi_level_bom, fetch_qty_in_stock_uom=False, ) @@ -1935,8 +1936,8 @@ class StockEntry(StockController): child_row.qty = bom_items.get("qty", child_row.qty) child_row.amount = bom_items.get("amount", child_row.amount) - if row.item_code == production_item: - child_row.qty = disassemble_qty + if row.is_finished_item: + child_row.qty = self.fg_completed_qty child_row.s_warehouse = (self.from_warehouse or s_warehouse) if row.is_finished_item else "" child_row.t_warehouse = row.s_warehouse @@ -1972,12 +1973,12 @@ class StockEntry(StockController): ) @frappe.whitelist() - def get_items(self, qty=None, production_item=None): + def get_items(self): self.set("items", []) self.validate_work_order() - if self.purpose == "Disassemble" and qty is not None: - return self.get_items_for_disassembly(qty, production_item) + if self.purpose == "Disassemble": + return self.get_items_for_disassembly() if not self.posting_date or not self.posting_time: frappe.throw(_("Posting date and posting time is mandatory")) @@ -2815,6 +2816,17 @@ class StockEntry(StockController): }, ) + if d.docstatus == 1: + transfer_qty = frappe.get_value("Stock Entry Detail", d.ste_detail, "transfer_qty") + + if transferred_qty and transferred_qty[0]: + if transferred_qty[0].qty > transfer_qty: + frappe.throw( + _( + "Row {0}: Transferred quantity cannot be greater than the requested quantity." + ).format(d.idx) + ) + stock_entries[(d.against_stock_entry, d.ste_detail)] = ( transferred_qty[0].qty if transferred_qty and transferred_qty[0] else 0.0 ) or 0.0 @@ -2878,7 +2890,7 @@ class StockEntry(StockController): parent_se = frappe.get_value("Stock Entry", self.outgoing_stock_entry, "add_to_transit") for item in self.items: - material_request = item.material_request or None + material_request = item.get("material_request") if self.purpose == "Material Transfer" and material_request not in material_requests: if self.outgoing_stock_entry and parent_se: material_request = frappe.get_value( @@ -2887,6 +2899,11 @@ class StockEntry(StockController): if material_request and material_request not in material_requests: material_requests.append(material_request) + if status == "Completed": + qty = get_transferred_qty(material_request) + if qty.get("transfer_qty") > qty.get("transferred_qty"): + status = "In Transit" + frappe.db.set_value("Material Request", material_request, "transfer_status", status) def set_serial_no_batch_for_finished_good(self): @@ -3545,3 +3562,18 @@ def get_batchwise_serial_nos(item_code, row): batchwise_serial_nos[batch_no] = sorted([serial_no.name for serial_no in serial_nos]) return batchwise_serial_nos + + +def get_transferred_qty(material_request): + sed = DocType("Stock Entry Detail") + + query = ( + frappe.qb.from_(sed) + .select( + Sum(sed.transfer_qty).as_("transfer_qty"), + Sum(sed.transferred_qty).as_("transferred_qty"), + ) + .where((sed.material_request == material_request) & (sed.docstatus == 1)) + ).run(as_dict=True) + + return query[0] diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index ddef027d0b8..5344636dfaa 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -113,17 +113,15 @@ class StockLedgerEntry(Document): return flt_precision = cint(frappe.db.get_default("float_precision")) or 2 - for dimension, values in dimensions.items(): - dimension_value = values.get("value") - available_qty = self.get_available_qty_after_prev_transaction(dimension, dimension_value) + available_qty = self.get_available_qty_after_prev_transaction(dimensions) - diff = flt(available_qty + flt(self.actual_qty), flt_precision) # qty after current transaction - if diff < 0 and abs(diff) > 0.0001: - self.throw_validation_error(diff, dimension, dimension_value) + diff = flt(available_qty + flt(self.actual_qty), flt_precision) # qty after current transaction + if diff < 0 and abs(diff) > 0.0001: + self.throw_validation_error(diff, dimensions) - def get_available_qty_after_prev_transaction(self, dimension, dimension_value): + def get_available_qty_after_prev_transaction(self, dimensions): sle = frappe.qb.DocType("Stock Ledger Entry") - available_qty = ( + available_qty_query = ( frappe.qb.from_(sle) .select(Sum(sle.actual_qty)) .where( @@ -132,21 +130,27 @@ class StockLedgerEntry(Document): & (sle.posting_datetime < self.posting_datetime) & (sle.company == self.company) & (sle.is_cancelled == 0) - & (sle[dimension] == dimension_value) ) - ).run() + ) + + for dimension, values in dimensions.items(): + dimension_value = values.get("value") + available_qty_query = available_qty_query.where(sle[dimension] == dimension_value) + + available_qty = available_qty_query.run() return available_qty[0][0] or 0 - def throw_validation_error(self, diff, dimension, dimension_value): + def throw_validation_error(self, diff, dimensions): msg = _( - "{0} units of {1} are required in {2} with the inventory dimension: {3} ({4}) on {5} {6} for {7} to complete the transaction." + "{0} units of {1} are required in {2} with the inventory dimension: {3} on {4} {5} for {6} to complete the transaction." ).format( abs(diff), frappe.get_desk_link("Item", self.item_code), frappe.get_desk_link("Warehouse", self.warehouse), - frappe.bold(dimension), - frappe.bold(dimension_value), + frappe.bold( + ", ".join([f"{dimension}: {values.get('value')}" for dimension, values in dimensions.items()]) + ), self.posting_date, self.posting_time, frappe.get_desk_link(self.voucher_type, self.voucher_no), diff --git a/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py b/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py index 0ec4e1ce957..a456bad72d7 100644 --- a/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py +++ b/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py @@ -20,6 +20,9 @@ def execute(filters=None): def get_chart_data(data, filters): + def wrap_in_quotes(label): + return f"'{label}'" + if not data: return [] @@ -36,6 +39,9 @@ def get_chart_data(data, filters): data = data[:10] for row in data: + if row[0] == wrap_in_quotes(_("Total")): + continue + labels.append(row[0]) datapoints.append(row[-1]) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 0edce832aec..46f655c8b11 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -273,6 +273,7 @@ class FIFOSlots: else: serial_nos = get_serial_nos_from_bundle(d.serial_and_batch_bundle) or [] + serial_nos = self.uppercase_serial_nos(serial_nos) if d.actual_qty > 0: self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos) else: @@ -289,6 +290,10 @@ class FIFOSlots: return self.item_details + def uppercase_serial_nos(self, serial_nos): + "Convert serial nos to uppercase for uniformity." + return [sn.upper() for sn in serial_nos] + def __init_key_stores(self, row: dict) -> tuple: "Initialise keys and FIFO Queue." diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 15e0259722f..cd20986dc5a 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -543,6 +543,12 @@ class SubcontractingReceipt(SubcontractingController): for row in self.items: precision = row.precision("qty") + + # if allow alternative item, ignore the validation as per BOM required qty + is_allow_alternative_item = frappe.db.get_value("BOM", row.bom, "allow_alternative_item") + if is_allow_alternative_item: + continue + for bom_item in self._get_materials_from_bom( row.item_code, row.bom, row.get("include_exploded_items") ): diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index 3ec23cf1481..b6023b47e76 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -199,7 +199,7 @@ def get_issue_list(doctype, txt, filters, limit_start, limit_page_length=20, ord customer = contact_doc.get_link_for("Customer") ignore_permissions = False - if is_website_user(): + if is_website_user() and user != "Guest": if not filters: filters = {} diff --git a/erpnext/utilities/doctype/rename_tool/rename_tool.js b/erpnext/utilities/doctype/rename_tool/rename_tool.js index 47677a62500..621f8484f53 100644 --- a/erpnext/utilities/doctype/rename_tool/rename_tool.js +++ b/erpnext/utilities/doctype/rename_tool/rename_tool.js @@ -2,14 +2,6 @@ // License: GNU General Public License v3. See license.txt frappe.ui.form.on("Rename Tool", { - onload: function (frm) { - return frappe.call({ - method: "erpnext.utilities.doctype.rename_tool.rename_tool.get_doctypes", - callback: function (r) { - frm.set_df_property("select_doctype", "options", r.message); - }, - }); - }, refresh: function (frm) { frm.disable_save(); diff --git a/erpnext/utilities/doctype/rename_tool/rename_tool.json b/erpnext/utilities/doctype/rename_tool/rename_tool.json index 617354d91ce..cfa08670c76 100644 --- a/erpnext/utilities/doctype/rename_tool/rename_tool.json +++ b/erpnext/utilities/doctype/rename_tool/rename_tool.json @@ -8,27 +8,13 @@ "doctype": "DocType", "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "description": "Type of document to rename.", - "fieldname": "select_doctype", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Select DocType", - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "description": "Type of document to rename.", + "fieldname": "select_doctype", + "fieldtype": "Link", + "label": "Select DocType", + "link_filters": "[[\"DocType\",\"allow_rename\",\"=\",1],[\"DocType\",\"module\",\"!=\",\"Core\"]]", + "options": "DocType" + }, { "allow_on_submit": 0, "bold": 0, @@ -72,22 +58,18 @@ "set_only_once": 0, "unique": 0 } - ], - "hide_heading": 0, - "hide_toolbar": 1, - "icon": "fa fa-magic", - "idx": 1, - "in_create": 0, - - "is_submittable": 0, - "issingle": 1, - "istable": 0, - "max_attachments": 1, - "modified": "2015-10-19 03:04:49.097140", - "modified_by": "Administrator", - "module": "Utilities", - "name": "Rename Tool", - "owner": "Administrator", + ], + "hide_toolbar": 1, + "icon": "fa fa-magic", + "idx": 1, + "issingle": 1, + "links": [], + "max_attachments": 1, + "modified": "2025-12-09 14:18:33.838623", + "modified_by": "Administrator", + "module": "Utilities", + "name": "Rename Tool", + "owner": "Administrator", "permissions": [ { "amend": 0, @@ -112,4 +94,4 @@ ], "read_only": 0, "read_only_onload": 0 -} \ No newline at end of file +} diff --git a/erpnext/utilities/doctype/rename_tool/rename_tool.py b/erpnext/utilities/doctype/rename_tool/rename_tool.py index 230845e55de..0134d9c3e1e 100644 --- a/erpnext/utilities/doctype/rename_tool/rename_tool.py +++ b/erpnext/utilities/doctype/rename_tool/rename_tool.py @@ -7,6 +7,7 @@ import frappe from frappe.model.document import Document from frappe.model.rename_doc import bulk_rename +from frappe.utils.deprecations import deprecated class RenameTool(Document): @@ -19,13 +20,14 @@ class RenameTool(Document): from frappe.types import DF file_to_rename: DF.Attach | None - select_doctype: DF.Literal + select_doctype: DF.Link | None # end: auto-generated types pass @frappe.whitelist() +@deprecated def get_doctypes(): return frappe.db.sql_list( """select name from tabDocType