diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 94b76b12ce7..3b9d953db1c 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -18,7 +18,19 @@ jobs: cache: pip - name: Install and Run Pre-commit - uses: pre-commit/action@v3.0.0 + uses: pre-commit/action@v3.0.1 + + semgrep: + name: semgrep + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: '3.10' + cache: pip - name: Download Semgrep rules run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 13cbf66a5af..c09e5cdedb9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,7 +50,6 @@ repos: cypress/.*| .*node_modules.*| .*boilerplate.*| - erpnext/public/js/controllers/.*| erpnext/templates/pages/order.js| erpnext/templates/includes/.* )$ diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json index ea3977711a7..1cad6a97d0c 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/philippines.json @@ -406,8 +406,7 @@ "Customer Deposits": { "account_number": "2500", "is_group": 0, - "root_type": "Liability", - "account_type": "Payable" + "root_type": "Liability" } }, "Non Current Liabilities": { diff --git a/erpnext/accounts/doctype/accounting_period/accounting_period.py b/erpnext/accounts/doctype/accounting_period/accounting_period.py index 300d216618e..426a4d57064 100644 --- a/erpnext/accounts/doctype/accounting_period/accounting_period.py +++ b/erpnext/accounts/doctype/accounting_period/accounting_period.py @@ -5,6 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document +from frappe.utils import getdate, nowdate class OverlapError(frappe.ValidationError): @@ -34,8 +35,20 @@ class AccountingPeriod(Document): # end: auto-generated types def validate(self): + self.validate_dates() self.validate_overlap() + def validate_dates(self): + if getdate(self.start_date) > getdate(self.end_date): + frappe.throw(_("Start Date cannot be after End Date")) + + if getdate(self.end_date) > getdate(nowdate()): + frappe.throw( + _( + "Accounting Period cannot be created for a future date. End Date {0} is after today." + ).format(frappe.bold(frappe.format(self.end_date, "Date"))) + ) + def before_insert(self): self.bootstrap_doctypes_for_closing() diff --git a/erpnext/accounts/doctype/accounting_period/test_accounting_period.py b/erpnext/accounts/doctype/accounting_period/test_accounting_period.py index 16cae9683f9..671a28e3956 100644 --- a/erpnext/accounts/doctype/accounting_period/test_accounting_period.py +++ b/erpnext/accounts/doctype/accounting_period/test_accounting_period.py @@ -4,7 +4,7 @@ import unittest import frappe -from frappe.utils import add_months, nowdate +from frappe.utils import nowdate from erpnext.accounts.doctype.accounting_period.accounting_period import ( ClosedAccountingPeriod, @@ -47,7 +47,7 @@ def create_accounting_period(**args): accounting_period = frappe.new_doc("Accounting Period") accounting_period.start_date = args.start_date or nowdate() - accounting_period.end_date = args.end_date or add_months(nowdate(), 1) + accounting_period.end_date = args.end_date or nowdate() accounting_period.company = args.company or "_Test Company" accounting_period.period_name = args.period_name or "_Test_Period_Name_1" accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1}) diff --git a/erpnext/accounts/doctype/bank_account/bank_account.py b/erpnext/accounts/doctype/bank_account/bank_account.py index aced4258526..30dbb013be8 100644 --- a/erpnext/accounts/doctype/bank_account/bank_account.py +++ b/erpnext/accounts/doctype/bank_account/bank_account.py @@ -115,7 +115,7 @@ def get_party_bank_account(party_type, party): ) -def get_default_company_bank_account(company, party_type, party): +def get_default_company_bank_account(company, party_type, party, ignore_permissions=True): default_company_bank_account = frappe.db.get_value(party_type, party, "default_bank_account") if default_company_bank_account: if company != frappe.get_cached_value("Bank Account", default_company_bank_account, "company"): @@ -126,6 +126,14 @@ def get_default_company_bank_account(company, party_type, party): "Bank Account", {"company": company, "is_company_account": 1, "is_default": 1} ) + if not ignore_permissions: + default_company_bank_account = ( + default_company_bank_account + if default_company_bank_account + and frappe.get_cached_doc("Bank Account", default_company_bank_account).has_permission("select") + else None + ) + return default_company_bank_account diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index 9ea87ef0ae7..f249cf9c19d 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -57,7 +57,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None): filters.append(["date", "<=", to_date]) if from_date: filters.append(["date", ">=", from_date]) - transactions = frappe.get_all( + transactions = frappe.get_list( "Bank Transaction", fields=[ "date", @@ -82,6 +82,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None): @frappe.whitelist() def get_account_balance(bank_account, till_date, company): # returns account balance till the specified date + frappe.has_permission("Bank Account", "read", bank_account, throw=True) account = frappe.db.get_value("Bank Account", bank_account, "account") filters = frappe._dict( { diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py index 4294c4462b1..05a9c055078 100644 --- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py @@ -115,6 +115,36 @@ class TestBankTransaction(FrappeTestCase): self.assertEqual(bank_transaction.unallocated_amount, 1700) self.assertEqual(bank_transaction.payment_entries, []) + # Amending a reconciled payment entry must not carry over its clearance date + def test_clearance_date_cleared_on_amend(self): + bank_transaction = frappe.get_doc( + "Bank Transaction", + dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"), + ) + payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700)) + vouchers = json.dumps( + [ + { + "payment_doctype": "Payment Entry", + "payment_name": payment.name, + "amount": bank_transaction.unallocated_amount, + } + ] + ) + reconcile_vouchers(bank_transaction.name, vouchers) + + self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date")) + + payment.reload() + payment.cancel() + + amended = frappe.copy_doc(payment) + amended.amended_from = payment.name + amended.docstatus = 0 + amended.insert() + + self.assertFalse(amended.clearance_date) + # Check if ERPNext can correctly filter a linked payments based on the debit/credit amount def test_debit_credit_output(self): bank_transaction = frappe.get_doc( diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js index eeda531c4d6..a2e5e54a776 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js @@ -22,17 +22,27 @@ frappe.ui.form.on("Exchange Rate Revaluation", { refresh: function (frm) { if (frm.doc.docstatus == 1) { frappe.call({ - method: "check_journal_entry_condition", + method: "check_journal_and_reversal", doc: frm.doc, callback: function (r) { if (r.message) { - frm.add_custom_button( - __("Journal Entries"), - function () { - return frm.events.make_jv(frm); - }, - __("Create") - ); + if (!r.message.journals_posted) { + frm.add_custom_button( + __("Journal Entries"), + function () { + return frm.events.make_jv(frm); + }, + __("Create") + ); + } else if (!r.message.reversals_posted) { + frm.add_custom_button( + __("Reversal Journal Entries"), + function () { + return frm.events.make_reverse_journal(frm); + }, + __("Create") + ); + } } }, }); @@ -100,6 +110,14 @@ frappe.ui.form.on("Exchange Rate Revaluation", { }, }); }, + make_reverse_journal: function (frm) { + frappe.call({ + method: "make_reverse_journal", + doc: frm.doc, + freeze: true, + freeze_message: __("Reversing Journals..."), + }); + }, }); frappe.ui.form.on("Exchange Rate Revaluation Account", { diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index 41249662624..b87e8e00951 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -8,7 +8,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 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 @@ -90,25 +90,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 = ( @@ -123,12 +129,31 @@ class ExchangeRateRevaluation(Document): .run() ) - if total_amt and total_amt[0][0] != self.total_gain_loss: - return True + if total_amt and total_amt[0][0] == self.total_gain_loss: + journals_posted = True else: - return False + journals_posted = False - return True + # reverse journals + reverse_journals = ( + qb.from_(je) + .join(jea) + .on(je.name == jea.parent) + .select(je.name) + .where( + (jea.reference_type == "Exchange Rate Revaluation") + & (jea.reference_name == self.name) + & (jea.docstatus == 1) + & (je.reversal_of.notnull()) + ) + .run(pluck="name") + ) + if reverse_journals: + reversals_posted = True + else: + reversals_posted = False + + return {"journals_posted": journals_posted, "reversals_posted": reversals_posted} def fetch_and_calculate_accounts_data(self): accounts = self.get_accounts_data() @@ -342,6 +367,7 @@ class ExchangeRateRevaluation(Document): @frappe.whitelist() def make_jv_entries(self): + frappe.has_permission("Journal Entry", "write", throw=True) zero_balance_jv = self.make_jv_for_zero_balance() if zero_balance_jv: frappe.msgprint( @@ -571,6 +597,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.journal_entry import make_reverse_journal_entry + + for x in journals: + reversal = make_reverse_journal_entry(x) + reversal.posting_date = nowdate() + reversal.submit() + frappe.msgprint( + _("Revaluation journal for {0} has been created: {1}").format( + frappe.bold(x), get_link_to_form("Journal Entry", reversal.name) + ) + ) + def calculate_exchange_rate_using_last_gle(company, account, party_type, party): """ diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py index 3eef6ab3832..4329b6078ec 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/test_exchange_rate_revaluation.py @@ -130,7 +130,8 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase): 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() @@ -213,7 +214,8 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase): 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() @@ -287,3 +289,83 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase): for key, _val in expected_data.items(): self.assertEqual(expected_data.get(key), account_details.get(key)) + + @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) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js index ae3ee00e535..232c33d4def 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js @@ -40,6 +40,10 @@ frappe.ui.form.on("Journal Entry", { }, refresh: function (frm) { + if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) { + frm.set_read_only(); + } + erpnext.toggle_naming_series(); if (frm.doc.docstatus > 0) { diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index aa048a71ff2..762585601e5 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -21,6 +21,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( get_party_tax_withholding_details, ) +from erpnext.accounts.general_ledger import validate_opening_entry_against_pcv from erpnext.accounts.party import get_party_account from erpnext.accounts.utils import ( cancel_exchange_gain_loss_journal, @@ -123,6 +124,9 @@ class JournalEntry(AccountsController): if not self.is_opening: self.is_opening = "No" + if self.is_opening == "Yes": + validate_opening_entry_against_pcv(self.company) + self.clearance_date = None self.validate_party() diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js index 9d6e87392e5..45d29d08477 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry_list.js +++ b/erpnext/accounts/doctype/journal_entry/journal_entry_list.js @@ -1,12 +1,15 @@ frappe.listview_settings["Journal Entry"] = { - add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark"], + add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark", "reversal_of"], get_indicator: function (doc) { if (doc.docstatus == 0) { return [__("Draft", "red", "docstatus,=,0")]; } else if (doc.docstatus == 2) { return [__("Cancelled", "grey", "docstatus,=,2")]; - } else { - return [__(doc.voucher_type), "blue", "voucher_type,=," + doc.voucher_type]; + } else if (doc.docstatus === 1) { + if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") { + return [__("Reversal Of Exchange Rate Revaluation"), "blue"]; + } + return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`]; } }, }; diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json b/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json index ed8ff7c0f7a..d1d65dd9185 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool_item/opening_invoice_creation_tool_item.json @@ -79,6 +79,7 @@ "fieldtype": "Currency", "in_list_view": 1, "label": "Outstanding Amount", + "options": "Company:company:default_currency", "reqd": 1 }, { @@ -115,7 +116,7 @@ ], "istable": 1, "links": [], - "modified": "2022-03-21 19:31:45.382656", + "modified": "2026-07-02 15:17:11.938499", "modified_by": "Administrator", "module": "Accounts", "name": "Opening Invoice Creation Tool Item", @@ -126,4 +127,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 148dd4edcc5..815bd0fd6f2 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2691,6 +2691,9 @@ def get_party_details(company, party_type, party, date, cost_center=None): if not frappe.db.exists(party_type, party): frappe.throw(_("{0} {1} does not exist").format(_(party_type), party)) + ptype = "select" if frappe.only_has_select_perm(party_type) else "read" + frappe.has_permission(party_type, ptype, party, throw=True) + party_account = get_party_account(party_type, party, company) account_currency = get_account_currency(party_account) account_balance = ( @@ -2707,7 +2710,7 @@ def get_party_details(company, party_type, party, date, cost_center=None): ) if party_type in ["Customer", "Supplier"]: party_bank_account = get_party_bank_account(party_type, party) - bank_account = get_default_company_bank_account(company, party_type, party) + bank_account = get_default_company_bank_account(company, party_type, party, ignore_permissions=False) return { "party_account": party_account, diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 9ec4e0a073a..d286c6513f4 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -796,10 +796,17 @@ class PaymentReconciliation(Document): def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None): + allocated_amount_precision = get_field_precision( + frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount") + ) for inv in dr_cr_notes: if ( - abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount")) - < inv.allocated_amount + flt( + abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount")) + - inv.allocated_amount, + allocated_amount_precision, + ) + < 0 ): frappe.throw( _("{0} has been modified after you pulled it. Please pull it again.").format(inv.voucher_type) diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index b7d8fb44853..a727e4ab894 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -155,6 +155,7 @@ class TestPaymentReconciliation(FrappeTestCase): sinv = create_sales_invoice( qty=qty, rate=rate, + posting_date=posting_date, company=self.company, customer=self.customer, item_code=self.item, @@ -2146,7 +2147,7 @@ class TestPaymentReconciliation(FrappeTestCase): pr.reconcile() si.reload() - self.assertEqual(si.status, "Partly Paid") + self.assertEqual(si.status, "Overdue") # check PR tool output post reconciliation self.assertEqual(len(pr.get("invoices")), 1) self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 120) @@ -2540,6 +2541,76 @@ class TestPaymentReconciliation(FrappeTestCase): self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0) pr.reconcile() + def test_cr_note_split_across_invoices_floating_point_precision(self): + """Regression: when a credit note is split across multiple invoices, floating-point + arithmetic (150 - 8.45 - 90.72 = 50.83000000000001) must not cause reconcile() to fail. + + The test environment rounds INR totals to whole rupees (smallest_currency_fraction_value=0), + so the invoices are created with round-number totals (100, 200, 100) and then partially paid + down to the decimal outstanding amounts (8.45, 90.72, 72.57) via payment entries. + """ + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + + # Create invoices on different posting dates to control sort-order in Payment Reconciliation + # (invoices are sorted by posting_date ascending, so si_a is processed first). + # Processing order 8.45 → 90.72 → 72.57 produces the float chain: + # 150 - 8.45 = 141.55 → 141.55 - 90.72 = 50.83000000000001 + # The last allocation row will therefore carry allocated_amount = 50.83000000000001. + si_a = self.create_sales_invoice(qty=1, rate=100, posting_date=add_days(nowdate(), -2)) + si_b = self.create_sales_invoice(qty=1, rate=200, posting_date=add_days(nowdate(), -1)) + si_c = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate()) + + # Partially pay each invoice so the remaining outstanding is a clean decimal value. + # INR rounds the invoice total to a whole rupee, so we achieve decimal outstandings + # by subtracting a decimal-valued payment from the integer total: + # 100 - 91.55 = 8.45 + # 200 - 109.28 = 90.72 + # 100 - 27.43 = 72.57 + for si, partial_paid in ((si_a, 91.55), (si_b, 109.28), (si_c, 27.43)): + pe = get_payment_entry(si.doctype, si.name) + pe.paid_amount = partial_paid + pe.received_amount = partial_paid + pe.references[0].allocated_amount = partial_paid + pe.save().submit() + + cr_note = self.create_sales_invoice( + qty=-1, rate=150, posting_date=nowdate(), do_not_save=True, do_not_submit=True + ) + cr_note.is_return = 1 + cr_note = cr_note.save().submit() + + pr = self.create_payment_reconciliation() + # Widen date range so all three invoices (oldest is -2 days) are fetched + pr.from_invoice_date = add_days(nowdate(), -2) + pr.to_invoice_date = nowdate() + pr.from_payment_date = nowdate() + pr.to_payment_date = nowdate() + + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 3) + self.assertEqual(len(pr.payments), 1) + + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + + # Credit note (150) covers all of si_a (8.45) and si_b (90.72), then partially si_c + self.assertEqual(len(pr.allocation), 3) + last_row = pr.allocation[-1] + # Last allocated amount should be ~50.83 (possibly 50.83000000000001 due to float arithmetic) + self.assertAlmostEqual(flt(last_row.allocated_amount), 50.83, places=2) + + # reconcile() must not raise "has been modified after you pulled it" due to float imprecision + pr.reconcile() + + si_a.reload() + si_b.reload() + si_c.reload() + self.assertEqual(si_a.outstanding_amount, 0) + self.assertEqual(si_b.outstanding_amount, 0) + # si_c is only partially settled: 72.57 - 50.83 = 21.74 + self.assertAlmostEqual(si_c.outstanding_amount, 21.74, places=2) + def make_customer(customer_name, currency=None): if not frappe.db.exists("Customer", customer_name): diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index e341490ed7f..01de1e34e21 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -367,6 +367,7 @@ class PaymentRequest(Document): bank_amount=bank_amount, created_from_payment_request=True, ) + payment_entry.set_missing_ref_details(force=True) payment_entry.update( { @@ -834,6 +835,7 @@ def resend_payment_email(docname): @frappe.whitelist() def make_payment_entry(docname): doc = frappe.get_doc("Payment Request", docname) + doc.check_permission("read") return doc.create_payment_entry(submit=False).as_dict() diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index df28b623488..9f92e9f4f09 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -618,6 +618,22 @@ class TestPaymentRequest(FrappeTestCase): pi.load_from_db() self.assertEqual(pr_2.grand_total, pi.outstanding_amount) + def test_payment_entry_reference_details_fetched_from_invoice(self): + pi = make_purchase_invoice(currency="INR", qty=1, rate=94500) + pi.submit() + + pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1, submit_doc=0, return_doc=1) + pr.grand_total = 94000 + pr.submit() + + pe = pr.create_payment_entry(submit=False) + + self.assertEqual(pe.references[0].reference_name, pi.name) + self.assertEqual(pe.references[0].total_amount, pi.grand_total) + self.assertEqual(pe.references[0].outstanding_amount, pi.outstanding_amount) + self.assertEqual(pe.references[0].allocated_amount, 94000) + self.assertEqual(pe.paid_amount, 94000) + def test_consider_journal_entry_and_return_invoice(self): from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py index e4e31a9adf4..e9bad6d7494 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py @@ -379,12 +379,15 @@ class TestPeriodClosingVoucher(unittest.TestCase): self.make_period_closing_voucher(posting_date="2021-03-31") - # Passed posting_date is after PCV end date, so cancellation should not fail. - make_reverse_gl_entries( - voucher_type="Journal Entry", - voucher_no=jv.name, - posting_date="2022-01-01", - ) + frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", "2021-12-31") + + try: + make_reverse_gl_entries( + voucher_type="Journal Entry", + voucher_no=jv.name, + ) + finally: + frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None) totals_after_cancel = frappe.db.sql( """ diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index cc090df9270..4fab7fc1121 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -156,6 +156,24 @@ class PricingRule(Document): if len(values) != len(set(values)): frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on)) + if self.apply_on == "Item Code": + self.validate_template_with_variant(values) + + def validate_template_with_variant(self, item_codes): + # throws if a template and its variant both exist in one rule + variants = frappe.get_all( + "Item", + filters={"name": ("in", item_codes), "variant_of": ("in", item_codes)}, + fields=["name", "variant_of"], + ) + if variants: + variant = variants[0] + frappe.throw( + _("Variant {0} and its template {1} cannot both be added to the same Pricing Rule").format( + frappe.bold(variant.name), frappe.bold(variant.variant_of) + ) + ) + def validate_mandatory(self): if self.has_priority and not self.priority: throw(_("Priority is mandatory"), frappe.MandatoryError, _("Please Set Priority")) diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index 123c17f9b75..b5b464b05d9 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -336,6 +336,31 @@ class TestPricingRule(FrappeTestCase): details = get_item_details(args) self.assertEqual(details.get("discount_percentage"), 17.5) + def test_pricing_rule_with_template_and_its_variant(self): + if not frappe.db.exists("Item", "Test Variant PRT"): + variant = frappe.new_doc("Item") + variant.item_code = "Test Variant PRT" + variant.item_name = "Test Variant PRT" + variant.item_group = "_Test Item Group" + variant.is_stock_item = 1 + variant.variant_of = "_Test Variant Item" + variant.stock_uom = "_Test UOM" + variant.append("attributes", {"attribute": "Test Size", "attribute_value": "Medium"}) + variant.insert() + + rule = frappe.new_doc("Pricing Rule") + rule.title = "_Test Pricing Rule Template Variant" + rule.apply_on = "Item Code" + rule.currency = "USD" + rule.selling = 1 + rule.rate_or_discount = "Discount Percentage" + rule.discount_percentage = 10 + rule.company = "_Test Company" + rule.append("items", {"item_code": "_Test Variant Item"}) + rule.append("items", {"item_code": "Test Variant PRT"}) + + self.assertRaises(frappe.ValidationError, rule.insert) + def test_pricing_rule_for_stock_qty(self): test_record = { "doctype": "Pricing Rule", diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index 096b085cf0e..6315560b89f 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -86,50 +86,55 @@ class ProcessPeriodClosingVoucher(Document): cancel_pcv_processing(self.name) +def initialize_parallel_threads(docname: str): + threads = 4 + timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 + ppcvd = qb.DocType("Process Period Closing Voucher Detail") + + frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running") + + if normal_balances := ( + qb.from_(ppcvd) + .select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield) + .where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued")) + .orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date) + .limit(threads) + .for_update(skip_locked=True) + .run(as_dict=True) + ): + if not is_scheduler_inactive(): + for x in normal_balances: + frappe.db.set_value( + "Process Period Closing Voucher Detail", + x.name, + "status", + "Running", + ) + frappe.enqueue( + method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", + queue="long", + timeout=timeout, + is_async=True, + enqueue_after_commit=True, + docname=docname, + row_name=x.name, + date=x.processing_date, + report_type=x.report_type, + parentfield=x.parentfield, + ) + # keep transaction on PPCV and PPCVD short + # prevents concurrency errors - REPEATABLE READ + if not frappe.in_test: + frappe.db.commit() # nosemgrep + else: + frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed") + + @frappe.whitelist() def start_pcv_processing(docname: str): if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]: - frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True) - frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running") - - timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 - - ppcvd = qb.DocType("Process Period Closing Voucher Detail") - if normal_balances := ( - qb.from_(ppcvd) - .select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield) - .where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued")) - .orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date) - .limit(4) - .for_update(skip_locked=True) - .run(as_dict=True) - ): - if not is_scheduler_inactive(): - for x in normal_balances: - frappe.db.set_value( - "Process Period Closing Voucher Detail", - { - "processing_date": x.processing_date, - "parent": docname, - "report_type": x.report_type, - "parentfield": x.parentfield, - }, - "status", - "Running", - ) - frappe.enqueue( - method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", - queue="long", - timeout=timeout, - is_async=True, - enqueue_after_commit=True, - docname=docname, - date=x.processing_date, - report_type=x.report_type, - parentfield=x.parentfield, - ) - else: - frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed") + frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True) + initialize_parallel_threads(docname) @frappe.whitelist() @@ -247,11 +252,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions): @frappe.whitelist() def schedule_next_date(docname: str): timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 - ppcvd = qb.DocType("Process Period Closing Voucher Detail") + if to_process := ( qb.from_(ppcvd) - .select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield) + .select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield) .where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued")) .orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date) .limit(1) @@ -261,15 +266,15 @@ def schedule_next_date(docname: str): if not is_scheduler_inactive(): frappe.db.set_value( "Process Period Closing Voucher Detail", - { - "processing_date": to_process[0].processing_date, - "parent": docname, - "report_type": to_process[0].report_type, - "parentfield": to_process[0].parentfield, - }, + to_process[0].name, "status", "Running", ) + # keep transaction on PPCV and PPCVD short + # prevents concurrency errors - REPEATABLE READ + if not frappe.in_test: + frappe.db.commit() # nosemgrep + frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", queue="long", @@ -277,6 +282,7 @@ def schedule_next_date(docname: str): is_async=True, enqueue_after_commit=True, docname=docname, + row_name=to_process[0].name, date=to_process[0].processing_date, report_type=to_process[0].report_type, parentfield=to_process[0].parentfield, @@ -441,6 +447,11 @@ def summarize_and_post_ledger_entries(docname): make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date) + # keep transaction on PPCV and PPCVD short + # prevents concurrency errors - REPEATABLE READ + if not frappe.in_test: + frappe.db.commit() # nosemgrep + frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed") frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed") @@ -526,10 +537,10 @@ def build_dimension_wise_balance_dict(gl_entries): return dimension_balances -def process_individual_date(docname: str, date, report_type, parentfield): +def process_individual_date(docname: str, row_name, date, report_type, parentfield): current_date_status = frappe.db.get_value( "Process Period Closing Voucher Detail", - {"processing_date": date, "report_type": report_type, "parentfield": parentfield}, + row_name, "status", ) if current_date_status != "Running": @@ -576,17 +587,20 @@ def process_individual_date(docname: str, date, report_type, parentfield): # save results frappe.db.set_value( "Process Period Closing Voucher Detail", - {"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield}, + row_name, "closing_balance", frappe.json.dumps(res), ) frappe.db.set_value( "Process Period Closing Voucher Detail", - {"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield}, + row_name, "status", "Completed", ) + # commit heavy computation before touching PPCV or PPCVD + if not frappe.in_test: + frappe.db.commit() # nosemgrep # chain call schedule_next_date(docname) diff --git a/erpnext/accounts/doctype/process_period_closing_voucher_detail/process_period_closing_voucher_detail.py b/erpnext/accounts/doctype/process_period_closing_voucher_detail/process_period_closing_voucher_detail.py index f3a8302ac5b..0e0b905c96a 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher_detail/process_period_closing_voucher_detail.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher_detail/process_period_closing_voucher_detail.py @@ -1,7 +1,7 @@ # Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document @@ -24,3 +24,10 @@ class ProcessPeriodClosingVoucherDetail(Document): # end: auto-generated types pass + + +def on_doctype_update(): + frappe.db.add_index( + "Process Period Closing Voucher Detail", + ["parent", "status", "parentfield", "idx", "processing_date"], + ) diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html index cd1e357e3bc..c60de4c29c7 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.html @@ -13,7 +13,7 @@ {% endif %} -

{{ _("GENERAL LEDGER") }}

+

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

{% if filters.party[0] == filters.party_name[0] %}
{{ _("Customer: ") }} {{ filters.party_name[0] }}
diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index 48349a4bd99..9f0680de3ee 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -439,6 +439,8 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr when Is Billing Contact checked and Primary email- email with Is Primary checked""" + frappe.has_permission("Customer", "read", customer_name, throw=True) + billing_email = frappe.db.sql( """ SELECT @@ -482,6 +484,7 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr @frappe.whitelist() def download_statements(document_name): doc = frappe.get_doc("Process Statement Of Accounts", document_name) + doc.check_permission("read") report = get_report_pdf(doc) if report: frappe.local.response.filename = doc.name + ".pdf" diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index bccd29c822a..5aa2faed1a1 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -2924,6 +2924,24 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): # Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail self.assertRaises(frappe.ValidationError, pi.submit) + @change_settings("Accounts Settings", {"over_billing_allowance": 0}) + def test_non_stock_item_over_billing_against_po_is_blocked(self): + service_item = create_item( + "_Test Service Item Non Stock PI", + is_stock_item=0, + is_purchase_item=1, + ).name + + po = create_purchase_order(item_code=service_item, qty=5, rate=100, do_not_save=False) + po.submit() + + pi = make_pi_from_po(po.name) + pi.items[0].qty = 10 # overbill by 100 % + pi.save() + + with self.assertRaises(frappe.ValidationError): + pi.submit() + def test_discount_percentage_not_set_when_amount_is_manually_set(self): pi = make_purchase_invoice(do_not_save=True) discount_amount = 7 diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 6f51e27f532..0b1f1e922bf 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -3700,6 +3700,51 @@ class TestSalesInvoice(FrappeTestCase): self.assertTrue("cannot overbill" in str(err.exception).lower()) dn.cancel() + @change_settings("Accounts Settings", {"over_billing_allowance": 0}) + def test_non_stock_item_over_billing_against_so_is_blocked(self): + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + service_item = create_item( + "_Test Service Item Non Stock SI", + is_stock_item=0, + ).name + + so = make_sales_order(item_code=service_item, qty=5, rate=100) + so.submit() + + si = make_si_from_so(so.name) + si.items[0].qty = 10 # overbill by 100 % + si.save() + + with self.assertRaises(frappe.ValidationError): + si.submit() + + @change_settings("Accounts Settings", {"over_billing_allowance": 0}) + def test_non_stock_item_over_billing_against_so_from_quotation_is_blocked(self): + from erpnext.selling.doctype.quotation.quotation import make_sales_order as make_so_from_quotation + from erpnext.selling.doctype.quotation.test_quotation import make_quotation + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so + + service_item = create_item( + "_Test Service Item Non Stock SI Quot", + is_stock_item=0, + ).name + + quotation = make_quotation(item_code=service_item, qty=5, rate=100) + + so = make_so_from_quotation(quotation.name) + so.delivery_date = frappe.utils.add_days(frappe.utils.today(), 7) + so.insert() + so.submit() + + si = make_si_from_so(so.name) + si.items[0].qty = 10 # overbill by 100 % + si.save() + + with self.assertRaises(frappe.ValidationError): + si.submit() + @change_settings( "Accounts Settings", { diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 599173c99f5..a1cabc1f3a5 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -697,13 +697,15 @@ def make_reverse_gl_entries( partial_cancel=partial_cancel, ) validate_accounting_period(gl_entries) - check_freezing_date(gl_entries[0]["posting_date"], adv_adj) is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries) - # For reverse entries, use the posting_date parameter if provided and valid - # Otherwise fall back to original posting_date - validation_date = posting_date if posting_date else gl_entries[0]["posting_date"] + if immutable_ledger_enabled: + validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate() + else: + validation_date = posting_date if posting_date else gl_entries[0]["posting_date"] + + check_freezing_date(validation_date, adv_adj) validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"]) if partial_cancel: @@ -770,7 +772,7 @@ def make_reverse_gl_entries( if immutable_ledger_enabled: new_gle["is_cancelled"] = 0 - new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate() + new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate() elif posting_date: new_gle["posting_date"] = posting_date @@ -802,13 +804,24 @@ def check_freezing_date(posting_date, adv_adj=False): ) -def validate_against_pcv(is_opening, posting_date, company): - if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}): +def validate_opening_entry_against_pcv(company): + if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}): frappe.throw( - _("Opening Entry can not be created after Period Closing Voucher is created."), + _( + "A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more." + ).format( + '' + + _("Read the docs") + + "" + ), title=_("Invalid Opening Entry"), ) + +def validate_against_pcv(is_opening, posting_date, company): + if is_opening: + validate_opening_entry_against_pcv(company) + # Local import so you don't have to touch file-level imports from frappe.query_builder.functions import Max diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index b39c5a7dc62..2edba2e5c5d 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -431,6 +431,17 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals Will first search in party (Customer / Supplier) record, if not found, will search in group (Customer Group / Supplier Group), finally will return default.""" + + def account_perm_check(account): + ptype = "select" if frappe.only_has_select_perm("Account") else "read" + if frappe.has_permission("Account", ptype, account): + return + + # Using custom message to prevent data leak in case of `apply_strict_permission` is enabled. + frappe.throw( + _("User don't have permissions to select/read this account."), exc=frappe.PermissionError + ) + if not party_type: frappe.throw(_("Party Type is mandatory")) if not company: @@ -441,46 +452,51 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals "default_receivable_account" if party_type == "Customer" else "default_payable_account" ) - return frappe.get_cached_value("Company", company, default_account_name) - - account = frappe.db.get_value( - "Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account" - ) - - if not account and party_type in ["Customer", "Supplier"]: - party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group" - group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype)) + account = frappe.get_cached_value("Company", company, default_account_name) + else: account = frappe.db.get_value( - "Party Account", - {"parenttype": party_group_doctype, "parent": group, "company": company}, - "account", + "Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account" ) - if not account and party_type in ["Customer", "Supplier"]: - default_account_name = ( - "default_receivable_account" if party_type == "Customer" else "default_payable_account" - ) - account = frappe.get_cached_value("Company", company, default_account_name) + if not account and party_type in ["Customer", "Supplier"]: + party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group" + group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype)) + account = frappe.db.get_value( + "Party Account", + {"parenttype": party_group_doctype, "parent": group, "company": company}, + "account", + ) - existing_gle_currency = get_party_gle_currency(party_type, party, company) - if existing_gle_currency: - if account: - account_currency = frappe.get_cached_value("Account", account, "account_currency") - if (account and account_currency != existing_gle_currency) or not account: - account = get_party_gle_account(party_type, party, company) + if not account and party_type in ["Customer", "Supplier"]: + default_account_name = ( + "default_receivable_account" if party_type == "Customer" else "default_payable_account" + ) + account = frappe.get_cached_value("Company", company, default_account_name) - # get default account on the basis of party type - if not account: - account_type = frappe.get_cached_value("Party Type", party_type, "account_type") - default_account_name = "default_" + account_type.lower() + "_account" - account = frappe.get_cached_value("Company", company, default_account_name) + existing_gle_currency = get_party_gle_currency(party_type, party, company) + if existing_gle_currency: + if account: + account_currency = frappe.get_cached_value("Account", account, "account_currency") + if (account and account_currency != existing_gle_currency) or not account: + account = get_party_gle_account(party_type, party, company) - if include_advance and party_type in ["Customer", "Supplier", "Student"]: + # get default account on the basis of party type + if not account: + account_type = frappe.get_cached_value("Party Type", party_type, "account_type") + default_account_name = "default_" + account_type.lower() + "_account" + account = frappe.get_cached_value("Company", company, default_account_name) + + if account: + account_perm_check(account) + + if include_advance and party and party_type in ["Customer", "Supplier", "Student"]: advance_account = get_party_advance_account(party_type, party, company) + if advance_account: + account_perm_check(advance_account) return [account, advance_account] - else: - return [account] + + return [account] return account diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index e83311647b2..42b3991194b 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -263,10 +263,12 @@ class ReceivablePayableReport: # Build and use a separate row for Employee Advances. # This allows Payments or Journals made against Emp Advance to be processed. - if ( - not row - and ple.against_voucher_type == "Employee Advance" - and self.filters.handle_employee_advances + if not row and ( + (ple.against_voucher_type == "Employee Advance" and self.filters.handle_employee_advances) + or ( + ple.against_voucher_type == "Exchange Rate Revaluation" + and self.filters.for_revaluation_journals + ) ): _d = self.build_voucher_dict(ple) _d.voucher_type = ple.against_voucher_type diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 21f999197c2..8ecfe51d244 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -562,7 +562,12 @@ class GrossProfitGenerator: row.base_amount = packed_item.base_amount # get buying amount - if row.item_code in product_bundles: + if row.is_debit_note: + # Rate adjustment debit notes have no stock movement, so buying amount is zero + if not grouped_by_invoice: + row.qty = 0 + row.buying_amount = 0 + elif row.item_code in product_bundles: row.buying_amount = flt( self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]), self.currency_precision, @@ -925,6 +930,7 @@ class GrossProfitGenerator: SalesInvoice.customer_group, SalesInvoice.customer_name, SalesInvoice.territory, + SalesInvoice.is_debit_note, SalesInvoiceItem.item_code, SalesInvoice.base_net_total.as_("invoice_base_net_total"), SalesInvoiceItem.item_name, @@ -1104,6 +1110,7 @@ class GrossProfitGenerator: "posting_time": row.posting_time, "project": row.project, "update_stock": row.update_stock, + "is_debit_note": row.is_debit_note, "customer": row.customer, "customer_group": row.customer_group, "customer_name": row.customer_name, @@ -1142,6 +1149,7 @@ class GrossProfitGenerator: "description": item.description, "warehouse": item.warehouse or row.warehouse, "update_stock": row.update_stock, + "is_debit_note": row.is_debit_note, "item_group": "", "brand": "", "dn_detail": row.dn_detail, diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index 9a0a9cc5174..d24d472710d 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -727,6 +727,160 @@ class TestGrossProfit(FrappeTestCase): self.assertEqual(total[7], 1000.0) self.assertEqual(total[8], 100.0) + def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None): + """Create a rate adjustment debit note with no stock movement.""" + dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True) + if item_code: + dn.items[0].item_code = item_code + dn.items[0].item_name = item_code + dn.is_debit_note = 1 + dn.return_against = against_invoice.name + dn.items[0].allow_zero_valuation_rate = 1 + return dn.save().submit() + + def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self): + """ + Rate adjustment debit note (is_debit_note=1) should show buying_amount=0 + since there is no stock movement. Gross profit equals the adjustment amount + and gross profit % equals 100%. + """ + make_stock_entry( + company=self.company, + item_code=self.item, + target=self.warehouse, + qty=1, + basic_rate=100, + ) + + sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True) + sinv.update_stock = 1 + sinv = sinv.save().submit() + + debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20) + + filters = frappe._dict( + company=self.company, + from_date=nowdate(), + to_date=nowdate(), + group_by="Invoice", + ) + + columns, data = execute(filters=filters) + + dn_item_rows = [ + x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0 + ] + self.assertEqual(len(dn_item_rows), 1) + + dn_row = dn_item_rows[0] + self.assertEqual(dn_row.buying_amount, 0.0) + self.assertEqual(dn_row.selling_amount, 20.0) + self.assertEqual(dn_row.gross_profit, 20.0) + self.assertEqual(dn_row["gross_profit_%"], 100.0) + + def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self): + """ + The original invoice's GP should be derived solely from its own selling + amount and COGS — the rate adjustment debit note must not alter it. + """ + make_stock_entry( + company=self.company, + item_code=self.item, + target=self.warehouse, + qty=1, + basic_rate=100, + ) + + sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True) + sinv.update_stock = 1 + sinv = sinv.save().submit() + + self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20) + + filters = frappe._dict( + company=self.company, + from_date=nowdate(), + to_date=nowdate(), + group_by="Invoice", + ) + + columns, data = execute(filters=filters) + + sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0] + self.assertEqual(len(sinv_item_rows), 1) + + sinv_row = sinv_item_rows[0] + self.assertEqual(sinv_row.selling_amount, 200.0) + self.assertEqual(sinv_row.buying_amount, 100.0) + self.assertEqual(sinv_row.gross_profit, 100.0) + self.assertEqual(sinv_row["gross_profit_%"], 50.0) + + def test_debit_note_qty_not_inflated_in_grouped_report(self): + """ + When grouped by Item Code, the debit note (qty=0) must not inflate + the group's qty or buying_amount. The selling amount and average + selling rate correctly reflect the rate adjustment. + """ + item = create_item("_Test Rate Adjustment Debit Note Item") + + make_stock_entry( + company=self.company, + item_code=item.item_code, + target=self.warehouse, + qty=1, + basic_rate=100, + ) + + sinv = create_sales_invoice( + qty=1, + rate=200, + company=self.company, + customer=self.customer, + item_code=item.item_code, + item_name=item.item_code, + cost_center=self.cost_center, + warehouse=self.warehouse, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + update_stock=1, + currency="INR", + income_account=self.income_account, + expense_account=self.expense_account, + ) + + self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code) + + filters = frappe._dict( + company=self.company, + from_date=nowdate(), + to_date=nowdate(), + group_by="Item Code", + ) + + columns, data = execute(filters=filters) + + # group_by="Item Code" column order: + # [item_code, item_name, brand, description, qty, base_rate, + # buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency] + item_row = next((row for row in data if row[0] == item.item_code), None) + self.assertIsNotNone(item_row) + + qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = ( + item_row[4], + item_row[5], + item_row[8], + item_row[7], + item_row[9], + item_row[10], + ) + + self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated + self.assertEqual(buying_amount, 100.0) # only original invoice COGS + self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment) + self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1 + self.assertEqual(gross_profit, 120.0) # 220 - 100 + self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100 + def make_sales_person(sales_person_name="_Test Sales Person"): if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}): diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py index fbe9d7fcf7d..6ffb23659c8 100644 --- a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py @@ -30,10 +30,7 @@ class BulkTransactionLog(Document): def load_from_db(self): log_detail = qb.DocType("Bulk Transaction Log Detail") - has_records = frappe.db.sql( - "select exists (select * from `tabBulk Transaction Log Detail` where date = %s);", - (self.name,), - )[0][0] + has_records = frappe.db.exists("Bulk Transaction Log Detail", {"date": self.name}) if not has_records: raise frappe.DoesNotExistError diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 0d3f13dde8c..365e481890f 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -141,6 +141,26 @@ class AccountsController(TransactionBase): if self.doctype in relevant_docs: self.set_payment_schedule() + def before_insert(self): + self.clear_clearance_date_on_amend() + + def clear_clearance_date_on_amend(self): + """Drop the bank reconciliation clearance date copied over while amending. + + The framework copies `no_copy` fields when amending, so a reconciled + voucher would carry a stale clearance date into its amendment even though + the linked bank transaction gets unreconciled on cancellation. + """ + if not self.get("amended_from"): + return + + if self.meta.has_field("clearance_date"): + self.clearance_date = None + + for payment in self.get("payments") or []: + if payment.meta.has_field("clearance_date"): + payment.clearance_date = None + def remove_bundle_for_non_stock_invoices(self): has_sabb = False if self.doctype in ("Sales Invoice", "Purchase Invoice") and not self.update_stock: diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index c2c620950af..a05ff7f3b7c 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -177,6 +177,68 @@ def update_variant_attribute_values(item_attribute): frappe.flags.attribute_values = None +def get_attribute_abbr_renames(item_attribute): + """Return the set of (current) attribute values whose abbreviation was renamed.""" + if item_attribute.numeric_values: + return set() + + db_value = item_attribute.get_doc_before_save() + if not db_value: + return set() + + old_abbrs = {d.name: d.abbr for d in db_value.item_attribute_values} + changed_values = set() + + for row in item_attribute.item_attribute_values: + if row.name in old_abbrs and old_abbrs[row.name] != row.abbr: + changed_values.add(row.attribute_value) + + return changed_values + + +def update_variant_item_codes_for_abbr_renames(item_attribute): + """Rebuild item_code/item_name of variant Items affected by a renamed Item Attribute abbreviation.""" + changed_values = get_attribute_abbr_renames(item_attribute) + if not changed_values: + return + + item_variant_table = frappe.qb.DocType("Item Variant Attribute") + variant_names = ( + frappe.qb.from_(item_variant_table) + .select(item_variant_table.parent) + .where(item_variant_table.attribute == item_attribute.name) + .where(item_variant_table.attribute_value.isin(list(changed_values))) + .distinct() + .run(pluck=True) + ) + + for variant_name in variant_names: + rename_variant_item_code(variant_name) + + +def rename_variant_item_code(variant_name): + """Recompute a variant's item_code/item_name from its template and current attribute abbreviations, + renaming the Item if it has changed.""" + variant = frappe.get_doc("Item", variant_name) + if not variant.variant_of: + return + + template = frappe.get_cached_doc("Item", variant.variant_of) + + new_code = frappe._dict({"item_code": None, "item_name": None, "attributes": variant.attributes}) + make_variant_item_code(template.item_code, template.item_name, new_code) + + if not new_code.item_code or new_code.item_code == variant.item_code: + return + + frappe.rename_doc("Item", variant.item_code, new_code.item_code) + + # Keep item_name in lockstep with item_code: both are derived from the same abbreviation, so + # item_name is always rebuilt here too, even if it had since been customized away from that pattern. + if new_code.item_name and new_code.item_name != variant.item_name: + frappe.db.set_value("Item", new_code.item_code, "item_name", new_code.item_name) + + def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True): allow_rename_attribute_value = frappe.db.get_single_value( "Item Variant Settings", "allow_rename_attribute_value" diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index e1e3ba3e84e..c58580739e3 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -143,7 +143,7 @@ def validate_returned_items(doc): ref.rate and flt(d.rate) > ref.rate and doc.doctype in ("Delivery Note", "Sales Invoice") - and get_valuation_method(ref.item_code) != "Moving Average" + and get_valuation_method(d.item_code) != "Moving Average" ): frappe.throw( _("Row # {0}: Rate cannot be greater than the rate used in {1} {2}").format( diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 21e6d3ea8b9..c695d17e80f 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -135,7 +135,7 @@ status_map = { ], [ "Partially Ordered", - "eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type != 'Material Transfer'", + "eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.per_received < 100 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']", ], ], "POS Opening Entry": [ @@ -275,6 +275,12 @@ class StatusUpdater(Document): item["idx"] = d.idx item["target_ref_field"] = args["target_ref_field"].replace("_", " ") + # skip qty over-allowance check for non-stock items + if "qty" in args.get("target_ref_field", "") and not frappe.get_cached_value( + "Item", item["item_code"], "is_stock_item" + ): + continue + # if not item[args['target_ref_field']]: # msgprint(_("Note: System will not check over-delivery and over-booking for Item {0} as quantity or amount is 0").format(item.item_code)) if args.get("no_allowance"): diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 32968952fc6..c48eb2bd620 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -263,6 +263,10 @@ class StockController(AccountsController): parent_details = self.get_parent_details_for_packed_items() for row in self.get(table_name): + item_code = row.get("rm_item_code") or row.get("item_code") + if not item_code or not self.is_serial_batch_item(item_code): + continue + if ( not via_landed_cost_voucher and row.serial_and_batch_bundle @@ -1490,6 +1494,9 @@ class StockController(AccountsController): "remarks": remarks, } + if project: + gl_entry.update({"project": project}) + if voucher_detail_no: gl_entry.update({"voucher_detail_no": voucher_detail_no}) diff --git a/erpnext/controllers/tests/test_website_list_for_contact.py b/erpnext/controllers/tests/test_website_list_for_contact.py new file mode 100644 index 00000000000..d62254d8a0c --- /dev/null +++ b/erpnext/controllers/tests/test_website_list_for_contact.py @@ -0,0 +1,36 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import json + +from frappe.tests.utils import FrappeTestCase + + +class TestWebsiteListForContact(FrappeTestCase): + def test_get_list_context_currency_symbols(self): + # get_list_context builds the enabled-currency symbol map via frappe.get_all (converted from + # raw SQL). Exercises that query and asserts a known enabled currency is present. + from erpnext.controllers.website_list_for_contact import get_list_context + + context = get_list_context() + + symbols = json.loads(context["currency_symbols"]) + self.assertIsInstance(symbols, dict) + self.assertIn("USD", symbols) + + def test_rfq_transaction_list_returns_supplier_rfq(self): + # rfq_transaction_list filters RFQs by the supplier (parties[0]) and uses SELECT DISTINCT with + # ORDER BY creation -- both must be valid on Postgres, and the supplier filter must compare to the + # party value (not a stray `party[0]` column reference). + from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation import ( + make_request_for_quotation, + ) + from erpnext.controllers.website_list_for_contact import rfq_transaction_list + + rfq = make_request_for_quotation() + supplier = rfq.suppliers[0].supplier + + rows = rfq_transaction_list( + "Request for Quotation Supplier", "Request for Quotation", [supplier], 0, 20 + ) + self.assertIn(rfq.name, [row.name for row in rows]) diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index f8e152f5299..28ff84c83fd 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -361,13 +361,24 @@ def based_wise_columns_query(based_on, trans): # based_on_cols, based_on_select, based_on_group_by, addl_tables if based_on == "Item": - based_on_details["based_on_cols"] = ["Item:Link/Item:120", "Item Name:Data:120"] + based_on_details["based_on_cols"] = [ + {"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"}, + {"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"}, + ] based_on_details["based_on_select"] = "t2.item_code, t2.item_name," based_on_details["based_on_group_by"] = "t2.item_code" based_on_details["addl_tables"] = "" elif based_on == "Item Group": - based_on_details["based_on_cols"] = ["Item Group:Link/Item Group:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Item Group"), + "fieldtype": "Link", + "options": "Item Group", + "width": 120, + "fieldname": "item_group", + } + ] based_on_details["based_on_select"] = "t2.item_group," based_on_details["based_on_group_by"] = "t2.item_group" based_on_details["addl_tables"] = "" @@ -375,32 +386,80 @@ def based_wise_columns_query(based_on, trans): elif based_on == "Customer": if trans == "Quotation": based_on_details["based_on_cols"] = [ - "Party:Link/Customer:120", - "Party Name:Data:120", - "Territory:Link/Territory:120", + { + "label": _("Party"), + "fieldtype": "Link", + "options": "Customer", + "width": 120, + "fieldname": "party", + }, + {"label": _("Party Name"), "fieldtype": "Data", "width": 120, "fieldname": "party_name"}, + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + }, ] based_on_details["based_on_select"] = "t1.party_name, t1.customer_name, t1.territory," else: based_on_details["based_on_cols"] = [ - "Customer:Link/Customer:120", - "Customer Name:Data:120", - "Territory:Link/Territory:120", + { + "label": _("Customer"), + "fieldtype": "Link", + "options": "Customer", + "width": 120, + "fieldname": "customer", + }, + { + "label": _("Customer Name"), + "fieldtype": "Data", + "width": 120, + "fieldname": "customer_name", + }, + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + }, ] based_on_details["based_on_select"] = "t1.customer, t1.customer_name, t1.territory," based_on_details["based_on_group_by"] = "t1.party_name" if trans == "Quotation" else "t1.customer" based_on_details["addl_tables"] = "" elif based_on == "Customer Group": - based_on_details["based_on_cols"] = ["Customer Group:Link/Customer Group"] + based_on_details["based_on_cols"] = [ + { + "label": _("Customer Group"), + "fieldtype": "Link", + "options": "Customer Group", + "fieldname": "customer_group", + } + ] based_on_details["based_on_select"] = "t1.customer_group," based_on_details["based_on_group_by"] = "t1.customer_group" based_on_details["addl_tables"] = "" elif based_on == "Supplier": based_on_details["based_on_cols"] = [ - "Supplier:Link/Supplier:120", - "Supplier Name:Data:120", - "Supplier Group:Link/Supplier Group:140", + { + "label": _("Supplier"), + "fieldtype": "Link", + "options": "Supplier", + "width": 120, + "fieldname": "supplier", + }, + {"label": _("Supplier Name"), "fieldtype": "Data", "width": 120, "fieldname": "supplier_name"}, + { + "label": _("Supplier Group"), + "fieldtype": "Link", + "options": "Supplier Group", + "width": 140, + "fieldname": "supplier_group", + }, ] based_on_details["based_on_select"] = "t1.supplier, t1.supplier_name, t3.supplier_group," based_on_details["based_on_group_by"] = "t1.supplier" @@ -408,26 +467,58 @@ def based_wise_columns_query(based_on, trans): based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name" elif based_on == "Supplier Group": - based_on_details["based_on_cols"] = ["Supplier Group:Link/Supplier Group:140"] + based_on_details["based_on_cols"] = [ + { + "label": _("Supplier Group"), + "fieldtype": "Link", + "options": "Supplier Group", + "width": 140, + "fieldname": "supplier_group", + } + ] based_on_details["based_on_select"] = "t3.supplier_group," based_on_details["based_on_group_by"] = "t3.supplier_group" based_on_details["addl_tables"] = ",`tabSupplier` t3" based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name" elif based_on == "Territory": - based_on_details["based_on_cols"] = ["Territory:Link/Territory:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Territory"), + "fieldtype": "Link", + "options": "Territory", + "width": 120, + "fieldname": "territory", + } + ] based_on_details["based_on_select"] = "t1.territory," based_on_details["based_on_group_by"] = "t1.territory" based_on_details["addl_tables"] = "" elif based_on == "Project": if trans in ["Sales Invoice", "Delivery Note", "Sales Order"]: - based_on_details["based_on_cols"] = ["Project:Link/Project:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Project"), + "fieldtype": "Link", + "options": "Project", + "width": 120, + "fieldname": "project", + } + ] based_on_details["based_on_select"] = "t1.project," based_on_details["based_on_group_by"] = "t1.project" based_on_details["addl_tables"] = "" elif trans in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]: - based_on_details["based_on_cols"] = ["Project:Link/Project:120"] + based_on_details["based_on_cols"] = [ + { + "label": _("Project"), + "fieldtype": "Link", + "options": "Project", + "width": 120, + "fieldname": "project", + } + ] based_on_details["based_on_select"] = "t2.project," based_on_details["based_on_group_by"] = "t2.project" based_on_details["addl_tables"] = "" @@ -435,7 +526,15 @@ def based_wise_columns_query(based_on, trans): frappe.throw(_("Project-wise data is not available for Quotation")) based_on_details["based_on_select"] += "t4.default_currency as currency," - based_on_details["based_on_cols"].append("Currency:Link/Currency:120") + based_on_details["based_on_cols"].append( + { + "label": _("Currency"), + "fieldtype": "Link", + "options": "Currency", + "width": 120, + "fieldname": "currency", + } + ) based_on_details["addl_tables"] += ", `tabCompany` t4" based_on_details["addl_tables_relational_cond"] = ( based_on_details.get("addl_tables_relational_cond", "") + " and t1.company = t4.name" @@ -446,6 +545,14 @@ def based_wise_columns_query(based_on, trans): def group_wise_column(group_by): if group_by: - return [group_by + ":Link/" + group_by + ":120"] + return [ + { + "label": _(group_by), + "fieldtype": "Link", + "options": group_by, + "width": 120, + "fieldname": frappe.scrub(group_by), + } + ] else: return [] diff --git a/erpnext/controllers/website_list_for_contact.py b/erpnext/controllers/website_list_for_contact.py index ea7b47bd487..a62fccc752c 100644 --- a/erpnext/controllers/website_list_for_contact.py +++ b/erpnext/controllers/website_list_for_contact.py @@ -181,9 +181,10 @@ def rfq_transaction_list(parties_doctype, doctype, parties, limit_start, limit_p party = frappe.qb.DocType(parties_doctype) data = ( frappe.qb.from_(party) - .select(party.parent.as_("name"), party.supplier) + # creation must be selected: Postgres requires SELECT DISTINCT order-by exprs in the select list + .select(party.parent.as_("name"), party.supplier, party.creation) .distinct() - .where((party.supplier == party[0]) & (party.docstatus == 1)) + .where((party.supplier == parties[0]) & (party.docstatus == 1)) .orderby(party.creation, order=frappe.qb.desc) .limit(limit_page_length) .offset(limit_start) diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.js b/erpnext/crm/doctype/crm_settings/crm_settings.js index 0fb695a3da4..ef71437be49 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.js +++ b/erpnext/crm/doctype/crm_settings/crm_settings.js @@ -2,6 +2,35 @@ // For license information, please see license.txt frappe.ui.form.on("CRM Settings", { - // refresh: function(frm) { - // } + refresh: function (frm) { + const flag = frm.events.calculate_visiblity_flag(frm); + + frm.set_df_property("allowed_users", "hidden", !flag); + frm.set_df_property("allowed_users", "reqd", flag); + }, + + enable_frappe_crm_data_synchronization: function (frm) { + const flag = frm.events.calculate_visiblity_flag(frm); + + if (flag) { + frappe.show_alert( + __("Allowed Users is required for data synchronization from remote Frappe CRM site.") + ); + } + + /* + make allowed_users field visible and mandatory if enable_frappe_crm_data_synchronization + is set and crm app is not installed. + */ + + frm.set_df_property("allowed_users", "hidden", !flag); + frm.set_df_property("allowed_users", "reqd", flag); + }, + + calculate_visiblity_flag: function (frm) { + const crm_sync_enabled = frm.doc.enable_frappe_crm_data_synchronization; + const is_crm_installed = cint(frappe.utils.get_installed_apps().includes("crm")); + + return crm_sync_enabled && !is_crm_installed; + }, }); diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.json b/erpnext/crm/doctype/crm_settings/crm_settings.json index 8822dd7ea02..3539da5b7cb 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.json +++ b/erpnext/crm/doctype/crm_settings/crm_settings.json @@ -120,9 +120,9 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;", "fieldname": "allowed_users", "fieldtype": "Table MultiSelect", + "hidden": 1, "label": "Allowed Users", "options": "Frappe CRM Allowed User", "permlevel": 1 @@ -139,7 +139,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-06-22 01:26:13.474915", + "modified": "2026-07-01 01:09:16.461470", "modified_by": "Administrator", "module": "CRM", "name": "CRM Settings", diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py index 04e5a402add..379c55ae5b3 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.py +++ b/erpnext/crm/doctype/crm_settings/crm_settings.py @@ -3,9 +3,11 @@ import frappe from frappe import _ -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields, delete_custom_fields +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.model.document import Document +from erpnext.crm.frappe_crm_api import is_crm_installed + class CRMSettings(Document): # begin: auto-generated types @@ -46,13 +48,16 @@ class CRMSettings(Document): ) def validate_allowed_users(self): - if self.enable_frappe_crm_data_synchronization and not self.allowed_users: + if self.enable_frappe_crm_data_synchronization and not (is_crm_installed() or self.allowed_users): frappe.throw( _( "Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site." ) ) + if self.enable_frappe_crm_data_synchronization and is_crm_installed() and self.allowed_users: + frappe.throw(_("Allowed Users is not required as Frappe CRM is already installed on the site.")) + def before_save(self): self.clear_allowed_users() diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 94e99a612e8..42b82395719 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -438,6 +438,7 @@ def get_lead_details(lead, posting_date=None, company=None, doctype=None): out = frappe._dict() lead_doc = frappe.get_doc("Lead", lead) + lead_doc.check_permission() lead = lead_doc out.update( diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index 5db9b7dc652..ddd974663dc 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -1,5 +1,6 @@ import json +import click import frappe from frappe import _ @@ -150,7 +151,9 @@ def create_customer(customer_data=None): for field in CUSTOMER_ALLOWED_FIELDS: if customer_data.get(field) is not None: customer.set(field, customer_data.get(field)) - customer.insert(ignore_permissions=True) + + # If CRM is installed on the site, User Permission cannot be ignored while saving Customer Records. + customer.insert(ignore_permissions=not is_crm_installed()) customer_name = customer.name contacts = json.loads(customer_data.get("contacts")) @@ -169,6 +172,10 @@ def validate_frappe_crm_sync(): _("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.") ) + # Skip allowed_users validation if CRM is installed on the site. + if is_crm_installed(): + return + allowed_users = [d.user for d in CRMSettings.allowed_users] if frappe.session.user not in allowed_users: @@ -178,3 +185,35 @@ def validate_frappe_crm_sync(): ), exc=frappe.PermissionError, ) + + +def is_crm_installed(): + return "crm" in frappe.get_installed_apps() + + +def remove_allowed_users_on_crm_install(): + try: + CRMSettings = frappe.get_single("CRM Settings") + + if not CRMSettings.enable_frappe_crm_data_synchronization: + return + + CRMSettings.allowed_users = [] + CRMSettings.save() + click.secho("Removed 'Allowed Users' from CRM Settings.") + except Exception: + click.secho("'Allowed Users' from CRM Settings couldn't be cleared.") + + +def disable_frappe_crm_data_synchronization_on_crm_uninstall(): + try: + CRMSettings = frappe.get_single("CRM Settings") + + if not CRMSettings.enable_frappe_crm_data_synchronization: + return + + CRMSettings.enable_frappe_crm_data_synchronization = 0 + CRMSettings.save() + click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings has been disabled.") + except Exception: + click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings could not be disabled.") diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py index 8e6574bde4d..06d97b5173f 100644 --- a/erpnext/crm/utils.py +++ b/erpnext/crm/utils.py @@ -166,6 +166,7 @@ def get_open_todos(ref_doctype, ref_docname): "allocated_to", "date", ], + order_by="date asc", ) @@ -190,6 +191,7 @@ def get_open_events(ref_doctype, ref_docname): & (event_link.reference_docname == ref_docname) & (event.status == "Open") ) + .orderby(event.starts_on) ) data = query.run(as_dict=True) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index a1c64b60377..118f047f19c 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -61,6 +61,9 @@ before_install = [ ] after_install = "erpnext.setup.install.after_install" +after_app_install = "erpnext.setup.install.after_app_install" +after_app_uninstall = "erpnext.setup.install.after_app_uninstall" + boot_session = "erpnext.startup.boot.boot_session" notification_config = "erpnext.startup.notifications.get_notification_config" get_help_messages = "erpnext.utilities.activation.get_help_messages" diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index e83f7e232ad..88a6c3bad66 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -441,7 +441,11 @@ frappe.ui.form.on("BOM", { }, routing(frm) { - if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) { + // Refetch operations whenever the routing is (re)selected, so that + // changing the routing - e.g. on a new BOM version copied from another + // BOM - replaces the operations with those of the newly selected routing + // instead of keeping the old ones. + if (frm.doc.routing && frm.doc.with_operations) { frappe.call({ doc: frm.doc, method: "get_routing", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 67323d42d40..cb8f24fc9f1 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -9,6 +9,7 @@ from collections import defaultdict import frappe from frappe import _, msgprint from frappe.model.document import Document +from frappe.query_builder import Case from frappe.query_builder.functions import IfNull, Sum from frappe.utils import ( add_days, @@ -1375,7 +1376,7 @@ def get_material_request_items( get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0 ) - if required_qty > 0: + if flt(row.get("qty")) > 0: return { "item_code": row.item_code, "item_name": row.item_name, @@ -1880,7 +1881,12 @@ def get_reserved_qty_for_production_plan(item_code, warehouse): frappe.qb.from_(table) .inner_join(child) .on(table.name == child.parent) - .select(Sum(child.quantity * child.conversion_factor)) + .select( + Sum( + (Case().when(child.quantity == 0, child.required_bom_qty).else_(child.quantity)) + * child.conversion_factor + ) + ) .where( (table.docstatus == 1) & (child.item_code == item_code) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 5af1fdb36b5..e64a8c16b84 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -212,13 +212,15 @@ class TestProductionPlan(FrappeTestCase): quantities = [d["quantity"] for d in mr_items] rm_qty = sum(quantities) - # Only 2 MR item created - the first SO's requirement is fully covered by stock (v15 behaviour) - self.assertEqual(len(mr_items), 2) - self.assertEqual(rm_qty, 2, "Cascading failed: total MR qty should be 2 (3 needed - 1 in stock)") + # 3 MR items: SO1's requirement is covered by stock (qty=0 but reserved), SO2 and SO3 need 1 each + self.assertEqual(len(mr_items), 3) + self.assertEqual( + rm_qty, 2, "Cascading failed: total purchase qty should be 2 (3 needed - 1 in stock)" + ) self.assertEqual( quantities, - [1, 1], - "Cascading failed: only second and third SO should need procurement (qty=1) since first SO consumed stock", + [0, 1, 1], + "SO1 stock-covered item should appear with qty=0 for reservation; SO2 and SO3 need qty=1", ) sr.cancel() @@ -251,11 +253,13 @@ class TestProductionPlan(FrappeTestCase): pln = create_production_plan( item_code="Test Production Item 1", use_multi_level_bom=0, ignore_existing_ordered_qty=0 ) - self.assertFalse(len(pln.mr_items)) + items_needing_purchase = [row.item_code for row in pln.mr_items if row.quantity > 0] + self.assertFalse(len(items_needing_purchase)) + + pln.cancel() sr1.cancel() sr2.cancel() - pln.cancel() def test_production_plan_sales_orders(self): "Test if previously fulfilled SO (with WO) is pulled into Prod Plan." diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 2679d6e29fe..0d421ed4a63 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -687,6 +687,28 @@ class TestWorkOrder(FrappeTestCase): ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty) self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC") + @change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0}) + def test_cost_center_for_manufacture_falls_back_to_item_group_default(self): + # "_Test Item Group" is master data with buying_cost_center already set to + # "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its + # BOM need to be created, since no existing item in that group has one. + fg_item = make_item( + "_Test FG Item For Item Group Cost Center", + {"is_stock_item": 1, "item_group": "_Test Item Group", "include_item_in_manufacturing": 1}, + ) + + if not frappe.db.exists("BOM", {"item": fg_item.name, "is_active": 1, "is_default": 1}): + make_bom(item=fg_item.name, raw_materials=["_Test Item"]) + + wo_order = make_wo_order_test_record( + production_item=fg_item.name, skip_transfer=1, source_warehouse="_Test Warehouse - _TC" + ) + ste = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", wo_order.qty)) + ste.insert() + + fg_row = next(d for d in ste.items if d.is_finished_item) + self.assertEqual(fg_row.cost_center, "_Test Cost Center 2 - _TC") + def test_operation_time_with_batch_size(self): fg_item = "Test Batch Size Item For BOM" rm1 = "Test Batch Size Item RM 1 For BOM" @@ -1523,6 +1545,38 @@ class TestWorkOrder(FrappeTestCase): work_order.reload() self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0) + def test_status_in_process_when_only_one_required_item_transferred(self): + """Stock Entry created from a Pick List that picked only one of the required items: + min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must + still move to In Process because material is already in WIP.""" + from erpnext.manufacturing.doctype.work_order.work_order import create_pick_list + from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry + + work_order = make_wo_order_test_record( + planned_start_date=now(), qty=2, source_warehouse="Stores - _TC" + ) + test_stock_entry.make_stock_entry( + item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0 + ) + test_stock_entry.make_stock_entry( + item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0 + ) + + pick_list = create_pick_list(work_order.name, for_qty=work_order.qty) + # pick only _Test Item; the other required item is left out of this pick list + pick_list.pick_manually = 1 + pick_list.locations = [loc for loc in pick_list.locations if loc.item_code == "_Test Item"] + pick_list.save() + pick_list.submit() + + stock_entry = frappe.get_doc(create_stock_entry(frappe.as_json(pick_list.as_dict()))) + self.assertEqual(stock_entry.fg_completed_qty, 0.0) + stock_entry.submit() + + work_order.reload() + self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0) + self.assertEqual(work_order.status, "In Process") + def test_backflushed_batch_raw_materials_based_on_transferred(self): frappe.db.set_single_value( "Manufacturing Settings", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index d6764005a80..dd5c07c2945 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -157,6 +157,7 @@ class WorkOrder(Document): self.check_wip_warehouse_skip() self.calculate_operating_cost() self.validate_qty() + self.validate_dates() self.validate_transfer_against() self.validate_operations() self.status = self.get_status() @@ -175,6 +176,11 @@ class WorkOrder(Document): self.validate_operations_sequence() + def validate_dates(self): + if self.planned_start_date and self.planned_end_date: + if get_datetime(self.planned_end_date) < get_datetime(self.planned_start_date): + frappe.throw(_("Planned End Date cannot be before Planned Start Date")) + def validate_operations_sequence(self): if all([not op.sequence_id for op in self.operations]): for op in self.operations: @@ -406,7 +412,11 @@ class WorkOrder(Document): elif self.docstatus == 1: if status not in ["Closed", "Stopped"]: status = "Not Started" - if flt(self.material_transferred_for_manufacturing) > 0 or self.skip_transfer: + if ( + flt(self.material_transferred_for_manufacturing) > 0 + or self.skip_transfer + or self.has_transferred_material() + ): status = "In Process" precision = frappe.get_precision("Work Order", "produced_qty") @@ -425,6 +435,26 @@ class WorkOrder(Document): return status + def has_transferred_material(self): + """True if any raw material was transferred against this work order via a pick list + (these leave material_transferred_for_manufacturing at 0 via the min-fraction rule).""" + ste = frappe.qb.DocType("Stock Entry") + ste_child = frappe.qb.DocType("Stock Entry Detail") + qty = ( + frappe.qb.from_(ste) + .inner_join(ste_child) + .on(ste_child.parent == ste.name) + .select(Sum(ste_child.transfer_qty)) + .where( + (ste.work_order == self.name) + & (ste.docstatus == 1) + & (ste.purpose == "Material Transfer for Manufacture") + & (ste.is_return == 0) + & (ste.pick_list.isnotnull()) + ) + ).run()[0][0] + return flt(qty) > 0 + def update_work_order_qty(self): """Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order based on Stock Entry""" diff --git a/erpnext/patches.txt b/erpnext/patches.txt index b48f16a7550..f28e3e1840f 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -262,7 +262,6 @@ execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Deta erpnext.patches.v14_0.update_proprietorship_to_individual erpnext.patches.v15_0.rename_subcontracting_fields erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage -erpnext.patches.v16_0.create_company_custom_fields [post_model_sync] erpnext.patches.v15_0.create_asset_depreciation_schedules_from_assets @@ -422,6 +421,7 @@ execute:frappe.db.set_single_value("Accounts Settings", "fetch_valuation_rate_fo erpnext.patches.v15_0.add_company_payment_gateway_account erpnext.patches.v15_0.update_uae_zero_rated_fetch erpnext.patches.v15_0.update_fieldname_in_accounting_dimension_filter +erpnext.patches.v16_0.create_company_custom_fields erpnext.patches.v15_0.set_asset_status_if_not_already_set erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing execute:frappe.db.set_single_value("Accounts Settings", "show_party_balance", 1) @@ -438,3 +438,6 @@ erpnext.patches.v16_0.migrate_address_contact_custom_fields erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600) +erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field +erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield +erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm \ No newline at end of file diff --git a/erpnext/patches/v15_0/backfill_sla_link_filters_on_custom_field.py b/erpnext/patches/v15_0/backfill_sla_link_filters_on_custom_field.py new file mode 100644 index 00000000000..65996f258d8 --- /dev/null +++ b/erpnext/patches/v15_0/backfill_sla_link_filters_on_custom_field.py @@ -0,0 +1,21 @@ +import frappe + + +def execute(): + for custom_field in frappe.get_all( + "Custom Field", + filters={ + "fieldname": "service_level_agreement", + "fieldtype": "Link", + "options": "Service Level Agreement", + "link_filters": ("is", "not set"), + }, + fields=["name", "dt"], + ): + link_filters = frappe.as_json( + [["Service Level Agreement", "document_type", "=", custom_field.dt]], indent=None + ) + frappe.db.set_value( + "Custom Field", custom_field.name, "link_filters", link_filters, update_modified=False + ) + frappe.clear_cache(doctype=custom_field.dt) diff --git a/erpnext/patches/v15_0/backfill_sla_link_filters_on_docfield.py b/erpnext/patches/v15_0/backfill_sla_link_filters_on_docfield.py new file mode 100644 index 00000000000..22110afc9ff --- /dev/null +++ b/erpnext/patches/v15_0/backfill_sla_link_filters_on_docfield.py @@ -0,0 +1,20 @@ +import frappe + + +def execute(): + for docfield in frappe.get_all( + "DocField", + filters={ + "parenttype": "DocType", + "fieldname": "service_level_agreement", + "fieldtype": "Link", + "options": "Service Level Agreement", + "link_filters": ("is", "not set"), + }, + fields=["name", "parent"], + ): + link_filters = frappe.as_json( + [["Service Level Agreement", "document_type", "=", docfield.parent]], indent=None + ) + frappe.db.set_value("DocField", docfield.name, "link_filters", link_filters, update_modified=False) + frappe.clear_cache(doctype=docfield.parent) diff --git a/erpnext/patches/v16_0/crm_settings_handle_allowed_users_for_frappe_crm.py b/erpnext/patches/v16_0/crm_settings_handle_allowed_users_for_frappe_crm.py new file mode 100644 index 00000000000..166cd5c66f8 --- /dev/null +++ b/erpnext/patches/v16_0/crm_settings_handle_allowed_users_for_frappe_crm.py @@ -0,0 +1,10 @@ +import frappe + + +def execute(): + from erpnext.crm.frappe_crm_api import is_crm_installed, remove_allowed_users_on_crm_install + + if not is_crm_installed(): + return + + remove_allowed_users_on_crm_install() diff --git a/erpnext/public/js/controllers/accounts.js b/erpnext/public/js/controllers/accounts.js index dec0f1c024d..1b1c45e38ea 100644 --- a/erpnext/public/js/controllers/accounts.js +++ b/erpnext/public/js/controllers/accounts.js @@ -16,13 +16,15 @@ erpnext.accounts.taxes = { } }); }, - onload: function(frm) { - if(frm.get_field("taxes")) { - frm.set_query("account_head", "taxes", function(doc) { - if(frm.cscript.tax_table == "Sales Taxes and Charges") { - var account_type = ["Tax", "Chargeable", "Expense Account"]; + onload: function (frm) { + if (frm.get_field("taxes")) { + frm.set_query("account_head", "taxes", function (doc) { + let account_type = ["Tax", "Chargeable"]; + + if (frm.cscript.tax_table == "Sales Taxes and Charges") { + account_type.push("Expense Account"); } else { - var account_type = ["Tax", "Chargeable", "Income Account", "Expenses Included In Valuation"]; + account_type.push("Income Account", "Expenses Included In Valuation"); } return { diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 0df4cabfb4f..1f091f3934d 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -498,7 +498,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } else if(tax.charge_type == "On Net Total") { if (tax.account_head in item_tax_map) { current_net_amount = item.net_amount - }; + } current_tax_amount = (tax_rate / 100.0) * item.net_amount; } else if(tax.charge_type == "On Previous Row Amount") { current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item @@ -862,12 +862,13 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if(["Sales Invoice", "POS Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) { let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total; + let total_amount_to_pay; if(this.frm.doc.party_account_currency == this.frm.doc.currency) { - var total_amount_to_pay = flt((grand_total - this.frm.doc.total_advance + total_amount_to_pay = flt((grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount), precision("grand_total")); } else { - var total_amount_to_pay = flt( + total_amount_to_pay = flt( (flt(base_grand_total, precision("base_grand_total")) - this.frm.doc.total_advance - this.frm.doc.base_write_off_amount), precision("base_grand_total") @@ -901,14 +902,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { async set_total_amount_to_default_mop() { let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total; + let total_amount_to_pay; if (this.frm.doc.party_account_currency == this.frm.doc.currency) { - var total_amount_to_pay = flt( + total_amount_to_pay = flt( grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount, precision("grand_total") ); } else { - var total_amount_to_pay = flt( + total_amount_to_pay = flt( ( flt( base_grand_total, diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 5fce7783c38..88dc01d5845 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1007,13 +1007,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe var set_party_account = function(set_pricing) { if (["Sales Invoice", "Purchase Invoice"].includes(me.frm.doc.doctype)) { - if(me.frm.doc.doctype=="Sales Invoice") { - var party_type = "Customer"; - var party_account_field = 'debit_to'; - } else { - var party_type = "Supplier"; - var party_account_field = 'credit_to'; - } + let party_type = me.frm.doc.doctype == "Sales Invoice" ? "Customer" : "Supplier"; + let party_account_field = me.frm.doc.doctype == "Sales Invoice" ? "debit_to" : "credit_to"; var party = me.frm.doc[frappe.model.scrub(party_type)]; if(party && me.frm.doc.company && (!me.frm.doc.__onload?.load_after_mapping || !me.frm.doc[party_account_field])) { @@ -1427,7 +1422,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe let first_row = this.frm.doc.items[0]; if (!first_row) { return false - }; + } let mapped_rows = mappped_fields.filter(d => first_row[d]) @@ -1599,7 +1594,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe this.frm.set_currency_labels(["operating_cost", "hour_rate"], this.frm.doc.currency, "operations"); this.frm.set_currency_labels(["base_operating_cost", "base_hour_rate"], company_currency, "operations"); - var item_grid = this.frm.fields_dict["operations"].grid; + let item_grid = this.frm.fields_dict["operations"].grid; $.each(["base_operating_cost", "base_hour_rate"], function(i, fname) { if(frappe.meta.get_docfield(item_grid.doctype, fname)) item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency); @@ -1610,7 +1605,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "scrap_items"); this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "scrap_items"); - var item_grid = this.frm.fields_dict["scrap_items"].grid; + let item_grid = this.frm.fields_dict["scrap_items"].grid; $.each(["base_rate", "base_amount"], function(i, fname) { if(frappe.meta.get_docfield(item_grid.doctype, fname)) item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency); @@ -2005,7 +2000,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe row_to_modify[key] = pr_row[key]; } - if (this.frm.doc.hasOwnProperty("is_pos") && this.frm.doc.is_pos) { + if (Object.prototype.hasOwnProperty.call(this.frm.doc, "is_pos") && this.frm.doc.is_pos) { let r = await frappe.db.get_value("POS Profile", this.frm.doc.pos_profile, "cost_center"); if (r.message.cost_center) { row_to_modify["cost_center"] = r.message.cost_center; @@ -2237,8 +2232,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }, callback: function(r) { if (!r.exc) { - $.each(me.frm.doc.items || [], function(i, item) { - if (item.name && r.message.hasOwnProperty(item.name) && r.message[item.name].item_tax_template) { + $.each(me.frm.doc.items || [], function (i, item) { + if ( + item.name && + Object.prototype.hasOwnProperty.call(r.message, item.name) && + r.message[item.name].item_tax_template + ) { item.item_tax_template = r.message[item.name].item_tax_template; item.item_tax_rate = r.message[item.name].item_tax_rate; me.add_taxes_from_item_tax_template(item.item_tax_rate); diff --git a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py index 23ed83cca84..405159215cd 100644 --- a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py +++ b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py @@ -4,7 +4,7 @@ import frappe from frappe import _, msgprint, qb -from frappe.query_builder import Case, Criterion +from frappe.query_builder import Criterion from erpnext import get_company_currency @@ -155,60 +155,50 @@ def get_columns(filters): def get_entries(filters): - doc_type = filters["doc_type"] + date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date" + if filters["doc_type"] == "Sales Order": + qty_field = "delivered_qty" + else: + qty_field = "qty" + conditions, values = get_conditions(filters, date_field) - date_field = "transaction_date" if doc_type == "Sales Order" else "posting_date" - qty_field = "delivered_qty" if doc_type == "Sales Order" else "qty" - - dt = frappe.qb.DocType(doc_type) - dt_item = frappe.qb.DocType(f"{doc_type} Item") - st = frappe.qb.DocType("Sales Team") - - calc_qty = dt_item[qty_field] * dt_item.conversion_factor - calc_net_amount = dt_item.base_net_rate * calc_qty - - stock_qty_case = Case().when(dt.status == "Closed", calc_qty).else_(dt_item.stock_qty).as_("stock_qty") - - base_net_amount_case = ( - Case() - .when(dt.status == "Closed", calc_net_amount) - .else_(dt_item.base_net_amount) - .as_("base_net_amount") + entries = frappe.db.sql( + """ + SELECT + dt.name, dt.customer, dt.territory, dt.{} as posting_date, dt_item.item_code, + st.sales_person, st.allocated_percentage, dt_item.warehouse, + CASE + WHEN dt.status = "Closed" THEN dt_item.{} * dt_item.conversion_factor + ELSE dt_item.stock_qty + END as stock_qty, + CASE + WHEN dt.status = "Closed" THEN (dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) + ELSE dt_item.base_net_amount + END as base_net_amount, + CASE + WHEN dt.status = "Closed" THEN ((dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) * st.allocated_percentage/100) + ELSE dt_item.base_net_amount * st.allocated_percentage/100 + END as contribution_amt + FROM + `tab{}` dt, `tab{} Item` dt_item, `tabSales Team` st + WHERE + st.parent = dt.name and dt.name = dt_item.parent and st.parenttype = {} + and dt.docstatus = 1 {} order by st.sales_person, dt.name desc + """.format( + date_field, + qty_field, + qty_field, + qty_field, + filters["doc_type"], + filters["doc_type"], + "%s", + conditions, + ), + tuple([filters["doc_type"], *values]), + as_dict=1, ) - contribution_amt_case = ( - Case() - .when(dt.status == "Closed", (calc_net_amount * st.allocated_percentage / 100)) - .else_(dt_item.base_net_amount * st.allocated_percentage / 100) - .as_("contribution_amt") - ) - - query = ( - frappe.get_query(dt, filters=filters, ignore_permissions=False) - .join(dt_item) - .on(dt.name == dt_item.parent) - .join(st) - .on(dt.name == st.parent) - .select( - dt.name, - dt.customer, - dt.territory, - dt[date_field].as_("posting_date"), - dt_item.item_code, - st.sales_person, - st.allocated_percentage, - dt_item.warehouse, - stock_qty_case, - base_net_amount_case, - contribution_amt_case, - ) - .where(st.parenttype == doc_type) - .where(dt.docstatus == 1) - ) - - query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc) - - return query.run(as_dict=True) + return entries def get_conditions(filters, date_field): diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json index fc6533a1e89..380320f0399 100644 --- a/erpnext/setup/doctype/company/company.json +++ b/erpnext/setup/doctype/company/company.json @@ -330,33 +330,48 @@ "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "round_off_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Round Off Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "round_off_cost_center", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Round Off Cost Center", + "no_copy": 1, "options": "Cost Center" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "write_off_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Write Off Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "exchange_gain_loss_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Exchange Gain / Loss Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "unrealized_exchange_gain_loss_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Unrealized Exchange Gain/Loss Account", + "no_copy": 1, "options": "Account" }, { @@ -482,6 +497,7 @@ "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "expenses_included_in_valuation", "fieldtype": "Link", "ignore_user_permissions": 1, @@ -490,15 +506,19 @@ "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "accumulated_depreciation_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Accumulated Depreciation Account", "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "depreciation_expense_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Depreciation Expense Account", "no_copy": 1, "options": "Account" @@ -519,29 +539,39 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "disposal_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Gain/Loss Account on Asset Disposal", "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "depreciation_cost_center", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Asset Depreciation Cost Center", "no_copy": 1, "options": "Cost Center" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "capital_work_in_progress_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Capital Work In Progress Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "asset_received_but_not_billed", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Asset Received But Not Billed", + "no_copy": 1, "options": "Account" }, { @@ -673,15 +703,21 @@ "options": "Warehouse" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "unrealized_profit_loss_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Unrealized Profit / Loss Account", + "no_copy": 1, "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "default_discount_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Payment Discount Account", + "no_copy": 1, "options": "Account" }, { @@ -723,8 +759,10 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account", "fieldname": "default_advance_received_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Advance Received Account", "mandatory_depends_on": "book_advance_payments_as_liability", + "no_copy": 1, "options": "Account" }, { @@ -733,8 +771,10 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account", "fieldname": "default_advance_paid_account", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Default Advance Paid Account", "mandatory_depends_on": "book_advance_payments_as_liability", + "no_copy": 1, "options": "Account" }, { @@ -814,9 +854,12 @@ "options": "Account" }, { + "depends_on": "eval:!doc.__islocal", "fieldname": "round_off_for_opening", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Round Off for Opening", + "no_copy": 1, "options": "Account" }, { @@ -865,7 +908,7 @@ "image_field": "company_logo", "is_tree": 1, "links": [], - "modified": "2025-11-16 16:51:27.624096", + "modified": "2026-07-02 07:21:21.794533", "modified_by": "Administrator", "module": "Setup", "name": "Company", diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 299ae82cb69..c53dfa5fb40 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -74,6 +74,7 @@ class Company(NestedSet): default_operating_cost_account: DF.Link | None default_payable_account: DF.Link | None default_provisional_account: DF.Link | None + default_purchase_price_variance_account: DF.Link | None default_receivable_account: DF.Link | None default_sales_contact: DF.Link | None default_selling_terms: DF.Link | None diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 03fc31b253e..89e2a4c89ee 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -367,3 +367,19 @@ DEFAULT_ROLE_PROFILES = { "Purchase Manager", ], } + + +def after_app_install(app_name=None): + if app_name == "crm": + from erpnext.crm.frappe_crm_api import remove_allowed_users_on_crm_install + + remove_allowed_users_on_crm_install() + + +def after_app_uninstall(app_name=None): + if app_name == "crm": + from erpnext.crm.frappe_crm_api import disable_frappe_crm_data_synchronization_on_crm_uninstall + + disable_frappe_crm_data_synchronization_on_crm_uninstall() + + frappe.db.commit() # nosemgrep diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 0ad8bc781a5..49aa06ddd79 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -9,6 +9,7 @@ from frappe import _ from frappe.contacts.doctype.address.address import get_company_address from frappe.contacts.doctype.contact.contact import get_default_contact from frappe.desk.notifications import clear_doctype_notifications +from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values from frappe.query_builder import DocType @@ -441,22 +442,34 @@ class DeliveryNote(SellingController): frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"])) def update_current_stock(self): - if self.get("_action") and self._action != "update_after_submit": - for d in self.get("items"): - d.actual_qty = frappe.db.get_value( - "Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty" - ) + if not (self.get("_action") and self._action != "update_after_submit"): + return - for d in self.get("packed_items"): - bin_qty = frappe.db.get_value( - "Bin", - {"item_code": d.item_code, "warehouse": d.warehouse}, - ["actual_qty", "projected_qty"], - as_dict=True, - ) - if bin_qty: - d.actual_qty = flt(bin_qty.actual_qty) - d.projected_qty = flt(bin_qty.projected_qty) + warehouse_item_codes = {} + for d in self.get("items") + self.get("packed_items"): + warehouse_item_codes.setdefault(d.warehouse, set()).add(d.item_code) + + if not warehouse_item_codes: + return + + bin_map = {} + for warehouse, item_codes in warehouse_item_codes.items(): + for b in frappe.get_all( + "Bin", + filters={"item_code": ["in", item_codes], "warehouse": warehouse}, + fields=["item_code", "actual_qty", "projected_qty"], + ): + bin_map[(b.item_code, warehouse)] = b + + for d in self.get("items"): + bin_data = bin_map.get((d.item_code, d.warehouse)) + d.actual_qty = bin_data.actual_qty if bin_data else None + + for d in self.get("packed_items"): + bin_data = bin_map.get((d.item_code, d.warehouse)) + if bin_data: + d.actual_qty = flt(bin_data.actual_qty) + d.projected_qty = flt(bin_data.projected_qty) def on_submit(self): self.validate_packed_qty() @@ -910,7 +923,9 @@ def get_returned_qty_map(delivery_note): @frappe.whitelist() -def make_sales_invoice(source_name, target_doc=None, args=None): +def make_sales_invoice( + source_name: str, target_doc: Document | str | None = None, args: dict | str | None = None +): if args is None: args = {} if isinstance(args, str): @@ -1015,7 +1030,12 @@ def make_sales_invoice(source_name, target_doc=None, args=None): frappe.db.get_single_value("Accounts Settings", "automatically_fetch_payment_terms") ) - if not doc.is_return: + if doc.is_return: + # A credit note made from a return Delivery Note should roll back the billed + # amount on the linked Sales Order too, so that per_billed stays consistent with + # per_delivered (which the return already reset). + doc.update_billed_amount_in_sales_order = True + else: so, doctype, fieldname = doc.get_order_details() if ( doc.linked_order_has_payment_terms(so, fieldname, doctype) diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 90bae7f68f5..e77940b1661 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -2599,6 +2599,92 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(dn.per_returned, 100) self.assertEqual(returned.status, "Return") + def _assert_credit_note_from_return_dn_resets_per_billed(self, so, dn): + """Given a fully billed Sales Order and a submitted Delivery Note that delivers it, + a credit note made from the return of that Delivery Note must reset per_billed to 0 + while leaving the delivery quantities exactly as the return already set them.""" + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + + so.load_from_db() + self.assertEqual(so.per_delivered, 100) + self.assertEqual(so.per_billed, 100) + + return_dn = make_sales_return(dn.name) + return_dn.insert() + return_dn.submit() + + # the return reverses the delivery quantities + so.load_from_db() + self.assertEqual(so.per_delivered, 0) + self.assertEqual(so.items[0].delivered_qty, 0) + + credit_note = make_sales_invoice(return_dn.name) + self.assertTrue(credit_note.is_return) + self.assertTrue(credit_note.update_billed_amount_in_sales_order) + # A Delivery Note-linked invoice can't update stock (validate_delivery_note), so the + # credit note only rolls back billing and never re-reverses the delivery quantities. + self.assertFalse(credit_note.update_stock) + credit_note.insert() + credit_note.submit() + + # per_billed is reset, and the delivery state stays exactly as the return left it + so.load_from_db() + self.assertEqual(so.per_billed, 0) + self.assertEqual(so.per_delivered, 0) + self.assertEqual(so.items[0].delivered_qty, 0) + self.assertEqual(so.items[0].returned_qty, 0) + + # Cancelling the credit note should restore the billed amount on the Sales Order. + credit_note.cancel() + so.load_from_db() + self.assertEqual(so.per_billed, 100) + + def test_sales_order_per_billed_after_credit_note_from_return_dn(self): + # Reported flow: SO -> SI (from SO) -> DN (from SI) -> return DN -> credit note. + # The DN carries si_detail in this path. + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so + + make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100) + + so = make_sales_order(qty=2) + + si = make_si_from_so(so.name) + si.insert() + si.submit() + + dn = make_delivery_note(si.name) + dn.insert() + dn.submit() + + self._assert_credit_note_from_return_dn_resets_per_billed(so, dn) + + def test_sales_order_per_billed_after_credit_note_from_so_derived_dn(self): + # SO billed and delivered separately (SO -> SI, SO -> DN), then return DN -> credit note. + # SO per_billed rolls back via the status_updater in update_prevdoc_status. + from erpnext.selling.doctype.sales_order.sales_order import ( + make_delivery_note as make_dn_from_so, + ) + from erpnext.selling.doctype.sales_order.sales_order import ( + make_sales_invoice as make_si_from_so, + ) + + make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100) + + so = make_sales_order(qty=2) + + si = make_si_from_so(so.name) + si.insert() + si.submit() + + dn = make_dn_from_so(so.name) + dn.insert() + dn.submit() + + self.assertIsNone(dn.items[0].si_detail) + + self._assert_credit_note_from_return_dn_resets_per_billed(so, dn) + def test_sales_return_for_product_bundle(self): from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 10c771d1415..e8280bacf4a 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -145,6 +145,7 @@ "ignore_user_permissions": 1, "in_standard_filter": 1, "label": "Variant Of", + "link_filters": "[[\"Item\",\"has_variants\",\"=\",1]]", "options": "Item", "read_only": 1, "search_index": 1, @@ -897,7 +898,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2026-03-17 20:39:05.218344", + "modified": "2026-07-05 23:24:45.734144", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 05e8a2f3779..7f077cfd4dd 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -217,6 +217,7 @@ class Item(Document): self.validate_item_defaults() self.validate_auto_reorder_enabled_in_stock_settings() self.cant_change() + self.validate_serialized_change_with_bundle() self.validate_item_tax_net_rate_range() if not self.is_new(): @@ -1074,6 +1075,25 @@ class Item(Document): frappe.throw(msg, title=_("Linked with submitted documents")) + def validate_serialized_change_with_bundle(self): + """Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists + for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first.""" + if self.is_new() or self.has_serial_no or not self._doc_before_save: + return + + # Only relevant when the item was serialized before and is now being unset. + if not self._doc_before_save.has_serial_no: + return + + # Draft (docstatus 0) or submitted (docstatus 1) bundles block the change; cancelled ones don't. + if frappe.db.count("Serial and Batch Bundle", {"item_code": self.name, "docstatus": ("<", 2)}): + frappe.throw( + _( + "Cannot change Item {0} from serialized to non-serialized because a Serial and Batch Bundle exists for it. Please delete or cancel the Serial and Batch Bundle first." + ).format(frappe.bold(self.name)), + title=_("Serial and Batch Bundle Exists"), + ) + def _get_linked_submitted_documents(self, changed_fields: list[str]) -> dict[str, str] | None: linked_doctypes = [ "Delivery Note Item", diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 8072437a173..073c8c8be93 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -443,6 +443,100 @@ class TestItem(FrappeTestCase): "Large", ) + def test_rename_attribute_abbr_updates_variant_item_code(self): + frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1) + + variant = create_variant("_Test Variant Item", {"Test Size": "Large"}) + variant.save() + + attribute = frappe.get_doc("Item Attribute", "Test Size") + for row in attribute.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "LRG" + break + + def restore_test_size_abbr(): + doc = frappe.get_doc("Item Attribute", "Test Size") + for row in doc.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "L" + break + frappe.flags.attribute_values = None + doc.save() + + self.addCleanup(restore_test_size_abbr) + self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1)) + + frappe.flags.attribute_values = None + attribute.save() + + self.assertFalse(frappe.db.exists("Item", "_Test Variant Item-L")) + self.assertTrue(frappe.db.exists("Item", "_Test Variant Item-LRG")) + self.assertEqual( + frappe.db.get_value("Item", "_Test Variant Item-LRG", "item_name"), + "_Test Variant Item-LRG", + ) + + def test_rename_attribute_abbr_updates_variant_item_name_from_template_name(self): + # item_name can be derived from the template's item_name, which may differ from its + # item_code (e.g. a friendly display name vs. a SKU-style code). The variant's item_name + # must follow the abbreviation rename the same way item_code does. + frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-L", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1) + + template = frappe.get_doc("Item", "_Test Variant Item").as_dict() + template = frappe.get_doc( + { + "doctype": "Item", + "item_code": "_Test Variant Item Diff", + "item_name": "Test Variant Friendly Name", + "item_group": template.item_group, + "stock_uom": template.stock_uom, + "has_variants": 1, + "attributes": [{"attribute": "Test Size"}], + } + ) + template.insert() + self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1)) + + variant = create_variant("_Test Variant Item Diff", {"Test Size": "Large"}) + variant.save() + self.assertEqual(variant.item_code, "_Test Variant Item Diff-L") + self.assertEqual(variant.item_name, "Test Variant Friendly Name-L") + + # even a manually customized item_name (unrelated to the auto-generated pattern) must be + # rebuilt on abbreviation rename, since item_code and item_name are meant to stay in lockstep. + frappe.db.set_value("Item", variant.name, "item_name", "Custom Friendly Large Shirt Name") + + attribute = frappe.get_doc("Item Attribute", "Test Size") + for row in attribute.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "LRG" + break + + def restore_test_size_abbr(): + doc = frappe.get_doc("Item Attribute", "Test Size") + for row in doc.item_attribute_values: + if row.attribute_value == "Large": + row.abbr = "L" + break + frappe.flags.attribute_values = None + doc.save() + + self.addCleanup(restore_test_size_abbr) + self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1)) + + frappe.flags.attribute_values = None + attribute.save() + + self.assertFalse(frappe.db.exists("Item", "_Test Variant Item Diff-L")) + self.assertEqual( + frappe.db.get_value("Item", "_Test Variant Item Diff-LRG", "item_name"), + "Test Variant Friendly Name-LRG", + ) + def test_make_item_variant(self): frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) @@ -966,6 +1060,47 @@ class TestItem(FrappeTestCase): self.assertRaises(frappe.ValidationError, item_doc.save) + def test_cannot_unset_serialized_while_bundle_exists(self): + from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + make_serial_batch_bundle, + ) + + item = make_item( + properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TSN-UNSET-.####"} + ).name + + serial_no = f"{item}-SN-01" + frappe.get_doc( + {"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"} + ).insert() + + # A draft (unsubmitted) Serial and Batch Bundle for the item must block the change. + bundle = make_serial_batch_bundle( + { + "item_code": item, + "warehouse": "_Test Warehouse - _TC", + "company": "_Test Company", + "qty": 1, + "rate": 100, + "voucher_type": "Stock Entry", + "serial_nos": [serial_no], + "type_of_transaction": "Inward", + "do_not_submit": True, + "ignore_sabb_validation": True, + } + ) + + doc = frappe.get_doc("Item", item) + doc.has_serial_no = 0 + self.assertRaises(frappe.ValidationError, doc.save) + + # Once the bundle is removed, the item can be made non-serialized. + frappe.delete_doc("Serial and Batch Bundle", bundle.name, force=True) + doc = frappe.get_doc("Item", item) + doc.has_serial_no = 0 + doc.save() + self.assertEqual(frappe.db.get_value("Item", item, "has_serial_no"), 0) + def set_item_variant_settings(fields): doc = frappe.get_doc("Item Variant Settings") diff --git a/erpnext/stock/doctype/item_attribute/item_attribute.py b/erpnext/stock/doctype/item_attribute/item_attribute.py index 14d2c6a4f12..09e1d56ffdd 100644 --- a/erpnext/stock/doctype/item_attribute/item_attribute.py +++ b/erpnext/stock/doctype/item_attribute/item_attribute.py @@ -10,6 +10,7 @@ from frappe.utils import flt from erpnext.controllers.item_variant import ( InvalidItemAttributeValueError, update_variant_attribute_values, + update_variant_item_codes_for_abbr_renames, validate_is_incremental, validate_item_attribute_value, ) @@ -49,6 +50,7 @@ class ItemAttribute(Document): def on_update(self): update_variant_attribute_values(self) + update_variant_item_codes_for_abbr_renames(self) self.validate_exising_items() self.set_enabled_disabled_in_items() diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 6d42f51a8d6..9cadca6e137 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1415,6 +1415,9 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): if location.sales_order != sales_order or location.product_bundle_item: continue + if flt(location.picked_qty) - flt(location.delivered_qty) <= 0: + continue + if location.sales_order_item: sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item) else: diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 83d1827794c..114da89007e 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -1016,6 +1016,45 @@ class TestPickList(FrappeTestCase): pl.reload() self.assertEqual(pl.status, "Cancelled") + def test_create_second_delivery_note_with_fully_delivered_location(self): + # When one pick list item is fully delivered by the first Delivery Note + # and another item is still pending, creating a second Delivery Note from + # the Pick List must not create a zero-qty row for the delivered item. + warehouse = "_Test Warehouse - _TC" + item_a = make_item(properties={"is_stock_item": 1}).name + item_b = make_item(properties={"is_stock_item": 1}).name + make_stock_entry(item=item_a, to_warehouse=warehouse, qty=20) + make_stock_entry(item=item_b, to_warehouse=warehouse, qty=20) + + so = make_sales_order( + item_list=[ + {"item_code": item_a, "warehouse": warehouse, "qty": 10, "rate": 100}, + {"item_code": item_b, "warehouse": warehouse, "qty": 5, "rate": 100}, + ] + ) + + pl = create_pick_list(so.name) + pl.save().submit() + + # First Delivery Note: fully deliver item_a, drop item_b. + dn1 = create_delivery_note(pl.name) + for row in list(dn1.items): + if row.item_code == item_b: + dn1.remove(row) + dn1.save().submit() + + pl.reload() + delivered = {loc.item_code: loc.delivered_qty for loc in pl.locations} + self.assertEqual(delivered[item_a], 10) + self.assertEqual(delivered[item_b], 0) + + # Second Delivery Note for the remaining item must succeed and must not + # include a zero-qty row for the already delivered item_a. + dn2 = create_delivery_note(pl.name) + self.assertEqual(len(dn2.items), 1) + self.assertEqual(dn2.items[0].item_code, item_b) + self.assertEqual(dn2.items[0].qty, 5) + def test_pick_list_validation(self): warehouse = "_Test Warehouse - _TC" item = make_item("Test Non Serialized Pick List Item", properties={"is_stock_item": 1}).name diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index c27e2e40f30..10099631a75 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -493,6 +493,7 @@ class PurchaseReceipt(BuyingController): remarks=remarks, against_account=stock_asset_rbnb, account_currency=account_currency, + project=item.project, item=item, ) @@ -535,6 +536,7 @@ class PurchaseReceipt(BuyingController): against_account=stock_asset_account_name, debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")), account_currency=account_currency, + project=item.project, item=item, ) @@ -559,6 +561,7 @@ class PurchaseReceipt(BuyingController): against_account=self.supplier, debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, account_currency=account_currency, + project=item.project, item=item, ) @@ -572,6 +575,7 @@ class PurchaseReceipt(BuyingController): against_account=self.supplier, debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, account_currency=account_currency, + project=item.project, item=item, ) @@ -634,6 +638,7 @@ class PurchaseReceipt(BuyingController): remarks=remarks, against_account=stock_asset_account_name, account_currency=supplier_warehouse_account_currency, + project=item.project, item=item, ) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 3799e773a7d..edde28a04e6 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1662,6 +1662,93 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(query[0].value, 0) + def test_internal_transfer_pr_incoming_sle_anchored_to_dn_rate(self): + """Internal-transfer PR's inward SLE must use DN.incoming_rate even when + PR.item.valuation_rate was wrong at submit, so divisional_loss does not + leak to COGS.""" + from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.stock_ledger import update_entries_after + + prepare_data_for_internal_transfer() + customer = "_Test Internal Customer 2" + company = "_Test Company with perpetual inventory" + + from_warehouse = create_warehouse("_Test Drift From", company=company) + transit_warehouse = create_warehouse("_Test Drift Transit", company=company) + to_warehouse = create_warehouse("_Test Drift Receiver", company=company) + item_doc = create_item("Test Internal Drift Item") + + make_purchase_receipt( + item_code=item_doc.name, + company=company, + posting_date=add_days(today(), -1), + warehouse=from_warehouse, + qty=10, + rate=100, + ) + + dn = create_delivery_note( + item_code=item_doc.name, + company=company, + customer=customer, + cost_center="Main - TCP1", + expense_account="Cost of Goods Sold - TCP1", + qty=1, + rate=100, + warehouse=from_warehouse, + target_warehouse=transit_warehouse, + ) + self.assertEqual(flt(dn.items[0].incoming_rate), 100.0) + + pr = make_inter_company_purchase_receipt(dn.name) + pr.items[0].warehouse = to_warehouse + pr.submit() + + inward_sle = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Purchase Receipt", + "voucher_no": pr.name, + "warehouse": to_warehouse, + "is_cancelled": 0, + }, + ["name", "item_code", "warehouse", "posting_date", "posting_time", "creation", "incoming_rate"], + as_dict=True, + ) + self.assertEqual(flt(inward_sle.incoming_rate), 100.0) + + frappe.db.set_value( + "Purchase Receipt Item", + pr.items[0].name, + {"sales_incoming_rate": 0, "valuation_rate": 80}, + ) + frappe.db.set_value( + "Stock Ledger Entry", + inward_sle.name, + {"incoming_rate": 80, "stock_value_difference": 80}, + ) + + update_entries_after( + { + "item_code": inward_sle.item_code, + "warehouse": inward_sle.warehouse, + "posting_date": inward_sle.posting_date, + "posting_time": inward_sle.posting_time, + "sle_id": inward_sle.name, + "creation": inward_sle.creation, + } + ) + + refreshed = frappe.db.get_value( + "Stock Ledger Entry", + inward_sle.name, + ["incoming_rate", "stock_value_difference"], + as_dict=True, + ) + self.assertEqual(flt(refreshed.incoming_rate), 100.0) + self.assertEqual(flt(refreshed.stock_value_difference), 100.0) + def test_backdated_transaction_for_internal_transfer_in_trasit_warehouse_for_purchase_invoice( self, ): diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index 98a2720e6d2..f7cc4b90c36 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -86,7 +86,7 @@ class RepostItemValuation(Document): self.validate_recreate_stock_ledgers() def set_default_posting_time(self): - if not self.posting_time: + if self.posting_time is None: self.posting_time = nowtime() if not self.posting_date: diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index f8facea5f78..e3428c98add 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -26,6 +26,7 @@ from frappe.utils import ( ) from frappe.utils.csvutils import build_csv_response +from erpnext.stock.doctype.purchase_receipt_item.purchase_receipt_item import PurchaseReceiptItem from erpnext.stock.serial_batch_bundle import ( BatchNoValuation, SerialNoValuation, @@ -513,10 +514,12 @@ class SerialandBatchBundle(Document): ] # Added to handle rejected warehouse case + return_warehouse = None if self.voucher_type in ["Purchase Receipt", "Purchase Invoice"]: warehouses = get_warehouses_for_return(self.voucher_type, return_against_voucher_detail_no) if self.warehouse in warehouses: - filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse]) + return_warehouse = self.warehouse + filters.append(["Serial and Batch Entry", "warehouse", "=", return_warehouse]) bundle_data = frappe.get_all( "Serial and Batch Bundle", @@ -529,6 +532,11 @@ class SerialandBatchBundle(Document): order_by="`tabSerial and Batch Bundle`.`creation`, `tabSerial and Batch Entry`.`idx`", ) + if not bundle_data: + bundle_data = self.get_legacy_valuation_rate_for_return_entry( + return_against, return_against_voucher_detail_no, return_warehouse + ) + if not bundle_data: return {} @@ -540,6 +548,49 @@ class SerialandBatchBundle(Document): return valuation_details + def get_legacy_valuation_rate_for_return_entry( + self, return_against, return_against_voucher_detail_no, return_warehouse=None + ): + """Return the original line's incoming rate per serial no / batch from the SLE, for legacy receipts with no bundle.""" + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + if not (self.has_serial_no or self.has_batch_no): + return [] + + sle = frappe.qb.DocType("Stock Ledger Entry") + query = ( + frappe.qb.from_(sle) + .select(sle.serial_no, sle.batch_no, sle.incoming_rate) + .where( + (sle.voucher_no == return_against) + & (sle.voucher_detail_no == return_against_voucher_detail_no) + & (sle.item_code == self.item_code) + & (sle.is_cancelled == 0) + & (sle.serial_and_batch_bundle.isnull()) + ) + ) + + if return_warehouse: + query = query.where(sle.warehouse == return_warehouse) + + data = [] + for d in query.run(as_dict=True): + if d.serial_no: + for serial_no in get_serial_nos(d.serial_no): + data.append( + frappe._dict( + {"serial_no": serial_no, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate} + ) + ) + elif d.batch_no: + data.append( + frappe._dict( + {"serial_no": None, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate} + ) + ) + + return data + def calculate_total_qty(self, save=True): self.total_qty = 0.0 for d in self.entries: @@ -2042,9 +2093,14 @@ def get_reference_serial_and_batch_bundle(child_row): @frappe.whitelist() -def add_serial_batch_ledgers(entries, child_row, doc, warehouse, do_not_save=False) -> object: - if isinstance(child_row, str): - child_row = frappe._dict(parse_json(child_row)) +def add_serial_batch_ledgers( + entries: list | str, + child_row: PurchaseReceiptItem | dict | str, + doc: Document | dict | str, + warehouse: str | None = None, + do_not_save: bool = False, +): + child_row = parse_json(child_row) if isinstance(entries, str): entries = parse_json(entries) @@ -2076,7 +2132,9 @@ def create_serial_batch_no_ledgers( if parent_doc.get("doctype") == "Stock Entry": warehouse = warehouse or child_row.s_warehouse or child_row.t_warehouse - posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time")) + posting_datetime = combine_datetime( + parent_doc.get("posting_date") or today(), parent_doc.get("posting_time") or nowtime() + ) doc = frappe.get_doc( { @@ -2193,7 +2251,9 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh ) doc.voucher_detail_no = child_row.name - doc.posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time")) + doc.posting_datetime = combine_datetime( + parent_doc.get("posting_date") or today(), parent_doc.get("posting_time") or nowtime() + ) doc.warehouse = warehouse or doc.warehouse doc.set("entries", []) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 2cee2bb6f0a..100e62deafb 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -1246,6 +1246,91 @@ class TestSerialandBatchBundle(FrappeTestCase): self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se1.name) + def _assert_legacy_return_valuation(self, item_code, props, batch_no=None): + """Return against a legacy serial/batch receipt (no Serial and Batch Bundle) must value outgoing stock from the original ledger rate.""" + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + make_item(item_code, props) + if batch_no and not frappe.db.exists("Batch", batch_no): + frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert() + + pr = make_purchase_receipt( + item_code=item_code, qty=10, rate=100, batch_no=batch_no, use_serial_batch_fields=True + ) + + # Simulate a receipt migrated from an older version: serial nos / batch tracked via the + # deprecated fields on the Stock Ledger Entry, with no Serial and Batch Bundle. + serial_nos = [] + for row in pr.items: + if row.serial_and_batch_bundle: + serial_nos = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": row.serial_and_batch_bundle}, + pluck="serial_no", + ) + frappe.db.delete("Serial and Batch Bundle", {"name": row.serial_and_batch_bundle}) + frappe.db.set_value("Purchase Receipt Item", row.name, "serial_and_batch_bundle", None) + + serial_nos = [sn for sn in serial_nos if sn] + legacy = {"serial_and_batch_bundle": None} + if batch_no: + legacy["batch_no"] = batch_no + if serial_nos: + legacy["serial_no"] = "\n".join(serial_nos) + for sle in frappe.get_all("Stock Ledger Entry", filters={"voucher_no": pr.name}, pluck="name"): + frappe.db.set_value("Stock Ledger Entry", sle, legacy) + + rt = make_return_doc("Purchase Receipt", pr.name) + rt.items[0].qty = -4 + rt.items[0].received_qty = -4 + rt.items[0].use_serial_batch_fields = 1 + if batch_no: + rt.items[0].batch_no = batch_no + if serial_nos: + rt.items[0].serial_no = "\n".join(serial_nos[:4]) + rt.submit() + + difference_in_stock_value = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": rt.name, "is_cancelled": 0, "voucher_type": "Purchase Receipt"}, + "stock_value_difference", + ) + # 4 units returned at the original ledger rate of 100 -> -400 (must not be zero) + self.assertEqual(flt(difference_in_stock_value, 2), -400.0) + + def test_return_valuation_for_legacy_batch_without_bundle(self): + self._assert_legacy_return_valuation( + "Test Legacy Batch Return Valuation", + { + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "LBRV-.#####", + "is_stock_item": 1, + }, + batch_no="LBRV-BATCH-0001", + ) + + def test_return_valuation_for_legacy_serial_without_bundle(self): + self._assert_legacy_return_valuation( + "Test Legacy Serial Return Valuation", + {"has_serial_no": 1, "serial_no_series": "LSRV-.#####", "is_stock_item": 1}, + ) + + def test_return_valuation_for_legacy_serial_and_batch_without_bundle(self): + self._assert_legacy_return_valuation( + "Test Legacy Serial Batch Return Valuation", + { + "has_serial_no": 1, + "serial_no_series": "LSBRV-.#####", + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "LSBRVB-.#####", + "is_stock_item": 1, + }, + batch_no="LSBRV-BATCH-0001", + ) + def get_batch_from_bundle(bundle): from erpnext.stock.serial_batch_bundle import get_batch_nos diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index 4e422e320b9..ec73d0c6fee 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -337,7 +337,6 @@ "print_hide": 1 }, { - "default": ":Company", "depends_on": "eval:cint(erpnext.is_perpetual_inventory_enabled(parent.company))", "fieldname": "cost_center", "fieldtype": "Link", @@ -616,7 +615,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-04-27 11:40:38.294196", + "modified": "2026-07-03 12:11:53.714931", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 917aba9803e..6aea0ef5337 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1257,6 +1257,148 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): self.assertEqual(sle[0].qty_after_transaction, 105) self.assertEqual(sle[0].actual_qty, 100) + def test_update_qty_in_future_sle_shifts_same_timestamp_later_entry(self): + # update_qty_in_future_sle treats "future" as strictly after the current entry in the + # (posting_datetime, creation) order. An entry sharing the exact posting timestamp but created + # later must still have its running balance shifted; comparing posting_datetime alone would skip + # it. The current entry itself (same timestamp, same creation) must not be shifted. + from erpnext.stock.stock_ledger import update_qty_in_future_sle + + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + receipt1 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=10, + rate=10, + posting_date="2021-01-01", + posting_time="02:00:00", + ) + time.sleep(1) + receipt2 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=20, + rate=10, + posting_date="2021-01-01", + posting_time="02:00:00", # identical timestamp, later creation + ) + + def sle(voucher): + return frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": voucher.name, "is_cancelled": 0}, + ["name", "posting_date", "posting_time", "creation", "qty_after_transaction"], + as_dict=True, + ) + + sle1, sle2 = sle(receipt1), sle(receipt2) + self.assertEqual(sle1.qty_after_transaction, 10) + self.assertEqual(sle2.qty_after_transaction, 30) + + # Simulate a +5 qty shift originating at receipt1's ledger position. + args = frappe._dict( + { + "item_code": item, + "warehouse": warehouse, + "voucher_type": "Purchase Receipt", + "voucher_no": receipt1.name, + "posting_date": sle1.posting_date, + "posting_time": sle1.posting_time, + "creation": sle1.creation, + "actual_qty": 5, + } + ) + update_qty_in_future_sle(args, allow_negative_stock=True) + + # receipt2 (same timestamp, later creation) is shifted; receipt1 (the current entry) is not. + self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle2.name, "qty_after_transaction"), 35) + self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle1.name, "qty_after_transaction"), 10) + + def test_cancel_first_of_two_same_timestamp_entries(self): + # Two receipts of the same item+warehouse at the exact same posting timestamp: balances 10 -> 20. + # Cancelling the first must leave the second standing alone on a zero base (qty 10), not + # double-decremented. The same-timestamp sibling is corrected by the cancellation reprocessing, + # so update_qty_in_future_sle must not shift it again. + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + receipt1 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=10, + rate=10, + posting_date="2026-06-01", + posting_time="10:00:00", + ) + time.sleep(1) + receipt2 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=10, + rate=10, + posting_date="2026-06-01", + posting_time="10:00:00", # identical timestamp, later creation + ) + + def qty_after(voucher): + return frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": voucher.name, "is_cancelled": 0}, + "qty_after_transaction", + ) + + self.assertEqual(qty_after(receipt1), 10) + self.assertEqual(qty_after(receipt2), 20) + + receipt1.cancel() + + # receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error). + self.assertEqual(qty_after(receipt2), 10) + + def test_get_next_stock_reco_respects_creation_order(self): + # A stock reco sharing the exact posting timestamp of the current entry must only count as the + # "next" reco when it was created after that entry. A reco created before it actually precedes + # the entry and must not bound (truncate) the qty-shift range. + from erpnext.stock.stock_ledger import get_next_stock_reco + + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + reco = create_stock_reconciliation( + item_code=item, + warehouse=warehouse, + qty=10, + rate=100, + posting_date="2021-01-01", + posting_time="02:00:00", + ) + reco_sle = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": reco.name, "is_cancelled": 0}, + ["posting_date", "posting_time", "creation"], + as_dict=True, + ) + + base_kwargs = { + "item_code": item, + "warehouse": warehouse, + "voucher_no": "SOME-OTHER-VOUCHER", + "posting_date": reco_sle.posting_date, + "posting_time": reco_sle.posting_time, + } + + # Current entry created AFTER the reco at the same timestamp -> reco precedes it -> not returned. + after = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=5)} + self.assertFalse(get_next_stock_reco(after)) + + # Current entry created BEFORE the reco at the same timestamp -> reco follows it -> returned. + before = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=-5)} + result = get_next_stock_reco(before) + self.assertTrue(result) + self.assertEqual(result[0].voucher_no, reco.name) + @change_settings("System Settings", {"float_precision": 3, "currency_precision": 2}) def test_transfer_invariants(self): """Extact stock value should be transferred.""" diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 67f9b57c172..ab1358e8293 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -993,6 +993,102 @@ class StockReconciliation(StockController): d.quantity_difference = flt(d.qty) - flt(d.current_qty) d.amount_difference = flt(d.amount) - flt(d.current_amount) + def recalculate_difference_amount_from_ledger(self): + """Sync the displayed current qty/rate and difference amount with the (reposted) ledger. + + Submitted reconciliations freeze ``difference_amount`` and the per-row current values at + submit time, but reposting/backdated transactions recompute the reconciliation's Stock Ledger + Entries and rebuild the GL from them. Without this sync the document keeps showing stale figures + that no longer match the GL entries. Anchoring ``amount_difference`` to the row's summed + ``stock_value_difference`` keeps the document and the GL consistent by construction. + """ + difference_amount = 0.0 + + for row in self.items: + stock_value_difference = flt(get_row_stock_value_difference(self.doctype, self.name, row.name)) + + amount = flt(flt(row.qty) * flt(row.valuation_rate), row.precision("amount")) + amount_difference = flt(stock_value_difference, row.precision("amount_difference")) + current_amount = flt(amount - amount_difference, row.precision("current_amount")) + + current_qty = self.get_current_qty_from_ledger(row) + current_valuation_rate = ( + flt(current_amount / current_qty, row.precision("current_valuation_rate")) + if current_qty + else 0.0 + ) + + row.db_set( + { + "amount": amount, + "current_qty": current_qty, + "current_valuation_rate": current_valuation_rate, + "current_amount": current_amount, + "quantity_difference": flt(row.qty) - current_qty, + "amount_difference": amount_difference, + }, + update_modified=False, + ) + + difference_amount += amount_difference + + self.db_set( + "difference_amount", + flt(difference_amount, self.precision("difference_amount")), + update_modified=False, + ) + + def get_current_qty_from_ledger(self, row): + """Current (pre-reconciliation) qty for a row, recomputed from the ledger after reposting. + + Serial/batch rows cannot have backdated qty changes inserted before a future reconciliation + (blocked by ``check_future_entries_exists``), so their current qty is frozen and read straight + from the current bundle. Non-serial rows can float, so read the ledger balance just before the + reconciliation, excluding the reconciliation's own entries. + """ + if row.current_serial_and_batch_bundle: + total_qty = frappe.db.get_value( + "Serial and Batch Bundle", row.current_serial_and_batch_bundle, "total_qty" + ) + return abs(flt(total_qty, row.precision("current_qty"))) + + reco_sle = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": self.doctype, + "voucher_no": self.name, + "voucher_detail_no": row.name, + "is_cancelled": 0, + }, + ["posting_datetime", "creation"], + as_dict=True, + ) + if not reco_sle: + return flt(row.current_qty, row.precision("current_qty")) + + sle = frappe.qb.DocType("Stock Ledger Entry") + previous_sle = ( + frappe.qb.from_(sle) + .select(sle.qty_after_transaction) + .where( + (sle.item_code == row.item_code) + & (sle.warehouse == row.warehouse) + & (sle.is_cancelled == 0) + & ( + (sle.posting_datetime < reco_sle.posting_datetime) + | ( + (sle.posting_datetime == reco_sle.posting_datetime) + & (sle.creation < reco_sle.creation) + ) + ) + ) + .orderby(sle.posting_datetime, order=frappe.qb.desc) + .orderby(sle.creation, order=frappe.qb.desc) + .limit(1) + ).run() + + return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0 + def submit(self): if len(self.items) > 100: msgprint( @@ -1179,6 +1275,23 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None): return itemwise_batch_data +def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_detail_no: str): + """Net stock value change posted to the GL by a reconciliation row (sum of its SLEs).""" + sle = frappe.qb.DocType("Stock Ledger Entry") + result = ( + frappe.qb.from_(sle) + .select(Sum(sle.stock_value_difference)) + .where( + (sle.voucher_type == voucher_type) + & (sle.voucher_no == voucher_no) + & (sle.voucher_detail_no == voucher_detail_no) + & (sle.is_cancelled == 0) + ) + ).run() + + return flt(result[0][0]) if result and result[0][0] else 0.0 + + @frappe.whitelist() def get_stock_balance_for( item_code: str, diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 795ea870cf1..11d7850913e 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -782,6 +782,172 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): sr1.load_from_db() self.assertEqual(sr1.difference_amount, 10000) + def assert_reco_difference_matches_gl(self, reco_name): + """The displayed Difference Amount (doc and per-row) must equal the reposted GL impact, + i.e. the sum of the reconciliation's Stock Ledger Entry ``stock_value_difference``.""" + from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( + get_row_stock_value_difference, + ) + + reco = frappe.get_doc("Stock Reconciliation", reco_name) + total_difference = 0.0 + + for row in reco.items: + row_difference = flt( + get_row_stock_value_difference("Stock Reconciliation", reco_name, row.name), + row.precision("amount_difference"), + ) + + self.assertEqual(flt(row.amount_difference), row_difference) + total_difference += row_difference + + self.assertEqual( + flt(reco.difference_amount, reco.precision("difference_amount")), + flt(total_difference, reco.precision("difference_amount")), + ) + + def test_difference_amount_synced_with_gl_after_repost_non_serialized(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item().name + warehouse = "_Test Warehouse - _TC" + + # Opening stock => 100 * 100 = 10000 + make_stock_entry( + item_code=item_code, + target=warehouse, + qty=100, + basic_rate=100, + posting_date=add_days(nowdate(), -5), + posting_time="10:00:00", + ) + + # Reconcile to 100 @ 200 => difference 20000 - 10000 = 10000 + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=100, + rate=200, + posting_date=add_days(nowdate(), -2), + ) + self.assertEqual(reco.difference_amount, 10000) + self.assert_reco_difference_matches_gl(reco.name) + + # Backdated reconciliation lowers the pre-reco stock value to 50 * 50 = 2500 + create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=50, + rate=50, + posting_date=add_days(nowdate(), -3), + ) + + reco.load_from_db() + # Current is now 2500 => difference 20000 - 2500 = 17500 + self.assertEqual(reco.difference_amount, 17500) + self.assert_reco_difference_matches_gl(reco.name) + + def test_difference_amount_synced_with_gl_after_repost_batched(self): + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + make_landed_cost_voucher, + ) + + item_code = self.make_item( + "Test Batch Item Reco Difference Sync", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TEST-BATCH-DIFFSYNC-.###", + }, + ).name + warehouse = "_Test Warehouse - _TC" + + # Receive 10 @ 100 (batch value 1000) + pr = make_purchase_receipt( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=100, + posting_date=add_days(nowdate(), -5), + ) + batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle) + + # Reconcile the batch to 10 @ 500 => difference 5000 - 1000 = 4000 + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=500, + batch_no=batch_no, + use_serial_batch_fields=1, + posting_date=add_days(nowdate(), -2), + ) + difference_on_submit = reco.difference_amount + self.assert_reco_difference_matches_gl(reco.name) + + # Landed cost retroactively raises the receipt (and batch) valuation, reposting the reco + make_landed_cost_voucher( + receipt_document_type="Purchase Receipt", + receipt_document=pr.name, + charges=1000, + company="_Test Company", + ) + + reco.load_from_db() + self.assertNotEqual(reco.difference_amount, difference_on_submit) + self.assert_reco_difference_matches_gl(reco.name) + + def test_difference_amount_synced_with_gl_after_repost_serialized(self): + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + make_landed_cost_voucher, + ) + + item_code = self.make_item( + "Test Serial Item Reco Difference Sync", + { + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "TSIRDS.####", + }, + ).name + warehouse = "_Test Warehouse - _TC" + + # Receive 5 serial nos @ 100 (value 500) + pr = make_purchase_receipt( + item_code=item_code, + warehouse=warehouse, + qty=5, + rate=100, + posting_date=add_days(nowdate(), -5), + ) + serial_nos = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle) + + # Reconcile the serial nos to 5 @ 500 => difference 2500 - 500 = 2000 + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=5, + rate=500, + serial_no="\n".join(serial_nos), + use_serial_batch_fields=1, + posting_date=add_days(nowdate(), -2), + ) + difference_on_submit = reco.difference_amount + self.assert_reco_difference_matches_gl(reco.name) + + # Landed cost retroactively raises the receipt (and serial) valuation, reposting the reco + make_landed_cost_voucher( + receipt_document_type="Purchase Receipt", + receipt_document=pr.name, + charges=1000, + company="_Test Company", + ) + + reco.load_from_db() + self.assertNotEqual(reco.difference_amount, difference_on_submit) + self.assert_reco_difference_matches_gl(reco.name) + def test_make_stock_zero_for_serial_batch_item(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 8c205a10874..fe41802fdf0 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -12,6 +12,7 @@ from frappe.model.utils import get_fetch_values from frappe.query_builder.functions import IfNull, Sum from frappe.utils import add_days, add_months, cint, cstr, flt, get_link_to_form, getdate, parse_json +import erpnext from erpnext import get_company_currency from erpnext.accounts.doctype.pricing_rule.pricing_rule import ( get_pricing_rule_for_item, @@ -410,12 +411,26 @@ def get_basic_details(args, item, overwrite_warehouse=True): expense_account = None - if args.get("doctype") == "Purchase Invoice" and item.is_fixed_asset: - from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account + if item.is_fixed_asset: + from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled - expense_account = get_asset_category_account( - fieldname="fixed_asset_account", item=args.item_code, company=args.company - ) + if is_cwip_accounting_enabled(item.asset_category): + expense_account = get_asset_account( + "capital_work_in_progress_account", + asset_category=item.asset_category, + company=args.company, + ) + elif args.get("doctype") in ( + "Purchase Invoice", + "Purchase Receipt", + "Purchase Order", + "Material Request", + ): + from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account + + expense_account = get_asset_category_account( + fieldname="fixed_asset_account", item=args.item_code, company=args.company + ) # Set the UOM to the Default Sales UOM or Default Purchase UOM if configured in the Item Master if not args.get("uom"): @@ -518,10 +533,21 @@ def get_basic_details(args, item, overwrite_warehouse=True): args.name, args.conversion_rate, item.name, out.conversion_factor ) + expense_account_field = "default_expense_account" + if ( + item.is_stock_item + and erpnext.is_perpetual_inventory_enabled(args.company) + and ( + args.doctype == "Purchase Receipt" + or (args.doctype == "Purchase Invoice" and args.get("update_stock")) + ) + ): + expense_account_field = "stock_received_but_not_billed" + # if default specified in item is for another company, fetch from company for d in [ ["Account", "income_account", "default_income_account"], - ["Account", "expense_account", "default_expense_account"], + ["Account", "expense_account", expense_account_field], ["Cost Center", "cost_center", "cost_center"], ["Warehouse", "warehouse", ""], ]: @@ -1492,6 +1518,11 @@ def apply_price_list(args, as_doc=False, doc=None): def apply_price_list_on_item(args, doc=None): item_doc = frappe.db.get_value("Item", args.item_code, ["name", "variant_of"], as_dict=1) item_details = get_price_list_rate(args, item_doc) + + args.conversion_factor = flt(args.conversion_factor) or get_conversion_factor( + args.item_code, args.uom + ).get("conversion_factor", 1) + args.stock_qty = flt(args.qty) * flt(args.conversion_factor) item_details.update(get_pricing_rule_for_item(args, doc=doc)) return item_details diff --git a/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json b/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json index a8ab8f6ac7d..9b5a71aae02 100644 --- a/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json +++ b/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json @@ -8,7 +8,7 @@ "docstatus": 0, "doctype": "Print Format", "font_size": 14, - "format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"
\\t\\t\\t\\t

Purchase Receipt

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

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

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

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

Purchase Receipt

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

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

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

\\n {% endfor %}\\n {% endif %}\\n\\t\\t\\t
\\n\"}]", "idx": 0, "line_breaks": 0, "margin_bottom": 15.0, @@ -27,4 +27,4 @@ "raw_printing": 0, "show_section_headings": 0, "standard": "Yes" -} \ No newline at end of file +} diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py index 1f527e7071a..e3d60f0d0d3 100644 --- a/erpnext/stock/reorder_item.py +++ b/erpnext/stock/reorder_item.py @@ -186,6 +186,10 @@ def get_item_warehouse_projected_qty(items_to_consider): item_warehouse_projected_qty = {} items_to_consider = list(items_to_consider.keys()) + warehouse_parent_map = frappe._dict( + frappe.get_all("Warehouse", fields=["name", "parent_warehouse"], as_list=True) + ) + for item_code, warehouse, projected_qty in frappe.db.sql( """select item_code, warehouse, projected_qty from tabBin where item_code in ({}) @@ -200,16 +204,14 @@ def get_item_warehouse_projected_qty(items_to_consider): if warehouse not in item_warehouse_projected_qty.get(item_code): item_warehouse_projected_qty[item_code][warehouse] = flt(projected_qty) - warehouse_doc = frappe.get_doc("Warehouse", warehouse) + parent_warehouse = warehouse_parent_map.get(warehouse) - while warehouse_doc.parent_warehouse: - if not item_warehouse_projected_qty.get(item_code, {}).get(warehouse_doc.parent_warehouse): - item_warehouse_projected_qty.setdefault(item_code, {})[warehouse_doc.parent_warehouse] = flt( - projected_qty - ) + while parent_warehouse: + if not item_warehouse_projected_qty.get(item_code, {}).get(parent_warehouse): + item_warehouse_projected_qty.setdefault(item_code, {})[parent_warehouse] = flt(projected_qty) else: - item_warehouse_projected_qty[item_code][warehouse_doc.parent_warehouse] += flt(projected_qty) - warehouse_doc = frappe.get_doc("Warehouse", warehouse_doc.parent_warehouse) + item_warehouse_projected_qty[item_code][parent_warehouse] += flt(projected_qty) + parent_warehouse = warehouse_parent_map.get(parent_warehouse) return item_warehouse_projected_qty diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 2e11fa1664b..e0106f7ddb0 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -287,6 +287,7 @@ class FIFOSlots: self.serial_no_details = {} self.batch_no_details = {} self.batchwise_valuation_by_batch = {} + self.valuation_method_by_item = {} self.filters = filters self.sle = sle @@ -307,9 +308,10 @@ class FIFOSlots: self.prepare_stock_reco_voucher_wise_count() if stock_ledger_entries is None: - # nested queries invalidate the streaming cursor below, - # so batchwise valuation flags must be resolved beforehand + # streaming path: nested queries invalidate the streaming cursor below, + # so batchwise valuation flags and item valuation methods must be resolved beforehand self._prefetch_batchwise_valuations() + self._prefetch_valuation_methods() with frappe.db.unbuffered_cursor(): if stock_ledger_entries is None: @@ -321,12 +323,28 @@ class FIFOSlots: # Note that stock_ledger_entries is an iterator, you can not reuse it like a list del stock_ledger_entries + self._recompute_moving_average_slots() + if not self.filters.get("show_warehouse_wise_stock"): # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) self.item_details = self._aggregate_details_by_item(self.item_details) return self.item_details + def _recompute_moving_average_slots(self) -> None: + for item_dict in self.item_details.values(): + if item_dict.get("has_serial_no") or item_dict.get("has_batch_no"): + continue + + details = item_dict["details"] + if self._get_item_valuation_method(details.name) != "Moving Average": + continue + + rate = flt(details.valuation_rate) + for slot in item_dict["fifo_queue"]: + if is_qty_slot(slot): + slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate) + def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]: if stock_ledger_entries is not None: return frappe._dict({}), frappe._dict({}) @@ -347,7 +365,10 @@ class FIFOSlots: if row.actual_qty > 0: self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos) else: - self._compute_outgoing_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos) + from_end = self._get_item_valuation_method(row.name) == "LIFO" + self._compute_outgoing_stock( + row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end + ) self._update_balances(row, key) self._trim_serial_fifo_queue(row, key, fifo_queue) @@ -460,6 +481,43 @@ class FIFOSlots: for batch_no, use_batchwise_valuation in query.run(): self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation + def _get_item_valuation_method(self, item_code: str) -> str: + from erpnext.stock.utils import get_valuation_method + + if item_code not in self.valuation_method_by_item: + # only reachable when stock ledger entries are passed in directly; + # the streaming path prefetches all methods before iteration + self.valuation_method_by_item[item_code] = get_valuation_method(item_code) + + return self.valuation_method_by_item[item_code] + + def _prefetch_valuation_methods(self) -> None: + from erpnext.stock.utils import get_valuation_method + + company = self.filters.get("company") + sle = frappe.qb.DocType("Stock Ledger Entry") + item = frappe.qb.DocType("Item") + to_date = get_datetime(self.filters.get("to_date") + " 23:59:59") + + query = ( + frappe.qb.from_(sle) + .inner_join(item) + .on(sle.item_code == item.name) + .select(item.name, item.valuation_method) + .distinct() + .where((sle.company == company) & (sle.posting_datetime <= to_date) & (sle.is_cancelled != 1)) + ) + query = self._apply_filter(query, sle, "item_code") + + # items with no item-level method share the company/settings default; resolve it once + default_method = None + for item_code, valuation_method in query.run(): + if not valuation_method: + if default_method is None: + default_method = get_valuation_method(item_code) + valuation_method = default_method + self.valuation_method_by_item[item_code] = valuation_method + def _init_key_stores(self, row: dict) -> tuple: "Initialise keys and FIFO Queue." @@ -492,7 +550,7 @@ class FIFOSlots: self._add_serial_fifo_slots(row, fifo_queue, serial_nos) elif batch_nos and row.get("has_batch_no"): self._add_batch_fifo_slots(row, fifo_queue, batch_nos) - elif fifo_queue and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0: + elif fifo_queue and is_qty_slot(fifo_queue[0]) and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0: self._add_to_negative_fifo_head(row, fifo_queue) else: fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)]) @@ -576,7 +634,13 @@ class FIFOSlots: fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference) def _compute_outgoing_stock( - self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list + self, + row: dict, + fifo_queue: list, + transfer_key: tuple, + serial_nos: list, + batch_nos: list, + from_end: bool = False, ): "Update FIFO Queue on outward stock." if serial_nos: @@ -584,7 +648,7 @@ class FIFOSlots: elif batch_nos: self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos) else: - self._consume_fifo_slots(row, fifo_queue, transfer_key) + self._consume_fifo_slots(row, fifo_queue, transfer_key, from_end) def _consume_serial_fifo_slots(self, fifo_queue: list, serial_nos: list) -> None: fifo_queue[:] = [slot for slot in fifo_queue if slot[FIFO_QTY_INDEX] not in serial_nos] @@ -661,19 +725,23 @@ class FIFOSlots: ) self.transferred_item_details[transfer_key].append([qty, row.posting_date, stock_value_difference]) - def _consume_fifo_slots(self, row: dict, fifo_queue: list, transfer_key: tuple) -> None: + def _consume_fifo_slots( + self, row: dict, fifo_queue: list, transfer_key: tuple, from_end: bool = False + ) -> None: + # LIFO consumes the most recent inward first, so pop from the tail instead of the head. + index = -1 if from_end else 0 qty_to_pop = abs(row.actual_qty) stock_value = abs(row.stock_value_difference) while qty_to_pop: - slot = fifo_queue[0] if fifo_queue else [0, None, 0] + slot = fifo_queue[index] if fifo_queue else [0, None, 0] slot_qty = flt(slot[FIFO_QTY_INDEX]) slot_value = flt(slot[FIFO_VALUE_INDEX]) if 0 < slot_qty <= qty_to_pop: qty_to_pop -= slot_qty stock_value -= slot_value - self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) + self.transferred_item_details[transfer_key].append(fifo_queue.pop(index)) elif not fifo_queue: fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)]) self.transferred_item_details[transfer_key].append( diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 003d1a51d93..2f74e1e3327 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -1,6 +1,8 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +from unittest.mock import patch + import frappe from frappe.tests.utils import FrappeTestCase @@ -67,6 +69,131 @@ class TestStockAgeing(FrappeTestCase): data = format_report_data(self.filters, slots, self.filters["to_date"]) self.assertEqual(data[0][8], 40.0) # valuating for stock value between age 0-30 + def test_moving_average_value_ties_to_stock_balance(self): + """For Moving Average items the queue value is re-derived as qty * rate so the + report's stock value ties to Stock Balance, instead of stranding a residual + from FIFO-by-qty consumption vs blended outgoing value.""" + sle = [ + frappe._dict( + name="MA Item", + actual_qty=10, + qty_after_transaction=10, + stock_value_difference=1000, + valuation_rate=100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=10, + qty_after_transaction=20, + stock_value_difference=2000, + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=(-10), + qty_after_transaction=10, + stock_value_difference=(-1500), + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-03", + voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="MA Item", + actual_qty=(-5), + qty_after_transaction=5, + stock_value_difference=(-750), + valuation_rate=150, + warehouse="WH 1", + posting_date="2021-12-04", + voucher_type="Stock Entry", + voucher_no="004", + has_serial_no=False, + serial_no=None, + ), + ] + + with patch("erpnext.stock.utils.get_valuation_method", return_value="Moving Average"): + slots = FIFOSlots(self.filters, sle).generate() + + queue = slots["MA Item"]["fifo_queue"] + total_value = sum(slot[2] for slot in queue) + + # Stock Balance bal_val = qty_after_transaction * valuation_rate = 5 * 150 + self.assertEqual(total_value, 750.0) + + def test_lifo_consumes_newest_first(self): + """LIFO items consume the most recent inward first, so the oldest lot stays on + hand. The remaining queue, stock value and average age must reflect the older + stock, unlike the default FIFO which retains the newest lots.""" + sle = [ + frappe._dict( + name="LIFO Item", + actual_qty=30, + qty_after_transaction=30, + stock_value_difference=30, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="LIFO Item", + actual_qty=20, + qty_after_transaction=50, + stock_value_difference=20, + warehouse="WH 1", + posting_date="2021-12-02", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + serial_no=None, + ), + frappe._dict( + name="LIFO Item", + actual_qty=(-10), + qty_after_transaction=40, + stock_value_difference=(-10), + warehouse="WH 1", + posting_date="2021-12-03", + voucher_type="Stock Entry", + voucher_no="003", + has_serial_no=False, + serial_no=None, + ), + ] + + with patch("erpnext.stock.utils.get_valuation_method", return_value="LIFO"): + slots = FIFOSlots(self.filters, sle).generate() + + queue = slots["LIFO Item"]["fifo_queue"] + + # newest lot (day 2) is consumed first: oldest 30 stays, newest drops 20 -> 10 + self.assertEqual(queue[0][0], 30.0) + self.assertEqual(queue[-1][0], 10.0) + self.assertEqual(sum(slot[0] for slot in queue), 40.0) + self.assertEqual(sum(slot[2] for slot in queue), 40.0) + + # average age skews older than the FIFO result (8.5) because the old lot is retained + self.assertEqual(get_average_age(queue, self.filters["to_date"]), 8.75) + def test_insufficient_balance(self): "Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)" sle = [ @@ -1438,6 +1565,47 @@ class TestStockAgeing(FrappeTestCase): self.assertEqual(item_result["total_qty"], -4.0) self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]]) + def test_untagged_receipt_with_negative_batch_head(self): + """An incoming SLE without batch details must not treat a negative + batch slot at the queue head as a qty slot (TypeError: str += float).""" + sle = [ + frappe._dict( + name="Enclosure Item", + actual_qty=-10, + qty_after_transaction=-10, + stock_value_difference=-100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no="QI-06448", + ), + frappe._dict( + name="Enclosure Item", + actual_qty=45, + qty_after_transaction=35, + stock_value_difference=1051.65, + warehouse="WH 1", + posting_date="2021-12-05", + voucher_type="Purchase Receipt", + voucher_no="002", + has_serial_no=False, + serial_no=None, + batch_no=None, + serial_and_batch_bundle="SABB-00001294", + ), + ] + + slots = FIFOSlots(self.filters, sle).generate() + queue = slots["Enclosure Item"]["fifo_queue"] + + self.assertEqual(slots["Enclosure Item"]["total_qty"], 35.0) + self.assertEqual(queue[0], ["QI-06448", None, -10.0, "2021-12-01", -100.0]) + self.assertEqual(queue[1], [45.0, "2021-12-05", 1051.65]) + def test_batchwise_valuation_stock_reconciliation_with_bundle(self): from frappe.utils import add_days, getdate, nowdate diff --git a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py index b83e46012cc..ec02318ce71 100644 --- a/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py +++ b/erpnext/stock/report/stock_and_account_value_comparison/stock_and_account_value_comparison.py @@ -171,14 +171,20 @@ def get_columns(filters): @frappe.whitelist() -def create_reposting_entries(rows, company): +def create_reposting_entries(rows: str | list, company: str): if isinstance(rows, str): rows = parse_json(rows) entries = [] item_wh = frappe._dict() - vouchers = [row.get("voucher_no") for row in rows] + vouchers = [ + row.get("voucher_no") + for row in rows + if row.get("voucher_type") not in ["Purchase Receipt", "Purchase Invoice"] + ] + repost_based_on_transaction(rows, company, entries) + sles = get_stock_ledgers(vouchers) for sle in sles: key = (sle.item_code, sle.warehouse) @@ -211,3 +217,39 @@ def create_reposting_entries(rows, company): if entries: entries = ", ".join(entries) frappe.msgprint(_("Reposting entries created: {0}").format(entries)) + + +def repost_based_on_transaction(rows, company=None, entries=None): + if entries is None: + entries = [] + + duplicate_vouchers = set() + for row in rows: + if ( + row.get("voucher_type") == "Purchase Invoice" + and frappe.get_cached_value("Purchase Invoice", row.get("voucher_no"), "update_stock") == 0 + ): + continue + + if row.get("voucher_type") in ["Purchase Receipt", "Purchase Invoice"]: + voucher_key = (row.get("voucher_type"), row.get("voucher_no")) + if voucher_key in duplicate_vouchers: + continue + + duplicate_vouchers.add(voucher_key) + doc = frappe.get_doc( + { + "doctype": "Repost Item Valuation", + "based_on": "Transaction", + "status": "Queued", + "voucher_type": row.get("voucher_type"), + "voucher_no": row.get("voucher_no"), + "posting_date": row.get("posting_date"), + "posting_time": row.get("posting_time"), + "company": company, + "allow_nagative_stock": 1, + "recalculate_valuation_rate": 1, + } + ).submit() + + entries.append(get_link_to_form("Repost Item Valuation", doc.name)) diff --git a/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py b/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py new file mode 100644 index 00000000000..66120a56b79 --- /dev/null +++ b/erpnext/stock/report/stock_and_account_value_comparison/test_stock_and_account_value_comparison.py @@ -0,0 +1,57 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import today + +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt +from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import ( + create_reposting_entries, + execute, +) + +PI_COMPANY = "_Test Company with perpetual inventory" +PI_STORES = "Stores - TCP1" + + +class TestStockAndAccountValueComparison(FrappeTestCase): + def test_purchase_voucher_reposted_transaction_based(self): + # A Purchase Receipt whose GL entries are missing must surface in the report and, when reposted + # from it, be reposted Transaction-based (so its own GL is regenerated) rather than the slower + # Item-and-Warehouse based reposting. + item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name + + pr = make_purchase_receipt(item_code=item, company=PI_COMPANY, warehouse=PI_STORES, qty=5, rate=100) + + # Simulate the out-of-sync state: stock ledger exists but the accounting ledger does not. + frappe.db.delete("GL Entry", {"voucher_type": "Purchase Receipt", "voucher_no": pr.name}) + + # The receipt now shows up in the comparison report (stock value 500 vs account value 0). + filters = frappe._dict(company=PI_COMPANY, as_on_date=today()) + _columns, data = execute(filters) + + row = next((d for d in data if d.get("voucher_no") == pr.name), None) + self.assertIsNotNone(row, "Out-of-sync Purchase Receipt should appear in the report") + self.assertEqual(row.get("voucher_type"), "Purchase Receipt") + + # Repost from the report. + create_reposting_entries([row], PI_COMPANY) + + # A Transaction-based Repost Item Valuation must have been created for this voucher... + transaction_rivs = frappe.get_all( + "Repost Item Valuation", + filters={"voucher_no": pr.name, "voucher_type": "Purchase Receipt"}, + fields=["name", "based_on"], + ) + + self.assertTrue(transaction_rivs, "Expected a Repost Item Valuation for the Purchase Receipt") + self.assertTrue(all(riv.based_on == "Transaction" for riv in transaction_rivs)) + + # ...and no Item-and-Warehouse based reposting should have been created for this item. + item_wh_rivs = frappe.get_all( + "Repost Item Valuation", + filters={"based_on": "Item and Warehouse", "item_code": item}, + ) + self.assertFalse(item_wh_rivs, "Purchase vouchers must not be reposted Item-and-Warehouse based") diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 6bceb0483f8..2c25b2c5b2b 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -100,8 +100,6 @@ class StockBalanceReport: self.filters["show_warehouse_wise_stock"] = True item_wise_fifo_queue = FIFOSlots(self.filters, self.sle_entries).generate() - _func = itemgetter(1) - del self.sle_entries sre_details = self.get_sre_reserved_qty_details() @@ -126,16 +124,7 @@ class StockBalanceReport: stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0} if opening_fifo_queue: - fifo_queue = sorted(filter(_func, opening_fifo_queue), key=_func) - fifo_queue = normalize_fifo_queue(fifo_queue) - if not fifo_queue: - continue - - to_date = self.to_date - stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date) - stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1]) - stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1]) - stock_ageing_data["fifo_queue"] = fifo_queue + stock_ageing_data.update(get_stock_ageing_data(opening_fifo_queue, self.to_date)) report_data.update(stock_ageing_data) @@ -694,6 +683,21 @@ class StockBalanceReport: return opening_fifo_queue +def get_stock_ageing_data(fifo_queue: list, to_date: str) -> dict: + stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0} + fifo_queue = sorted(filter(itemgetter(1), normalize_fifo_queue(fifo_queue)), key=itemgetter(1)) + + if not fifo_queue: + return stock_ageing_data + + stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date) + stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1]) + stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1]) + stock_ageing_data["fifo_queue"] = fifo_queue + + return stock_ageing_data + + def filter_items_with_no_transactions( iwb_map, float_precision: float, inventory_dimensions: list | None = None ): diff --git a/erpnext/stock/report/stock_balance/test_stock_balance.py b/erpnext/stock/report/stock_balance/test_stock_balance.py index 0985e4783c3..347c14c7eb0 100644 --- a/erpnext/stock/report/stock_balance/test_stock_balance.py +++ b/erpnext/stock/report/stock_balance/test_stock_balance.py @@ -7,7 +7,7 @@ from frappe.utils import today from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.stock.report.stock_balance.stock_balance import execute +from erpnext.stock.report.stock_balance.stock_balance import execute, get_stock_ageing_data def stock_balance(filters): @@ -168,3 +168,19 @@ class TestStockBalance(FrappeTestCase): rows = stock_balance(self.filters.update({"show_variant_attributes": 1, "item_code": [variant.name]})) self.assertPartialDictEq(attributes, rows[0]) self.assertInvariants(rows) + + def test_stock_ageing_data_accepts_batchwise_valuation_slots(self): + fifo_queue = [ + ["SA-BATCH-NEWER", 1, 2.0, "2021-12-05", 20.0], + ["SA-BATCH-OLDER", 1, 3.0, "2021-12-01", 30.0], + ] + + stock_ageing_data = get_stock_ageing_data(fifo_queue, "2021-12-10") + + self.assertEqual(stock_ageing_data["average_age"], 7.4) + self.assertEqual(stock_ageing_data["earliest_age"], 9) + self.assertEqual(stock_ageing_data["latest_age"], 5) + self.assertEqual( + stock_ageing_data["fifo_queue"], + [[3.0, "2021-12-01", 30.0], [2.0, "2021-12-05", 20.0]], + ) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index 954acf998d8..aef9fec6414 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -20,6 +20,7 @@ SLE_FIELDS = ( "outgoing_rate", "stock_queue", "batch_no", + "serial_no", "stock_value", "stock_value_difference", "valuation_rate", @@ -52,16 +53,16 @@ def add_invariant_check_fields(sles, filters): balance_qty = 0.0 balance_stock_value = 0.0 - incorrect_idx = 0 - precision = frappe.get_precision("Stock Ledger Entry", "actual_qty") + incorrect_idx = None + float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3 + currency_precision = ( + cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision + ) for idx, sle in enumerate(sles): - queue = json.loads(sle.stock_queue) if sle.stock_queue else [] - - fifo_qty = 0.0 - fifo_value = 0.0 - for qty, rate in queue: - fifo_qty += qty - fifo_value += qty * rate + if sle.batch_no: + sle.use_batchwise_valuation = frappe.db.get_value( + "Batch", sle.batch_no, "use_batchwise_valuation", cache=True + ) if sle.actual_qty < 0: sle.consumption_rate = sle.stock_value_difference / sle.actual_qty @@ -77,57 +78,67 @@ def add_invariant_check_fields(sles, filters): if balance_qty is None: balance_qty = sle.qty_after_transaction - sle.fifo_queue_qty = fifo_qty - sle.fifo_stock_value = fifo_value - sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None sle.balance_value_by_qty = ( sle.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None ) sle.expected_qty_after_transaction = balance_qty sle.stock_value_from_diff = balance_stock_value - # set difference fields sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction - sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty - sle.fifo_value_diff = sle.stock_value - fifo_value - sle.fifo_valuation_diff = ( - sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None - ) sle.valuation_diff = ( sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None ) sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value - if not incorrect_idx and filters.get("show_incorrect_entries"): - if is_sle_has_correct_data(sle, precision): - continue - else: - incorrect_idx = idx + if maintains_fifo_queue(sle): + add_fifo_fields(sle, sles[idx - 1] if idx else None) - if idx > 0: - sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value - sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference - - if sle.batch_no: - sle.use_batchwise_valuation = frappe.db.get_value( - "Batch", sle.batch_no, "use_batchwise_valuation", cache=True - ) + if incorrect_idx is None and not is_sle_has_correct_data(sle, float_precision, currency_precision): + incorrect_idx = idx if filters.get("show_incorrect_entries"): - if incorrect_idx > 0: - sles = sles[cint(incorrect_idx) - 1 :] - - return [] + if incorrect_idx is None: + return [] + return sles[max(incorrect_idx - 1, 0) :] return sles -def is_sle_has_correct_data(sle, precision): - if flt(sle.difference_in_qty, precision) != 0.0 or flt(sle.diff_value_diff, precision) != 0: - print(flt(sle.difference_in_qty, precision), flt(sle.diff_value_diff, precision)) - return False +def maintains_fifo_queue(sle): + # no queue is maintained for serialized/batchwise-valued stock + return not ( + sle.serial_and_batch_bundle or sle.serial_no or (sle.batch_no and sle.use_batchwise_valuation) + ) - return True + +def add_fifo_fields(sle, prev_sle): + queue = json.loads(sle.stock_queue) if sle.stock_queue else [] + + fifo_qty = 0.0 + fifo_value = 0.0 + for qty, rate in queue: + fifo_qty += qty + fifo_value += qty * rate + + sle.fifo_queue_qty = fifo_qty + sle.fifo_stock_value = fifo_value + sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None + sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty + sle.fifo_value_diff = sle.stock_value - fifo_value + sle.fifo_valuation_diff = ( + sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None + ) + # prev row may not maintain a queue; H and H - F stay blank across the gap + if prev_sle and prev_sle.fifo_stock_value is not None: + sle.fifo_stock_diff = sle.fifo_stock_value - prev_sle.fifo_stock_value + sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference + + +def is_sle_has_correct_data(sle, float_precision, currency_precision): + return ( + flt(sle.difference_in_qty, float_precision) == 0.0 + and flt(sle.diff_value_diff, currency_precision) == 0.0 + ) def get_columns(): diff --git a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py new file mode 100644 index 00000000000..b82e341c84a --- /dev/null +++ b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase + +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import execute + +WAREHOUSE = "Stores - _TC" +COMPANY = "_Test Company" + + +class TestStockLedgerInvariantCheck(FrappeTestCase): + def run_report(self, **extra): + filters = frappe._dict({"company": COMPANY, "warehouse": WAREHOUSE}) + filters.update(extra) + return execute(filters)[1] + + def make_movements(self) -> str: + # fresh item per test: db is only rolled back at class teardown on v15 + item = make_item(properties={"valuation_method": "FIFO"}).name + make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100, posting_date="2026-06-01") + make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=5, rate=120, posting_date="2026-06-02") + make_stock_entry(item_code=item, from_warehouse=WAREHOUSE, qty=4, rate=0, posting_date="2026-06-03") + return item + + def test_diagnostic_rows_have_no_discrepancy(self): + item = self.make_movements() + + data = self.run_report(item_code=item) + + self.assertEqual(len(data), 3) + for row in data: + self.assertLess(abs(row.difference_in_qty), 0.01) + self.assertLess(abs(row.fifo_qty_diff), 0.01) + self.assertLess(abs(row.diff_value_diff), 0.01) + + def test_running_balance_matches(self): + item = self.make_movements() + + data = self.run_report(item_code=item) + + self.assertEqual(data[-1].qty_after_transaction, 11) + + def test_show_incorrect_entries(self): + item = self.make_movements() + + self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), []) + + sle = frappe.get_last_doc( + "Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0} + ) + frappe.db.set_value( + "Stock Ledger Entry", sle.name, "qty_after_transaction", sle.qty_after_transaction + 5 + ) + + data = self.run_report(item_code=item, show_incorrect_entries=1) + self.assertEqual(len(data), 2) # incorrect entry + one before it for context + self.assertEqual(data[-1].name, sle.name) + + def test_batch_item_skips_fifo_queue_checks(self): + item = make_item( + properties={"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "SLIC-BAT-.####"} + ).name + make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100) + + data = self.run_report(item_code=item) + self.assertTrue(data) + for row in data: + self.assertIsNone(row.fifo_qty_diff) + self.assertIsNone(row.fifo_value_diff) + + self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), []) diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py index 327f158e3f6..04b888d85fe 100644 --- a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py +++ b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py @@ -205,7 +205,10 @@ def get_data(filters=None): data = [] if item_warehouse_map: - precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) + float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3 + currency_precision = ( + cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision + ) for item_warehouse in item_warehouse_map: report_data = stock_ledger_invariant_check(item_warehouse) @@ -215,7 +218,11 @@ def get_data(filters=None): for row in report_data: if has_difference( - row, precision, filters.difference_in, item_warehouse.valuation_method or valuation_method + row, + float_precision, + currency_precision, + filters.difference_in, + item_warehouse.valuation_method or valuation_method, ): row.update( { @@ -261,23 +268,26 @@ def get_item_warehouse_combinations(filters: dict | None = None) -> dict: return query.run(as_dict=1) -def has_difference(row, precision, difference_in, valuation_method): +def has_difference(row, float_precision, currency_precision, difference_in, valuation_method): if valuation_method == "Moving Average": - qty_diff = flt(row.difference_in_qty, precision) - value_diff = flt(row.diff_value_diff, precision) - valuation_diff = flt(row.valuation_diff, precision) + qty_diff = flt(row.difference_in_qty, float_precision) + value_diff = flt(row.diff_value_diff, currency_precision) + valuation_diff = flt(row.valuation_diff, currency_precision) else: - qty_diff = flt(row.difference_in_qty, precision) - value_diff = flt(row.diff_value_diff, precision) + qty_diff = flt(row.difference_in_qty, float_precision) + value_diff = flt(row.diff_value_diff, currency_precision) if row.stock_queue and json.loads(row.stock_queue): value_diff = value_diff or ( - flt(row.fifo_value_diff, precision) or flt(row.fifo_difference_diff, precision) + flt(row.fifo_value_diff, currency_precision) + or flt(row.fifo_difference_diff, currency_precision) ) - qty_diff = qty_diff or flt(row.fifo_qty_diff, precision) + qty_diff = qty_diff or flt(row.fifo_qty_diff, float_precision) - valuation_diff = flt(row.valuation_diff, precision) or flt(row.fifo_valuation_diff, precision) + valuation_diff = flt(row.valuation_diff, currency_precision) or flt( + row.fifo_valuation_diff, currency_precision + ) if difference_in == "Qty" and qty_diff: return True @@ -287,3 +297,5 @@ def has_difference(row, precision, difference_in, valuation_method): return True elif difference_in not in ["Qty", "Value", "Valuation"] and (qty_diff or value_diff or valuation_diff): return True + + return False diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js index f80126bcb0a..40ad8843871 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js @@ -28,28 +28,30 @@ frappe.query_reports["Stock Qty vs Batch Qty"] = { }, ], onload: function (report) { - report.page.add_inner_button(__("Update Batch Qty"), function () { - let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows(); - let selected_rows = indexes - .map((i) => frappe.query_report.data[i]) - .filter((row) => row.difference != 0); + if (frappe.model.can_write("Batch")) { + report.page.add_inner_button(__("Update Batch Qty"), function () { + let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows(); + let selected_rows = indexes + .map((i) => frappe.query_report.data[i]) + .filter((row) => row.difference != 0); - if (selected_rows.length) { - frappe.call({ - method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty", - args: { - selected_batches: selected_rows, - }, - callback: function (r) { - if (!r.exc) { - report.refresh(); - } - }, - }); - } else { - frappe.msgprint(__("Please select at least one row with difference value")); - } - }); + if (selected_rows.length) { + frappe.call({ + method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty", + args: { + selected_batches: selected_rows, + }, + callback: function (r) { + if (!r.exc) { + report.refresh(); + } + }, + }); + } else { + frappe.msgprint(__("Please select at least one row with difference value")); + } + }); + } }, formatter: function (value, row, column, data, default_formatter) { diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py index 87c5e1419cc..e9ccde483ae 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py @@ -101,6 +101,7 @@ def get_data(filters=None): @frappe.whitelist() def update_batch_qty(selected_batches=None): + frappe.has_permission("Batch", "write", throw=True, ignore_share_permissions=True) if not selected_batches: return diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 9ee458ce9c7..ba578f69814 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -134,6 +134,7 @@ def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_vou "sle_id": args.get("name"), "creation": args.get("creation"), "reserved_stock": args.get("reserved_stock"), + "cancelled": args.get("is_cancelled"), }, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher, @@ -869,10 +870,16 @@ class update_entries_after: if ( sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"] and sle.voucher_detail_no - and sle.actual_qty < 0 and is_internal_transfer(sle) ): - sle.outgoing_rate = get_incoming_rate_for_inter_company_transfer(sle) + # Anchor both legs of an internal-transfer PR/PI to the DN/SI incoming_rate; + # otherwise an inward SLE that inherits a stale PR.valuation_rate leaks the + # gap to COGS via divisional_loss. + rate = get_incoming_rate_for_inter_company_transfer(sle) + if sle.actual_qty < 0: + sle.outgoing_rate = rate + elif rate: + sle.incoming_rate = rate dimensions = get_inventory_dimensions() has_dimensions = False @@ -1079,7 +1086,11 @@ class update_entries_after: self.wh_data.stock_queue = json.loads(stock_queue[0]) if stock_queue else [] self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + doc.total_amount) - self.wh_data.qty_after_transaction += flt(doc.total_qty, self.flt_precision) + # Replay the immutable qty recorded on the SLE at submission, not the bundle's recomputed + # total_qty. A valuation repost must never rewrite physical quantities; if the bundle's child + # rows were edited after submission, doc.total_qty would silently corrupt qty_after_transaction + # (and every downstream balance). sle.actual_qty is the frozen movement for this entry. + self.wh_data.qty_after_transaction += flt(sle.actual_qty, self.flt_precision) if flt(self.wh_data.qty_after_transaction, self.flt_precision): self.wh_data.valuation_rate = flt(self.wh_data.stock_value, self.flt_precision) / flt( self.wh_data.qty_after_transaction, self.flt_precision @@ -1329,6 +1340,11 @@ class update_entries_after: Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return In case of Stock Entry, also calculate FG Item rate and total incoming/outgoing amount """ + if sle.voucher_type == "Stock Reconciliation": + if flt(sle.actual_qty) <= 0 and not self.args.get("sle_id"): + self.update_rate_on_stock_reconciliation(sle) + return + if sle.actual_qty and sle.voucher_detail_no: outgoing_rate = abs(flt(sle.stock_value_difference)) / abs(sle.actual_qty) @@ -1340,8 +1356,6 @@ class update_entries_after: self.update_rate_on_purchase_receipt(sle, outgoing_rate) elif flt(sle.actual_qty) < 0 and sle.voucher_type == "Subcontracting Receipt": self.update_rate_on_subcontracting_receipt(sle, outgoing_rate) - elif sle.voucher_type == "Stock Reconciliation": - self.update_rate_on_stock_reconciliation(sle) def update_rate_on_stock_entry(self, sle, outgoing_rate): frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate) @@ -1435,37 +1449,13 @@ class update_entries_after: d.db_update() def update_rate_on_stock_reconciliation(self, sle): - if not sle.serial_no and not sle.batch_no: - sr = frappe.get_doc("Stock Reconciliation", sle.voucher_no, for_update=True) - - for item in sr.items: - # Skip for Serial and Batch Items - if item.name != sle.voucher_detail_no or item.serial_no or item.batch_no: - continue - - previous_sle = get_previous_sle( - { - "item_code": item.item_code, - "warehouse": item.warehouse, - "posting_date": sr.posting_date, - "posting_time": sr.posting_time, - "sle": sle.name, - } - ) - - item.current_qty = previous_sle.get("qty_after_transaction") or 0.0 - item.current_valuation_rate = previous_sle.get("valuation_rate") or 0.0 - item.current_amount = flt(item.current_qty) * flt(item.current_valuation_rate) - - item.amount = flt(item.qty) * flt(item.valuation_rate) - item.quantity_difference = item.qty - item.current_qty - item.amount_difference = item.amount - item.current_amount - else: - sr.difference_amount = sum([item.amount_difference for item in sr.items]) - sr.db_update() - - for item in sr.items: - item.db_update() + # Refresh the reconciliation's difference amount and per-row current qty/rate from the reposted + # ledger so the document keeps matching the GL entries. Handles serialized, batched and + # non-serialized items uniformly (the document method reads the current bundle for serial/batch + # rows and the pre-reconciliation ledger balance for non-serial rows). + frappe.get_lazy_doc( + "Stock Reconciliation", sle.voucher_no, for_update=True + ).recalculate_difference_amount_from_ledger() def get_incoming_value_for_serial_nos(self, sle, serial_nos): # get rate from serial nos within same company @@ -2062,36 +2052,47 @@ def get_valuation_rate( def update_qty_in_future_sle(args, allow_negative_stock=False): """Recalculate Qty after Transaction in future SLEs based on current SLE.""" - datetime_limit_condition = "" qty_shift = args.actual_qty - args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"]) + posting_datetime = get_combine_datetime(args["posting_date"], args["posting_time"]) + args["posting_datetime"] = posting_datetime # find difference/shift in qty caused by stock reconciliation if args.voucher_type == "Stock Reconciliation": qty_shift = get_stock_reco_qty_shift(args) + sle = frappe.qb.DocType("Stock Ledger Entry") + + # SLEs are ordered by (posting_datetime, creation). "Future" therefore means strictly after the + # current entry in that tuple order: a later posting_datetime, or the same posting_datetime with a + # later creation. Comparing posting_datetime alone would skip same-timestamp entries created after + # this one (e.g. the same item repeated in a voucher, or another voucher posted in the same second). + # On cancellation `args` is a freshly inserted reversal entry, so its `creation` is the cancel time + # (not the original entry's position) and same-timestamp siblings are already recomputed by the + # cancelled path in update_entries_after; applying the tiebreaker here would double-shift them. + future_condition = sle.posting_datetime > posting_datetime + if args.get("creation") and not args.get("is_cancelled"): + future_condition = future_condition | ( + (sle.posting_datetime == posting_datetime) & (sle.creation > args.get("creation")) + ) + + query = ( + frappe.qb.update(sle) + .set(sle.qty_after_transaction, sle.qty_after_transaction + qty_shift) + .where( + (sle.item_code == args.get("item_code")) + & (sle.warehouse == args.get("warehouse")) + & (sle.is_cancelled == 0) + & future_condition + ) + ) + # find the next nearest stock reco so that we only recalculate SLEs till that point next_stock_reco_detail = get_next_stock_reco(args) if next_stock_reco_detail: - detail = next_stock_reco_detail[0] - datetime_limit_condition = get_datetime_limit_condition(detail) + query = query.where(get_datetime_limit_condition(sle, next_stock_reco_detail[0])) - frappe.db.sql( # nosemgrep - f""" - update `tabStock Ledger Entry` - set qty_after_transaction = qty_after_transaction + {qty_shift} - where - item_code = %(item_code)s - and warehouse = %(warehouse)s - and is_cancelled = 0 - and ( - posting_datetime > %(posting_datetime)s - ) - {datetime_limit_condition} - """, - args, - ) + query.run() validate_negative_qty_in_future_sle(args, allow_negative_stock) @@ -2126,6 +2127,22 @@ def get_stock_reco_qty_shift(args): return stock_reco_qty_shift +def get_next_reco_datetime_condition(sle, kwargs): + """Match stock recos that come strictly after the current entry in (posting_datetime, creation) + order. Using posting_datetime alone (>=) could pick a reco sharing this exact timestamp but created + earlier — i.e. one that actually precedes this entry — and wrongly truncate the qty-shift range.""" + current_datetime = get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) + + creation = kwargs.get("creation") + if not creation: + # No creation tiebreaker available; fall back to the posting_datetime-only bound. + return sle.posting_datetime >= current_datetime + + return (sle.posting_datetime > current_datetime) | ( + (sle.posting_datetime == current_datetime) & (sle.creation > creation) + ) + + def get_next_stock_reco(kwargs): """Returns next nearest stock reconciliaton's details.""" @@ -2151,10 +2168,7 @@ def get_next_stock_reco(kwargs): & (sle.voucher_type == "Stock Reconciliation") & (sle.voucher_no != kwargs.get("voucher_no")) & (sle.is_cancelled == 0) - & ( - sle.posting_datetime - >= get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) - ) + & get_next_reco_datetime_condition(sle, kwargs) ) .orderby(sle.posting_datetime) .orderby(sle.creation) @@ -2167,17 +2181,12 @@ def get_next_stock_reco(kwargs): return query.run(as_dict=True) -def get_datetime_limit_condition(detail): +def get_datetime_limit_condition(sle, detail): posting_datetime = get_combine_datetime(detail.posting_date, detail.posting_time) - return f""" - and - (posting_datetime < '{posting_datetime}' - or ( - posting_datetime = '{posting_datetime}' - and creation < '{detail.creation}' - ) - )""" + return (sle.posting_datetime < posting_datetime) | ( + (sle.posting_datetime == posting_datetime) & (sle.creation < detail.creation) + ) def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): @@ -2427,7 +2436,16 @@ def get_incoming_rate_for_inter_company_transfer(sle) -> float: if lcv_amount: lcv_rate = flt(lcv_amount / abs(sle.actual_qty)) - return rate + lcv_rate + charges_rate = 0.0 + if flt(sle.actual_qty) > 0: + charge_fields = ["item_tax_amount", "rm_supp_cost"] + charges = frappe.db.get_value( + f"{sle.voucher_type} Item", sle.voucher_detail_no, charge_fields, as_dict=True + ) + if charges: + charges_rate = flt(sum(flt(charges.get(f)) for f in charge_fields)) / abs(sle.actual_qty) + + return rate + lcv_rate + charges_rate def is_internal_transfer(sle): diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index fc19bac0a44..af98aa43980 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -35,6 +35,52 @@ class TestGetItemDetail(FrappeTestCase): details = get_item_details(args) self.assertEqual(details.get("price_list_rate"), 100) + def test_fetch_asset_category_expense_account_on_purchase_receipt(self): + from erpnext.stock.doctype.item.test_item import make_item + + asset_category = "Test Expense Account Asset Category" + if not frappe.db.exists("Asset Category", asset_category): + frappe.get_doc( + { + "doctype": "Asset Category", + "asset_category_name": asset_category, + "enable_cwip_accounting": 0, + "depreciation_method": "Straight Line", + "total_number_of_depreciations": 12, + "frequency_of_depreciation": 1, + "accounts": [ + { + "company_name": "_Test Company", + "fixed_asset_account": "_Test Fixed Asset - _TC", + "accumulated_depreciation_account": "_Test Accumulated Depreciations - _TC", + "depreciation_expense_account": "_Test Depreciations - _TC", + } + ], + } + ).insert() + + asset_item = make_item( + "Test Expense Account Asset Item", + {"is_stock_item": 0, "is_fixed_asset": 1, "asset_category": asset_category}, + ).item_code + + args = frappe._dict( + { + "item_code": asset_item, + "company": "_Test Company", + "conversion_rate": 1.0, + "price_list_currency": "USD", + "plc_conversion_rate": 1.0, + "doctype": "Purchase Receipt", + "supplier": "_Test Supplier", + "price_list": "_Test Buying Price List", + "ignore_pricing_rule": 1, + "qty": 1, + } + ) + details = get_item_details(args) + self.assertEqual(details.get("expense_account"), "_Test Fixed Asset - _TC") + # making this test in get_item_details test file as feat/fix is present in that method def test_fetch_price_from_list_rate_on_doc_save(self): # create item diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 6f7c943ddad..531c6371591 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -232,7 +232,7 @@ class ServiceLevelAgreement(Document): if self.document_type == "Issue": return - service_level_agreement_fields = get_service_level_agreement_fields() + service_level_agreement_fields = get_service_level_agreement_fields(self.document_type) meta = frappe.get_meta(self.document_type, cached=False) if meta.custom: @@ -276,6 +276,7 @@ class ServiceLevelAgreement(Document): "hidden": field.get("hidden"), "description": field.get("description"), "default": field.get("default"), + "link_filters": field.get("link_filters"), } ).insert(ignore_permissions=True) else: @@ -302,6 +303,7 @@ class ServiceLevelAgreement(Document): "hidden": field.get("hidden"), "description": field.get("description"), "default": field.get("default"), + "link_filters": field.get("link_filters"), } ).insert(ignore_permissions=True) else: @@ -309,7 +311,7 @@ class ServiceLevelAgreement(Document): self.reset_field_properties(existing_field, "Custom Field", field) def reset_field_properties(self, field, field_dt, sla_field): - field = frappe.get_doc(field_dt, {"fieldname": field.fieldname}) + field = frappe.get_doc(field_dt, field.name) field.label = sla_field.get("label") field.fieldname = sla_field.get("fieldname") field.fieldtype = sla_field.get("fieldtype") @@ -320,6 +322,7 @@ class ServiceLevelAgreement(Document): field.hidden = sla_field.get("hidden") field.description = sla_field.get("description") field.default = sla_field.get("default") + field.link_filters = sla_field.get("link_filters") field.save(ignore_permissions=True) @@ -909,7 +912,7 @@ def record_assigned_users_on_failure(doc): doc.add_comment(comment_type="Assigned", text=message) -def get_service_level_agreement_fields(): +def get_service_level_agreement_fields(doctype: str): return [ { "collapsible": 1, @@ -922,6 +925,9 @@ def get_service_level_agreement_fields(): "fieldtype": "Link", "label": "Service Level Agreement", "options": "Service Level Agreement", + "link_filters": frappe.as_json( + [["Service Level Agreement", "document_type", "=", doctype]], indent=None + ), }, {"fieldname": "priority", "fieldtype": "Link", "label": "Priority", "options": "Issue Priority"}, {"fieldname": "response_by", "fieldtype": "Datetime", "label": "Response By", "read_only": 1}, diff --git a/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py index cabd38f6427..7d579786f51 100644 --- a/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py @@ -2,6 +2,7 @@ # See license.txt import datetime +import json import unittest import frappe @@ -176,11 +177,14 @@ class TestServiceLevelAgreement(unittest.TestCase): self.assertEqual(lead_sla.name, default_sla.name) # check SLA custom fields created for leads - sla_fields = get_service_level_agreement_fields() + sla_fields = get_service_level_agreement_fields(doctype) for field in sla_fields: - self.assertTrue( - frappe.db.exists("Custom Field", {"dt": doctype, "fieldname": field.get("fieldname")}) + filters = {"dt": doctype, "fieldname": field.get("fieldname")} + self.assertTrue(frappe.db.exists("Custom Field", filters)) + self.assertEqual( + get_link_filters("Custom Field", filters), + json.loads(field["link_filters"]) if field.get("link_filters") else None, ) def test_docfield_creation_for_sla_on_custom_dt(self): @@ -200,13 +204,66 @@ class TestServiceLevelAgreement(unittest.TestCase): self.assertEqual(sla.name, default_sla.name) # check SLA docfields created - sla_fields = get_service_level_agreement_fields() + sla_fields = get_service_level_agreement_fields(doctype.name) for field in sla_fields: - self.assertTrue( - frappe.db.exists("DocField", {"fieldname": field.get("fieldname"), "parent": doctype.name}) + filters = {"fieldname": field.get("fieldname"), "parent": doctype.name} + self.assertTrue(frappe.db.exists("DocField", filters)) + self.assertEqual( + get_link_filters("DocField", filters), + json.loads(field["link_filters"]) if field.get("link_filters") else None, ) + def test_reset_field_properties_does_not_clobber_other_doctypes_field(self): + """Two doctypes each get their own "service_level_agreement" custom field + (same fieldname, different owning doctype). Updating the field on one of + them must not clobber the other's, even though both share the fieldname + (regression test for the fix in reset_field_properties, see PR #56954).""" + doctype_a = create_custom_doctype("Test SLA Dt A") + doctype_b = create_custom_doctype("Test SLA Dt B") + + for doctype in (doctype_a.name, doctype_b.name): + create_service_level_agreement( + default_service_level_agreement=1, + holiday_list="__Test Holiday List", + entity_type=None, + entity=None, + response_time=14400, + resolution_time=21600, + doctype=doctype, + ) + + def get_sla_field_link_filters(doctype): + return get_link_filters("DocField", {"parent": doctype, "fieldname": "service_level_agreement"}) + + self.assertEqual( + get_sla_field_link_filters(doctype_a.name), + [["Service Level Agreement", "document_type", "=", doctype_a.name]], + ) + + # The field on doctype_b already exists, so creating another, entity-specific + # SLA for doctype_b takes the "update existing field" branch (reset_field_properties) + # instead of creating a new field. + customer = create_customer() + create_service_level_agreement( + default_service_level_agreement=0, + holiday_list="__Test Holiday List", + entity_type="Customer", + entity=customer, + response_time=7200, + resolution_time=10800, + doctype=doctype_b.name, + ) + + self.assertEqual( + get_sla_field_link_filters(doctype_a.name), + [["Service Level Agreement", "document_type", "=", doctype_a.name]], + ) + self.assertEqual( + get_sla_field_link_filters(doctype_b.name), + [["Service Level Agreement", "document_type", "=", doctype_b.name]], + ) + def test_sla_application(self): # Default Service Level Agreement doctype = "Lead" @@ -362,6 +419,11 @@ class TestServiceLevelAgreement(unittest.TestCase): frappe.delete_doc("Service Level Agreement", d.name, force=1) +def get_link_filters(field_doctype, filters): + value = frappe.db.get_value(field_doctype, filters, "link_filters") + return json.loads(value) if value else None + + def get_service_level_agreement( default_service_level_agreement=None, entity_type=None, entity=None, doctype="Issue" ): @@ -602,8 +664,8 @@ def make_holiday_list(): ).insert() -def create_custom_doctype(): - if not frappe.db.exists("DocType", "Test SLA on Custom Dt"): +def create_custom_doctype(name="Test SLA on Custom Dt"): + if not frappe.db.exists("DocType", name): doc = frappe.get_doc( { "doctype": "DocType", @@ -626,13 +688,13 @@ def create_custom_doctype(): }, ], "permissions": [{"role": "System Manager", "read": 1, "write": 1}], - "name": "Test SLA on Custom Dt", + "name": name, } ) doc.insert() return doc else: - return frappe.get_doc("DocType", "Test SLA on Custom Dt") + return frappe.get_doc("DocType", name) def make_lead(creation=None, index=0, company=None):