diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js index 2637e49d00a..fac8b582a22 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js @@ -22,17 +22,27 @@ frappe.ui.form.on("Exchange Rate Revaluation", { refresh: function (frm) { if (frm.doc.docstatus == 1) { frappe.call({ - method: "check_journal_entry_condition", + method: "check_journal_and_reversal", doc: frm.doc, callback: function (r) { if (r.message) { - frm.add_custom_button( - __("Journal Entries"), - function () { - return frm.events.make_jv(frm); - }, - __("Create") - ); + if (!r.message.journals_posted) { + frm.add_custom_button( + __("Journal Entries"), + function () { + return frm.events.make_jv(frm); + }, + __("Create") + ); + } else if (!r.message.reversals_posted) { + frm.add_custom_button( + __("Reversal Journal Entries"), + function () { + return frm.events.make_reverse_journal(frm); + }, + __("Create") + ); + } } }, }); @@ -100,6 +110,14 @@ frappe.ui.form.on("Exchange Rate Revaluation", { }, }); }, + make_reverse_journal: function (frm) { + frappe.call({ + method: "make_reverse_journal", + doc: frm.doc, + freeze: true, + freeze_message: __("Reversing Journals..."), + }); + }, }); frappe.ui.form.on("Exchange Rate Revaluation Account", { diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index 0ed30eaee52..84ba411c97f 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -9,7 +9,7 @@ from frappe.model.document import Document from frappe.model.meta import get_field_precision from frappe.query_builder import Criterion, Order from frappe.query_builder.functions import Max, NullIf, Sum -from frappe.utils import flt, get_link_to_form +from frappe.utils import flt, get_link_to_form, nowdate import erpnext from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on @@ -91,25 +91,31 @@ class ExchangeRateRevaluation(Document): ) def on_cancel(self): - self.ignore_linked_doctypes = "GL Entry" + self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"] @frappe.whitelist() - def check_journal_entry_condition(self): + def check_journal_and_reversal(self): exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account() + journals_posted = False + reversals_posted = False + + je = qb.DocType("Journal Entry") jea = qb.DocType("Journal Entry Account") journals = ( - qb.from_(jea) - .select(jea.parent) + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) .distinct() .where( (jea.reference_type == "Exchange Rate Revaluation") & (jea.reference_name == self.name) & (jea.docstatus == 1) + & (je.reversal_of.isnull()) # omit journals that have reversals ) - .run() + .run(pluck="name") ) - if journals: gle = qb.DocType("GL Entry") total_amt = ( @@ -124,12 +130,31 @@ class ExchangeRateRevaluation(Document): .run() ) - if total_amt and total_amt[0][0] != self.total_gain_loss: - return True + if total_amt and total_amt[0][0] == self.total_gain_loss: + journals_posted = True else: - return False + journals_posted = False - return True + # reverse journals + reverse_journals = ( + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) + .where( + (jea.reference_type == "Exchange Rate Revaluation") + & (jea.reference_name == self.name) + & (jea.docstatus == 1) + & (je.reversal_of.notnull()) + ) + .run(pluck="name") + ) + if reverse_journals: + reversals_posted = True + else: + reversals_posted = False + + return {"journals_posted": journals_posted, "reversals_posted": reversals_posted} def fetch_and_calculate_accounts_data(self): accounts = self.get_accounts_data() @@ -347,6 +372,7 @@ class ExchangeRateRevaluation(Document): @frappe.whitelist() def make_jv_entries(self): + frappe.has_permission("Journal Entry", "write", throw=True) zero_balance_jv = self.make_jv_for_zero_balance() if zero_balance_jv: frappe.msgprint( @@ -575,6 +601,38 @@ class ExchangeRateRevaluation(Document): journal_entry.save() return journal_entry + @frappe.whitelist() + def make_reverse_journal(self): + frappe.has_permission("Journal Entry", "write", throw=True) + je = qb.DocType("Journal Entry") + jea = qb.DocType("Journal Entry Account") + journals = ( + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) + .distinct() + .where( + (jea.reference_type == "Exchange Rate Revaluation") + & (jea.reference_name == self.name) + & (jea.docstatus == 1) + & (je.reversal_of.isnull()) # omit journals that have reversals + ) + .run(pluck="name") + ) + if journals: + from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry + + for x in journals: + reversal = make_reverse_journal_entry(x) + reversal.posting_date = nowdate() + reversal.submit() + frappe.msgprint( + _("Revaluation journal for {0} has been created: {1}").format( + frappe.bold(x), get_link_to_form("Journal Entry", reversal.name) + ) + ) + def calculate_exchange_rate_using_last_gle(company, account, party_type, party): """ diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py index e794311c2cd..5a37bccaafb 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py @@ -132,7 +132,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): err = err.save().submit() # Create JV for ERR - self.assertTrue(err.check_journal_entry_condition()) + ret = err.check_journal_and_reversal() + self.assertFalse(ret.get("journals_posted")) err_journals = err.make_jv_entries() je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) je = je.submit() @@ -221,7 +222,8 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): err = err.save().submit() # Create JV for ERR - self.assertTrue(err.check_journal_entry_condition()) + ret = err.check_journal_and_reversal() + self.assertFalse(ret.get("journals_posted")) err_journals = err.make_jv_entries() je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv")) je = je.submit() @@ -299,6 +301,86 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): for key, _val in expected_data.items(): self.assertEqual(expected_data.get(key), account_details.get(key)) + @ERPNextTestSuite.change_settings( + "Accounts Settings", + {"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0}, + ) + def test_05_revaluation_journal_reversal(self): + """ + Test reversing of revaluation journals + """ + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debtors_usd, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + price_list_rate=100, + do_not_submit=1, + ) + si.currency = "USD" + si.conversion_rate = 80 + si.save().submit() + + err = frappe.new_doc("Exchange Rate Revaluation") + err.company = self.company + err.posting_date = today() + err.fetch_and_calculate_accounts_data() + self.assertEqual(len(err.accounts), 1) + err.save().submit() + + gain_loss_account = err.get_for_unrealized_gain_loss_account() + usd_account = err.accounts[0].account + old_balance = err.accounts[0].balance_in_base_currency + new_balance = err.accounts[0].new_balance_in_base_currency + total_gain_loss = err.total_gain_loss + + # Create JV for ERR + ret = err.check_journal_and_reversal() + self.assertFalse(ret.get("journals_posted")) + err_journals = err.make_jv_entries() + je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv")) + je = je.submit() + + je.reload() + self.assertEqual(je.voucher_type, "Exchange Rate Revaluation") + self.assertEqual(len(je.accounts), 3) + expected = [ + (usd_account, new_balance, 0.0, 100.0, 0.0), + (usd_account, 0.0, old_balance, 0.0, 100.0), + (gain_loss_account, 0.0, total_gain_loss, 0.0, total_gain_loss), + ] + actual = [] + for acc in je.accounts: + actual.append( + ( + acc.account, + acc.debit, + acc.credit, + acc.debit_in_account_currency, + acc.credit_in_account_currency, + ) + ) + self.assertEqual(expected, actual) + + # Assert reversals are not posted + ret = err.check_journal_and_reversal() + self.assertTrue(ret.get("journals_posted")) + self.assertFalse(ret.get("reversals_posted")) + + err.make_reverse_journal() + ret = err.check_journal_and_reversal() + self.assertTrue(ret.get("journals_posted")) + self.assertTrue(ret.get("reversals_posted")) + + reverse_jv = frappe.db.get_all( + "Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name" + ) + self.assertIsNotNone(reverse_jv) + class TestExchangeRateRevaluationValidation(ERPNextTestSuite): """Validation and gain/loss calculation paths, exercised on the document directly diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js index 6ea0df946f2..1738beb3630 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js @@ -1,7 +1,10 @@ frappe.listview_settings["Journal Entry"] = { - add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark"], + add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark", "reversal_of"], get_indicator: function (doc) { if (doc.docstatus === 1) { + if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") { + return [__("Reversal Of Exchange Rate Revaluation"), "blue"]; + } return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`]; } }, diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json b/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json index 6448d725de9..7389d0687b6 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json @@ -82,6 +82,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Outstanding Amount", + "options": "Company:company:default_currency", "reqd": 1 }, { @@ -136,7 +137,7 @@ ], "istable": 1, "links": [], - "modified": "2026-04-29 17:08:15.617047", + "modified": "2026-07-02 15:17:11.938499", "modified_by": "Administrator", "module": "Accounts", "name": "Opening Invoice Creation Tool Item", diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 5b6a56e69c3..d71af8bc677 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -542,6 +542,7 @@ class PaymentRequest(Document): bank_amount=bank_amount, created_from_payment_request=True, ) + payment_entry.set_missing_ref_details(force=True) payment_entry.update( { diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index f09b9b6a626..440933360d1 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -774,6 +774,22 @@ class TestPaymentRequest(ERPNextTestSuite): pi.load_from_db() self.assertEqual(pr_2.grand_total, pi.outstanding_amount) + def test_payment_entry_reference_details_fetched_from_invoice(self): + pi = make_purchase_invoice(currency="INR", qty=1, rate=94500) + pi.submit() + + pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1, submit_doc=0, return_doc=1) + pr.grand_total = 94000 + pr.submit() + + pe = pr.create_payment_entry(submit=False) + + self.assertEqual(pe.references[0].reference_name, pi.name) + self.assertEqual(pe.references[0].total_amount, pi.grand_total) + self.assertEqual(pe.references[0].outstanding_amount, pi.outstanding_amount) + self.assertEqual(pe.references[0].allocated_amount, 94000) + self.assertEqual(pe.paid_amount, 94000) + def test_consider_journal_entry_and_return_invoice(self): from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 9b5fbc1b606..ac6f6fdac66 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -264,10 +264,12 @@ class ReceivablePayableReport: # Build and use a separate row for Employee Advances. # This allows Payments or Journals made against Emp Advance to be processed. - if ( - not row - and ple.against_voucher_type == "Employee Advance" - and self.filters.handle_employee_advances + if not row and ( + (ple.against_voucher_type == "Employee Advance" and self.filters.handle_employee_advances) + or ( + ple.against_voucher_type == "Exchange Rate Revaluation" + and self.filters.for_revaluation_journals + ) ): _d = self.build_voucher_dict(ple) _d.voucher_type = ple.against_voucher_type diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py index 31da6d5c41f..65e1853b83f 100644 --- a/erpnext/accounts/report/cash_flow/cash_flow.py +++ b/erpnext/accounts/report/cash_flow/cash_flow.py @@ -88,6 +88,7 @@ def execute(filters=None): "parent_section": None, "indent": 0.0, "section": cash_flow_section["section_header"], + "currency": company_currency, } ) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index af209a67f25..c600226e9ee 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -227,6 +227,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_ ) if total_base_amount else 0, + "currency": filters.currency, } ) ) @@ -269,6 +270,7 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_ "buying_amount": total_buying_amount, "gross_profit": total_gross_profit, "gross_profit_percent": flt(gross_profit_percent, currency_precision), + "currency": filters.currency, } total_row = [total_row.get(col, None) for col in [*group_columns, "currency"]] diff --git a/erpnext/accounts/services/deferred_accounting.py b/erpnext/accounts/services/deferred_accounting.py new file mode 100644 index 00000000000..8465d079955 --- /dev/null +++ b/erpnext/accounts/services/deferred_accounting.py @@ -0,0 +1,57 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Deferred revenue/expense accounting validations.""" + +import frappe +from frappe import _ +from frappe.utils import getdate + +DEFERRED_ACCOUNT_FIELD = { + "Sales Invoice": "deferred_revenue_account", + "Purchase Invoice": "deferred_expense_account", +} + + +class DeferredAccountingService: + def __init__(self, doc): + self.doc = doc + + def validate_income_expense_account(self) -> None: + account_field = DEFERRED_ACCOUNT_FIELD.get(self.doc.doctype) + + for item in self.doc.get("items"): + if not self._is_deferred(item) or item.get(account_field): + continue + + default_account = frappe.get_cached_value("Company", self.doc.company, "default_" + account_field) + if not default_account: + frappe.throw( + _( + "Row #{0}: Please update deferred revenue/expense account in item row or default account in company master" + ).format(item.idx) + ) + item.set(account_field, default_account) + + def validate_start_and_end_date(self) -> None: + for item in self.doc.items: + if not self._is_deferred(item): + continue + + if not (item.service_start_date and item.service_end_date): + frappe.throw( + _("Row #{0}: Service Start and End Date is required for deferred accounting").format( + item.idx + ) + ) + elif getdate(item.service_start_date) > getdate(item.service_end_date): + frappe.throw( + _("Row #{0}: Service Start Date cannot be greater than Service End Date").format(item.idx) + ) + elif getdate(self.doc.posting_date) > getdate(item.service_end_date): + frappe.throw( + _("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(item.idx) + ) + + def _is_deferred(self, item) -> bool: + return bool(item.get("enable_deferred_revenue") or item.get("enable_deferred_expense")) diff --git a/erpnext/accounts/services/payment_schedule.py b/erpnext/accounts/services/payment_schedule.py index d1ff7e91cb7..79cf352cd6c 100644 --- a/erpnext/accounts/services/payment_schedule.py +++ b/erpnext/accounts/services/payment_schedule.py @@ -293,6 +293,39 @@ class PaymentScheduleService: _("Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total") ) + def validate_all_documents_schedule(self) -> None: + if self.doc.doctype in ("Sales Invoice", "Purchase Invoice"): + self.validate_invoice_documents_schedule() + elif self.doc.doctype in ("Quotation", "Purchase Order", "Sales Order"): + self.validate_non_invoice_documents_schedule() + + def validate_invoice_documents_schedule(self) -> None: + doc = self.doc + if ( + doc.is_return + or (doc.doctype == "Purchase Invoice" and doc.is_paid) + or (doc.doctype == "Sales Invoice" and doc.is_pos) + or doc.get("is_opening") == "Yes" + ): + doc.payment_terms_template = "" + doc.payment_schedule = [] + + if doc.is_return: + return + + self.validate_payment_schedule_dates() + self.set_due_date() + self.set_payment_schedule() + if not doc.get("ignore_default_payment_terms_template"): + self.validate_payment_schedule_amount() + doc.validate_due_date() + doc.validate_advance_entries() + + def validate_non_invoice_documents_schedule(self) -> None: + self.set_payment_schedule() + self.validate_payment_schedule_dates() + self.validate_payment_schedule_amount() + def linked_order_has_payment_terms_template(po_or_so, doctype) -> str | None: return frappe.get_value(doctype, po_or_so, "payment_terms_template") diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py index dd518e838ad..f220b9a5308 100644 --- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py +++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.py @@ -14,7 +14,6 @@ def execute(filters=None): conditions = get_columns(filters, "Purchase Order") data = get_data(filters, conditions) chart_data = get_chart_data(data, conditions, filters) - return conditions["columns"], data, None, chart_data @@ -39,9 +38,15 @@ def get_chart_data(data, conditions, filters): labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns] datapoints = [0] * len(labels) + group_by_col_idx = None + if filters.get("group_by"): + group_by_col_idx = conditions["columns"].index(conditions["grbc"][0]) + for row in data: - # If group by filter, don't add first row of group (it's already summed) - if not row[start]: + # Skip the final grand-total row + if row[0] == f"'{_('Total')}'": + continue + if group_by_col_idx is not None and row[group_by_col_idx] == "": continue # Remove None values and compute only periodic data row = [x if x else 0 for x in row[start:-2]] @@ -60,4 +65,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } diff --git a/erpnext/buying/report/purchase_order_trends/test_purchase_order_trends.py b/erpnext/buying/report/purchase_order_trends/test_purchase_order_trends.py index 90d84447cb7..d11ad290120 100644 --- a/erpnext/buying/report/purchase_order_trends/test_purchase_order_trends.py +++ b/erpnext/buying/report/purchase_order_trends/test_purchase_order_trends.py @@ -2,7 +2,10 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe import _ +from frappe.utils import today +from erpnext.accounts.utils import get_fiscal_year from erpnext.tests.utils import ERPNextTestSuite @@ -30,3 +33,166 @@ class TestPurchaseOrderTrends(ERPNextTestSuite): self.assertTrue(columns) supplier_rows = [row for row in data if row[0] == "_Test Supplier"] self.assertEqual(len(supplier_rows), 1) + + def test_total_row_not_double_counted_in_chart(self): + # Regression test for the fix in trends.calculate_total_row that populates the + # Total row's Currency column. Before the fix in get_chart_data (skipping the + # Total row by label instead of `if not row[start]`), that populated Currency + # cell made the Total-row-skip guard falsy, so the already-summed Total row got + # added into the chart a second time (a PO of qty=3, rate=100 -> 300 read as 600). + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order(supplier="_Test Supplier", qty=3, rate=100, transaction_date=today()) + + fiscal_year = get_fiscal_year(today())[0] + + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + } + ) + + columns, data, _message, chart = execute(filters) + + self.assertTrue(columns) + self.assertTrue(data) + + # The Total row (present in `data`) must not be re-summed into the chart's datapoints. + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] # Total(Amt) is the last column + + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + self.assertEqual(chart_total, expected_total) + self.assertEqual(chart_total, 300) + + def test_chart_currency_matches_company_currency(self): + # Regression test: the chart's "currency" key should reflect the transacting + # company's currency (conditions["company_currency"]), not a stale global default. + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order(supplier="_Test Supplier", qty=1, rate=100, transaction_date=today()) + + fiscal_year = get_fiscal_year(today())[0] + + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + } + ) + + _columns, _data, _message, chart = execute(filters) + + expected_currency = frappe.get_cached_value("Company", "_Test Company", "default_currency") + self.assertEqual(chart["currency"], expected_currency) + + def test_group_by_chart_matches_table_total_with_mixed_group_sizes(self): + # _Test Item is split across two suppliers -> two detail rows under one header row. + # _Test Item 2 has only one supplier -> exactly one detail row under its header row. + # A regression that double-counts header rows would inflate the chart above 600; + # a regression that zeroes single-group rows would report less than 600. + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order( + item_code="_Test Item", supplier="_Test Supplier", qty=3, rate=100, transaction_date=today() + ) + create_purchase_order( + item_code="_Test Item", supplier="_Test Supplier 1", qty=2, rate=100, transaction_date=today() + ) + create_purchase_order( + item_code="_Test Item 2", supplier="_Test Supplier", qty=1, rate=100, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + "group_by": "Supplier", + } + ) + + columns, data, _message, chart = execute(filters) + self.assertTrue(columns) + self.assertTrue(data) + + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 300 (item/supplier) + 200 (item/supplier1) + 100 (item2/supplier) = 600 + self.assertEqual(expected_total, 600) + self.assertEqual(chart_total, expected_total) + + def test_group_by_swapped_roles_based_on_supplier_group_by_item(self): + # Same regression, opposite role assignment: based_on="Supplier" with group_by="Item". + # Supplier's based_on_cols (Supplier, Supplier Name, Supplier Group, Currency) put the + # group_by placeholder at a different column index than the Item-based_on case above, + # exercising the alternate `inc`/`ind` arithmetic. + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order( + item_code="_Test Item", supplier="_Test Supplier", qty=3, rate=100, transaction_date=today() + ) + create_purchase_order( + item_code="_Test Item 2", supplier="_Test Supplier", qty=1, rate=100, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Supplier", + "group_by": "Item", + } + ) + + columns, data, _message, chart = execute(filters) + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 300 + 100 = 400 + self.assertEqual(expected_total, 400) + self.assertEqual(chart_total, expected_total) + + def test_group_by_single_group_value_not_zeroed(self): + # Isolates the specific failure mode flagged in review: a based_on value with exactly + # one associated group value must still contribute its real amount to the chart, not 0. + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.report.purchase_order_trends.purchase_order_trends import execute + + create_purchase_order( + item_code="_Test Item", supplier="_Test Supplier", qty=2, rate=150, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + "group_by": "Supplier", + } + ) + + columns, data, _message, chart = execute(filters) + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + self.assertGreater(chart_total, 0) + self.assertEqual(chart_total, 300) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 6b477471db7..56e6e381bb5 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -234,7 +234,9 @@ class AccountsController(TransactionBase): if self.is_return: self.validate_qty() else: - self.validate_deferred_start_and_end_date() + from erpnext.accounts.services.deferred_accounting import DeferredAccountingService + + DeferredAccountingService(self).validate_start_and_end_date() from erpnext.accounts.services.internal_transfer import InternalTransferService @@ -262,7 +264,9 @@ class AccountsController(TransactionBase): validate_return(self) - self.validate_all_documents_schedule() + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(self).validate_all_documents_schedule() from erpnext.accounts.services.party_validation import PartyValidator @@ -286,7 +290,9 @@ class AccountsController(TransactionBase): self.set_advance_gain_or_loss() - self.validate_deferred_income_expense_account() + from erpnext.accounts.services.deferred_accounting import DeferredAccountingService + + DeferredAccountingService(self).validate_income_expense_account() InternalTransferService(self).set_account() if self.doctype == "Purchase Invoice": @@ -504,89 +510,10 @@ class AccountsController(TransactionBase): ) ) - def validate_deferred_income_expense_account(self): - field_map = { - "Sales Invoice": "deferred_revenue_account", - "Purchase Invoice": "deferred_expense_account", - } - - for item in self.get("items"): - if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"): - if not item.get(field_map.get(self.doctype)): - default_deferred_account = frappe.get_cached_value( - "Company", self.company, "default_" + field_map.get(self.doctype) - ) - if not default_deferred_account: - frappe.throw( - _( - "Row #{0}: Please update deferred revenue/expense account in item row or default account in company master" - ).format(item.idx) - ) - else: - item.set(field_map.get(self.doctype), default_deferred_account) - def validate_auto_repeat_subscription_dates(self): if self.get("from_date") and self.get("to_date") and getdate(self.from_date) > getdate(self.to_date): frappe.throw(_("To Date cannot be before From Date"), title=_("Invalid Auto Repeat Date")) - def validate_deferred_start_and_end_date(self): - for d in self.items: - if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"): - if not (d.service_start_date and d.service_end_date): - frappe.throw( - _("Row #{0}: Service Start and End Date is required for deferred accounting").format( - d.idx - ) - ) - elif getdate(d.service_start_date) > getdate(d.service_end_date): - frappe.throw( - _("Row #{0}: Service Start Date cannot be greater than Service End Date").format( - d.idx - ) - ) - elif getdate(self.posting_date) > getdate(d.service_end_date): - frappe.throw( - _("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(d.idx) - ) - - def validate_invoice_documents_schedule(self): - if ( - self.is_return - or (self.doctype == "Purchase Invoice" and self.is_paid) - or (self.doctype == "Sales Invoice" and self.is_pos) - or self.get("is_opening") == "Yes" - ): - self.payment_terms_template = "" - self.payment_schedule = [] - - if self.is_return: - return - - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - ps = PaymentScheduleService(self) - ps.validate_payment_schedule_dates() - ps.set_due_date() - ps.set_payment_schedule() - if not self.get("ignore_default_payment_terms_template"): - ps.validate_payment_schedule_amount() - self.validate_due_date() - self.validate_advance_entries() - - def validate_non_invoice_documents_schedule(self): - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - ps = PaymentScheduleService(self) - ps.set_payment_schedule() - ps.validate_payment_schedule_dates() - ps.validate_payment_schedule_amount() - - def validate_all_documents_schedule(self): - if self.doctype in ("Sales Invoice", "Purchase Invoice"): - self.validate_invoice_documents_schedule() - elif self.doctype in ("Quotation", "Purchase Order", "Sales Order"): - self.validate_non_invoice_documents_schedule() - def before_print(self, settings=None): if self.doctype in [ "Purchase Order", diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index 4dadc91da3b..3e4f632307e 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -186,6 +186,68 @@ def update_variant_attribute_values(item_attribute): frappe.flags.attribute_values = None +def get_attribute_abbr_renames(item_attribute): + """Return the set of (current) attribute values whose abbreviation was renamed.""" + if item_attribute.numeric_values: + return set() + + db_value = item_attribute.get_doc_before_save() + if not db_value: + return set() + + old_abbrs = {d.name: d.abbr for d in db_value.item_attribute_values} + changed_values = set() + + for row in item_attribute.item_attribute_values: + if row.name in old_abbrs and old_abbrs[row.name] != row.abbr: + changed_values.add(row.attribute_value) + + return changed_values + + +def update_variant_item_codes_for_abbr_renames(item_attribute): + """Rebuild item_code/item_name of variant Items affected by a renamed Item Attribute abbreviation.""" + changed_values = get_attribute_abbr_renames(item_attribute) + if not changed_values: + return + + item_variant_table = frappe.qb.DocType("Item Variant Attribute") + variant_names = ( + frappe.qb.from_(item_variant_table) + .select(item_variant_table.parent) + .where(item_variant_table.attribute == item_attribute.name) + .where(item_variant_table.attribute_value.isin(list(changed_values))) + .distinct() + .run(pluck=True) + ) + + for variant_name in variant_names: + rename_variant_item_code(variant_name) + + +def rename_variant_item_code(variant_name): + """Recompute a variant's item_code/item_name from its template and current attribute abbreviations, + renaming the Item if it has changed.""" + variant = frappe.get_doc("Item", variant_name) + if not variant.variant_of: + return + + template = frappe.get_cached_doc("Item", variant.variant_of) + + new_code = frappe._dict({"item_code": None, "item_name": None, "attributes": variant.attributes}) + make_variant_item_code(template.item_code, template.item_name, new_code) + + if not new_code.item_code or new_code.item_code == variant.item_code: + return + + frappe.rename_doc("Item", variant.item_code, new_code.item_code) + + # Keep item_name in lockstep with item_code: both are derived from the same abbreviation, so + # item_name is always rebuilt here too, even if it had since been customized away from that pattern. + if new_code.item_name and new_code.item_name != variant.item_name: + frappe.db.set_value("Item", new_code.item_code, "item_name", new_code.item_name) + + def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True): allow_rename_attribute_value = frappe.db.get_single_value( "Item Variant Settings", "allow_rename_attribute_value" diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index 530b6574a42..2563d3b7fd7 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -6,6 +6,7 @@ import frappe from frappe import _ from frappe.utils import DateTimeLikeObject, getdate, today +import erpnext from erpnext.accounts.utils import get_fiscal_year @@ -42,6 +43,9 @@ def get_columns(filters, trans): "addl_tables": based_on_details["addl_tables"], "addl_tables_relational_cond": based_on_details.get("addl_tables_relational_cond", ""), } + conditions["company_currency"] = ( + erpnext.get_company_currency(filters.get("company")) if filters.get("company") else None + ) return conditions @@ -214,7 +218,7 @@ def get_data(filters, conditions): data.append(des) - total_row = calculate_total_row(data1, conditions["columns"]) + total_row = calculate_total_row(data1, conditions["columns"], conditions.get("company_currency")) data.append(total_row) else: data = frappe.db.sql( @@ -239,20 +243,23 @@ def get_data(filters, conditions): as_list=1, ) - total_row = calculate_total_row(data, conditions["columns"]) + total_row = calculate_total_row(data, conditions["columns"], conditions.get("company_currency")) data.append(total_row) return data -def calculate_total_row(data, columns): +def calculate_total_row(data, columns, company_currency=None): def wrap_in_quotes(label): return f"'{label}'" total_values = {} + currency_col_idx = None for i, col in enumerate(columns): if "Float" in col or "Currency/currency" in col: total_values[i] = 0 + if "Link/Currency" in col: + currency_col_idx = i for row in data: for i in total_values.keys(): @@ -262,6 +269,9 @@ def calculate_total_row(data, columns): for i in range(1, len(columns)): total_row.append(total_values.get(i, None)) + if currency_col_idx is not None: + total_row[currency_col_idx] = company_currency + return total_row @@ -371,7 +381,10 @@ def based_wise_columns_query(based_on, trans): # based_on_cols, based_on_select, based_on_group_by, addl_tables if based_on == "Item": - based_on_details["based_on_cols"] = ["Item:Link/Item:120", "Item Name:Data:120"] + based_on_details["based_on_cols"] = [ + {"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"}, + {"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"}, + ] # item_name is an editable per-line field, not functionally dependent on item_code, so it # is aggregated (one row per item_code) rather than added to GROUP BY (which would split # the row and change the MariaDB row count). See get_data's group-by query. @@ -380,7 +393,15 @@ def based_wise_columns_query(based_on, trans): based_on_details["addl_tables"] = "" elif based_on == "Item Group": - based_on_details["based_on_cols"] = ["Item Group:Link/Item Group:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Item Group"), + "fieldtype": "Link", + "options": "Item Group", + "width": 120, + "fieldname": "item_group", + } + ] based_on_details["based_on_select"] = "t2.item_group," based_on_details["based_on_group_by"] = "t2.item_group" based_on_details["addl_tables"] = "" @@ -388,18 +409,47 @@ def based_wise_columns_query(based_on, trans): elif based_on == "Customer": if trans == "Quotation": based_on_details["based_on_cols"] = [ - "Party:Link/Customer:120", - "Party Name:Data:120", - "Territory:Link/Territory:120", + { + "label": _("Party"), + "fieldtype": "Link", + "options": "Customer", + "width": 120, + "fieldname": "party", + }, + {"label": _("Party Name"), "fieldtype": "Data", "width": 120, "fieldname": "party_name"}, + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + }, ] based_on_details[ "based_on_select" ] = "t1.party_name, Max(t1.customer_name) as customer_name, Max(t1.territory) as territory," else: based_on_details["based_on_cols"] = [ - "Customer:Link/Customer:120", - "Customer Name:Data:120", - "Territory:Link/Territory:120", + { + "label": _("Customer"), + "fieldtype": "Link", + "options": "Customer", + "width": 120, + "fieldname": "customer", + }, + { + "label": _("Customer Name"), + "fieldtype": "Data", + "width": 120, + "fieldname": "customer_name", + }, + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + }, ] based_on_details[ "based_on_select" @@ -410,16 +460,35 @@ def based_wise_columns_query(based_on, trans): based_on_details["addl_tables"] = "" elif based_on == "Customer Group": - based_on_details["based_on_cols"] = ["Customer Group:Link/Customer Group"] + based_on_details["based_on_cols"] = [ + { + "label": _("Customer Group"), + "fieldtype": "Link", + "options": "Customer Group", + "fieldname": "customer_group", + } + ] based_on_details["based_on_select"] = "t1.customer_group," based_on_details["based_on_group_by"] = "t1.customer_group" based_on_details["addl_tables"] = "" elif based_on == "Supplier": based_on_details["based_on_cols"] = [ - "Supplier:Link/Supplier:120", - "Supplier Name:Data:120", - "Supplier Group:Link/Supplier Group:140", + { + "label": _("Supplier"), + "fieldtype": "Link", + "options": "Supplier", + "width": 120, + "fieldname": "supplier", + }, + {"label": _("Supplier Name"), "fieldtype": "Data", "width": 120, "fieldname": "supplier_name"}, + { + "label": _("Supplier Group"), + "fieldtype": "Link", + "options": "Supplier Group", + "width": 140, + "fieldname": "supplier_group", + }, ] # supplier_name is a stored per-transaction field (not functionally dependent on supplier), so # it is aggregated to keep one row per supplier — matching the prior MariaDB output, which grouped @@ -433,26 +502,58 @@ def based_wise_columns_query(based_on, trans): based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name" elif based_on == "Supplier Group": - based_on_details["based_on_cols"] = ["Supplier Group:Link/Supplier Group:140"] + based_on_details["based_on_cols"] = [ + { + "label": _("Supplier Group"), + "fieldtype": "Link", + "options": "Supplier Group", + "width": 140, + "fieldname": "supplier_group", + } + ] based_on_details["based_on_select"] = "t3.supplier_group," based_on_details["based_on_group_by"] = "t3.supplier_group" based_on_details["addl_tables"] = ",`tabSupplier` t3" based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name" elif based_on == "Territory": - based_on_details["based_on_cols"] = ["Territory:Link/Territory:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + } + ] based_on_details["based_on_select"] = "t1.territory," based_on_details["based_on_group_by"] = "t1.territory" based_on_details["addl_tables"] = "" elif based_on == "Project": if trans in ["Sales Invoice", "Delivery Note", "Sales Order"]: - based_on_details["based_on_cols"] = ["Project:Link/Project:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Project"), + "fieldtype": "Link", + "options": "Project", + "width": 120, + "fieldname": "project", + } + ] based_on_details["based_on_select"] = "t1.project," based_on_details["based_on_group_by"] = "t1.project" based_on_details["addl_tables"] = "" elif trans in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]: - based_on_details["based_on_cols"] = ["Project:Link/Project:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Project"), + "fieldtype": "Link", + "options": "Project", + "width": 120, + "fieldname": "project", + } + ] based_on_details["based_on_select"] = "t2.project," based_on_details["based_on_group_by"] = "t2.project" based_on_details["addl_tables"] = "" @@ -461,7 +562,15 @@ def based_wise_columns_query(based_on, trans): based_on_details["based_on_select"] += "t4.default_currency as currency," based_on_details["based_on_group_by"] += ", t4.default_currency" - based_on_details["based_on_cols"].append("Currency:Link/Currency:120") + based_on_details["based_on_cols"].append( + { + "label": _("Currency"), + "fieldtype": "Link", + "options": "Currency", + "width": 120, + "fieldname": "currency", + } + ) based_on_details["addl_tables"] += ", `tabCompany` t4" based_on_details["addl_tables_relational_cond"] = ( based_on_details.get("addl_tables_relational_cond", "") + " and t1.company = t4.name" @@ -472,6 +581,14 @@ def based_wise_columns_query(based_on, trans): def group_wise_column(group_by): if group_by: - return [group_by + ":Link/" + group_by + ":120"] + return [ + { + "label": _(group_by), + "fieldtype": "Link", + "options": group_by, + "width": 120, + "fieldname": frappe.scrub(group_by), + } + ] else: return [] diff --git a/erpnext/locale/bs.po b/erpnext/locale/bs.po index 0e53d6a1dab..781e0d00cfc 100644 --- a/erpnext/locale/bs.po +++ b/erpnext/locale/bs.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: hello@frappe.io\n" "POT-Creation-Date: 2026-07-05 10:19+0000\n" -"PO-Revision-Date: 2026-07-08 21:28\n" +"PO-Revision-Date: 2026-07-09 21:42\n" "Last-Translator: hello@frappe.io\n" "Language-Team: Bosnian\n" "MIME-Version: 1.0\n" @@ -2487,7 +2487,7 @@ msgstr "Trošak Aktivnosti postoji za {0} u odnosu na vrstu aktivnosti - {1}" #: erpnext/projects/doctype/activity_type/activity_type.js:10 msgid "Activity Cost per Employee" -msgstr "Trošak aktivnosti po personalu" +msgstr "Trošak Aktivnosti po Osoblju" #. Label of the activity_type (Link) field in DocType 'Sales Invoice Timesheet' #. Label of the activity_type (Link) field in DocType 'Activity Cost' @@ -2724,7 +2724,7 @@ msgstr "Dodaj popust" #: erpnext/public/js/event.js:40 msgid "Add Employees" -msgstr "Dodaj Personal" +msgstr "Dodaj Osoblje" #: erpnext/public/js/bom_configurator/bom_configurator.bundle.js:256 #: erpnext/selling/doctype/sales_order/sales_order.js:278 @@ -3896,7 +3896,7 @@ msgstr "Svi odjeli" #. Option for the 'Send To' (Select) field in DocType 'SMS Center' #: erpnext/selling/doctype/sms_center/sms_center.json msgid "All Employee (Active)" -msgstr "Sav Personal (Aktivni)" +msgstr "Sve Osoblje (Aktivno)" #: erpnext/setup/doctype/item_group/item_group.py:35 #: erpnext/setup/doctype/item_group/item_group.py:36 @@ -3934,7 +3934,7 @@ msgstr "Kontakt svih prodajnih partnera" #. Option for the 'Send To' (Select) field in DocType 'SMS Center' #: erpnext/selling/doctype/sms_center/sms_center.json msgid "All Sales Person" -msgstr "Sav Prodajni Personal" +msgstr "Sve Prodajno Osoblje" #. Description of a DocType #: erpnext/setup/doctype/sales_person/sales_person.json @@ -5212,7 +5212,7 @@ msgstr "Primjenjivo na (Pozicija)" #. Label of the to_emp (Link) field in DocType 'Authorization Rule' #: erpnext/setup/doctype/authorization_rule/authorization_rule.json msgid "Applicable To (Employee)" -msgstr "Primjenjivo na (Personal)" +msgstr "Primjenjivo na (Osoblje)" #. Label of the system_role (Link) field in DocType 'Authorization Rule' #: erpnext/setup/doctype/authorization_rule/authorization_rule.json @@ -6227,7 +6227,7 @@ msgstr "Imovina {assets_link} kreirana za {item_code}" #: erpnext/manufacturing/doctype/job_card/job_card.js:712 msgid "Assign Job to Employee" -msgstr "Dodijeli Posao Personalu" +msgstr "Dodijeli Posao Osoblju" #. Label of the assign_to_name (Read Only) field in DocType 'Asset Maintenance #. Task' @@ -9715,7 +9715,7 @@ msgstr "Nije moguće spojiti" #: erpnext/setup/doctype/employee/employee.py:292 msgid "Cannot Relieve Employee" -msgstr "Nije moguće razriješiti Personal" +msgstr "Nije moguće Razriješiti Osoblje" #: erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py:71 msgid "Cannot Resubmit Ledger entries for vouchers in Closed fiscal year." @@ -11917,7 +11917,7 @@ msgstr "Poduzeće imovine {0} i nabavni dokument {1} ne odgovara." #: erpnext/setup/doctype/employee/employee.py:164 msgid "Company or Personal Email is mandatory when 'Create User Automatically' is enabled" -msgstr "E-mail poduzeća ili lični e-mail je obavezan kada je omogućena opcija \"Automatski Kreiraj Korisnika\"" +msgstr "E-mail poduzeća ili lični e-mail je obavezan kada je omogućena opcija \"Automatski Izradi Osoblje\"" #. Description of the 'Registration Details' (Code) field in DocType 'Company' #: erpnext/setup/doctype/company/company.json @@ -13477,15 +13477,15 @@ msgstr "Kreiraj Dostavni Put" #: erpnext/utilities/activation.py:139 msgid "Create Employee" -msgstr "Kreiraj Personal" +msgstr "Izradi Osoblje" #: erpnext/utilities/activation.py:137 msgid "Create Employee Records" -msgstr "Kreiraj Personalni Registar" +msgstr "Izradi Registar Osoblja" #: erpnext/utilities/activation.py:138 msgid "Create Employee records." -msgstr "Kreiraj Personalni Registar" +msgstr "Izradi Registar Osoblja." #. Title of an Onboarding Step #. Label of an action in the Onboarding Step 'Create Existing Asset' @@ -13902,7 +13902,7 @@ msgstr "Kreirano {0} tablica bodova za {1} između:" #. 'Employee' #: erpnext/setup/doctype/employee/employee.json msgid "Creates a User account for this employee using the Preferred, Company, or Personal email." -msgstr "Kreira korisnički račun za personal koristeći preferiranu, poduzeća ili ličnu e-poštu." +msgstr "Izradi korisnički račun za Osoblje koristeći Preferiranu, Poduzeća ili Ličnu adresu e-pošte." #. Description of the 'Create Grouped Asset' (Check) field in DocType 'Item' #: erpnext/stock/doctype/item/item.json @@ -18895,44 +18895,44 @@ msgstr "Hitni Telefon" #: erpnext/stock/doctype/serial_no/serial_no.json #: erpnext/telephony/doctype/call_log/call_log.json msgid "Employee" -msgstr "Personal" +msgstr "Osoblje" #. Label of the employee_link (Link) field in DocType 'Supplier Scorecard #. Scoring Standing' #: erpnext/buying/doctype/supplier_scorecard_scoring_standing/supplier_scorecard_scoring_standing.json msgid "Employee " -msgstr "Personal " +msgstr "Osoblje " #. Option for the 'Reference Type' (Select) field in DocType 'Journal Entry #. Account' #: erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json msgid "Employee Advance" -msgstr "Predujam Personala" +msgstr "Predujam Osoblja" #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:26 #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:37 msgid "Employee Advances" -msgstr "Predujam Personala" +msgstr "Predujam Osoblja" #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts.py:188 #: erpnext/accounts/doctype/account/chart_of_accounts/verified/standard_chart_of_accounts_with_account_number.py:327 msgid "Employee Benefits Obligation" -msgstr "Obaveza Beneficija Personala" +msgstr "Obaveza Pogodnosti Osoblja" #. Label of the employee_detail (Section Break) field in DocType 'Timesheet' #: erpnext/projects/doctype/timesheet/timesheet.json msgid "Employee Detail" -msgstr "Detalji Personala" +msgstr "Detalji Osoblja" #. Name of a DocType #: erpnext/setup/doctype/employee_education/employee_education.json msgid "Employee Education" -msgstr "Obuka Personala" +msgstr "Obuka Osoblja" #. Name of a DocType #: erpnext/setup/doctype/employee_external_work_history/employee_external_work_history.json msgid "Employee External Work History" -msgstr "Eksterna Radna Historija Personala" +msgstr "Vanjska Radna Historija Osoblja" #. Label of the employee_group (Link) field in DocType 'Communication Medium #. Timeslot' @@ -18940,12 +18940,12 @@ msgstr "Eksterna Radna Historija Personala" #: erpnext/communication/doctype/communication_medium_timeslot/communication_medium_timeslot.json #: erpnext/setup/doctype/employee_group/employee_group.json msgid "Employee Group" -msgstr "Grupa Personala" +msgstr "Grupa Osoblja" #. Name of a DocType #: erpnext/setup/doctype/employee_group_table/employee_group_table.json msgid "Employee Group Table" -msgstr "Tabela Grupe Personala" +msgstr "Tabela Grupe Osoblja" #: erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py:33 msgid "Employee ID" @@ -18954,7 +18954,7 @@ msgstr "ID Personala" #. Name of a DocType #: erpnext/setup/doctype/employee_internal_work_history/employee_internal_work_history.json msgid "Employee Internal Work History" -msgstr "Eksterna Radna Historija Personala" +msgstr "Unutarnja Radna Historija Osoblja" #. Label of the employee_name (Data) field in DocType 'Activity Cost' #. Label of the employee_name (Data) field in DocType 'Timesheet' @@ -18965,50 +18965,50 @@ msgstr "Eksterna Radna Historija Personala" #: erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py:53 #: erpnext/setup/doctype/employee_group_table/employee_group_table.json msgid "Employee Name" -msgstr "Ime Personala" +msgstr "Ime Osoblja" #. Label of the employee_number (Data) field in DocType 'Employee' #: erpnext/setup/doctype/employee/employee.json msgid "Employee Number" -msgstr "Broj Personala" +msgstr "Broj Osoblja" #. Label of the employee_user_id (Link) field in DocType 'Call Log' #: erpnext/telephony/doctype/call_log/call_log.json msgid "Employee User Id" -msgstr "Korisnički ID Personala" +msgstr "Korisnički ID Osoblja" #: erpnext/setup/doctype/employee/employee.py:333 msgid "Employee cannot report to himself." -msgstr "Personal ne može da izvještava sam sebe." +msgstr "Osoblje ne može da izvještava samo sebe." #: erpnext/setup/doctype/employee/employee.py:583 msgid "Employee is required" -msgstr "Potreban je Personal" +msgstr "Osoblje je obavezno" #: erpnext/assets/doctype/asset_movement/asset_movement.py:109 msgid "Employee is required while issuing Asset {0}" -msgstr "Personal je obavezan prilikom izdavanja Imovine {0}" +msgstr "Osoblje je obavezno prilikom izdavanja Imovine {0}" #: erpnext/setup/doctype/employee/employee.py:440 msgid "Employee {0} already has a linked user" -msgstr "Personal {0} već ima povezanog korisnika" +msgstr "Osoblje {0} već ima povezanog korisnika" #: erpnext/assets/doctype/asset_movement/asset_movement.py:92 #: erpnext/assets/doctype/asset_movement/asset_movement.py:113 msgid "Employee {0} does not belong to the company {1}" -msgstr "Personal {0} ne pripada {1}" +msgstr "Osoblje {0} ne pripada {1}" #: erpnext/manufacturing/doctype/job_card/job_card.py:411 msgid "Employee {0} is currently working on another workstation. Please assign another employee." -msgstr "{0} trenutno radi na drugoj radnoj stanici. Dodijeli drugi personal." +msgstr "{0} trenutno radi na drugoj radnoj stanici. Dodijeli drugo osoblje." #: erpnext/setup/doctype/employee/employee.py:608 msgid "Employee {0} not found" -msgstr "Personal {0} nije pronađen" +msgstr "Osoblje {0} nije pronađeno" #: erpnext/public/js/shop_floor/shop_floor.js:684 msgid "Employees" -msgstr "Personal" +msgstr "Osoblje" #: erpnext/stock/doctype/batch/batch_list.js:16 msgid "Empty" @@ -21779,11 +21779,11 @@ msgstr "Od Datuma Dospijeća" #. Label of the from_employee (Link) field in DocType 'Asset Movement Item' #: erpnext/assets/doctype/asset_movement_item/asset_movement_item.json msgid "From Employee" -msgstr "Od Personala" +msgstr "Od Osoblja" #: erpnext/assets/doctype/asset_movement/asset_movement.py:98 msgid "From Employee is required while issuing Asset {0}" -msgstr "Personal je obavezan prilikom izdavanja Imovine {0}" +msgstr "Osoblje je obavezano prilikom izdavanja Imovine {0}" #. Label of the from_external_ecomm_platform (Check) field in DocType 'Coupon #. Code' @@ -23088,7 +23088,7 @@ msgstr "Hand" #: erpnext/accounts/report/accounts_payable/accounts_payable.js:161 msgid "Handle Employee Advances" -msgstr "Rukovanje Predujmom Personala" +msgstr "Rukovanje Predujmom Osoblja" #: erpnext/setup/setup_wizard/operations/install_fixtures.py:228 msgid "Hardware" @@ -24169,7 +24169,7 @@ msgstr "Zanemari Šablon Standard Uslova Plaćanja" #. Settings' #: erpnext/projects/doctype/projects_settings/projects_settings.json msgid "Ignore Employee Time Overlap" -msgstr "Zanemari preklapanje vremena Personala" +msgstr "Zanemari preklapanje vremena Osoblja" #: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js:145 msgid "Ignore Empty Stock" @@ -24303,7 +24303,7 @@ msgstr "Uvoz Podataka" #: erpnext/setup/doctype/employee/employee_list.js:16 msgid "Import Employees" -msgstr "Uvoz Personala" +msgstr "Uvezi Osoblje" #: erpnext/edi/doctype/code_list/code_list.js:7 #: erpnext/edi/doctype/code_list/code_list_list.js:3 @@ -32013,7 +32013,7 @@ msgstr "N/A" #. Person' #: erpnext/setup/doctype/sales_person/sales_person.json msgid "Name and Employee ID" -msgstr "Ime i Personalni ID" +msgstr "Ime i ID Osoblja" #. Label of the name_of_beneficiary (Data) field in DocType 'Bank Guarantee' #: erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json @@ -32922,7 +32922,7 @@ msgstr "Nije pronađena e-pošta za {0} {1}" #: erpnext/telephony/doctype/call_log/call_log.py:119 msgid "No employee was scheduled for call popup" -msgstr "Personal nije zakazao poziv" +msgstr "Osoblje nije zakazalo poziv" #: banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx:235 #: banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx:225 @@ -32993,7 +32993,7 @@ msgstr "Broj Dokumenata" #: erpnext/crm/doctype/lead/lead.json #: erpnext/crm/doctype/opportunity/opportunity.json msgid "No of Employees" -msgstr "Personalni Broj" +msgstr "Broj Osoblja" #: erpnext/crm/report/lead_conversion_time/lead_conversion_time.py:62 msgid "No of Interactions" @@ -33210,7 +33210,7 @@ msgstr "Nije pronađen {0} za transakcije među poduzećima." #. Label of the no_of_employees (Select) field in DocType 'Prospect' #: erpnext/crm/doctype/prospect/prospect.json msgid "No. of Employees" -msgstr "Personalni Broj" +msgstr "Broj Osoblja" #: erpnext/manufacturing/doctype/workstation/workstation.js:63 msgid "No. of parallel job cards which can be allowed on this workstation. Example: 2 would mean this workstation can process production for two Work Orders at a time." @@ -33477,7 +33477,7 @@ msgstr "Obavijesti klijente putem e-pošte" #: erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.json #: erpnext/buying/doctype/supplier_scorecard_scoring_standing/supplier_scorecard_scoring_standing.json msgid "Notify Employee" -msgstr "Obavijesti Personal" +msgstr "Obavijesti Osoblje" #. Label of the notify_employee (Check) field in DocType 'Supplier Scorecard #. Standing' @@ -37674,7 +37674,7 @@ msgstr "Lični Detalji" #. Label of the personal_email (Data) field in DocType 'Employee' #: erpnext/setup/doctype/employee/employee.json msgid "Personal Email" -msgstr "Liöna e-pošta" +msgstr "Lična adresa e-pošte" #: erpnext/setup/setup_wizard/setup_wizard.py:33 msgid "Personalizing your setup" @@ -37874,7 +37874,7 @@ msgstr "Quart Liquid (US)" #: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.js:8 msgid "Pipeline By" -msgstr "Lijevak prema" +msgstr "Proces Prema" #. Label of the place_of_issue (Data) field in DocType 'Employee' #: erpnext/setup/doctype/employee/employee.json @@ -38347,7 +38347,7 @@ msgstr "Unesi Datum Dostave" #: erpnext/setup/doctype/sales_person/sales_person_tree.js:9 msgid "Please enter Employee Id of this sales person" -msgstr "Unesi Personal Id ovog Prodavača" +msgstr "Unesi Osobni ID ovog Prodavača" #: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:1103 msgid "Please enter Expense Account" @@ -48526,7 +48526,7 @@ msgstr "Sažetak Transakcije Prodaje po Prodavaču" #: erpnext/selling/page/sales_funnel/sales_funnel.js:50 #: erpnext/workspace_sidebar/crm.json msgid "Sales Pipeline" -msgstr "Prodajni Cjevovod" +msgstr "Prodajni Proces" #. Name of a report #. Label of a Link in the CRM Workspace @@ -48534,11 +48534,11 @@ msgstr "Prodajni Cjevovod" #: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.json #: erpnext/crm/workspace/crm/crm.json erpnext/workspace_sidebar/crm.json msgid "Sales Pipeline Analytics" -msgstr "Analiza Prodaje" +msgstr "Analiza Procesa Prodaje" #: erpnext/selling/page/sales_funnel/sales_funnel.js:157 msgid "Sales Pipeline by Stage" -msgstr "Prodaja po Fazama" +msgstr "Proces Prodaje po Fazama" #: erpnext/stock/report/item_prices/item_prices.py:58 msgid "Sales Price List" @@ -49230,7 +49230,7 @@ msgstr "Odaberite Klijente po" #: erpnext/setup/doctype/employee/employee.js:244 msgid "Select Date of Birth. This will validate Employees age and prevent hiring of under-age staff." -msgstr "Navedi Datum Rođenja. Ovo će potvrditi dob personala i spriječiti zapošljavanje maloljetnih osoba." +msgstr "Navedi Datum Rođenja. Ovo će potvrditi dob Osoblja i spriječiti zapošljavanje maloljetnih osoba." #: erpnext/setup/doctype/employee/employee.js:251 msgid "Select Date of joining. It will have impact on the first salary calculation, Leave allocation on pro-rata bases." @@ -49256,7 +49256,7 @@ msgstr "Odaberi Otpremnu Adresu " #: erpnext/manufacturing/doctype/job_card/job_card.js:705 msgid "Select Employees" -msgstr "Navedi Personal" +msgstr "Odaberi Osoblje" #: erpnext/buying/doctype/purchase_order/purchase_order.js:174 #: erpnext/selling/doctype/sales_order/sales_order.js:862 @@ -49378,7 +49378,7 @@ msgstr "Odaberi Poduzeće" #: erpnext/setup/doctype/employee/employee.js:239 msgid "Select a Company this Employee belongs to." -msgstr "Navedi Poduzeće kojoj ovaj personal pripada." +msgstr "Odaberi Poduzeće kojoj ovo Osoblje pripada." #: erpnext/buying/doctype/supplier/supplier.js:221 msgid "Select a Customer" @@ -50788,7 +50788,7 @@ msgstr "Postavljanje Tipa Računa pomaže pri odabiru Računa u transakcijama." #: erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py:129 msgid "Setting Events to {0}, since the Employee attached to the below Sales Persons does not have a User ID{1}" -msgstr "Postavljanje Događaja na {0}, budući da Personal vezan za ispod navedene Prodavače nema Korisnički ID{1}" +msgstr "Postavljanje Događaja na {0}, budući da Osoblje vezano za ispod navedene Prodavače nema Korisnički ID {1}" #: erpnext/stock/doctype/pick_list/pick_list.js:98 msgid "Setting Item Locations..." @@ -55984,7 +55984,7 @@ msgstr "Sljedeći izbrisani atributi postoje u varijantama, ali ne i u šablonu. #: erpnext/setup/doctype/employee/employee.py:286 msgid "The following employees are currently still reporting to {0}:" -msgstr "Sljedeći personal još uvijek podnose izvještaj {0}:" +msgstr "Sljedeće Osoblje još uvijek podnosi izvještaj {0}:" #: erpnext/accounts/doctype/promotional_scheme/promotional_scheme.py:185 msgid "The following invalid Pricing Rules are deleted:{0}" @@ -57117,7 +57117,7 @@ msgstr "Do Datuma isteka roka" #. Label of the to_employee (Link) field in DocType 'Asset Movement Item' #: erpnext/assets/doctype/asset_movement_item/asset_movement_item.json msgid "To Employee" -msgstr "Za Personal" +msgstr "Za Osoblje" #. Label of the to_fiscal_year (Link) field in DocType 'Budget' #: erpnext/accounts/doctype/budget/budget.json @@ -60113,11 +60113,11 @@ msgstr "Korisnik {0} je onemogućen. Odaberi važećeg korisnika/blagajnika" #: erpnext/setup/doctype/employee/employee.py:365 msgid "User {0}: Removed Employee Self Service role as there is no mapped employee." -msgstr "Korisnik {0}: Uklonjena uloga samoposluživanja zaposlenika jer nema mapiranog zaposlenika." +msgstr "Korisnik {0}: Uklonjena uloga samoposluživanja Osoblja jer nema mapiranog Osoblja." #: erpnext/setup/doctype/employee/employee.py:360 msgid "User {0}: Removed Employee role as there is no mapped employee." -msgstr "Korisnik {0}: Uklonjena uloga personala jer nema mapiranog personala." +msgstr "Korisnik {0}: Uklonjena uloga Osoblja jer nema mapiranog Osoblja." #. Description of the 'Set Landed Cost Based on Purchase Invoice Rate' (Check) #. field in DocType 'Buying Settings' diff --git a/erpnext/locale/sv.po b/erpnext/locale/sv.po index a1ee00d2258..df31715d13a 100644 --- a/erpnext/locale/sv.po +++ b/erpnext/locale/sv.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: hello@frappe.io\n" "POT-Creation-Date: 2026-07-05 10:19+0000\n" -"PO-Revision-Date: 2026-07-06 21:26\n" +"PO-Revision-Date: 2026-07-09 21:42\n" "Last-Translator: hello@frappe.io\n" "Language-Team: Swedish\n" "MIME-Version: 1.0\n" @@ -34582,12 +34582,12 @@ msgstr "Möjlighet Källa" #. Label of a Workspace Sidebar Item #: erpnext/crm/workspace/crm/crm.json erpnext/workspace_sidebar/crm.json msgid "Opportunity Summary by Sales Stage" -msgstr "Möjlighet Översikt efter Försäljning Fas" +msgstr "Möjlighet Översikt efter Försäljning Steg" #. Name of a report #: erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.json msgid "Opportunity Summary by Sales Stage " -msgstr "Möjlighet Översikt efter Försäljning Fas " +msgstr "Möjlighet Översikt efter Försäljning Steg " #. Label of the opportunity_type (Link) field in DocType 'Opportunity' #. Name of a DocType @@ -37880,7 +37880,7 @@ msgstr "Pint, Liquid (US)" #: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.js:8 msgid "Pipeline By" -msgstr "Tratt Efter" +msgstr "Process Efter" #. Label of the place_of_issue (Data) field in DocType 'Employee' #: erpnext/setup/doctype/employee/employee.json @@ -48541,11 +48541,11 @@ msgstr "Försäljning" #: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.json #: erpnext/crm/workspace/crm/crm.json erpnext/workspace_sidebar/crm.json msgid "Sales Pipeline Analytics" -msgstr "Försäljning Statistik" +msgstr "Försäljning Process Statistik" #: erpnext/selling/page/sales_funnel/sales_funnel.js:157 msgid "Sales Pipeline by Stage" -msgstr "Försäljning efter Fas" +msgstr "Försäljning Process efter Steg" #: erpnext/stock/report/item_prices/item_prices.py:58 msgid "Sales Price List" @@ -48578,7 +48578,7 @@ msgstr "Försäljning Retur" #: erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py:69 #: erpnext/crm/workspace/crm/crm.json erpnext/workspace_sidebar/crm.json msgid "Sales Stage" -msgstr "Försäljning Fas" +msgstr "Försäljning Steg" #: erpnext/accounts/doctype/pos_closing_entry/closing_voucher_details.html:8 msgid "Sales Summary" @@ -52005,7 +52005,7 @@ msgstr "Kvadratyard" #. Label of the stage_name (Data) field in DocType 'Sales Stage' #: erpnext/crm/doctype/sales_stage/sales_stage.json msgid "Stage Name" -msgstr "Fas Namn" +msgstr "Försäljning Steg Namn" #. Label of the stale_days (Int) field in DocType 'Accounts Settings' #: erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -56228,11 +56228,11 @@ msgstr "System kommer att skapa Försäljning Faktura eller Kassa Faktura från #: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:1239 msgid "The task has been enqueued as a background job. In case there is any issue on processing in background, the system will add a comment about the error on this Stock Reconciliation and revert to the Draft stage" -msgstr "Uppgift är i kö som bakgrund jobb. Om det finns problem med behandling i bakgrund kommer system att lägga till kommentar om fel i denna Lager Inventering och återgå till Utkast status." +msgstr "Uppgift är i kö som bakgrund jobb. Om det finns problem med behandling i bakgrund kommer system att lägga till kommentar om fel i denna Lager Inventering och återgå till Utkast steg" #: erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py:1250 msgid "The task has been enqueued as a background job. In case there is any issue on processing in background, the system will add a comment about the error on this Stock Reconciliation and revert to the Submitted stage" -msgstr "Uppgift är i kö som ett bakgrund jobb. Om det finns några problem med bearbetning i bakgrund kommer system att lägga till kommentar om fel på denna Lager Inventering och återgå till Godkänd status" +msgstr "Uppgift är i kö som ett bakgrund jobb. Om det finns några problem med bearbetning i bakgrund kommer system att lägga till kommentar om fel på denna Lager Inventering och återgå till Godkänd steg" #: erpnext/stock/doctype/material_request/material_request.py:352 msgid "The total Issue / Transfer quantity {0} in Material Request {1} cannot be greater than allowed requested quantity {2} for Item {3}" diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 9fbe4f1174c..7a002da2fac 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -586,7 +586,11 @@ frappe.ui.form.on("BOM", { }, routing(frm) { - if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) { + // Refetch operations whenever the routing is (re)selected, so that + // changing the routing - e.g. on a new BOM version copied from another + // BOM - replaces the operations with those of the newly selected routing + // instead of keeping the old ones. + if (frm.doc.routing && frm.doc.with_operations) { frappe.call({ doc: frm.doc, method: "get_routing", diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 263f0e0b09b..6583e28889a 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1387,13 +1387,29 @@ def _merge_phantom_bom_items(item_dict, item, company, opts): def _set_default_accounts_for_items(item_dict, company): + fields = [ + ["Account", "expense_account", "stock_adjustment_account"], + ["Cost Center", "cost_center", "cost_center"], + ["Warehouse", "default_warehouse", ""], + ] + + company_of = {} + for d in fields: + names = {item_details.get(d[1]) for item_details in item_dict.values() if item_details.get(d[1])} + company_of[d[0]] = ( + { + r.name: r.company + for r in frappe.get_all( + d[0], filters={"name": ("in", list(names))}, fields=["name", "company"] + ) + } + if names + else {} + ) + for item, item_details in item_dict.items(): - for d in [ - ["Account", "expense_account", "stock_adjustment_account"], - ["Cost Center", "cost_center", "cost_center"], - ["Warehouse", "default_warehouse", ""], - ]: - company_in_record = frappe.db.get_value(d[0], item_details.get(d[1]), "company") + for d in fields: + company_in_record = company_of[d[0]].get(item_details.get(d[1])) if not item_details.get(d[1]) or (company_in_record and company != company_in_record): item_dict[item][d[1]] = frappe.get_cached_value("Company", company, d[2]) if d[2] else None diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index efb1636e7c1..4d8998f64db 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -665,6 +665,48 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(ste.from_bom, 1.0) self.assertEqual(ste.bom_no, work_order.bom_no) + def test_job_card_material_transfer_via_pick_list(self): + from erpnext.stock.doctype.material_request.mapper import create_pick_list + from erpnext.stock.doctype.pick_list.mapper import ( + create_stock_entry as create_stock_entry_from_pick_list, + ) + + create_bom_with_multiple_operations() + work_order = make_wo_with_transfer_against_jc() + + for item in work_order.required_items: + make_stock_entry( + item_code=item.item_code, + target=item.source_warehouse, + qty=item.required_qty * 2, + basic_rate=100, + ) + + job_card_name = frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name") + job_card = frappe.get_doc("Job Card", job_card_name) + + mr = make_material_request(job_card_name) + mr.schedule_date = today() + mr.submit() + + pick_list = create_pick_list(mr.name) + pick_list.submit() + + ste = frappe.get_doc(create_stock_entry_from_pick_list(pick_list.as_dict())) + self.assertEqual(ste.purpose, "Material Transfer for Manufacture") + self.assertEqual(ste.job_card, job_card_name) + self.assertEqual(ste.work_order, work_order.name) + self.assertEqual(ste.fg_completed_qty, job_card.for_quantity) + for row in ste.items: + self.assertEqual(row.t_warehouse, job_card.wip_warehouse) + self.assertTrue(row.job_card_item) + + ste.insert() + ste.submit() + + job_card.reload() + self.assertEqual(job_card.transferred_qty, job_card.for_quantity) + def test_job_card_proccess_qty_and_completed_qty(self): from erpnext.manufacturing.doctype.routing.test_routing import ( create_routing, diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index 2337b8d0246..3bef5d30712 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -40,10 +40,29 @@ frappe.ui.form.on("Production Plan", { }); frm.set_query("for_warehouse", function (doc) { + // when a group is chosen, For Warehouse must be one of its child warehouses + if (doc.raw_material_group_warehouse) { + return { + query: "erpnext.manufacturing.doctype.production_plan.production_plan.get_child_warehouses", + filters: { + group_warehouse: doc.raw_material_group_warehouse, + company: doc.company, + }, + }; + } + return { + filters: [ + ["Warehouse", "company", "=", doc.company], + ["Warehouse", "is_group", "=", 0], + ], + }; + }); + + frm.set_query("raw_material_group_warehouse", function (doc) { return { filters: { company: doc.company, - is_group: 0, + is_group: 1, }, }; }); @@ -102,6 +121,13 @@ frappe.ui.form.on("Production Plan", { }); }, + raw_material_group_warehouse(frm) { + // For Warehouse must sit inside the chosen group, so drop a stale selection + if (frm.doc.for_warehouse) { + frm.set_value("for_warehouse", null); + } + }, + refresh(frm) { if (frm.doc.docstatus === 1) { frm.trigger("show_progress"); @@ -451,6 +477,7 @@ frappe.ui.form.on("Production Plan", { frm.events.get_items_for_material_requests(frm); } else { const title = __("Transfer Materials For Warehouse {0}", [frm.doc.for_warehouse]); + const source_warehouse = frm.doc.raw_material_group_warehouse; var dialog = new frappe.ui.Dialog({ title: title, fields: [ @@ -459,6 +486,7 @@ frappe.ui.form.on("Production Plan", { fieldtype: "Table MultiSelect", fieldname: "warehouses", options: "Production Plan Material Request Warehouse", + default: source_warehouse ? [{ warehouse: source_warehouse }] : [], get_query: function () { return { filters: { @@ -515,8 +543,9 @@ frappe.ui.form.on("Production Plan", { download_materials_required(frm) { const warehouses_data = []; - if (frm.doc.for_warehouse) { - warehouses_data.push({ warehouse: frm.doc.for_warehouse }); + const availability_warehouse = frm.doc.raw_material_group_warehouse || frm.doc.for_warehouse; + if (availability_warehouse) { + warehouses_data.push({ warehouse: availability_warehouse }); } const fields = [ diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 32a67eae228..d7e5c48de1c 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -52,6 +52,7 @@ "include_safety_stock", "ignore_existing_ordered_qty", "column_break_25", + "raw_material_group_warehouse", "for_warehouse", "get_items_for_mr", "transfer_materials", @@ -318,6 +319,13 @@ "label": "For Warehouse", "options": "Warehouse" }, + { + "description": "Optional group warehouse. Raw material availability is checked across its child warehouses; material is still received into For Warehouse.", + "fieldname": "raw_material_group_warehouse", + "fieldtype": "Link", + "label": "Raw Material Group Warehouse", + "options": "Warehouse" + }, { "fieldname": "warehouses", "fieldtype": "Table MultiSelect", @@ -445,7 +453,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-08-12 19:48:09.302503", + "modified": "2026-07-07 00:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index be57e1108b8..64c1f81447b 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -102,6 +102,7 @@ class ProductionPlan(Document): posting_date: DF.Date prod_plan_references: DF.Table[ProductionPlanItemReference] project: DF.Link | None + raw_material_group_warehouse: DF.Link | None reserve_stock: DF.Check sales_order_status: DF.Literal["", "To Deliver and Bill", "To Bill", "To Deliver"] sales_orders: DF.Table[ProductionPlanSalesOrder] @@ -144,8 +145,30 @@ class ProductionPlan(Document): validate_uom_is_integer(self, "stock_uom", "planned_qty") self.validate_sales_orders() self.validate_material_request_type() + self.validate_raw_material_group_warehouse() self.enable_auto_reserve_stock() + def validate_raw_material_group_warehouse(self): + if not self.raw_material_group_warehouse: + return + + group = frappe.db.get_value( + "Warehouse", self.raw_material_group_warehouse, ["lft", "rgt", "is_group"], as_dict=True + ) + if not group.is_group: + frappe.throw( + _("{0} must be a group warehouse.").format(frappe.bold(_("Raw Material Group Warehouse"))) + ) + + if self.for_warehouse: + child = frappe.db.get_value("Warehouse", self.for_warehouse, ["lft", "rgt"], as_dict=True) + if not (group.lft <= child.lft and child.rgt <= group.rgt): + frappe.throw( + _("For Warehouse {0} must be a child of the group warehouse {1}.").format( + frappe.bold(self.for_warehouse), frappe.bold(self.raw_material_group_warehouse) + ) + ) + def enable_auto_reserve_stock(self): if self.is_new() and frappe.db.get_single_value("Stock Settings", "auto_reserve_stock"): self.reserve_stock = 1 @@ -466,3 +489,26 @@ class ProductionPlan(Document): def all_items_completed(self): return SubAssemblyService(self).all_items_completed() + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_child_warehouses( + doctype: str | None, txt: str, searchfield: str | None, start: int, page_len: int, filters: dict +): + "Leaf warehouses under the given group warehouse, for the For Warehouse link query." + bounds = frappe.db.get_value("Warehouse", filters.get("group_warehouse"), ["lft", "rgt"], as_dict=True) + if not bounds: + return [] + + wh = frappe.qb.DocType("Warehouse") + query = ( + frappe.qb.from_(wh) + .select(wh.name) + .where((wh.is_group == 0) & (wh.lft >= bounds.lft) & (wh.rgt <= bounds.rgt)) + ) + if filters.get("company"): + query = query.where(wh.company == filters.get("company")) + if txt: + query = query.where(wh[searchfield].like(f"%{txt}%")) + return query.limit(page_len).offset(start).run() diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py index c4f12d8f1b7..fc2ad13df73 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/material_request.py +++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py @@ -97,6 +97,13 @@ class MaterialRequestService: def _material_request_item(self, item, material_request_type, schedule_date): from_warehouse = item.from_warehouse if material_request_type == "Material Transfer" else None + # a group warehouse cannot receive stock; it must never reach a Material Request line + if item.warehouse and frappe.get_cached_value("Warehouse", item.warehouse, "is_group"): + frappe.throw( + _("Cannot create Material Request for item {0} in group warehouse {1}.").format( + frappe.bold(item.item_code), frappe.bold(item.warehouse) + ) + ) project = ( frappe.db.get_value("Sales Order", item.sales_order, "project") if item.sales_order else None ) @@ -132,13 +139,14 @@ class MaterialRequestService: @frappe.whitelist() def get_items_for_material_requests( - doc: str | frappe._dict | Document, + doc: str | dict | Document, warehouses: str | list | None = None, get_parent_warehouse_data: bool | int | None = None, ): frappe.has_permission("Production Plan", "read", throw=True) doc = _normalize_mr_doc(doc) + _validate_group_warehouse_target(doc) warehouses = _filter_warehouses(doc, warehouses, get_parent_warehouse_data) doc["mr_items"] = [] @@ -163,6 +171,17 @@ def _normalize_mr_doc(doc): return doc +def _validate_group_warehouse_target(doc): + # the group only scopes availability; raw materials still need a concrete + # receiving warehouse, so for_warehouse is required once we generate items. + if doc.get("raw_material_group_warehouse") and not doc.get("for_warehouse"): + frappe.throw( + _("{0} is required to get raw materials when {1} is set.").format( + frappe.bold(_("For Warehouse")), frappe.bold(_("Raw Material Group Warehouse")) + ) + ) + + def _filter_warehouses(doc, warehouses, get_parent_warehouse_data): if not warehouses: return warehouses @@ -355,13 +374,18 @@ def _accumulate_so_items(so_item_details, sales_order, item_details, qty_precisi def _build_mr_items(doc, so_item_details, ignore_ordered_qty): mr_items = [] consumed_qty = defaultdict(float) - warehouse = doc.get("for_warehouse") + # raw_material_group_warehouse (optional, group) only widens the availability + # scope to its child warehouses; material is still received into for_warehouse. + target_warehouse = doc.get("for_warehouse") + scope_warehouse = doc.get("raw_material_group_warehouse") or target_warehouse company = doc.get("company") include_safety_stock = doc.get("include_safety_stock") for sales_order, item_dict in so_item_details.items(): for details in item_dict.values(): - warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse") + fallback = details.get("source_warehouse") or details.get("default_warehouse") + scope_warehouse = scope_warehouse or fallback + target_warehouse = target_warehouse or fallback row = _mr_item_for_details( doc, details, @@ -369,7 +393,8 @@ def _build_mr_items(doc, so_item_details, ignore_ordered_qty): company, ignore_ordered_qty, include_safety_stock, - warehouse, + scope_warehouse, + target_warehouse, consumed_qty, ) if row: @@ -378,10 +403,19 @@ def _build_mr_items(doc, so_item_details, ignore_ordered_qty): def _mr_item_for_details( - doc, details, sales_order, company, ignore_ordered_qty, include_safety_stock, warehouse, consumed_qty + doc, + details, + sales_order, + company, + ignore_ordered_qty, + include_safety_stock, + warehouse, + target_warehouse, + consumed_qty, ): - bin_dict = get_bin_details(details, doc.company, warehouse) - bin_dict = bin_dict[0] if bin_dict else {} + # get_bin_details scopes to the warehouse's descendants, returning one row per + # child warehouse; sum them so a group warehouse reflects combined child stock. + bin_dict = _aggregate_bin_details(get_bin_details(details, doc.company, warehouse)) if details.qty <= 0: return None return get_material_request_items( @@ -392,11 +426,27 @@ def _mr_item_for_details( ignore_ordered_qty, include_safety_stock, warehouse, + target_warehouse, bin_dict, consumed_qty, ) +def _aggregate_bin_details(bin_list): + qty_fields = ( + "projected_qty", + "actual_qty", + "ordered_qty", + "reserved_qty_for_production", + "planned_qty", + ) + aggregated = {field: 0 for field in qty_fields} + for row in bin_list or []: + for field in qty_fields: + aggregated[field] += flt(row.get(field)) + return aggregated + + def _apply_other_locations(doc, mr_items, warehouses, ignore_ordered_qty, get_parent_warehouse_data): if not ((ignore_ordered_qty or get_parent_warehouse_data) and warehouses): return mr_items @@ -428,6 +478,7 @@ def get_material_request_items( ignore_existing_ordered_qty, include_safety_stock, warehouse, + target_warehouse, bin_dict, consumed_qty, ): @@ -438,7 +489,7 @@ def get_material_request_items( item_group_defaults = get_item_group_defaults(row.item_code, company) conversion_factor = _mr_purchase_conversion_factor(row) return _material_request_item_row( - row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults + row, sales_order, target_warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults ) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index ac2b38ea216..e63bc8a2b09 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1592,6 +1592,104 @@ class TestProductionPlan(ERPNextTestSuite): for row in plan.mr_items: self.assertFalse(row.from_warehouse) + def _setup_group_rm_warehouse(self): + """FG + RM with a group raw-material warehouse (C1, C2) partially stocked (3 + 4).""" + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + + group_warehouse = "_Test Warehouse Group - _TC" + child_1 = "_Test Warehouse Group-C1 - _TC" + child_2 = "_Test Warehouse Group-C2 - _TC" + + fg_item = "Test PP Group FG" + rm_item = "Test PP Group RM" + create_item(rm_item, valuation_rate=100) + create_item(fg_item, valuation_rate=100) + if not frappe.db.get_value("BOM", {"item": fg_item, "is_active": 1}): + create_nested_bom({fg_item: {rm_item: {}}}, prefix="") + + make_stock_entry(item_code=rm_item, qty=3, rate=100, target=child_1) + make_stock_entry(item_code=rm_item, qty=4, rate=100, target=child_2) + + return frappe._dict( + group_warehouse=group_warehouse, + children={child_1, child_2}, + for_wh=child_1, # a leaf inside the group, used as For Warehouse + fg_item=fg_item, + rm_item=rm_item, + ) + + def test_group_raw_material_warehouse_aggregates_child_stock(self): + "Combined child stock (3 + 4) is used as projected qty; material targets For Warehouse." + data = self._setup_group_rm_warehouse() + + plan = create_production_plan( + item_code=data.fg_item, + planned_qty=10, + for_warehouse=data.for_wh, + raw_material_group_warehouse=data.group_warehouse, + do_not_save=1, + skip_getting_mr_items=1, + ) + mr_items = get_items_for_material_requests(plan.as_dict()) + + rm_rows = [d for d in mr_items if d.get("item_code") == data.rm_item] + self.assertEqual(len(rm_rows), 1) + # projected qty reflects the sum across both child warehouses, not a single child + self.assertEqual(flt(rm_rows[0].get("projected_qty")), 7.0) + # the group is only an availability scope; the row targets For Warehouse + self.assertEqual(rm_rows[0].get("warehouse"), data.for_wh) + + def test_group_raw_material_warehouse_transfers_from_child_warehouses(self): + "Material is transferred only from actual child warehouses, never the group node." + data = self._setup_group_rm_warehouse() + + plan = create_production_plan( + item_code=data.fg_item, + planned_qty=10, + ignore_existing_ordered_qty=1, + for_warehouse=data.for_wh, + raw_material_group_warehouse=data.group_warehouse, + do_not_save=1, + skip_getting_mr_items=1, + ) + mr_items = get_items_for_material_requests( + plan.as_dict(), warehouses=[{"warehouse": data.group_warehouse}] + ) + + transfer_rows = [d for d in mr_items if d.get("material_request_type") == "Material Transfer"] + self.assertTrue(transfer_rows) + for row in transfer_rows: + self.assertIn(row.get("from_warehouse"), data.children) + for row in mr_items: + # a group warehouse must never be a Material Request target + self.assertNotEqual(row.get("warehouse"), data.group_warehouse) + + def test_for_warehouse_must_be_child_of_group(self): + "A For Warehouse outside the chosen group warehouse is rejected on save." + data = self._setup_group_rm_warehouse() + + plan = create_production_plan( + item_code=data.fg_item, + planned_qty=10, + for_warehouse="_Test Warehouse - _TC", # outside the group + raw_material_group_warehouse=data.group_warehouse, + do_not_save=1, + skip_getting_mr_items=1, + ) + self.assertRaises(frappe.ValidationError, plan.save) + + def test_for_warehouse_required_with_group_when_getting_raw_materials(self): + "A group warehouse without a For Warehouse is rejected when raw materials are fetched." + data = self._setup_group_rm_warehouse() + + plan = create_production_plan( + item_code=data.fg_item, + planned_qty=10, + raw_material_group_warehouse=data.group_warehouse, + skip_getting_mr_items=1, + ) + self.assertRaises(frappe.ValidationError, get_items_for_material_requests, plan.as_dict()) + def test_skip_available_qty_for_sub_assembly_items(self): from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom @@ -3122,6 +3220,7 @@ def create_production_plan(**args): "sub_assembly_warehouse": args.sub_assembly_warehouse, "reserve_stock": args.reserve_stock or 0, "for_warehouse": args.for_warehouse or None, + "raw_material_group_warehouse": args.raw_material_group_warehouse or None, } ) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 5a6e6dbf55f..8846aae59d0 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -11,6 +11,7 @@ from frappe.query_builder.functions import Coalesce, IfNull, Sum from frappe.utils import ( cint, flt, + get_datetime, get_link_to_form, now, nowdate, @@ -318,6 +319,10 @@ class WorkOrder(Document): self.validate_subcontracting_inward_order() def validate_dates(self): + if self.planned_start_date and self.planned_end_date: + if get_datetime(self.planned_end_date) < get_datetime(self.planned_start_date): + frappe.throw(_("Planned End Date cannot be before Planned Start Date")) + if self.actual_start_date and self.actual_end_date: if self.actual_end_date < self.actual_start_date: frappe.throw(_("Actual End Date cannot be before Actual Start Date")) diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 51637316446..acaf7fb056e 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -562,6 +562,18 @@ $.extend(erpnext.utils, { }, }); +erpnext.utils.confirm_negative_stock = function (frm) { + if (!frm.doc.allow_negative_stock) return; + + frappe.confirm( + __( + "Using negative stock disables FIFO/Moving average valuation when inventory is negative. This is considered dangerous from accounting point of view.
Do you still want to enable negative inventory?" + ), + () => {}, + () => frm.set_value("allow_negative_stock", 0) + ); +}; + erpnext.utils.select_alternate_items = function (opts) { const frm = opts.frm; const warehouse_field = opts.warehouse_field || "warehouse"; diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 6a27febe21a..61a79027a33 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -1368,7 +1368,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex frappe.throw(__("Please select at least one item to continue")); } me.frm.call({ - method: "make_work_orders", + method: "erpnext.selling.doctype.sales_order.mapper.make_work_orders", args: { items: data, company: me.frm.doc.company, diff --git a/erpnext/selling/report/quotation_trends/quotation_trends.py b/erpnext/selling/report/quotation_trends/quotation_trends.py index 92f9d17a9c7..e5b62569394 100644 --- a/erpnext/selling/report/quotation_trends/quotation_trends.py +++ b/erpnext/selling/report/quotation_trends/quotation_trends.py @@ -1,7 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - from frappe import _ from erpnext.controllers.trends import get_columns, get_data @@ -40,9 +39,15 @@ def get_chart_data(data, conditions, filters): labels = [column.split(":")[0] for column in columns] datapoints = [0] * len(labels) + group_by_col_idx = None + if filters.get("group_by"): + group_by_col_idx = conditions["columns"].index(conditions["grbc"][0]) + for row in data: - # If group by filter, don't add first row of group (it's already summed) - if not row[start]: + # Skip the final grand-total row + if row[0] == f"'{_('Total')}'": + continue + if group_by_col_idx is not None and row[group_by_col_idx] == "": continue # Remove None values and compute only periodic data row = [x if x else 0 for x in row[start:-2]] @@ -59,4 +64,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } diff --git a/erpnext/selling/report/quotation_trends/test_quotation_trends.py b/erpnext/selling/report/quotation_trends/test_quotation_trends.py index 4ff03a5b53c..95ba6dd50bc 100644 --- a/erpnext/selling/report/quotation_trends/test_quotation_trends.py +++ b/erpnext/selling/report/quotation_trends/test_quotation_trends.py @@ -2,6 +2,7 @@ # See license.txt import frappe +from frappe import _ from erpnext.selling.doctype.quotation.test_quotation import make_quotation from erpnext.selling.report.quotation_trends.quotation_trends import execute @@ -86,3 +87,94 @@ class TestQuotationTrends(ERPNextTestSuite): labels, after = self.run_report(based_on="Customer") self.assertEqual(self._cell(after, "Party", "_Test Customer", amt_col, labels) - before_amt, 300) + + def test_group_by_chart_matches_table_total_with_mixed_group_sizes(self): + # _Test Item is quoted to two customers -> two detail rows under one header row. + # _Test Item 2 is quoted to only one customer -> exactly one detail row under its + # header row. A regression that double-counts header rows would inflate the chart + # above 800; a regression that zeroes single-group rows would report less than 800. + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": FISCAL_YEAR, + "period": "Yearly", + "based_on": "Item", + "group_by": "Customer", + } + ) + + make_quotation( + item="_Test Item", party_name="_Test Customer", qty=4, rate=100, transaction_date=TXN_DATE + ) + make_quotation( + item="_Test Item", party_name="_Test Customer 1", qty=1, rate=100, transaction_date=TXN_DATE + ) + make_quotation( + item="_Test Item 2", party_name="_Test Customer", qty=3, rate=100, transaction_date=TXN_DATE + ) + + columns, data, _message, chart = execute(filters) + self.assertTrue(columns) + self.assertTrue(data) + + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 400 (item/customer) + 100 (item/customer1) + 300 (item2/customer) = 800 + self.assertEqual(expected_total, 800) + self.assertEqual(chart_total, expected_total) + + def test_group_by_swapped_roles_based_on_customer_group_by_item(self): + # Same regression, opposite role assignment: based_on="Customer" with group_by="Item". + # Customer's based_on_cols for Quotation (Party, Party Name, Territory, Currency) put + # the group_by placeholder at a different column index than the Item-based_on case + # above, exercising the alternate `inc`/`ind` arithmetic. + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": FISCAL_YEAR, + "period": "Yearly", + "based_on": "Customer", + "group_by": "Item", + } + ) + + make_quotation( + party_name="_Test Customer", item="_Test Item", qty=3, rate=100, transaction_date=TXN_DATE + ) + make_quotation( + party_name="_Test Customer", item="_Test Item 2", qty=1, rate=100, transaction_date=TXN_DATE + ) + + columns, data, _message, chart = execute(filters) + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 300 + 100 = 400 + self.assertEqual(expected_total, 400) + self.assertEqual(chart_total, expected_total) + + def test_group_by_single_group_value_not_zeroed(self): + # Isolates the specific failure mode flagged in review: a based_on value with exactly + # one associated group value must still contribute its real amount to the chart, not 0. + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": FISCAL_YEAR, + "period": "Yearly", + "based_on": "Item", + "group_by": "Customer", + } + ) + + make_quotation( + item="_Test Item", party_name="_Test Customer", qty=2, rate=150, transaction_date=TXN_DATE + ) + + columns, data, _message, chart = execute(filters) + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + self.assertGreater(chart_total, 0) + self.assertEqual(chart_total, 300) diff --git a/erpnext/selling/report/sales_order_trends/sales_order_trends.py b/erpnext/selling/report/sales_order_trends/sales_order_trends.py index ca11b8302de..e0de678f22d 100644 --- a/erpnext/selling/report/sales_order_trends/sales_order_trends.py +++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.py @@ -39,9 +39,15 @@ def get_chart_data(data, conditions, filters): labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns] datapoints = [0] * len(labels) + group_by_col_idx = None + if filters.get("group_by"): + group_by_col_idx = conditions["columns"].index(conditions["grbc"][0]) + for row in data: - # If group by filter, don't add first row of group (it's already summed) - if not row[start]: + # Skip the final grand-total row + if row[0] == f"'{_('Total')}'": + continue + if group_by_col_idx is not None and row[group_by_col_idx] == "": continue # Remove None values and compute only periodic data row = [x if x else 0 for x in row[start:-2]] @@ -58,4 +64,6 @@ def get_chart_data(data, conditions, filters): "type": "line", "lineOptions": {"regionFill": 1}, "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } diff --git a/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py b/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py index 46f856a6f03..47a1c9679f8 100644 --- a/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py +++ b/erpnext/selling/report/sales_order_trends/test_sales_order_trends.py @@ -2,7 +2,10 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe import _ +from frappe.utils import today +from erpnext.accounts.utils import get_fiscal_year from erpnext.tests.utils import ERPNextTestSuite @@ -51,3 +54,160 @@ class TestSalesOrderTrends(ERPNextTestSuite): self.assertTrue(columns) customer_rows = [row for row in data if row[0] == "_Test Customer"] self.assertEqual(len(customer_rows), 1) + + def test_total_row_not_double_counted_in_chart(self): + # Regression test for the fix in trends.calculate_total_row that populates the + # Total row's Currency column. Before the fix in get_chart_data (skipping the + # Total row by label instead of `if not row[start]`), that populated Currency + # cell made the Total-row-skip guard falsy, so the already-summed Total row got + # added into the chart a second time (an SO of qty=3, rate=100 -> 300 read as 600). + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.selling.report.sales_order_trends.sales_order_trends import execute + + make_sales_order(item_code="_Test Item", qty=3, rate=100, transaction_date=today()) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + } + ) + + columns, data, _message, chart = execute(filters) + self.assertTrue(columns) + self.assertTrue(data) + + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] # Total(Amt) is the last column + + chart_total = sum(chart["data"]["datasets"][0]["values"]) + self.assertEqual(chart_total, expected_total) + self.assertEqual(chart_total, 300) + + def test_chart_currency_matches_company_currency(self): + # Regression test: the chart's "currency" key should reflect the transacting + # company's currency (conditions["company_currency"]), not a stale global default. + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.selling.report.sales_order_trends.sales_order_trends import execute + + make_sales_order(item_code="_Test Item", qty=1, rate=100, transaction_date=today()) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + } + ) + + _columns, _data, _message, chart = execute(filters) + expected_currency = frappe.get_cached_value("Company", "_Test Company", "default_currency") + self.assertEqual(chart["currency"], expected_currency) + + def test_group_by_chart_matches_table_total_with_mixed_group_sizes(self): + # _Test Item is split across two customers -> two detail rows under one header row. + # _Test Item 2 has only one customer -> exactly one detail row under its header row. + # A regression that double-counts header rows would inflate the chart above 600; + # a regression that zeroes single-group rows would report less than 600. + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.selling.report.sales_order_trends.sales_order_trends import execute + + make_sales_order( + item_code="_Test Item", customer="_Test Customer", qty=3, rate=100, transaction_date=today() + ) + make_sales_order( + item_code="_Test Item", customer="_Test Customer 1", qty=2, rate=100, transaction_date=today() + ) + make_sales_order( + item_code="_Test Item 2", customer="_Test Customer", qty=1, rate=100, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + "group_by": "Customer", + } + ) + + columns, data, _message, chart = execute(filters) + self.assertTrue(columns) + self.assertTrue(data) + + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 300 (item/customer) + 200 (item/customer1) + 100 (item2/customer) = 600 + self.assertEqual(expected_total, 600) + self.assertEqual(chart_total, expected_total) + + def test_group_by_swapped_roles_based_on_customer_group_by_item(self): + # Same regression, opposite role assignment: based_on="Customer" with group_by="Item". + # Customer's based_on_cols (Customer, Customer Name, Territory, Currency) put the + # group_by placeholder at a different column index than the Item-based_on case above, + # exercising the alternate `inc`/`ind` arithmetic. + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.selling.report.sales_order_trends.sales_order_trends import execute + + make_sales_order( + item_code="_Test Item", customer="_Test Customer", qty=3, rate=100, transaction_date=today() + ) + make_sales_order( + item_code="_Test Item 2", customer="_Test Customer", qty=1, rate=100, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Customer", + "group_by": "Item", + } + ) + + columns, data, _message, chart = execute(filters) + total_row = next(row for row in data if row[0] == f"'{_('Total')}'") + expected_total = total_row[-1] + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + # 300 + 100 = 400 + self.assertEqual(expected_total, 400) + self.assertEqual(chart_total, expected_total) + + def test_group_by_single_group_value_not_zeroed(self): + # Isolates the specific failure mode flagged in review: a based_on value with exactly + # one associated group value must still contribute its real amount to the chart, not 0. + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.selling.report.sales_order_trends.sales_order_trends import execute + + make_sales_order( + item_code="_Test Item", customer="_Test Customer", qty=2, rate=150, transaction_date=today() + ) + + fiscal_year = get_fiscal_year(today())[0] + filters = frappe._dict( + { + "company": "_Test Company", + "fiscal_year": fiscal_year, + "period": "Monthly", + "based_on": "Item", + "group_by": "Customer", + } + ) + + columns, data, _message, chart = execute(filters) + chart_total = sum(chart["data"]["datasets"][0]["values"]) + + self.assertGreater(chart_total, 0) + self.assertEqual(chart_total, 300) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index a3a1884cae2..e100da3c4a4 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -401,22 +401,34 @@ class DeliveryNote(SellingController): frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"])) def update_current_stock(self): - if self.get("_action") and self._action != "update_after_submit": - for d in self.get("items"): - d.actual_qty = frappe.db.get_value( - "Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty" - ) + if not (self.get("_action") and self._action != "update_after_submit"): + return - for d in self.get("packed_items"): - bin_qty = frappe.db.get_value( - "Bin", - {"item_code": d.item_code, "warehouse": d.warehouse}, - ["actual_qty", "projected_qty"], - as_dict=True, - ) - if bin_qty: - d.actual_qty = flt(bin_qty.actual_qty) - d.projected_qty = flt(bin_qty.projected_qty) + warehouse_item_codes = {} + for d in self.get("items") + self.get("packed_items"): + warehouse_item_codes.setdefault(d.warehouse, set()).add(d.item_code) + + if not warehouse_item_codes: + return + + bin_map = {} + for warehouse, item_codes in warehouse_item_codes.items(): + for b in frappe.get_all( + "Bin", + filters={"item_code": ["in", item_codes], "warehouse": warehouse}, + fields=["item_code", "actual_qty", "projected_qty"], + ): + bin_map[(b.item_code, warehouse)] = b + + for d in self.get("items"): + bin_data = bin_map.get((d.item_code, d.warehouse)) + d.actual_qty = bin_data.actual_qty if bin_data else None + + for d in self.get("packed_items"): + bin_data = bin_map.get((d.item_code, d.warehouse)) + if bin_data: + d.actual_qty = flt(bin_data.actual_qty) + d.projected_qty = flt(bin_data.projected_qty) def validate_expense_account(self): company_values = frappe.get_cached_value( diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 3a475032bd5..b20f53f74e7 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -54,6 +54,10 @@ frappe.ui.form.on("Item", { } }, + allow_negative_stock(frm) { + erpnext.utils.confirm_negative_stock(frm); + }, + setup: function (frm) { frm.add_fetch("attribute", "numeric_values", "numeric_values"); frm.add_fetch("attribute", "from_range", "from_range"); diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 0f5840c3a0a..6da7ec333b9 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -170,6 +170,7 @@ "ignore_user_permissions": 1, "in_standard_filter": 1, "label": "Variant Of", + "link_filters": "[[\"Item\",\"has_variants\",\"=\",1]]", "options": "Item", "read_only": 1, "search_index": 1, @@ -1090,7 +1091,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2026-06-26 10:05:00.000000", + "modified": "2026-07-05 23:24:45.734144", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 7569d1c538e..425d5e4692a 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -506,6 +506,100 @@ class TestItem(ERPNextTestSuite): "Large", ) + def test_rename_attribute_abbr_updates_variant_item_code(self): + frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1) + + variant = create_variant("_Test Variant Item", {"Test Size": "Large"}) + variant.save() + + attribute = frappe.get_doc("Item Attribute", "Test Size") + for row in attribute.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "LRG" + break + + def restore_test_size_abbr(): + doc = frappe.get_doc("Item Attribute", "Test Size") + for row in doc.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "L" + break + frappe.flags.attribute_values = None + doc.save() + + self.addCleanup(restore_test_size_abbr) + self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1)) + + frappe.flags.attribute_values = None + attribute.save() + + self.assertFalse(frappe.db.exists("Item", "_Test Variant Item-L")) + self.assertTrue(frappe.db.exists("Item", "_Test Variant Item-LRG")) + self.assertEqual( + frappe.db.get_value("Item", "_Test Variant Item-LRG", "item_name"), + "_Test Variant Item-LRG", + ) + + def test_rename_attribute_abbr_updates_variant_item_name_from_template_name(self): + # item_name can be derived from the template's item_name, which may differ from its + # item_code (e.g. a friendly display name vs. a SKU-style code). The variant's item_name + # must follow the abbreviation rename the same way item_code does. + frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-L", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1) + + template = frappe.get_doc("Item", "_Test Variant Item").as_dict() + template = frappe.get_doc( + { + "doctype": "Item", + "item_code": "_Test Variant Item Diff", + "item_name": "Test Variant Friendly Name", + "item_group": template.item_group, + "stock_uom": template.stock_uom, + "has_variants": 1, + "attributes": [{"attribute": "Test Size"}], + } + ) + template.insert() + self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1)) + + variant = create_variant("_Test Variant Item Diff", {"Test Size": "Large"}) + variant.save() + self.assertEqual(variant.item_code, "_Test Variant Item Diff-L") + self.assertEqual(variant.item_name, "Test Variant Friendly Name-L") + + # even a manually customized item_name (unrelated to the auto-generated pattern) must be + # rebuilt on abbreviation rename, since item_code and item_name are meant to stay in lockstep. + frappe.db.set_value("Item", variant.name, "item_name", "Custom Friendly Large Shirt Name") + + attribute = frappe.get_doc("Item Attribute", "Test Size") + for row in attribute.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "LRG" + break + + def restore_test_size_abbr(): + doc = frappe.get_doc("Item Attribute", "Test Size") + for row in doc.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "L" + break + frappe.flags.attribute_values = None + doc.save() + + self.addCleanup(restore_test_size_abbr) + self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1)) + + frappe.flags.attribute_values = None + attribute.save() + + self.assertFalse(frappe.db.exists("Item", "_Test Variant Item Diff-L")) + self.assertEqual( + frappe.db.get_value("Item", "_Test Variant Item Diff-LRG", "item_name"), + "Test Variant Friendly Name-LRG", + ) + def test_make_item_variant(self): frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) diff --git a/erpnext/stock/doctype/item_attribute/item_attribute.py b/erpnext/stock/doctype/item_attribute/item_attribute.py index 822d257e050..ae2029e3215 100644 --- a/erpnext/stock/doctype/item_attribute/item_attribute.py +++ b/erpnext/stock/doctype/item_attribute/item_attribute.py @@ -10,6 +10,7 @@ from frappe.utils import flt from erpnext.controllers.item_variant import ( InvalidItemAttributeValueError, update_variant_attribute_values, + update_variant_item_codes_for_abbr_renames, validate_is_incremental, validate_item_attribute_value, ) @@ -46,6 +47,7 @@ class ItemAttribute(Document): def on_update(self): update_variant_attribute_values(self) + update_variant_item_codes_for_abbr_renames(self) self.validate_exising_items() self.set_enabled_disabled_in_items() diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py index df2c3f4e2c2..c6e27087e75 100644 --- a/erpnext/stock/doctype/pick_list/mapper.py +++ b/erpnext/stock/doctype/pick_list/mapper.py @@ -289,15 +289,22 @@ def create_stock_entry(pick_list: str | dict): stock_entry.pick_list = pick_list.get("name") stock_entry.purpose = pick_list.get("purpose") stock_entry.company = pick_list.get("company") - stock_entry.set_stock_entry_type() - if pick_list.get("work_order"): + job_card = pick_list.get("material_request") and frappe.db.get_value( + "Material Request", pick_list.get("material_request"), "job_card" + ) + + if job_card: + stock_entry = update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card) + elif pick_list.get("work_order"): stock_entry = update_stock_entry_based_on_work_order(pick_list, stock_entry) elif pick_list.get("material_request"): stock_entry = update_stock_entry_based_on_material_request(pick_list, stock_entry) else: stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry) + stock_entry.set_stock_entry_type() + if not stock_entry.get("items"): return frappe.msgprint(_("All picked items have already been transferred against this Pick List")) @@ -344,9 +351,57 @@ def stock_entry_exists(pick_list_name): return frappe.db.exists("Stock Entry", {"pick_list": pick_list_name}) +def update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card): + job_card = frappe.db.get_value( + "Job Card", + job_card, + ["name", "work_order", "bom_no", "semi_fg_bom", "for_quantity", "transferred_qty", "wip_warehouse"], + as_dict=True, + ) + + stock_entry.purpose = "Material Transfer for Manufacture" + stock_entry.job_card = job_card.name + stock_entry.work_order = job_card.work_order + stock_entry.from_bom = 1 + stock_entry.bom_no = job_card.semi_fg_bom or job_card.bom_no + stock_entry.fg_completed_qty = max(flt(job_card.for_quantity) - flt(job_card.transferred_qty), 0) + stock_entry.to_warehouse = job_card.wip_warehouse + + job_card_items = get_job_card_items_by_material_request_item(pick_list) + + for location in pick_list.locations: + if get_pending_transfer_stock_qty(location) <= 0: + continue + item = frappe._dict() + update_common_item_properties(item, location) + item.t_warehouse = job_card.wip_warehouse + item.job_card_item = job_card_items.get(location.material_request_item) + stock_entry.append("items", item) + + return stock_entry + + +def get_job_card_items_by_material_request_item(pick_list): + material_request_items = [ + location.material_request_item for location in pick_list.locations if location.material_request_item + ] + if not material_request_items: + return {} + + return dict( + frappe.get_all( + "Material Request Item", + filters={"name": ["in", material_request_items]}, + fields=["name", "job_card_item"], + as_list=True, + ) + ) + + def update_stock_entry_based_on_work_order(pick_list, stock_entry): work_order = frappe.get_doc("Work Order", pick_list.get("work_order")) + stock_entry.purpose = "Material Transfer for Manufacture" stock_entry.work_order = work_order.name stock_entry.company = work_order.company stock_entry.from_bom = 1 diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index 71d2265879e..4a6f8d960d2 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -342,7 +342,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend make_retention_stock_entry() { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.move_sample_to_retention_warehouse", + method: "erpnext.stock.doctype.stock_entry.services.manufacturing.move_sample_to_retention_warehouse", args: { company: cur_frm.doc.company, items: cur_frm.doc.items, @@ -455,7 +455,7 @@ var validate_sample_quantity = function (frm, cdt, cdn) { var d = locals[cdt][cdn]; if (d.sample_quantity && d.qty) { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.validate_sample_quantity", + method: "erpnext.stock.doctype.stock_entry.services.manufacturing.validate_sample_quantity", args: { batch_no: d.batch_no, item_code: d.item_code, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index fed14074419..9469ac48a62 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -518,7 +518,7 @@ frappe.ui.form.on("Stock Entry", { __("Expired Batches"), function () { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.serial_batch.get_expired_batch_items", + method: "erpnext.stock.doctype.stock_entry.services.serial_batch.get_expired_batch_items", freeze: true, callback: function (r) { if (!r.exc && r.message) { @@ -692,7 +692,7 @@ frappe.ui.form.on("Stock Entry", { make_retention_stock_entry: function (frm) { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.move_sample_to_retention_warehouse", + method: "erpnext.stock.doctype.stock_entry.services.manufacturing.move_sample_to_retention_warehouse", args: { company: frm.doc.company, items: frm.doc.items, @@ -961,7 +961,7 @@ frappe.ui.form.on("Stock Entry", { if (frm.doc.purchase_order) { frm.set_value("subcontracting_order", ""); erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.subcontracting.get_items_from_subcontract_order", + method: "erpnext.stock.doctype.stock_entry.services.subcontracting.get_items_from_subcontract_order", source_name: frm.doc.purchase_order, target_doc: frm, freeze: true, @@ -973,7 +973,7 @@ frappe.ui.form.on("Stock Entry", { if (frm.doc.subcontracting_order) { frm.set_value("purchase_order", ""); erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.subcontracting.get_items_from_subcontract_order", + method: "erpnext.stock.doctype.stock_entry.services.subcontracting.get_items_from_subcontract_order", source_name: frm.doc.subcontracting_order, target_doc: frm, freeze: true, @@ -1187,7 +1187,7 @@ var validate_sample_quantity = function (frm, cdt, cdn) { var d = locals[cdt][cdn]; if (d.sample_quantity && d.transfer_qty && frm.doc.purpose == "Material Receipt") { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.validate_sample_quantity", + method: "erpnext.stock.doctype.stock_entry.services.manufacturing.validate_sample_quantity", args: { batch_no: d.batch_no, item_code: d.item_code, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index ca72ced6157..321948c52e2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1423,6 +1423,23 @@ class StockEntry(StockController, SubcontractingInwardController): used_alternative_items = get_used_alternative_items( subcontract_order_field=self.subcontract_data.order_field, work_order=self.work_order ) + + skip_transfer, from_wip_warehouse = ( + frappe.get_value("Work Order", self.work_order, ["skip_transfer", "from_wip_warehouse"]) + if self.work_order + else [None, None] + ) + wo_item_source_warehouses = {} + if skip_transfer and not from_wip_warehouse: + for d in frappe.get_all( + "Work Order Item", + filters={"parent": self.work_order}, + fields=["item_code", "source_warehouse"], + ): + # default ordering is creation desc; keep the first (most recent) row per + # item_code to match the limit-1 behaviour of the get_value call this replaces + wo_item_source_warehouses.setdefault(d.item_code, d.source_warehouse) + for item in item_dict.values(): # if source warehouse presents in BOM set from_warehouse as bom source_warehouse if item["allow_alternative_item"]: @@ -1430,18 +1447,8 @@ class StockEntry(StockController, SubcontractingInwardController): "Work Order", self.work_order, "allow_alternative_item" ) - skip_transfer, from_wip_warehouse = ( - frappe.get_value("Work Order", self.work_order, ["skip_transfer", "from_wip_warehouse"]) - if self.work_order - else [None, None] - ) - item.from_warehouse = ( - frappe.get_value( - "Work Order Item", - {"parent": self.work_order, "item_code": item.item_code}, - "source_warehouse", - ) + wo_item_source_warehouses.get(item.item_code) if skip_transfer and not from_wip_warehouse else self.from_warehouse or item.source_warehouse or item.default_warehouse ) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.js b/erpnext/stock/doctype/stock_settings/stock_settings.js index 5fd07e78289..4e1052475d6 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.js +++ b/erpnext/stock/doctype/stock_settings/stock_settings.js @@ -96,25 +96,7 @@ frappe.ui.form.on("Stock Settings", { }, allow_negative_stock: function (frm) { - if (!frm.doc.allow_negative_stock) { - return; - } - - let msg = __( - "Using negative stock disables FIFO/Moving average valuation when inventory is negative." - ); - msg += " "; - msg += __("This is considered dangerous from accounting point of view."); - msg += "
"; - msg += __("Do you still want to enable negative inventory?"); - - frappe.confirm( - msg, - () => {}, - () => { - frm.set_value("allow_negative_stock", 0); - } - ); + erpnext.utils.confirm_negative_stock(frm); }, auto_insert_price_list_rate_if_missing(frm) { if (!frm.doc.auto_insert_price_list_rate_if_missing) return; diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index aa076894649..a58c1b037ef 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -1594,7 +1594,7 @@ def get_batch_qty(batch_no: str, warehouse: str, item_code: str): @frappe.whitelist() @erpnext.normalize_ctx_input(ItemDetailsCtx) -def apply_price_list(ctx: ItemDetailsCtx, as_doc: bool = False, doc: Document | str | None = None): +def apply_price_list(ctx: ItemDetailsCtx, as_doc: bool = False, doc: Document | str | dict | None = None): """Apply pricelist on a document-like dict object and return as {'parent': dict, 'children': list} diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py index dc6168f52ac..9c8a93b4b69 100644 --- a/erpnext/stock/reorder_item.py +++ b/erpnext/stock/reorder_item.py @@ -182,6 +182,10 @@ def get_item_warehouse_projected_qty(items_to_consider): item_warehouse_projected_qty = {} items_to_consider = list(items_to_consider.keys()) + warehouse_parent_map = frappe._dict( + frappe.get_all("Warehouse", fields=["name", "parent_warehouse"], as_list=True) + ) + for item_code, warehouse, projected_qty in frappe.get_all( "Bin", filters={"item_code": ["in", items_to_consider], "warehouse": ["is", "set"]}, @@ -194,16 +198,14 @@ def get_item_warehouse_projected_qty(items_to_consider): if warehouse not in item_warehouse_projected_qty.get(item_code): item_warehouse_projected_qty[item_code][warehouse] = flt(projected_qty) - warehouse_doc = frappe.get_doc("Warehouse", warehouse) + parent_warehouse = warehouse_parent_map.get(warehouse) - while warehouse_doc.parent_warehouse: - if not item_warehouse_projected_qty.get(item_code, {}).get(warehouse_doc.parent_warehouse): - item_warehouse_projected_qty.setdefault(item_code, {})[warehouse_doc.parent_warehouse] = flt( - projected_qty - ) + while parent_warehouse: + if not item_warehouse_projected_qty.get(item_code, {}).get(parent_warehouse): + item_warehouse_projected_qty.setdefault(item_code, {})[parent_warehouse] = flt(projected_qty) else: - item_warehouse_projected_qty[item_code][warehouse_doc.parent_warehouse] += flt(projected_qty) - warehouse_doc = frappe.get_doc("Warehouse", warehouse_doc.parent_warehouse) + item_warehouse_projected_qty[item_code][parent_warehouse] += flt(projected_qty) + parent_warehouse = warehouse_parent_map.get(parent_warehouse) return item_warehouse_projected_qty 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 a456bad72d7..1365a02ba25 100644 --- a/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py +++ b/erpnext/stock/report/delivery_note_trends/delivery_note_trends.py @@ -14,12 +14,12 @@ def execute(filters=None): conditions = get_columns(filters, "Delivery Note") data = get_data(filters, conditions) - chart_data = get_chart_data(data, filters) + chart_data = get_chart_data(data, conditions, filters) return conditions["columns"], data, None, chart_data -def get_chart_data(data, filters): +def get_chart_data(data, conditions, filters): def wrap_in_quotes(label): return f"'{label}'" @@ -52,4 +52,6 @@ def get_chart_data(data, filters): }, "type": "bar", "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } diff --git a/erpnext/stock/report/landed_cost_report/landed_cost_report.py b/erpnext/stock/report/landed_cost_report/landed_cost_report.py index b5738c2e20f..18473c51b24 100644 --- a/erpnext/stock/report/landed_cost_report/landed_cost_report.py +++ b/erpnext/stock/report/landed_cost_report/landed_cost_report.py @@ -4,6 +4,8 @@ import frappe from frappe import _ +import erpnext + def execute(filters: dict | None = None): columns = get_columns() @@ -24,6 +26,14 @@ def get_columns() -> list[dict]: "label": _("Total Landed Cost"), "fieldname": "landed_cost", "fieldtype": "Currency", + "options": "currency", + }, + { + "label": _("Currency"), + "fieldname": "currency", + "fieldtype": "Link", + "options": "Currency", + "hidden": 1, }, { "label": _("Purchase Voucher Type"), @@ -49,6 +59,7 @@ def get_columns() -> list[dict]: def get_data(filters) -> list[list]: + company_currency = erpnext.get_company_currency(filters.get("company")) landed_cost_vouchers = get_landed_cost_vouchers(filters) or {} landed_vouchers = list(landed_cost_vouchers.keys()) vendor_invoices = {} @@ -57,7 +68,6 @@ def get_data(filters) -> list[list]: data = [] - print(vendor_invoices) for name, vouchers in landed_cost_vouchers.items(): res = { "name": name, @@ -72,6 +82,7 @@ def get_data(filters) -> list[list]: "landed_cost": d.landed_cost, "voucher_type": d.voucher_type, "voucher_no": d.voucher_no, + "currency": company_currency, } ) else: @@ -88,7 +99,6 @@ def get_data(filters) -> list[list]: if vendor_invoice_list and len(vendor_invoice_list) > len(vouchers): for row in vendor_invoice_list[last_index + 1 :]: - print(row) data.append({"vendor_invoice": row}) return data diff --git a/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py b/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py index 9d313b477a3..4210d1a3604 100644 --- a/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py +++ b/erpnext/stock/report/purchase_receipt_trends/purchase_receipt_trends.py @@ -14,12 +14,12 @@ def execute(filters=None): conditions = get_columns(filters, "Purchase Receipt") data = get_data(filters, conditions) - chart_data = get_chart_data(data, filters) + chart_data = get_chart_data(data, conditions, filters) return conditions["columns"], data, None, chart_data -def get_chart_data(data, filters): +def get_chart_data(data, conditions, filters): def wrap_in_quotes(label): return f"'{label}'" @@ -53,4 +53,6 @@ def get_chart_data(data, filters): "type": "bar", "colors": ["#5e64ff"], "fieldtype": "Currency", + "options": "currency", + "currency": conditions.get("company_currency"), } diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index c52d466b897..fb64fb70bcd 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -289,6 +289,7 @@ class FIFOSlots: self.serial_no_details = {} self.batch_no_details = {} self.batchwise_valuation_by_batch = {} + self.valuation_method_by_item = {} self.filters = filters self.sle = sle @@ -310,8 +311,9 @@ class FIFOSlots: if stock_ledger_entries is None: # streaming path: nested queries invalidate the streaming cursor below, - # so batchwise valuation flags must be resolved beforehand + # so batchwise valuation flags and item valuation methods must be resolved beforehand self._prefetch_batchwise_valuations() + self._prefetch_valuation_methods() if frappe.db.db_type == "postgres": # postgres server-side cursors can't run nested queries mid-iteration; _get_stock_ledger_entries @@ -334,12 +336,28 @@ class FIFOSlots: for row in stock_ledger_entries: self._process_stock_ledger_entry(row, bundle_wise_serial_nos, bundle_wise_batch_nos) + self._recompute_moving_average_slots() + if not self.filters.get("show_warehouse_wise_stock"): # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) self.item_details = self._aggregate_details_by_item(self.item_details) return self.item_details + def _recompute_moving_average_slots(self) -> None: + for item_dict in self.item_details.values(): + if item_dict.get("has_serial_no") or item_dict.get("has_batch_no"): + continue + + details = item_dict["details"] + if self._get_item_valuation_method(details.name) != "Moving Average": + continue + + rate = flt(details.valuation_rate) + for slot in item_dict["fifo_queue"]: + if is_qty_slot(slot): + slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate) + def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]: if stock_ledger_entries is not None: return frappe._dict({}), frappe._dict({}) @@ -360,7 +378,10 @@ class FIFOSlots: if row.actual_qty > 0: self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos) else: - self._compute_outgoing_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos) + from_end = self._get_item_valuation_method(row.name) == "LIFO" + self._compute_outgoing_stock( + row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end + ) self._update_balances(row, key) self._trim_serial_fifo_queue(row, key, fifo_queue) @@ -473,6 +494,45 @@ class FIFOSlots: for batch_no, use_batchwise_valuation in query.run(): self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation + def _get_item_valuation_method(self, item_code: str) -> str: + from erpnext.stock.utils import get_valuation_method + + if item_code not in self.valuation_method_by_item: + # only reachable when stock ledger entries are passed in directly; + # the streaming path prefetches all methods before iteration + self.valuation_method_by_item[item_code] = get_valuation_method( + item_code, self.filters.get("company") + ) + + return self.valuation_method_by_item[item_code] + + def _prefetch_valuation_methods(self) -> None: + from erpnext.stock.utils import get_valuation_method + + company = self.filters.get("company") + sle = frappe.qb.DocType("Stock Ledger Entry") + item = frappe.qb.DocType("Item") + to_date = get_datetime(self.filters.get("to_date") + " 23:59:59") + + query = ( + frappe.qb.from_(sle) + .inner_join(item) + .on(sle.item_code == item.name) + .select(item.name, item.valuation_method) + .distinct() + .where((sle.company == company) & (sle.posting_datetime <= to_date) & (sle.is_cancelled != 1)) + ) + query = self._apply_filter(query, sle, "item_code") + + # items with no item-level method share the company/settings default; resolve it once + default_method = None + for item_code, valuation_method in query.run(): + if not valuation_method: + if default_method is None: + default_method = get_valuation_method(item_code, company) + valuation_method = default_method + self.valuation_method_by_item[item_code] = valuation_method + def _init_key_stores(self, row: dict) -> tuple: "Initialise keys and FIFO Queue." @@ -589,7 +649,13 @@ class FIFOSlots: fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference) def _compute_outgoing_stock( - self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list + self, + row: dict, + fifo_queue: list, + transfer_key: tuple, + serial_nos: list, + batch_nos: list, + from_end: bool = False, ): "Update FIFO Queue on outward stock." if serial_nos: @@ -597,7 +663,7 @@ class FIFOSlots: elif batch_nos: self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos) else: - self._consume_fifo_slots(row, fifo_queue, transfer_key) + self._consume_fifo_slots(row, fifo_queue, transfer_key, from_end) def _consume_serial_fifo_slots(self, fifo_queue: list, serial_nos: list) -> None: fifo_queue[:] = [slot for slot in fifo_queue if slot[FIFO_QTY_INDEX] not in serial_nos] @@ -674,19 +740,23 @@ class FIFOSlots: ) self.transferred_item_details[transfer_key].append([qty, row.posting_date, stock_value_difference]) - def _consume_fifo_slots(self, row: dict, fifo_queue: list, transfer_key: tuple) -> None: + def _consume_fifo_slots( + self, row: dict, fifo_queue: list, transfer_key: tuple, from_end: bool = False + ) -> None: + # LIFO consumes the most recent inward first, so pop from the tail instead of the head. + index = -1 if from_end else 0 qty_to_pop = abs(row.actual_qty) stock_value = abs(row.stock_value_difference) while qty_to_pop: - slot = fifo_queue[0] if fifo_queue else [0, None, 0] + slot = fifo_queue[index] if fifo_queue else [0, None, 0] slot_qty = flt(slot[FIFO_QTY_INDEX]) slot_value = flt(slot[FIFO_VALUE_INDEX]) if 0 < slot_qty <= qty_to_pop: qty_to_pop -= slot_qty stock_value -= slot_value - self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) + self.transferred_item_details[transfer_key].append(fifo_queue.pop(index)) elif not fifo_queue: fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)]) self.transferred_item_details[transfer_key].append( diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 7809451744d..180a424b209 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -1,6 +1,8 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +from unittest.mock import patch + import frappe from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data, get_average_age @@ -63,6 +65,131 @@ class TestStockAgeing(ERPNextTestSuite): data = format_report_data(self.filters, slots, self.filters["to_date"]) self.assertEqual(data[0][8], 40.0) # valuating for stock value between age 0-30 + def test_moving_average_value_ties_to_stock_balance(self): + """For Moving Average items the queue value is re-derived as qty * rate so the + report's stock value ties to Stock Balance, instead of stranding a residual + from FIFO-by-qty consumption vs blended outgoing value.""" + sle = [ + frappe._dict( + name="MA Item", + actual_qty=10, + qty_after_transaction=10, + stock_value_difference=1000, + valuation_rate=100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=10, + qty_after_transaction=20, + stock_value_difference=2000, + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=(-10), + qty_after_transaction=10, + stock_value_difference=(-1500), + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-03", + voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=(-5), + qty_after_transaction=5, + stock_value_difference=(-750), + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-04", + voucher_type="Stock Entry", + voucher_no="004", + has_serial_no=False, + serial_no=None, + ), + ] + + with patch("erpnext.stock.utils.get_valuation_method", return_value="Moving Average"): + slots = FIFOSlots(self.filters, sle).generate() + + queue = slots["MA Item"]["fifo_queue"] + total_value = sum(slot[2] for slot in queue) + + # Stock Balance bal_val = qty_after_transaction * valuation_rate = 5 * 150 + self.assertEqual(total_value, 750.0) + + def test_lifo_consumes_newest_first(self): + """LIFO items consume the most recent inward first, so the oldest lot stays on + hand. The remaining queue, stock value and average age must reflect the older + stock, unlike the default FIFO which retains the newest lots.""" + sle = [ + frappe._dict( + name="LIFO Item", + actual_qty=30, + qty_after_transaction=30, + stock_value_difference=30, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="LIFO Item", + actual_qty=20, + qty_after_transaction=50, + stock_value_difference=20, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="LIFO Item", + actual_qty=(-10), + qty_after_transaction=40, + stock_value_difference=(-10), + warehouse="WH 1", + posting_date="2021-12-03", + voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, + serial_no=None, + ), + ] + + with patch("erpnext.stock.utils.get_valuation_method", return_value="LIFO"): + slots = FIFOSlots(self.filters, sle).generate() + + queue = slots["LIFO Item"]["fifo_queue"] + + # newest lot (day 2) is consumed first: oldest 30 stays, newest drops 20 -> 10 + self.assertEqual(queue[0][0], 30.0) + self.assertEqual(queue[-1][0], 10.0) + self.assertEqual(sum(slot[0] for slot in queue), 40.0) + self.assertEqual(sum(slot[2] for slot in queue), 40.0) + + # average age skews older than the FIFO result (8.5) because the old lot is retained + self.assertEqual(get_average_age(queue, self.filters["to_date"]), 8.75) + def test_insufficient_balance(self): "Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)" sle = [ diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 85f2e83f8d9..dd071c0b717 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -575,7 +575,7 @@ class TransactionBase(StatusUpdater): "is_internal_customer": self.is_internal_customer, } # TODO: test method call impact on document - apply_price_list(cts=args, as_doc=True, doc=self) + apply_price_list(ctx=args, as_doc=True, doc=self) def delete_events(ref_type, ref_name):